Skip to content

Sign in through Fizzy's real magic-link contract in every SDK - #215

Merged
jeremy merged 12 commits into
mainfrom
fix-magic-link-flows
Oct 18, 2026
Merged

jeremy merged 12 commits into
mainfrom
fix-magic-link-flows

Conversation

@jeremy

@jeremy jeremy commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Fizzy can't be signed into through the magic-link flow in five of the six SDKs, and nothing generated from the model can complete a sign-in either. This PR corrects the model to match Fizzy and moves every SDK's flow onto the generated routes and types, the way Rust's flow already worked.

This changes an auth flow in every SDK and breaks their public magic-link APIs, so it's held for @jeremy.

What Fizzy does

I read this from ~/Work/basecamp/fizzy at db36756, together with its controllers and tests. The SDK's api-provenance.json pins 08395fab, and since then the only auth change in Fizzy is unrelated (#3093).

  • POST /session(.json) takes email_address and answers 201 {pending_authentication_token}. It also sets that token as an HttpOnly pending_authentication_token cookie (via_magic_link.rb:32,43-50).
  • POST /session/magic_link(.json) takes params.expect(:code) (magic_links_controller.rb:32) and reads the pending sign-in only from the cookie (via_magic_link.rb:60-62). It answers 200 {session_token, requires_signup_completion}, or 401 when the cookie is missing or the code is wrong.
  • POST /signup/completion(.json) takes {full_name} and answers an empty 201.
  • DELETE /session answers 204.
  • There's no /api scope and no /sessions route, so the old SDK paths 404.

What was wrong

Path Body Answer
Model right redeem said token; Fizzy reads code CreateSession modelled 200 (Fizzy sends 201), DestroySession 200 (sends 204); the cookie wasn't documented
Go /api/v1/sessions, /api/v1/sessions/redeem: 404 {email}, {token} dropped the pending token; no cookie
TypeScript /sessions, /sessions/redeem: 404 {email}, {token} discarded the create answer; no cookie
Ruby /sessions, /sessions/redeem: 404 {email}, {token} no cookie
Swift /sessions.json, /sessions/redeem.json: 404; signup path right {email}, {token}, signup {name} the pending-token decode failed; signup expected a body
Kotlin /api/sessions…: 404 redeem sent the pending token and no code; signup {name} redeem's argument was the wrong value
Rust right (generated routes) sent {code, token} to cover the model's token right, and it sent the cookie

What changes

  • Spec (spec/fizzy.smithy): RedeemMagicLinkInput.code replaces token. CreateSession is now code: 201 and DestroySession code: 204. The operation docs say which cookie each side carries.
  • Regeneration: every language regenerates. Rust's names.toml keeps the code redacted, as the token was.
  • TypeScript generator: a multi-line operation description now keeps the JSDoc * gutter on every line.
  • Conformance: paths.json now sends {code} and expects 201 and 204. The TypeScript, Swift and Kotlin runners send code.
  • Every flow now goes through the client's generated sessions operations, so paths, bodies, statuses, error mapping, HTTPS enforcement, body limits and hooks all come from the SDK's shared client.
    • Redeem adds an explicit, percent-encoded Cookie: pending_authentication_token=…, encoded the same way as Rust. None of the flows relies on a cookie jar.
    • Each flow keeps the pending token and can resume one that another process started.
    • Redeeming with no sign-in pending is a usage error and sends nothing.

Public API changes (breaking)

  • Go: CreateSession returns *generated.PendingAuthentication. RedeemMagicLink(ctx, code) returns *generated.SessionAuthorization. New: PendingToken() and ResumeMagicLinkFlow(...). Removed: CreateSessionRequest/Response, RedeemMagicLinkRequest/Response and UserID. Login returns (*SessionAuthorization, error).
  • TypeScript: requestLink(email) becomes createSession(emailAddress), and redeemToken(token) becomes redeem(code). New: resume, pendingToken and completeSignup(fullName).
  • Ruby: request_magic_link(email:) becomes create_session(email_address:), and redeem(token:) becomes redeem(code:). New: the pending_token: constructor argument and pending_token.
  • Swift: start(email:) becomes start(emailAddress:) -> PendingAuthentication. redeem(token:) becomes redeem(code:) -> SessionAuthorization. completeSignup(fullName:sessionToken:) now returns nothing. PendingSession, RedeemResult and SignupResult are removed. New: pendingToken and resume.
  • Kotlin: redeemMagicLink(code) takes the emailed code; before, it took the pending token. completeSignup(sessionToken, fullName) changes too. New: pendingToken and resume.
  • Rust: RedeemMagicLinkRequestContent.token is renamed code. cargo-semver-checks reports this as major, and that CI step is advisory.

