# 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-sync` on 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-api` resolves `provider_id` from the inbound `Host` header 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**: 1. `BrandDomainGuard` resolves `provider_id` from `Host` header via `provider_brand_domains`. 2. Look up `clients` WHERE `provider_id=$p AND phone_e164=$phone`. 3. If no row found → return `{ sent: true }` with no SMS sent. Generic response; no enumeration. 4. Count `otp_attempts` WHERE `provider_id=$p AND phone_e164=$phone AND created_at > now()-interval '10 minutes'`. Reject if ≥ 3 with HTTP 429. 5. Generate 6-digit OTP. Store `sha256(otp)` in `otp_attempts` with `expires_at = now()+10min`, `attempt_count = 0`. 6. Call `mac-sync` SMS adapter: INSERT into `outreach.scheduled_send` with `fire_at = now()+30s`. 7. 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**: 1. Resolve `provider_id` from `Host`. 2. Find active `otp_attempts` row WHERE `provider_id=$p AND phone_e164=$phone AND expires_at > now() AND consumed_at IS NULL`. If none → `{ error: 'invalid_or_expired' }`. 3. Increment `attempt_count`. If `attempt_count >= 5` → `{ error: 'too_many_attempts' }`. Hard stop; the row remains unconsumed so subsequent guesses also fail. 4. Compare `sha256(code) == code_hash`. If mismatch → return error without consuming the row. 5. On match: `UPDATE otp_attempts SET consumed_at = now()`. 6. Resolve `clients` row for `(provider_id, phone_e164)`. 7. Issue `cas_session` JWT (`client_id`, `provider_id`, 24h TTL). `Set-Cookie: cas_session=; HttpOnly; SameSite=Strict; Domain=.cocotte.club; Path=/`. 8. 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` → call `GET /client-area/documents/by-number/quote/1` → redirect to `/documents/2026/london-first-weekend-together`. - `redirect=documents` (no action) → navigate to `/documents` index. - No query params → navigate to `/documents` index. **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**: 1. Validate `cas_session` JWT. Reject if expired or signature invalid. 2. Resolve `client_id` from JWT. Verify `client_id.provider_id` matches `BrandDomainGuard`'s resolved `provider_id`. 3. `SELECT * FROM documents WHERE client_id=$cid AND doctype IS NOT NULL AND doc_year=$year AND slug=$slug AND provider_id=$pid`. 4. Run `sanitizeDocument()` to strip server-only fields. 5. Return document payload + `payment_methods` catalog (filtered by `payment_methods_config.visibleMethodIds` if 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: '' }`. 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`](./client-area-architecture.md) — full OTP sequence diagram, endpoint specs, DB schema - [`client-area.brief.md`](./client-area.brief.md) — feature overview, multi-tenancy, brand-domain config - [`client-document-types.contract.md`](./client-document-types.contract.md) — doctype/doc_no contract - [`client-quote-confirm.flow.md`](./client-quote-confirm.flow.md) — what happens after the client selects an option