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-ruby studio · code
|
|
@greptile review |
ce99ce4 to
79c4671
Compare
9c598bf to
8b2b873
Compare
|
@greptile review |
1 similar comment
|
@greptile review |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
1bca871 to
9689a50
Compare
9689a50 to
3267da9
Compare
|
@faraday review this |
|
📌 Bolt Status 2026-07-09 06:52:06 UTC — ⚡ Agent |
3267da9 to
513676e
Compare
…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
af68ac7
Compare
…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
d71d44d
Compare
## 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_01VZsLXjVaJZbjjjoswBmhMu
94e5d2f to
b7f7e5b
Compare
3ab6065 to
9fc8846
Compare
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

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