None of these SDKs has a CHANGELOG or MIGRATING file, so this list is the migration note. The old flows couldn't sign in, so a caller has nothing working to migrate from.

fizzy-cli isn't affected. It signs in with its own net/http client and a cookie jar, against exactly the contract described above (internal/commands/signup.go), and it doesn't use any SDK MagicLinkFlow. Once this lands it could drop its copy.

Tests

None of the five rewritten flows had tests before. Each now has one that walks the whole exchange against a fake Fizzy: the paths, the exact bodies, no cookie on create, the encoded cookie on redeem, 201 handling, resume, a 401 mapped to an auth error and sent once, and a usage error when nothing is pending. Swift and Kotlin also cover an empty-201 signup.

  • Each leg checked that its tests fail against the old flow.
  • Ruby swapped the old flow back in: 6 of its 7 tests fail. The seventh, the plain-HTTP refusal, passes either way.
  • TypeScript swapped the old flow back in: all 7 fail.
  • Kotlin swapped it back in: the 3 tests that compile against the old API fail, and the other 2 don't compile.
  • Go and Swift didn't run the old code. Their tests don't compile against the old API, and the leg reasoned that the old paths and bodies would fail them.
  • Rust's test now matches the body exactly as {"code":"ABC123"}.

Verified locally: make -k check passes everything except audit-check, which is the stale rubric-audit.json date that #214 refreshes. That includes all six conformance runners at 134/134. Nothing was sent to a live Fizzy. I didn't probe staging, because CreateSession sends a real email.

Still open

  • Swift's and Go's flows now build the SDK client, which refuses a non-HTTPS base URL outside localhost. Swift does so with a preconditionFailure. The old flows didn't check.
  • The public CookieAuth in Kotlin and Swift still writes session_token unencoded. I left it as it was.
  • The generated RedeemMagicLink alone still can't complete a sign-in, because the model can't express the cookie. The hand-written flows supply it.

Summary by cubic

Fizzy couldn't be signed into through magic links in five of the six SDKs, and nothing generated from the Smithy model could complete a sign-in either. This PR corrects the model to Fizzy's real contract and moves every SDK's flow onto the generated sessions operations, the way Rust already worked.

What changed

  • RedeemMagicLinkInput.code replaces token; CreateSession and DestroySession now model their true statuses, 201 and 204.
  • The operation docs now describe the pending_authentication_token cookie; each flow sends it explicitly, percent-encoded, and can resume a sign-in another process started.
  • Each rewritten flow has a test covering the full exchange against a fake Fizzy.
  • Swift builds its sessions service directly per request, so a finished flow's client and the cookie it carried are released.
  • A redeem now spends only the sign-in it consumed: the pending token clears after a successful redeem, so a refused code can be retried and a second redeem fails locally instead of hitting Fizzy. Kotlin and Ruby bind each redeem to the token it read and guard the pending state, so a hook that starts another sign-in mid-redeem can't pair the code with a different cookie.
  • The TypeScript flow no longer keeps a session; completeSignup takes the token redeem answered, so an older redeem can't sign one person's sign-up under another's session.

Breaking API changes

  • Go: CreateSession returns *generated.PendingAuthentication; RedeemMagicLink(ctx, code) returns *generated.SessionAuthorization; the hand-written request/response types and UserID are gone.
  • TypeScript: requestLink(email) → createSession(emailAddress), redeemToken(token) → redeem(code); new resume, pendingToken, completeSignup(fullName).
  • Ruby: request_magic_link(email:) → create_session(email_address:), redeem(token:) → redeem(code:); new pending_token: constructor argument and reader.
  • Swift: start(email:) → start(emailAddress:) -> PendingAuthentication, redeem(token:) → redeem(code:) -> SessionAuthorization; old result types removed.
  • Kotlin: redeemMagicLink(code) takes the emailed code, not the pending token; completeSignup takes fullName; new pendingToken and resume.
  • Rust: RedeemMagicLinkRequestContent.token → code.

Written for commit 52d1e99. Summary will update on new commits.

View guided diff Turn on auto-fix

…ions

