You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Sign in through Fizzy's real magic-link contract in every SDK - #215
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.
/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.
…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.
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.
…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.
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.
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.
… 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.
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.
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.
* 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/fizzyat db36756, together with its controllers and tests. The SDK'sapi-provenance.jsonpins 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./apiscope and no/sessionsroute, so the old SDK paths 404.What was wrong
token; Fizzy readscode/api/v1/sessions,/api/v1/sessions/redeem: 404{email},{token}/sessions,/sessions/redeem: 404{email},{token}/sessions,/sessions/redeem: 404{email},{token}/sessions.json,/sessions/redeem.json: 404; signup path right{email},{token}, signup{name}/api/sessions…: 404{name}{code, token}to cover the model'stokenWhat changes
spec/fizzy.smithy):RedeemMagicLinkInput.codereplacestoken. CreateSession is nowcode: 201and DestroySessioncode: 204. The operation docs say which cookie each side carries.names.tomlkeeps the code redacted, as the token was.*gutter on every line.paths.jsonnow sends{code}and expects 201 and 204. The TypeScript, Swift and Kotlin runners sendcode.sessionsoperations, so paths, bodies, statuses, error mapping, HTTPS enforcement, body limits and hooks all come from the SDK's shared client.Cookie: pending_authentication_token=…, encoded the same way as Rust. None of the flows relies on a cookie jar.Public API changes (breaking)
CreateSessionreturns*generated.PendingAuthentication.RedeemMagicLink(ctx, code)returns*generated.SessionAuthorization. New:PendingToken()andResumeMagicLinkFlow(...). Removed:CreateSessionRequest/Response,RedeemMagicLinkRequest/ResponseandUserID.Loginreturns(*SessionAuthorization, error).requestLink(email)becomescreateSession(emailAddress), andredeemToken(token)becomesredeem(code). New:resume,pendingTokenandcompleteSignup(fullName).request_magic_link(email:)becomescreate_session(email_address:), andredeem(token:)becomesredeem(code:). New: thepending_token:constructor argument andpending_token.start(email:)becomesstart(emailAddress:) -> PendingAuthentication.redeem(token:)becomesredeem(code:) -> SessionAuthorization.completeSignup(fullName:sessionToken:)now returns nothing.PendingSession,RedeemResultandSignupResultare removed. New:pendingTokenandresume.redeemMagicLink(code)takes the emailed code; before, it took the pending token.completeSignup(sessionToken, fullName)changes too. New:pendingTokenandresume.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/httpclient and a cookie jar, against exactly the contract described above (internal/commands/signup.go), and it doesn't use any SDKMagicLinkFlow. 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.
{"code":"ABC123"}.Verified locally:
make -k checkpasses everything exceptaudit-check, which is the stalerubric-audit.jsondate 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
preconditionFailure. The old flows didn't check.CookieAuthin Kotlin and Swift still writessession_tokenunencoded. I left it as it was.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.pending_authentication_tokencookie; each flow sends it explicitly, percent-encoded, and can resume a sign-in another process started.completeSignuptakes the tokenredeemanswered, so an older redeem can't sign one person's sign-up under another's session.Breaking API changes
CreateSessionreturns*generated.PendingAuthentication;RedeemMagicLink(ctx, code)returns*generated.SessionAuthorization; the hand-written request/response types andUserIDare gone.requestLink(email)→createSession(emailAddress),redeemToken(token)→redeem(code); newresume,pendingToken,completeSignup(fullName).request_magic_link(email:)→create_session(email_address:),redeem(token:)→redeem(code:); newpending_token:constructor argument and reader.start(email:)→start(emailAddress:) -> PendingAuthentication,redeem(token:)→redeem(code:) -> SessionAuthorization; old result types removed.redeemMagicLink(code)takes the emailed code, not the pending token;completeSignuptakesfullName; newpendingTokenandresume.RedeemMagicLinkRequestContent.token→code.Written for commit 52d1e99. Summary will update on new commits.