From 0cf85c144998a9cee07ec6ccdf63a4325563f8bd Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:07:00 +0530 Subject: [PATCH 1/8] fix(auth): rotate durable sessions and revoke credentials atomically --- apps/api/src/lib/agent-channel.ts | 2 +- apps/api/src/lib/web-sessions.ts | 115 +++++++ apps/api/src/middleware/auth.ts | 12 +- apps/api/src/routes/auth.ts | 112 +++---- apps/api/src/routes/invites.ts | 113 ++++--- apps/api/src/routes/members.ts | 213 ++++++------- apps/api/src/socket.ts | 25 +- apps/api/test/auth-refresh-contract.test.ts | 58 +--- apps/api/test/identity-hardening.test.ts | 148 ++++++++- apps/api/test/profile-settings.test.ts | 11 +- apps/api/test/teams.test.ts | 24 +- apps/api/test/web-session-family.test.ts | 147 +++++++++ .../src/app/(app)/settings/profile/page.tsx | 5 +- apps/web/src/lib/api-auth.test.ts | 292 ++++++++++++++++++ apps/web/src/lib/api-retry.test.ts | 77 +++++ apps/web/src/lib/api.ts | 67 +++- apps/web/src/lib/auth-context.tsx | 174 +++++++++-- .../2026-09-05-preview-session-decision.md | 15 + packages/db/scripts/apply-extras.ts | 6 + packages/db/src/schema.ts | 12 + .../0.3.0-preview.27-web-session-families.sql | 10 + .../0.3.0-preview.28-password-generation.sql | 1 + packages/db/upgrades/manifest.ts | 10 + 23 files changed, 1316 insertions(+), 333 deletions(-) create mode 100644 apps/api/src/lib/web-sessions.ts create mode 100644 apps/api/test/web-session-family.test.ts create mode 100644 apps/web/src/lib/api-auth.test.ts create mode 100644 apps/web/src/lib/api-retry.test.ts create mode 100644 docs/superpowers/plans/2026-09-05-preview-session-decision.md create mode 100644 packages/db/upgrades/0.3.0-preview.27-web-session-families.sql create mode 100644 packages/db/upgrades/0.3.0-preview.28-password-generation.sql diff --git a/apps/api/src/lib/agent-channel.ts b/apps/api/src/lib/agent-channel.ts index be118bee..759eec2e 100644 --- a/apps/api/src/lib/agent-channel.ts +++ b/apps/api/src/lib/agent-channel.ts @@ -47,7 +47,7 @@ export const AGENT_CHANNEL_AUTONOMOUS_REQUIRED_RUNTIME_CAPABILITIES = [ export type AgentChannelAdapterMode = 'supervised_runtime' | 'autonomous_platform'; export const DEFT_RELEASE_VERSION = process.env.DEFT_RELEASE_VERSION || '0.3.0-preview.14'; export const DEFT_BUILD_COMMIT = process.env.DEFT_BUILD_COMMIT || process.env.VCS_REF || 'unknown'; -export const DEFT_SCHEMA_HEAD = '0.3.0-preview.14'; +export const DEFT_SCHEMA_HEAD = '0.3.0-preview.28'; export const AGENT_CHANNEL_DEFAULT_LEASE_MS = 120_000; export const AGENT_CHANNEL_MIN_LEASE_MS = 30_000; export const AGENT_CHANNEL_MAX_LEASE_MS = 600_000; diff --git a/apps/api/src/lib/web-sessions.ts b/apps/api/src/lib/web-sessions.ts new file mode 100644 index 00000000..65c51a4c --- /dev/null +++ b/apps/api/src/lib/web-sessions.ts @@ -0,0 +1,115 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { and, eq, isNull } from 'drizzle-orm'; +import { webSessions, users, revokedTokens, orgMembers } from '@deft/db/schema'; +import { db } from './db.js'; +import { env } from './env.js'; +import { OrgMembershipError, requireActiveOrgMembership } from './org-membership.js'; + +const claimsSchema = z.object({ + id: z.string().min(1).max(256), email: z.string(), org_id: z.string().min(1).max(256), + sid: z.string().uuid(), jti: z.string().uuid(), + purpose: z.enum(['web-access', 'web-refresh']), exp: z.number(), +}); +type Identity = { id: string; email: string; org_id: string }; +const hash = (token: string) => createHash('sha256').update(token).digest('hex'); +export const webSessionEvents = new EventEmitter(); +export class WebCredentialsChangedError extends Error {} + +export function emitWebSessionRevocations(sessionIds: readonly string[]) { + for (const sessionId of sessionIds) webSessionEvents.emit('revoked', sessionId); +} + +function claims(token: string, purpose: 'web-access' | 'web-refresh') { + const value = claimsSchema.parse(jwt.verify(token, purpose === 'web-access' ? env.JWT_SECRET : env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] })); + if (value.purpose !== purpose) throw new Error('Invalid token purpose'); + return value; +} + +function tokens(identity: Identity, sid: string, expiresAt: Date) { + const remaining = Math.floor((expiresAt.getTime() - Date.now()) / 1000); + if (remaining <= 0) throw new Error('Session expired'); + return { + accessToken: jwt.sign({ ...identity, sid, jti: randomUUID(), purpose: 'web-access' }, env.JWT_SECRET, { algorithm: 'HS256', expiresIn: Math.min(900, remaining) }), + refreshToken: jwt.sign({ ...identity, sid, jti: randomUUID(), purpose: 'web-refresh' }, env.JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: remaining }), + }; +} + +export async function createWebSession(identity: Identity, expectedPasswordHash?: string) { + const sid = randomUUID(); + const expiresAt = new Date(Date.now() + 30 * 86400_000); + const pair = tokens(identity, sid, expiresAt); + await db.transaction(async (tx) => { + // Serialize issuance with password reset/change so an old-password login + // cannot create a family after that change has revoked earlier families. + const [user] = await tx.select().from(users).where(eq(users.id, identity.id)).for('share'); + if (!user || (expectedPasswordHash !== undefined && user.password_hash !== expectedPasswordHash)) throw new WebCredentialsChangedError('Credentials changed; sign in again'); + const [membership] = await tx.select({ id: orgMembers.id }).from(orgMembers).where(and(eq(orgMembers.org_id, identity.org_id), eq(orgMembers.user_id, identity.id), eq(orgMembers.is_active, true))).for('share'); + if (!membership) throw new OrgMembershipError('User is not an active member of this organization'); + await tx.insert(webSessions).values({ id: sid, user_id: identity.id, org_id: identity.org_id, refresh_token_hash: hash(pair.refreshToken), expires_at: expiresAt }); + }); + return pair; +} + +export async function verifyWebAccess(token: string) { + const payload = claims(token, 'web-access'); + const [session] = await db.select().from(webSessions).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id))).limit(1); + if (!session || session.revoked_at || session.expires_at.getTime() <= Date.now()) throw new Error('Session unavailable'); + const membership = await requireActiveOrgMembership(payload.org_id, payload.id); + return { ...payload, role: membership.role }; +} + +export async function rotateWebSession(token: string) { + const payload = claims(token, 'web-refresh'); + const result = await db.transaction(async (tx) => { + const [session] = await tx.select().from(webSessions).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id))).for('update'); + if (!session || session.revoked_at || session.expires_at.getTime() <= Date.now()) return null; + if (session.refresh_token_hash !== hash(token)) { + // Return rather than throw so revocation commits before the denial. + await tx.update(webSessions).set({ revoked_at: new Date() }).where(eq(webSessions.id, session.id)); + return null; + } + await requireActiveOrgMembership(payload.org_id, payload.id); + const pair = tokens({ id: payload.id, email: payload.email, org_id: payload.org_id }, session.id, session.expires_at); + await tx.update(webSessions).set({ refresh_token_hash: hash(pair.refreshToken) }).where(eq(webSessions.id, session.id)); + return pair; + }); + if (!result) { + webSessionEvents.emit('revoked', payload.sid); + throw new Error('Session revoked or refresh reused'); + } + return result; +} + +export async function revokeWebSession(token: string) { + const payload = claims(token, 'web-refresh'); + await db.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.id, payload.sid), eq(webSessions.user_id, payload.id), eq(webSessions.org_id, payload.org_id), isNull(webSessions.revoked_at))); + webSessionEvents.emit('revoked', payload.sid); +} + +type PasswordChangeAuthority = { expectedPasswordHash: string } | { resetToken: string; passwordVersion: number; orgId: string }; +export async function changeWebPassword(userId: string, passwordHash: string, authority: PasswordChangeAuthority) { + const sessions = await db.transaction(async (tx) => { + const [user] = await tx.select().from(users).where(eq(users.id, userId)).for('update'); + if (!user) throw new WebCredentialsChangedError('Credentials changed'); + if ('resetToken' in authority) { + if (user.password_version !== authority.passwordVersion) throw new WebCredentialsChangedError('Reset link superseded'); + const [membership] = await tx.select({ id: orgMembers.id }).from(orgMembers).where(and(eq(orgMembers.org_id, authority.orgId), eq(orgMembers.user_id, userId), eq(orgMembers.is_active, true))).for('share'); + if (!membership) throw new OrgMembershipError('Reset membership is no longer active'); + const inserted = await tx.insert(revokedTokens).values({ token_hash: hash(authority.resetToken), user_id: userId }).onConflictDoNothing().returning({ id: revokedTokens.id }); + if (!inserted.length) throw new Error('Reset token already used'); + } else if (user.password_hash !== authority.expectedPasswordHash) { + throw new WebCredentialsChangedError('Credentials changed'); + } + await tx.update(users).set({ password_hash: passwordHash, password_version: user.password_version + 1 }).where(eq(users.id, userId)); + return tx.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.user_id, userId), isNull(webSessions.revoked_at))).returning({ id: webSessions.id }); + }); + emitWebSessionRevocations(sessions.map(session => session.id)); +} + +export async function revokeMemberWebSessions(orgId: string, userId: string) { + const sessions = await db.update(webSessions).set({ revoked_at: new Date() }).where(and(eq(webSessions.org_id, orgId), eq(webSessions.user_id, userId), isNull(webSessions.revoked_at))).returning({ id: webSessions.id }); + emitWebSessionRevocations(sessions.map(session => session.id)); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 72af3d87..371977b2 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,7 +1,6 @@ import { createMiddleware } from 'hono/factory'; -import jwt from 'jsonwebtoken'; -import { env } from '../lib/env.js'; -import { OrgMembershipError, requireActiveOrgMembership, type OrgRole } from '../lib/org-membership.js'; +import { verifyWebAccess } from '../lib/web-sessions.js'; +import { OrgMembershipError, type OrgRole } from '../lib/org-membership.js'; export type AuthUser = { id: string; @@ -19,7 +18,7 @@ declare module 'hono' { export const authMiddleware = createMiddleware(async (c, next) => { // Skip auth for auth routes (they're mounted before this middleware) const path = c.req.path; - if (path.startsWith('/api/auth') || path.includes('/callback')) { + if (path.startsWith('/api/auth/')) { return next(); } @@ -30,9 +29,8 @@ export const authMiddleware = createMiddleware(async (c, next) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as AuthUser; - const membership = await requireActiveOrgMembership(payload.org_id, payload.id); - c.set('user', { ...payload, role: membership.role }); + const payload = await verifyWebAccess(token); + c.set('user', payload); return next(); } catch (err) { if (err instanceof OrgMembershipError) { diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 92fe78d7..be5da8fc 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; @@ -13,26 +14,27 @@ import { spaces, spaceMembers, onboardingState, - revokedTokens, type UserNotificationPreferences, } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { countOrgs, SINGLE_ORG_ERROR } from '../lib/single-org-guard.js'; import { ensureDeftyMembership, ensureDeftyDm } from '../lib/ensure-defty-membership.js'; import { OrgMembershipError, requireActiveOrgMembership } from '../lib/org-membership.js'; +import { createWebSession, rotateWebSession, revokeWebSession, changeWebPassword, verifyWebAccess, WebCredentialsChangedError } from '../lib/web-sessions.js'; export const authRoutes = new Hono(); +authRoutes.use('*', bodyLimit({ maxSize: 64 * 1024, onError: (c) => c.json({ error: 'Request body is too large', code: 'VALIDATION_ERROR' }, 413) })); const signupSchema = z.object({ - name: z.string().min(1), - email: z.string().email(), - password: z.string().min(8), - org_name: z.string().min(1), + name: z.string().min(1).max(160), + email: z.string().email().max(320), + password: z.string().min(8).max(1024), + org_name: z.string().min(1).max(160), }); const loginSchema = z.object({ - email: z.string().email(), - password: z.string().min(1), + email: z.string().email().max(320), + password: z.string().min(1).max(1024), }); const loginAttempts = new Map(); @@ -46,6 +48,7 @@ export function allowAccountLoginAttempt(email: string): boolean { const key = createHash('sha256').update(email.trim().toLowerCase()).digest('hex'); const current = loginAttempts.get(key); if (!current || current.resetAt <= now) { + if (!current && loginAttempts.size >= 10_000) return false; loginAttempts.set(key, { count: 1, resetAt: now + 60_000 }); return true; } @@ -53,20 +56,6 @@ export function allowAccountLoginAttempt(email: string): boolean { return current.count <= 10; } -function generateTokens(user: { id: string; email: string; org_id: string }) { - const accessToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_SECRET, - { expiresIn: '15m' } - ); - const refreshToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_REFRESH_SECRET, - { expiresIn: '30d' } - ); - return { accessToken, refreshToken }; -} - // GET /api/auth/has-workspace — public pre-check for the signup page authRoutes.get('/has-workspace', async (c) => { const { countOrgs } = await import('../lib/single-org-guard.js'); @@ -75,7 +64,7 @@ authRoutes.get('/has-workspace', async (c) => { // POST /api/auth/signup authRoutes.post('/signup', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = signupSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -160,7 +149,7 @@ authRoutes.post('/signup', async (c) => { org_created: true, }); - const tokens = generateTokens({ id: user!.id, email: user!.email!, org_id: org!.id }); + const tokens = await createWebSession({ id: user!.id, email: user!.email!, org_id: org!.id }); return c.json({ user: { id: user!.id, name: user!.name, email: user!.email }, @@ -171,7 +160,7 @@ authRoutes.post('/signup', async (c) => { // POST /api/auth/login authRoutes.post('/login', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = loginSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -212,7 +201,14 @@ authRoutes.post('/login', async (c) => { return c.json({ error: 'No active organization membership found', code: 'ORG_MEMBERSHIP_INACTIVE' }, 403); } - const tokens = generateTokens({ id: user.id, email: user.email!, org_id: membership.org_id }); + let tokens; + try { + tokens = await createWebSession({ id: user.id, email: user.email!, org_id: membership.org_id }, user.password_hash); + } catch (error) { + if (error instanceof WebCredentialsChangedError) return c.json({ error: 'Credentials changed. Please sign in again.', code: 'INVALID_CREDENTIALS' }, 401); + if (error instanceof OrgMembershipError) return c.json({ error: error.message, code: error.code }, 403); + throw error; + } return c.json({ user: { id: user.id, name: user.name, email: user.email }, @@ -223,24 +219,11 @@ authRoutes.post('/login', async (c) => { // POST /api/auth/refresh authRoutes.post('/refresh', async (c) => { - const body = await c.req.json(); - const { refreshToken } = body; - - if (!refreshToken) { - return c.json({ error: 'No refresh token', code: 'NO_TOKEN' }, 401); - } - - // Check revocation list before validating the JWT - const tokenHash = createHash('sha256').update(refreshToken).digest('hex'); - const [revoked] = await db.select().from(revokedTokens).where(eq(revokedTokens.token_hash, tokenHash)).limit(1); - if (revoked) { - return c.json({ error: 'Token revoked', code: 'TOKEN_REVOKED' }, 401); - } + const parsed = z.object({ refreshToken: z.string().min(1).max(8192) }).safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); try { - const payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as { id: string; email: string; org_id: string }; - await requireActiveOrgMembership(payload.org_id, payload.id); - const tokens = generateTokens({ id: payload.id, email: payload.email, org_id: payload.org_id }); + const tokens = await rotateWebSession(parsed.data.refreshToken); return c.json(tokens); } catch (err) { if (err instanceof OrgMembershipError) { @@ -250,23 +233,17 @@ authRoutes.post('/refresh', async (c) => { } }); -// POST /api/auth/logout — revoke the caller's refresh token +// POST /api/auth/logout — revoke the entire caller session, including access. authRoutes.post('/logout', async (c) => { + const parsed = z.object({ refreshToken: z.string().min(1).max(8192).optional() }).safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); try { - const body = await c.req.json().catch(() => ({} as { refreshToken?: string })); - const token = body.refreshToken; + const token = parsed.data.refreshToken; if (!token) return c.json({ ok: true }); // idempotent: nothing to revoke - - const tokenHash = createHash('sha256').update(token).digest('hex'); - await db.insert(revokedTokens).values({ - id: crypto.randomUUID(), - token_hash: tokenHash, - }).onConflictDoNothing(); - + await revokeWebSession(token); return c.json({ ok: true }); } catch (err) { - console.error('[auth] Failed to logout:', err); - return c.json({ error: 'Failed to logout', code: 'INTERNAL_ERROR' }, 500); + return c.json({ error: 'Invalid token', code: 'INVALID_TOKEN' }, 401); } }); @@ -279,7 +256,7 @@ authRoutes.get('/me', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); const [user] = await db.select({ id: users.id, @@ -413,12 +390,12 @@ authRoutes.post('/forgot-password', async (c) => { // POST /api/auth/reset-password — reset password with token const resetPasswordSchema = z.object({ - token: z.string().min(1), - password: z.string().min(8), + token: z.string().min(1).max(8192), + password: z.string().min(8).max(128), }); authRoutes.post('/reset-password', async (c) => { - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = resetPasswordSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -427,13 +404,10 @@ authRoutes.post('/reset-password', async (c) => { const { token, password } = parsed.data; try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; purpose?: string }; - if (payload.purpose !== 'password-reset') { - return c.json({ error: 'Invalid reset token', code: 'INVALID_TOKEN' }, 400); - } + const payload = z.object({ id: z.string().min(1), org_id: z.string().min(1), purpose: z.literal('password-reset'), password_version: z.number().int().nonnegative() }).parse(jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] })); const passwordHash = await bcrypt.hash(password, 12); - await db.update(users).set({ password_hash: passwordHash }).where(eq(users.id, payload.id)); + await changeWebPassword(payload.id, passwordHash, { resetToken: token, passwordVersion: payload.password_version, orgId: payload.org_id }); return c.json({ success: true, message: 'Password has been reset. You can now log in.' }); } catch { @@ -451,9 +425,9 @@ authRoutes.patch('/password', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = passwordChangeSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); @@ -474,7 +448,7 @@ authRoutes.patch('/password', async (c) => { } const passwordHash = await bcrypt.hash(parsed.data.new_password, 12); - await db.update(users).set({ password_hash: passwordHash }).where(eq(users.id, payload.id)); + await changeWebPassword(payload.id, passwordHash, { expectedPasswordHash: user.password_hash }); return c.json({ success: true }); } catch (err) { @@ -492,7 +466,7 @@ authRoutes.get('/onboarding', async (c) => { } const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); let [state] = await db.select().from(onboardingState).where(eq(onboardingState.user_id, payload.id)).limit(1); if (!state) { @@ -526,7 +500,7 @@ authRoutes.patch('/onboarding', async (c) => { } const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; org_id: string }; + const payload = await verifyWebAccess(token); await requireActiveOrgMembership(payload.org_id, payload.id); const body = await c.req.json().catch(() => ({})); const parsed = onboardingUpdateSchema.safeParse(body); @@ -560,9 +534,9 @@ authRoutes.patch('/me', async (c) => { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); const membership = await requireActiveOrgMembership(payload.org_id, payload.id); - const body = await c.req.json(); + const body = await c.req.json().catch(() => null); const parsed = profileUpdateSchema.safeParse(body); if (!parsed.success) { return c.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, 400); diff --git a/apps/api/src/routes/invites.ts b/apps/api/src/routes/invites.ts index a09e215d..9a8297fe 100644 --- a/apps/api/src/routes/invites.ts +++ b/apps/api/src/routes/invites.ts @@ -7,6 +7,7 @@ import { db } from '../lib/db.js'; import { users, orgs, orgMembers, invites } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { ensureDeftyMembership, ensureDeftyDm } from '../lib/ensure-defty-membership.js'; +import { createWebSession, WebCredentialsChangedError } from '../lib/web-sessions.js'; export const inviteRoutes = new Hono(); @@ -21,20 +22,6 @@ type InvitePayload = { exp?: number; }; -function generateAuthTokens(user: { id: string; email: string; org_id: string }) { - const accessToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_SECRET, - { expiresIn: '15m' }, - ); - const refreshToken = jwt.sign( - { id: user.id, email: user.email, org_id: user.org_id }, - env.JWT_REFRESH_SECRET, - { expiresIn: '30d' }, - ); - return { accessToken, refreshToken }; -} - // GET /api/invites/preview/:token — public preview of an invite // Used by the accept page to render "Sara invited you to Acme". inviteRoutes.get('/preview/:token', async (c) => { @@ -127,45 +114,6 @@ inviteRoutes.post('/accept', async (c) => { return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); } - const [invite] = await db - .select({ - id: invites.id, - accepted_at: invites.accepted_at, - expires_at: invites.expires_at, - }) - .from(invites) - .where(and(eq(invites.token, parsed.data.token), eq(invites.org_id, payload.org_id))) - .limit(1); - - if (!invite) { - return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); - } - if (invite.accepted_at) { - return c.json({ error: 'already accepted', code: 'INVITE_ALREADY_ACCEPTED' }, 400); - } - if (invite.expires_at && invite.expires_at < new Date()) { - return c.json({ error: 'expired', code: 'INVITE_EXPIRED' }, 400); - } - - // Verify user + membership still exist (admin may have removed them) - const [user] = await db - .select() - .from(users) - .where(eq(users.id, payload.user_id)) - .limit(1); - if (!user) { - return c.json({ error: 'invalid', code: 'INVITE_INVALID' }, 400); - } - - const [membership] = await db - .select() - .from(orgMembers) - .where(and(eq(orgMembers.user_id, payload.user_id), eq(orgMembers.org_id, payload.org_id))) - .limit(1); - if (!membership || !membership.is_active) { - return c.json({ error: 'invalid', code: 'INVITE_REVOKED' }, 400); - } - const passwordHash = await bcrypt.hash(parsed.data.password, 12); const updates: Record = { password_hash: passwordHash, @@ -173,11 +121,50 @@ inviteRoutes.post('/accept', async (c) => { }; if (parsed.data.name) updates.name = parsed.data.name; - await db.update(users).set(updates).where(eq(users.id, payload.user_id)); - await db - .update(invites) - .set({ accepted_by: payload.user_id, accepted_at: new Date() }) - .where(eq(invites.id, invite.id)); + type AcceptanceFailure = 'INVITE_INVALID' | 'INVITE_ALREADY_ACCEPTED' | 'INVITE_EXPIRED' | 'INVITE_REVOKED'; + const accepted = await db.transaction(async (tx) => { + // Lock the invite first so exactly one concurrent acceptance can set the + // password and mint a session from this token. + const [invite] = await tx + .select({ id: invites.id, accepted_at: invites.accepted_at, expires_at: invites.expires_at }) + .from(invites) + .where(and(eq(invites.token, parsed.data.token), eq(invites.org_id, payload.org_id))) + .for('update'); + if (!invite) return { failure: 'INVITE_INVALID' as AcceptanceFailure }; + if (invite.accepted_at) return { failure: 'INVITE_ALREADY_ACCEPTED' as AcceptanceFailure }; + if (invite.expires_at && invite.expires_at < new Date()) return { failure: 'INVITE_EXPIRED' as AcceptanceFailure }; + + const [user] = await tx.select().from(users).where(eq(users.id, payload.user_id)).for('update'); + if (!user) return { failure: 'INVITE_INVALID' as AcceptanceFailure }; + // An invite-created account has no password until its one accepted invite + // establishes credentials. Never let a stale pending invite replace + // credentials established by another invite or an admin recovery. + if (user.password_hash) return { failure: 'INVITE_ALREADY_ACCEPTED' as AcceptanceFailure }; + const [membership] = await tx + .select({ id: orgMembers.id }) + .from(orgMembers) + .where(and(eq(orgMembers.user_id, payload.user_id), eq(orgMembers.org_id, payload.org_id), eq(orgMembers.is_active, true))) + .for('share'); + if (!membership) return { failure: 'INVITE_REVOKED' as AcceptanceFailure }; + + await tx.update(users).set({ ...updates, password_version: user.password_version + 1 }).where(eq(users.id, payload.user_id)); + await tx.update(invites) + .set({ accepted_by: payload.user_id, accepted_at: new Date() }) + .where(and(eq(invites.id, invite.id), eq(invites.org_id, payload.org_id))); + return { user }; + }); + + if ('failure' in accepted) { + const messages: Record = { + INVITE_INVALID: 'invalid', + INVITE_ALREADY_ACCEPTED: 'already accepted', + INVITE_EXPIRED: 'expired', + INVITE_REVOKED: 'invalid', + }; + const failure = accepted.failure as AcceptanceFailure; + return c.json({ error: messages[failure], code: failure }, 400); + } + const user = accepted.user; // Ensure Defty is in the org and materialize the 1:1 DM so the new // member sees it in their sidebar immediately. Both are idempotent; @@ -212,7 +199,15 @@ inviteRoutes.post('/accept', async (c) => { } })(); - const tokens = generateAuthTokens({ id: user.id, email: user.email!, org_id: payload.org_id }); + let tokens; + try { + tokens = await createWebSession({ id: user.id, email: user.email!, org_id: payload.org_id }, passwordHash); + } catch (error) { + if (error instanceof WebCredentialsChangedError) { + return c.json({ error: 'Credentials changed; sign in again', code: 'CREDENTIALS_CHANGED' }, 409); + } + throw error; + } return c.json({ user: { id: user.id, name: parsed.data.name ?? user.name, email: user.email }, diff --git a/apps/api/src/routes/members.ts b/apps/api/src/routes/members.ts index 0959567a..9641104e 100644 --- a/apps/api/src/routes/members.ts +++ b/apps/api/src/routes/members.ts @@ -25,11 +25,13 @@ import { userGroupMembers, userGroups, wikiPages, + webSessions, } from '@deft/db/schema'; import { env } from '../lib/env.js'; import { DEFTY_EMAIL } from '../lib/ensure-defty-membership.js'; import { OrgMembershipError, requireOrgAdminOrOwner } from '../lib/org-membership.js'; import { evictActiveHuddleParticipants } from '../socket.js'; +import { emitWebSessionRevocations } from '../lib/web-sessions.js'; const INVITE_TTL = '7d'; const RECOVERY_TTL = '24h'; @@ -109,12 +111,28 @@ async function getCurrentMembership(orgId: string, userId: string) { async function revokeMemberWorkspaceAccess(orgId: string, memberId: string, deactivateMembership = true) { const revokedAt = new Date(); - - if (deactivateMembership) { - await db.update(orgMembers) - .set({ is_active: false, updated_at: revokedAt }) - .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, memberId))); - } + const revokedSessionIds = await db.transaction(async (tx) => { + const [user] = await tx.select({ password_version: users.password_version }) + .from(users).where(eq(users.id, memberId)).for('update'); + const [membership] = await tx.select({ id: orgMembers.id }) + .from(orgMembers) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, memberId))) + .for('update'); + if (!user || !membership) return []; + if (deactivateMembership) { + await tx.update(users).set({ password_version: user.password_version + 1 }).where(eq(users.id, memberId)); + await tx.update(orgMembers).set({ is_active: false, updated_at: revokedAt }).where(eq(orgMembers.id, membership.id)); + } + const sessions = await tx.select({ id: webSessions.id }) + .from(webSessions) + .where(and(eq(webSessions.org_id, orgId), eq(webSessions.user_id, memberId), sql`${webSessions.revoked_at} IS NULL`)) + .for('update'); + if (sessions.length) { + await tx.update(webSessions).set({ revoked_at: revokedAt }).where(inArray(webSessions.id, sessions.map(session => session.id))); + } + return sessions.map(session => session.id); + }); + emitWebSessionRevocations(revokedSessionIds); await db.execute(sql` DELETE FROM ${spaceMembers} @@ -1192,69 +1210,47 @@ memberRoutes.post('/invites/:id/reissue', async (c) => { return adminForbidden(c, err); } - const [invite] = await db.select() - .from(invites) - .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) - .limit(1); - - if (!invite) { - return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); - } - if (invite.accepted_at) { - return c.json({ error: 'Accepted invites cannot be reissued', code: 'INVITE_ACCEPTED' }, 409); - } - - const claims = decodeInviteClaims(invite.token); - let userId = claims?.user_id ?? null; - let role = claims?.role as 'admin' | 'member' | 'guest' | undefined; - const email = invite.email ?? claims?.email; - - if ((!userId || !role) && email) { - const [target] = await db.select({ - id: users.id, - role: orgMembers.role, - }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - userId = userId ?? target?.id ?? null; - role = role ?? (target?.role as 'admin' | 'member' | 'guest' | undefined); - } - - if (!userId || !email) { + const result = await db.transaction(async (tx) => { + const [invite] = await tx.select().from(invites) + .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) + .for('update'); + if (!invite) return { failure: 'NOT_FOUND' as const }; + if (invite.accepted_at) return { failure: 'INVITE_ACCEPTED' as const }; + + const claims = decodeInviteClaims(invite.token); + let userId = claims?.user_id ?? null; + let role = claims?.role as 'admin' | 'member' | 'guest' | undefined; + const email = invite.email ?? claims?.email; + if ((!userId || !role) && email) { + const [target] = await tx.select({ id: users.id, role: orgMembers.role }) + .from(users).innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) + .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) + .limit(1); + userId = userId ?? target?.id ?? null; + role = role ?? (target?.role as 'admin' | 'member' | 'guest' | undefined); + } + if (!userId || !email) return { failure: 'INVITE_INVALID' as const }; + + const inviteToken = jwt.sign({ + purpose: 'invite-accept', user_id: userId, org_id: currentUser.org_id, email, + inviter_id: currentUser.id, role: role ?? 'member', nonce: crypto.randomUUID(), + }, env.JWT_SECRET, { expiresIn: INVITE_TTL }); + const decoded = jwt.decode(inviteToken) as { exp?: number } | null; + const expiresAtDate = decoded?.exp ? new Date(decoded.exp * 1000) : null; + await tx.update(invites).set({ token: inviteToken, invited_by: currentUser.id, expires_at: expiresAtDate ?? undefined, updated_at: new Date() }) + .where(eq(invites.id, inviteId)); + return { inviteToken, expiresAtDate }; + }); + if ('failure' in result) { + if (result.failure === 'NOT_FOUND') return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); + if (result.failure === 'INVITE_ACCEPTED') return c.json({ error: 'Accepted invites cannot be reissued', code: 'INVITE_ACCEPTED' }, 409); return c.json({ error: 'Invite payload is incomplete', code: 'INVITE_INVALID' }, 409); } - const inviteToken = jwt.sign( - { - purpose: 'invite-accept', - user_id: userId, - org_id: currentUser.org_id, - email, - inviter_id: currentUser.id, - role: role ?? 'member', - nonce: crypto.randomUUID(), - }, - env.JWT_SECRET, - { expiresIn: INVITE_TTL }, - ); - - const decoded = jwt.decode(inviteToken) as { exp?: number } | null; - const expiresAtDate = decoded?.exp ? new Date(decoded.exp * 1000) : null; - await db.update(invites) - .set({ - token: inviteToken, - invited_by: currentUser.id, - expires_at: expiresAtDate ?? undefined, - updated_at: new Date(), - }) - .where(eq(invites.id, inviteId)); - return c.json({ success: true, - invite_url: buildInviteUrl(inviteToken), - expires_at: expiresAtDate?.toISOString() ?? null, + invite_url: buildInviteUrl(result.inviteToken), + expires_at: result.expiresAtDate?.toISOString() ?? null, }); } catch (err) { console.error('Failed to reissue invite:', err); @@ -1274,47 +1270,56 @@ memberRoutes.delete('/invites/:id', async (c) => { return adminForbidden(c, err); } - const [invite] = await db.select() - .from(invites) - .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) - .limit(1); + const revokedAt = new Date(); + const result = await db.transaction(async (tx) => { + const [invite] = await tx.select().from(invites) + .where(and(eq(invites.org_id, currentUser.org_id), eq(invites.id, inviteId))) + .for('update'); + if (!invite) return { failure: 'NOT_FOUND' as const }; + if (invite.accepted_at) return { failure: 'INVITE_ACCEPTED' as const }; + + const claims = decodeInviteClaims(invite.token); + let userId = claims?.user_id ?? null; + const email = invite.email ?? claims?.email; + if (!userId && email) { + const [found] = await tx.select({ id: users.id }).from(users) + .where(eq(users.email, email)).limit(1); + userId = found?.id ?? null; + } - if (!invite) { - return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); - } - if (invite.accepted_at) { + const revokedSessionIds: string[] = []; + let deactivatedUserId: string | null = null; + if (userId) { + // Invite row is already locked; keep remaining order user -> membership -> sessions. + const [user] = await tx.select({ password_hash: users.password_hash, password_version: users.password_version }) + .from(users).where(eq(users.id, userId)).for('update'); + const [membership] = await tx.select({ id: orgMembers.id, role: orgMembers.role, is_active: orgMembers.is_active }) + .from(orgMembers) + .where(and(eq(orgMembers.org_id, currentUser.org_id), eq(orgMembers.user_id, userId))) + .for('update'); + if (user && membership?.is_active && !user.password_hash && membership.role !== 'owner') { + await tx.update(users).set({ password_version: user.password_version + 1 }).where(eq(users.id, userId)); + await tx.update(orgMembers).set({ is_active: false, updated_at: revokedAt }).where(eq(orgMembers.id, membership.id)); + const sessions = await tx.select({ id: webSessions.id }).from(webSessions) + .where(and(eq(webSessions.org_id, currentUser.org_id), eq(webSessions.user_id, userId), sql`${webSessions.revoked_at} IS NULL`)) + .for('update'); + if (sessions.length) { + await tx.update(webSessions).set({ revoked_at: revokedAt }).where(inArray(webSessions.id, sessions.map(session => session.id))); + revokedSessionIds.push(...sessions.map(session => session.id)); + } + deactivatedUserId = userId; + } + } + await tx.delete(invites).where(eq(invites.id, inviteId)); + return { revokedSessionIds, deactivatedUserId }; + }); + if ('failure' in result) { + if (result.failure === 'NOT_FOUND') return c.json({ error: 'Invite not found', code: 'NOT_FOUND' }, 404); return c.json({ error: 'Accepted invites cannot be revoked here', code: 'INVITE_ACCEPTED' }, 409); } - - const claims = decodeInviteClaims(invite.token); - let userId = claims?.user_id ?? null; - const email = invite.email ?? claims?.email; - - if (!userId && email) { - const [target] = await db.select({ id: users.id }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.email, email), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - userId = target?.id ?? null; - } - - await db.delete(invites).where(eq(invites.id, inviteId)); - - if (userId) { - const [target] = await db.select({ - password_hash: users.password_hash, - role: orgMembers.role, - is_active: orgMembers.is_active, - }) - .from(users) - .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) - .where(and(eq(users.id, userId), eq(orgMembers.org_id, currentUser.org_id))) - .limit(1); - - if (target?.is_active && !target.password_hash && target.role !== 'owner') { - await revokeMemberWorkspaceAccess(currentUser.org_id, userId, true); - } + emitWebSessionRevocations(result.revokedSessionIds); + if (result.deactivatedUserId) { + await revokeMemberWorkspaceAccess(currentUser.org_id, result.deactivatedUserId, false); } return c.json({ success: true }); @@ -1336,7 +1341,7 @@ memberRoutes.post('/:id/recovery-url', async (c) => { } const [target] = await db - .select({ id: users.id, email: users.email }) + .select({ id: users.id, email: users.email, password_version: users.password_version }) .from(users) .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) .where(and(eq(orgMembers.org_id, currentUser.org_id), eq(users.id, memberId), eq(orgMembers.is_active, true))) @@ -1347,7 +1352,7 @@ memberRoutes.post('/:id/recovery-url', async (c) => { } const resetToken = jwt.sign( - { id: target.id, email: target.email, purpose: 'password-reset' }, + { id: target.id, email: target.email, org_id: currentUser.org_id, purpose: 'password-reset', password_version: target.password_version }, env.JWT_SECRET, { expiresIn: RECOVERY_TTL }, ); diff --git a/apps/api/src/socket.ts b/apps/api/src/socket.ts index c26c8678..3dcbc892 100644 --- a/apps/api/src/socket.ts +++ b/apps/api/src/socket.ts @@ -1,7 +1,6 @@ import { Server as SocketIOServer } from 'socket.io'; import type { Server as HTTPServer } from 'node:http'; -import jwt from 'jsonwebtoken'; -import { env } from './lib/env.js'; +import { verifyWebAccess, webSessionEvents } from './lib/web-sessions.js'; import { db } from './lib/db.js'; import { users, orgMembers, spaceMembers, spaces } from '@deft/db/schema'; import { eq, and, ne } from 'drizzle-orm'; @@ -13,6 +12,8 @@ export type SocketUser = { id: string; email: string; org_id: string; + sid?: string; + exp?: number; role?: 'owner' | 'admin' | 'member' | 'guest'; }; type HuddleEventName = 'huddle:create' | 'huddle:list' | 'huddle:join' | 'huddle:leave' | 'huddle:signal' | 'huddle:mute'; @@ -275,13 +276,16 @@ export function setupSocket(server: HTTPServer) { }); // Auth middleware + const disconnectSession = (sid: string) => io?.in(`web-session:${sid}`).disconnectSockets(true); + webSessionEvents.on('revoked', disconnectSession); + server.once('close', () => webSessionEvents.off('revoked', disconnectSession)); io.use(async (socket, next) => { const token = socket.handshake.auth.token; if (!token) { return next(new Error('Authentication required')); } try { - const payload = jwt.verify(token, env.JWT_SECRET) as { id: string; email: string; org_id: string }; + const payload = await verifyWebAccess(token); const authorizationGeneration = captureRealtimeAccessGeneration(); const membership = await requireActiveOrgMembership(payload.org_id, payload.id); if (!isRealtimeAccessGenerationCurrent(authorizationGeneration)) { @@ -296,7 +300,19 @@ export function setupSocket(server: HTTPServer) { }); io.on('connection', async (socket) => { + socket.use(async (_event, next) => { + try { + await verifyWebAccess(socket.handshake.auth.token); + next(); + } catch { + socket.disconnect(true); + next(new Error('Session unavailable; reconnect')); + } + }); const user = (socket as any).user as SocketUser; + const expiryTimer = setTimeout(() => socket.disconnect(true), Math.max(0, (user.exp ?? 0) * 1000 - Date.now())); + expiryTimer.unref(); + socket.once('disconnect', () => clearTimeout(expiryTimer)); const connectionGeneration = (socket as any).realtimeAccessGeneration as number; if (!socket.connected || !isRealtimeAccessGenerationCurrent(connectionGeneration)) { socket.disconnect(true); @@ -305,12 +321,15 @@ export function setupSocket(server: HTTPServer) { try { const initialRooms = [ + `web-session:${user.sid}`, `org:${user.org_id}`, `user:${user.id}`, `org-user:${user.org_id}:${user.id}`, ]; if (user.role !== 'guest') initialRooms.push(`org-members:${user.org_id}`); await socket.join(initialRooms); + // Close the handshake/join revocation race before room data is sent. + await verifyWebAccess(socket.handshake.auth.token); } catch (error) { console.error('Failed to initialize socket rooms:', error); socket.disconnect(true); diff --git a/apps/api/test/auth-refresh-contract.test.ts b/apps/api/test/auth-refresh-contract.test.ts index eb76b31f..eb6dbad5 100644 --- a/apps/api/test/auth-refresh-contract.test.ts +++ b/apps/api/test/auth-refresh-contract.test.ts @@ -3,25 +3,25 @@ * * Purpose: lock down the server-side refresh endpoint contract so that future * refactors cannot silently break the web client's 401-retry logic that now - * relies on it. The server is NOT being changed here — we are merely asserting - * its current behaviour. + * relies on it. Browser credentials use durable one-use session families. * * Covers: * 1. Valid refresh token → 200 + rotated accessToken + refreshToken - * 2. Revoked refresh token → 401 + code: TOKEN_REVOKED + * 2. Revoked refresh token → 401 + code: INVALID_TOKEN * 3. Malformed / garbage token → 401 + code: INVALID_TOKEN - * 4. Missing refresh token → 401 + code: NO_TOKEN + * 4. Missing refresh token → 400 + code: VALIDATION_ERROR * * Run: pnpm --filter @deft/api test -- auth-refresh-contract */ import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; -import { createHash } from 'node:crypto'; import pg from 'pg'; import jwt from 'jsonwebtoken'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; +import { createWebSession, revokeWebSession } from '../src/lib/web-sessions.js'; +import { env } from '../src/lib/env.js'; // ── Database helpers ────────────────────────────────────────────────────────── @@ -55,8 +55,7 @@ async function callRefresh(body: unknown): Promise<{ status: number; json: unkno // ── Fixtures ────────────────────────────────────────────────────────────────── -const JWT_REFRESH_SECRET = - process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me'; +const JWT_REFRESH_SECRET = env.JWT_REFRESH_SECRET; // Unique ids per run so parallel test suites don't collide. const TEST_USER_ID = `auth-refresh-contract-user-${crypto.randomUUID()}`; @@ -93,21 +92,13 @@ before(async () => { }); // Generate a valid refresh token via the same algorithm the server uses. - validRefreshToken = jwt.sign( + validRefreshToken = (await createWebSession( { id: TEST_USER_ID, email: `contract-test-${TEST_USER_ID.slice(-8)}@test.local`, org_id: TEST_ORG_ID }, - JWT_REFRESH_SECRET, - { expiresIn: '30d' }, - ); + )).refreshToken; }); after(async () => { await withClient(async (c) => { - // Clean up revoked tokens first (FK constraint) — guard against seeding failure - if (validRefreshToken) { - const tokenHash = createHash('sha256').update(validRefreshToken).digest('hex'); - await c.query(`DELETE FROM revoked_tokens WHERE token_hash = $1`, [tokenHash]); - } - await c.query(`DELETE FROM org_members WHERE user_id = $1`, [TEST_USER_ID]); await c.query(`DELETE FROM users WHERE id = $1`, [TEST_USER_ID]); await c.query(`DELETE FROM orgs WHERE id = $1`, [TEST_ORG_ID]); @@ -135,27 +126,12 @@ describe('/api/auth/refresh contract', () => { assert.equal(decoded.id, TEST_USER_ID, 'rotated refreshToken should carry the correct user id'); }); - test('2. revoked refresh token → 401 with code TOKEN_REVOKED', async () => { - // Revoke the token by inserting its hash into revoked_tokens (same logic as /logout). - const tokenHash = createHash('sha256').update(validRefreshToken).digest('hex'); - await withClient(async (c) => { - await c.query( - `INSERT INTO revoked_tokens (id, token_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [crypto.randomUUID(), tokenHash], - ); - }); - - try { - const { status, json } = await callRefresh({ refreshToken: validRefreshToken }); - assert.equal(status, 401, `expected 401 for revoked token, got ${status}`); - const body = json as Record; - assert.equal(body.code, 'TOKEN_REVOKED', `expected code TOKEN_REVOKED, got ${body.code}`); - } finally { - // Remove the revocation so other tests (e.g. test 1 if run order changes) aren't affected. - await withClient(async (c) => { - await c.query(`DELETE FROM revoked_tokens WHERE token_hash = $1`, [tokenHash]); - }); - } + test('2. revoked refresh token → 401 with code INVALID_TOKEN', async () => { + const pair = await createWebSession({ id: TEST_USER_ID, email: `${TEST_USER_ID}@test.local`, org_id: TEST_ORG_ID }); + await revokeWebSession(pair.refreshToken); + const { status, json } = await callRefresh({ refreshToken: pair.refreshToken }); + assert.equal(status, 401, `expected 401 for revoked token, got ${status}`); + assert.equal((json as Record).code, 'INVALID_TOKEN'); }); test('3. malformed / garbage token → 401 with code INVALID_TOKEN', async () => { @@ -168,10 +144,10 @@ describe('/api/auth/refresh contract', () => { ); }); - test('4. missing refresh token → 401 with code NO_TOKEN', async () => { + test('4. missing refresh token → 400 with code VALIDATION_ERROR', async () => { const { status, json } = await callRefresh({}); - assert.equal(status, 401, `expected 401 when no token provided, got ${status}`); + assert.equal(status, 400, `expected 400 when no token provided, got ${status}`); const body = json as Record; - assert.equal(body.code, 'NO_TOKEN', `expected NO_TOKEN code, got ${body.code}`); + assert.equal(body.code, 'VALIDATION_ERROR', `expected VALIDATION_ERROR code, got ${body.code}`); }); }); diff --git a/apps/api/test/identity-hardening.test.ts b/apps/api/test/identity-hardening.test.ts index 9a8b8dd3..5e65a5f9 100644 --- a/apps/api/test/identity-hardening.test.ts +++ b/apps/api/test/identity-hardening.test.ts @@ -14,6 +14,7 @@ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; import { authMiddleware } from '../src/middleware/auth.js'; @@ -22,6 +23,7 @@ import { apiKeyRoutes } from '../src/routes/api-keys.js'; import { memberRoutes } from '../src/routes/members.js'; import { taskRoutes } from '../src/routes/tasks.js'; import { inviteRoutes } from '../src/routes/invites.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; import { env } from '../src/lib/env.js'; const DATABASE_URL = @@ -68,13 +70,15 @@ const TARGET_OAUTH_GRANT_ID = crypto.randomUUID(); const TARGET_OAUTH_ACCESS_ID = crypto.randomUUID(); const TARGET_OAUTH_REFRESH_ID = crypto.randomUUID(); const INVITED_EMAIL = `invite-${RUN_ID}@test.local`; +const RACE_EMAIL = `invite-race-${RUN_ID}@test.local`; -function accessToken(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_SECRET, { expiresIn: '15m' }); +const sessions = new Map>>(); +const sessionKey = (userId: string, orgId: string) => `${orgId}:${userId}`; +function accessToken(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.accessToken; } - -function refreshToken(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_REFRESH_SECRET, { expiresIn: '30d' }); +function refreshToken(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.refreshToken; } async function authed(path: string, userId: string, init: RequestInit = {}) { @@ -211,11 +215,27 @@ before(async () => { [OTHER_GROUP_MEMBER_ID, OTHER_GROUP_ID, OTHER_USER_ID], ); }); + const identities = [ + [ADMIN_ID, ORG_ID, `admin-${RUN_ID}@test.local`], + [MEMBER_ID, ORG_ID, `member-${RUN_ID}@test.local`], + [TARGET_ID, ORG_ID, `target-${RUN_ID}@test.local`], + [OTHER_USER_ID, OTHER_ORG_ID, `other-${RUN_ID}@test.local`], + ] as const; + for (const [id, orgId, email] of identities) { + sessions.set(sessionKey(id, orgId), await createWebSession({ id, org_id: orgId, email })); + } + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); + sessions.set(sessionKey(INACTIVE_ID, ORG_ID), await createWebSession({ id: INACTIVE_ID, org_id: ORG_ID, email: `inactive-${RUN_ID}@test.local` })); + await withClient(c => c.query(`UPDATE org_members SET is_active = false WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM invites WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); + await c.query(`DELETE FROM space_members WHERE space_id IN (SELECT id FROM spaces WHERE created_by IN (SELECT id FROM users WHERE email = ANY($1::text[])))`, [[INVITED_EMAIL, RACE_EMAIL]]); + await c.query(`DELETE FROM spaces WHERE created_by IN (SELECT id FROM users WHERE email = ANY($1::text[]))`, [[INVITED_EMAIL, RACE_EMAIL]]); + await c.query(`DELETE FROM space_members WHERE user_id IN (SELECT id FROM users WHERE email = ANY($1::text[]))`, [[INVITED_EMAIL, RACE_EMAIL]]); await c.query(`DELETE FROM oauth_access_tokens WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM oauth_refresh_tokens WHERE grant_id IN (SELECT id FROM oauth_grants WHERE org_id IN ($1, $2))`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM oauth_grants WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); @@ -231,7 +251,7 @@ after(async () => { await c.query(`DELETE FROM space_members WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM spaces WHERE id = $1`, [SPACE_ID]); await c.query(`DELETE FROM org_members WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); - await c.query(`DELETE FROM users WHERE email = $1`, [INVITED_EMAIL]); + await c.query(`DELETE FROM users WHERE email = ANY($1::text[])`, [[INVITED_EMAIL, RACE_EMAIL]]); await c.query(`DELETE FROM users WHERE id = ANY($1::text[])`, [[ADMIN_ID, MEMBER_ID, TARGET_ID, INACTIVE_ID, OTHER_USER_ID]]); await c.query(`DELETE FROM orgs WHERE id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); }); @@ -326,10 +346,12 @@ describe('Loop 0 identity hardening', () => { }); test('member invite creates a durable invite row used by preview', async () => { - const res = await authed('/api/members/invite', ADMIN_ID, { + const adminSession = await createWebSession({ id: ADMIN_ID, org_id: ORG_ID, email: `admin-${RUN_ID}@test.local` }); + const res = await app.fetch(new Request('http://localhost/api/members/invite', { method: 'POST', + headers: { Authorization: `Bearer ${adminSession.accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ email: INVITED_EMAIL, role: 'member' }), - }); + })); const body = await res.json() as { invite_url: string; expires_at: string }; assert.equal(res.status, 201, JSON.stringify(body)); const token = new URL(body.invite_url).pathname.split('/').pop(); @@ -348,6 +370,79 @@ describe('Loop 0 identity hardening', () => { assert.equal(preview.status, 200, JSON.stringify(previewBody)); assert.equal(previewBody.email, INVITED_EMAIL); assert.equal(previewBody.already_accepted, false); + + const passwords = ['Invite-race-alpha-2026!', 'Invite-race-beta-2026!']; + const responses = await Promise.all(passwords.map((password, index) => app.fetch(new Request('http://localhost/api/invites/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password, name: `Invite winner ${index}` }), + })))); + const statuses = responses.map(response => response.status).sort(); + assert.deepEqual(statuses, [200, 400]); + const winnerIndex = responses.findIndex(response => response.status === 200); + assert.notEqual(winnerIndex, -1); + assert.equal((await responses[1 - winnerIndex]!.json() as Record).code, 'INVITE_ALREADY_ACCEPTED'); + + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT u.password_hash, u.password_version, + (SELECT count(*)::int FROM web_sessions ws WHERE ws.user_id = u.id) AS session_count, + (SELECT count(*)::int FROM invites i WHERE i.org_id = $1 AND i.email = $2 AND i.accepted_at IS NOT NULL) AS accepted_count + FROM users u WHERE u.email = $2`, + [ORG_ID, INVITED_EMAIL], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].password_version, 1); + assert.equal(rows[0].session_count, 1); + assert.equal(rows[0].accepted_count, 1); + assert.equal(await bcrypt.compare(passwords[winnerIndex]!, rows[0].password_hash), true); + assert.equal(await bcrypt.compare(passwords[1 - winnerIndex]!, rows[0].password_hash), false); + }); + + const raceInviteResponse = await app.fetch(new Request('http://localhost/api/members/invite', { + method: 'POST', headers: { Authorization: `Bearer ${adminSession.accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: RACE_EMAIL, role: 'member' }), + })); + const raceInviteBody = await raceInviteResponse.json() as { invite_url: string; error?: string }; + assert.equal(raceInviteResponse.status, 201, JSON.stringify(raceInviteBody)); + const raceToken = new URL(raceInviteBody.invite_url).pathname.split('/').pop()!; + const raceInvite = await withClient(async (c) => { + const { rows } = await c.query(`SELECT id FROM invites WHERE token = $1`, [raceToken]); + return rows[0] as { id: string }; + }); + const [acceptRace, revokeRace] = await Promise.all([ + app.fetch(new Request('http://localhost/api/invites/accept', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: raceToken, password: 'Invite-revoke-race-2026!' }), + })), + app.fetch(new Request(`http://localhost/api/members/invites/${raceInvite.id}`, { + method: 'DELETE', headers: { Authorization: `Bearer ${adminSession.accessToken}` }, + })), + ]); + assert.equal( + (acceptRace.status === 200 && revokeRace.status === 409) + || (acceptRace.status === 400 && revokeRace.status === 200), + true, + `unexpected accept/revoke results ${acceptRace.status}/${revokeRace.status}`, + ); + await withClient(async (c) => { + const { rows } = await c.query( + `SELECT u.password_hash, om.is_active, + (SELECT count(*)::int FROM web_sessions ws WHERE ws.user_id = u.id AND ws.revoked_at IS NULL) session_count + FROM users u JOIN org_members om ON om.user_id = u.id AND om.org_id = $1 WHERE u.email = $2`, + [ORG_ID, RACE_EMAIL], + ); + assert.equal(rows.length, 1); + if (acceptRace.status === 200) { + assert.equal(rows[0].is_active, true); + assert.ok(rows[0].password_hash); + assert.equal(rows[0].session_count, 1); + } else { + assert.equal(rows[0].is_active, false); + assert.equal(rows[0].password_hash, null); + assert.equal(rows[0].session_count, 0); + } + }); }); test('task writes reject inactive or cross-org assignees', async () => { @@ -405,6 +500,32 @@ describe('Loop 0 identity hardening', () => { }); test('member removal revokes space access and personal MCP tokens', async () => { + const triggerSuffix = RUN_ID.replace(/-/g, '_'); + const triggerName = `fail_web_session_revoke_${triggerSuffix}`; + const functionName = `fail_web_session_revoke_fn_${triggerSuffix}`; + const before = await withClient(async (c) => { + const { rows } = await c.query(`SELECT password_version FROM users WHERE id = $1`, [TARGET_ID]); + await c.query(`CREATE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected revoke failure'; END $$`); + await c.query(`CREATE TRIGGER ${triggerName} BEFORE UPDATE ON web_sessions FOR EACH ROW WHEN (OLD.user_id = '${TARGET_ID}' AND NEW.revoked_at IS NOT NULL) EXECUTE FUNCTION ${functionName}()`); + return rows[0] as { password_version: number }; + }); + const rollbackResponse = await authed(`/api/members/${TARGET_ID}`, ADMIN_ID, { method: 'DELETE' }); + assert.equal(rollbackResponse.status, 500); + await withClient(async (c) => { + await c.query(`DROP TRIGGER ${triggerName} ON web_sessions`); + await c.query(`DROP FUNCTION ${functionName}()`); + const { rows } = await c.query( + `SELECT om.is_active, u.password_version FROM users u JOIN org_members om ON om.user_id = u.id WHERE u.id = $1 AND om.org_id = $2`, + [TARGET_ID, ORG_ID], + ); + assert.equal(rows[0].is_active, true, 'membership deactivation must roll back with session revocation'); + assert.equal(rows[0].password_version, before.password_version, 'password version must roll back with session revocation'); + }); + + const oldAccessToken = accessToken(TARGET_ID); + const oldResetToken = jwt.sign({ + id: TARGET_ID, org_id: ORG_ID, purpose: 'password-reset', password_version: before.password_version, + }, env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '24h' }); const res = await authed(`/api/members/${TARGET_ID}`, ADMIN_ID, { method: 'DELETE' }); assert.equal(res.status, 200, await res.text()); @@ -450,6 +571,17 @@ describe('Loop 0 identity hardening', () => { [TARGET_OAUTH_REFRESH_ID], ); assert.equal(oauthRefreshRows.rows[0].revoked, true); + const version = await c.query(`SELECT password_version FROM users WHERE id = $1`, [TARGET_ID]); + assert.equal(version.rows[0].password_version, before.password_version + 1); }); + + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, TARGET_ID]).then(() => undefined)); + const revivedAccess = await app.fetch(new Request('http://localhost/api/groups', { headers: { Authorization: `Bearer ${oldAccessToken}` } })); + assert.equal(revivedAccess.status, 401, 'reactivation must not revive a pre-removal session'); + const staleReset = await app.fetch(new Request('http://localhost/api/auth/reset-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: oldResetToken, password: 'stale-reset-must-fail-2026' }), + })); + assert.equal(staleReset.status, 400, 'reactivation must not revive a pre-removal recovery URL'); }); }); diff --git a/apps/api/test/profile-settings.test.ts b/apps/api/test/profile-settings.test.ts index cb42e78a..41d80441 100644 --- a/apps/api/test/profile-settings.test.ts +++ b/apps/api/test/profile-settings.test.ts @@ -2,13 +2,12 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; -import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { Hono } from 'hono'; import { authRoutes } from '../src/routes/auth.js'; import { messageRoutes } from '../src/routes/messages.js'; import { authMiddleware } from '../src/middleware/auth.js'; -import { env } from '../src/lib/env.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/deft'; @@ -33,15 +32,13 @@ const USER_ID = crypto.randomUUID(); const SPACE_ID = crypto.randomUUID(); const EMAIL = `profile-${RUN_ID}@test.local`; -function accessToken() { - return jwt.sign({ id: USER_ID, email: EMAIL, org_id: ORG_ID }, env.JWT_SECRET, { expiresIn: '15m' }); -} +let accessToken = ''; async function authed(path: string, init: RequestInit = {}) { return app.fetch(new Request(`http://localhost${path}`, { ...init, headers: { - Authorization: `Bearer ${accessToken()}`, + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...(init.headers ?? {}), }, @@ -77,10 +74,12 @@ before(async () => { [crypto.randomUUID(), SPACE_ID, USER_ID], ); }); + accessToken = (await createWebSession({ id: USER_ID, email: EMAIL, org_id: ORG_ID })).accessToken; }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE user_id = $1`, [USER_ID]); await c.query(`DELETE FROM messages WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM space_members WHERE space_id = $1`, [SPACE_ID]); await c.query(`DELETE FROM spaces WHERE id = $1`, [SPACE_ID]); diff --git a/apps/api/test/teams.test.ts b/apps/api/test/teams.test.ts index 7a5c03e8..d51c105e 100644 --- a/apps/api/test/teams.test.ts +++ b/apps/api/test/teams.test.ts @@ -2,11 +2,10 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import pg from 'pg'; -import jwt from 'jsonwebtoken'; import { Hono } from 'hono'; import { authMiddleware } from '../src/middleware/auth.js'; import { teamRoutes } from '../src/routes/teams.js'; -import { env } from '../src/lib/env.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/deft'; @@ -45,8 +44,10 @@ const OTHER_PROJECT_ID = crypto.randomUUID(); const OVERDUE_TASK_ID = crypto.randomUUID(); const REVIEW_TASK_ID = crypto.randomUUID(); -function token(userId: string, orgId = ORG_ID, email = `${userId}@test.local`) { - return jwt.sign({ id: userId, email, org_id: orgId }, env.JWT_SECRET, { expiresIn: '15m' }); +const sessions = new Map>>(); +const sessionKey = (userId: string, orgId: string) => `${orgId}:${userId}`; +function token(userId: string, orgId = ORG_ID) { + return sessions.get(sessionKey(userId, orgId))!.accessToken; } async function authed(path: string, userId: string, init: RequestInit = {}, orgId = ORG_ID) { @@ -171,10 +172,25 @@ before(async () => { [crypto.randomUUID(), ORG_ID, TEAM_ID, LEAD_ID, crypto.randomUUID(), MEMBER_ID, crypto.randomUUID(), PRIVATE_TEAM_ID], ); }); + const identities = [ + [ADMIN_ID, ORG_ID, `teams-admin-${RUN_ID}@test.local`], + [LEAD_ID, ORG_ID, `teams-lead-${RUN_ID}@test.local`], + [MEMBER_ID, ORG_ID, `teams-member-${RUN_ID}@test.local`], + [TARGET_ID, ORG_ID, `teams-target-${RUN_ID}@test.local`], + [AGENT_USER_ID, ORG_ID, `teams-agent-${RUN_ID}@test.local`], + [OTHER_USER_ID, OTHER_ORG_ID, `teams-other-user-${RUN_ID}@test.local`], + ] as const; + for (const [id, orgId, email] of identities) { + sessions.set(sessionKey(id, orgId), await createWebSession({ id, org_id: orgId, email })); + } + await withClient(c => c.query(`UPDATE org_members SET is_active = true WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); + sessions.set(sessionKey(INACTIVE_ID, ORG_ID), await createWebSession({ id: INACTIVE_ID, org_id: ORG_ID, email: `teams-inactive-${RUN_ID}@test.local` })); + await withClient(c => c.query(`UPDATE org_members SET is_active = false WHERE org_id = $1 AND user_id = $2`, [ORG_ID, INACTIVE_ID]).then(() => undefined)); }); after(async () => { await withClient(async (c) => { + await c.query(`DELETE FROM web_sessions WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_dashboard_snapshots WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_resources WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); await c.query(`DELETE FROM team_members WHERE org_id IN ($1, $2)`, [ORG_ID, OTHER_ORG_ID]); diff --git a/apps/api/test/web-session-family.test.ts b/apps/api/test/web-session-family.test.ts new file mode 100644 index 00000000..41cc0994 --- /dev/null +++ b/apps/api/test/web-session-family.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import { Hono } from 'hono'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { eq } from 'drizzle-orm'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +test('web session HTTP and database rotation/revocation boundaries', { skip: !databaseUrl }, async (t) => { + const target = new URL(databaseUrl!); + const disposableName = target.pathname.startsWith('/preview_') || (process.env.CI === 'true' && target.pathname === '/deft_test'); + assert.ok(['127.0.0.1', 'localhost'].includes(target.hostname) && disposableName, 'Only disposable local preview or declared CI databases'); + process.env.DATABASE_URL = databaseUrl; + process.env.JWT_SECRET = 'preview-session-tests-access-only'; + process.env.JWT_REFRESH_SECRET = 'preview-session-tests-refresh-only'; + const { db, closeDb } = await import('../src/lib/db.js'); + const { users, orgs, orgMembers, webSessions } = await import('@deft/db/schema'); + const { createWebSession, rotateWebSession, verifyWebAccess, revokeWebSession, changeWebPassword, revokeMemberWebSessions } = await import('../src/lib/web-sessions.js'); + const { authRoutes } = await import('../src/routes/auth.js'); + const { authMiddleware } = await import('../src/middleware/auth.js'); + const userId = randomUUID(); + const orgId = randomUUID(); + const identity = { id: userId, org_id: orgId, email: `${userId}@preview-session.local` }; + await db.insert(users).values({ id: userId, name: 'Session regression', email: identity.email, email_verified: true }); + await db.insert(orgs).values({ id: orgId, name: 'Session regression', slug: `session-${orgId}` }); + await db.insert(orgMembers).values({ org_id: orgId, user_id: userId, role: 'owner' }); + t.after(closeDb); + const app = new Hono(); + app.route('/api/auth', authRoutes); + app.use('/api/*', authMiddleware); + app.get('/api/probe', (c) => c.json({ ok: true })); + const post = (route: string, body: unknown) => app.request(`/api/auth/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const probe = (token: string) => app.request('/api/probe', { headers: { Authorization: `Bearer ${token}` } }); + + await t.test('one-use rotation revokes descendants and access on replay', async () => { + const initial = await createWebSession(identity); + const rotated = await rotateWebSession(initial.refreshToken); + assert.notEqual(rotated.refreshToken, initial.refreshToken); + assert.equal((await probe(rotated.accessToken)).status, 200); + assert.equal((await post('refresh', { refreshToken: initial.refreshToken })).status, 401); + await assert.rejects(() => rotateWebSession(rotated.refreshToken)); + assert.equal((await probe(initial.accessToken)).status, 401); + assert.equal((await probe(rotated.accessToken)).status, 401); + }); + await t.test('concurrent use rotates at most once then fails closed for the family', async () => { + const initial = await createWebSession(identity); + const results = await Promise.allSettled([rotateWebSession(initial.refreshToken), rotateWebSession(initial.refreshToken)]); + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + for (const result of results) if (result.status === 'fulfilled') await assert.rejects(() => verifyWebAccess(result.value.accessToken)); + }); + await t.test('logout revokes access and refresh; repeated logout is idempotent', async () => { + const pair = await createWebSession(identity); + assert.equal((await post('logout', { refreshToken: pair.refreshToken })).status, 200); + assert.equal((await post('logout', { refreshToken: pair.refreshToken })).status, 200); + assert.equal((await probe(pair.accessToken)).status, 401); + assert.equal((await app.request('/api/auth/me', { headers: { Authorization: `Bearer ${pair.accessToken}` } })).status, 401); + await assert.rejects(() => rotateWebSession(pair.refreshToken)); + }); + await t.test('malformed bodies are bounded structured 400 responses', async () => { + for (const route of ['refresh', 'logout', 'login', 'signup']) { + for (const body of [null, [], { refreshToken: {} }]) assert.equal((await post(route, body)).status, 400); + assert.equal((await app.request(`/api/auth/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{' })).status, 400); + } + }); + await t.test('legacy, wrong purpose, expired and inactive-member credentials are denied', async () => { + const pair = await createWebSession(identity); + const legacy = jwt.sign(identity, process.env.JWT_SECRET!, { expiresIn: '15m' }); + assert.equal((await probe(legacy)).status, 401); + assert.equal((await probe(pair.refreshToken)).status, 401); + const sid = (jwt.decode(pair.accessToken) as { sid: string }).sid; + await db.update(webSessions).set({ expires_at: new Date(0) }).where(eq(webSessions.id, sid)); + await assert.rejects(() => verifyWebAccess(pair.accessToken)); + const active = await createWebSession(identity); + await db.update(orgMembers).set({ is_active: false }).where(eq(orgMembers.user_id, userId)); + assert.equal((await probe(active.accessToken)).status, 403); + await assert.rejects(() => rotateWebSession(active.refreshToken)); + await revokeMemberWebSessions(orgId, userId); + await db.update(orgMembers).set({ is_active: true }).where(eq(orgMembers.user_id, userId)); + await assert.rejects(() => verifyWebAccess(active.accessToken), 'reactivating membership must not revive revoked sessions'); + await revokeWebSession(active.refreshToken); + }); + await t.test('password change revokes all user families and reset token is one-use', async () => { + const first = await createWebSession(identity); + const second = await createWebSession(identity); + const resetToken = `disposable-one-use-reset-${userId}`; + await changeWebPassword(userId, 'test-hash-not-for-login', { resetToken, passwordVersion: 0, orgId }); + await assert.rejects(() => verifyWebAccess(first.accessToken)); + await assert.rejects(() => rotateWebSession(second.refreshToken)); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { resetToken, passwordVersion: 0, orgId })); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { resetToken: `other-${resetToken}`, passwordVersion: 0, orgId }), 'all older reset links are invalid after any password change'); + await assert.rejects(() => changeWebPassword(userId, 'replacement', { expectedPasswordHash: 'old-password-hash' }), 'a stale password comparison cannot overwrite a concurrent reset'); + const [user] = await db.select().from(users).where(eq(users.id, userId)); + assert.equal(user.password_hash, 'test-hash-not-for-login'); + await assert.rejects(() => createWebSession(identity, 'old-password-hash')); + }); + await t.test('HTTP reset links bind the current password generation and active organization', async () => { + const [before] = await db.select().from(users).where(eq(users.id, userId)); + const reset = (org_id: string, generation = before.password_version) => jwt.sign({ + id: userId, org_id, purpose: 'password-reset', password_version: generation, jti: randomUUID(), + }, process.env.JWT_SECRET!, { expiresIn: '15m' }); + const pair = await createWebSession(identity); + const token = reset(orgId); + const sibling = reset(orgId); + const password = 'Reset-regression-only-2026!'; + assert.equal((await post('reset-password', { token: reset(randomUUID()), password })).status, 400); + const results = await Promise.all([ + post('reset-password', { token, password }), + post('reset-password', { token: sibling, password: `${password}other` }), + ]); + assert.deepEqual(results.map((r) => r.status).sort(), [200, 400]); + assert.equal((await post('reset-password', { token, password })).status, 400); + assert.equal((await post('reset-password', { token: sibling, password })).status, 400); + const [after] = await db.select().from(users).where(eq(users.id, userId)); + assert.equal(after.password_version, before.password_version + 1); + await assert.rejects(() => verifyWebAccess(pair.accessToken)); + const legacy = jwt.sign({ id: userId, org_id: orgId, purpose: 'password-reset' }, process.env.JWT_SECRET!, { expiresIn: '15m' }); + assert.equal((await post('reset-password', { token: legacy, password })).status, 400); + }); + await t.test('a real connected socket is evicted on logout and rejects revoked reconnect', async () => { + const { setupSocket } = await import('../src/socket.js'); + const { io: connect } = createRequire(new URL('../../web/package.json', import.meta.url))('socket.io-client'); + const server = createServer(); + const sockets = setupSocket(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + const pair = await createWebSession(identity); + const client = connect(`http://127.0.0.1:${port}`, { auth: { token: pair.accessToken }, transports: ['websocket'], reconnection: false }); + const event = (name: string) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Socket ${name} timed out`)), 8000); + client.once(name, (value: unknown) => { clearTimeout(timer); resolve(value); }); + }); + try { + await event('connect'); + const disconnected = event('disconnect'); + await revokeWebSession(pair.refreshToken); + assert.equal(await disconnected, 'io server disconnect'); + const denied = event('connect_error'); + client.connect(); + assert.match(String(await denied), /Invalid token/); + } finally { + client.close(); + await new Promise((resolve) => sockets.close(() => resolve())); + } + }); +}); diff --git a/apps/web/src/app/(app)/settings/profile/page.tsx b/apps/web/src/app/(app)/settings/profile/page.tsx index ce3cd58c..816854f4 100644 --- a/apps/web/src/app/(app)/settings/profile/page.tsx +++ b/apps/web/src/app/(app)/settings/profile/page.tsx @@ -80,7 +80,7 @@ function joinList(value: string[] | null | undefined): string { } export default function ProfileSettingsPage() { - const { user, refreshUser, replaceUser } = useAuth(); + const { user, refreshUser, replaceUser, logout } = useAuth(); const fileInputRef = useRef(null); const [name, setName] = useState(''); const [title, setTitle] = useState(''); @@ -237,7 +237,8 @@ export default function ProfileSettingsPage() { if (!res.ok) throw new Error(body.error || 'Failed to change password'); setCurrentPassword(''); setNewPassword(''); - setPasswordMessage('Password changed'); + setPasswordMessage('Password changed. Sign in again with your new password.'); + await logout({ revokeServer: false, destination: '/login?reason=password-changed' }); } catch (err) { setPasswordError(err instanceof Error ? err.message : 'Failed to change password'); } finally { diff --git a/apps/web/src/lib/api-auth.test.ts b/apps/web/src/lib/api-auth.test.ts new file mode 100644 index 00000000..bdb5dc09 --- /dev/null +++ b/apps/web/src/lib/api-auth.test.ts @@ -0,0 +1,292 @@ +/** + * Run: pnpm exec tsx --test apps/web/src/lib/api-auth.test.ts + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { api, refreshAccessToken } from './api'; +import { + AuthRequestGeneration, + isCrossTabSessionReplacement, + isCurrentRefreshStorageEvent, + revokeWebSessionBestEffort, + safePostLoginDestination, +} from './auth-context'; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length() { + return this.values.size; + } + + clear() { + this.values.clear(); + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + key(index: number) { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string) { + this.values.delete(key); + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +function installBrowserGlobals(t: test.TestContext, pathname: string) { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + const local = new MemoryStorage(); + const session = new MemoryStorage(); + const location = { pathname, href: pathname }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location }, + }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: local, + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: session, + }); + + t.after(() => { + for (const [name, descriptor] of [ + ['window', originalWindow], + ['localStorage', originalLocalStorage], + ['sessionStorage', originalSessionStorage], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } + }); + + return { local, session, location }; +} + +test('an invalid login returns its 401 without entering session-expiry handling', async (t) => { + const browser = installBrowserGlobals(t, '/login'); + const originalFetch = globalThis.fetch; + let transportCalls = 0; + globalThis.fetch = (async () => { + transportCalls += 1; + return new Response(JSON.stringify({ error: 'Invalid credentials' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + t.mock.method(console, 'warn', () => {}); + api.clearTokens(); + + const response = await api.post('/api/auth/login', { + email: 'audit@example.invalid', + password: 'wrong-password', + }); + + assert.equal(response.status, 401); + assert.equal(transportCalls, 1); + assert.equal(browser.location.href, '/login', 'the login page must remain mounted'); + assert.equal(browser.session.getItem('deft-redirect-after-login'), null); +}); + +test('a protected resource still refreshes the session and retries once', async (t) => { + const browser = installBrowserGlobals(t, '/tasks'); + const token = (jti: string) => { + const encoded = btoa(JSON.stringify({ id: 'user', org_id: 'org', sid: 'session', jti })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `header.${encoded}.signature`; + }; + const staleAccess = token('stale-access'); + const storedRefresh = token('stored-refresh'); + const freshAccess = token('fresh-access'); + const freshRefresh = token('fresh-refresh'); + const originalFetch = globalThis.fetch; + const authorizations: Array = []; + let protectedCalls = 0; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith('/api/auth/refresh')) { + return new Response(JSON.stringify({ + accessToken: freshAccess, + refreshToken: freshRefresh, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + protectedCalls += 1; + authorizations.push(new Headers(init?.headers).get('Authorization')); + return new Response(null, { status: protectedCalls === 1 ? 401 : 200 }); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + api.setTokens(staleAccess, storedRefresh); + + const response = await api.get('/api/tasks'); + + assert.equal(response.status, 200); + assert.equal(protectedCalls, 2); + assert.deepEqual(authorizations, [`Bearer ${staleAccess}`, `Bearer ${freshAccess}`]); + assert.equal(browser.local.getItem('deft-access-token'), freshAccess); + assert.equal(browser.location.href, '/tasks'); +}); + +test('post-login destinations allow local workspace routes and reject auth loops or unsafe URLs', () => { + assert.equal( + safePostLoginDestination('/tasks?task=OPS-42#activity'), + '/tasks?task=OPS-42#activity', + ); + assert.equal(safePostLoginDestination('/oauth/authorize?client_id=local'), '/oauth/authorize?client_id=local'); + + for (const value of [ + '/login', + '/login?next=/tasks', + '/signup/', + '/forgot-password', + '/reset-password?token=secret', + '//evil.example/path', + '/\\evil.example/path', + 'https://evil.example/path', + 'javascript:alert(1)', + '', + null, + ]) { + assert.equal(safePostLoginDestination(value), null, String(value)); + } +}); + +test('concurrent callers share a rotation and a late response cannot undo logout', async (t) => { + const browser = installBrowserGlobals(t, '/tasks'); + const originalFetch = globalThis.fetch; + let finish!: (response: Response) => void; + let calls = 0; + globalThis.fetch = (() => { + calls += 1; + return new Promise((resolve) => { finish = resolve; }); + }) as typeof fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + api.setTokens('old', 'old-refresh'); + const first = refreshAccessToken(); + const second = refreshAccessToken(); + assert.equal(calls, 1); + api.clearTokens(); + finish(new Response(JSON.stringify({ accessToken: 'late', refreshToken: 'late-refresh' }))); + assert.deepEqual(await Promise.all([first, second]), [null, null]); + assert.equal(browser.local.getItem('deft-access-token'), null); +}); + +test('a tab waiting for the refresh lock reuses the completed rotation from another tab', async (t) => { + const browser = installBrowserGlobals(t, '/tasks'); + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { calls += 1; throw new Error('Unexpected duplicate rotation'); }) as typeof fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + api.setTokens('old', 'old-refresh'); + Object.defineProperty(window, 'navigator', { value: { locks: { request: async (_name: string, callback: () => Promise) => { + browser.local.setItem('deft-access-token', 'other-tab-access'); + browser.local.setItem('deft-refresh-token', 'other-tab-refresh'); + return callback(); + } } } }); + assert.equal(await refreshAccessToken(), 'other-tab-access'); + assert.equal(api.getAccessToken(), 'other-tab-access'); + assert.equal(calls, 0); +}); + +test('best-effort logout posts the captured refresh token with a bounded signal', async () => { + let request: { url: string; init?: RequestInit } | undefined; + await revokeWebSessionBestEffort('captured-refresh', (async (input, init) => { + request = { url: String(input), init }; + return new Response(null, { status: 200 }); + }) as typeof fetch, 100); + + assert.equal(request?.url.endsWith('/api/auth/logout'), true); + assert.deepEqual(JSON.parse(String(request?.init?.body)), { refreshToken: 'captured-refresh' }); + assert.equal(request?.init?.signal instanceof AbortSignal, true); +}); + +test('best-effort logout resolves when its request exceeds the timeout', async () => { + const started = Date.now(); + await revokeWebSessionBestEffort('captured-refresh', ((_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + })) as typeof fetch, 10); + assert.ok(Date.now() - started < 500); +}); + +test('auth generations reject a pre-logout response and an older login response', () => { + const generation = new AuthRequestGeneration(); + const initialMe = generation.capture(); + generation.advance(); // logout + assert.equal(generation.isCurrent(initialMe), false, 'logout must invalidate the pending /me response'); + + const firstLoginMe = generation.advance(); // first login starts + const secondLoginMe = generation.advance(); // newer login starts + assert.equal(generation.isCurrent(firstLoginMe), false, 'the older identity response cannot win'); + assert.equal(generation.isCurrent(secondLoginMe), true); +}); + +test('cross-tab session replacement differs from seamless refresh rotation', () => { + const token = (claims: Record) => { + const encoded = btoa(JSON.stringify(claims)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `header.${encoded}.signature`; + }; + const first = token({ id: 'user-a', org_id: 'org-a', sid: 'session-a', jti: 'one' }); + const rotated = token({ id: 'user-a', org_id: 'org-a', sid: 'session-a', jti: 'two' }); + const otherLogin = token({ id: 'user-b', org_id: 'org-a', sid: 'session-b', jti: 'three' }); + + assert.equal(isCrossTabSessionReplacement(first, rotated), false); + assert.equal(isCrossTabSessionReplacement(rotated, otherLogin), true); + assert.equal(isCrossTabSessionReplacement(null, otherLogin), true); + assert.equal(isCrossTabSessionReplacement(otherLogin, null), false); +}); + +test('queued storage events cannot overwrite a newer refresh token', () => { + assert.equal(isCurrentRefreshStorageEvent('stale-rotation', 'new-login'), false); + assert.equal(isCurrentRefreshStorageEvent(null, 'new-login'), false); + assert.equal(isCurrentRefreshStorageEvent('new-login', 'new-login'), true); + assert.equal(isCurrentRefreshStorageEvent(null, null), true); +}); + +test('a late 401 from an old session cannot refresh, replay, or clear a newer login', async (t) => { + const browser = installBrowserGlobals(t, '/tasks'); + const token = (claims: Record) => { + const encoded = btoa(JSON.stringify(claims)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `header.${encoded}.signature`; + }; + const oldAccess = token({ id: 'old-user', org_id: 'org', sid: 'old-session' }); + const oldRefresh = token({ id: 'old-user', org_id: 'org', sid: 'old-session' }); + const newAccess = token({ id: 'new-user', org_id: 'org', sid: 'new-session' }); + const newRefresh = token({ id: 'new-user', org_id: 'org', sid: 'new-session' }); + const originalFetch = globalThis.fetch; + let release!: (response: Response) => void; + let calls = 0; + globalThis.fetch = (() => { + calls += 1; + return new Promise(resolve => { release = resolve; }); + }) as typeof fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + + api.setTokens(oldAccess, oldRefresh); + const pending = api.post('/api/tasks', { title: 'must not replay as another user' }); + api.setTokens(newAccess, newRefresh); + release(new Response(null, { status: 401 })); + + assert.equal((await pending).status, 401); + assert.equal(calls, 1); + assert.equal(browser.local.getItem('deft-access-token'), newAccess); + assert.equal(browser.local.getItem('deft-refresh-token'), newRefresh); +}); diff --git a/apps/web/src/lib/api-retry.test.ts b/apps/web/src/lib/api-retry.test.ts new file mode 100644 index 00000000..10560514 --- /dev/null +++ b/apps/web/src/lib/api-retry.test.ts @@ -0,0 +1,77 @@ +/** + * Run: pnpm --filter @deft/web exec tsx --test src/lib/api-retry.test.ts + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, open, readFile, unlink, rmdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { api } from './api'; + +test('does not replay a POST when the server commits before the response is lost', async (t) => { + const originalFetch = globalThis.fetch; + let transportCalls = 0; + const directory = await mkdtemp(path.join(tmpdir(), 'deft-retry-proof-')); + const journal = path.join(directory, 'committed-events.jsonl'); + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const file = await open(journal, 'a'); + try { + await file.writeFile(`${Buffer.concat(chunks).toString()}\n`); + await file.sync(); + } finally { await file.close(); } + // Commit is durable before destroying the real HTTP response transport. + request.socket.destroy(); + response.destroy(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + globalThis.fetch = (async (_input, init) => { + transportCalls += 1; + return originalFetch(`http://127.0.0.1:${port}/api/events`, init); + }) as typeof fetch; + t.after(async () => { + globalThis.fetch = originalFetch; + await new Promise((resolve) => server.close(() => resolve())); + await unlink(journal); + await rmdir(directory); + }); + + let observedError: unknown; + try { + await api.post('/api/events', { title: 'Launch review' }); + } catch (error) { + observedError = error; + } + + const durableCreates = (await readFile(journal, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(durableCreates, [{ title: 'Launch review' }], 'one user action must produce one durable create'); + assert.equal(transportCalls, 1, 'an unsafe request must not be replayed automatically'); + assert.ok(observedError instanceof TypeError, 'the ambiguous network failure must reach the caller'); +}); + +test('retries a GET after a transient network failure', async (t) => { + const originalFetch = globalThis.fetch; + let transportCalls = 0; + + globalThis.fetch = (async () => { + transportCalls += 1; + if (transportCalls === 1) { + throw new TypeError('temporary network failure'); + } + return new Response(JSON.stringify({ events: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const response = await api.get('/api/events'); + + assert.equal(response.status, 200); + assert.equal(transportCalls, 2, 'safe reads should retain bounded network retries'); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c3d4445b..da751961 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,10 +1,33 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const SESSION_REFRESH_EXEMPT_PATHS = new Set([ + '/api/auth/login', + '/api/auth/signup', +]); // ── Concurrency-guarded token refresh ──────────────────────────────────────── // A burst of concurrent 401 responses must only trigger ONE refresh call. // Subsequent callers await the same in-flight promise and share the result. let _refreshPromise: Promise | null = null; +function webSessionIdentity(token: string | null): string | null { + if (!token) return null; + try { + const segment = token.split('.')[1]; + if (!segment) return null; + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))) as Record; + if (typeof payload.id !== 'string' || typeof payload.org_id !== 'string' || typeof payload.sid !== 'string') return null; + return `${payload.id}:${payload.org_id}:${payload.sid}`; + } catch { + return null; + } +} + +export function isSameWebSession(first: string | null, second: string | null): boolean { + const firstIdentity = webSessionIdentity(first); + return firstIdentity !== null && firstIdentity === webSessionIdentity(second); +} + /** * Silently refresh the access token using the stored refresh token. * Updates both the in-memory ApiClient singleton and localStorage. @@ -15,11 +38,18 @@ let _refreshPromise: Promise | null = null; */ export async function refreshAccessToken(): Promise { if (_refreshPromise) return _refreshPromise; - _refreshPromise = (async () => { - const refresh = typeof window !== 'undefined' + const observedRefresh = typeof window !== 'undefined' ? localStorage.getItem('deft-refresh-token') : null; + const rotate = async () => { + const refresh = typeof window !== 'undefined' ? localStorage.getItem('deft-refresh-token') : null; if (!refresh) return null; + // Another tab may have rotated while this caller waited for the lock. + if (refresh !== observedRefresh) { + const access = localStorage.getItem('deft-access-token'); + if (access) api.setTokens(access, refresh); + return access; + } try { const r = await fetch(`${API_URL}/api/auth/refresh`, { method: 'POST', @@ -28,15 +58,19 @@ export async function refreshAccessToken(): Promise { }); if (!r.ok) return null; const j = await r.json(); - if (!j.accessToken) return null; + if (typeof j.accessToken !== 'string' || typeof j.refreshToken !== 'string') return null; + // Logout or a new login during the request must not resurrect this session. + if (localStorage.getItem('deft-refresh-token') !== refresh) return null; // Mirror into the singleton instance and localStorage. // `api` is initialised before this closure ever runs (module-level const). - api.setTokens(j.accessToken, j.refreshToken ?? refresh); + api.setTokens(j.accessToken, j.refreshToken); return j.accessToken as string; } catch { return null; } - })(); + }; + const locks = typeof window !== 'undefined' ? window.navigator?.locks : undefined; + _refreshPromise = locks ? locks.request('deft-web-session-refresh', rotate) : rotate(); try { return await _refreshPromise; } finally { @@ -73,12 +107,15 @@ class ApiClient { return this.accessToken; } - private async fetchWithRetry(url: string, options: RequestInit, retries = 2): Promise { + private async fetchWithRetry(url: string, options: RequestInit): Promise { + const method = (options.method ?? 'GET').toUpperCase(); + const retries = method === 'GET' || method === 'HEAD' ? 2 : 0; + for (let attempt = 0; attempt <= retries; attempt++) { try { return await fetch(url, options); } catch (err) { - // Only retry on network errors (TypeError from fetch), not HTTP errors + // Only retry safe reads after network errors, never ambiguous writes. if (attempt === retries || !(err instanceof TypeError)) throw err; await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); } @@ -87,6 +124,10 @@ class ApiClient { } async fetch(path: string, options: RequestInit = {}): Promise { + if (typeof window !== 'undefined') { + this.accessToken = localStorage.getItem('deft-access-token'); + this.refreshToken = localStorage.getItem('deft-refresh-token'); + } const headers = new Headers(options.headers); // Proactive refresh: if we have a refresh token but no access token @@ -96,6 +137,7 @@ class ApiClient { await refreshAccessToken(); } + const requestAccessToken = this.accessToken; if (this.accessToken) { headers.set('Authorization', `Bearer ${this.accessToken}`); } @@ -109,12 +151,21 @@ class ApiClient { // Reactive 401 interceptor: access token was present but has since expired. // Attempt a silent refresh (concurrency-guarded) and retry the original // request exactly once. If the refresh also fails, clear tokens and redirect. - if (response.status === 401) { + if (response.status === 401 && !SESSION_REFRESH_EXEMPT_PATHS.has(path)) { + const currentAccessToken = typeof window !== 'undefined' ? localStorage.getItem('deft-access-token') : this.accessToken; + if (currentAccessToken !== requestAccessToken && !isSameWebSession(requestAccessToken, currentAccessToken)) { + return response; + } const fresh = await refreshAccessToken(); if (fresh) { + if (!isSameWebSession(requestAccessToken, fresh)) return response; headers.set('Authorization', `Bearer ${fresh}`); response = await this.fetchWithRetry(`${API_URL}${path}`, { ...options, headers }); } else { + const latestAccessToken = typeof window !== 'undefined' ? localStorage.getItem('deft-access-token') : this.accessToken; + if (latestAccessToken !== requestAccessToken && !isSameWebSession(requestAccessToken, latestAccessToken)) { + return response; + } this.clearTokens(); // Store current path for post-login redirect if (typeof window !== 'undefined') { diff --git a/apps/web/src/lib/auth-context.tsx b/apps/web/src/lib/auth-context.tsx index 2d893a01..b6aae3c0 100644 --- a/apps/web/src/lib/auth-context.tsx +++ b/apps/web/src/lib/auth-context.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'; import { api } from './api'; import { setUserTimezone } from './time'; import { useRouter } from 'next/navigation'; +import { disconnectSocket } from './socket'; type NotificationPreferences = { keywords: string[]; @@ -55,12 +56,107 @@ type AuthContextType = { loading: boolean; login: (email: string, password: string) => Promise; signup: (name: string, email: string, password: string, orgName: string) => Promise; - logout: () => void; + logout: (options?: LogoutOptions) => Promise; replaceUser: (nextUser: User) => void; refreshUser: () => Promise; }; +type LogoutOptions = { + revokeServer?: boolean; + destination?: string; +}; + +const LOGOUT_TIMEOUT_MS = 3000; + +export class AuthRequestGeneration { + private value = 0; + + capture(): number { + return this.value; + } + + advance(): number { + this.value += 1; + return this.value; + } + + isCurrent(value: number): boolean { + return value === this.value; + } +} + +function sessionIdentity(token: string | null): string | null { + if (!token) return null; + try { + const segment = token.split('.')[1]; + if (!segment) return null; + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))) as Record; + if (typeof payload.id !== 'string' || typeof payload.org_id !== 'string' || typeof payload.sid !== 'string') return null; + return `${payload.id}:${payload.org_id}:${payload.sid}`; + } catch { + return null; + } +} + +export function isCrossTabSessionReplacement(oldRefresh: string | null, newRefresh: string | null): boolean { + if (!newRefresh) return false; + const oldIdentity = sessionIdentity(oldRefresh); + const newIdentity = sessionIdentity(newRefresh); + return oldIdentity === null || newIdentity === null || oldIdentity !== newIdentity; +} + +export function isCurrentRefreshStorageEvent(eventValue: string | null, storedValue: string | null): boolean { + return eventValue === storedValue; +} + +export async function revokeWebSessionBestEffort( + refreshToken: string, + fetchImpl: typeof fetch = fetch, + timeoutMs = LOGOUT_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + try { + await fetchImpl(`${apiUrl}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + signal: controller.signal, + }); + } catch { + // Local logout has already completed; server revocation is best effort. + } finally { + clearTimeout(timeout); + } +} + const AuthContext = createContext(null); +const AUTH_PAGE_PATHS = new Set([ + '/login', + '/signup', + '/forgot-password', + '/reset-password', +]); + +export function safePostLoginDestination(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) { + return null; + } + + try { + const base = new URL('https://deft.local'); + const destination = new URL(value, base); + const normalizedPath = destination.pathname.replace(/\/+$/, '') || '/'; + if (destination.origin !== base.origin || AUTH_PAGE_PATHS.has(normalizedPath.toLowerCase())) { + return null; + } + return `${destination.pathname}${destination.search}${destination.hash}`; + } catch { + return null; + } +} export function useAuth() { const ctx = useContext(AuthContext); @@ -73,12 +169,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [org, setOrg] = useState(null); const [loading, setLoading] = useState(true); const router = useRouter(); + const authGeneration = useRef(new AuthRequestGeneration()); const fetchMe = useCallback(async () => { + const generation = authGeneration.current.capture(); try { const res = await api.get('/api/auth/me'); + if (!authGeneration.current.isCurrent(generation)) return; if (res.ok) { const data = await res.json(); + if (!authGeneration.current.isCurrent(generation)) return; setUser(data.user); setOrg(data.org); // Use browser timezone if DB has default 'UTC' (means not yet auto-detected) @@ -92,10 +192,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setOrg(null); } } catch { + if (!authGeneration.current.isCurrent(generation)) return; setUser(null); setOrg(null); } finally { - setLoading(false); + if (authGeneration.current.isCurrent(generation)) setLoading(false); } }, []); @@ -108,51 +209,82 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [fetchMe]); + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.storageArea !== localStorage || event.key !== 'deft-refresh-token') return; + if (!isCurrentRefreshStorageEvent(event.newValue, localStorage.getItem('deft-refresh-token'))) return; + if (event.newValue === null) { + authGeneration.current.advance(); + api.clearTokens(); + disconnectSocket(); + setUser(null); + setOrg(null); + setLoading(false); + router.replace('/login'); + return; + } + const accessToken = localStorage.getItem('deft-access-token'); + if (!accessToken) return; + api.setTokens(accessToken, event.newValue); + if (!isCrossTabSessionReplacement(event.oldValue, event.newValue)) return; + authGeneration.current.advance(); + disconnectSocket(); + setUser(null); + setOrg(null); + setLoading(true); + void fetchMe(); + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, [fetchMe, router]); + const login = async (email: string, password: string) => { + const generation = authGeneration.current.advance(); const res = await api.post('/api/auth/login', { email, password }); + if (!authGeneration.current.isCurrent(generation)) return; if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Login failed'); } const data = await res.json(); + if (!authGeneration.current.isCurrent(generation)) return; api.setTokens(data.accessToken, data.refreshToken); await fetchMe(); - const redirect = sessionStorage.getItem('deft-redirect-after-login'); - if (redirect) { - sessionStorage.removeItem('deft-redirect-after-login'); - router.push(redirect); - } else { - router.push('/dashboard'); - } + if (!authGeneration.current.isCurrent(generation)) return; + const redirect = safePostLoginDestination( + sessionStorage.getItem('deft-redirect-after-login'), + ); + sessionStorage.removeItem('deft-redirect-after-login'); + router.push(redirect ?? '/dashboard'); }; const signup = async (name: string, email: string, password: string, orgName: string) => { + const generation = authGeneration.current.advance(); const res = await api.post('/api/auth/signup', { name, email, password, org_name: orgName }); + if (!authGeneration.current.isCurrent(generation)) return; if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Signup failed'); } const data = await res.json(); + if (!authGeneration.current.isCurrent(generation)) return; api.setTokens(data.accessToken, data.refreshToken); await fetchMe(); + if (!authGeneration.current.isCurrent(generation)) return; router.push('/setup-ai'); }; - const logout = async () => { + const logout = async (options: LogoutOptions = {}) => { const refreshToken = localStorage.getItem('deft-refresh-token'); - if (refreshToken) { - // Best-effort server-side revocation — ignore failures so client still clears - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; - await fetch(`${apiUrl}/api/auth/logout`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - }).catch(() => {}); - } + authGeneration.current.advance(); api.clearTokens(); + disconnectSocket(); setUser(null); setOrg(null); - router.push('/login'); + router.replace(options.destination ?? '/login'); + if (refreshToken && options.revokeServer !== false) { + await revokeWebSessionBestEffort(refreshToken); + } }; const replaceUser = useCallback((nextUser: User) => { diff --git a/docs/superpowers/plans/2026-09-05-preview-session-decision.md b/docs/superpowers/plans/2026-09-05-preview-session-decision.md new file mode 100644 index 00000000..d72bb0e6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-preview-session-decision.md @@ -0,0 +1,15 @@ +# Preview session-family decision + +L07 reproduction on the frozen Loop 0 Default image: first refresh 200, reused refresh 200, malformed object token 500, access after logout 200. Desired behavior is one-use rotation, durable revocation and structured input errors while preserving current organization membership checks and separate MCP OAuth grants. + +The existing flow signs stateless access/refresh JWTs at login/signup/invite acceptance; logout blacklists one refresh hash. HTTP middleware, auth routes and socket handshake independently verify signatures. A blacklist-only patch cannot revoke descendants or already-issued access tokens. Migrating the whole product to another auth framework adds unrelated account/client/schema compatibility work. The smallest adequate change is an additive web-session family table and one shared token service used by all existing issuers/verifiers. + +Store the family ID, user/org, current refresh hash, absolute expiry and revocation timestamp. Rotation locks the family row; valid reuse revokes the family, including its descendant/access tokens. Concurrent use is treated as reuse: at most one rotation succeeds, and subsequent family access is denied. The browser must serialize refresh across same-origin tabs where Web Locks is available as well as within a tab. No grace window or stored plaintext refresh token is introduced. Database failure denies access. + +New access and refresh JWTs have distinct purpose claims, random token IDs and a family ID. Legacy browser JWTs without a family are rejected after upgrade, requiring sign-in again. This is an intentional security cutover; users and membership data are unchanged. Existing MCP OAuth credentials retain their independent implementation. Password changes/reset revoke the user's web sessions. Live sockets receive family revocation and recheck credentials at event boundaries. + +Migration is additive and follows the supported upgrade manifest. Old images must not restart after migration unless the upgrader establishes compatibility; rollback otherwise restores the recorded DB/config/image backup. Do not remove the new table to bypass revocation. Fresh and upgraded schema parity, real DB concurrent/reuse/logout tests, malformed inputs, current membership denial, socket revocation, existing MCP OAuth tests and browser refresh coordination are required focused evidence before B1. + +Upgrade `.27` adds session families; `.28` adds a monotonically increasing user password generation. Reset links carry this generation and the organization that authorized recovery. Reset holds the user lock, checks the generation and current active membership, consumes the reset token, changes the password, increments the generation and revokes every family in one transaction. This also invalidates other previously issued recovery links. Legacy reset links require replacement after upgrade. Login and authenticated password changes compare the password hash observed before bcrypt under the user lock, preventing a concurrent reset from being overwritten or followed by a stale-password login. Invite acceptance locks and consumes the invite once and cannot overwrite an established password. + +Browser logout clears credentials, identity and realtime immediately, then attempts server revocation with a three-second bound. Password change redirects to sign-in after the server revokes all families. These semantics deliberately require reauthentication across the security cutover; they do not change MCP credential ownership. diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index 12707899..ec082c66 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -130,6 +130,12 @@ async function main() { const appAutomationFoundationFile = '0.3.0-preview.26-app-automation-foundation.sql'; await client.query(readFileSync(resolve(upgradesDir, appAutomationFoundationFile), 'utf8')); console.log(`[apply-extras] reconciled ${appAutomationFoundationFile}`); + const webSessionFile = '0.3.0-preview.27-web-session-families.sql'; + await client.query(readFileSync(resolve(upgradesDir, webSessionFile), 'utf8')); + console.log(`[apply-extras] reconciled ${webSessionFile}`); + const passwordGenerationFile = '0.3.0-preview.28-password-generation.sql'; + await client.query(readFileSync(resolve(upgradesDir, passwordGenerationFile), 'utf8')); + console.log(`[apply-extras] reconciled ${passwordGenerationFile}`); // Expression-based unique indexes can't be declared in schema.ts, so // `drizzle-kit push` silently drops them. Re-create the ones the app diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1079531b..005f9d6e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -187,6 +187,7 @@ export const users = pgTable('users', { status_text: text('status_text'), status_expires_at: timestamp('status_expires_at'), password_hash: text('password_hash'), + password_version: integer('password_version').default(0).notNull(), email_verified: boolean('email_verified').default(false).notNull(), last_seen_at: timestamp('last_seen_at'), notification_keywords: text('notification_keywords').array(), @@ -4441,6 +4442,17 @@ export const messageClassifications = pgTable('message_classifications', { index('mc_org_msg_idx').on(t.org_id, t.message_id), ]); +// Browser session families: refresh rotation and access revocation share one row. +export const webSessions = pgTable('web_sessions', { + ...id(), + user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + org_id: text('org_id').notNull().references(() => orgs.id, { onDelete: 'cascade' }), + refresh_token_hash: text('refresh_token_hash').notNull(), + expires_at: timestamp('expires_at', { withTimezone: true }).notNull(), + revoked_at: timestamp('revoked_at', { withTimezone: true }), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (t) => [index('web_sessions_user_org_idx').on(t.user_id, t.org_id)]); + // ═══ REVOKED TOKENS ═══ // Server-side refresh token revocation (Option B — stateless JWTs, hash-based blacklist). // Logout inserts the sha256 hash; /refresh rejects any token whose hash is present. diff --git a/packages/db/upgrades/0.3.0-preview.27-web-session-families.sql b/packages/db/upgrades/0.3.0-preview.27-web-session-families.sql new file mode 100644 index 00000000..faf36e22 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.27-web-session-families.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS web_sessions ( + id text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + refresh_token_hash text NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS web_sessions_user_org_idx ON web_sessions(user_id, org_id); diff --git a/packages/db/upgrades/0.3.0-preview.28-password-generation.sql b/packages/db/upgrades/0.3.0-preview.28-password-generation.sql new file mode 100644 index 00000000..8b962cc5 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.28-password-generation.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_version integer NOT NULL DEFAULT 0; diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index be99af50..354c794b 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -152,6 +152,16 @@ export const upgradeManifest = { file: '0.3.0-preview.26-app-automation-foundation.sql', description: 'Add dormant approved App automation definitions, fire identities, and exact Run lineage', }, + { + version: '0.3.0-preview.27', + file: '0.3.0-preview.27-web-session-families.sql', + description: 'Add durable browser session families for rotation and immediate revocation; legacy browser sessions require sign-in', + }, + { + version: '0.3.0-preview.28', + file: '0.3.0-preview.28-password-generation.sql', + description: 'Invalidate outstanding browser password reset links after credential changes', + }, ] satisfies UpgradeMigration[], } as const; From 8f2b9d382b0128ffa2007ef651b712e56cf1dac7 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:07:53 +0530 Subject: [PATCH 2/8] fix(notes): preserve pending edits and authorize protected editor images --- apps/web/package.json | 42 +- .../(app)/notes/note-save-coordinator.test.ts | 95 +++ .../app/(app)/notes/note-save-coordinator.ts | 84 +++ apps/web/src/app/(app)/notes/page.tsx | 126 ++-- .../(app)/notes/protected-note-image.test.ts | 16 + .../app/(app)/notes/protected-note-image.tsx | 85 +++ .../src/lib/editor-attribute-security.test.ts | 11 + pnpm-lock.yaml | 662 +++++++++--------- scripts/preview-notes-browser.mjs | 376 ++++++++++ 9 files changed, 1098 insertions(+), 399 deletions(-) create mode 100644 apps/web/src/app/(app)/notes/note-save-coordinator.test.ts create mode 100644 apps/web/src/app/(app)/notes/note-save-coordinator.ts create mode 100644 apps/web/src/app/(app)/notes/protected-note-image.test.ts create mode 100644 apps/web/src/app/(app)/notes/protected-note-image.tsx create mode 100644 apps/web/src/lib/editor-attribute-security.test.ts create mode 100644 scripts/preview-notes-browser.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 23368004..bfe00e20 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,27 +17,27 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/postcss": "^4.3.3", - "@tiptap/core": "^3.30.1", - "@tiptap/extension-code-block-lowlight": "^3.30.1", - "@tiptap/extension-details": "^3.30.1", - "@tiptap/extension-document": "^3.30.1", - "@tiptap/extension-highlight": "^3.30.1", - "@tiptap/extension-image": "^3.30.1", - "@tiptap/extension-link": "^3.30.1", - "@tiptap/extension-paragraph": "^3.30.1", - "@tiptap/extension-placeholder": "^3.30.1", - "@tiptap/extension-table": "^3.30.1", - "@tiptap/extension-table-cell": "^3.30.1", - "@tiptap/extension-table-header": "^3.30.1", - "@tiptap/extension-table-row": "^3.30.1", - "@tiptap/extension-task-item": "^3.30.1", - "@tiptap/extension-task-list": "^3.30.1", - "@tiptap/extension-text": "^3.30.1", - "@tiptap/extension-underline": "^3.30.1", - "@tiptap/pm": "^3.30.1", - "@tiptap/react": "^3.30.1", - "@tiptap/starter-kit": "^3.30.1", - "@tiptap/suggestion": "^3.30.1", + "@tiptap/core": "^3.30.4", + "@tiptap/extension-code-block-lowlight": "^3.30.4", + "@tiptap/extension-details": "^3.30.4", + "@tiptap/extension-document": "^3.30.4", + "@tiptap/extension-highlight": "^3.30.4", + "@tiptap/extension-image": "^3.30.4", + "@tiptap/extension-link": "^3.30.4", + "@tiptap/extension-paragraph": "^3.30.4", + "@tiptap/extension-placeholder": "^3.30.4", + "@tiptap/extension-table": "^3.30.4", + "@tiptap/extension-table-cell": "^3.30.4", + "@tiptap/extension-table-header": "^3.30.4", + "@tiptap/extension-table-row": "^3.30.4", + "@tiptap/extension-task-item": "^3.30.4", + "@tiptap/extension-task-list": "^3.30.4", + "@tiptap/extension-text": "^3.30.4", + "@tiptap/extension-underline": "^3.30.4", + "@tiptap/pm": "^3.30.4", + "@tiptap/react": "^3.30.4", + "@tiptap/starter-kit": "^3.30.4", + "@tiptap/suggestion": "^3.30.4", "@types/react-resizable": "^4.0.0", "@types/turndown": "^5.0.6", "d3-drag": "^3.0.0", diff --git a/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts b/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts new file mode 100644 index 00000000..8afed402 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-save-coordinator.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NoteSaveCoordinator } from './note-save-coordinator'; + +test('does not report saved while a body edit is still pending behind a title save', async () => { + const requests: Array> = []; + const statuses: string[] = []; + const coordinator = new NoteSaveCoordinator(async (payload) => { + requests.push(payload); + return true; + }, status => statuses.push(status)); + + const bodyRevision = coordinator.markDirty('content'); + const titleRevision = coordinator.markDirty('title'); + await coordinator.save('title', titleRevision, { title: 'Changed title' }); + + assert.equal(coordinator.status, 'saving'); + await coordinator.save('content', bodyRevision, { content: '

Changed body

' }); + assert.equal(coordinator.status, 'saved'); + assert.deepEqual(requests, [ + { title: 'Changed title' }, + { content: '

Changed body

' }, + ]); + assert.equal(statuses.includes('saved'), true); +}); + +test('keeps a failed field dirty and never reports saved after an HTTP failure', async () => { + const coordinator = new NoteSaveCoordinator(async () => false); + const revision = coordinator.markDirty('content'); + + await coordinator.save('content', revision, { content: '

Not persisted

' }); + + assert.equal(coordinator.status, 'error'); + assert.equal(coordinator.isDirty('content'), true); +}); + +test('serializes note writes so delayed responses cannot overwrite newer fields out of order', async () => { + const releases: Array<() => void> = []; + const started: string[] = []; + const coordinator = new NoteSaveCoordinator(async (payload) => { + started.push(Object.keys(payload)[0]); + await new Promise(resolve => releases.push(resolve)); + return true; + }); + const bodyRevision = coordinator.markDirty('content'); + const titleRevision = coordinator.markDirty('title'); + + const bodySave = coordinator.save('content', bodyRevision, { content: 'body' }); + const titleSave = coordinator.save('title', titleRevision, { title: 'title' }); + await Promise.resolve(); + assert.deepEqual([...started], ['content']); + releases.shift()?.(); + await bodySave; + await Promise.resolve(); + assert.deepEqual([...started], ['content', 'title']); + releases.shift()?.(); + await Promise.all([bodySave, titleSave]); + assert.equal(coordinator.status, 'saved'); +}); + +test('awaitIdle waits for a debounce-fired in-flight failure before Back decides to leave', async () => { + let release!: (ok: boolean) => void; + const coordinator = new NoteSaveCoordinator(() => new Promise(resolve => { release = resolve; })); + const revision = coordinator.markDirty('content'); + void coordinator.save('content', revision, { content: 'delayed body' }); + await Promise.resolve(); + + let settled = false; + const idle = coordinator.awaitIdle().then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false); + + release(false); + await idle; + assert.equal(coordinator.status, 'error'); +}); + +test('a new dirty edit made while Back awaits an older save keeps the editor unsettled', async () => { + let release!: () => void; + const coordinator = new NoteSaveCoordinator(async () => { + await new Promise(resolve => { release = resolve; }); + return true; + }); + const firstRevision = coordinator.markDirty('content'); + void coordinator.save('content', firstRevision, { content: 'first edit' }); + await Promise.resolve(); + + const idle = coordinator.awaitIdle(); + coordinator.markDirty('content'); + release(); + await idle; + + assert.equal(coordinator.status, 'saving'); + assert.equal(coordinator.isDirty('content'), true); +}); diff --git a/apps/web/src/app/(app)/notes/note-save-coordinator.ts b/apps/web/src/app/(app)/notes/note-save-coordinator.ts new file mode 100644 index 00000000..711b3741 --- /dev/null +++ b/apps/web/src/app/(app)/notes/note-save-coordinator.ts @@ -0,0 +1,84 @@ +export type NoteSaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +type SaveRequest = (payload: Record) => Promise; +type StatusListener = (status: NoteSaveStatus) => void; + +/** + * Keeps every note mutation on one ordered lane and tracks dirtiness per field. + * A successful title/icon write therefore cannot acknowledge an unsent body edit. + */ +export class NoteSaveCoordinator { + private revisions = new Map(); + private dirty = new Map(); + private failed = new Map(); + private queue: Promise = Promise.resolve(); + private inFlight = 0; + private currentStatus: NoteSaveStatus = 'idle'; + + constructor( + private readonly request: SaveRequest, + private readonly onStatus: StatusListener = () => {}, + ) {} + + get status(): NoteSaveStatus { + return this.currentStatus; + } + + markDirty(field: string): number { + const revision = (this.revisions.get(field) ?? 0) + 1; + this.revisions.set(field, revision); + this.dirty.set(field, revision); + this.failed.delete(field); + this.setStatus('saving'); + return revision; + } + + isDirty(field: string): boolean { + return this.dirty.has(field); + } + + async awaitIdle(): Promise { + while (true) { + const observedQueue = this.queue; + await observedQueue; + if (this.queue === observedQueue) return; + } + } + + save(field: string, revision: number, payload: Record): Promise { + const operation = async () => { + this.inFlight += 1; + this.updateStatus(); + try { + const ok = await this.request(payload); + if (ok) { + if (this.dirty.get(field) === revision) this.dirty.delete(field); + if (this.failed.get(field) === revision) this.failed.delete(field); + } else if (this.dirty.get(field) === revision) { + this.failed.set(field, revision); + } + } catch { + if (this.dirty.get(field) === revision) this.failed.set(field, revision); + } finally { + this.inFlight -= 1; + this.updateStatus(); + } + }; + + const result = this.queue.then(operation, operation); + this.queue = result.catch(() => {}); + return result; + } + + private updateStatus() { + if (this.failed.size > 0) this.setStatus('error'); + else if (this.dirty.size > 0 || this.inFlight > 0) this.setStatus('saving'); + else this.setStatus('saved'); + } + + private setStatus(status: NoteSaveStatus) { + if (this.currentStatus === status) return; + this.currentStatus = status; + this.onStatus(status); + } +} diff --git a/apps/web/src/app/(app)/notes/page.tsx b/apps/web/src/app/(app)/notes/page.tsx index 0cfab9df..efebc2d4 100644 --- a/apps/web/src/app/(app)/notes/page.tsx +++ b/apps/web/src/app/(app)/notes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { sanitizeHtml } from '@/lib/sanitize'; @@ -18,7 +18,6 @@ import { Table } from '@tiptap/extension-table'; import { TableRow } from '@tiptap/extension-table-row'; import { TableCell } from '@tiptap/extension-table-cell'; import { TableHeader } from '@tiptap/extension-table-header'; -import { Image as TiptapImage } from '@tiptap/extension-image'; import { TaskList } from '@tiptap/extension-task-list'; import { TaskItem } from '@tiptap/extension-task-item'; import { Highlight } from '@tiptap/extension-highlight'; @@ -40,6 +39,8 @@ import { useSetPageContext } from '@/components/app-header-context'; import { OverflowMenu } from '@/components/overflow-menu'; import { AppBottomSheet } from '@/components/overlay-primitives'; import { stripHtml } from '@/lib/strip-html'; +import { NoteSaveCoordinator, type NoteSaveStatus } from './note-save-coordinator'; +import { noteImageAttributes, ProtectedNoteImage } from './protected-note-image'; // Register built-in slash menu commands (idempotent). registerBuiltInCommands(); @@ -203,7 +204,7 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const { user } = useAuth(); const [note, setNote] = useState(null); const [loading, setLoading] = useState(true); - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [saveStatus, setSaveStatus] = useState('idle'); const [title, setTitle] = useState(''); const [icon, setIcon] = useState(null); const [visibility, setVisibility] = useState<'private' | 'org'>('private'); @@ -235,6 +236,22 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const initialContentSet = useRef(false); const titleDebounce = useRef>(undefined); const deleteTimerRef = useRef>(undefined); + const pendingContentSave = useRef<{ revision: number; payload: Record } | null>(null); + const pendingTitleSave = useRef<{ revision: number; payload: Record } | null>(null); + + const saveCoordinator = useMemo(() => new NoteSaveCoordinator( + async payload => { + const response = await api.patch(`/api/daily-notes/${noteId}`, payload); + return response.ok; + }, + status => { + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + setSaveStatus(status); + if (status === 'saved') { + savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + } + }, + ), [noteId]); const isNoteOwner = !note || note.user_id === user?.id; @@ -253,7 +270,7 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () TableRow, TableCell, TableHeader, - TiptapImage.configure({ inline: false, allowBase64: false }), + ProtectedNoteImage.configure({ inline: false, allowBase64: false }), TaskList, TaskItem.configure({ nested: true }), Highlight.configure({ multicolor: false }), @@ -277,10 +294,10 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () api.upload('/api/upload', file).then(async (res) => { if (res.ok) { const data = await res.json(); - const imgNode = view.state.schema.nodes.image; + const imgNode = view.state.schema.nodes.protectedNoteImage; if (imgNode) { view.dispatch(view.state.tr.replaceSelectionWith( - imgNode.create({ src: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/api/files/${data.id}` }) + imgNode.create(noteImageAttributes({ id: data.id, name: file.name })) )); } } @@ -293,13 +310,14 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () }, onUpdate: ({ editor: ed }) => { if (!initialContentSet.current) return; - setSaveStatus('saving'); + const revision = saveCoordinator.markDirty('content'); + pendingContentSave.current = { revision, payload: { content: ed.getHTML() } }; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(async () => { - await api.patch(`/api/daily-notes/${noteId}`, { content: ed.getHTML() }); - setSaveStatus('saved'); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + saveTimerRef.current = setTimeout(() => { + const pending = pendingContentSave.current; + if (!pending || pending.revision !== revision) return; + pendingContentSave.current = null; + void saveCoordinator.save('content', pending.revision, pending.payload); }, 600); }, }); @@ -315,7 +333,10 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const res = await api.upload('/api/upload', file); if (res.ok) { const data = await res.json(); - editor.chain().focus().setImage({ src: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/api/files/${data.id}` }).run(); + editor.chain().focus().insertContent({ + type: 'protectedNoteImage', + attrs: noteImageAttributes({ id: data.id, name: file.name }), + }).run(); } } catch {} }; @@ -335,27 +356,11 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () URL.revokeObjectURL(url); }, [editor, note, title]); - const clearSavedTimers = useCallback(() => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - }, []); - - const markSaved = useCallback(() => { - clearSavedTimers(); - setSaveStatus('saved'); - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); - }, [clearSavedTimers]); - const persistNoteUpdate = useCallback(async (payload: Record) => { - setSaveStatus('saving'); - try { - const res = await api.patch(`/api/daily-notes/${noteId}`, payload); - if (res.ok) markSaved(); - else setSaveStatus('idle'); - } catch { - setSaveStatus('idle'); - } - }, [markSaved, noteId]); + const field = Object.keys(payload)[0] || 'metadata'; + const revision = saveCoordinator.markDirty(field); + await saveCoordinator.save(field, revision, payload); + }, [saveCoordinator]); const handlePromoteToWiki = async () => { if (!editor || !note || !title.trim()) return; @@ -467,19 +472,36 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () const handleTitleChange = (value: string) => { setTitle(value); - setSaveStatus('saving'); + const revision = saveCoordinator.markDirty('title'); + pendingTitleSave.current = { revision, payload: { title: value } }; if (titleDebounce.current) clearTimeout(titleDebounce.current); - titleDebounce.current = setTimeout(async () => { - try { - const res = await api.patch(`/api/daily-notes/${noteId}`, { title: value }); - if (res.ok) markSaved(); - else setSaveStatus('idle'); - } catch { - setSaveStatus('idle'); - } + titleDebounce.current = setTimeout(() => { + const pending = pendingTitleSave.current; + if (!pending || pending.revision !== revision) return; + pendingTitleSave.current = null; + void saveCoordinator.save('title', pending.revision, pending.payload); }, 500); }; + const flushPendingSaves = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (titleDebounce.current) clearTimeout(titleDebounce.current); + const content = pendingContentSave.current; + const pendingTitle = pendingTitleSave.current; + pendingContentSave.current = null; + pendingTitleSave.current = null; + const saves: Promise[] = []; + if (content) saves.push(saveCoordinator.save('content', content.revision, content.payload)); + if (pendingTitle) saves.push(saveCoordinator.save('title', pendingTitle.revision, pendingTitle.payload)); + await Promise.all(saves); + }, [saveCoordinator]); + + const handleEditorBack = useCallback(async () => { + await flushPendingSaves(); + await saveCoordinator.awaitIdle(); + if (saveCoordinator.status === 'saved' || saveCoordinator.status === 'idle') onBack(); + }, [flushPendingSaves, onBack, saveCoordinator]); + const handleIconChange = async (emoji: string) => { setIcon(emoji); setIconPickerOpen(false); @@ -519,10 +541,15 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () setPendingDelete(false); }; - // Clean up delete timer on unmount + // Clean up editor timers on unmount. The explicit Back action awaits the + // coordinator; browser/tab teardown remains best-effort by design. useEffect(() => { - return () => { if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current); }; - }, []); + return () => { + if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current); + void flushPendingSaves(); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, [flushPendingSaves]); const isOwner = !note || note.user_id === user?.id; @@ -559,7 +586,7 @@ function NoteEditor({ noteId, onBack, onDeleted }: { noteId: string; onBack: () > {/* Top bar */}
-