Skip to content

feat(sca): SCA management surface — enrollment, login/session, beneficiary trust, 2FA reset#600

Open
jklein24 wants to merge 7 commits into
feat/striga-scafrom
feat/striga-sca-management
Open

feat(sca): SCA management surface — enrollment, login/session, beneficiary trust, 2FA reset#600
jklein24 wants to merge 7 commits into
feat/striga-scafrom
feat/striga-sca-management

Conversation

@jklein24

@jklein24 jklein24 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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 — 409 for 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

  • Factor enrollment (design PR3/PR4): POST /customers/{id}/sca/factors/totp (+/confirm), .../passkey (+/confirm), GET .../sca/factors, DELETE .../passkey/{credentialId}.
  • Login / 180-day session (PR5): POST .../sca/login/start (+/complete); POST .../sca/record-event.
  • Beneficiary trust (PR6): POST .../external-accounts/{extId}/trust (issues the challenge + whitelistedId), .../trust/confirm, .../untrust/confirm — the whitelisting exemption that lets recurring payees skip per-transaction SCA.
  • 2FA reset / recovery (PR7): POST .../sca/factors/reset (201) → GET .../reset/{resetId} (poll to LIVENESS_PASSED) → POST .../reset/{resetId}/complete.

Reuses ScaFactor / ScaChallenge / ScaAuthorization from #558; confirm/login bodies carry the code | passkeyAssertion + origin proof, and beneficiary confirm threads whitelistedId back so confirm never re-whitelists (no duplicate SMS). TwoFactorResetStatus / ScaLoginComplete document their provider-passthrough terminal values (LIVENESS_PASSED / SUCCESS).

NOT in this PR

  • Per-transaction authorization — the challenge on a debit, the authorize / resend endpoints, and inline scaAuthorization / scaFactor — that's feat(sca): add Strong Customer Authentication surface #558.
  • The sparkcore backend for these endpoints (separate stack), and client-side WebAuthn ceremony logic (Grid relays opaque passkey blobs; it performs no crypto).

