9.9 KiB
Flow — client onboarding (invite SMS → first document)
Goal
A prospect receives a text from the provider, taps the link, authenticates with one OTP, and lands on their first document — without creating an account, setting a password, or providing any information beyond a phone number. The provider never shares a static URL that leaks document contents; access is phone-gated on every visit.
Constraints
- Client has no prior account. The phone number is the identity key.
- OTP is delivered via
mac-syncon plum — 30–40s minimum latency. The magic link in the SMS body lets the client tap to autofill rather than type the code. - Unknown phones receive a generic non-revealing error — no enumeration of whether the phone exists in the provider's client registry.
- Session cookie is
HttpOnly; the SPA cannot read it. All auth state lives server-side. - The magic-link URL encodes
action=openQuote&userDocNo=1(or similar) so the SPA can navigate directly to the right document post-auth. platform-apiresolvesprovider_idfrom the inboundHostheader before any handler runs (BrandDomainGuard). The client never sends a provider identifier explicitly.
Step-by-step flow
Step 1 — Provider creates the client record
Actor: provider (via provider-portal or direct DB seed)
Action: INSERT into clients with (provider_id, phone_e164, display_name).
State change: clients row exists; doc_no sequence for this client starts at 0.
Logged: provider action recorded in agent_actions (audit spine).
Step 2 — Provider authors the document
Actor: provider (via provider-portal document editor)
Action: Create a documents row with doctype='quote', status='draft'. The doc_no is assigned at INSERT: COALESCE(MAX(doc_no) FILTER (WHERE doctype='quote'), 0) + 1 within a transaction scoped to (client_id, doctype='quote'). doc_year is extracted from created_at. The slug is provider-supplied (descriptive, URL-safe, year-scoped).
State change: documents row with status='draft'. Not yet visible to the client.
Logged: creation event in agent_actions.
Step 3 — Provider sends the invite SMS
Actor: provider
Action: Sends a message (via iMessage, SMS, or email — outside the Client Area) containing the magic-link URL. Canonical format:
https://www.cocotte.club/login?redirect=documents&action=openDocument&doctype=quote&userDocNo=1
The provider also updates documents.status to 'sent'.
State change: document status='sent'. Provider-side only; the client has not authenticated yet.
Open question: should the status='sent' transition be automated when the provider copies the magic-link URL, or should it remain a manual step in provider-portal? Manual is safer (provider may want to draft and re-draft before sending) but easy to forget.
Step 4 — Client opens /login
Actor: client browser
Action: SPA loads. If ?oneTimePw=X&redirect=Y&... is present in the query string, the SPA extracts the OTP and pre-fills the phone field (if ?phone=Z is also present), then auto-submits step 6.
If arriving at /login without query params (fresh visit, expired session), the SPA shows the phone-number entry form.
State change: none (SPA load only).
Step 5 — Client enters phone number and requests OTP
Actor: client
Action: Submits phone number. SPA calls POST /client-area/auth/otp/request with { phone: '+44...' }.
platform-api processing:
BrandDomainGuardresolvesprovider_idfromHostheader viaprovider_brand_domains.- Look up
clientsWHEREprovider_id=$p AND phone_e164=$phone. - If no row found → return
{ sent: true }with no SMS sent. Generic response; no enumeration. - Count
otp_attemptsWHEREprovider_id=$p AND phone_e164=$phone AND created_at > now()-interval '10 minutes'. Reject if ≥ 3 with HTTP 429. - Generate 6-digit OTP. Store
sha256(otp)inotp_attemptswithexpires_at = now()+10min,attempt_count = 0. - Call
mac-syncSMS adapter: INSERT intooutreach.scheduled_sendwithfire_at = now()+30s. - Return
{ sent: true }.
State change: otp_attempts row created. outreach.scheduled_send row created on plum's DB.
Logged: OTP request event (phone hash, not plaintext; provider_id).
Step 6 — Client receives SMS and taps magic link (or enters code manually)
Actor: mac-sync (plum) → client
mac-sync action: scheduled-send-worker on plum polls outreach.scheduled_send, dispatches SMS via macOS Messages.app. SMS body:
Your code is 482910. Tap to open: https://www.cocotte.club/login?oneTimePw=482910&phone=%2B44...&redirect=documents&action=openDocument&doctype=quote&userDocNo=1
Client action: taps link → browser opens /login with query params → SPA auto-extracts oneTimePw and phone, auto-submits OTP verify (step 7). Alternatively, client types the 6-digit code into the OTP entry form.
State change: none yet (tap opens browser).
Step 7 — OTP verification
Actor: SPA → platform-api
Action: POST /client-area/auth/otp/verify with { phone: '+44...', code: '482910' }.
platform-api processing:
- Resolve
provider_idfromHost. - Find active
otp_attemptsrow WHEREprovider_id=$p AND phone_e164=$phone AND expires_at > now() AND consumed_at IS NULL. If none →{ error: 'invalid_or_expired' }. - Increment
attempt_count. Ifattempt_count >= 5→{ error: 'too_many_attempts' }. Hard stop; the row remains unconsumed so subsequent guesses also fail. - Compare
sha256(code) == code_hash. If mismatch → return error without consuming the row. - On match:
UPDATE otp_attempts SET consumed_at = now(). - Resolve
clientsrow for(provider_id, phone_e164). - Issue
cas_sessionJWT (client_id,provider_id, 24h TTL).Set-Cookie: cas_session=<jwt>; HttpOnly; SameSite=Strict; Domain=.cocotte.club; Path=/. - Return
{ ok: true }.
State change: otp_attempts.consumed_at set. cas_session cookie issued.
Logged: auth success event (client_id, provider_id, source: 'otp').
Step 8 — SPA resolves post-auth action and navigates
Actor: SPA
Action: After successful verify, SPA reads the action + userDocNo (or redirect) from the original query params:
action=openDocument&doctype=quote&userDocNo=1→ callGET /client-area/documents/by-number/quote/1→ redirect to/documents/2026/london-first-weekend-together.redirect=documents(no action) → navigate to/documentsindex.- No query params → navigate to
/documentsindex.
State change: none. Client is now authenticated and viewing their first document.
Step 9 — Document loads
Actor: SPA → platform-api
Action: GET /client-area/documents/:year/:slug with session cookie.
platform-api processing:
- Validate
cas_sessionJWT. Reject if expired or signature invalid. - Resolve
client_idfrom JWT. Verifyclient_id.provider_idmatchesBrandDomainGuard's resolvedprovider_id. SELECT * FROM documents WHERE client_id=$cid AND doctype IS NOT NULL AND doc_year=$year AND slug=$slug AND provider_id=$pid.- Run
sanitizeDocument()to strip server-only fields. - Return document payload +
payment_methodscatalog (filtered bypayment_methods_config.visibleMethodIdsif set).
State change: none (read-only).
Edge cases
Unknown phone
POST /auth/otp/request returns { sent: true } regardless. No SMS is sent. The client sees "We sent a code to your number." If they type the 6-digit code, verify will return { error: 'invalid_or_expired' } (no active row). No enumeration of whether the phone exists.
Expired OTP (10-minute window elapsed)
Verify returns { error: 'invalid_or_expired' }. Client must request a new OTP. If within 10 minutes they request again, the send-count check allows up to 3 total sends; after that, 429 until the 10-minute window slides.
Max verify attempts (5 attempts)
The otp_attempts row is not consumed; attempt_count is at 5. Every subsequent verify call hits the attempt_count >= 5 guard and returns { error: 'too_many_attempts' }. The client must request a new OTP (which starts a fresh row with a new code, reset attempt_count). The old expired row is ignored on future lookups (AND consumed_at IS NULL).
mac-sync stalled (plum asleep or disconnected)
GET /client-area/health returns { ok: false, macSyncLastSeen: '<stale timestamp>' }. The SPA can check this before showing the phone-entry form and surface a degraded-mode banner: "SMS delivery is delayed — codes may take longer to arrive." The OTP request still proceeds; the SMS will dispatch when mac-sync recovers.
No documents yet
GET /client-area/documents returns []. The SPA shows a "Nothing here yet" state. The client is authenticated but has nothing to view. This is expected if the provider has not yet authored any documents for this client.
Multi-document index
GET /client-area/documents returns all documents for the client across all doctypes, sorted by created_at DESC. Each row includes doctype, doc_no, doc_year, slug, title, status. The SPA renders a simple list; clients can tap any document to view it.
Out of scope
- Password-based login (not in scope; OTP is the only auth path)
- Social / OAuth login
- Client self-registration (providers create client records; clients cannot sign themselves up)
- Document content editing by the client
Related
client-area-architecture.md— full OTP sequence diagram, endpoint specs, DB schemaclient-area.brief.md— feature overview, multi-tenancy, brand-domain configclient-document-types.contract.md— doctype/doc_no contractclient-quote-confirm.flow.md— what happens after the client selects an option