Sessions::MagicLinksController reads params[:code], not token, and
reads the pending sign-in from the pending_authentication_token cookie
CreateSession sets; CreateSession answers 201 and DestroySession 204.
The model said token, 200 and 200, so nothing generated from it could
complete a sign-in. RedeemMagicLinkInput now carries code, the two
statuses are modelled, and the operations' docs say which cookie each
side of the exchange carries. Every language regenerates; the
conformance fixtures and runners send code and expect 201 and 204, and
Rust's names.toml keeps the code redacted as the token was.
MagicLinkFlow posted {email} to /api/v1/sessions and {token} to
/api/v1/sessions/redeem, neither of which Fizzy routes, and dropped the
pending token the answer carries, so it could not sign anyone in. It
now sends through the generated SessionsService: CreateSession posts
{email_address} to /session.json and keeps the pending token from the
201; RedeemMagicLink posts {code} to /session/magic_link.json with the
token as the pending_authentication_token cookie, escaped as Rack
unescapes it, and answers the generated SessionAuthorization.
PendingToken and ResumeMagicLinkFlow let a sign-in finish in another
process, as Rust's flow does. Like NewClient, the flow now requires
HTTPS outside localhost.

This breaks the flow's API: CreateSession answers
*generated.PendingAuthentication, RedeemMagicLink takes the emailed
code and answers *generated.SessionAuthorization, Login answers that
session too, and the hand-written CreateSessionRequest/Response and
RedeemMagicLinkRequest/Response types, with their user_id, are gone.
fizzy-cli does not use this flow.
MagicLinkFlow posted {email} to /sessions and {token} to
/sessions/redeem, which Fizzy has never routed, and discarded the
pending token CreateSession answers. It now goes through the generated
sessions operations on a client from createFizzyClient: CreateSession
with {email_address}, keeping the 201's pending token; RedeemMagicLink
with {code} and the pending token as a percent-encoded
pending_authentication_token cookie, which is where Fizzy reads it; and
CompleteSignup with {full_name} under the session cookie.

This changes the API: requestLink(email) is createSession(emailAddress)
and answers the pending token; redeemToken(token) is redeem(code) and
answers requiresSignupCompletion beside the session token and auth;
resume(pendingToken) and pendingToken let a sign-in finish in another
process; completeSignup(fullName) is new. The options take the client
options that apply before there is a credential (baseUrl, userAgent,
requestTimeoutMs, hooks).
MagicLinkFlow posted {email} to /sessions and {token} to
/sessions/redeem, neither of which Fizzy routes, and never sent back
the pending_authentication_token cookie Fizzy reads the sign-in from,
so no sign-in could complete. It now goes through the generated
SessionsService: create_session(email_address:) posts /session.json,
expects the 201's pending token and keeps it; redeem(code:) posts
/session/magic_link.json with {code} and the token as a percent-encoded
Cookie, and answers session_token and requires_signup_completion.
pending_token hands the token out and pending_token: resumes a sign-in
another process started. request_magic_link(email:) and redeem(token:)
are gone; they could not have worked.
MagicLinkFlow posted {email} to /sessions.json and {token} to
/sessions/redeem.json, which Fizzy does not route, decoded a
pendingToken Fizzy never sends, and completed signup with {name} into a
body Fizzy does not answer. It now goes through the generated
SessionsService: start(emailAddress:) posts {email_address} to
/session.json and keeps the 201's pending token, redeem(code:) posts
{code} to /session/magic_link.json with the token as an explicit,
percent-encoded pending_authentication_token cookie and no cookie store
in play, and completeSignup(fullName:sessionToken:) posts {full_name}
and takes the empty 201. pendingToken and resume(pendingToken:) let a
flow finish in another process, as Rust's does.

This changes the flow's API: start(email:) is start(emailAddress:) and
answers PendingAuthentication, redeem(token:) is redeem(code:) and
answers SessionAuthorization, and completeSignup takes fullName and
answers nothing; PendingSession, RedeemResult and SignupResult are gone.
MagicLinkFlow posted to /api/sessions, /api/sessions/redeem and
/api/sessions/signup, which Fizzy does not route, redeemed with the
pending token in the body where Fizzy wants the emailed code and the
token as a cookie, and named a new user with name rather than
full_name. No sign-in could complete.