Known gaps / follow-ups (tracked in the design doc addendum)

  • Untrust has confirm but no start, and there is no trust-list read — a caller who loses whitelistedId (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.
  • Reset status documents LIVENESS_PASSED / PENDING but no terminal failure value — infinite-poll risk (F10).
  • Distinct SCA 409 codes (SCA_NOT_REQUIRED vs NO_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=main PR #558; they do not run here because this PR targets feat/striga-sca.)

Notes

  • Field shapes mirror the sparkcore implementation (the source of truth until the SDK regenerates). A few Striga response bodies (login-complete, reset-status/complete, passkey list/delete) are typed from the sparkcore 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

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
grid-flow-builder Ignored Ignored Preview Jul 16, 2026 8:04pm
grid-wallet-demo Ignored Ignored Preview Jul 16, 2026 8:04pm

Request Review

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

✱ Stainless preview builds for grid

This PR will update the grid SDKs with the following commit messages.

cli

chore(internal): regenerate SDK with no functional changes

go

chore(internal): regenerate SDK with no functional changes

kotlin

chore(internal): regenerate SDK with no functional changes

openapi

feat(api): add SCA factor management, login, 2FA reset, beneficiary trust endpoints

php

chore(internal): regenerate SDK with no functional changes

python

chore(internal): regenerate SDK with no functional changes

ruby

chore(internal): regenerate SDK with no functional changes

typescript

chore(internal): regenerate SDK with no functional changes

Edit this comment to update them. They will appear in their respective SDK's changelogs.

grid-ruby studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅build ⏭️ (prev: build ✅) → lint ✅test ✅

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-openapi studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-typescript studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅build ⏭️ (prev: build ✅) → lint ⏭️ (prev: lint ❗) → test ✅

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-cli studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ⏭️ (prev: build ❗) → lint ⏭️ (prev: lint ❗) → test ❗

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-python studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅build ⏭️ (prev: build ✅) → lint ⏭️ (prev: lint ❗) → test ❗

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-kotlin studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ⏭️ (prev: build ✅) → lint ⏭️ (prev: lint ✅) → test ❗

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-go studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅build ⏭️ (prev: build ✅) → lint ❗test ❗

go get github.com/stainless-sdks/grid-go@d161b335c9029c8281fc9283213167749f86c273
New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`
grid-php studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ✅lint ✅test ✅

New diagnostics (14 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/confirm`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /sca/factors/{credentialId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/start`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/login/complete`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/record-event`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /sca/factors/reset/{resetId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /sca/factors/reset/{resetId}/complete`

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-16 20:09:53 UTC

@jklein24

Copy link
Copy Markdown
Contributor Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

\m/ ⚡ RIFF OF THE SPEC DESCENDS ⚡ \m/
    |===========================================|
    |  S  C  A     M  A  N  A  G  E  M  E  N  T |
    |  ~~ THRASH THE ENROLLMENT FLOWS ~~        |
    |===========================================|
              \m/            \m/

This PR adds the EU SCA management surface — factor enrollment (TOTP + passkey), the 180-day login/session flow, beneficiary trust/untrust, and 2FA liveness-gated reset — as 13 new OpenAPI paths and 22 new schemas, completing design PR3–PR7 that stacked on the per-transaction core in #558.

  • Factor enrollment & management: POST .../sca/factors/totp (+/confirm), .../passkey (+/confirm), GET .../sca/factors, DELETE .../passkey/{credentialId} — all returning 409 for non-SCA regions and mirroring sparkcore route shapes.
  • Login, security events, and 2FA reset: POST .../sca/login/start (+/complete), POST .../sca/record-event, and the POST .../sca/factors/reset → poll → /complete liveness flow with documented LIVENESS_PASSED sentinel after prior review iteration.
  • Beneficiary trust/untrust: POST .../trustPOST .../trust/confirm and POST .../untrust/confirm with a shared BeneficiaryTrustConfirmRequest; the whitelistedId field the PR description says is issued by the start and threaded on confirm is absent from both schemas, and anyOf in the confirm request (and ScaLoginCompleteRequest) should be oneOf to enforce mutual exclusion between OTP and passkey proof types.

Confidence Score: 4/5

Mergeable after resolving two spec gaps: the missing whitelistedId field in the trust start/confirm schemas and the anyOf-vs-oneOf discriminator ambiguity.

The trust-start response schema omits the whitelistedId the PR description says is issued and threaded back on confirm — if the sparkcore backend requires it, every trust-confirm call will fail silently. Separately, anyOf in both confirm-request schemas permits a caller to submit both an OTP code and a passkey assertion simultaneously, leaving the server to receive an ambiguous proof payload with no spec guidance on which to use.

BeneficiaryTrustStart.yaml, BeneficiaryTrustConfirmRequest.yaml, and ScaLoginCompleteRequest.yaml need attention before merge.

Important Files Changed

Filename Overview
openapi/components/schemas/sca/BeneficiaryTrustStart.yaml Trust-start response schema is missing the whitelistedId field the PR description says it returns; the confirm request is also missing it as an input.
openapi/components/schemas/sca/ScaLoginCompleteRequest.yaml Uses anyOf instead of oneOf for the OTP/passkey proof discriminator, allowing both proof types to be submitted in the same request; same issue in BeneficiaryTrustConfirmRequest.
openapi/components/schemas/sca/BeneficiaryTrustConfirmRequest.yaml Same anyOf-vs-oneOf issue as ScaLoginCompleteRequest; also missing whitelistedId that the PR description says must be threaded back on confirm.
openapi/paths/customers/customers_{customerId}_sca_factors_reset.yaml 2FA reset start endpoint correctly returns 201 and threads resetId through to status/complete; no issues found.
openapi/paths/customers/customers_{customerId}external-accounts{externalAccountId}_trust.yaml Trust-start endpoint has no request body (correctly inferred from path) but returns a BeneficiaryTrustStart that omits the whitelistedId the PR description says is issued here.
openapi/paths/customers/customers_{customerId}external-accounts{externalAccountId}_trust_confirm.yaml Trust-confirm endpoint structure is clean; underlying BeneficiaryTrustConfirmRequest schema has the anyOf/whitelistedId issues flagged separately.
openapi/openapi.yaml All 13 new path refs wired correctly; ordering and grouping is consistent with existing paths.
.redocly.lint-ignore.yaml Suppressions added for anyOf branches referencing parent-level properties; comment explains the valid JSON Schema rationale.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant G as Grid API
    participant S as Striga (SCA Provider)

    Note over C,S: Factor Enrollment — TOTP
    C->>G: "POST /customers/{id}/sca/factors/totp"
    G->>S: start TOTP enrollment
    S-->>G: secret + totpUri
    G-->>C: "TotpEnrollmentStart {secret, secretBase32Encoded, totpUri}"
    C->>G: "POST /sca/factors/totp/confirm {secret, code}"
    G->>S: confirm TOTP
    S-->>G: recovery codes
    G-->>C: "TotpEnrollmentConfirmResponse {recoveryCodes}"

    Note over C,S: SCA Login — SMS_OTP
    C->>G: "POST /customers/{id}/sca/login/start {factor: SMS_OTP}"
    G->>S: dispatch OTP
    S-->>G: challengeId + expiresAt
    G-->>C: "ScaLoginStart {factor, challengeId, expiresAt}"
    C->>G: "POST /sca/login/complete {factor, challengeId, code}"
    G->>S: verify code
    S-->>G: status
    G-->>C: "ScaLoginComplete {status: SUCCESS}"

    Note over C,S: Beneficiary Trust
    C->>G: "POST /customers/{id}/external-accounts/{extId}/trust"
    G->>S: start trust
    S-->>G: scaChallenge (optional)
    G-->>C: "BeneficiaryTrustStart {scaChallenge?}"
    C->>G: "POST .../trust/confirm {challengeId?, code|passkeyAssertion+origin}"
    G->>S: confirm trust
    S-->>G: "trusted=true"
    G-->>C: "BeneficiaryTrustConfirm {trusted: true}"

    Note over C,S: 2FA Reset
    C->>G: "POST /customers/{id}/sca/factors/reset {factor}"
    G->>S: start liveness
    S-->>G: resetId + liveness handles
    G-->>C: "TwoFactorResetStart {resetId, livenessAccessToken?, verificationLink?}"
    loop Poll until LIVENESS_PASSED
        C->>G: "GET /sca/factors/reset/{resetId}"
        G->>S: check status
        S-->>G: status
        G-->>C: "TwoFactorResetStatus {status}"
    end
    C->>G: "POST /sca/factors/reset/{resetId}/complete"
    G->>S: complete reset
    G-->>C: 204 No Content
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Caller
    participant G as Grid API
    participant S as Striga (SCA Provider)

    Note over C,S: Factor Enrollment — TOTP
    C->>G: "POST /customers/{id}/sca/factors/totp"
    G->>S: start TOTP enrollment
    S-->>G: secret + totpUri
    G-->>C: "TotpEnrollmentStart {secret, secretBase32Encoded, totpUri}"
    C->>G: "POST /sca/factors/totp/confirm {secret, code}"
    G->>S: confirm TOTP
    S-->>G: recovery codes
    G-->>C: "TotpEnrollmentConfirmResponse {recoveryCodes}"

    Note over C,S: SCA Login — SMS_OTP
    C->>G: "POST /customers/{id}/sca/login/start {factor: SMS_OTP}"
    G->>S: dispatch OTP
    S-->>G: challengeId + expiresAt
    G-->>C: "ScaLoginStart {factor, challengeId, expiresAt}"
    C->>G: "POST /sca/login/complete {factor, challengeId, code}"
    G->>S: verify code
    S-->>G: status
    G-->>C: "ScaLoginComplete {status: SUCCESS}"

    Note over C,S: Beneficiary Trust
    C->>G: "POST /customers/{id}/external-accounts/{extId}/trust"
    G->>S: start trust
    S-->>G: scaChallenge (optional)
    G-->>C: "BeneficiaryTrustStart {scaChallenge?}"
    C->>G: "POST .../trust/confirm {challengeId?, code|passkeyAssertion+origin}"
    G->>S: confirm trust
    S-->>G: "trusted=true"
    G-->>C: "BeneficiaryTrustConfirm {trusted: true}"

    Note over C,S: 2FA Reset
    C->>G: "POST /customers/{id}/sca/factors/reset {factor}"
    G->>S: start liveness
    S-->>G: resetId + liveness handles
    G-->>C: "TwoFactorResetStart {resetId, livenessAccessToken?, verificationLink?}"
    loop Poll until LIVENESS_PASSED
        C->>G: "GET /sca/factors/reset/{resetId}"
        G->>S: check status
        S-->>G: status
        G-->>C: "TwoFactorResetStatus {status}"
    end
    C->>G: "POST /sca/factors/reset/{resetId}/complete"
    G->>S: complete reset
    G-->>C: 204 No Content
Loading

Reviews (7): Last reviewed commit: "docs(sca): enumerate record-event values..." | Re-trigger Greptile

Comment thread openapi/components/schemas/sca/BeneficiaryTrustConfirmRequest.yaml
Comment thread openapi/components/schemas/sca/TwoFactorResetStatus.yaml
Comment thread openapi/components/schemas/sca/TotpEnrollmentConfirmRequest.yaml
Comment thread openapi/components/schemas/sca/TwoFactorResetStatus.yaml
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 9c598bf to 8b2b873 Compare June 17, 2026 22:35
@jklein24

Copy link
Copy Markdown
Contributor Author

@greptile review

1 similar comment
@jklein24

Copy link
Copy Markdown
Contributor Author

@greptile review

jklein24 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 1bca871 to 9689a50 Compare July 8, 2026 20:57
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 9689a50 to 3267da9 Compare July 8, 2026 23:57
@lightsparkdev lightsparkdev deleted a comment from greptile-apps Bot Jul 9, 2026
@jklein24
jklein24 marked this pull request as draft July 9, 2026 06:50
@jklein24

jklein24 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@faraday review this

@ls-bolt

ls-bolt Bot commented Jul 9, 2026

Copy link
Copy Markdown

📌 Bolt Status

2026-07-09 06:52:06 UTC — ⚡ Agent bright-palladium(#1) started


Feedback

Comment thread openapi/components/schemas/sca/BeneficiaryTrustConfirmRequest.yaml Outdated
Comment thread openapi/components/schemas/sca/RecordSecurityEventRequest.yaml
Comment thread openapi/components/schemas/sca/BeneficiaryTrustConfirmRequest.yaml Outdated
Comment thread openapi/components/schemas/sca/TwoFactorResetStart.yaml Outdated
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 3267da9 to 513676e Compare July 9, 2026 07:20
jklein24 added a commit that referenced this pull request Jul 9, 2026
…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>
jklein24 added a commit that referenced this pull request Jul 10, 2026
…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>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 763b88b to af68ac7 Compare July 10, 2026 07:52
Comment thread openapi/components/schemas/sca/RecordSecurityEventRequest.yaml Outdated
Comment thread openapi/components/schemas/sca/BeneficiaryTrustStart.yaml Outdated
jklein24 added a commit that referenced this pull request Jul 14, 2026
…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>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from af68ac7 to d71d44d Compare July 14, 2026 06:29
jklein24 added a commit that referenced this pull request Jul 14, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread openapi/openapi.yaml Outdated
/customers/{customerId}/external-accounts/{externalAccountId}/trust:
$ref: paths/customers/customers_{customerId}_external-accounts_{externalAccountId}_trust.yaml
/customers/{customerId}/external-accounts/{externalAccountId}/trust/confirm:
$ref: paths/customers/customers_{customerId}_external-accounts_{externalAccountId}_trust_confirm.yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does untrust need a verify?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could it be like deleted a trusted account resource?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe i SCA when adding the external account?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we already have that below?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry 3 different thoughts

1. If we model trusted external accounts as a resource eg /sca/trusted-external-accounts/{id}
Could untrusting it be a deletion.

  1. Does the untrust process also need a SCA or can it be a simple delete it?
  2. Does it make sense to add SCA to external account creation directly? so it becomes create external account + verify sca. Or i guess this SCA is not always required? let me finish reading the SCA thread.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jklein24 added a commit that referenced this pull request Jul 15, 2026
…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>
jklein24 added a commit that referenced this pull request Jul 15, 2026
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>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 2843a5c to 349f1d0 Compare July 15, 2026 20:11
jklein24 added a commit that referenced this pull request Jul 15, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <noreply@anthropic.com>
jklein24 added a commit that referenced this pull request Jul 16, 2026
…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>
jklein24 added a commit that referenced this pull request Jul 16, 2026
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>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 349f1d0 to 65c265a Compare July 16, 2026 07:33
jklein24 added a commit that referenced this pull request Jul 16, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <noreply@anthropic.com>
jklein24 and others added 6 commits July 16, 2026 00:46
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>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from 65c265a to b55685f Compare July 16, 2026 07:49
jklein24 added a commit that referenced this pull request Jul 16, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <noreply@anthropic.com>
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from b55685f to b906634 Compare July 16, 2026 08:26
jklein24 added a commit that referenced this pull request Jul 16, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <noreply@anthropic.com>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reset 2FA completion, if it's SMS, this will take in a new mobile number

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Confirmed it against the docs and added it — complete now takes a mobile object (countryCode + number), required for SMS resets. Docs updated to match.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth enumerating our REJECTED/EXPIRED as terminal so clients don't poll a failed status? our status response also carries factor, enrollmentStatus, expiresAt, completedAt, which are worth surfacing. Also: we rate limit reset initiation (5 per 24h), might be worth adding a 429 for that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, all in. status is an enum now, with COMPLETED/REJECTED/EXPIRED marked terminal so clients stop polling a dead reset, and it surfaces factor, enrollmentStatus, expiresAt, completedAt. Added a 429 on reset initiate for the 5/24h limit too. Pulled the exact status and enrollmentStatus value sets from your docs.

Comment on lines +11 to +14
Begin trusting (whitelisting) an external account so future sends to it can
skip the per-transaction SCA ceremony. Returns the `scaChallenge` to satisfy
(when one is issued). Complete with
`POST /customers/external-accounts/{externalAccountId}/trust/confirm`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ronniebhatt please correct me if I'm wrong here but a trusted eur/usdc payout will still get a challenge and what's skipped is the dynamic linking enforcement no?

Also only USDC addresses can be "trusted" right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fixed the USDC part — docs now say only USDC addresses can be trusted (dropped the old "IBAN or crypto address" framing). On the challenge-vs-dynamic-linking piece I think you're right, but I'd rather have @ronniebhatt confirm the exact semantics before I reword it, since it's his flow. Leaving that half open for him.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the lockout state is invisible? HTTP 423 (code 31105). We return {event, suspended?, lockedUntil?, failedAttempts?} so might be worth surfacing lockedUntil to tell the user to try again in XX

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, the lockout state was totally invisible. Fixed — record-event returns { suspended, lockedUntil, failedAttempts } now (200 instead of 204), plus a 423 (ACCOUNT_LOCKED) that surfaces lockedUntil/failedAttempts so integrators can tell the user when to retry. Added the same 423 to login-complete since a locked user hits it there too.

Comment on lines +12 to +14
Begin enrolling a WebAuthn passkey factor for the customer. Returns opaque
WebAuthn registration `options`; pass them to the device's WebAuthn API and
submit the resulting credential via

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember our current implementation is one passkey per user (We return error 100002 when a user tries to enrol a new passkey)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted and documented. Enrolling a passkey now returns a 409 (PASSKEY_ALREADY_ENROLLED) on a second one, and the docs call out the one-per-user limit plus the fact that the passkey side of the factor list is 0-or-1.

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.
@jklein24
jklein24 force-pushed the feat/striga-sca-management branch from b906634 to 94e5d2f Compare July 16, 2026 20:04
jklein24 added a commit that referenced this pull request Jul 16, 2026
Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers
in a region where SCA is required (EU)' / 'customers outside SCA-regulated
regions' instead of 'whose payment provider requires SCA'.

Co-Authored-By: Claude <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