feat: pluggable session storage, and a cross-subdomain sign-in hint - #153
Conversation
78e9443 to
27b1bdb
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new synchronous, observable “session storage” abstraction for persisting a non-secret DelegationChain, with implementations for same-origin storage (localStorage) and cross-subdomain sign-in signaling via a hint cookie.
Changes:
- Add
SessionStorage/Sessioninterfaces for synchronous session persistence and change subscriptions. - Implement
LocalSessionStorage(localStorage-backed, same-tab + cross-tab notifications). - Implement
CookieSessionStorage(composes local storage with a domain-scoped hint cookie + multi-source change detection) and add tests for both.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/client/local-session-storage.test.ts | Adds coverage for localStorage round-tripping and notification behavior. |
| tests/client/cookie-session-storage.test.ts | Adds coverage for cookie-gated session restore, hint parsing, and multi-source notifications. |
| src/client/session-storage.ts | Defines the new Session and SessionStorage API contracts. |
| src/client/local-session-storage.ts | Implements localStorage-backed session storage with subscriber notifications. |
| src/client/cookie-session-storage.ts | Implements cross-subdomain hint cookie behavior and consolidated change detection. |
| src/client/index.ts | Exports the new session storage types and implementations from the client entrypoint. |
Suppressed comments (2)
src/client/cookie-session-storage.ts:116
secondsis computed withMath.floor, which can drop the hint cookie up to ~999ms beforeexpiresAtMs. That makesget()treat the session as signed-out early (cookie absent) even though the delegation may still be valid for the remainder of the second.
const hint = deriveHint(session);
const seconds = hint === null ? 0 : Math.floor((hint.expiresAtMs - Date.now()) / 1000);
if (hint === null || seconds <= 0) {
src/client/cookie-session-storage.ts:213
#snapshot()concatenates two arbitrary strings with a|delimiter. If either side ever contains|, different states can collapse to the same snapshot string and subscribers may miss a change. Use a structured encoding (e.g.JSON.stringify([..])) to avoid delimiter collisions.
#snapshot(): string {
return `${localStorage.getItem(this.key) ?? ''}|${readCookie(this.key) ?? ''}`;
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
6656c45 to
181d1c8
Compare
Adds the second half of AuthClient's persistence as its own swappable store, holding a Session rather than a bare delegation chain. A session is what an application is given at sign-in and what its access derives from, so the record is named for what it is. It carries the account key alongside the chain because the chain is rooted at the session's own key, not the account's, so the key is the only record of who is signed in until something mints. SessionStorage (get/set/remove/discard/subscribe over Session) holds only non-secret material, which is what lets it be synchronous and observable where identity storage cannot be. LocalSessionStorage keeps it in localStorage, per origin, and notifies other tabs through the storage event; every change source routes through one snapshot comparison, so a single change notifies subscribers once rather than once per source. toBase64/fromBase64 move out of auth-client into base64.ts, since the store has to encode the account key and both callers want the same pair. remove() and discard() exist because ending a session and finding out about one are different acts. remove() retracts anything the store publishes beyond itself, which is what signing out means; discard() drops only this store's copy, so a reader sharing the same session is not told its own has gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
82bb670 to
9df96a7
Compare
181d1c8 to
b0b0c23
Compare
|
Not merged — nothing from this branch reached |
The other half of
AuthClient's persistence, holding aSessionrather than a bare delegation chain. A session is what an application is given at sign-in and what its access derives from, so the record is named for what it is.SessionStorage(get/set/remove/discard/subscribeoverSession) holds only non-secret material.LocalSessionStoragekeeps it inlocalStorageand notifies other tabs through the storage event.CookieSessionStoragecomposes that and adds a domain-scoped hint cookie carrying the signed-in principal and the session's expiry, so sibling subdomains of one domain can see that a session exists and notice a sign-out. The session itself stays inlocalStorage, per origin, so the cookie carries no key material and no chain; each subdomain re-issues its own.readHint()is what lets an application decide whether to acquire silently.remove()anddiscard()exist because ending a session and finding out about one are different acts, and this is the part to read hardest. One session serves every sibling of a domain, so an origin that discovers the chain it held is stale has learned something about itself: a sibling may have signed in a moment ago and written a hint that is perfectly good.remove()takes the hint with it, which is what signing out means.discard()leaves it, so the sibling that signed in is not told its session has gone.Read the notification path next. Every change source — the storage event, the cookie watchers, and the Cookie Store API where it exists — routes through one snapshot comparison, so a single change notifies subscribers once rather than once per source.
A
domainthe browser would refuse is rejected in the constructor. Setting such a cookie fails silently, and the hint is what makes a stored session count as live, so a session would appear to be forgotten the moment it was written.Tests:
local-session-storage.test.tsandcookie-session-storage.test.ts, including thatdiscardleaves the hint for the siblings whileremovetakes it.