The flow now runs on the generated Sessions operations through a
FizzyClient, so the paths, bodies, statuses, error mapping, HTTPS check
and body limits are the SDK's own: requestMagicLink posts
/session.json with email_address and keeps the pending token from the
201; redeemMagicLink takes the code and sends it to
/session/magic_link.json with the pending token as a percent-encoded
pending_authentication_token cookie; completeSignup posts full_name to
/signup/completion.json on the session cookie and takes the empty 201.
A sign-in finishing in another process resumes from pendingToken, as a
constructor argument or through resume; redeeming with none pending is
a usage error.

Breaking: redeemMagicLink takes the emailed code, not the pending
token, and completeSignup's second argument is the full name.
…code

The flow sent a private {code, token} body so it worked against Fizzy
and against a server built from the model, which spelled the code
token. The model now says code, so the flow sends the generated
RedeemMagicLinkRequestContent, still with the pending cookie the model
documents but cannot send, and the module docs say that instead of
where the model was wrong.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 00:11
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T01:47:39.402489Z 52d1e99 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Breaking authentication changes across six SDKs require maintainer sign-off, and the Swift client-lifetime issue remains unresolved.

1 open issue
What changed in this PR

Aligns magic-link sign-in across all six SDKs with Fizzy’s contract, addressing the generated-route gaps identified in #214.

Changes:

  • Corrects the modeled code field, response statuses and cookie documentation.
  • Moves authentication flows onto generated operations with pending-token support.
  • Adds flow tests and updates conformance fixtures and runners.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
typescript/​tests/​magic-link.test.ts Adds authentication flow tests.
typescript/​src/​generated/​services/​sessions.ts Updates redemption body and documentation.
typescript/​src/​generated/​schema.d.ts Updates generated fields and statuses.
typescript/​src/​generated/​openapi-stripped.json Refreshes authentication contract.
typescript/​src/​generated/​metadata.json Refreshes generation timestamp.
typescript/​src/​auth/​magic-link.ts Uses generated operations and explicit cookies.
typescript/​scripts/​generate-services.ts Preserves multiline JSDoc formatting.
swift/​Tests/​FizzyTests/​MagicLinkFlowTests.swift Adds flow and signup tests.
swift/​Sources/​Fizzy/​Generated/​Models/​RedeemMagicLinkRequest.swift Renames token to code.
swift/​Sources/​Fizzy/​Auth/​MagicLinkFlow.swift Uses generated operations and encoded cookies.
spec/​fizzy.smithy Corrects the authentication contract.
rust/​generator/​names.toml Keeps redemption codes sensitive.
rust/​fizzy-sdk/​tests/​magic_link.rs Checks the exact redemption body.
rust/​fizzy-sdk/​src/​magic_link.rs Uses the generated redemption type.
rust/​fizzy-sdk/​src/​generated/​types.rs Renames the sensitive request field.
rust/​fizzy-sdk/​src/​generated/​redaction.rs Redacts the code field.
ruby/​test/​magic_link_flow_test.rb Adds authentication flow tests.
ruby/​lib/​fizzy/​magic_link_flow.rb Uses generated sessions and pending cookies.
ruby/​lib/​fizzy/​generated/​types.rb Renames token to code.
ruby/​lib/​fizzy/​generated/​services/​sessions_service.rb Updates redemption parameters.
ruby/​lib/​fizzy/​generated/​metadata.json Refreshes generation timestamp.
openapi.json Regenerates the corrected contract.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​fizzy/​MagicLinkFlowTest.kt Adds flow and signup tests.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​fizzy/​generated/​services/​Types.kt Renames token to code.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​fizzy/​generated/​services/​sessions.kt Updates redemption serialization.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​fizzy/​auth/​MagicLinkFlow.kt Uses generated operations and pending cookies.
kotlin/​conformance/​src/​main/​kotlin/​com/​basecamp/​fizzy/​conformance/​Main.kt Dispatches redemption codes.
go/​pkg/​generated/​types.gen.go Updates the generated redemption field.
go/​pkg/​fizzy/​magic_link.go Uses generated sessions and resumable state.
go/​pkg/​fizzy/​magic_link_test.go Adds authentication flow tests.
conformance/​tests/​paths.json Corrects request fields and statuses.
conformance/​runner/​typescript/​runner.test.ts Dispatches redemption codes.
conformance/​runner/​swift/​Sources/​ConformanceRunner/​Dispatch.swift Dispatches redemption codes.

Review effort: Balanced

Comment thread swift/Sources/Fizzy/Auth/MagicLinkFlow.swift Outdated
…t's cache

