From 86870db726aa54d2b582f73621f633c5a2ac8392 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:10:47 -0700 Subject: [PATCH 1/6] feat(auth): family-wide session constants + mint primitive + auth factory Adds src/constants.ts exporting the family-wide session policy (SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000, SESSION_RENEW_THRESHOLD_SECONDS=3600, LOGOUT_PATHS) so every bchen.dev app shares the same persistence window. Introduces issueSessionCookie as the single mint site for fastify-jwt sign + setCookie, replacing inlined jwt.sign + setCookie calls in pages.ts and api/v1/auth.ts. The cookie now carries Max-Age=SESSION_TTL_SECONDS so it persists across browser restarts. Converts requireAuth into a makeRequireAuth(config) factory; route plugins build their own preHandler at registration time. Threads through pages.ts, api/v1/auth.ts, and api/v1/files.ts. SESSION_COOKIE_NAME stays 'token' in this commit so existing tests remain green; the rename to 'nanodrop_session' lands in a follow-up. JWT_EXPIRY env var is still read; its removal also lands in a follow-up so each commit builds cleanly. --- src/constants.ts | 10 +++++++ src/middleware/auth.ts | 55 ++++++++++++++++++++++++++------------ src/routes/api/v1/auth.ts | 14 ++++++---- src/routes/api/v1/files.ts | 3 ++- src/routes/pages.ts | 16 +++++++---- src/types.ts | 1 + 6 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 src/constants.ts diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..bea0d34 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +// Family-wide session policy. Used by every bchen.dev app's session cookie. +// DO NOT diverge per-app — coherence across apps is a feature. + +export const SESSION_TTL_DAYS = 30; +export const SESSION_TTL_SECONDS = SESSION_TTL_DAYS * 24 * 60 * 60; +export const SESSION_RENEW_THRESHOLD_SECONDS = 60 * 60; +// Phase 1: keep cookie name as 'token' so existing tests stay green. +// Phase 2 flips this to 'nanodrop_session'. +export const SESSION_COOKIE_NAME = 'token'; +export const LOGOUT_PATHS = new Set(['/logout', '/api/v1/auth/logout']); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 77050e5..812a637 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,24 +1,45 @@ -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; +import type { Config } from '../config.ts'; +import type { JwtPayload } from '../types.ts'; +import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts'; -export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { - try { - await request.jwtVerify(); - } catch { - // API routes get 401, page routes get redirect - const isApi = request.url.startsWith('/api/'); - if (isApi) { - reply.status(401).send({ error: 'Unauthorized' }); - } else { - reply.redirect('/'); - } - } -} - -export function tokenCookieOptions(secure: boolean): { +export function sessionCookieOptions(secure: boolean): { httpOnly: boolean; sameSite: 'strict'; secure: boolean; path: string; + maxAge: number; } { - return { httpOnly: true, sameSite: 'strict', secure, path: '/' }; + return { + httpOnly: true, + sameSite: 'strict', + secure, + path: '/', + maxAge: SESSION_TTL_SECONDS, + }; +} + +export function issueSessionCookie( + reply: FastifyReply, + server: FastifyInstance, + claims: JwtPayload, + cookieSecure: boolean, +): void { + const token = server.jwt.sign(claims, { expiresIn: SESSION_TTL_SECONDS }); + reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure)); +} + +export function makeRequireAuth(_config: Config) { + return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { + try { + await request.jwtVerify(); + } catch { + const isApi = request.url.startsWith('/api/'); + if (isApi) { + reply.status(401).send({ error: 'Unauthorized' }); + } else { + reply.redirect('/'); + } + } + }; } diff --git a/src/routes/api/v1/auth.ts b/src/routes/api/v1/auth.ts index 7b11200..fe3632b 100644 --- a/src/routes/api/v1/auth.ts +++ b/src/routes/api/v1/auth.ts @@ -4,7 +4,8 @@ import type { Config } from '../../../config.ts'; import type { Logger } from '../../../middleware/logging.ts'; import type { LockoutService } from '../../../services/lockout.ts'; import { attemptLogin } from '../../../services/login-handler.ts'; -import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts'; +import { makeRequireAuth, issueSessionCookie } from '../../../middleware/auth.ts'; +import { SESSION_COOKIE_NAME } from '../../../constants.ts'; interface Deps { db: Database.Database; @@ -20,6 +21,7 @@ interface LoginBody { export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { config } = deps; + const requireAuth = makeRequireAuth(config); app.post<{ Body: LoginBody }>( '/login', @@ -56,15 +58,17 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d return reply.status(401).send({ error: 'Invalid credentials' }); } - const token = app.jwt.sign( + issueSessionCookie( + reply, + app, { sub: result.user.id, username: result.user.username }, - { expiresIn: config.jwtExpiry }, + config.cookieSecure, ); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); + reply.send({ ok: true }); }, ); app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { - reply.clearCookie('token', { path: '/' }).send({ ok: true }); + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).send({ ok: true }); }); }; diff --git a/src/routes/api/v1/files.ts b/src/routes/api/v1/files.ts index 4da58ce..fd582df 100644 --- a/src/routes/api/v1/files.ts +++ b/src/routes/api/v1/files.ts @@ -7,7 +7,7 @@ import type { Logger } from '../../../middleware/logging.ts'; import type { JwtPayload } from '../../../types.ts'; import { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts'; import { saveFile, deleteStoredFile } from '../../../services/storage.ts'; -import { requireAuth } from '../../../middleware/auth.ts'; +import { makeRequireAuth } from '../../../middleware/auth.ts'; interface Deps { db: Database.Database; @@ -17,6 +17,7 @@ interface Deps { export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { db, config } = deps; + const requireAuth = makeRequireAuth(config); app.get('/', { preHandler: requireAuth }, async (request, reply) => { const { sub: userId } = request.user as JwtPayload; diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 0800896..6b16a52 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -10,7 +10,8 @@ import type { LockoutService } from '../services/lockout.ts'; import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; import { attemptLogin } from '../services/login-handler.ts'; -import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; +import { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts'; +import { SESSION_COOKIE_NAME } from '../constants.ts'; import { loginPage } from '../views/login.ts'; import { uploadPage, uploadResultPage } from '../views/upload.ts'; import { fileListPage } from '../views/file-list.ts'; @@ -49,6 +50,7 @@ function parseRangeHeader(header: string, fileSize: number): { start: number; en export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { db, config, logger } = deps; + const requireAuth = makeRequireAuth(config); const loginRateLimit = { rateLimit: { max: config.loginRateLimitMax, @@ -57,6 +59,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }; // GET / — login page or redirect if authed + // opportunistic auth — does not slide the session; see middleware/session-renewal.ts app.get('/', async (request, reply) => { try { await request.jwtVerify(); @@ -91,17 +94,19 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); } - const token = app.jwt.sign( + issueSessionCookie( + reply, + app, { sub: result.user.id, username: result.user.username }, - { expiresIn: config.jwtExpiry }, + config.cookieSecure, ); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); + reply.redirect('/upload'); }, ); // POST /logout app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { - reply.clearCookie('token', { path: '/' }).redirect('/'); + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).redirect('/'); }); // GET /upload @@ -163,6 +168,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }); // GET /f/:id — public file view (owner-aware) + // opportunistic auth — does not slide the session; see middleware/session-renewal.ts app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => { const { id } = request.params; diff --git a/src/types.ts b/src/types.ts index 7408ccf..5c126e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ export interface JwtPayload { sub: number; username: string; + iat?: number; } From 623a3374cfccb42809fe7c37811a3ca85270affc Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:12:25 -0700 Subject: [PATCH 2/6] feat(auth): rename session cookie to nanodrop_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips SESSION_COOKIE_NAME from 'token' to 'nanodrop_session' per the family per-app naming convention (_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. --- src/constants.ts | 4 +--- src/server.ts | 3 ++- tests/helpers/setup.ts | 3 ++- tests/integration/auth-api.test.ts | 9 ++++---- tests/integration/auth-rate-limit.test.ts | 4 ++-- tests/integration/files-api.test.ts | 10 ++++---- tests/integration/pages.test.ts | 28 +++++++++++------------ 7 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index bea0d34..943ab53 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,7 +4,5 @@ export const SESSION_TTL_DAYS = 30; export const SESSION_TTL_SECONDS = SESSION_TTL_DAYS * 24 * 60 * 60; export const SESSION_RENEW_THRESHOLD_SECONDS = 60 * 60; -// Phase 1: keep cookie name as 'token' so existing tests stay green. -// Phase 2 flips this to 'nanodrop_session'. -export const SESSION_COOKIE_NAME = 'token'; +export const SESSION_COOKIE_NAME = 'nanodrop_session'; export const LOGOUT_PATHS = new Set(['/logout', '/api/v1/auth/logout']); diff --git a/src/server.ts b/src/server.ts index cbc0ce3..67e2864 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { join } from 'path'; import { fileURLToPath } from 'url'; import type Database from 'better-sqlite3'; import type { Config } from './config.ts'; +import { SESSION_COOKIE_NAME } from './constants.ts'; import { createLogger } from './middleware/logging.ts'; import { createLockoutService } from './services/lockout.ts'; import { authApiRoutes } from './routes/api/v1/auth.ts'; @@ -30,7 +31,7 @@ export function createServer({ config, db }: ServerDeps) { app.register(fastifyCookie); app.register(fastifyJwt, { secret: config.jwtSecret, - cookie: { cookieName: 'token', signed: false }, + cookie: { cookieName: SESSION_COOKIE_NAME, signed: false }, }); app.register(fastifyFormbody); app.register(fastifyMultipart, { limits: { fileSize: config.maxFileSize } }); diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts index a105ee6..2c466b1 100644 --- a/tests/helpers/setup.ts +++ b/tests/helpers/setup.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { initDb } from '../../src/db/schema.ts'; import { createServer } from '../../src/server.ts'; +import { SESSION_COOKIE_NAME } from '../../src/constants.ts'; import type { Config } from '../../src/config.ts'; import type Database from 'better-sqlite3'; import type { FastifyInstance } from 'fastify'; @@ -15,7 +16,7 @@ export async function loginAs(app: FastifyInstance, username: string, password: body: JSON.stringify({ username, password }), }); const cookie = res.headers['set-cookie'] as string; - return cookie.split(';')[0].replace('token=', ''); + return cookie.split(';')[0].replace(`${SESSION_COOKIE_NAME}=`, ''); } interface MultipartFile { diff --git a/tests/integration/auth-api.test.ts b/tests/integration/auth-api.test.ts index b8636d1..b26ff25 100644 --- a/tests/integration/auth-api.test.ts +++ b/tests/integration/auth-api.test.ts @@ -26,7 +26,8 @@ describe('POST /api/v1/auth/login', () => { }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ ok: true }); - expect(res.headers['set-cookie']).toMatch(/token=/); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); + expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/); }); it('returns 401 on wrong password', async () => { @@ -76,7 +77,7 @@ describe('POST /api/v1/auth/logout', () => { body: JSON.stringify({ username: 'alice', password: 'secret' }), }); const cookie = res.headers['set-cookie'] as string; - token = cookie.split(';')[0].replace('token=', ''); + token = cookie.split(';')[0].replace('nanodrop_session=', ''); }); afterEach(async () => { @@ -88,10 +89,10 @@ describe('POST /api/v1/auth/logout', () => { const res = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/logout', - cookies: { token }, + cookies: { nanodrop_session: token }, }); expect(res.statusCode).toBe(200); - expect(res.headers['set-cookie']).toMatch(/token=;/); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=;/); }); it('returns 401 without cookie', async () => { diff --git a/tests/integration/auth-rate-limit.test.ts b/tests/integration/auth-rate-limit.test.ts index bfb898b..091cd15 100644 --- a/tests/integration/auth-rate-limit.test.ts +++ b/tests/integration/auth-rate-limit.test.ts @@ -56,14 +56,14 @@ describe('per-IP rate limit on login routes', () => { body: JSON.stringify({ username: 'alice', password: 'correct-pw' }), }); expect(loginRes.statusCode).toBe(200); - const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('token=', ''); + const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('nanodrop_session=', ''); // Now hit /upload (GET) repeatedly past the login-route limit threshold for (let i = 0; i < 6; i++) { const r = await ctx.app.inject({ method: 'GET', url: '/upload', - cookies: { token: cookie }, + cookies: { nanodrop_session: cookie }, }); expect(r.statusCode).toBe(200); } diff --git a/tests/integration/files-api.test.ts b/tests/integration/files-api.test.ts index 52b8893..3be5882 100644 --- a/tests/integration/files-api.test.ts +++ b/tests/integration/files-api.test.ts @@ -23,7 +23,7 @@ describe('GET /api/v1/files', () => { const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files', - cookies: { token }, + cookies: { nanodrop_session: token }, }); expect(res.statusCode).toBe(200); expect(res.json().files).toEqual([]); @@ -55,7 +55,7 @@ describe('POST /api/v1/files', () => { const res = await ctx.app.inject({ method: 'POST', url: '/api/v1/files', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hello') } }), }); expect(res.statusCode).toBe(201); @@ -84,7 +84,7 @@ describe('DELETE /api/v1/files/:id', () => { const uploadRes = await ctx.app.inject({ method: 'POST', url: '/api/v1/files', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'f.txt', contentType: 'text/plain', data: Buffer.from('data') } }), }); fileId = uploadRes.json().file.id; @@ -99,7 +99,7 @@ describe('DELETE /api/v1/files/:id', () => { const res = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/files/${fileId}`, - cookies: { token }, + cookies: { nanodrop_session: token }, }); expect(res.statusCode).toBe(200); }); @@ -108,7 +108,7 @@ describe('DELETE /api/v1/files/:id', () => { const res = await ctx.app.inject({ method: 'DELETE', url: '/api/v1/files/doesnotexist', - cookies: { token }, + cookies: { nanodrop_session: token }, }); expect(res.statusCode).toBe(404); }); diff --git a/tests/integration/pages.test.ts b/tests/integration/pages.test.ts index 20402c8..d5ccb60 100644 --- a/tests/integration/pages.test.ts +++ b/tests/integration/pages.test.ts @@ -25,7 +25,7 @@ describe('GET /', () => { it('redirects to /upload when authenticated', async () => { const token = await loginAs(ctx.app, 'alice', 'secret'); - const res = await ctx.app.inject({ method: 'GET', url: '/', cookies: { token } }); + const res = await ctx.app.inject({ method: 'GET', url: '/', cookies: { nanodrop_session: token } }); expect(res.statusCode).toBe(302); expect(res.headers['location']).toBe('/upload'); }); @@ -46,7 +46,7 @@ describe('POST /login (page)', () => { }); expect(res.statusCode).toBe(302); expect(res.headers['location']).toBe('/upload'); - expect(res.headers['set-cookie']).toMatch(/token=/); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); }); it('shows login page with error on invalid credentials', async () => { @@ -72,7 +72,7 @@ describe('GET /upload + POST /upload', () => { afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); it('shows upload form', async () => { - const res = await ctx.app.inject({ method: 'GET', url: '/upload', cookies: { token } }); + const res = await ctx.app.inject({ method: 'GET', url: '/upload', cookies: { nanodrop_session: token } }); expect(res.statusCode).toBe(200); expect(res.body).toContain('Upload'); }); @@ -87,7 +87,7 @@ describe('GET /upload + POST /upload', () => { const res = await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'doc.txt', contentType: 'text/plain', data: Buffer.from('content') } }), }); expect(res.statusCode).toBe(200); @@ -106,7 +106,7 @@ describe('GET /f/:id and GET /f/:id/raw', () => { const uploadRes = await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'hello.txt', contentType: 'text/plain', data: Buffer.from('hello!') } }), }); // Extract file id from response body @@ -188,7 +188,7 @@ describe('GET /f/:id — image inline', () => { const uploadRes = await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'photo.png', contentType: 'image/png', data: Buffer.from('fakepng') } }), }); const match = uploadRes.body.match(/\/f\/([^/"]+)/); @@ -213,14 +213,14 @@ describe('GET /files — copy link', () => { await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hi') } }), }); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); it('shows Copy link button for each file', async () => { - const res = await ctx.app.inject({ method: 'GET', url: '/files', cookies: { token } }); + const res = await ctx.app.inject({ method: 'GET', url: '/files', cookies: { nanodrop_session: token } }); expect(res.statusCode).toBe(200); expect(res.body).toContain('Copy link'); expect(res.body).toContain('class="table-wrap"'); @@ -245,7 +245,7 @@ describe('GET /f/:id — owner-aware header', () => { const uploadRes = await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token: aliceToken }, + cookies: { nanodrop_session: aliceToken }, ...buildMultipart({ file: { filename: 'owned.txt', contentType: 'text/plain', data: Buffer.from('data') } }), }); const match = uploadRes.body.match(/\/f\/([^/"]+)/); @@ -254,19 +254,19 @@ describe('GET /f/:id — owner-aware header', () => { afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); it('shows nav when owner views their file', async () => { - const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } }); + const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: aliceToken } }); expect(res.statusCode).toBe(200); expect(res.body).toContain('My Files'); }); it('shows delete button when owner views', async () => { - const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } }); + const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: aliceToken } }); expect(res.statusCode).toBe(200); expect(res.body).toContain('delete'); }); it('no header when non-owner views', async () => { - const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: bobToken } }); + const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: bobToken } }); expect(res.statusCode).toBe(200); expect(res.body).not.toContain(' { const uploadRes = await ctx.app.inject({ method: 'POST', url: '/upload', - cookies: { token }, + cookies: { nanodrop_session: token }, ...buildMultipart({ file: { filename: 'del.txt', contentType: 'text/plain', data: Buffer.from('bye') } }), }); const match = uploadRes.body.match(/\/f\/([^/"]+)/); @@ -301,7 +301,7 @@ describe('POST /files/:id/delete', () => { const res = await ctx.app.inject({ method: 'POST', url: `/files/${fileId}/delete`, - cookies: { token }, + cookies: { nanodrop_session: token }, }); expect(res.statusCode).toBe(302); expect(res.headers['location']).toBe('/files'); From 0f0c2f0e96b5b784a89cf11577aa63b5838c507d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:15:31 -0700 Subject: [PATCH 3/6] feat(auth): sliding session renewal middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/middleware/session-renewal.ts with slideSessionIfNeeded — a pure function that re-mints the session cookie iff the JWT is older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into makeRequireAuth so every authenticated request bumps the cookie's expiration window forward, giving the 'stay signed in unless you clear cookies' UX. Logout paths are explicitly guarded via LOGOUT_PATHS so the renewer never resurrects a session the user is actively terminating. Query-string strip prevents /logout?next=foo bypass. Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly without going through makeRequireAuth, so they don't slide — by design, a public file view shouldn't extend the owner's session. Tests cover threshold semantics, both logout paths, query-string handling, missing iat (legacy token forces refresh), and a full integration suite simulating 25-day and 31-day jumps via vi.setSystemTime. --- src/middleware/auth.ts | 6 +- src/middleware/session-renewal.ts | 27 ++++ tests/integration/session-persistence.test.ts | 100 ++++++++++++++ tests/unit/session-renewal.test.ts | 122 ++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/middleware/session-renewal.ts create mode 100644 tests/integration/session-persistence.test.ts create mode 100644 tests/unit/session-renewal.test.ts diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 812a637..a72cb1c 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -2,6 +2,7 @@ import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; import type { Config } from '../config.ts'; import type { JwtPayload } from '../types.ts'; import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts'; +import { slideSessionIfNeeded } from './session-renewal.ts'; export function sessionCookieOptions(secure: boolean): { httpOnly: boolean; @@ -29,10 +30,11 @@ export function issueSessionCookie( reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure)); } -export function makeRequireAuth(_config: Config) { +export function makeRequireAuth(config: Config) { return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { try { - await request.jwtVerify(); + const payload = await request.jwtVerify(); + slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure); } catch { const isApi = request.url.startsWith('/api/'); if (isApi) { diff --git a/src/middleware/session-renewal.ts b/src/middleware/session-renewal.ts new file mode 100644 index 0000000..b6878b9 --- /dev/null +++ b/src/middleware/session-renewal.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; +import type { JwtPayload } from '../types.ts'; +import { LOGOUT_PATHS, SESSION_RENEW_THRESHOLD_SECONDS } from '../constants.ts'; +import { issueSessionCookie } from './auth.ts'; + +// Family pattern includes an mfa-pending early-return; nanodrop has no MFA, so this is N/A. +export function slideSessionIfNeeded( + request: FastifyRequest, + reply: FastifyReply, + payload: JwtPayload & { iat?: number }, + server: FastifyInstance, + cookieSecure: boolean, +): void { + const path = request.url.split('?', 1)[0]; + if (LOGOUT_PATHS.has(path)) return; + + const nowSec = Math.floor(Date.now() / 1000); + const iat = payload.iat ?? 0; + if (nowSec - iat < SESSION_RENEW_THRESHOLD_SECONDS) return; + + issueSessionCookie( + reply, + server, + { sub: payload.sub, username: payload.username }, + cookieSecure, + ); +} diff --git a/tests/integration/session-persistence.test.ts b/tests/integration/session-persistence.test.ts new file mode 100644 index 0000000..ae54bf7 --- /dev/null +++ b/tests/integration/session-persistence.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestApp, type TestContext, loginAs } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +describe('session persistence (sliding renewal)', () => { + let ctx: TestContext; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + vi.useRealTimers(); + await ctx.app.close(); + ctx.cleanup(); + }); + + it('login response includes Set-Cookie with Max-Age=2592000', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'secret' }), + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); + expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/); + }); + + it('request 25 days after login triggers a fresh Set-Cookie', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-26T00:00:00Z')); // +25 days + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toBeTruthy(); + expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/); + expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/); + }); + + it('request within renewal threshold does NOT include a fresh Set-Cookie', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-01T00:30:00Z')); // +30 minutes (under 1 hour) + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toBeFalsy(); + }); + + it('logout still clears cookie even after renewal threshold has passed', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-01-01T02:00:00Z')); // +2 hours (past threshold) + + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(200); + const setCookie = res.headers['set-cookie'] as string | string[]; + const setCookieStr = Array.isArray(setCookie) ? setCookie.join('\n') : setCookie; + // Cookie cleared (Max-Age=0 or empty value) + expect(setCookieStr).toMatch(/nanodrop_session=;/); + // No fresh session cookie issued by the renewer + const freshIssue = /nanodrop_session=eyJ/.test(setCookieStr); + expect(freshIssue).toBe(false); + }); + + it('request 31 days after login is bounced (idle lapse)', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + vi.setSystemTime(new Date('2026-02-01T01:00:00Z')); // +31 days, past 30-day TTL + + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { nanodrop_session: token }, + }); + expect(res.statusCode).toBe(401); + }); + + it('anonymous request to /api/v1/files returns 401 with no Set-Cookie', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files' }); + expect(res.statusCode).toBe(401); + expect(res.headers['set-cookie']).toBeFalsy(); + }); +}); diff --git a/tests/unit/session-renewal.test.ts b/tests/unit/session-renewal.test.ts new file mode 100644 index 0000000..abd789a --- /dev/null +++ b/tests/unit/session-renewal.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyJwt from '@fastify/jwt'; +import { slideSessionIfNeeded } from '../../src/middleware/session-renewal.ts'; +import { + SESSION_COOKIE_NAME, + SESSION_RENEW_THRESHOLD_SECONDS, + SESSION_TTL_SECONDS, +} from '../../src/constants.ts'; + +interface TestPayload { + sub: number; + username: string; + iat?: number; +} + +async function buildTestServer(): Promise { + const app = Fastify(); + await app.register(fastifyCookie); + await app.register(fastifyJwt, { + secret: 'test-secret', + cookie: { cookieName: SESSION_COOKIE_NAME, signed: false }, + }); + await app.ready(); + return app; +} + +const NOW_ISO = '2026-01-01T12:00:00Z'; +const NOW_SEC = Math.floor(new Date(NOW_ISO).getTime() / 1000); + +describe('slideSessionIfNeeded', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(NOW_ISO)); + app = await buildTestServer(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await app.close(); + }); + + function fakeReply(): { setCookieCalls: Array<{ name: string; value: string; opts: Record }> } & Record { + const setCookieCalls: Array<{ name: string; value: string; opts: Record }> = []; + return { + setCookieCalls, + setCookie(name: string, value: string, opts: Record) { + setCookieCalls.push({ name, value, opts }); + return this; + }, + }; + } + + function fakeRequest(url: string): { url: string } { + return { url }; + } + + it('re-issues cookie when now - iat >= SESSION_RENEW_THRESHOLD_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { + sub: 1, + username: 'alice', + iat: NOW_SEC - SESSION_RENEW_THRESHOLD_SECONDS, + }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(1); + expect(reply.setCookieCalls[0].name).toBe(SESSION_COOKIE_NAME); + expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS); + }); + + it('skips re-issue when now - iat < SESSION_RENEW_THRESHOLD_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { + sub: 1, + username: 'alice', + iat: NOW_SEC - (SESSION_RENEW_THRESHOLD_SECONDS - 1), + }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue on /logout path', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient + slideSessionIfNeeded(fakeRequest('/logout') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue on /api/v1/auth/logout path', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/api/v1/auth/logout') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('skips re-issue when /logout has a query string', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/logout?redirect=foo') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(0); + }); + + it('treats missing iat as 0 (forces refresh)', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice' }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); + expect(reply.setCookieCalls.length).toBe(1); + }); + + it('produces a cookie carrying Max-Age=SESSION_TTL_SECONDS', () => { + const reply = fakeReply(); + const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, true); + expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS); + expect(reply.setCookieCalls[0].opts.secure).toBe(true); + expect(reply.setCookieCalls[0].opts.httpOnly).toBe(true); + expect(reply.setCookieCalls[0].opts.sameSite).toBe('strict'); + }); +}); From a4355e1ef3e9084a99880ee739e95f6b45fdc691 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:17:04 -0700 Subject: [PATCH 4/6] refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/config.ts | 2 -- tests/helpers/setup.ts | 1 - tests/unit/config.test.ts | 4 ---- tests/unit/lockout-service.test.ts | 1 - tests/unit/login-handler.test.ts | 1 - 5 files changed, 9 deletions(-) diff --git a/src/config.ts b/src/config.ts index 63f93f1..763a1bf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ export interface Config { port: number; host: string; jwtSecret: string; - jwtExpiry: string; dbPath: string; uploadDir: string; logFile: string; @@ -28,7 +27,6 @@ export function loadConfig(): Config { port: parseInt(process.env.PORT ?? '3000', 10), host: process.env.HOST ?? '0.0.0.0', jwtSecret, - jwtExpiry: process.env.JWT_EXPIRY ?? '7d', dbPath: process.env.DB_PATH ?? './data/nanodrop.db', uploadDir: process.env.UPLOAD_DIR ?? './data/uploads', logFile: process.env.LOG_FILE ?? './data/nanodrop.log', diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts index 2c466b1..fa9c1d4 100644 --- a/tests/helpers/setup.ts +++ b/tests/helpers/setup.ts @@ -67,7 +67,6 @@ export function createTestApp(overrides: Partial = {}): TestContext { port: 0, host: '127.0.0.1', jwtSecret: 'test-secret-key', - jwtExpiry: '1h', dbPath: ':memory:', uploadDir, logFile, diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index f0b80e9..fc4fa07 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -15,7 +15,6 @@ describe('config', () => { process.env.JWT_SECRET = 'test-secret'; delete process.env.PORT; delete process.env.HOST; - delete process.env.JWT_EXPIRY; delete process.env.DB_PATH; delete process.env.UPLOAD_DIR; delete process.env.LOG_FILE; @@ -35,7 +34,6 @@ describe('config', () => { expect(config.port).toBe(3000); expect(config.host).toBe('0.0.0.0'); - expect(config.jwtExpiry).toBe('7d'); expect(config.dbPath).toBe('./data/nanodrop.db'); expect(config.uploadDir).toBe('./data/uploads'); expect(config.logFile).toBe('./data/nanodrop.log'); @@ -55,7 +53,6 @@ describe('config', () => { process.env.JWT_SECRET = 'my-secret'; process.env.PORT = '4000'; process.env.HOST = '127.0.0.1'; - process.env.JWT_EXPIRY = '1d'; process.env.COOKIE_SECURE = 'true'; process.env.TRUST_PROXY = 'true'; process.env.MAX_FILE_SIZE = '52428800'; @@ -66,7 +63,6 @@ describe('config', () => { expect(config.port).toBe(4000); expect(config.host).toBe('127.0.0.1'); expect(config.jwtSecret).toBe('my-secret'); - expect(config.jwtExpiry).toBe('1d'); expect(config.cookieSecure).toBe(true); expect(config.trustProxy).toBe(true); expect(config.maxFileSize).toBe(52428800); diff --git a/tests/unit/lockout-service.test.ts b/tests/unit/lockout-service.test.ts index 5d8cddb..595a415 100644 --- a/tests/unit/lockout-service.test.ts +++ b/tests/unit/lockout-service.test.ts @@ -10,7 +10,6 @@ function makeConfig(overrides: Partial = {}): Config { port: 0, host: '127.0.0.1', jwtSecret: 'x', - jwtExpiry: '1h', dbPath: ':memory:', uploadDir: '/tmp', logFile: '/tmp/x.log', diff --git a/tests/unit/login-handler.test.ts b/tests/unit/login-handler.test.ts index db9ca90..2a3042c 100644 --- a/tests/unit/login-handler.test.ts +++ b/tests/unit/login-handler.test.ts @@ -17,7 +17,6 @@ function makeConfig(overrides: Partial = {}): Config { port: 0, host: '127.0.0.1', jwtSecret: 'x', - jwtExpiry: '1h', dbPath: ':memory:', uploadDir: '/tmp', logFile: '/tmp/x.log', From cbc22dcac422843e097a7e94fed4414591aaa910 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:23:24 -0700 Subject: [PATCH 5/6] refactor: drop redundant iat intersections and reuse JwtPayload in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/middleware/auth.ts | 2 +- src/middleware/session-renewal.ts | 2 +- tests/unit/session-renewal.test.ts | 21 ++++++++------------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index a72cb1c..81c5af9 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -33,7 +33,7 @@ export function issueSessionCookie( export function makeRequireAuth(config: Config) { return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { try { - const payload = await request.jwtVerify(); + const payload = await request.jwtVerify(); slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure); } catch { const isApi = request.url.startsWith('/api/'); diff --git a/src/middleware/session-renewal.ts b/src/middleware/session-renewal.ts index b6878b9..f3ebd4b 100644 --- a/src/middleware/session-renewal.ts +++ b/src/middleware/session-renewal.ts @@ -7,7 +7,7 @@ import { issueSessionCookie } from './auth.ts'; export function slideSessionIfNeeded( request: FastifyRequest, reply: FastifyReply, - payload: JwtPayload & { iat?: number }, + payload: JwtPayload, server: FastifyInstance, cookieSecure: boolean, ): void { diff --git a/tests/unit/session-renewal.test.ts b/tests/unit/session-renewal.test.ts index abd789a..ad88141 100644 --- a/tests/unit/session-renewal.test.ts +++ b/tests/unit/session-renewal.test.ts @@ -3,18 +3,13 @@ import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyJwt from '@fastify/jwt'; import { slideSessionIfNeeded } from '../../src/middleware/session-renewal.ts'; +import type { JwtPayload } from '../../src/types.ts'; import { SESSION_COOKIE_NAME, SESSION_RENEW_THRESHOLD_SECONDS, SESSION_TTL_SECONDS, } from '../../src/constants.ts'; -interface TestPayload { - sub: number; - username: string; - iat?: number; -} - async function buildTestServer(): Promise { const app = Fastify(); await app.register(fastifyCookie); @@ -60,7 +55,7 @@ describe('slideSessionIfNeeded', () => { it('re-issues cookie when now - iat >= SESSION_RENEW_THRESHOLD_SECONDS', () => { const reply = fakeReply(); - const payload: TestPayload = { + const payload: JwtPayload = { sub: 1, username: 'alice', iat: NOW_SEC - SESSION_RENEW_THRESHOLD_SECONDS, @@ -73,7 +68,7 @@ describe('slideSessionIfNeeded', () => { it('skips re-issue when now - iat < SESSION_RENEW_THRESHOLD_SECONDS', () => { const reply = fakeReply(); - const payload: TestPayload = { + const payload: JwtPayload = { sub: 1, username: 'alice', iat: NOW_SEC - (SESSION_RENEW_THRESHOLD_SECONDS - 1), @@ -84,35 +79,35 @@ describe('slideSessionIfNeeded', () => { it('skips re-issue on /logout path', () => { const reply = fakeReply(); - const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient + const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient slideSessionIfNeeded(fakeRequest('/logout') as never, reply as never, payload, app, false); expect(reply.setCookieCalls.length).toBe(0); }); it('skips re-issue on /api/v1/auth/logout path', () => { const reply = fakeReply(); - const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 }; slideSessionIfNeeded(fakeRequest('/api/v1/auth/logout') as never, reply as never, payload, app, false); expect(reply.setCookieCalls.length).toBe(0); }); it('skips re-issue when /logout has a query string', () => { const reply = fakeReply(); - const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 }; slideSessionIfNeeded(fakeRequest('/logout?redirect=foo') as never, reply as never, payload, app, false); expect(reply.setCookieCalls.length).toBe(0); }); it('treats missing iat as 0 (forces refresh)', () => { const reply = fakeReply(); - const payload: TestPayload = { sub: 1, username: 'alice' }; + const payload: JwtPayload = { sub: 1, username: 'alice' }; slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false); expect(reply.setCookieCalls.length).toBe(1); }); it('produces a cookie carrying Max-Age=SESSION_TTL_SECONDS', () => { const reply = fakeReply(); - const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; + const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 }; slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, true); expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS); expect(reply.setCookieCalls[0].opts.secure).toBe(true); From 3b3a56cd947e31f3198420fe2f90af430aaa52b7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 9 May 2026 10:29:32 -0700 Subject: [PATCH 6/6] docs: drop JWT_EXPIRY from .env.example, docker-compose, README (family TTL is canonical) --- .env.example | 1 - README.md | 3 ++- docker-compose.yml | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index fd7f705..35d4313 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ PORT=3000 HOST=0.0.0.0 JWT_SECRET=change-me-to-a-long-random-secret -JWT_EXPIRY=7d DB_PATH=./data/nanodrop.db UPLOAD_DIR=./data/uploads LOG_FILE=./data/nanodrop.log diff --git a/README.md b/README.md index f1b9c0c..85bb986 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ docker compose run --rm register-user --username alice --password secret | Variable | Default | Description | |---|---|---| | `JWT_SECRET` | *(required)* | Secret key for signing JWTs | -| `JWT_EXPIRY` | `7d` | JWT token lifetime | | `PORT` | `3000` | Port to listen on | | `HOST` | `0.0.0.0` | Host to bind | | `BASE_URL` | `http://localhost:3000` | Public base URL (used in share links) | @@ -71,6 +70,8 @@ docker compose run --rm register-user --username alice --password secret | `COOKIE_SECURE` | `false` | Set `true` when serving over HTTPS | | `TRUST_PROXY` | `false` | Set `true` when behind a reverse proxy | +Session lifetime is family-pinned to 30 days with sliding renewal (see `src/constants.ts`). Not configurable per deployment. + ### Reverse proxy Set `TRUST_PROXY=true` when running behind a reverse proxy so Nanodrop sees the real client IP in logs. diff --git a/docker-compose.yml b/docker-compose.yml index c3aa65f..d54be92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ x-env: &env PORT: "${PORT:-3000}" HOST: "${HOST:-0.0.0.0}" JWT_SECRET: "${JWT_SECRET}" - JWT_EXPIRY: "${JWT_EXPIRY:-7d}" DB_PATH: "${DB_PATH:-./data/nanodrop.db}" UPLOAD_DIR: "${UPLOAD_DIR:-./data/uploads}" LOG_FILE: "${LOG_FILE:-./data/nanodrop.log}"