Uh oh!
There was an error while loading. Please reload this page.
feat(sca): SCA management surface — enrollment, login/session, beneficiary trust, 2FA reset - #600
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
✱ Stainless preview builds for gridThis PR will update the cli go kotlin openapi php python ruby typescript ✅ grid-rubystudio · code
|
jklein24
commented
Jun 17, 2026
@greptile review |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
9c598bf to
8b2b873Comparejklein24
commented
Jun 18, 2026
@greptile review |
1 similar comment
jklein24
commented
Jun 18, 2026
@greptile review |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
1bca871 to
9689a50Compare9689a50 to
3267da9Comparejklein24
commented
Jul 9, 2026
@faraday review this |
📌 Bolt Status 2026-07-09 06:52:06 UTC — ⚡ Agent |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
3267da9 to
513676eCompare…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
763b88b to
af68ac7CompareUh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
af68ac7 to
d71d44dCompare## Summary The **per-transaction SCA authorization surface** — everything needed to gate and authorize an EU (Striga) money-movement debit with an SMS OTP or passkey. This is the compliance-critical core of the Striga SCA work and the **base of the grid-api SCA stack**; the management surface (#600) stacks on top, and the sparkcore backend lands separately. Designed to stay **invisible to every non-SCA (non-EU) caller**. ## Covered by this PR **Shared SCA vocabulary** (defined here, reused by the whole stack): - `ScaFactor` (`SMS_OTP` / `TOTP` / `PASSKEY`) - `ScaChallenge` (`id`, `expiresAt`, `factor`, `availableFactors`, `passkeyAssertionOptions`, `passkeyAllowedOrigins`) - `ScaAuthorization` (exactly one of `code` / `passkeyAssertion`; `origin` required with a passkey assertion) **Per-transaction authorization:** - `PENDING_AUTHORIZATION` status + readOnly `scaChallenge` on `Quote` and `Transaction` (incl. the `OutgoingTransactionStatus` override) — omitted entirely for non-SCA providers. - Resource-scoped authorize endpoints: `POST /quotes/{id}/authorize` → `Quote`, `POST /transactions/{id}/authorize` → `Transaction` (both `409` for non-SCA providers). - SMS code re-send: `POST /quotes/{id}/authorize/resend`, `POST /transactions/{id}/authorize/resend`. - Inline one-shot proof: optional `scaAuthorization` on `execute` / `transfer-out` (satisfy the challenge in the same call). - Optional `scaFactor` on `execute` / `transfer-out` to pick `SMS_OTP` (default) or `PASSKEY` for the per-transaction challenge. ## NOT in this PR — see #600 (stacked on top) The SCA **management** surface (customer-level setup/recovery, not per-payment authorization) lives in #600 and reuses the schemas defined here: - Factor **enrollment** — TOTP + passkey register / list / delete - **Login / 180-day session** — `sca/login/start|complete` - **Beneficiary trust** whitelisting — `external-accounts/{id}/trust`, `.../untrust/confirm` - **2FA reset / recovery** — liveness-gated - Security-event recording — `sca/record-event` Also out of scope: the **sparkcore backend** (separate stack) and any client-side WebAuthn ceremony logic (Grid relays opaque passkey blobs; it performs no crypto). ## Known caveat - The realtime-funding **`202` + withheld-`paymentInstructions`** path on `POST /quotes` is specified here but **not yet reachable** — the backend defers it (Striga has no Grid realtime-funding path today; see design doc addendum item **F6** / open question 2). It's documented so the contract is stable; treat it as inert until that flow ships. ## Stacking `main` → **#558 (this PR)** → #600 (management). #558 is self-contained and independently mergeable — SMS-OTP authorization needs no enrollment or login — so it can merge to `main` on its own. #600 depends on the `ScaFactor` / `ScaChallenge` / `ScaAuthorization` schemas introduced here. ## Invisibility guarantee `scaChallenge`, `PENDING_AUTHORIZATION`, the authorize/resend endpoints, and `scaFactor` engage **only** for customers whose provider requires SCA. Non-EU callers see no new fields, no new statuses, and no behavioral change. ## Validation `npm run lint:openapi` (redocly + spectral) passes; additions are additive / non-breaking ("Detect breaking changes" CI green). ## Design webdev `docs/plans/2026-06-06-striga-sca-design.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
| credentialId: | ||
| type: | ||
| - string | ||
| - 'null' |
There was a problem hiding this comment.
do we need explicit nulls here? I saw this in another PR but always thought that the field would be excluded vs explicitly null'd out
There was a problem hiding this comment.
Yeah, good call — dropped them. These are genuinely omitted for TOTP/SMS_OTP, not sent as null, so plain-optional is the right contract and the explicit null was just wrong here.
fwiw the rule I'm going by: nullable and optional are separate axes. Leave a field out of required when the key can be absent; only add 'null' to the type when the server actually puts "field": null on the wire. A few of the newer sca/agents schemas started reaching for explicit null where the field is really just omitted — that's the inconsistency you're seeing. Fixing those as I hit them.
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Name the provider-reported terminal sentinels (LIVENESS_PASSED for 2FA reset, SUCCESS for login) in the status field descriptions so consumers can write polling/branching logic without out-of-band knowledge, while keeping the field a verbatim provider passthrough rather than a locked enum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
Match the #558 terminology fix: the enrollment / login / trust / reset endpoints now say "customers in a region where SCA is required (e.g. EU)" / "customers outside SCA-regulated regions" instead of "customers whose payment provider requires SCA", and the 409 reads "SCA is not required for this customer". Incidental references to the underlying provider as a system (provider-reported status, risk engine, whitelist handle) are left as-is. Co-Authored-By: Claude <noreply@anthropic.com>
…nt surface To the integrator, Grid is the provider; the underlying provider must never leak into the docs. Reworded provider-reported status -> the (reported) status, the provider's risk engine -> Grid's risk engine, whitelist/liveness handles and 'when the provider does not return one' -> neutral phrasing. Co-Authored-By: Claude <noreply@anthropic.com>
Per review on #600: - RecordSecurityEventRequest.eventType is now an explicit enum (RESET_PASSWORD_COMPLETED | FAILED_LOGIN_ATTEMPT) with each event's effect, instead of a vague free-string "closed vocabulary". - Drop whitelistedId from the beneficiary-trust surface: BeneficiaryTrustStart returns just the scaChallenge, and confirm/untrust identify the beneficiary by the {externalAccountId} already in the path. Requires the backend to persist the externalAccountId -> whitelist handle mapping (tracked follow-up). Co-Authored-By: Claude <noreply@anthropic.com>
Untrust previously had only a confirm step, so an SMS_OTP (default, no
enrollment) or PASSKEY customer had no way to obtain the SCA challenge that
untrust/confirm expects — only a self-served TOTP code worked. Add
POST /customers/{customerId}/external-accounts/{externalAccountId}/untrust
(startBeneficiaryUntrust), mirroring startBeneficiaryTrust: it issues the
scaChallenge that untrust/confirm satisfies. Returns BeneficiaryTrustStart.…docs)
transfer-out has no associated quote and does not offer Strong Customer
Authentication — per-transaction SCA is authorized only on the quote
(POST /quotes/{quoteId}/authorize). Remove the vestigial scaFactor field from
TransferOutRequest and the SCA paragraph from the transfer-out 201 response
description, so the endpoint carries no SCA surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZsLXjVaJZbjjjoswBmhMu94e5d2f to
b7f7e5bCompare
pengying
left a comment
There was a problem hiding this comment.
LGTM 2 general questions
1. how does someone add sms otp
2. do we need -null as a type if the field is optional or are we explicitly setting it to null.
| selects the factor — `TOTP` or `PASSKEY`. `SMS_OTP` is not enrollable (it uses | ||
| the customer's verified phone). Returns the factor-specific material needed to | ||
| finish via `POST /sca/factors/confirm`. | ||
There was a problem hiding this comment.
Is enrolling the concept of trusting a user device / auth method. What triggers the sms otp?
There was a problem hiding this comment.
Yes, it is. With striga, sms is enrolled by default since creating a customer requires sms and email verification via the contact verification API flows. I could definitely document this a bit better here, though. Striga does that at the top of this page: https://docs.striga.com/reference/enrollment. I'll clarify the SMS_OTP line here to indicate that all customers need a verified phone at customer creation in SCA-regulated regions. See the "verifications" routes.
There was a problem hiding this comment.
so then when the customer is created, they provide the phone number as part of customer creation. we auto send a sms otp?
What if they change phone numbers? is that a patch to customer.phone_number that triggers a sms otp? or do we make it explicit here?
…llable These fields are omitted (not sent as null) for TOTP/SMS_OTP factors, so explicit null was the wrong contract. Drop 'null' from the type; the fields stay optional via absence from `required`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015635zZx4bhUJHNBs5WdB4c
Preview deployment for your docs. Learn more about Mintlify Previews.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
…lment Expand the enrollment description to explain that SMS_OTP is not enrolled through POST /sca/factors — every customer in an SCA-regulated region has a verified phone from customer creation (Contact Verification flows), so SMS is always available as a factor. TOTP/PASSKEY remain the explicit opt-in factors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015635zZx4bhUJHNBs5WdB4c
The transfer-out SCA/deprecation language was removed from the endpoint spec in #600, so drop the "being deprecated" claim from the per-transaction guide and stop showing transfer-out as an SCA-challenge-producing path in the overview sequence diagram. transfer-out has no associated quote and offers no SCA-gated EU debits; the guide points EU callers to the quote + execute flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZsLXjVaJZbjjjoswBmhMu
Uh oh!
There was an error while loading. Please reload this page.

Summary
The SCA management surface for EU (Striga) customers — the customer-level setup, session, and recovery flows that sit alongside per-payment authorization (not inside it). This single PR collapses the original design's PR3–PR7 (enrollment, passkey, login/session, beneficiary trust, 2FA reset). Stacks on #558, which defines the per-transaction authorization core and the shared schemas this PR reuses. All endpoints are EU-only —
409for providers that don't require SCA (the invisibility guarantee) and mirror the sparkcore handlers' exact routes + JSON shapes. Surfaced after an audit of #558 found these existed only as sparkcore temp types, not in the spec.Covered by this PR
POST /customers/{id}/sca/factors/totp(+/confirm),.../passkey(+/confirm),GET .../sca/factors,DELETE .../passkey/{credentialId}.POST .../sca/login/start(+/complete);POST .../sca/record-event.POST .../external-accounts/{extId}/trust(issues the challenge +whitelistedId),.../trust/confirm,.../untrust/confirm— the whitelisting exemption that lets recurring payees skip per-transaction SCA.POST .../sca/factors/reset(201) →GET .../reset/{resetId}(poll toLIVENESS_PASSED) →POST .../reset/{resetId}/complete.Reuses
ScaFactor/ScaChallenge/ScaAuthorizationfrom #558; confirm/login bodies carry thecode | passkeyAssertion + originproof, and beneficiary confirm threadswhitelistedIdback so confirm never re-whitelists (no duplicate SMS).TwoFactorResetStatus/ScaLoginCompletedocument their provider-passthrough terminal values (LIVENESS_PASSED/SUCCESS).NOT in this PR
authorize/resendendpoints, and inlinescaAuthorization/scaFactor— that's feat(sca): add Strong Customer Authentication surface #558.Known gaps / follow-ups (tracked in the design doc addendum)
confirmbut nostart, and there is no trust-list read — a caller who loseswhitelistedId(or needs the untrust OTP re-sent) is stuck (addendum F4). The trusted-beneficiary exemption benefit (a trusted payee actually skipping per-tx SCA) is also not yet realized in the backend.statusdocumentsLIVENESS_PASSED/PENDINGbut no terminal failure value — infinite-poll risk (F10).409codes (SCA_NOT_REQUIREDvsNO_PENDING_SCA_CHALLENGE) are not yet modeled (F9).Validation
npm run lint:openapi(redocly + spectral, fail-on-error) passes clean; additions are additive / non-breaking. (The OpenAPI build / breaking-changes / lint CI checks run on the base=mainPR #558; they do not run here because this PR targetsfeat/striga-sca.)Notes
to_dict()since Striga's docs only cover the start side — flagged for sandbox verification.Stacking
main→ #558 (per-transaction) → #600 (this PR). Merges after #558; depends on the schemas #558 introduces.🤖 Generated with Claude Code