The client caches an AccountClient that holds the client, and the
account caches the service that holds it, so a client built per request
and reached through .sessions was never freed, nor the cookie it signed
with. The flow now builds SessionsService(accountClient:) directly, and
a test checks a released flow lets go of its transport.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cabf6fc74

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ruby/lib/fizzy/magic_link_flow.rb Outdated
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt Outdated
Comment thread swift/Sources/Fizzy/Auth/MagicLinkFlow.swift Outdated
Comment thread go/pkg/fizzy/magic_link.go
Comment thread typescript/src/auth/magic-link.ts Outdated
Ruby, Kotlin and Swift kept the pending token after a successful
redeem, so pendingToken reported a consumed sign-in and a second redeem
sent it to Fizzy instead of failing locally. Go, TypeScript and Rust
cleared it unconditionally, which erased a newer sign-in a create
started while the redeem was in flight. Each flow now clears the token
only if it is still the one it redeemed, and only after success, so a
refused code can be retried. Tests assert the spent token and the local
usage error on a second redeem.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Concurrent pending-token replacement can cause Ruby and Kotlin redemption requests to consume valid codes with the wrong cookie.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Conformance test does not assert the redeem request code field

conformance/​tests/​paths.json:296

requestBody supplies runner input; it does not assert what the SDK sends. This is the only RedeemMagicLink conformance case, and its assertions check only the path and absence of errors. The mock accepts any body, so sending token instead of code, or omitting code, still passes. Add a requestBodyField assertion for code so conformance covers this changed request field.

1 resolved since last review

Review effort: Balanced

Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt Outdated
Comment thread ruby/lib/fizzy/magic_link_flow.rb Outdated
… state

Kotlin's and Ruby's auth strategy read the flow's pending token when the
request was signed, after hooks had run, so a sign-in started or resumed
meanwhile could pair this code with another's cookie; Fizzy spends the
code before refusing the mismatch. Each redeem now signs with a client
bound to the token it read, and the pending state sits behind an atomic
(Kotlin) or a mutex (Ruby). A Ruby test starts a second sign-in from a
hook mid-redeem and checks the cookie and the surviving pending token.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f3d84d09a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread typescript/src/auth/magic-link.ts Outdated
An older redeem finishing after a newer one overwrote the flow's stored
session, so an implicit completeSignup could sign one person's sign-up
under another's session. The flow now keeps no session: completeSignup
takes the token redeem answered, as every other SDK's flow already does.
@jeremy

jeremy commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Review threads: 9 resolved (9 fixed, 0 declined).

  • Round 1: a Swift retain cycle, and pending-token clearing in all six flows.
  • Round 2: the cookie binding in Kotlin and Ruby.
  • Round 3: the TypeScript stored session. I fixed it by removing the stored session.

Codex and Copilot have both reported on 52d1e99. Codex has no findings.
CI: everything passes except Rubric Check, which is the stale audit date. #214, stacked on this PR, refreshes it and is green.
Open for a decision: @jeremy needs to decide whether to land this PR. It changes the magic-link sign-in flow and breaks its public API in every SDK. The PR body lists the per-language API changes.

@jeremy
jeremy merged commit c3f80e1 into main Oct 18, 2026
45 of 46 checks passed
@jeremy
jeremy deleted the fix-magic-link-flows branch October 18, 2026 04:02
jeremy added a commit that referenced this pull request Oct 18, 2026
* Model RedeemMagicLink's code, and the statuses Fizzy answers for sessions

Sessions::MagicLinksController reads params[:code], not token, and
reads the pending sign-in from the pending_authentication_token cookie
CreateSession sets; CreateSession answers 201 and DestroySession 204.
The model said token, 200 and 200, so nothing generated from it could
complete a sign-in. RedeemMagicLinkInput now carries code, the two
statuses are modelled, and the operations' docs say which cookie each
side of the exchange carries. Every language regenerates; the
conformance fixtures and runners send code and expect 201 and 204, and
Rust's names.toml keeps the code redacted as the token was.

* Keep the JSDoc gutter on every line of a generated operation's description

* Sign Go's magic-link flow in through Fizzy's session routes

