feat: persistent session cookies (30d sliding) — nanodrop tier #4
Reference in New Issue
Block a user
Delete Branch "feat/persistent-session-cookies"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Third tier of the cross-project persistent-session-cookies sweep, after authd PR #7 (
562aca5) and buchinese PR #3 (9015328). Mirrors authd's stateless-JWT shape (no sessions table) since nanodrop has no session DB.src/constants.ts:SESSION_TTL_DAYS=30,SESSION_TTL_SECONDS=2_592_000,SESSION_RENEW_THRESHOLD_SECONDS=3600. Verbatim from authd PR #7 and buchinese PR #3.token→nanodrop_sessionper the family<app>_sessionconvention. Hard-cut migration with no dual-cookie shim: the existingtokencookie has noMax-Ageso it dies on browser close anyway, and this is a single-user deploy. You re-log in once after deploy.issueSessionCookie(reply, server, claims, secure)is now the only place that signs a JWT and writes the session cookie. The cookie carriesMax-Age=2592000so it persists across browser restarts.grep -rn 'reply.setCookie\|app.jwt.sign' src/converges to one site (auth.ts:29-30).slideSessionIfNeededruns after every successfulrequest.jwtVerify()insidemakeRequireAuth. It re-mints the cookie iff the JWT is older than 1 hour, avoiding Set-Cookie thrash on every request. Both logout paths (POST /logout,POST /api/v1/auth/logout) are URL-guarded inLOGOUT_PATHSso the renewer never resurrects a session the user is terminating. Architectural placement (renewer onrequireAuth, NOT on opportunistic-auth blocks) is the primary defense; URL guard is belt-and-braces.JWT_EXPIRYenv var REMOVED. The family TTL is canonical; a staleJWT_EXPIRY=2hin a.envis now silently ignored. Removed fromsrc/config.ts,tests/helpers/setup.ts,.env.example,docker-compose.yml, and the README env-var table.Architectural decisions
iat. No DB migration, noexpires_atcolumn, no backfill.sameSite: 'strict'retained. Family default is'lax'; nanodrop kept'strict'because the family rule is "preserved or strengthened, never relaxed".'strict'is safe-stricter, so this is in spirit with the family policy.requireAuthis now a factorymakeRequireAuth(config). Renewer needsconfig.cookieSecure; threading via factory matches the existingDepsinjection style.GET /,GET /f/:id) do NOT slide the session. They callrequest.jwtVerify()directly outside the factory — a public file view shouldn't extend the owner's session.Cookie attributes
All four flags strictly stronger or equal vs. the old cookie:
HttpOnlySameSiteStrictStrictSecurePath//Max-Age2592000(30 days)The only relaxation-direction change is adding
Max-Age, which is the spec.Phasing (one PR, six commits)
Each feat/refactor commit builds clean (
npm run build && npm test):86870dbfeat(auth): family-wide session constants + mint primitive + auth factory623a337feat(auth): rename session cookie to nanodrop_session0f0c2f0feat(auth): sliding session renewal middlewarea4355e1refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical)cbc22dcrefactor: drop redundant iat intersections and reuse JwtPayload in tests3b3a56cdocs: drop JWT_EXPIRY from .env.example, docker-compose, README (family TTL is canonical)Test plan
npm run buildgreennpm test— 125 passed (was 112; +13 new: 7 unit + 6 integration on renewer/persistence)JWT_EXPIRY)grep -rn "'token'" src/returns nothing for the cookie namegrep -rn "JWT_EXPIRY\|jwtExpiry" .returns zero hits across source, tests, deploy manifests, and docsMax-Age=2592000(asserted inauth-api.test.tsandsession-persistence.test.ts)npm run dev, log in, DevTools → Cookies, verifynanodrop_sessionwithHttpOnly; SameSite=Strict; Max-Age=2592000(deferred — no headed browser in autonomous loop)/uploadredirects to/(deferred)Security audit
slideSessionIfNeededLOGOUT_PATHSSet + query-string strip) AND architectural placement (renewer onrequireAuth, not on opportunistic auth)issueSessionCookieissueSessionCookieGET /,GET /f/:id)request.jwtVerify()bypasses the factory; no slideJWT_EXPIRYremovalMax-AgeaddedReviewer cycle
JWT_EXPIRYwas removed fromsrc/but still appeared in.env.example,docker-compose.yml, README. Caught a real docs-drift risk: an operator settingJWT_EXPIRY=2hwould expect 2-hour sessions but get 30-day.3b3a56cremoved all three references. Reviewer confirmedgrep -rn "JWT_EXPIRY|jwtExpiry" .returns zero hits, single mint primitive intact, cookie attrs intact, both logout paths URL-guarded, no new env vars.Cross-project umbrella status
Remaining apps in the persistent-session-cookies umbrella: dashcam, movement. Inventory is BLOCKED behind PR #17 manual merge.
Flips SESSION_COOKIE_NAME from 'token' to 'nanodrop_session' per the family per-app naming convention (<app>_session). fastify-jwt's cookieName in server.ts is now sourced from the constant so a future rename only needs to touch constants.ts. Hard-cut migration with no dual-cookie shim: the existing 'token' cookie has no Max-Age so it dies on browser close anyway, and this is a single-user deployment per CLAUDE.md. Users re-log in once after deploy. Test files updated mechanically: cookies: { token } → cookies: { nanodrop_session: token } (variable name 'token' kept locally), clearCookie regex updated, login response now also asserts Max-Age=2592000 from the family TTL.The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days) inside issueSessionCookie, so JWT_EXPIRY had no effect after the prior commits. Removing the field from Config and the env read in loadConfig lets the TypeScript compiler verify no caller was still relying on it. Per the family invariant ('coherence across apps is a feature'), the per-app override is intentionally gone — a deploy with stale JWT_EXPIRY in .env will now silently use the 30-day family default regardless of the value. Test fixtures (Config literals in setup.ts, login-handler.test.ts, lockout-service.test.ts) and config.test.ts assertions updated to match the new shape.JwtPayload already declares iat?: number, so the & { iat?: number } in makeRequireAuth and slideSessionIfNeeded was a no-op. The unit test had a local TestPayload duplicating the same shape — replaced with the canonical import.