Sign in through Fizzy's real magic-link contract in every SDK
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)takesemail_addressand answers 201{pending_authentication_token}. It also sets that token as an HttpOnlypending_authentication_tokencookie (via_magic_link.rb:32,43-50).POST /session/magic_link(.json)takesparams.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 /sessionanswers 204.- There's no
/apiscope and no/sessionsroute, 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.codereplacestoken. CreateSession is nowcode: 201and DestroySessioncode: 204. The operation docs say which cookie each side carries. - Regeneration: every language regenerates. Rust's
names.tomlkeeps the code redacted, as the token was. - TypeScript generator: a multi-line operation description now keeps the JSDoc
*gutter on every line. - Conformance:
paths.jsonnow sends{code}and expects 201 and 204. The TypeScript, Swift and Kotlin runners sendcode. - Every flow now goes through the client's generated
sessionsoperations, 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.
- Redeem adds an explicit, percent-encoded
Public API changes (breaking)
- Go:
CreateSessionreturns*generated.PendingAuthentication.RedeemMagicLink(ctx, code)returns*generated.SessionAuthorization. New:PendingToken()andResumeMagicLinkFlow(...). Removed:CreateSessionRequest/Response,RedeemMagicLinkRequest/ResponseandUserID.Loginreturns(*SessionAuthorization, error). - TypeScript:
requestLink(email)becomescreateSession(emailAddress), andredeemToken(token)becomesredeem(code). New:resume,pendingTokenandcompleteSignup(fullName). - Ruby:
request_magic_link(email:)becomescreate_session(email_address:), andredeem(token:)becomesredeem(code:). New: thepending_token:constructor argument andpending_token. - Swift:
start(email:)becomesstart(emailAddress:) -> PendingAuthentication.redeem(token:)becomesredeem(code:) -> SessionAuthorization.completeSignup(fullName:sessionToken:)now returns nothing.PendingSession,RedeemResultandSignupResultare removed. New:pendingTokenandresume. - Kotlin:
redeemMagicLink(code)takes the emailed code; before, it took the pending token.completeSignup(sessionToken, fullName)changes too. New:pendingTokenandresume. - Rust:
RedeemMagicLinkRequestContent.tokenis renamedcode. 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
CookieAuthin Kotlin and Swift still writessession_tokenunencoded. I left it as it was. - The generated
RedeemMagicLinkalone 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.codereplacestoken;CreateSessionandDestroySessionnow model their true statuses, 201 and 204.- The operation docs now describe the
pending_authentication_tokencookie; 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;
completeSignuptakes the tokenredeemanswered, so an older redeem can't sign one person's sign-up under another's session.
Breaking API changes
- Go:
CreateSessionreturns*generated.PendingAuthentication;RedeemMagicLink(ctx, code)returns*generated.SessionAuthorization; the hand-written request/response types andUserIDare gone. - TypeScript:
requestLink(email)→createSession(emailAddress),redeemToken(token)→redeem(code); newresume,pendingToken,completeSignup(fullName). - Ruby:
request_magic_link(email:)→create_session(email_address:),redeem(token:)→redeem(code:); newpending_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;completeSignuptakesfullName; newpendingTokenandresume. - Rust:
RedeemMagicLinkRequestContent.token→code.