MagicLinkFlow posted {email} to /api/v1/sessions and {token} to
/api/v1/sessions/redeem, neither of which Fizzy routes, and dropped the
pending token the answer carries, so it could not sign anyone in. It
now sends through the generated SessionsService: CreateSession posts
{email_address} to /session.json and keeps the pending token from the
201; RedeemMagicLink posts {code} to /session/magic_link.json with the
token as the pending_authentication_token cookie, escaped as Rack
unescapes it, and answers the generated SessionAuthorization.
PendingToken and ResumeMagicLinkFlow let a sign-in finish in another
process, as Rust's flow does. Like NewClient, the flow now requires
HTTPS outside localhost.

This breaks the flow's API: CreateSession answers
*generated.PendingAuthentication, RedeemMagicLink takes the emailed
code and answers *generated.SessionAuthorization, Login answers that
session too, and the hand-written CreateSessionRequest/Response and
RedeemMagicLinkRequest/Response types, with their user_id, are gone.
fizzy-cli does not use this flow.

* Sign in to Fizzy the way it signs in, in TypeScript

MagicLinkFlow posted {email} to /sessions and {token} to
/sessions/redeem, which Fizzy has never routed, and discarded the
pending token CreateSession answers. It now goes through the generated
sessions operations on a client from createFizzyClient: CreateSession
with {email_address}, keeping the 201's pending token; RedeemMagicLink
with {code} and the pending token as a percent-encoded
pending_authentication_token cookie, which is where Fizzy reads it; and
CompleteSignup with {full_name} under the session cookie.

This changes the API: requestLink(email) is createSession(emailAddress)
and answers the pending token; redeemToken(token) is redeem(code) and
answers requiresSignupCompletion beside the session token and auth;
resume(pendingToken) and pendingToken let a sign-in finish in another
process; completeSignup(fullName) is new. The options take the client
options that apply before there is a credential (baseUrl, userAgent,
requestTimeoutMs, hooks).

* Sign Ruby's magic-link flow in the way Fizzy answers

MagicLinkFlow posted {email} to /sessions and {token} to
/sessions/redeem, neither of which Fizzy routes, and never sent back
the pending_authentication_token cookie Fizzy reads the sign-in from,
so no sign-in could complete. It now goes through the generated
SessionsService: create_session(email_address:) posts /session.json,
expects the 201's pending token and keeps it; redeem(code:) posts
/session/magic_link.json with {code} and the token as a percent-encoded
Cookie, and answers session_token and requires_signup_completion.
pending_token hands the token out and pending_token: resumes a sign-in
another process started. request_magic_link(email:) and redeem(token:)
are gone; they could not have worked.

* Sign the Swift magic-link flow in through the routes Fizzy serves

MagicLinkFlow posted {email} to /sessions.json and {token} to
/sessions/redeem.json, which Fizzy does not route, decoded a
pendingToken Fizzy never sends, and completed signup with {name} into a
body Fizzy does not answer. It now goes through the generated
SessionsService: start(emailAddress:) posts {email_address} to
/session.json and keeps the 201's pending token, redeem(code:) posts
{code} to /session/magic_link.json with the token as an explicit,
percent-encoded pending_authentication_token cookie and no cookie store
in play, and completeSignup(fullName:sessionToken:) posts {full_name}
and takes the empty 201. pendingToken and resume(pendingToken:) let a
flow finish in another process, as Rust's does.

This changes the flow's API: start(email:) is start(emailAddress:) and
answers PendingAuthentication, redeem(token:) is redeem(code:) and
answers SessionAuthorization, and completeSignup takes fullName and
answers nothing; PendingSession, RedeemResult and SignupResult are gone.

* Sign Kotlin's magic-link flow in on Fizzy's sessions contract

MagicLinkFlow posted to /api/sessions, /api/sessions/redeem and
/api/sessions/signup, which Fizzy does not route, redeemed with the
pending token in the body where Fizzy wants the emailed code and the
token as a cookie, and named a new user with name rather than
full_name. No sign-in could complete.

The flow now runs on the generated Sessions operations through a
FizzyClient, so the paths, bodies, statuses, error mapping, HTTPS check
and body limits are the SDK's own: requestMagicLink posts
/session.json with email_address and keeps the pending token from the
201; redeemMagicLink takes the code and sends it to
/session/magic_link.json with the pending token as a percent-encoded
pending_authentication_token cookie; completeSignup posts full_name to
/signup/completion.json on the session cookie and takes the empty 201.
A sign-in finishing in another process resumes from pendingToken, as a
constructor argument or through resume; redeeming with none pending is
a usage error.

