Web logins from before cutover carry over, until a set date
MergedThis adds a temporary layer that opens hatk's __hatk_session and __hatk_session_accounts cookies and mints grain_session and grain_accounts for them. It also adds an import step that carries the web sessions and derives the cookie key.
I read carryover.rs in full. I did not read the import, the V8 migration or the integration tests: the full pull diff was too large and includes the stacked pulls. The carry logic holds up:
- A carried login needs a decryptable cookie, no existing
grain_session, a date before the cutoff, and aheld()session that isn't taken down. - The new cookies go ahead of the request's own, and the old ones are cleared.
- A tampered, truncated or wrong-key value opens as nothing.
One thing to confirm in the cutover rehearsal, next to the token_expires_at units you already flagged. The unit tests pick ts themselves (TS = 1793500000, in seconds), so the sealed fixtures prove the cipher and key but not hatk's ts unit. If hatk stores ts in milliseconds, now.saturating_sub(ts) is 0 and the 30-day expiry never applies. Carrying would still stop at GRAIN_CARRY_COOKIES_UNTIL, so the exposure is bounded. Check a real cookie's ts against Date.now() in hatk's session.ts.
The checks were still queued or running when I looked, so I haven't seen their results. Before merging, make sure harness and rust pass.
On ts's unit: it is seconds. hatk's packages/hatk/src/oauth/session.ts seals { ...data, ts: Math.floor(Date.now() / 1000) } (line 87) and the accounts cookie the same way (line 110), and refuses Date.now() / 1000 - data.ts > MAX_AGE with MAX_AGE in seconds (lines 8, 103, 127), so now_secs() - ts <= MAX_AGE here matches it. No change pushed for this.
🤖 Generated with Claude Code
This adds a temporary layer that turns hatk's __hatk_session and __hatk_session_accounts cookies into grain_session and grain_accounts. It also adds the import of web sessions and the derived cookie key. All four checks (test, rust, harness, check) pass on c106f70.
I found nothing that must change. I read carryover.rs again, and the held() gate, the cookie-header rewrite and the end-date stop are as the body describes.
My earlier note about the ts unit is still open. open() in carryover.rs does now.saturating_sub(ts) with ts read as seconds, and the sealed fixtures use TS = 1793500000. Those fixtures prove the cipher and the key, but not the unit hatk writes. If a real hatk cookie holds milliseconds, the 30-day expiry never applies. Carrying still stops at GRAIN_CARRY_COOKIES_UNTIL, so the exposure is bounded. Compare a real cookie's ts with Date.now() during the cutover rehearsal, next to the token_expires_at check you already listed. If it turns out to be milliseconds, divide it by 1000 in open().
- Web logins from before cutover carry over, until a set date
- main
What changes
Web users stay logged in across cutover, for a while. This is a temporary carryover: it, its table and its import are deleted once
GRAIN_CARRY_COOKIES_UNTILhas passed.grain-import (
crates/grain-import/src/lib.rs):_oauth_sessionsrow becomes aweb_sessionin grain's owngrain::session::Sessionshape: tokens, the PDS, the auth server (falling back to the PDS), the token endpoint (falling back to<issuer>/oauth/token),token_expires_at(seconds; none reads as expired, so it is refreshed first),requested_scope, the client ID<--public-url or GRAIN_PUBLIC_URL>/oauth-client-metadata.json, and as DPoP key the secret scalar ofappview-oauth-key, which every hatk PDS session is bound to (dpop_jktis checked against its RFC 7638 thumbprint). Rows bound to another key, or with no refresh token, are skipped and counted: the summary prints in-hatk vs carried, and why none carried if none did.JSON.stringify(appview-oauth-key's private JWK, members sorted), empty salt, infohatk-session-cookie(oauth/server.ts:203passesserverPrivateJwk, theappview-oauth-keyrow, toinitSession). It stores only the 32 bytes, in a new tablecarried_cookie_key(migration V8, neutral, checked to be 32 bytes). The JWK itself is not imported, so runtime never sees it or the derivation.Runtime (
crates/grain-serve/src/login/carryover.rs, the only runtime file that names the old server):__hatk_sessionand nograin_session, before the end date. It opens the cookie with AES-256-GCM (<b64url iv>.<b64url ct>, 12-byte IV, payload{did, handle, ts}, 30 days fromtsas hatk held it). It then mintsgrain_sessionfor that DID with the cookie key, signed now, soiat >= since(imported sessions havesince0), and keeps it only ifheld()accepts it: aweb_sessionexists and the account isn't taken down.__hatk_session_accounts(newest first), intograin_accounts.Cookieheader and set on the response. Both old cookies are cleared (Max-Age=0; Path=/). Each account carried is logged ascarried a web loginwith its DID only.Hard stop.
GRAIN_CARRY_COOKIES_UNTIL, an RFC 3339 time (validated; the server refuses to start on a bad one). Unset, or no key imported, carries nothing; past it, nothing is carried.CI.
rust.yml's old-name check excludescrates/grain-serve/src/login/carryover.rsby path, besidecrates/grain-import.Why
You asked for web users to stay signed in through cutover. The old cookie format is confined to one runtime file and the throwaway import, and the old JWK and its derivation stay in the import, so deleting the carryover later is three removals: the file, the table, and the import's two functions.
How it was checked
carryover.rswere generated by hatk's ownencrypton Node (from the earlier round), under a JWK whose HKDF keygrain-import's test derives to the exact bytescarryover.rsopens them with (a0fb2a23…6903, also computed independently with Python's hmac). The test helperseal()round-trips through the same opener.carryover.rs): it opens within 30 days and not after; the accounts are read newest first; tampered, truncated-IV, non-JSON and wrong-key values open as nothing.index/sessions.rs,lib.rs): the 32-byte table check, RFC 3339 parsing, and a bad setting refusing to start.tests/login.rs(reference PDS):a_browser_logged_in_before_stays_logged_in: an old session cookie plus accounts cookie becomesgrain_sessionandgrain_accountson one request, the old cookies are cleared, and the next request holds by grain's cookie alone.updateSeensucceeds on the very request that carries. A browser already holdinggrain_sessionkeeps its own.a_cookie_that_does_not_hold_carries_no_one: wrong key, a tampered IV, a DID with no carried session, a taken-down account, past the end date and a server not set to carry all leave the browser unauthenticated. An accounts cookie naming a DID with no session carries the main account only.The rest is left to the checks.
Shortcomings
token_expires_atunits (seconds) were checked against the old server's code and fixtures, not a production backup; check during the cutover rehearsal.login/carryover.rs,carried_cookie_key(a later migration drops it), theGRAIN_CARRY_COOKIES_UNTILsetting and the rust.yml exclusion once the date passes.Review
crates/grain-serve/src/login/carryover.rs(carry, especially the cookie-header rewrite and theheld()gate), thengrain-import'scopy_sessions/cookie_key/copy_cookie_key, and V8. Stacked onweb/hatk-sessions(pull 3mz7pjygpcuoh), itself onweb/login; its own diff is the last commit.🤖 Generated with Claude Code