Adds `customers.account_belongs_to UUID NULL` and refactors customer sign-in (phone/Google/Apple) so an anon row that re-verifies into an existing customer no longer 409s. Instead the anon row stays intact with a breadcrumb pointing at the real customer; tokens are issued for the existing user. Actual data reconciliation onto the existing row (chat_sessions, customer_transactions, payment_sessions, pairing_failures) is deferred. Backend - migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL. - customer.service.js: stampAccountBelongsTo helper; account_belongs_to exposed in CUSTOMER_SELECT. - auth.service.js: new shared resolveCustomerForIdentity (4-case logic); normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted; completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all route through the shared helper. - client.auth.routes.js: new resolveAnonymousCustomerId picks the anon prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack where a tamper-able body field could mis-route someone else's transactions. /otp/verify, /google, /apple all use it; the body field `anonymous_customer_id` is no longer accepted on any of them. - test/services/auth.service.test.js: 9 Vitest cases covering phone + Google + Apple, all 4 logic cases + multi-merge accumulation. Customer app - auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead body field so ApiClient auto-attaches the anon's Bearer from AuthBridge. Survives the AuthOtpSentData state transition (the earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by sourcing the id from the bridge instead of state). - Google + Apple client paths remain unchanged (gated on provider creds; mirror this fix when wiring lands). Docs - flow_customer.mermaid.md: new §2.1 sub-section with the merge diagram, schema note, replaces-current-behaviour paragraph, and Bearer-only security callout. - phase3.4-testing.md: §1.5 line 76 simplified (no more per-path split); new §1.5.1 with the 5-step operator scenario + DB invariants + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple parity (deferred client work flagged). Verification (against live dev backend, before this commit): - Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated pre-existing failures in session-timer.service.test.js). - Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the Bearer-precedence cases. - Real-device UI walkthrough on SM-A530F still pending — see resume memory `project_phase4_2_1_resume_test`. Sister WIP bundled in migrate.js + customer.service.js: `usp_seen` column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was already uncommitted in the working tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
38 KiB
Phase 3.4 Testing & Outstanding Regression Checklist
Consolidated testing document covering Phase 3.4 (self-managed auth) plus every outstanding test item carried over from Phases 3.0 – 3.3. Replaces phase3.3-testing.md.
Tick boxes as you verify. Cluster labels in brackets: [BE] backend / curl, [CC] control_center, [M] mitra_app, [C] client_app.
Related docs: phase3.4.md, phase3.4-plan.md, phase3.3.md, phase3.3-plan.md.
Part 1 — Phase 3.4: Self-Managed Auth
1.1 Backend: Schema, Services, Env
- [BE]
npm run db:migrateis idempotent — re-running reports "skipping" on existing objects, adds new 3.4 tables/columns, exits 0 - [BE]
auth_sessions,otp_requeststables exist with correct columns + indexes - [BE]
customershas new nullable columns:email,google_sub (UNIQUE),apple_sub (UNIQUE) - [BE]
control_center_usershaspassword_hash,failed_login_count,lockout_until - [BE]
firebase_uidcolumns still exist but are nullable + unused by code (cleanup migration deferred) - [BE]
app_configseeded with 6 new OTP / CC-lockout keys at default values - [BE]
npm run db:seedcreates the super admin with bcrypt-hashed password (runs twice → second is a no-op) - [BE] Server boots cleanly on
INTERNAL_PORT=3001+PUBLIC_PORT=3000 - [BE] Missing
AUTH_JWT_SECRET(or <32 chars) → server refuses to start / logs a clear error - [BE] CORS config on internal listener allows
CC_ORIGINwithcredentials: true
1.2 Backend: Token & Session Service (curl)
- [BE] Access token claims:
{ sub, user_type, session_id, iat, exp }— HS256, 1h TTL - [BE] Refresh token is opaque (uuid.random), 30d TTL, bcrypt-hashed in
auth_sessions.refresh_token_hash - [BE] Refresh rotates: old refresh after one use →
REFRESH_INVALID, samesession_idpersists, new access token issued - [BE] Logout deletes the
auth_sessionsrow → subsequent refresh →REFRESH_INVALID - [BE] Multi-device: same user signs in twice → two
auth_sessionsrows; logout on one doesn't kill the other - [BE] Tampered JWT (signature broken) → 401
TOKEN_INVALID - [BE] Expired JWT → 401; api_client auto-refreshes and retries once
- [BE]
device_infoJSONB populated withuser_agent+ipon each session - [BE] Session fingerprint survives refresh (no churn)
- [BE] Documented 1h access-token revocation window: post-logout access token still verifies until natural expiry (expected — Valkey revoked_sessions pre-wired but not active)
1.3 Backend: OTP Service (stub mode)
Fazpass is stubbed. Dev code is logged to backend console as
[OTP STUB] phone=… code=… ref=….
- [BE]
POST /api/client/auth/otp/requestwith valid phone → 200 +otp_request_id, stub logs code - [BE]
POST /api/mitra/auth/otp/requestsame, separate user_type - [BE] Invalid phone format (not E.164) → 422
PHONE_INVALID - [BE] Resend within
otp_resend_cooldown_seconds(default 60) → 429OTP_COOLDOWN - [BE] 4th request in an hour from same phone → 429
OTP_RATE_LIMIT_PHONE - [BE] 11th request in an hour from same IP → 429
OTP_RATE_LIMIT_IP - [BE] OTP verify with wrong code → 401
CODE_MISMATCH,attemptsincremented - [BE] 6th verify attempt (after 5 wrongs) → 429
OTP_ATTEMPTS_EXCEEDED - [BE] OTP verify after
expires_at(default 5min) → 410OTP_EXPIRED - [BE] OTP verify twice with correct code → second call → 409
OTP_USED - [BE] Mitra OTP verified via
/api/client/auth/otp/verify→ 400WRONG_FLOW - [BE] Customer OTP verified via
/api/mitra/auth/otp/verify→ 400WRONG_FLOW
1.4 Backend: Social Identity (post-creds)
Deferred until Google/Apple OAuth credentials land. Run this block after setting
GOOGLE_OAUTH_CLIENT_IDS+APPLE_*in.envand flippingENABLE_SOCIAL_AUTH=trueon client_app.
- [BE] Google: valid id_token → customer created / upgraded, tokens issued
- [BE] Google: wrong audience (id_token from different client) → 401
INVALID_ID_TOKEN - [BE] Google: tampered signature → 401
INVALID_ID_TOKEN - [BE] Google:
google_subalready linked to another customer → 409IDENTITY_CONFLICT(merge deferred) - [BE] Apple: valid id_token → customer created / upgraded
- [BE] Apple: invalid signature (JWKS mismatch) → 401
INVALID_ID_TOKEN - [BE] Apple: missing email (user opted out) → account still created,
emailNULL - [BE] Apple:
apple_subalready linked elsewhere → 409IDENTITY_CONFLICT
1.5 Backend: Anonymous + Upgrade
- [BE]
POST /api/shared/auth/anonymous→ creates customer with auto-generateddisplay_name, returns tokens + profile - [BE] Anonymous customer row:
phone=NULL,google_sub=NULL,apple_sub=NULL - [BE] OTP verify with
anonymous_customer_id→ upgrades SAME customer row; customer UUID unchanged; display_name preserved - [BE] Google verify with
anonymous_customer_id→ upgrades SAME customer row; google_sub added; display_name preserved if present, else backfilled from Google profile - [BE] Apple verify with
anonymous_customer_id→ upgrades SAME row - [BE] Upgrade with
anonymous_customer_idwhen identity is ALREADY taken by a different customer → 200 with merge breadcrumb (see §1.5.1 below). Applies uniformly to phone, Google, and Apple paths (Phase 4 §2.1). - [BE] Upgrade issues a NEW auth_sessions row. Old anonymous refresh still works (separate session) and reflects the upgraded profile on subsequent calls — intentional for multi-device UX; do not "fix" without discussion
1.5.1 Backend: Anonymous → existing-user merge breadcrumb (Phase 4 §2.1)
End-to-end scenario where a verified user accidentally transacts as anonymous on a fresh install, then re-verifies the same phone. The flow must NOT 409 and must stamp the breadcrumb on the anonymous row so reconciliation can move its transactions onto the real account later. Spec: flow_customer.mermaid.md §2.1.
Setup
- Verified customer row
User-Aexists withphone=+62…X,display_name="Wati",is_anonymous=false. (Created earlier via OTP sign-up.)
Scenario steps
- [C] Customer logs in successfully using phone
+62…X→ app holdsUser-Atokens (smoke-verifiable:/api/client/auth/mereturnsUser-A'sid+display_name="Wati"). - [C] Customer taps logout → refresh token revoked; app state returns to
AuthInitialData. - [C] Customer reopens the app and proceeds through onboarding → S2 Nama "Bujak" → VerifChoice → "tanpa verifikasi" (anonymous) → backend creates
Anon-Bwithis_anonymous=true,display_name="Bujak",phone=NULL. - [C] Customer completes ≥1 transaction as
Anon-B(chat session, payment session, etc.) → rows inchat_sessions/customer_transactionscarrycustomer_id = Anon-B.id. - [C] Customer goes back through VerifChoice → "verifikasi nomor HP" → enters the same
+62…X→ OTP verify request carriesAuthorization: Bearer <Anon-B's access_token>(issued by/api/shared/auth/anonymousin step 3). The backend derives the anon id from the Bearer JWT only — there is no body field foranonymous_customer_id. The same Bearer-only contract applies to/api/client/auth/googleand/api/client/auth/apple(client_app does not exercise those paths yet — creds pending — but the backend behaves identically when they land).
Expected
- [BE]
POST /api/client/auth/otp/verifyreturns 200 (NOT 409). - [BE] Response
data.profile.id === User-A.id(the existing account, notAnon-B). - [BE] Response
data.profile.display_name === "Wati"(overwrites the locally-typed "Bujak" per mermaid §2 line 62). - [BE] Response
data.access_tokendecodes to{ sub: User-A.id, user_type: 'customer', … }— subsequent authenticated calls act asUser-A. - [BE] DB invariant:
SELECT account_belongs_to FROM customers WHERE id = Anon-B.id→User-A.id. Breadcrumb stamped. - [BE] DB invariant:
Anon-Brow still exists —is_anonymous=true,display_name="Bujak"preserved,phone=NULL. NOT deleted. - [BE] DB invariant:
Anon-B'schat_sessions/customer_transactionsFKs are unchanged (still point atAnon-B.id). Actual data reconciliation ontoUser-Ais the deferred phase; the breadcrumb is what enables it later. - [BE] Re-running the same flow on a NEW fresh install (different
Anon-C) with the same phone → second breadcrumb accumulates (Anon-C.account_belongs_to = User-A.id). Many anon rows may point at the same real user — fine.
Negative checks
- [BE] If the customer logs in directly with their phone (no anonymous prefix, fresh install) → 200, no merge breadcrumb on any row (Case 2a of the spec).
- [BE] If the customer re-verifies their own phone while logged in as
User-A(anonymous_customer_id == existing.id) → 200,account_belongs_tostays NULL (no self-stamping, Case 2b). - [BE] New phone (not in DB) +
anonymous_customer_idset → anon row upgraded in place (is_anonymous=false, phone set,display_namepreserved); no merge breadcrumb (Case 3 of spec).
Auth source for anonymous_customer_id (security note)
The backend derives the anon id only from a verified Bearer JWT presented as Authorization: Bearer <token>. The body field anonymous_customer_id is no longer read — accepting it would let anyone who learns a victim's anon UUID stamp the merge breadcrumb on it. Bearer tokens are HS256-signed with AUTH_JWT_SECRET and cannot be forged.
The resolver (resolveAnonymousCustomerId in client.auth.routes.js) returns:
- The anon's
customer.id, if the Bearer is valid ANDis_anonymous = true. nullif the Bearer is missing, invalid, expired, or belongs to a verified customer.
Curl-runnable smoke (against running dev backend; uses fazpass_reference to read the stub OTP code from DB):
# Pre: insert User-A and Anon-B via SQL; Anon-B's access_token is what the
# /api/shared/auth/anonymous endpoint returned.
curl -X POST $BASE/api/client/auth/otp/request \
-H 'content-type: application/json' \
-d '{"phone":"+62…X"}' # → otp_request_id
# Look up stub code:
psql -c "SELECT fazpass_reference FROM otp_requests WHERE id='<otp_request_id>'"
# fazpass_reference is "stub_<uuid>:<code>"
curl -X POST $BASE/api/client/auth/otp/verify \
-H 'content-type: application/json' \
-H "Authorization: Bearer <Anon-B's access_token>" \
-d '{"otp_request_id":"…","code":"<code>"}'
# Expect 200; profile.id === User-A.id; profile.display_name === "Wati"
psql -c "SELECT id, account_belongs_to FROM customers WHERE id='<Anon-B.id>'"
# Expect account_belongs_to === User-A.id
Vitest coverage for the boundary logic: backend/test/services/auth.service.test.js covers all 4 service-level cases + multi-merge accumulation (6 tests). The route-layer Bearer enforcement was operator-verified with 11 additional checks against the live backend (5 cases: anon-Bearer, attacker-tries-body, no-Bearer-body-ignored, verified-Bearer, garbage-Bearer) — all green; the test script is intentionally not retained in-tree (one-off operator smoke).
1.5.2 Backend: Google / Apple parity for the merge breadcrumb (Phase 4 §2.1)
The same 4-case merge logic (resolveCustomerForIdentity in auth.service.js) is applied to signInWithGoogle and signInWithApple. Both /api/client/auth/google and /api/client/auth/apple derive the anon prefix only from the Bearer JWT; the body field anonymous_customer_id is not read.
client_app does not exercise these routes today. Google / Apple SDK integration on the customer app is gated on
authProvidersProviderand won't trigger until provider credentials are provisioned. The backend tests below cover the behaviour so the merge is correct the moment the client wiring lands. When that happens, the client work mirrors the phone-OTP fix: dropskipAuth: trueand anyanonymous_customer_idbody field onloginGoogle/loginAppleso ApiClient attaches the anon's Bearer automatically.
Expected behaviour — uniform with the phone path
| Path | Existing identity? | Anon prefix (Bearer)? | Outcome |
|---|---|---|---|
/google |
yes, different id | yes | Anon row gets account_belongs_to = Existing.id; tokens for Existing. Anon row preserved. |
/google |
no | yes | Anon row upgraded in place (google_sub + email set; display_name preserved via COALESCE; is_anonymous=false). |
/google |
yes | no | Returns existing; no merge. |
/google |
no | no | Creates a fresh row with display_name=null; client routes to AuthNeedsDisplayNameData. |
/apple |
(same matrix) | (same outcomes; apple_sub instead of google_sub) |
Provider-specific notes
- Display name is not taken from the provider. Google's
nameclaim is intentionally ignored (anonymous-chose name wins); Apple's first-launch display_name behaviour (Apple withholds it on subsequent sign-ins) is irrelevant for the same reason. emailis recorded on the customer row when present in the verified id_token; subsequent OTP/Apple sign-ins don't overwrite a non-null email viaCOALESCE.
Checklist
- [BE] Vitest
signInWithGoogle— Case 1 (existing google_sub on a DIFFERENT customer + anon prefix) → 200; stampsaccount_belongs_to; returns existing. - [BE] Vitest
signInWithGoogle— Case 3 (new google_sub + anon prefix) → 200; anon row upgraded in place;display_namepreserved;google_sub/emailset. - [BE] Vitest
signInWithApple— Case 1 (existing apple_sub on a DIFFERENT customer + anon prefix) → 200; stampsaccount_belongs_to; returns existing. - [BE] Route-level (curl):
POST /api/client/auth/googlewithAuthorization: Bearer <Anon's access_token>and a validid_tokenfor an existing Google account → 200 + merge breadcrumb on the anon row. - [BE] Route-level (curl): same call WITHOUT Bearer (body has
anonymous_customer_idinstead) → 200 sign-in completes, but the body field is ignored; anon row'saccount_belongs_tostays NULL. Confirms the security hardening from §1.5.1 covers Google as well. - [BE] Identical curl smokes for
/api/client/auth/apple.
The "valid id_token" requirement makes the curl checks non-trivial without provider credentials. Until creds land, the Vitest layer is the authoritative coverage; the curl checklist serves as an operator runbook for the day client wiring goes live.
Vitest coverage lives in backend/test/services/auth.service.test.js alongside the phone tests (9 tests total: 6 phone + 1 Google upgrade + 1 Google stamp + 1 Apple stamp). The social-identity verifiers are mocked at module scope so tests don't require real id_tokens.
1.6 Backend: Auth Middleware + Cross-User-Type Guards
- [BE] Protected route with no
Authorizationheader → 401AUTH_MISSING - [BE] Expired access token → 401 → api_client refreshes → retry succeeds
- [BE] Customer JWT calling
/api/mitra/auth/me→ 403FORBIDDEN - [BE] Mitra JWT calling
/api/client/auth/me→ 403FORBIDDEN - [BE] Customer JWT calling
/internal/*→ 403FORBIDDEN - [BE]
request.auth = { userType, userId, sessionId }populated correctly on every protected route (spot-check in logs)
1.7 Backend: Mitra Activation + Inactive Flow
- [BE] New phone number → auto-creates
mitrasrow withis_active=false; OTP verify returns 403ACCOUNT_INACTIVE - [BE] Admin activates mitra via CC (or direct DB
UPDATE mitras SET is_active=true) → re-request OTP → verify succeeds, tokens returned - [BE] Mitra re-OTP after deactivation → 403
ACCOUNT_INACTIVEon next verify; existing sessions keep working until natural JWT expiry (documented 1h window)
1.8 Backend: Password / CC Login
- [BE]
POST /internal/auth/loginwith correct creds → setscc_refresh_tokenhttpOnly cookie, returns access token + profile + role/permissions - [BE] Wrong password → 401
INVALID_CREDENTIALS,failed_login_countincremented - [BE] 5th wrong password →
lockout_until = NOW() + 15min; subsequent attempts (even with right password) → 423ACCOUNT_LOCKED - [BE] Successful login resets
failed_login_count+lockout_until - [BE]
PATCH /internal/control-center-users/me/passwordwith wrong current → 401INVALID_CREDENTIALS - [BE] Password complexity rejected: <8 chars →
PASSWORD_TOO_SHORT; no digit →PASSWORD_MISSING_DIGIT; no upper →PASSWORD_MISSING_UPPERCASE; no lower →PASSWORD_MISSING_LOWERCASE - [BE] Admin-forced reset via
PATCH /internal/control-center-users/:id/passwordrequirescontrol_center_users:updatepermission - [BE] Create CC user
POST /internal/control-center-usersrequirescontrol_center_users:create; stores bcrypt hash; rejects weak password with same complexity codes - [BE] Seed script does NOT enforce complexity (intentional bootstrap loophole — dev
admin123works)
1.9 Backend: WebSocket Auth
- [BE] WS handshake with valid JWT in first
{type:"auth", token}frame →{type:"auth_ok"} - [BE] WS handshake with missing token → connection closed with code 4401 (or equivalent)
- [BE] WS handshake with expired/tampered token → closed
- [BE]
request.authequivalent available on WS connection (userId + userType + sessionId) - [BE] Old Firebase-era token on WS → rejected (documented expected failure from pre-3.4 builds)
1.10 Control Center: UI + Auth
- [CC]
firebasedep is gone frompackage.json; bundle is ~280KB not ~800KB - [CC] Login page: right creds → redirects to
/dashboard - [CC] Login page: wrong creds → inline error "Email atau password salah."
- [CC] Login page: account-locked → shows backend message (or translated Indonesian)
- [CC] Refresh cookie persists across a hard browser reload (F5) → user stays logged in
- [CC] Close tab + reopen →
AuthContext.bootstrapcalls/internal/auth/refreshwith the cookie → user stays logged in - [CC] Logout clears access token in memory + calls
/logout+ clears cookie → next action redirects to login - [CC] Access token expires mid-session → next api call 401 → bridge auto-refreshes → request succeeds transparently (simulate by breaking the access token in devtools)
- [CC] Refresh fails (cookie expired / revoked) →
onUnauthenticated→ user bounced to login - [CC] Two concurrent 401s (fire two API calls in parallel with a broken token) → only one refresh fires; both retries succeed
- [CC] Sidebar "Ganti password" form: wrong current → "Password saat ini salah."
- [CC] Sidebar "Ganti password" form: weak new password → shows backend complexity message
- [CC] Sidebar "Ganti password" form: success → "Password berhasil diubah." (and next login uses the new password)
- [CC] UsersPage create: all fields required; Generate button produces a compliant password; submit creates user
- [CC] UsersPage create: duplicate email → inline error ("Email sudah digunakan.")
- [CC] UsersPage per-row "Reset password": success → inline "Tersimpan."; the target user can then log in with that password
- [CC] All API calls send
credentials: 'include'(verify via devtools network tab)
1.11 mitra_app: UI + Auth
- [M]
firebase_authdep is gone frompubspec.yaml; app builds debug APK clean - [M] First launch with no stored session → lands on
/login - [M] Phone OTP request → OTP screen; backend console shows
[OTP STUB] phone=… code=… - [M] Enter stub code →
/home - [M] Kill app, relaunch → bootstrap reads refresh from secure storage →
/home(no login screen flash) - [M] Inactive mitra → snackbar "Akun tidak aktif. Hubungi administrator."
- [M] Wrong OTP code → snackbar "Kode OTP salah." Fields cleared, focus returns to first digit
- [M] OTP expired (wait 5min) → "Kode OTP kedaluwarsa. Minta kode baru."
- [M] Resend within cooldown → "Tunggu sebentar..." message
- [M] Logout button → storage cleared; next launch hits
/login - [M] Long session: access token expires mid-chat → api_client auto-refreshes; user sees no disruption
- [M] Refresh token expires / revoked →
onUnauthenticated→ app returns to/loginwith current screen cleared - [M] WebSocket handshake uses JWT from
AuthBridge— verify by logging the token frame in dev - [M] iOS: keychain persists the refresh token across app updates (reinstall DOES wipe it, but update keeps it)
- [M] Android: encrypted SharedPrefs persists across app updates
1.12 client_app: UI + Auth
- [C]
firebase_authdep is gone; app builds debug APK clean - [C] First launch → onboarding → welcome screen
- [C] "Lanjut sebagai Tamu" → DisplayNameScreen → enter name →
/home(anonymous customer created, display_name PATCHed) - [C] Kill app, relaunch → bootstrap succeeds →
/home(anonymous session restored) - [C] "Daftar / Masuk" → phone field → OTP → home (customer created with phone)
- [C] From anonymous state, tap "Daftar / Masuk" → OTP upgrade → same customer_id, display_name preserved, phone added (verify via
/me) - [C] OTP error codes all render correct Indonesian messages (
CODE_MISMATCH,OTP_EXPIRED,IDENTITY_CONFLICT, etc.) - [C] Anonymity config
anonymity_enabled=false→ after anonymous sign-in, router sends to/auth/force-register - [C] From ForceRegister, complete phone OTP → lands in
/homewith upgraded profile - [C] Google/Apple buttons are hidden by default (ENABLE_SOCIAL_AUTH=false)
- [C]
--dart-define=ENABLE_SOCIAL_AUTH=true→ buttons appear; tapping without backend creds returns clear error (no crash) - [C] After Google/Apple creds are live: sign-in with fresh account works; sign-in with existing-elsewhere account →
IDENTITY_CONFLICTsurfaced as "Akun ini sudah terhubung..." - [C] Logout from home → returns to welcome (storage cleared)
- [C] Set display name screen: empty input disabled; success → home
- [C] Long session: access token expires → auto-refresh transparent
- [C] Refresh expires → unauthenticated → welcome screen, not a broken home
1.13 Cross-App / WebSocket / Regression After Auth Change
- [BE][M][C] WS auth in chat (
chat_notifierclient +mitra_chat_notifier) works with the new JWT - [BE][M][C] WS auth in pairing (
pairing_notifierclient +chat_request_notifiermitra) works with the new JWT - [BE][M][C] Full chat flow: pair → chat → extension → closure — using the new JWT end-to-end
- [BE] FCM device-token registration endpoint (
/api/shared/device-token) still works; Authorization header is the new JWT
Part 2 — Phase 3.3 Carry-Over: Topic Sensitivity
2.1 Database / Migration
- Migration runs cleanly on an existing dev DB (no errors, all
IF NOT EXISTS/ON CONFLICTpaths hit) chat_sessions.topic_sensitivitycolumn exists with default'regular'and NOT NULL- Existing sessions (created before the migration) have
topic_sensitivity = 'regular'after migration session_sensitivity_logtable exists with correct FKs (sessions, mitras)idx_chat_sessions_topic_sensitivityindex createdidx_session_sensitivity_log_sessionindex createdapp_confighassensitive_flip_confirmation_enabled = trueby defaultapp_confighassensitive_flag_one_way_latch = falseby default
2.2 Customer Flow (client_app)
Topic selection bottom sheet
- Tap "Mulai Curhat" → topic selection bottom sheet appears
- Sheet cannot be dismissed by tapping outside
- Sheet cannot be dismissed by swiping down
- System back button cancels entire "Mulai Curhat" flow (does NOT open pricing)
- Copy matches PRD (title, body, sub-question, helper line)
- "Topik umum" button styling is primary
- "Topik sensitif" button styling is secondary but equal weight (not de-emphasized)
Request submission
- Tap "Topik umum" → pricing sheet opens with topic pre-selected as regular
- Tap "Topik sensitif" → pricing sheet opens with topic pre-selected as sensitive
- After pricing confirm →
POST /api/client/chat/requestbody includestopic_sensitivity: regular|sensitive - Backend rejects request with missing
topic_sensitivity(400 BAD_REQUEST) - Backend rejects request with invalid
topic_sensitivityvalue (e.g.,"other") - Created
chat_sessionsrow has correcttopic_sensitivityvalue
Customer UI after request
- Chat screen stays pink (no yellow), regardless of flag
- Customer history screen: no badge on any row regardless of flag
- Customer transcript screen: stays pink
- Customer receives
session_topic_updatedWS message after mitra flip → no UI change, no error, no crash
2.3 Mitra Flow — Incoming Request (mitra_app)
- Regular request: overlay has no badge, no yellow accent
- Sensitive request: overlay shows "Topik sensitif" badge + yellow accent
- Overlay payload from WS includes
topic_sensitivity - Overlay payload from FCM fallback includes
topic_sensitivity(or app fetches on open) - Overlay payload from
getPendingRequestsForMitra(app-resume path) includestopic_sensitivity - Mitra accepts sensitive request → lands in active chat screen with correct flag
2.4 Mitra Flow — Active Chat Screen
- Sensitive active session: yellow doodle background
- Regular active session: pink doodle (unchanged behavior)
- Header banner shows "Topik sensitif" label only when sensitive
- App-bar toggle icon visible (flag / flag_outlined depending on state)
Flip toggle — confirmation enabled (default)
- Tap toggle regular → sensitive → dialog appears: "Tandai sesi ini sebagai sensitif?"
- "Batal" cancels, no state change, no audit log entry, no WS broadcast
- "Tandai" flips, background turns yellow instantly, log entry created
- Tap toggle sensitive → regular → dialog: "Tandai sesi ini sebagai topik umum?"
Flip toggle — confirmation disabled (via CC config)
- Toggle
sensitive_flip_confirmation_enabledtofalsein CC - Flip happens immediately, toast "Sesi ditandai sensitif" / "Sesi ditandai topik umum"
- No dialog appears
One-way latch — disabled (default)
- Can flip regular → sensitive → regular → sensitive freely
One-way latch — enabled (via CC config)
- Toggle
sensitive_flag_one_way_latchtotruein CC - Session that was regular: can flip to sensitive; toggle then disabled
- Session that was already sensitive at latch-enable time: toggle disabled with tooltip
- Attempt to flip sensitive → regular with latch on: API returns 409
SENSITIVITY_LATCHED - Error dialog shown: "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali."
Audit trail
- Every successful flip creates a
session_sensitivity_logrow with correctfrom_value,to_value,changed_by_mitra_id - No-op flip (e.g., tap confirm but value didn't actually change) does NOT create a log row
- Log entries ordered correctly (ascending
created_at)
2.5 Mitra Flow — Extension
- Customer requests extension on regular session → mitra extension card has no badge
- Customer requests extension on sensitive session → mitra extension card shows "Topik sensitif" badge + yellow accent
- Mitra flipped session mid-chat regular → sensitive, then customer requests extension → extension card reflects current sensitive flag
- Extension accepted → flag carries over unchanged to extended session
2.6 Mitra Flow — History & Transcript
- Mitra history list row shows "Topik sensitif" badge for sensitive sessions
- Mitra history list row has no badge for regular sessions
- Mitra transcript view: sensitive session → yellow doodle background
- Mitra transcript view: regular session → pink doodle
- Customer-side history and transcript: always pink, no badge
2.7 Mitra Flow — Edge Cases
- Mitra tries to flip flag on a session they don't own → 403 FORBIDDEN
- Mitra tries to flip flag on a
CLOSINGsession → 409 SESSION_NOT_ACTIVE - Mitra tries to flip flag on a
COMPLETEDsession → 409 SESSION_NOT_ACTIVE - Mitra tries to flip flag on an
EXPIREDsession → 409 SESSION_NOT_ACTIVE - Invalid
topic_sensitivityvalue sent to PATCH endpoint → 400 BAD_REQUEST - Customer tries to call PATCH endpoint → 403 FORBIDDEN (only mitra allowed)
2.8 Control Center — Settings
- Settings page has new "Sensitivitas Topik" section
sensitive_flip_confirmation_enabledcheckbox reflects current backend valuesensitive_flag_one_way_latchcheckbox reflects current backend value- PATCH
/internal/config/sensitivitypersists changes - Changes take effect immediately on next mitra flip (no app restart needed on mitra side, if mitra fetches config dynamically)
2.9 Control Center — Sessions Page
- Sessions list has new filter dropdown (All / Umum / Sensitif)
- Filter "Sensitif" returns only sessions with
topic_sensitivity = 'sensitive' - Filter "Umum" returns only
regular - Filter "All" returns everything (backward-compatible)
- Filter works combined with existing status filter
- Session list has new "Topik" column showing badge (green "Umum" / yellow "Sensitif")
2.10 Control Center — Session Detail
- Session detail page shows current
topic_sensitivity - Session detail shows sensitivity audit trail timeline: "Mitra {name} menandai topik sebagai {from→to} pada {timestamp}"
- Timeline ordered ascending
- Sessions with no flips show empty timeline (no error)
2.11 Control Center — Dashboard
- Dashboard shows "Sesi Sensitif" card with total count + 30-day % breakdown
- Percentage math correct (sensitive / total × 100, rounded to 1 decimal)
- Edge case: 0 sessions in last 30 days → shows
0%notNaN
2.12 Control Center — Mitra Activity
- Summary table has new columns: Sensitive Total, Sensitive Accepted, Sensitive Rate (%)
- Mitra with 0 sensitive requests shows
—(not0%) - Sensitive rate computed correctly (sensitive_accepted / sensitive_total × 100)
- Detail log table: optional new "Topik" column with badge
- Date range filter still works with new columns
2.13 Control Center — Integration Regression
- Existing settings (anonymity, free-trial, extension-timeout, early-end, mitra-ping, price-tiers) still work under the new auth
- Existing sessions filter (by status) still works
- Existing dashboard cards still render
Part 3 — Phase 3.2 Carry-Over
3.1 Chat Request Overlay
- Multiple concurrent chat requests — verify queue behavior (one shown at a time, next appears when current resolved)
- Stale request: "cancelled by customer" message shown + requires acknowledge (no auto-dismiss)
- Stale request: "accepted by other bestie" message shown + requires acknowledge
- Stale request: "expired" message shown + requires acknowledge
- Swipe-to-dismiss (ignore) does NOT send reject to backend
- Ignored request eventually logs as
ignoredinchat_request_notificationsafter 60s timeout - Request
missed(another mitra accepted first) logs correctly active_session_countcaptured correctly at notification creation
3.2 End-to-End Flows
- Full chat flow: pair → chat → extension → closure (customer + mitra)
- Goodbye flow: session expires → closing → both submit goodbye → completed
- Extension accepted mid-flow → session resumes, timer extends, no grace timer lingering
- Extension rejected → session moves to closing, both see closure UI
- Extension timeout (no mitra response) → closing
3.3 iOS Coverage
- OTP login on iOS (customer) — update: flow now uses stub Fazpass OTP from backend console, no native reCAPTCHA
- OTP login on iOS (mitra) — same as above
- Push notifications on iOS (customer + mitra)
- FCM token registration on iOS
- Chat screen rendering on iOS
- Back button behavior on iOS (deep-link pop fallback)
- Overlay on iOS (iOS setup started but incomplete per prior memory)
- Splash screen on iOS
- Onboarding carousel on iOS
- Keyboard handling on iOS (chat input, goodbye form)
Part 4 — Phase 3 / 3.1 Carry-Over
4.1 Session Lifecycle
- Server restart mid-session: session timer is restored from DB (
restoreActiveTimers) - Stale active sessions auto-complete on restart
- Closing sessions with stale grace timers auto-complete on restart
- Session expired from customer side (5-min countdown display)
- Abandoned session during closure grace period → auto-completes
- Known limitation: multi-instance backend sessions not supported until Valkey keyspace notifications implemented (out of scope — see
project_session_timer_scalingin memory)
4.2 Chat Mechanics
- Message status transitions (sent → delivered → read) work correctly
- Typing indicator shows/hides correctly on both sides
- Messages received while backgrounded are marked
deliveredon foreground resume - Messages viewed are marked
readand the read receipt propagates back to sender - Unread badge on home screen updates correctly (client_app + mitra_app)
4.3 Navigation / UI
- All navigation uses
GoRouter.context.push/go(no leftoverNavigator.pushNamed) - Deep-linked screens work with
canPopfallback +PopScope notification_serviceusesgo(notpush) for terminal states- Splash screen hides auth loading flash on both apps
- Goodbye views use
SingleChildScrollView(no keyboard overflow)
4.4 Control Center Settings
- Free trial config: toggle + duration edit
- Extension timeout: edit seconds
- Early end: toggle mitra / customer independently
- Mitra ping: toggle require + interval
- Price tiers: add / edit / remove tiers and verify client_app pricing sheet reflects changes
Part 5 — Cross-Cutting / Pre-Release
5.1 Regression Checks (after Phase 3.4 merge)
- Existing customer auth flow still works (welcome → OTP → register → home) — under new JWT
- Existing mitra auth flow still works — under new JWT
- Existing control center login still works (admin with rotated password) — under new cookie-based refresh
- Pairing flow (mulai curhat → matched) still works end-to-end under new auth
- All existing WS messages still processed (no regressions from
session_topic_updatedhandler or JWT handshake)
5.2 Platform Coverage
- Android: client_app on emulator (Medium_Phone_API_36.1)
- Android: mitra_app on physical device (SM-A530F, 52002a5db8e0c46b)
- iOS: client_app (Mac + simulator / physical)
- iOS: mitra_app (Mac + simulator / physical)
- Control center: Chrome latest
- Control center: Firefox / Safari (if required)
5.3 Load / Concurrency (sanity)
- 2 concurrent customers requesting chat at the same time — both find a mitra (or one waits)
- 1 customer, 5 mitras online — blast notification reaches all 5
- Mitra accepts after another mitra already accepted → receives
missedwithaccepted_by_other - Backend restart with active sessions → timers restored, no data loss
- 3 concurrent CC admins logging in → each gets its own
auth_sessionsrow; logout on one doesn't affect others
5.4 Config Flag Interactions
sensitive_flag_one_way_latch = true+ existing sensitive session → toggle disabledsensitive_flip_confirmation_enabled = false+ rapid flips → no race, all logged in order- Anonymity toggle flip (enabled → disabled) while a user has an active anonymous session → next bootstrap routes to ForceRegister
- Anonymity toggle flip (disabled → enabled) → no-op for already-identified users; new users can go anonymous again
5.5 Security / Negative
- JWT secret leak simulation: a manually-signed token with the wrong secret → 401 across all surfaces
- Token from a deleted
auth_sessionsrow still verifies for up to 1h (documented window); revoke immediately with future Valkeyrevoked_sessionsset - Refresh token used twice in parallel → only one succeeds (rotation-on-use); second →
REFRESH_INVALID - Refresh token stolen + used from a different IP → still works (documented design — rotation + bcrypt is the defense);
device_infologs the new IP for audit - Control center cookie
SameSite/Secure/HttpOnlyflags correct in prod (verify via devtools in staging)
5.6 Known Blockers / Deferred
Not tests — tracked so they don't get forgotten:
- Valkey keyspace notifications — required for multi-instance session timers + real token revocation
- Mitra QC auto-flag — auto-flagging high-rejection mitras on CC dashboard
- Merge-on-link for social login (currently reject-on-existing with
IDENTITY_CONFLICT) - Drop
firebase_uidcolumns — cleanup migration once no code path references them - Real Fazpass integration — replace stub in
otp.service.jswhen API docs + creds arrive - Google + Apple OAuth creds — provision then flip
ENABLE_SOCIAL_AUTH=trueand run §1.4 + client_app social blocks - Apple Developer setup — Services ID +
.p8+ Team ID + Key ID before iOS Apple-sign-in E2E - JWT secret rotation procedure — documented but not implemented (dual-secret window plan in
phase3.4-plan.md)