Breaking: redeemMagicLink takes the emailed code, not the pending
token, and completeSignup's second argument is the full name.

* Redeem a Rust magic link with the generated body, now the model says code

The flow sent a private {code, token} body so it worked against Fizzy
and against a server built from the model, which spelled the code
token. The model now says code, so the flow sends the generated
RedeemMagicLinkRequestContent, still with the pending cookie the model
documents but cannot send, and the module docs say that instead of
where the model was wrong.

* Build the Swift flow's per-request sessions service outside the client's cache

The client caches an AccountClient that holds the client, and the
account caches the service that holds it, so a client built per request
and reached through .sessions was never freed, nor the cookie it signed
with. The flow now builds SessionsService(accountClient:) directly, and
a test checks a released flow lets go of its transport.

* Spend only the sign-in a redeem consumed, in every flow

Ruby, Kotlin and Swift kept the pending token after a successful
redeem, so pendingToken reported a consumed sign-in and a second redeem
sent it to Fizzy instead of failing locally. Go, TypeScript and Rust
cleared it unconditionally, which erased a newer sign-in a create
started while the redeem was in flight. Each flow now clears the token
only if it is still the one it redeemed, and only after success, so a
refused code can be retried. Tests assert the spent token and the local
usage error on a second redeem.

* Bind each redeem's cookie to the token it read, and guard the pending state

Kotlin's and Ruby's auth strategy read the flow's pending token when the
request was signed, after hooks had run, so a sign-in started or resumed
meanwhile could pair this code with another's cookie; Fizzy spends the
code before refusing the mismatch. Each redeem now signs with a client
bound to the token it read, and the pending state sits behind an atomic
(Kotlin) or a mutex (Ruby). A Ruby test starts a second sign-in from a
hook mid-redeem and checks the cookie and the surviving pending token.

* Keep no session in the TypeScript flow; completeSignup takes the token

An older redeem finishing after a newer one overwrote the flow's stored
session, so an implicit completeSignup could sign one person's sign-up
under another's session. The flow now keeps no session: completeSignup
takes the token redeem answered, as every other SDK's flow already does.

* Refresh the rubric audit against the SDK as it is now

The audit was 38 days old, past audit-check's 30. Re-checking each
criterion against main found notes that no longer, or never, matched
the code: error-body and success-body limits differ by language and
three languages have none, resilience is wired into the client only in
Go and Rust, hooks never see headers, the fixtures now hold 134 cases
naming 73 operations across six runners, and the hand-written
magic-link flows in five languages build their own paths, off the
spec's, rather than using the generated routes. The notes now say what
the code does and the criteria it fails are marked failing, which
includes the must-pass 1C.3.

* Correct the audit notes review found inaccurate, and recheck the rest

Rust parses *_at fields as chrono DateTime (1B.5); Go's generated types
use plain values with omitempty, not pointers (1B.4, now failing); Go's
magic-link flow reads error bodies up to 50 MB (2A.6); only parsed
server messages are bounded at 500 bytes, and Go's 404 quotes the full
URL (3C.3). 2D.5, 3A.5 and 3A.6 are re-read against the rubric's names
(resilience configurable, keyring integration, the no-keyring
fallback): keyring storage and FIZZY_NO_KEYRING exist in Go only. 1A.6,
1B.2 and 3A.4 were rechecked and still hold, so no criterion is carried
forward unread.

* Mark 3C.3, 4B.5 and 4C.4 failing where review found the gaps real

TypeScript's error fallback keeps statusText untruncated; 39 of 112
operations have no conformance case of their own; Kotlin's release
treats any 409 as a finished publish, so a rerun after a partial upload
leaves publications unpublished.

* Mark must-pass 1B.2 failing on the magic-link models, and tighten two notes

The hand-written magic-link flows declare their own request and response
shapes, the same root cause that fails 1C.1 and 1C.3. Go has one
generated pointer (Column.Color), and the rate limiter is per client,
not per operation.

* Name every language's hand-built magic-link bodies under 1B.2

* Mark 1B.2, 1C.1 and 1C.3 passing on the magic-link flows #215 fixes

* Correct the notes review found stale after the magic-link rewrite

Rust's flow sends generated types over generated routes through
client.operation rather than the generated service; Go's magic-link
flow no longer reads error bodies at the success limit; and Go's
WithResilience installs gates no request path consults, so 2D.5 fails.
@jeremy jeremy mentioned this pull request Oct 19, 2026
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.

2 participants