Skip to content

[DONT MERGE] Add mocked dashboard app#1236

Open
Sharqiewicz wants to merge 38 commits into
stagingfrom
feature/dashboard-app-staging
Open

[DONT MERGE] Add mocked dashboard app#1236
Sharqiewicz wants to merge 38 commits into
stagingfrom
feature/dashboard-app-staging

Conversation

@Sharqiewicz

@Sharqiewicz Sharqiewicz commented Jun 23, 2026

Copy link
Copy Markdown
Member

Adds a new apps/dashboard sub-app (mocked) with auth, onboarding, recipients, transactions, and transfer UI.

Changes

  • New apps/dashboard/ Vite + shadcn/ui app
  • Root package.json: build:dashboard / dev:dashboard scripts wired in
  • apps/frontend/_redirects: SPA fallback for /dashboard/*

Branch is rebuilt on top of the current staging tip so it compares cleanly.

@netlify

netlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox failed. Why did it fail? →

Name Link
🔨 Latest commit fbfcbaa
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a5144bb2d777c00076eac01

@netlify

netlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi failed. Why did it fail? →

Name Link
🔨 Latest commit fbfcbaa
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a5144bb56dab50008482652

@Sharqiewicz Sharqiewicz changed the title Add mocked dashboard app [DONT MERGE] Add mocked dashboard app Jun 23, 2026
Sharqiewicz and others added 18 commits June 24, 2026 16:16
…hain/vortex into feature/dashboard-app-staging
… dual-write)

Realizes the complete data model from the two architecture docs in one migration
set, per the amended plan (migrate data once, cut reads over next, keep legacy
tables as read-only backup):

- 038 customer_entities + backfill (one individual entity per profile)
- 039 partners split: partner_pricing_configs, dedup to unique-name partners,
  FK repoint (quotes/assignments) before fold, partners_legacy snapshot,
  assignments partner_id collapse, idempotent vortex re-seed
- 040 provider_customers + kyc_cases + backfills from mykobo_customers,
  alfredpay_customers, tax_ids (owned rows only; statuses verbatim; raw tax ids
  replaced by sha256 hash + masked display; ownerless rows quarantined)
- 041 api_keys.partner_id FK (backfill from partner_name) + scopes + revoked_at
  (user_id from 034 already is the doc's profile_id)
- 042 recipient_invitations (link-based, D1) + sender_recipients +
  recipient_payout_references (thin provider pointer, D3)
- 043 notifications + notification_preferences

All new tables ENABLE ROW LEVEL SECURITY (Supabase default privileges grant
anon/authenticated ALL on future tables; RLS-no-policy denies PostgREST).
New status columns are VARCHAR + CHECK (plan D6).

Verified: full chain 001-043 clean on fresh PG16; backfills exercised against
seeded legacy fixtures (dup alfredpay rows, IND+BUS same country, multi-tax-id
user, empty-string subaccount sentinel, orphan tax_id, divergent per-direction
partner activity, ghost partner_name key) with all assertions passing.

NOTE: not independently deployable — existing code still reads the pre-split
partners shape; the cutover commits follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zod was added to packages/shared and @vortexfi/sdk pinned shared to =0.2.0
without a lockfile update; the missing zod entry broke bun build:shared.
Pure bun install output — no package.json changes here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Existing functionality unchanged; pricing now resolves through the split
schema (migrate-once cutover, legacy columns unread):

- New partners/partner-pricing.service: findPartnerWithPricing merges the
  unique-name partner row with its per-direction pricing config — the exact
  semantics of the pre-split Partner.findOne({name|id, isActive, rampType})
- partner-resolution: assignment branch reads the collapsed
  profile_partner_assignments.partner_id (buy/sell pair now unread backup)
- quote-fees: single pricing-config lookup replaces the findAll loop;
  CalculateFeeComponentsRequest.partnerName renamed to partnerId (it always
  carried an id)
- discount helpers: ActivePartner sourced from pricing configs; in-memory
  dynamic-difference state keyed by (partner_id, ramp_type) to preserve the
  per-direction isolation the split rows used to give for free
- feeDistribution: vortex + markup payout addresses read from pricing configs
- apiKeyAuth: secret/public keys resolve partners via api_keys.partner_id FK;
  partner_name never read; user-scoped branch keys off partner_id IS NULL
- admin controllers: unique-name partner resolution; assignments write a
  single partner_id; api-key CRUD keyed by partner_id; revocation stamps
  revoked_at; ambiguity 409 retired (structurally impossible)
- factories: createTestPartner creates partner + config (findOrCreate by
  unique name); seedVortexPartners = 1 partner + BUY/SELL configs;
  updatePartnerPricing helper for per-direction test overrides

Verified: full API suite green (414 pass / 0 fail) including the golden
pricing test (fee parity through the split), corridor scenarios and
sdk-contract suites. Security-spec updated in the same change set:
01-auth/api-keys.md, 03-ramp-engine/{profile-partner-pricing,
discount-mechanism,fee-integrity,quote-lifecycle}.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mers/kyc_cases

Migrate-once cutover of the EUR (Mykobo) and BRL (Avenia) provider identity;
existing functionality unchanged (full suite green: 415 pass / 0 fail):

- customer-entity.service: getOrCreateCustomerEntityForProfile — eager create
  in verify-otp (outside the OTP error mapping so a DB failure cannot surface
  as "Invalid OTP") + lazy fallback on every entity-scoped read (covers users
  with pre-existing sessions who never re-verify)
- mykobo-customer.service: KYC mirror upserts/reads provider_customers
  (provider=mykobo, provider_customer_id = last-synced email); module path and
  exports unchanged (integration-test mock seam preserved)
- avenia-customer.service (new): hash/mask helpers, tax-hash lookups,
  kyc_cases upkeep, idempotent Requested→Accepted/Rejected outcome transition
- avenia-account/brla.controller/ramp.service/phase handlers/worker: all
  tax_ids reads keyed by tax_reference_hash against provider_customers;
  ownership checks compare the effective user's customer_entity; SDK-matched
  error strings byte-identical ("Subaccount not found", "KYC invalid", ...)
- createSubaccount keeps claim-on-create: quarantined (ownerless) legacy
  tax_ids rows are adoptable via a one-time read-only probe of the backup;
  owned legacy rows still 409
- Ownership gaps closed (the real remaining Phase 5): fetchSubaccountKycStatus
  (which also writes status transitions) and getSelfieLivenessUrl now enforce
  account ownership. Known residual: getKybAttemptStatus cannot be bound
  server-side (attempt response carries no subaccount linkage)
- Migration 040 amended (pre-deploy): provider_customers.tax_reference retains
  the raw normalized tax id — documented deviation from the "no raw tax IDs"
  non-goal; it is the join/aggregation key for in-flight ramp state and
  already persists in ramp_states.state JSONB
- Quote-provenance writes dropped (write-only columns); tests updated
  (brla.controller.test rewritten for the new model incl. legacy-adoption
  scenarios; factories create provider_customers; unique-subaccount fix in
  sdk-contract)

Security-spec updated in the same change set: 05-integrations/brla.md
(+ cutover section documenting the raw-tax-reference deviation and the KYB
residual), 05-integrations/mykobo.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odels

Completes the provider cutover (full suite green: 415 pass / 0 fail):

- alfredpay-customer.service (new): findAlfredpayCustomer resolves the
  caller's customer_entity and preserves the legacy updatedAt-DESC tie-break;
  a legacy-shaped view keeps the controller's status machine verbatim
  (alfredPayId/type/status accessors); status transitions mirror into the
  account's kyc_cases row; createAlfredpayCustomer maps country → rail
- alfredpay.controller: all 22 lookup/create sites ported (mechanical);
  status vocabulary unchanged — and UPDATE_REQUIRED writes now succeed
  (the legacy Postgres enum lacked the value the shared enum had)
- alfredpay.helpers/quote resolvers: customerType ASC keeps the type-ASC
  precedence; KYC gates read provider_customers
- Removed models with zero remaining readers: AlfredPayCustomer,
  MykoboCustomer, KycLevel2 (tables stay as read-only backup). TaxId model
  retained solely for the createSubaccount legacy-adoption probe

Security-spec updated: 05-integrations/alfredpay.md (+ cutover section),
07-operations/api-surface.md and 03-ramp-engine/quote-lifecycle.md
resolution chains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dashboard backend surface (plan §7/§8/D5), reading the new tables directly:

- /v1/recipients: link-token invites (sha256-hashed at rest, 14-day TTL,
  optional email binding), token-bound acceptance creating the
  sender_recipients relationship, listing, nickname/block/archive PATCH,
  and the transfer-eligibility gate with per-check blocking reasons.
- /v1/notifications: owner-scoped feed + unread count, read/read-all,
  preferences (defaults on first read). emitNotification never throws;
  email dispatch (D7) deliberately not wired yet.
- GET /v1/onboarding/status: per-entity aggregate over provider_customers
  + kyc_cases with a normalized approved/pending/rejected state.

New security specs 03-ramp-engine/recipient-transfers.md and
07-operations/notifications.md; plan Phase 1 marked landed (§7.1 payout
instrument mechanism still open, so verified payout references have no
production writer yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0 core connection plus the swap-execution engine, ported from the
widget's ramp machine per plan §9:

- Real auth: /v1/auth request-otp/verify-otp/refresh via a ported
  AuthService (dashboard-scoped storage keys) + api-client with bearer
  auth and single-flight 401 refresh; login page drops the fake OTP.
- Real quotes: POST /v1/quotes with a two-pass inversion so the
  payout-driven form ("recipient gets X") maps onto the input-driven
  quote endpoint.
- transfer.machine.ts: the widget's RegisterRamp → UpdateRamp/sign →
  StartRamp → RampFollowUp core as a dashboard-owned XState machine —
  register with fresh ephemerals, presign ephemeral txs client-side
  (signUnsignedTransactions), sign user-owned txs with the wagmi wallet
  (typed-data permits + squidRouter broadcasts, EVM-only), start, then
  poll to terminal. An app-lifetime actor keeps polling after the form
  unmounts and feeds the transactions table.
- CORS: allow :5174 in development; dashboard gains @vortexfi/shared
  (runtime signing helpers), .env.example, esnext build target.

AssetHub payin is disabled (no substrate wallet in the dashboard).
Alfredpay/BRL offramp additionalData stays incomplete until the §7.1
payout-instrument capture lands — EUR is the fully-wirable corridor.
Recipients/notifications/onboarding pages still run on mocks; wiring
them to the new /v1 endpoints is the remaining §9-D work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-verified per corridor: registration is structurally a self-offramp —
destinations are already sender-bound on mykobo (anchor-side IBAN) and
alfredpay (fiatAccountId scoped to the derived customer); only BRL takes a
third-party destination (pix key + receiverTaxId consistency check).
Sender→recipient transfers therefore need a second principal in
registration (resolve the payout side from the recipient's provider
identity), not a bolted-on eligibility check. Recorded as a pressing
open decision (plan §12.7) and corrected in the recipient-transfers spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
import { AlfredPayCountry, AlfredPayStatus } from "@vortexfi/shared";
import { emitNotification } from "../api/services/notifications/notification.service";
import KycCase from "../models/kycCase.model";
import ProviderCustomer, { AveniaKycStatus } from "../models/providerCustomer.model";
gianfra-t and others added 7 commits July 8, 2026 11:34
Replace the mocked recipient store with real data:

- Fetch third-party recipients + pending invitations from GET /v1/recipients
  (recipients.service.ts, recipient.mappers.ts, useRecipients hook).
- Derive self-recipients from fetched payout accounts: one per saved AlfredPay
  fiat account (GET /v1/alfredpay/fiatAccounts), carrying the real fiatAccountId;
  a single entry for BR/EU.
- Wire invite creation to POST /v1/recipients/invite (real one-time token link),
  invalidating the list.
- Transfer selector: only self-recipients are selectable; third-party rows are
  disabled with a "coming soon" tooltip (no third-party sending yet).
- Self-offramp additionalData now matches what the backend derives: AlfredPay
  sends fiatAccountId, BR sends only the user's PIX key (taxId/receiverTaxId are
  derived server-side) via a new PIX-key field, EU sends the receiving wallet
  (anchor email derived server-side).
- Remove the orphaned recipient mock machinery (store recipient state/methods,
  ensureSelfRecipient, recipientFlow simulate, SEED_RECIPIENTS, invite-code/
  profile mocks); persist bumped to v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive the approved-corridor gate from GET /v1/onboarding/status instead of
the mocked account onboardings:

- onboarding.service.ts + useApprovedCorridors hook derive the set of corridors
  the authenticated profile is provider-approved for (rail→corridor, with a
  provider+country fallback for null rails).
- useRecipients gates self-recipient synthesis on that real set and returns it
  so the recipients page can offer only invite-able corridors.
- recipients.tsx builds its "Add recipient" corridors from the real approved
  set rather than the mock account's selectedCorridors/onboardings.

Self-recipients (AlfredPay fiat accounts, BR/EU entries) and invite corridors
now reflect the user's actual provider_customers/kyc_cases, not seed data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the mock Zustand store and seed data (SEED_ACCOUNTS/SEED_TRANSACTIONS/
SEED_RECIPIENTS) and drive every surface from the authenticated session:

- Account identity is derived from the Supabase session + GET /v1/onboarding/status
  (type + per-corridor status); no account switcher (one session = one account).
- Transactions come from GET /v1/ramp/history/:walletAddress for the connected
  wallet (transaction.mappers + useTransactions); the transfer actor invalidates
  that query instead of writing a local row.
- Onboarding is a redirect: the mock KYC/KYB wizard, headless/external machines,
  country-select and add-corridor are deleted; overview shows real per-corridor
  status and "Start" opens the widget (?kybLocked=<region> via VITE_WIDGET_URL).
- Funding drops the fake Privy "embedded wallet" (mockPayinAddress) — the ramp is
  signed by the connected wallet, so both funding paths resolve to it.
- Remove now-dead helpers (routeFor/OnboardingRoute, simulated notify* helpers).

Third-party (non-self) recipients remain visible-but-disabled ("coming soon");
that is the only intentional restriction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the provider-status rollup so submitted-and-under-review statuses
(Mykobo PENDING, AlfredPay UserCompleted/Verifying, Avenia Requested) map
to a distinct `in_review` state instead of collapsing into `pending`,
which still means "awaiting the customer". Thread it through the
dashboard's status mapping.

Also point onboardingUrl at /widget, which the widget actually serves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan routed both sender and recipient onboarding to the widget via
?kybLocked=, and a later commit deleted the dashboard's onboarding wizards
on that basis. That bounced an authenticated sender to another origin with
no return path. Senders now onboard in the dashboard again; recipients keep
the widget link-out for now.

Restores OnboardingWizard / HeadlessFlow / ExternalFlow / WizardStepFields /
DocumentDropzone and their state machines as they were, plus routeFor() and
the CorridorCard route hints.

The wizard submits nothing, so GET /v1/onboarding/status cannot see it. A
session-only useOnboardingOverrideStore overlays wizard-advanced statuses on
the aggregator's real ones so the corridor card advances; a reload drops back
to real provider status. That store is scaffolding and is deleted when the
wizard drives the real KYC machines.

Plan §6 is rewritten accordingly (sender in-dashboard, recipient via widget),
and the follow-up plan gains the reuse-vs-port analysis for lifting the
widget's KYC state machines. Also records two gaps: sender onboarding is
mocked end-to-end, and the widget has no KYB region for EU or AR, so
recipient onboarding is unreachable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ract

AR was absent from KYB_REGIONS by omission, not by decision. alfredpayKyc
already handled AR everywhere it handled MX/CO (plus an AR-only selfie
upload), ArKycFormScreen existed, KYC_CHILD_BY_FIAT mapped ARS to it, and
AlfredPayCountry.AR was already accepted server-side. Adding the region
entry plus both locale strings makes ?kybLocked=AR resolve.

EU stays excluded for the documented reason: Mykobo is individual-KYC-only
and needs a connected wallet, so it cannot complete a quote-less KYB deep
link. Corrects the mappers.ts comment that lumped AR in with EU.

Follow-up plan §3 is reframed. "Move to shared" and "import the file from
the frontend" both fail for the same reason: the machines import the
frontend's module-scoped api-client and provider services, and
vortex-frontend is private with no exports. The blocker is coupling, not
packaging. Proposal is to inject the provider service at the machine
boundary, then extract to a new packages/kyc, starting with alfredpayKyc
(zero ramp coupling, four corridors) so the widget's ramp machine is
untouched.

No server behavior changes, so no security-spec update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Alfredpay KYC/KYB state machine out of the widget into a new
@vortexfi/kyc package so the dashboard can run the real verification flow
instead of a visual mock. Three couplings had to be inverted for the machine
to leave the widget:

- the module-scoped AlfredpayService import became an injected AlfredpayKycApi
  port (the machine no longer boots a Supabase client on import, so the test's
  auth mock is gone);
- window.open became an injected openVerificationUrl (no DOM in the package);
- AlfredpayKycContext no longer extends RampContext — the machine never read a
  ramp field, so kyc.states.ts now passes only { country, business }.

The zod schemas and phone normalisers move into the package too: both apps
resolve zod 4.4.3, and this is the one place a dashboard/widget divergence
would silently build a payload Alfredpay rejects. The widget's form screens now
import them.

Dashboard: the mock wizard collected no data (WizardStepFields submits nothing;
DocumentDropzone has no file input), so real screens were written —
KycFormScreen (MX/CO/AR), DocumentUploadScreen (real uploads, AR selfie), and
AlfredpayKycFlow mapping machine states to screens. OnboardingWizard routes
individual KYC on Alfredpay corridors to the real machine; everything else,
including Alfredpay company KYB, stays mocked (split by account.type). The
dashboard apiClient now passes FormData through for multipart uploads, and the
real flow refetches onboarding status rather than writing the mock override.

Tests: packages/kyc has 36 tests (18 machine incl. one new link-seam case,
18 schema). Frontend suite drops exactly the 17 moved tests; no regressions.
Per-app typecheck clean; both apps build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gianfra-t and others added 12 commits July 9, 2026 12:53
apps/dashboard had no tests. Port the frontend's hermetic Playwright setup
(page.route-mocked backend, no API server, database, or chain access) and
cover the auth surface: the email/OTP login flow incl. a rejected code, and
the _app route gate in both directions.

The dashboard gets its own config rather than a project inside the frontend's:
it runs its own Vite server on 5174 under a /dashboard/ base path and consumes
a different set of endpoints. Notes:

- Specs other than login skip the OTP walk by seeding the session into
  localStorage before app scripts run; useAuthStore reads it at module init.
  The login spec asserts the flow writes exactly that session, so the shortcut
  cannot drift from what the app actually stores.
- mockBackend records unmatched API paths and auth-gate.spec asserts the list
  is empty, so a newly-called endpoint fails the suite instead of escaping to
  a real server.
- webServer passes --host 127.0.0.1: vite's default binds IPv6 ::1 only, which
  Playwright's url check never reaches.
- Biome now excludes test-results/ and playwright-report/. The root .gitignore
  already covered the frontend's, but Biome resolves ignores relative to its
  invocation directory, so a workspace-local `verify` still linted them.

Nightly and non-PR-blocking, matching the existing E2E policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce docs/dashboard-app-spec.md: purpose, user stories collected from
the dashboard routes/domain/machines, and high-level strategy.

The dashboard's core feature is a third-party payment funded by either
crypto or fiat. Fiat funding is not a ramp — RampDirection is BUY|SELL,
one fiat side each — so it is an onramp chained to an offramp, and that
composition is still an open design question.

Decide invited-recipient onboarding (followup plan item 3): the invite link
opens the widget at ?kybLocked=<country>&invite=<token>. The widget already
has OTP login and the shipped KYC/KYB flows; it gains a redemption step.
No /invite route in the dashboard. A second login for recipients who later
use the dashboard is the accepted cost this iteration.

The token is a bearer capability (24 random bytes, stored hashed, shown
once), not the invitation id, and it does not authenticate — accept needs
both a session and the token, forcing authenticate -> accept -> KYC.

Deprecate dashboard-full-product-connection.md in favor of the spec,
retaining it for migration phasing and open decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Accepting an invite burned it: reopening the link mid-KYC returned 409
INVITE_ALREADY_ACCEPTED, so a recipient who closed the tab could not get
back into onboarding. Worse, an expiry passing after acceptance marked the
row expired and stranded a relationship that already existed — and KYC can
outlast the 14-day TTL.

acceptInvite is now idempotent for the profile that accepted:

- The invite binds to accepted_by_profile_id on first acceptance. Any other
  profile presenting the token still gets 409.
- Expiry closes a pending invite only; re-entry survives it.
- Re-entry returns 200 with the existing relationship, does not re-notify
  the sender, does not move accepted_at, and does not revive an archived
  relationship (the old !created path unconditionally set active, letting a
  recipient silently un-archive themselves).

Sender revocation (410) and a blocked relationship (409) remain the kill
switches. The transaction body was already idempotent; only the guards
changed.

Security spec: invariant 3 no longer claims invites are single-use — they
bind to one recipient — plus a new 3a for re-entry, and the threat model now
states that an intercepted link is only useful before the intended recipient
redeems it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the dashboard's money path: a SELL offramp of USDC-on-Polygon to an
MXN payout account, against the mocked backend and injected mock wallet.
The signing is real — the page generates ephemeral keypairs and viem
serializes EIP-1559 transactions offline — and the spec catches them at the
network boundary: all three ephemeral txs arrive as raw 0x02 payloads on the
first /ramp/update, the user-owned squidRouterNoPermitTransfer is broadcast
by the wallet and its hash reported in a second update, then start + polling
reach a terminal phase while the form navigates to /transactions.

A second test pins payout-account selection: the mock serves two saved fiat
accounts, and choosing the non-default one must register against that
fiatAccountId — a broken selector would silently pay the wrong account.

Hermeticity is enforced, not assumed. A catch-all route aborts every request
outside the app origin that is not in an explicit third-party blocklist
(WalletConnect/Reown, ConnectKit's Family probe, Coinbase Wallet SDK, Google
Fonts), and unmatched API paths 404. Both lists are recorded and asserted
empty, so a new endpoint or a changed viem default RPC URL fails the suite
instead of silently reaching the network — which is how the real Polygon
default (polygon.drpc.org, not polygon-rpc.com) was caught.

mockWallet.ts is copied from apps/frontend/e2e/support with the tx-hash
constant exported for the reported-hash assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive real Alfredpay MX individual KYC end to end against the mocked backend:
add the corridor, create the customer, fill the form, upload the ID
documents, and reach the "In review" screen. The KYC machine itself is
unit-tested in packages/kyc, so this covers the dashboard's wiring around it —
the six-screen flow and how approval surfaces.

Three variants exercise the approval seam, each a different mechanism:
- approval arrives while the wizard stays open (the machine's own status poll
  advances it to Approved, and the completion toast fires);
- "Continue in background" tears the machine down, and the corridor card flips
  to approved with no reload, purely via the 15s onboarding-status refetch;
- the wizard is dismissed mid-review and reopened, resuming into In review and
  then approval.

Approval timing is deterministic: the mock's route handlers are Node closures,
so the spec flips a kyc.approved flag between assertions and the browser's next
poll observes it. GET /onboarding/status reflects KYC progress so the card
tracks it; reflectOnboarding:false keeps the aggregator behind the provider so
the reopen variant's card action stays enabled (the UI disables it once a
corridor reads in-review).

Verified the background variant is not vacuous by disabling the
onboarding-status refetchInterval: that variant fails while the other two pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants