02 — Identity & Onboarding (Sub-system Design)
02 — Identity & Onboarding (Sub-system Design)
Section titled “02 — Identity & Onboarding (Sub-system Design)”Status: Draft
Date: 2026-05-23
Owner: backend agent
Parent: 2026-05-23-nowyourlink-master-design.md (binding — esp. §4.1, §10.4, D7, D15, D24)
1. Purpose & scope
Section titled “1. Purpose & scope”Owns pre-bidding identity: registration, DOI, VIES VAT validation (with degraded-pending fallback), magic-link auth + session, lazy Stripe Customer + SetupIntent (off-session B2B card, SCA/3DS), and Stripe Identity escalation. Non-EU routes to manual admin queue (D7).
In scope: §3 endpoints; users, sessions, vat_validation_attempts, setup_intents, kyc_escalations; Stripe Customer/SetupIntent/Identity; VIES + retry queue; magic-link abuse limits.
Out of scope: bid ranking (04), invoice/capture/cascade (05), creative moderation (03), admin UI (10), legal text + retention (12).
2. Data model slice
Section titled “2. Data model slice”D1 tables; UUIDs as text, ISO-8601 UTC.
2.1 users (canonical — see master §4.1)
Section titled “2.1 users (canonical — see master §4.1)”Extends master User with onboarding bookkeeping:
| Column | Type | Notes |
|---|---|---|
id | text PK | UUID v4 |
email | text UNIQUE | lower-cased, RFC 5321 |
email_verified_at | text NULL | DOI redemption |
company_name, country | text | country ISO-3166-1 alpha-2 |
vat_id | text NULL | normalised; NULL non-EU |
vat_validation_status | text | pending | vies_ok | vies_degraded_pending | manual_approved | rejected |
vat_validation_checked_at | text NULL | last VIES success |
stripe_customer_id | text NULL | set on first-bid |
default_payment_method_id | text NULL | after SetupIntent succeeded |
pm_last4, pm_exp_month, pm_exp_year | text NULL | for UI + pre-close health check |
kyc_level | text | none | identity_required | identity_passed | identity_failed |
tier | text | spectator / contender / challenger / champion |
strikes | int | 0..3 |
banned_at | text NULL | |
locale, created_at | text | en|de; ISO ts |
Note: vies_degraded_pending extends master §4.1’s enum under §10.4; resolves within 72 h via the retry queue (§4).
2.2 sessions (Cloudflare KV)
Section titled “2.2 sessions (Cloudflare KV)”Key sess:<opaque-256-bit> (URL-safe base64); value { user_id, issued_at, last_seen_at, ip_hash, ua_hash }; TTL 30 d sliding, cap 90 d absolute. Cookie nyl_sess: HttpOnly; Secure; SameSite=Lax; Domain=.nowyourlink.com; Path=/. DO-memory cache in front of KV (master §10.2).
2.3 vat_validation_attempts
Section titled “2.3 vat_validation_attempts”id PK, user_id FK, vat_id (snapshot), attempt_at, outcome (valid\|invalid\|service_unavailable\|timeout\|format_error), vies_request_id NULL, raw_response NULL — append-only audit trail.
2.4 setup_intents
Section titled “2.4 setup_intents”id PK (Stripe seti_…), user_id FK, client_secret_issued_at, status (mirrors Stripe: requires_payment_method\|requires_confirmation\|requires_action\|succeeded\|canceled), last_error_code NULL, next_action_type NULL, payment_method_id NULL, pending_bid_token NULL.
2.5 kyc_escalations
Section titled “2.5 kyc_escalations”id PK, user_id FK, reason (bid_over_500_new_account\|signature_suspicious\|manual_admin\|chargeback_history\|vies_degraded_first_bid), stripe_identity_session_id NULL (vs_…), verification_url NULL, status (open\|verification_pending\|verified\|rejected\|cancelled), created_at, resolved_at.
Reason vies_degraded_first_bid covers §7 trigger #2 (master D30); added in
migration 0003_kyc_escalation_vies_degraded.sql. verification_url is
populated when the Stripe Identity session is created in-band so
POST /first-bid/setup can return the same URL on idempotent retries
without re-calling Stripe.
3. Endpoints (HTTP contract)
Section titled “3. Endpoints (HTTP contract)”All on api.nowyourlink.com. Error envelope per master §4.5. Framework: Hono (per ADR-0001 / D40); routes registered as Hono handlers in apps/api/src/app.ts. :id-style segments become Hono path params via c.req.param('id').
| Method + Path | Auth | Purpose |
|---|---|---|
POST /register | none + Turnstile | {email, company_name, country, vat_id?, locale} → inserts user (pending, unverified), emits DOI via Resend. Idempotent on email. |
POST /verify-email | DOI token | {token} → marks email_verified_at, kicks VIES (sync; falls back to queue, §4). |
POST /magic-link | none + Turnstile | {email} → always 204. If verified + not banned, mails link. |
GET /auth/callback?token=… | magic-link | One-shot redemption → sets nyl_sess cookie, 302 to app. |
POST /logout | session | Deletes KV, clears cookie. |
GET /me | session | Projection: id, email, tier, kyc_level, vat_validation_status, default_pm summary, strikes, banned_at. |
POST /first-bid/setup | session | {auction_id, amount_cents, ad_id} → lazy stripe.customers.create, stripe.setupIntents.create, signed pending_bid_token (15-min TTL). Returns {client_secret, setup_intent_id, pending_bid_token, escalation?}. If amount > 50000 cents AND account < 14 d → opens kyc_escalations and returns escalation={type:"stripe_identity", verification_url}. |
POST /first-bid/confirm | session | {setup_intent_id, pending_bid_token} → stripe.setupIntents.retrieve; if succeeded, stripe.paymentMethods.attach + stripe.customers.update({invoice_settings.default_payment_method}), emit bid.placed to 04. If requires_action → 409 + next_action. |
POST /webhooks/stripe | Stripe sig | Routes setup_intent.*, identity.verification_session.*; other events → 05. |
POST /admin/users/:id/{manual-approve|reject|escalate-identity} | CF Access | Admin actions on a user. |
GET /admin/queue/{non-eu|vies-degraded} | CF Access | Admin work queues. |
4. VIES integration
Section titled “4. VIES integration”- Endpoint:
https://ec.europa.eu/taxation_customs/vies/services/checkVatService(SOAP 1.1,checkVat). - EU-27 country set: AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK (GR →
ELon wire). Non-EU skip VIES → admin queue. - Per request: 4 s connect, 6 s total. Single inline attempt on
POST /verify-email. - Degraded fallback (§10.4): on timeout / 5xx /
SERVICE_UNAVAILABLE/MS_UNAVAILABLE/GLOBAL_MAX_CONCURRENT_REQ→ statusvies_degraded_pending, enqueueq-vies-retry. Backoff 5 m / 15 m / 1 h / 4 h / 12 h / 24 h (6 attempts, ~72 h).valid→vies_ok;INVALID_INPUT→rejected; exhausted→admin queue. - Bidding allowed during
vies_degraded_pending(else outages block revenue), but auto-opens akyc_escalationsif first bid > 100 € (D30). - Attempts logged to
vat_validation_attempts. Successful results cached 90 d; re-validate onvat_idchange or before invoice handoff to05. VIES is advisory; Stripe Tax is the legal source at invoice time.
5. Stripe SetupIntent flow (off-session B2B card)
Section titled “5. Stripe SetupIntent flow (off-session B2B card)”Trigger: first authenticated POST /first-bid/setup.
- Lazy Customer (idempotency key
customer:${user_id}): ifstripe_customer_id IS NULL,stripe.customers.create({ email, name: company_name, address:{country}, tax_id_data:[{type:'eu_vat'|'de_ustid_nr', value:vat_id}] })(omittax_id_datafor non-EU). Persistcus_…. Fornever_attempted, atomically changestripe_customer_creation_statetostartedagainst the current identity before calling Stripe, then re-read it. An unpersistedstartedrequest stays locked because Stripe may have created the Customer despite a lost response. The first rollout leaves migrated and newly registered rowsunknown: company edits stay locked, while the existing Customer creation path continues. A later activation may mark new registrationsnever_attemptedonly after all older API Workers have drained; historicalunknownrows need Stripe reconciliation before company edits. - SetupIntent (idempotency
setup:${user_id}:${pending_bid_token}):stripe.setupIntents.create({ customer, usage:'off_session', automatic_payment_methods:{ enabled:true, allow_redirects:'never' }, metadata:{ user_id, auction_id, pending_bid_token } }).allow_redirects:'never'keeps MVP card-only per D10. - Client confirms with
stripe.confirmSetup(Stripe.js Elements); inline 3DS. - Server-side
POST /first-bid/confirmalways re-fetchesstripe.setupIntents.retrieve— never trusts the client outcome alone. - On
succeeded:stripe.paymentMethods.attach(pm, {customer})(idempotent) +stripe.customers.update(customer, { invoice_settings:{ default_payment_method: pm } }). Persistpm_last4,pm_exp_*. Hand offpending_bid_tokento04asbid.placed.
SCA / 3DS UX
Section titled “SCA / 3DS UX”requires_action: client SDK opens modal; if abandoned, /first-bid/confirm returns 409 setup.requires-action, no bid placed. Bid persisted only after succeeded. pending_bid_token TTL 15 min; on expiry restart at /first-bid/setup.
Error code → UI mapping
Section titled “Error code → UI mapping”Stripe code | UI message (EN) | Retryable |
|---|---|---|
card_declined (generic / insufficient_funds) | “Card was declined / insufficient funds — try another card.” | new card |
expired_card | ”Card expired.” | new card |
incorrect_cvc | ”Security code didn’t match.” | yes |
authentication_required | ”Your bank requires 3-D Secure verification.” | resume |
setup_intent_authentication_failure | ”Verification with your bank failed.” | yes |
rate_limit | Generic retry banner. | auto 1× |
6. Magic-link mechanics
Section titled “6. Magic-link mechanics”- Token: 256-bit random, base64url. KV
mlink:<token>→{user_id, created_at, used:false}. TTL: 15 min. Single-use; redemption deletes the key. - URL:
https://nowyourlink.com/auth/callback?token=…. - Rate limits (per email + per IP, KV sliding counters): 1/30 s, 5/h, 20/24 h. Overrun returns 204 (no enumeration), no mail sent, log
auth.magic_link.rate_limited. Turnstile required onPOST /magic-link. - Lockout: 10 failed redemptions for the same email within 1 h →
locked_at; admin clears, or auto-clears in 24 h. - Replay protection: token deleted on first redemption (KV get+delete with CAS); concurrent redemptions resolve to one winner.
7. Stripe Identity escalation
Section titled “7. Stripe Identity escalation”Triggered when: (1) first bid > 500 € AND account < 14 d old; (2) VIES degraded AND first bid > 100 € (D30); (3) admin force via /admin/users/:id/escalate-identity; (4) ≥3 chargebacks on the same VAT-ID hash (cross-account, from 05).
Flow: stripe.identity.verificationSessions.create({ type:'document', metadata:{ user_id }, options:{ document:{ require_matching_selfie:true, require_live_capture:true } } }). Persist vs_…, return verification_url. Webhook identity.verification_session.verified → kyc_level='identity_passed', mark verified, release staged bid. …requires_input / …canceled → Resend notice; bid blocked; repeats → admin queue.
Gating: while a kyc_escalations row is open / verification_pending, POST /first-bid/confirm returns 403 kyc.identity-required and 04 refuses bids (reads kyc_level via the USER_READ binding, §8).
8. Strike-bookkeeping integration with saga (05) and admin (10)
Section titled “8. Strike-bookkeeping integration with saga (05) and admin (10)”This sub-system owns writes to users for: email_verified_at, vat_validation_status, stripe_customer_id, default_payment_method_id, pm_*, kyc_level, tier, locale. It exposes reads + write hooks via Service Binding USER_READ:
incrementStrike(user_id, reason)— called by05. Atomic D1UPDATE users SET strikes = strikes + 1, banned_at = CASE WHEN strikes + 1 >= 3 THEN now() ELSE banned_at END WHERE id=?. Emitssaga.strike_added.markPmExpired(user_id)/refreshPmHealth(user_id)—05pre-close health check.getBiddingEligibility(user_id) → { eligible, reasons[] }—04calls before accepting a bid. Reasons:banned,email_unverified,vat_rejected,kyc_pending,pm_missing.- Admin endpoints (§3) for
10: approve, reject, force-escalate, clear strikes — each writes to append-onlyuser_admin_actions.
Strikes survive DSGVO deletion as (hashed_vat_id, strikes, banned_at) per master §4.1; the routine in 12 calls anonymizeUser(user_id) here, which retains only that row.
9. Edge cases
Section titled “9. Edge cases”- Duplicate email —
/registeridempotent: unverified → resend DOI (rate-limited); verified → 204 with no leak (user gets magic-link). - VAT-ID changed after
vies_ok— reset topending, re-run VIES, block bids until resolved; update Customertax_id_dataviastripe.customers.update. - Card expires — webhook
payment_method.detachedor pre-close health check nullsdefault_payment_method_id; next bid forces a new SetupIntent; mid-flight expiry →05cascades. - DSGVO delete with unpaid invoices — block, return
legal.unpaid-invoices. Once cleared (or credit-noted), anonymise to deterministic hashes, retain strikes row. - Non-EU registration —
vat_id=NULL, statuspending, routed to/admin/queue/non-eu; bidding blocked untilmanual_approved.05marks invoices “Nicht steuerbar”. - VIES vs Stripe Tax discrepancy — Stripe Tax wins at invoice time; VIES informational. Log + admin alert.
- Magic-link mail bounces — Resend bounce webhook sets
email_bounced_at; further requests silently 204. - SetupIntent succeeds but
pending_bid_tokenexpired — PM saved, bid dropped with UX message to re-bid; PM lifecycle deliberately decoupled from bid lifecycle.
10. Acceptance criteria
Section titled “10. Acceptance criteria”- New EU registrant: register → DOI →
vies_ok(orvies_degraded_pendingif VIES down) → magic-link →/mewith session, end-to-end <60 s when VIES healthy. - First-bid by a user without saved PM yields a
client_secret; after 3DS the bid is accepted by04. - Subsequent bids do not trigger a new SetupIntent.
- 600 € bid from a 5-day-old account triggers Stripe Identity; bid staged, not placed, until verified.
- Simulated VIES 503 does not block registration; degraded state retries and resolves on recovery; exhaustion → admin queue at 72 h.
- 11 magic-link requests in 30 s for the same email all return 204; only 1 mail sent.
- Admin can approve / reject / force-escalate / clear-strikes; every action audit-logged.
- DSGVO delete with no open invoices anonymises and keeps strikes; with open invoices returns
legal.unpaid-invoices.
11. Agent-team hints
Section titled “11. Agent-team hints”- Owner: backend agent.
- Wrangler bindings: D1
DB; KVSESSIONS,RATE,AUTH_TOKENS(carriesmlink:magic-link records ANDdoi:double-opt-in records ANDlockout:rows — the name reflects the union, not just magic-links; renamed fromMAGIC_LINKSin PR #53 to avoid misleading future maintainers); Queue producerq-vies-retry+ consumer worker; secretsSTRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,RESEND_API_KEY,TURNSTILE_SECRET_KEY; service bindingUSER_READexposed to04+05. - Stripe API surface:
stripe.customers.create,stripe.customers.update,stripe.setupIntents.create,stripe.setupIntents.retrieve,stripe.paymentMethods.attach,stripe.identity.verificationSessions.create. No invoice/charge calls — those belong to05. - Webhooks handled here:
setup_intent.succeeded,setup_intent.setup_failed,setup_intent.requires_action,payment_method.detached,identity.verification_session.{verified,requires_input,canceled}. Other Stripe events forwarded to05. - Sentry tags:
subsystem=identity-onboarding,vies_status,setup_intent_status. - Resolved (D30):
vies_degraded_pendingbidders trigger Identity escalation at the 100 € threshold (not 200 €). Confirmed by 12-legal-compliance. - Routing (D40 / ADR-0001): routes registered on the Hono app in
apps/api/src/app.ts. Sub-feature route modules can be mounted viaapp.route('/', identityRoutes). ThePOST /webhooks/striperoute (spec 05 §6) uses a raw-body Hono middleware beforeStripe.webhooks.constructEventAsync.