From 80416dda97e526af6cfe4421585dade9abe91239 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:27:15 -0700 Subject: [PATCH 01/16] feat(db): add sessionVersion, PasswordResetToken and AuthRateLimit TeamMember.sessionVersion lets the web app revoke every JWT session a member holds by bumping one integer. PasswordResetToken stores only the SHA-256 hash of each reset link token, with expiry, consumption and supersession timestamps and the member it belongs to. AuthRateLimit backs a fixed-window rate limiter that works across web replicas. --- .../migration.sql | 57 +++++++++++++++++++ packages/outpost/db/prisma/schema.prisma | 57 ++++++++++++++----- 2 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 packages/outpost/db/prisma/migrations/20261028223454_add_password_reset/migration.sql diff --git a/packages/outpost/db/prisma/migrations/20261028223454_add_password_reset/migration.sql b/packages/outpost/db/prisma/migrations/20261028223454_add_password_reset/migration.sql new file mode 100644 index 0000000..0d7a4ba --- /dev/null +++ b/packages/outpost/db/prisma/migrations/20261028223454_add_password_reset/migration.sql @@ -0,0 +1,57 @@ +-- Password reset (outpost#206) and session revocation (the old #207). +-- +-- "TeamMember"."sessionVersion" is copied into the JWT at sign-in and compared on +-- every server-side session read; a password reset, a profile password change, or a +-- disable bumps it, which revokes every older session. NOT NULL DEFAULT 0 with a +-- constant default is a catalog-only change on PG11+, so the ACCESS EXCLUSIVE lock on +-- "TeamMember" is brief. Every existing row starts at 0 and the callback treats a +-- token with no `sv` as 0, so no existing ACTIVE session is signed out by this deploy. +-- +-- "PasswordResetToken" stores only sha256(token). "memberId" is deliberately not +-- unique: issuing deletes the member's unused rows explicitly and used rows stay for +-- a 7-day audit trail. "AuthRateLimit" is the fixed-window counter for the public +-- reset routes and the admin send. +-- +-- Additive only. apps/web/start.sh and apps/worker/start.sh both run `migrate deploy` +-- against this database from the same commit; whichever starts first applies it, and +-- old replicas still running during the rollout ignore the new column and tables. +-- No lock_timeout, for the same reason as 20260820120000_add_job_claim_fencing: +-- waiting is recoverable, a half-recorded migration is not. + +-- AlterTable +ALTER TABLE "TeamMember" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "issuedVersion" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "requestedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthRateLimit" ( + "key" TEXT NOT NULL, + "windowStart" TIMESTAMP(3) NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "AuthRateLimit_pkey" PRIMARY KEY ("key","windowStart") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_memberId_idx" ON "PasswordResetToken"("memberId"); + +-- CreateIndex +CREATE INDEX "AuthRateLimit_windowStart_idx" ON "AuthRateLimit"("windowStart"); + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "TeamMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/outpost/db/prisma/schema.prisma b/packages/outpost/db/prisma/schema.prisma index 5fadc25..e9fe99d 100644 --- a/packages/outpost/db/prisma/schema.prisma +++ b/packages/outpost/db/prisma/schema.prisma @@ -392,21 +392,23 @@ model Organization { } model TeamMember { - id String @id @default(cuid()) - name String - email String @unique - passwordHash String? - role TeamMemberRole @default(MEMBER) - status TeamMemberStatus @default(ACTIVE) - avatarUrl String? - invitedBy String? - invitedAt DateTime? - joinedAt DateTime? - tickets Ticket[] - inviteTokens InviteToken[] - externalIdentities ExternalIdentity[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + email String @unique + passwordHash String? + role TeamMemberRole @default(MEMBER) + status TeamMemberStatus @default(ACTIVE) + avatarUrl String? + invitedBy String? + invitedAt DateTime? + joinedAt DateTime? + tickets Ticket[] + inviteTokens InviteToken[] + externalIdentities ExternalIdentity[] + sessionVersion Int @default(0) + passwordResetTokens PasswordResetToken[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum TeamMemberRole { @@ -518,3 +520,28 @@ model TemplateOverride { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model PasswordResetToken { + id String @id @default(cuid()) + memberId String + tokenHash String @unique + issuedVersion Int + expiresAt DateTime + usedAt DateTime? + // Admin TeamMember.id for an admin-sent link (spec §10); null for self-service. + // Plain string, no FK, so the row survives the admin's removal. + requestedById String? + createdAt DateTime @default(now()) + member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade) + + @@index([memberId]) +} + +model AuthRateLimit { + key String + windowStart DateTime + count Int @default(0) + + @@id([key, windowStart]) + @@index([windowStart]) +} From 5f04cae068996b6c91dcea9e04deafcf5103af2a Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:27:31 -0700 Subject: [PATCH 02/16] feat(shared): add requireTransport and trackLinks to sendEmail, and ship SMTP sendEmail gains two options. requireTransport makes it refuse, rather than fall back to logging to the console, when neither Postmark nor SMTP is configured, and the refusal names a transport that failed to load. trackLinks controls Postmark link tracking so security emails can turn it off and keep their one-time links unrewritten. nodemailer becomes a real dependency of @copilotkit/outpost, and the web server bundle stops aliasing it to an empty module, so SMTP sends work in the production web image instead of failing at load time. --- apps/web/next.config.ts | 9 +- .../__tests__/production-email-image.test.ts | 10 + packages/outpost/package.json | 1 + .../email/__tests__/sender-options.test.ts | 215 ++++++++++++++++++ .../__tests__/transport-dependencies.test.ts | 26 +++ packages/outpost/shared/src/email/sender.ts | 60 +++-- pnpm-lock.yaml | 9 + 7 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 packages/outpost/shared/src/email/__tests__/sender-options.test.ts create mode 100644 packages/outpost/shared/src/email/__tests__/transport-dependencies.test.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 970e965..7cc0949 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -39,11 +39,10 @@ const nextConfig: NextConfig = { 'utf-8-validate', '@slack/web-api', '@slack/bolt', - // nodemailer stays nulled: it is not installed, and the SMTP transport - // only matters for self-hosted setups. postmark is NOT here: sendEmail - // (transpiled from @copilotkit/outpost) loads it in this server bundle, - // and nulling it made every Postmark send fail (outpost#253). - 'nodemailer', + // postmark and nodemailer are NOT here: sendEmail (transpiled from + // @copilotkit/outpost) loads them in this server bundle. Nulling postmark + // made every Postmark send fail (outpost#253); nulling nodemailer made every + // SMTP send fail (outpost#206). nodemailer is a dependency of @copilotkit/outpost. ]; for (const pkg of nullPkgs) { config.resolve.alias[pkg] = false; diff --git a/apps/web/src/__tests__/production-email-image.test.ts b/apps/web/src/__tests__/production-email-image.test.ts index 0d84373..ecb91ed 100644 --- a/apps/web/src/__tests__/production-email-image.test.ts +++ b/apps/web/src/__tests__/production-email-image.test.ts @@ -56,4 +56,14 @@ describe('production web image: email', () => { const out = webpack(config, { isServer: true }); expect(out.resolve.alias.postmark).toBeUndefined(); }); + + it('does not replace nodemailer with an empty module in the server bundle', () => { + const config = { resolve: { alias: {} as Record } }; + const webpack = nextConfig.webpack as ( + c: typeof config, + o: { isServer: boolean }, + ) => typeof config; + const out = webpack(config, { isServer: true }); + expect(out.resolve.alias.nodemailer).toBeUndefined(); + }); }); diff --git a/packages/outpost/package.json b/packages/outpost/package.json index d9ce8fb..be74ac3 100644 --- a/packages/outpost/package.json +++ b/packages/outpost/package.json @@ -57,6 +57,7 @@ "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", + "nodemailer": "^10.2.1", "postmark": "^4.0.0", "zod": "^4.3.6" }, diff --git a/packages/outpost/shared/src/email/__tests__/sender-options.test.ts b/packages/outpost/shared/src/email/__tests__/sender-options.test.ts new file mode 100644 index 0000000..02c9d7f --- /dev/null +++ b/packages/outpost/shared/src/email/__tests__/sender-options.test.ts @@ -0,0 +1,215 @@ +/** + * requireTransport and trackLinks (outpost#206). The postmark module is mocked so the + * TrackLinks mapping inside createPostmarkTransport is observable; vi.mock intercepts + * the factory's dynamic import(). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockPostmarkSendEmail, mockSendMail, brokenPackages } = vi.hoisted(() => ({ + mockPostmarkSendEmail: vi.fn(), + mockSendMail: vi.fn(), + // A package named here throws on first access to its exports, which the transport + // factory catches in the same block as a failed import() (outpost#206). + brokenPackages: new Set(), +})); + +function loadOrThrow(pkg: string): void { + if (brokenPackages.has(pkg)) throw new Error(`Cannot find package '${pkg}'`); +} + +vi.mock('postmark', () => ({ + get ServerClient() { + loadOrThrow('postmark'); + return class { + sendEmail = mockPostmarkSendEmail; + }; + }, + get Models() { + loadOrThrow('postmark'); + return { LinkTrackingOptions: { None: 'None' } }; + }, +})); +vi.mock('nodemailer', () => ({ + get createTransport() { + loadOrThrow('nodemailer'); + return () => ({ sendMail: mockSendMail }); + }, +})); + +import { sendEmail } from '../sender.js'; +import type { EmailSendOptions, EmailTransport } from '../sender.js'; + +const context = { + org: { name: 'Acme', email: 'noreply@acme.test' }, + member: { name: 'A', email: 'a@acme.test' }, +}; + +// Every test starts with no transport configured, whatever the ambient environment +// holds (an exported POSTMARK_SERVER_TOKEN would otherwise route the SMTP test to +// Postmark), and the original values are put back afterwards. +const TRANSPORT_ENV = [ + 'POSTMARK_SERVER_TOKEN', + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_SECURE', + 'SMTP_USER', + 'SMTP_PASS', +] as const; +const savedEnv: Partial> = {}; + +beforeEach(() => { + for (const key of TRANSPORT_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + vi.restoreAllMocks(); + mockPostmarkSendEmail.mockReset(); + mockSendMail.mockReset(); + for (const key of TRANSPORT_ENV) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +describe('sendEmail requireTransport', () => { + it('refuses the console fallback and prints nothing', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await sendEmail({ + to: 'a@acme.test', + template: 'welcome', + context, + requireTransport: true, + }); + + expect(result).toEqual({ + success: false, + error: 'No email transport configured', + method: 'console', + }); + expect(log).not.toHaveBeenCalled(); + }); + + it('keeps the console fallback when requireTransport is not set', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await sendEmail({ to: 'a@acme.test', template: 'welcome', context }); + + expect(result).toEqual({ success: true, method: 'console' }); + expect(log).toHaveBeenCalled(); + }); +}); + +describe('sendEmail requireTransport when the configured transport fails to load', () => { + afterEach(() => { + brokenPackages.clear(); + }); + + it.each([ + ['postmark', 'POSTMARK_SERVER_TOKEN', 'pm-token'], + ['nodemailer', 'SMTP_HOST', 'mailpit'], + ] as const)( + 'names the %s load failure instead of "not configured", and prints nothing', + async (pkg, envVar, value) => { + process.env[envVar] = value; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + brokenPackages.add(pkg); + + const result = await sendEmail({ + to: 'a@acme.test', + template: 'system/password-reset', + context: { + ...context, + reset: { url: 'https://x.test/r#token=t', expiresIn: '30 minutes' }, + }, + requireTransport: true, + }); + + expect(result).toEqual({ + success: false, + error: `Email transport configured (${envVar}) but its package failed to load`, + method: 'console', + }); + expect(log).not.toHaveBeenCalled(); + // The factory's diagnostic names the package and never carries the body. + expect(err).toHaveBeenCalledTimes(1); + expect(String(err.mock.calls[0][0])).toContain(`${envVar} is set but ${pkg}`); + expect(JSON.stringify(err.mock.calls)).not.toContain('token=t'); + }, + ); +}); + +describe('sendEmail trackLinks', () => { + it('passes trackLinks to an injected transport', async () => { + const sent: EmailSendOptions[] = []; + const transport: EmailTransport = { + send: async (opts) => { + sent.push(opts); + return { id: '1' }; + }, + }; + + await sendEmail({ + to: 'a@acme.test', + template: 'welcome', + context, + transport, + trackLinks: 'None', + }); + + expect(sent[0].trackLinks).toBe('None'); + }); + + it('maps trackLinks to Postmark TrackLinks None, and omits it otherwise', async () => { + process.env.POSTMARK_SERVER_TOKEN = 'test-token'; + mockPostmarkSendEmail.mockResolvedValue({ MessageID: 'pm-1' }); + + await sendEmail({ to: 'a@acme.test', template: 'welcome', context, trackLinks: 'None' }); + expect(mockPostmarkSendEmail.mock.calls[0][0]).toMatchObject({ TrackLinks: 'None' }); + + await sendEmail({ to: 'a@acme.test', template: 'welcome', context }); + expect(mockPostmarkSendEmail.mock.calls[1][0].TrackLinks).toBeUndefined(); + }); +}); + +describe('createSmtpTransport', () => { + it('loads nodemailer through a module import and sends the full message', async () => { + process.env.SMTP_HOST = 'mailpit'; + // A value no code path invents, so a transport that returns its own id fails. + mockSendMail.mockResolvedValue({ messageId: '' }); + + const result = await sendEmail({ + to: ['a@acme.test', 'b@acme.test'], + template: 'system/password-reset', + context: { + org: { name: 'Acme', email: 'noreply@acme.test' }, + member: { email: 'a@acme.test' }, + reset: { + url: 'https://outpost.test/reset-password#token=abc', + expiresIn: '30 minutes', + }, + }, + replyTo: 'support@acme.test', + }); + + expect(result).toEqual({ + success: true, + messageId: '', + method: 'smtp', + }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + const mail = mockSendMail.mock.calls[0][0]; + expect(mail).toMatchObject({ + from: 'Acme ', + to: 'a@acme.test, b@acme.test', + subject: 'Reset your Acme Outpost password', + replyTo: 'support@acme.test', + }); + expect(mail.html).toContain('href="https://outpost.test/reset-password#token=abc"'); + expect(mail.text).toContain('https://outpost.test/reset-password#token=abc'); + }); +}); diff --git a/packages/outpost/shared/src/email/__tests__/transport-dependencies.test.ts b/packages/outpost/shared/src/email/__tests__/transport-dependencies.test.ts new file mode 100644 index 0000000..bbf325d --- /dev/null +++ b/packages/outpost/shared/src/email/__tests__/transport-dependencies.test.ts @@ -0,0 +1,26 @@ +/** + * The SMTP transport imports nodemailer at runtime, so it must be a runtime dependency + * of @copilotkit/outpost. If it moves to devDependencies or is dropped, the standalone + * web image ships without it and every SMTP send fails to load (outpost#206). + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const pkgPath = fileURLToPath(new URL('../../../../package.json', import.meta.url)); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { + name: string; + dependencies?: Record; + devDependencies?: Record; +}; + +describe('email transport dependencies', () => { + it('reads the @copilotkit/outpost manifest', () => { + expect(pkg.name).toBe('@copilotkit/outpost'); + }); + + it('lists nodemailer as a runtime dependency, not a dev dependency', () => { + expect(pkg.dependencies?.nodemailer).toEqual(expect.any(String)); + expect(pkg.devDependencies?.nodemailer).toBeUndefined(); + }); +}); diff --git a/packages/outpost/shared/src/email/sender.ts b/packages/outpost/shared/src/email/sender.ts index 65ecd82..d2f92bb 100644 --- a/packages/outpost/shared/src/email/sender.ts +++ b/packages/outpost/shared/src/email/sender.ts @@ -1,9 +1,10 @@ /** * Email sender. * - * Uses Postmark when POSTMARK_SERVER_TOKEN is set. - * Falls back to SMTP (nodemailer) when SMTP_HOST is set. - * Falls back to console.log in dev when neither is configured. + * Uses Postmark when POSTMARK_SERVER_TOKEN is set and the postmark package loads. + * Otherwise uses SMTP (nodemailer) when SMTP_HOST is set and nodemailer loads. + * Otherwise prints the message with console.log, in any environment, unless the + * caller sets requireTransport. */ import { renderTemplate } from '../templates/renderer.js'; import { interpolate } from '../templates/interpolate.js'; @@ -25,6 +26,16 @@ interface SendEmailOptions { inReplyTo?: string; /** References header for threading */ references?: string; + /** + * Refuse the console fallback. When no real transport (injected, Postmark, or + * SMTP) is available, return a failure and print nothing. The console fallback + * prints the full text body into the logs, so a caller whose body carries a link + * or token that must not reach the logs sets this (outpost#206). It is opt-in: + * without it, an email with no transport is printed. + */ + requireTransport?: boolean; + /** 'None' turns off link tracking, so the link is not rewritten through a third party. */ + trackLinks?: 'None'; } interface EmailResult { @@ -45,6 +56,8 @@ export interface EmailSendOptions { messageId?: string; inReplyTo?: string; references?: string; + /** Postmark maps 'None' to TrackLinks: None; SMTP ignores it. */ + trackLinks?: 'None'; } /** Abstraction over the email transport so we can inject mocks. */ @@ -58,7 +71,7 @@ export async function createPostmarkTransport(): Promise if (!serverToken) return null; try { - const { ServerClient } = await import('postmark'); + const { ServerClient, Models } = await import('postmark'); const client = new ServerClient(serverToken); return { async send(opts: EmailSendOptions) { @@ -84,36 +97,37 @@ export async function createPostmarkTransport(): Promise ReplyTo: opts.replyTo, Headers: headers.length > 0 ? headers : undefined, MessageStream: 'outbound', + TrackLinks: + opts.trackLinks === 'None' ? Models.LinkTrackingOptions.None : undefined, }); return { id: result.MessageID }; }, }; } catch (err) { - console.error('[Email] POSTMARK_SERVER_TOKEN is set but postmark package failed to load:', err); + console.error( + '[Email] POSTMARK_SERVER_TOKEN is set but postmark package failed to load:', + err, + ); return null; } } /** * Create an SMTP transport via nodemailer. Returns null if SMTP_HOST is not set. - * nodemailer is an optional peer dependency for self-hosted deployments. + * nodemailer is a dependency of @copilotkit/outpost, used when SMTP_HOST is set. */ export async function createSmtpTransport(): Promise { const host = process.env.SMTP_HOST; if (!host) return null; try { - // nodemailer is an optional dependency -- only installed in self-hosted setups. - // We use a dynamic require via createRequire to avoid TS module resolution errors. - const { createRequire } = await import('module'); - const require = createRequire(import.meta.url); - const nodemailer = require('nodemailer') as { - createTransport: (config: Record) => { - sendMail: (opts: Record) => Promise<{ messageId: string }>; - }; - }; - const transporter = nodemailer.createTransport({ + // A static specifier, so webpack bundles nodemailer into the web server and + // Node resolves it for the worker. The old createRequire(import.meta.url) form + // resolved from the build-time source path, which the standalone web image + // does not have (outpost#206 e2e). + const { createTransport } = await import('nodemailer'); + const transporter = createTransport({ host, port: Number(process.env.SMTP_PORT || '587'), secure: process.env.SMTP_SECURE === 'true', @@ -161,6 +175,8 @@ export async function sendEmail(options: SendEmailOptions): Promise messageId, inReplyTo, references, + requireTransport, + trackLinks, } = options; const rendered = await renderTemplate(template, context, dbLookup); @@ -184,6 +200,7 @@ export async function sendEmail(options: SendEmailOptions): Promise messageId, inReplyTo, references, + trackLinks, }; // Try injected transport first @@ -203,6 +220,17 @@ export async function sendEmail(options: SendEmailOptions): Promise return await attemptSend(smtpTransport, sendOpts, 'smtp'); } + if (requireTransport) { + // A transport env var set here means its factory ran but its package failed to + // load (the factory logged why), which is not the same fix as a missing env var. + const configured = ['POSTMARK_SERVER_TOKEN', 'SMTP_HOST'].filter((v) => process.env[v]); + const error = + configured.length > 0 + ? `Email transport configured (${configured.join(', ')}) but its package failed to load` + : 'No email transport configured'; + return { success: false, error, method: 'console' }; + } + // Dev fallback: log to console console.log('=== EMAIL (dev mode) ==='); console.log(`To: ${recipients.join(', ')}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64b58cf..e05e15d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 + nodemailer: + specifier: ^10.2.1 + version: 10.2.1 postmark: specifier: ^4.0.0 version: 4.0.7 @@ -3696,6 +3699,10 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + nodemailer@10.2.1: + resolution: {integrity: sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==} + engines: {node: '>=20.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -8650,6 +8657,8 @@ snapshots: node-releases@2.0.37: {} + nodemailer@10.2.1: {} + normalize-path@3.0.0: {} nypm@0.6.5: From 07a55f40146f724db252be8797f5a3458052b72f Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:27:40 -0700 Subject: [PATCH 03/16] feat(templates): add fixed password reset and password changed emails Two system templates: password-reset carries the one-time reset link, password-changed tells the member their password was changed and their other sessions were signed out. They are fixed system templates, not editable from the templates UI, because their wording is part of the security flow. --- apps/web/src/__tests__/templates-api.test.ts | 32 +++++ .../__tests__/system-templates.test.ts | 118 ++++++++++++++++++ templates/system/password-changed.md | 12 ++ templates/system/password-reset.md | 16 +++ 4 files changed, 178 insertions(+) create mode 100644 packages/outpost/shared/src/templates/__tests__/system-templates.test.ts create mode 100644 templates/system/password-changed.md create mode 100644 templates/system/password-reset.md diff --git a/apps/web/src/__tests__/templates-api.test.ts b/apps/web/src/__tests__/templates-api.test.ts index fb5a23f..49ed0c5 100644 --- a/apps/web/src/__tests__/templates-api.test.ts +++ b/apps/web/src/__tests__/templates-api.test.ts @@ -491,3 +491,35 @@ describe('template persistence and preview (outpost#226)', () => { }); }); }); + +describe('fixed system templates are not editable (outpost#206)', () => { + beforeEach(() => { + mockGetServerSession.mockReset(); + mockGetServerSession.mockResolvedValue(ADMIN_SESSION); + mockOverrideFindMany.mockReset(); + mockOverrideFindMany.mockResolvedValue([]); + mockOverrideUpsert.mockReset(); + }); + + it('does not list them', async () => { + const res = await listTemplates({} as NextRequest); + const body = (await res.json()) as Array<{ slug: string }>; + expect(body.map((t) => t.slug).filter((s) => /password-(reset|changed)/.test(s))).toEqual( + [], + ); + }); + + it.each(['system/password-reset', 'system/password-changed'])( + '404s GET, PUT, DELETE and preview for %s', + async (slug) => { + expect((await getTemplate({} as NextRequest, routeParams(slug))).status).toBe(404); + expect( + (await putTemplate(jsonRequest({ subject: 'x', body: 'y' }), routeParams(slug))) + .status, + ).toBe(404); + expect((await deleteTemplate({} as NextRequest, routeParams(slug))).status).toBe(404); + expect((await previewTemplate(bareRequest(), routeParams(slug))).status).toBe(404); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/outpost/shared/src/templates/__tests__/system-templates.test.ts b/packages/outpost/shared/src/templates/__tests__/system-templates.test.ts new file mode 100644 index 0000000..0364e48 --- /dev/null +++ b/packages/outpost/shared/src/templates/__tests__/system-templates.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { renderTemplate } from '../renderer.js'; +import { loadFromFilesystem, listTemplateSlugs, isKnownTemplateSlug } from '../loader.js'; +import { sendEmail } from '../../email/sender.js'; +import type { EmailSendOptions } from '../../email/sender.js'; +import type { TemplateContext } from '../types.js'; + +const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const url = `https://outpost.example.com/reset-password#token=${token}`; +const context = { + org: { name: 'Acme', email: 'noreply@acme.test' }, + member: { email: 'alex@acme.test' }, + reset: { url, expiresIn: '30 minutes' }, +}; + +describe('system templates', () => { + it('loads both files by their subdirectory slug', () => { + expect(loadFromFilesystem('system/password-reset')?.meta.subject).toBe( + 'Reset your {{org.name}} Outpost password', + ); + expect(loadFromFilesystem('system/password-changed')?.meta.name).toBe('Password Changed'); + }); + + it('keeps the full reset URL, fragment included, in html and text', async () => { + const rendered = await renderTemplate('system/password-reset', context); + expect(rendered).not.toBeNull(); + expect(rendered!.html).toContain(`href="${url}"`); + expect(rendered!.text).toContain(url); + expect(rendered!.subject).toBe('Reset your Acme Outpost password'); + expect(rendered!.text).toContain('30 minutes'); + }); + + it('is not listed or known as an editable template', () => { + // Matches any listed slug that looks like a reset template, so a copy placed at + // the top level of templates/ (which WOULD be listed and overridable) fails here. + expect(listTemplateSlugs().filter((s) => /password-(reset|changed)/.test(s))).toEqual([]); + expect(isKnownTemplateSlug('system/password-reset')).toBe(false); + expect(isKnownTemplateSlug('system/password-changed')).toBe(false); + }); +}); + +/** + * Sends each system template through sendEmail with a capturing transport, using the + * context shape that apps/web/src/lib/password-reset.ts builds: orgContext() gives + * { name, email }, member is { email } only (never name), and the reset mail adds + * { url, expiresIn: RESET_TTL_TEXT }. + */ +describe('system templates as sent', () => { + const org = { name: 'Acme', email: 'noreply@acme.test' }; + const member = { email: 'alex@acme.test' }; + + async function send(template: string, ctx: TemplateContext): Promise { + const sent: EmailSendOptions[] = []; + const result = await sendEmail({ + to: member.email, + template, + context: ctx, + transport: { + send: async (opts) => { + sent.push(opts); + return { id: 'captured' }; + }, + }, + }); + expect(result.success).toBe(true); + expect(sent).toHaveLength(1); + return sent[0]; + } + + it('password-reset: From, Subject, and the body that names the account and link', async () => { + const mail = await send('system/password-reset', { + org, + member, + reset: { url, expiresIn: '30 minutes' }, + }); + + expect(mail.from).toBe('Acme '); + expect(mail.to).toEqual(['alex@acme.test']); + expect(mail.subject).toBe('Reset your Acme Outpost password'); + for (const body of [mail.text, mail.html]) { + expect(body).toContain('reset the password for your'); + expect(body).toContain('(alex@acme.test)'); + expect(body).toContain('expires in 30 minutes and can be used once'); + } + expect(mail.html).toContain(`href="${url}"`); + expect(mail.text).toContain(url); + }); + + it('password-changed: From, Subject, and the body that names the account', async () => { + const mail = await send('system/password-changed', { org, member }); + + expect(mail.from).toBe('Acme '); + expect(mail.to).toEqual(['alex@acme.test']); + expect(mail.subject).toBe('Your Acme Outpost password was changed'); + for (const body of [mail.text, mail.html]) { + expect(body).toContain('(alex@acme.test) was just changed'); + expect(body).toContain('All other sessions have been signed out.'); + expect(body).toContain('contact your Outpost admin immediately'); + } + }); + + it.each(['system/password-reset', 'system/password-changed'])( + '%s never interpolates member.name', + async (template) => { + // The producers never pass a name, so a template that used one would render + // it empty and pass the tests above. Pass a canary name to catch that. + const canary = 'CANARYname'; + const mail = await send(template, { + org, + member: { ...member, name: canary }, + reset: { url, expiresIn: '30 minutes' }, + }); + for (const field of [mail.from, mail.subject, mail.text, mail.html]) { + expect(field).not.toContain('CANARY'); + } + }, + ); +}); diff --git a/templates/system/password-changed.md b/templates/system/password-changed.md new file mode 100644 index 0000000..4ca6108 --- /dev/null +++ b/templates/system/password-changed.md @@ -0,0 +1,12 @@ +--- +name: Password Changed +subject: "Your {{org.name}} Outpost password was changed" +from: "{{org.name}} <{{org.email}}>" +note: Fixed text by design (outpost#206). Not listed on Settings > Templates and not overridable. Never interpolate member.name. +--- + +Hi, + +The password for your **{{org.name}}** Outpost account ({{member.email}}) was just changed. All other sessions have been signed out. + +If this was not you, contact your Outpost admin immediately. diff --git a/templates/system/password-reset.md b/templates/system/password-reset.md new file mode 100644 index 0000000..0be8f0a --- /dev/null +++ b/templates/system/password-reset.md @@ -0,0 +1,16 @@ +--- +name: Password Reset +subject: "Reset your {{org.name}} Outpost password" +from: "{{org.name}} <{{org.email}}>" +note: Fixed text by design (outpost#206). Not listed on Settings > Templates and not overridable. Never interpolate member.name. +--- + +Hi, + +We received a request to reset the password for your **{{org.name}}** Outpost account ({{member.email}}). + +[Reset your password]({{reset.url}}) + +This link expires in {{reset.expiresIn}} and can be used once. + +If you did not request this, you can ignore this email. Your password will not change. From d3e6bfe803596869e7473d2bf4e9d668d1138406 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:27:40 -0700 Subject: [PATCH 04/16] chore(dev): add a Mailpit SMTP sink to the dev compose stack The web service sends through Mailpit (SMTP on 1025, UI and API on 8025), so local email, including the password reset e2e suite, takes the real sendEmail-to-SMTP path instead of the console fallback. --- docker-compose.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 13c926d..9c5580b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,16 @@ services: timeout: 5s retries: 5 + # Local SMTP sink: SMTP on 1025, web UI and API on 8025. The web service sends + # through it (SMTP_HOST below), so local email, including apps/web/e2e, takes the + # real sendEmail -> SMTP path instead of the console fallback. + mailpit: + image: axllent/mailpit:latest + container_name: outpost-mailpit + ports: + - '1025:1025' + - '8025:8025' + pathfinder: image: ghcr.io/copilotkit/pathfinder:latest container_name: outpost-pathfinder @@ -57,9 +67,13 @@ services: NEXT_PUBLIC_AUTH_PROVIDER: credentials NODE_ENV: production PORT: 3000 + SMTP_HOST: mailpit + SMTP_PORT: 1025 depends_on: postgres: condition: service_healthy + mailpit: + condition: service_started restart: unless-stopped volumes: From 13a6c60d15b89e772429cb2a3d81aa596205c18a Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:27:47 -0700 Subject: [PATCH 05/16] feat(web): add a shared new-password policy and tighten invite accept validateNewPassword enforces the minimum length and rejects passwords longer than 72 UTF-8 bytes, which bcrypt would silently truncate. The invite-accept route uses it, and now refuses unless the member is still INVITED, so an accept link cannot overwrite the password of an ACTIVE or DISABLED member. --- .../invite-accept-password-db.test.ts | 136 ++++++++++++++++++ .../__tests__/invite-accept-password.test.ts | 41 ++++++ .../web/src/__tests__/password-policy.test.ts | 37 +++++ .../src/app/api/team/invite/accept/route.ts | 46 +++--- apps/web/src/lib/password-policy.ts | 33 +++++ 5 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/__tests__/invite-accept-password-db.test.ts create mode 100644 apps/web/src/__tests__/invite-accept-password.test.ts create mode 100644 apps/web/src/__tests__/password-policy.test.ts create mode 100644 apps/web/src/lib/password-policy.ts diff --git a/apps/web/src/__tests__/invite-accept-password-db.test.ts b/apps/web/src/__tests__/invite-accept-password-db.test.ts new file mode 100644 index 0000000..83ba33e --- /dev/null +++ b/apps/web/src/__tests__/invite-accept-password-db.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'crypto'; +import type * as AcceptRoute from '@/app/api/team/invite/accept/route'; +import type { prisma as PrismaInstance } from '@copilotkit/outpost/db'; + +const url = process.env.OUTPOST_TEST_DATABASE_URL; +const requireDb = process.env.OUTPOST_REQUIRE_DB_TESTS === '1'; + +// Real route, real Postgres, real bcrypt; nothing is mocked. +describe.skipIf(!url && !requireDb)('POST /api/team/invite/accept against real Postgres', () => { + let realPOST: typeof AcceptRoute.POST; + let realGET: typeof AcceptRoute.GET; + let prisma: typeof PrismaInstance; + const memberIds: string[] = []; + + beforeAll(async () => { + if (!url) { + throw new Error('OUTPOST_REQUIRE_DB_TESTS=1 but OUTPOST_TEST_DATABASE_URL is not set'); + } + process.env.DATABASE_URL = url; + vi.resetModules(); + ({ POST: realPOST, GET: realGET } = await import('@/app/api/team/invite/accept/route')); + ({ prisma } = await import('@copilotkit/outpost/db')); + }); + + afterAll(async () => { + if (!prisma) return; + if (memberIds.length) { + await prisma.teamMember.deleteMany({ where: { id: { in: memberIds } } }); + } + await prisma.$disconnect(); + }); + + async function seed(status: 'INVITED' | 'DISABLED') { + const member = await prisma.teamMember.create({ + data: { + name: 'Pending', + email: `invitee-${randomUUID()}@example.test`, + status, + invitedAt: new Date(), + }, + }); + memberIds.push(member.id); + const token = `invite-${randomUUID()}`; + await prisma.inviteToken.create({ + data: { memberId: member.id, token, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + return { member, token }; + } + + function accept(token: string) { + return realPOST( + new Request('http://localhost:3000/api/team/invite/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token, + name: 'Accepted', + password: 'correct-horse-battery', + confirmPassword: 'correct-horse-battery', + }), + }), + ); + } + + it('refuses a DISABLED invitee and writes no password, status, or token use', async () => { + const { member, token } = await seed('DISABLED'); + + const res = await accept(token); + + expect(res.status).toBe(410); + expect((await res.json()).error).toBe('This invite is no longer valid'); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(after.status).toBe('DISABLED'); + expect(after.passwordHash).toBeNull(); + expect(after.joinedAt).toBeNull(); + expect(after.name).toBe('Pending'); + const invite = await prisma.inviteToken.findUniqueOrThrow({ where: { token } }); + expect(invite.usedAt).toBeNull(); + }); + + it('refuses when the member is disabled after the invite is read (conditional update)', async () => { + const { member, token } = await seed('INVITED'); + const realFind = prisma.inviteToken.findUnique.bind(prisma.inviteToken); + // Return the INVITED snapshot, then disable the member before the route writes. + const spy = vi.spyOn(prisma.inviteToken, 'findUnique').mockImplementationOnce((async ( + args: Parameters[0], + ) => { + const snapshot = await realFind(args); + await prisma.teamMember.update({ + where: { id: member.id }, + data: { status: 'DISABLED' }, + }); + return snapshot; + }) as unknown as typeof prisma.inviteToken.findUnique); + + let res: Response; + try { + res = await accept(token); + } finally { + spy.mockRestore(); + } + + expect(res.status).toBe(410); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(after.status).toBe('DISABLED'); + expect(after.passwordHash).toBeNull(); + const invite = await prisma.inviteToken.findUniqueOrThrow({ where: { token } }); + expect(invite.usedAt).toBeNull(); + }); + + it('GET refuses to show the invite form to a DISABLED invitee', async () => { + const { token } = await seed('DISABLED'); + + const res = await realGET( + new Request(`http://localhost:3000/api/team/invite/accept?token=${token}`), + ); + + expect(res.status).toBe(410); + expect((await res.json()).error).toBe('This invite is no longer valid'); + }); + + it('still activates an INVITED invitee', async () => { + const { member, token } = await seed('INVITED'); + + const res = await accept(token); + + expect(res.status).toBe(200); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(after.status).toBe('ACTIVE'); + expect(after.passwordHash).not.toBeNull(); + const invite = await prisma.inviteToken.findUniqueOrThrow({ where: { token } }); + expect(invite.usedAt).not.toBeNull(); + }); +}); diff --git a/apps/web/src/__tests__/invite-accept-password.test.ts b/apps/web/src/__tests__/invite-accept-password.test.ts new file mode 100644 index 0000000..5ca52db --- /dev/null +++ b/apps/web/src/__tests__/invite-accept-password.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; + +const mockInviteFindUnique = vi.fn(); +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + inviteToken: { findUnique: (...a: unknown[]) => mockInviteFindUnique(...a) }, + organization: { findFirst: vi.fn() }, + teamMember: { update: vi.fn() }, + $transaction: vi.fn(), + }, +})); +const mockHashPassword = vi.fn(); +vi.mock('@copilotkit/outpost/shared', () => ({ + hashPassword: (...a: unknown[]) => mockHashPassword(...a), +})); + +import { POST } from '@/app/api/team/invite/accept/route'; + +describe('POST /api/team/invite/accept password policy', () => { + it('rejects a password over 72 bytes before touching the token or hashing', async () => { + const password = 'é'.repeat(37); + const res = await POST( + new Request('http://localhost:3000/api/team/invite/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: 't', + name: 'New', + password, + confirmPassword: password, + }), + }), + ); + + expect(res.status).toBe(400); + expect((await res.json()).errors).toContain('Password must be 72 bytes or fewer.'); + expect(mockInviteFindUnique).not.toHaveBeenCalled(); + expect(mockHashPassword).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/__tests__/password-policy.test.ts b/apps/web/src/__tests__/password-policy.test.ts new file mode 100644 index 0000000..82eb8de --- /dev/null +++ b/apps/web/src/__tests__/password-policy.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { validateNewPassword } from '@/lib/password-policy'; + +describe('validateNewPassword', () => { + it('accepts 8 to 72 bytes when both fields match', () => { + expect(validateNewPassword('abcdefgh', 'abcdefgh')).toEqual([]); + expect(validateNewPassword('a'.repeat(72), 'a'.repeat(72))).toEqual([]); + }); + + it('rejects missing, short, and over-72-byte passwords', () => { + expect(validateNewPassword(undefined, undefined)).toEqual(['Password is required.']); + expect(validateNewPassword('short', 'short')).toEqual([ + 'Password must be at least 8 characters.', + ]); + expect(validateNewPassword('a'.repeat(73), 'a'.repeat(73))).toEqual([ + 'Password must be 72 bytes or fewer.', + ]); + }); + + it('counts UTF-8 bytes, not characters', () => { + const p = 'é'.repeat(37); // 37 characters, 74 bytes + expect(validateNewPassword(p, p)).toEqual(['Password must be 72 bytes or fewer.']); + }); + + it('reports a mismatch', () => { + expect(validateNewPassword('abcdefgh', 'abcdefgX')).toEqual(['Passwords do not match.']); + }); + + it('uses caller labels', () => { + expect( + validateNewPassword('short', 'other', { + noun: 'New password', + mismatch: 'New passwords do not match.', + }), + ).toEqual(['New password must be at least 8 characters.', 'New passwords do not match.']); + }); +}); diff --git a/apps/web/src/app/api/team/invite/accept/route.ts b/apps/web/src/app/api/team/invite/accept/route.ts index 7b2698c..7a65db3 100644 --- a/apps/web/src/app/api/team/invite/accept/route.ts +++ b/apps/web/src/app/api/team/invite/accept/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from 'next/server'; import { prisma } from '@copilotkit/outpost/db'; import { hashPassword } from '@copilotkit/outpost/shared'; +import { validateNewPassword } from '@/lib/password-policy'; + +// The member was disabled (or otherwise left INVITED) after the invite was sent. +const NO_LONGER_VALID = () => + NextResponse.json({ error: 'This invite is no longer valid' }, { status: 410 }); export async function GET(request: Request) { const url = new URL(request.url); @@ -27,6 +32,10 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'This invite has expired' }, { status: 410 }); } + if (inviteToken.member.status !== 'INVITED') { + return NO_LONGER_VALID(); + } + // Fetch the org name for display const org = await prisma.organization.findFirst(); @@ -51,15 +60,7 @@ export async function POST(request: Request) { errors.push('Name is required.'); } - if (!password || typeof password !== 'string') { - errors.push('Password is required.'); - } else if (password.length < 8) { - errors.push('Password must be at least 8 characters.'); - } - - if (password !== confirmPassword) { - errors.push('Passwords do not match.'); - } + errors.push(...validateNewPassword(password, confirmPassword)); if (errors.length > 0) { return NextResponse.json({ errors }, { status: 400 }); @@ -82,24 +83,35 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'This invite has expired' }, { status: 410 }); } + if (inviteToken.member.status !== 'INVITED') { + return NO_LONGER_VALID(); + } + const passwordHash = await hashPassword(password); - // Update member and mark token used in a transaction - const [member] = await prisma.$transaction([ - prisma.teamMember.update({ - where: { id: inviteToken.memberId }, + // Activate only a member that is still INVITED. The status condition is part of + // the UPDATE, so a disable that lands after the check above still wins. + const member = await prisma.$transaction(async (tx) => { + const { count } = await tx.teamMember.updateMany({ + where: { id: inviteToken.memberId, status: 'INVITED' }, data: { name: name.trim(), passwordHash, status: 'ACTIVE', joinedAt: new Date(), }, - }), - prisma.inviteToken.update({ + }); + if (count === 0) return null; + await tx.inviteToken.update({ where: { id: inviteToken.id }, data: { usedAt: new Date() }, - }), - ]); + }); + return tx.teamMember.findUniqueOrThrow({ where: { id: inviteToken.memberId } }); + }); + + if (!member) { + return NO_LONGER_VALID(); + } return NextResponse.json({ id: member.id, diff --git a/apps/web/src/lib/password-policy.ts b/apps/web/src/lib/password-policy.ts new file mode 100644 index 0000000..a6585b5 --- /dev/null +++ b/apps/web/src/lib/password-policy.ts @@ -0,0 +1,33 @@ +export const MIN_PASSWORD_LENGTH = 8; +/** bcrypt silently ignores everything after 72 bytes, so longer input is rejected. */ +export const MAX_PASSWORD_BYTES = 72; + +export interface PasswordLabels { + noun: string; + mismatch: string; +} + +const DEFAULT_LABELS: PasswordLabels = { noun: 'Password', mismatch: 'Passwords do not match.' }; + +/** + * Validate a new password and its confirmation. Returns user-facing messages, in the + * `{ errors: string[] }` shape the invite-accept, profile, and reset routes return. + */ +export function validateNewPassword( + password: unknown, + confirm: unknown, + labels: PasswordLabels = DEFAULT_LABELS, +): string[] { + const errors: string[] = []; + if (!password || typeof password !== 'string') { + errors.push(`${labels.noun} is required.`); + } else if (password.length < MIN_PASSWORD_LENGTH) { + errors.push(`${labels.noun} must be at least ${MIN_PASSWORD_LENGTH} characters.`); + } else if (new TextEncoder().encode(password).length > MAX_PASSWORD_BYTES) { + errors.push(`${labels.noun} must be ${MAX_PASSWORD_BYTES} bytes or fewer.`); + } + if (password !== confirm) { + errors.push(labels.mismatch); + } + return errors; +} From f3efcd44d78e9283f3757adb6d6912ad773aa3b3 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 29 Oct 2026 08:30:22 -0700 Subject: [PATCH 06/16] feat(web): revoke JWT sessions through TeamMember.sessionVersion Sign-in stamps the member's sessionVersion into the JWT, and every later request re-reads the member: a missing member, a status other than ACTIVE, or a different sessionVersion revokes the session. The same check refreshes the role from the database, so a role change takes effect on the next request without signing the member out. A database error during the check revokes too (fail closed). The check applies to credentials and OIDC sessions; GitHub mode stores the GitHub account id as memberId, so it has no TeamMember row to check. Credentials sign-in refuses non-ACTIVE members. OIDC sign-in leaves an ACTIVE member behind (creating one, or activating an INVITED one) or refuses; it no longer falls back to the provider subject as memberId. What bumps sessionVersion: - Disabling a member signs out all of their sessions. Re-enabling restores ACTIVE when the member has joined or has a password, and INVITED otherwise. Admins cannot disable themselves. - A profile password change signs out the member's other sessions and re-issues the current session cookie (chunked like NextAuth's) with the new version, so the browser that made the change stays signed in. Settings > Profile says so. - The admin data reset carries the admin's sessionVersion over, so the admin is not signed out by their own reset. --- .../web/src/__tests__/admin-reset-api.test.ts | 72 +++ apps/web/src/__tests__/auth-oidc.test.ts | 230 ++++++---- .../__tests__/auth-session-version.test.ts | 420 ++++++++++++++++++ apps/web/src/__tests__/profile-api.test.ts | 400 +++++++++++++++-- apps/web/src/__tests__/profile-page.test.tsx | 77 ++++ apps/web/src/__tests__/session-cookie.test.ts | 86 ++++ apps/web/src/__tests__/team-api.test.ts | 180 +++++++- apps/web/src/__tests__/team-status-db.test.ts | 233 ++++++++++ apps/web/src/app/api/admin/reset/route.ts | 7 +- apps/web/src/app/api/profile/route.ts | 103 ++++- apps/web/src/app/api/team/[id]/route.ts | 69 +-- apps/web/src/app/settings/profile/page.tsx | 61 ++- apps/web/src/lib/auth-provider.ts | 17 + apps/web/src/lib/auth.ts | 142 +++++- apps/web/src/lib/session-cookie.ts | 72 +++ 15 files changed, 1933 insertions(+), 236 deletions(-) create mode 100644 apps/web/src/__tests__/admin-reset-api.test.ts create mode 100644 apps/web/src/__tests__/auth-session-version.test.ts create mode 100644 apps/web/src/__tests__/profile-page.test.tsx create mode 100644 apps/web/src/__tests__/session-cookie.test.ts create mode 100644 apps/web/src/__tests__/team-status-db.test.ts create mode 100644 apps/web/src/lib/auth-provider.ts create mode 100644 apps/web/src/lib/session-cookie.ts diff --git a/apps/web/src/__tests__/admin-reset-api.test.ts b/apps/web/src/__tests__/admin-reset-api.test.ts new file mode 100644 index 0000000..6e065ab --- /dev/null +++ b/apps/web/src/__tests__/admin-reset-api.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGetServerSession = vi.fn(); +vi.mock('next-auth', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +const mockOrgFindFirst = vi.fn(); +const mockMemberFindUnique = vi.fn(); +const mockTxCreate = vi.fn(); +const mockTxExecute = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + organization: { findFirst: (...a: unknown[]) => mockOrgFindFirst(...a) }, + teamMember: { findUnique: (...a: unknown[]) => mockMemberFindUnique(...a) }, + $transaction: async (fn: (tx: unknown) => Promise) => + fn({ + $executeRawUnsafe: (...a: unknown[]) => mockTxExecute(...a), + teamMember: { create: (...a: unknown[]) => mockTxCreate(...a) }, + }), + }, +})); + +vi.mock('@copilotkit/outpost/shared', () => ({ verifyPassword: vi.fn() })); + +import { POST } from '@/app/api/admin/reset/route'; +import { authOptions } from '@/lib/auth'; + +const adminRow = { + id: 'admin-1', + name: 'Admin', + email: 'admin@x.io', + role: 'ADMIN', + status: 'ACTIVE', + avatarUrl: null, + passwordHash: 'h', + joinedAt: new Date('2026-01-01T00:00:00Z'), + sessionVersion: 3, +}; + +describe('POST /api/admin/reset', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue({ user: { role: 'ADMIN', memberId: 'admin-1' } }); + mockOrgFindFirst.mockResolvedValue(null); + mockMemberFindUnique.mockResolvedValue(adminRow); + }); + + it("recreates the admin with the saved sessionVersion, so the admin's own token still passes", async () => { + const res = await POST( + new Request('http://localhost:3000/api/admin/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirmation: 'RESET' }), + }), + ); + expect(res.status).toBe(200); + + const created = mockTxCreate.mock.calls[0][0].data as Record; + expect(created.sessionVersion).toBe(3); + + // Replay the row as the database now holds it (Prisma default 0 if omitted). + mockMemberFindUnique.mockResolvedValue({ + sessionVersion: (created.sessionVersion as number | undefined) ?? 0, + status: created.status, + }); + await expect( + authOptions.callbacks!.jwt!({ token: { memberId: 'admin-1', sv: 3 } } as never), + ).resolves.toMatchObject({ memberId: 'admin-1' }); + }); +}); diff --git a/apps/web/src/__tests__/auth-oidc.test.ts b/apps/web/src/__tests__/auth-oidc.test.ts index 9ae0bee..d0af25d 100644 --- a/apps/web/src/__tests__/auth-oidc.test.ts +++ b/apps/web/src/__tests__/auth-oidc.test.ts @@ -4,12 +4,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mockTeamMemberFindUnique = vi.fn(); const mockTeamMemberUpsert = vi.fn(); +const mockTeamMemberCreate = vi.fn(); +const mockTeamMemberUpdate = vi.fn(); vi.mock('@copilotkit/outpost/db', () => ({ prisma: { teamMember: { findUnique: mockTeamMemberFindUnique, upsert: mockTeamMemberUpsert, + create: mockTeamMemberCreate, + update: mockTeamMemberUpdate, }, }, })); @@ -18,6 +22,15 @@ vi.mock('@copilotkit/outpost/shared', () => ({ verifyPassword: vi.fn(), })); +function oidcSignInArgs(email: string, name: string | undefined = 'Alice') { + return { + user: { id: 'oidc-sub-1', name, email }, + account: { provider: 'oidc', type: 'oauth', providerAccountId: 'oidc-sub-1' } as never, + profile: { sub: 'oidc-sub-1', email, name } as never, + credentials: undefined, + }; +} + // ─── Tests ────────────────────────────────────────────────────────────────── describe('OIDC auth', () => { @@ -165,71 +178,98 @@ describe('OIDC auth', () => { }); }); - describe('signIn callback — auto-provisioning', () => { - it('creates TeamMember on first OIDC login', async () => { + describe('signIn callback — provisioning and status', () => { + it('creates an ACTIVE TeamMember on first OIDC login', async () => { mockTeamMemberFindUnique.mockResolvedValue(null); - mockTeamMemberUpsert.mockResolvedValue({ - id: 'member-new', - name: 'Alice', - email: 'alice@copilotkit.ai', - role: 'MEMBER', - }); + mockTeamMemberCreate.mockResolvedValue({ id: 'member-new' }); const { authOptions } = await import('../lib/auth.js'); - await authOptions.callbacks!.signIn!({ - user: { id: 'oidc-sub-1', name: 'Alice', email: 'alice@copilotkit.ai' }, - account: { - provider: 'oidc', - type: 'oauth', - providerAccountId: 'oidc-sub-1', - } as never, - profile: { - sub: 'oidc-sub-1', - email: 'alice@copilotkit.ai', - name: 'Alice', - } as never, - credentials: undefined, - }); + const result = await authOptions.callbacks!.signIn!( + oidcSignInArgs('alice@copilotkit.ai'), + ); - expect(mockTeamMemberUpsert).toHaveBeenCalledWith({ - where: { email: 'alice@copilotkit.ai' }, - create: expect.objectContaining({ + expect(result).toBe(true); + expect(mockTeamMemberCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'Alice', email: 'alice@copilotkit.ai', role: 'MEMBER', status: 'ACTIVE', }), - update: expect.objectContaining({ - name: 'Alice', - }), }); + expect(mockTeamMemberUpsert).not.toHaveBeenCalled(); }); - it('allows sign-in even when upsert fails', async () => { - mockTeamMemberUpsert.mockRejectedValue(new Error('DB timeout')); + it('promotes an INVITED member to ACTIVE with joinedAt', async () => { + mockTeamMemberFindUnique.mockResolvedValue({ id: 'm-inv', status: 'INVITED' }); + mockTeamMemberUpdate.mockResolvedValue({ id: 'm-inv' }); const { authOptions } = await import('../lib/auth.js'); - const result = await authOptions.callbacks!.signIn!({ - user: { id: 'oidc-sub-1', name: 'Alice', email: 'alice@copilotkit.ai' }, - account: { - provider: 'oidc', - type: 'oauth', - providerAccountId: 'oidc-sub-1', - } as never, - profile: { - sub: 'oidc-sub-1', - email: 'alice@copilotkit.ai', + const result = await authOptions.callbacks!.signIn!( + oidcSignInArgs('alice@copilotkit.ai'), + ); + + expect(result).toBe(true); + expect(mockTeamMemberUpdate).toHaveBeenCalledWith({ + where: { id: 'm-inv' }, + data: expect.objectContaining({ + status: 'ACTIVE', + joinedAt: expect.any(Date), name: 'Alice', - } as never, - credentials: undefined, + }), }); + }); - expect(result).toBe(true); - expect(mockTeamMemberUpsert).toHaveBeenCalled(); + it('updates name and avatar for an ACTIVE member without touching status', async () => { + mockTeamMemberFindUnique.mockResolvedValue({ id: 'm1', status: 'ACTIVE' }); + mockTeamMemberUpdate.mockResolvedValue({ id: 'm1' }); + + const { authOptions } = await import('../lib/auth.js'); + await authOptions.callbacks!.signIn!(oidcSignInArgs('alice@copilotkit.ai')); + + const call = mockTeamMemberUpdate.mock.calls[0][0] as { data: Record }; + expect(call.data).toEqual({ name: 'Alice', avatarUrl: null }); + }); + + it('denies a DISABLED member', async () => { + mockTeamMemberFindUnique.mockResolvedValue({ id: 'm-off', status: 'DISABLED' }); + + const { authOptions } = await import('../lib/auth.js'); + const result = await authOptions.callbacks!.signIn!( + oidcSignInArgs('alice@copilotkit.ai'), + ); + + expect(result).toBe('/login?error=AccessDenied'); + expect(mockTeamMemberUpdate).not.toHaveBeenCalled(); + expect(mockTeamMemberCreate).not.toHaveBeenCalled(); + }); + + it('denies sign-in when the database work throws', async () => { + mockTeamMemberFindUnique.mockRejectedValue(new Error('DB timeout')); + + const { authOptions } = await import('../lib/auth.js'); + const result = await authOptions.callbacks!.signIn!( + oidcSignInArgs('alice@copilotkit.ai'), + ); + + expect(result).toBe('/login?error=AccessDenied'); + }); + + it('looks up a mixed-case IdP email as lowercase', async () => { + mockTeamMemberFindUnique.mockResolvedValue({ id: 'm1', status: 'ACTIVE' }); + mockTeamMemberUpdate.mockResolvedValue({ id: 'm1' }); + + const { authOptions } = await import('../lib/auth.js'); + await authOptions.callbacks!.signIn!(oidcSignInArgs('Alex@CopilotKit.ai')); + + expect(mockTeamMemberFindUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { email: 'alex@copilotkit.ai' } }), + ); }); it('uses email local-part as name when user.name and profile.name are missing', async () => { - mockTeamMemberUpsert.mockResolvedValue({ id: 'member-new' }); + mockTeamMemberFindUnique.mockResolvedValue(null); + mockTeamMemberCreate.mockResolvedValue({ id: 'member-new' }); const { authOptions } = await import('../lib/auth.js'); await authOptions.callbacks!.signIn!({ user: { id: 'oidc-sub-7', email: 'bob@copilotkit.ai' }, @@ -242,74 +282,84 @@ describe('OIDC auth', () => { credentials: undefined, }); - expect(mockTeamMemberUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - create: expect.objectContaining({ name: 'bob' }), - }), - ); + expect(mockTeamMemberCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'bob' }), + }); }); }); describe('jwt callback — OIDC identity resolution', () => { - it('resolves TeamMember ID and role for OIDC users', async () => { + const signInJwt = (email?: string) => ({ + token: email ? { sub: 'oidc-sub-1', email } : { sub: 'oidc-sub-1' }, + user: { id: 'oidc-sub-1', name: 'Alice', ...(email ? { email } : {}) }, + account: { provider: 'oidc', type: 'oauth' } as never, + trigger: 'signIn', + }); + + it('resolves memberId, role and sv by lowercased email', async () => { mockTeamMemberFindUnique.mockResolvedValue({ id: 'member-1', role: 'ADMIN', + sessionVersion: 3, }); const { authOptions } = await import('../lib/auth.js'); - const token = await authOptions.callbacks!.jwt!({ - token: { sub: 'oidc-sub-1', email: 'alice@copilotkit.ai' }, - user: { id: 'oidc-sub-1', name: 'Alice', email: 'alice@copilotkit.ai' }, - account: { provider: 'oidc', type: 'oauth' } as never, - trigger: 'signIn', - } as never); + const token = await authOptions.callbacks!.jwt!( + signInJwt('Alex@CopilotKit.ai') as never, + ); - expect(token.memberId).toBe('member-1'); - expect(token.role).toBe('ADMIN'); + expect(mockTeamMemberFindUnique).toHaveBeenCalledWith({ + where: { email: 'alex@copilotkit.ai' }, + select: { id: true, role: true, sessionVersion: true }, + }); + expect(token).toMatchObject({ memberId: 'member-1', role: 'ADMIN', sv: 3 }); }); - it('falls back to MEMBER role when TeamMember not found', async () => { + it('throws (no fallback to the OIDC sub) when no TeamMember matches', async () => { mockTeamMemberFindUnique.mockResolvedValue(null); - const { authOptions } = await import('../lib/auth.js'); - const token = await authOptions.callbacks!.jwt!({ - token: { sub: 'oidc-sub-1', email: 'alice@copilotkit.ai' }, - user: { id: 'oidc-sub-1', name: 'Alice', email: 'alice@copilotkit.ai' }, - account: { provider: 'oidc', type: 'oauth' } as never, - trigger: 'signIn', - } as never); - - expect(token.role).toBe('MEMBER'); + await expect( + authOptions.callbacks!.jwt!(signInJwt('alice@copilotkit.ai') as never), + ).rejects.toThrow('OIDC_MEMBER_UNRESOLVED'); }); - it('falls back to user.id and MEMBER when findUnique throws', async () => { + it('throws when the lookup fails', async () => { mockTeamMemberFindUnique.mockRejectedValue(new Error('connection refused')); - const { authOptions } = await import('../lib/auth.js'); - const token = await authOptions.callbacks!.jwt!({ - token: { sub: 'oidc-sub-1', email: 'alice@copilotkit.ai' }, - user: { id: 'oidc-sub-1', name: 'Alice', email: 'alice@copilotkit.ai' }, - account: { provider: 'oidc', type: 'oauth' } as never, - trigger: 'signIn', - } as never); - - expect(token.memberId).toBe('oidc-sub-1'); - expect(token.role).toBe('MEMBER'); + await expect( + authOptions.callbacks!.jwt!(signInJwt('alice@copilotkit.ai') as never), + ).rejects.toThrow('connection refused'); }); - it('falls back to MEMBER for OIDC user when token has no email', async () => { + it('throws when the token has no email', async () => { const { authOptions } = await import('../lib/auth.js'); - const token = await authOptions.callbacks!.jwt!({ - token: { sub: 'oidc-sub-1' }, - user: { id: 'oidc-sub-1', name: 'Alice' }, - account: { provider: 'oidc', type: 'oauth' } as never, - trigger: 'signIn', - } as never); - - expect(token.memberId).toBe('oidc-sub-1'); - expect(token.role).toBe('MEMBER'); + await expect(authOptions.callbacks!.jwt!(signInJwt() as never)).rejects.toThrow( + 'OIDC_MEMBER_UNRESOLVED', + ); expect(mockTeamMemberFindUnique).not.toHaveBeenCalled(); }); + + it('an INVITED member promoted at sign-in passes the next session read', async () => { + const { authOptions } = await import('../lib/auth.js'); + mockTeamMemberFindUnique.mockResolvedValueOnce({ id: 'm-inv', status: 'INVITED' }); + mockTeamMemberUpdate.mockResolvedValue({ id: 'm-inv' }); + expect( + await authOptions.callbacks!.signIn!(oidcSignInArgs('alice@copilotkit.ai')), + ).toBe(true); + + mockTeamMemberFindUnique.mockResolvedValueOnce({ + id: 'm-inv', + role: 'MEMBER', + sessionVersion: 0, + }); + const token = await authOptions.callbacks!.jwt!( + signInJwt('alice@copilotkit.ai') as never, + ); + + mockTeamMemberFindUnique.mockResolvedValueOnce({ sessionVersion: 0, status: 'ACTIVE' }); + await expect(authOptions.callbacks!.jwt!({ token } as never)).resolves.toMatchObject({ + memberId: 'm-inv', + }); + }); }); }); diff --git a/apps/web/src/__tests__/auth-session-version.test.ts b/apps/web/src/__tests__/auth-session-version.test.ts new file mode 100644 index 0000000..ee2b291 --- /dev/null +++ b/apps/web/src/__tests__/auth-session-version.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { NextAuthOptions } from 'next-auth'; + +const mockFindUnique = vi.fn(); +const mockUpdate = vi.fn(); +const mockUpdateMany = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + teamMember: { + findUnique: (...args: unknown[]) => mockFindUnique(...args), + update: (...args: unknown[]) => mockUpdate(...args), + updateMany: (...args: unknown[]) => mockUpdateMany(...args), + }, + }, +})); + +// Only getServerSession is replaced (for requireAdmin in the team route); the +// providers that lib/auth.ts builds come from the real next-auth. +const mockGetServerSession = vi.fn(); +vi.mock('next-auth', async (importOriginal) => ({ + ...(await importOriginal>()), + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +const mockVerifyPassword = vi.fn(); +vi.mock('@copilotkit/outpost/shared', () => ({ + verifyPassword: (...args: unknown[]) => mockVerifyPassword(...args), +})); + +type JwtArgs = Parameters['jwt']>>[0]; + +async function loadAuth(provider: string): Promise { + process.env.AUTH_PROVIDER = provider; + vi.resetModules(); + return (await import('../lib/auth.js')).authOptions; +} + +/** A session read for an existing cookie: no `user`, no `account`. */ +function readSession( + auth: NextAuthOptions, + token: Record, + extra: Partial = {}, +) { + return auth.callbacks!.jwt!({ token, ...extra } as JwtArgs); +} + +type AuthorizeFn = (credentials: Record, req: unknown) => Promise; + +function credentialsAuthorize(auth: NextAuthOptions): AuthorizeFn { + // next-auth v4 keeps the user-supplied authorize under `options`. + return (auth.providers[0] as unknown as { options: { authorize: AuthorizeFn } }).options + .authorize; +} + +describe('jwt callback: sessionVersion check', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + mockFindUnique.mockReset(); + mockVerifyPassword.mockReset(); + process.env.NEXTAUTH_SECRET = 'test-secret'; + process.env.ALLOWED_EMAIL_DOMAIN = 'copilotkit.ai'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('passes a credentials token whose sv matches', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ sessionVersion: 2, status: 'ACTIVE', role: 'MEMBER' }); + + const token = await readSession(auth, { memberId: 'm1', sv: 2, role: 'MEMBER' }); + + expect(token).toMatchObject({ memberId: 'm1', sv: 2, role: 'MEMBER' }); + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { id: 'm1' }, + select: { sessionVersion: true, status: true, role: true }, + }); + }); + + it('throws SESSION_REVOKED for a stale sv', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ sessionVersion: 3, status: 'ACTIVE' }); + + await expect(readSession(auth, { memberId: 'm1', sv: 2 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + }); + + it('treats a missing sv as 0', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ sessionVersion: 0, status: 'ACTIVE' }); + await expect(readSession(auth, { memberId: 'm1' })).resolves.toMatchObject({ + memberId: 'm1', + }); + + mockFindUnique.mockResolvedValue({ sessionVersion: 1, status: 'ACTIVE' }); + await expect(readSession(auth, { memberId: 'm1' })).rejects.toThrow('SESSION_REVOKED'); + }); + + it('throws for a DISABLED member and for a missing member', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ sessionVersion: 0, status: 'DISABLED' }); + await expect(readSession(auth, { memberId: 'm1', sv: 0 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + + mockFindUnique.mockResolvedValue(null); + await expect(readSession(auth, { memberId: 'gone', sv: 0 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + }); + + it('fails closed when the database lookup throws', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockRejectedValue(new Error('connection refused')); + await expect(readSession(auth, { memberId: 'm1', sv: 0 })).rejects.toThrow( + 'connection refused', + ); + }); + + it('runs the check in OIDC mode too', async () => { + const auth = await loadAuth('oidc'); + mockFindUnique.mockResolvedValue({ sessionVersion: 1, status: 'ACTIVE' }); + await expect(readSession(auth, { memberId: 'm1', sv: 0 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + }); + + it('stores sv from the credentials user at sign-in without a lookup', async () => { + const auth = await loadAuth('credentials'); + const token = await auth.callbacks!.jwt!({ + token: {}, + user: { id: 'm1', role: 'MEMBER', sessionVersion: 4 }, + account: { provider: 'credentials', type: 'credentials' }, + trigger: 'signIn', + } as unknown as JwtArgs); + + expect(token).toMatchObject({ memberId: 'm1', sv: 4, role: 'MEMBER' }); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); + + it('ignores the client update() payload and still revokes', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ sessionVersion: 2, status: 'ACTIVE' }); + + await expect( + readSession( + auth, + { memberId: 'm1', sv: 1 }, + { trigger: 'update', session: { sv: 2, memberId: 'x', role: 'ADMIN' } }, + ), + ).rejects.toThrow('SESSION_REVOKED'); + + mockFindUnique.mockResolvedValue({ sessionVersion: 1, status: 'ACTIVE', role: 'MEMBER' }); + const token = await readSession( + auth, + { memberId: 'm1', sv: 1, role: 'MEMBER' }, + { trigger: 'update', session: { sv: 99, role: 'ADMIN' } }, + ); + expect(token).toMatchObject({ memberId: 'm1', sv: 1, role: 'MEMBER' }); + }); + + it('GitHub mode: passes a non-TeamMember memberId with no lookup', async () => { + const auth = await loadAuth('github'); + const token = await readSession(auth, { memberId: 'gh-12345' }); + expect(token).toMatchObject({ memberId: 'gh-12345' }); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); + + it('GitHub mode: signIn makes no TeamMember query', async () => { + const auth = await loadAuth('github'); + const result = await auth.callbacks!.signIn!({ + user: { id: 'gh-1', email: 'dev@copilotkit.ai' }, + account: { provider: 'github', type: 'oauth', providerAccountId: 'gh-1' }, + profile: { email: 'dev@copilotkit.ai', login: 'dev' }, + } as never); + expect(result).toBe(true); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); +}); + +describe('credentials authorize()', () => { + beforeEach(() => { + mockFindUnique.mockReset(); + mockVerifyPassword.mockReset(); + }); + + it('rejects a DISABLED member even with the right password', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ + id: 'm1', + name: 'A', + email: 'a@x.io', + avatarUrl: null, + role: 'MEMBER', + status: 'DISABLED', + passwordHash: 'h', + sessionVersion: 0, + }); + mockVerifyPassword.mockResolvedValue(true); + + await expect( + credentialsAuthorize(auth)({ email: 'a@x.io', password: 'pw' }, {}), + ).resolves.toBeNull(); + }); + + it('returns sessionVersion for an ACTIVE member', async () => { + const auth = await loadAuth('credentials'); + mockFindUnique.mockResolvedValue({ + id: 'm1', + name: 'A', + email: 'a@x.io', + avatarUrl: null, + role: 'MEMBER', + status: 'ACTIVE', + passwordHash: 'h', + sessionVersion: 7, + }); + mockVerifyPassword.mockResolvedValue(true); + + await expect( + credentialsAuthorize(auth)({ email: 'A@x.io', password: 'pw' }, {}), + ).resolves.toMatchObject({ + id: 'm1', + sessionVersion: 7, + }); + }); +}); + +describe('disable then re-enable', () => { + beforeEach(() => mockFindUnique.mockReset()); + + it('revokes a disabled member and does not revive the old token on re-enable', async () => { + const auth = await loadAuth('credentials'); + // After PATCH DISABLED: status DISABLED, version bumped 0 -> 1. + mockFindUnique.mockResolvedValue({ sessionVersion: 1, status: 'DISABLED' }); + await expect(readSession(auth, { memberId: 'm2', sv: 0 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + + // After PATCH ACTIVE: status ACTIVE, version still 1. + mockFindUnique.mockResolvedValue({ sessionVersion: 1, status: 'ACTIVE' }); + await expect(readSession(auth, { memberId: 'm2', sv: 0 })).rejects.toThrow( + 'SESSION_REVOKED', + ); + }); +}); + +describe('team PATCH -> jwt callback, end to end', () => { + type Row = { + id: string; + status: string; + role: string; + sessionVersion: number; + joinedAt: Date | null; + }; + type UpdateArgs = { where: { id: string }; data: Record }; + type UpdateManyArgs = { + where: { id: string; joinedAt?: null | { not: null } }; + data: Record; + }; + + let row: Row; + + beforeEach(() => { + mockFindUnique.mockReset(); + mockUpdate.mockReset(); + mockUpdateMany.mockReset(); + mockGetServerSession.mockReset(); + row = { + id: 'm2', + status: 'ACTIVE', + role: 'MEMBER', + sessionVersion: 0, + joinedAt: new Date(0), + }; + + // One in-memory TeamMember row shared by the route's update() and the + // jwt callback's findUnique(), so a write by PATCH is what the callback reads. + // Honors `select`, so the callback sees only the columns it actually asks for. + mockFindUnique.mockImplementation( + async ({ + where, + select, + }: { + where: { id: string }; + select: Record; + }) => + where.id === row.id + ? Object.fromEntries( + Object.keys(select) + .filter((key) => select[key]) + .map((key) => [key, (row as Record)[key]]), + ) + : null, + ); + const apply = (data: Record) => { + for (const [key, value] of Object.entries(data)) { + if (key === 'sessionVersion' && typeof value === 'object' && value !== null) { + row.sessionVersion += (value as { increment: number }).increment; + } else { + (row as Record)[key] = value; + } + } + }; + mockUpdate.mockImplementation(async ({ where, data }: UpdateArgs) => { + expect(where.id).toBe(row.id); + apply(data); + return { ...row }; + }); + // The enable path writes with a joinedAt condition; honor it like Postgres. + mockUpdateMany.mockImplementation(async ({ where, data }: UpdateManyArgs) => { + const joinedMatches = + where.joinedAt === undefined || + (where.joinedAt === null ? row.joinedAt === null : row.joinedAt !== null); + if (where.id !== row.id || !joinedMatches) return { count: 0 }; + apply(data); + return { count: 1 }; + }); + mockGetServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', memberId: 'admin-1' }, + }); + }); + + async function callPatch(id: string, body: Record) { + const { PATCH } = await import('../app/api/team/[id]/route.js'); + return PATCH( + new Request(`http://localhost:3000/api/team/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); + } + + async function patchMember(body: Record) { + const res = await callPatch('m2', body); + expect(res.status).toBe(200); + } + + it('disable bumps the stored version; the old token stays revoked after re-enable', async () => { + const auth = await loadAuth('credentials'); + const oldToken = { memberId: 'm2', sv: 0, role: 'MEMBER' }; + + // The member's existing session is current before the admin acts. + await expect(readSession(auth, { ...oldToken })).resolves.toMatchObject({ sv: 0 }); + + await patchMember({ status: 'DISABLED' }); + expect(row).toMatchObject({ status: 'DISABLED', sessionVersion: 1 }); + await expect(readSession(auth, { ...oldToken })).rejects.toThrow('SESSION_REVOKED'); + + await patchMember({ status: 'ACTIVE' }); + expect(row).toMatchObject({ status: 'ACTIVE', sessionVersion: 1 }); + await expect(readSession(auth, { ...oldToken })).rejects.toThrow('SESSION_REVOKED'); + + // A fresh sign-in after re-enable carries the new version and is accepted. + await expect(readSession(auth, { ...oldToken, sv: 1 })).resolves.toMatchObject({ sv: 1 }); + }); + + it('a role-only change does not revoke the member', async () => { + const auth = await loadAuth('credentials'); + + await patchMember({ role: 'ADMIN' }); + expect(row).toMatchObject({ role: 'ADMIN', sessionVersion: 0 }); + await expect(readSession(auth, { memberId: 'm2', sv: 0 })).resolves.toMatchObject({ + sv: 0, + }); + }); + + it('a demoted admin stays signed in but loses admin rights on the next request', async () => { + const auth = await loadAuth('credentials'); + row.role = 'ADMIN'; + const oldToken = { memberId: 'm2', sv: 0, role: 'ADMIN' }; + + // What getServerSession does with a cookie: the real jwt callback, then the + // real session callback, using the authOptions the caller passes in. + type Callbacks = NonNullable; + type SessionArgs = Parameters>[0]; + async function resolveSession(options: NextAuthOptions, token: Record) { + const callbacks = options.callbacks!; + const next = await callbacks.jwt!({ token: { ...token } } as JwtArgs); + return callbacks.session!({ + session: { user: {}, expires: '' }, + token: next, + } as unknown as SessionArgs); + } + + await expect(resolveSession(auth, oldToken)).resolves.toMatchObject({ + user: { role: 'ADMIN', memberId: 'm2' }, + }); + + await patchMember({ role: 'MEMBER' }); + // Role-only change: no sessionVersion bump, so the member is not signed out. + expect(row).toMatchObject({ role: 'MEMBER', sessionVersion: 0 }); + await expect(readSession(auth, { ...oldToken })).resolves.toMatchObject({ + sv: 0, + role: 'MEMBER', + }); + await expect(resolveSession(auth, oldToken)).resolves.toMatchObject({ + user: { role: 'MEMBER', memberId: 'm2' }, + }); + + // The old cookie no longer passes requireAdmin(), so admin routes refuse it. + mockGetServerSession.mockImplementation((options: NextAuthOptions) => + resolveSession(options, oldToken), + ); + const { requireAdmin } = await import('../lib/require-admin.js'); + const result = await requireAdmin(); + expect(result.session).toBeNull(); + expect(result.error?.status).toBe(403); + + const res = await callPatch('m3', { role: 'ADMIN' }); + expect(res.status).toBe(403); + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/__tests__/profile-api.test.ts b/apps/web/src/__tests__/profile-api.test.ts index 74029d0..925ae5f 100644 --- a/apps/web/src/__tests__/profile-api.test.ts +++ b/apps/web/src/__tests__/profile-api.test.ts @@ -1,15 +1,21 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { NextAuthOptions } from 'next-auth'; +import { NextRequest } from 'next/server'; +import { encode, decode, type JWT } from 'next-auth/jwt'; // ─── Mock Prisma ──────────────────────────────────────────────────────────── const mockFindUnique = vi.fn(); const mockUpdateMember = vi.fn(); +const mockUpdateMany = vi.fn(); vi.mock('@copilotkit/outpost/db', () => ({ prisma: { teamMember: { findUnique: (args: unknown) => mockFindUnique(args), update: (args: unknown) => mockUpdateMember(args), + updateMany: (args: unknown) => mockUpdateMany(args), }, }, })); @@ -38,15 +44,55 @@ vi.mock('@/lib/auth', () => ({ // Import after mocks import { GET, PUT } from '@/app/api/profile/route'; +import { DEFAULT_SESSION_MAX_AGE, SESSION_COOKIE_CHUNK_SIZE } from '@/lib/session-cookie'; -function makeRequest(body: Record): Request { - return new Request('http://localhost:3000/api/profile', { +const SECRET = 'profile-test-secret'; +const COOKIE = 'next-auth.session-token'; + +async function sessionJwt(claims: Record): Promise { + return encode({ token: claims, secret: SECRET }); +} + +function makeRequest( + body: Record, + cookies: Record = {}, +): NextRequest { + const cookieHeader = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + return new NextRequest('http://localhost:3000/api/profile', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + }, body: JSON.stringify(body), }); } +const passwordChange = { + name: 'Alice Admin', + currentPassword: 'oldpass123', + newPassword: 'newpass1234', + confirmNewPassword: 'newpass1234', +}; + +type JwtArgs = Parameters['jwt']>>[0]; + +/** + * A session read (no `user`, no `account`) through the REAL jwt callback from + * lib/auth.ts; the route itself sees the mocked authOptions. Its Prisma reads go + * through the mocked client above. + */ +async function realSessionCheck(): Promise<(token: JWT) => Promise> { + const actual = await vi.importActual<{ authOptions: NextAuthOptions }>('@/lib/auth'); + const jwt = actual.authOptions.callbacks!.jwt!; + return async (token) => { + const args: Partial = { token }; + return jwt(args as JwtArgs); + }; +} + const sampleMember = { id: 'member-1', name: 'Alice Admin', @@ -85,12 +131,25 @@ describe('GET /api/profile', () => { }); describe('PUT /api/profile', () => { + const originalEnv = { ...process.env }; + beforeEach(() => { + process.env.NEXTAUTH_SECRET = SECRET; + process.env.NEXTAUTH_URL = 'http://localhost:3000'; + delete process.env.AUTH_PROVIDER; mockGetServerSession.mockReset(); mockFindUnique.mockReset(); mockUpdateMember.mockReset(); + mockUpdateMany.mockReset(); mockVerifyPassword.mockReset(); mockHashPassword.mockReset().mockResolvedValue('new-hash-456'); + mockGetServerSession.mockResolvedValue({ + user: { id: 'member-1', role: 'ADMIN', memberId: 'member-1' }, + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; }); it('updates name and avatar', async () => { @@ -104,33 +163,282 @@ describe('PUT /api/profile', () => { avatarUrl: 'https://example.com/pic.png', }); - const res = await PUT(makeRequest({ - name: 'Alice Updated', - avatarUrl: 'https://example.com/pic.png', - })); + const res = await PUT( + makeRequest({ + name: 'Alice Updated', + avatarUrl: 'https://example.com/pic.png', + }), + ); expect(res.status).toBe(200); const body = await res.json(); expect(body.name).toBe('Alice Updated'); }); - it('changes password when current password is correct', async () => { - mockGetServerSession.mockResolvedValue({ - user: { id: 'member-1', role: 'ADMIN', memberId: 'member-1' }, + it('bumps sessionVersion with a compare-and-set and re-issues this session with sv + 1', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await sessionJwt({ + memberId: 'member-1', + role: 'ADMIN', + sv: 2, + email: 'alice@example.com', + }); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: jwt })); + + expect(res.status).toBe(200); + expect(mockHashPassword).toHaveBeenCalledWith('newpass1234'); + expect(mockUpdateMany).toHaveBeenCalledWith({ + where: { id: 'member-1', status: 'ACTIVE', sessionVersion: 2 }, + data: { + name: 'Alice Admin', + passwordHash: 'new-hash-456', + sessionVersion: { increment: 1 }, + }, + }); + expect(mockUpdateMember).not.toHaveBeenCalled(); + + const issued = res.cookies.get(COOKIE); + expect(issued?.httpOnly).toBe(true); + expect(issued?.sameSite).toBe('lax'); + expect(issued?.path).toBe('/'); + const claims = await decode({ token: issued!.value, secret: SECRET }); + expect(claims).toMatchObject({ memberId: 'member-1', role: 'ADMIN', sv: 3 }); + }); + + it('keeps the re-issued session and revokes the old one under the real jwt callback', async () => { + // A stateful member row: findUnique reads it, updateMany is a real compare-and-set on it. + const row = { ...sampleMember, status: 'ACTIVE', sessionVersion: 2 }; + mockFindUnique.mockImplementation(async () => ({ ...row })); + mockUpdateMany.mockImplementation( + async (args: { + where: { sessionVersion: number; status: string }; + data: Record; + }) => { + if ( + row.sessionVersion !== args.where.sessionVersion || + row.status !== args.where.status + ) { + return { count: 0 }; + } + row.passwordHash = args.data.passwordHash as string; + const sv = args.data.sessionVersion as { increment?: number } | undefined; + row.sessionVersion += sv?.increment ?? 0; + return { count: 1 }; + }, + ); + mockVerifyPassword.mockResolvedValue(true); + const sessionCheck = await realSessionCheck(); + const claims = { memberId: 'member-1', role: 'ADMIN', sv: 2 }; + const thisSession = await sessionJwt(claims); + const otherSession = await sessionJwt({ ...claims, name: 'other device' }); + + // Before the change, both sessions pass the real session check. + await expect(sessionCheck({ ...claims })).resolves.toMatchObject({ + sv: 2, + }); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: thisSession })); + + expect(res.status).toBe(200); + expect(row.sessionVersion).toBe(3); + const reissued = await decode({ token: res.cookies.get(COOKIE)!.value, secret: SECRET }); + expect(reissued?.sv).toBe(3); + await expect(sessionCheck(reissued!)).resolves.toMatchObject({ + memberId: 'member-1', + sv: 3, + }); + const other = await decode({ token: otherSession, secret: SECRET }); + await expect(sessionCheck(other!)).rejects.toThrow('SESSION_REVOKED'); + }); + + it('treats a token without sv as 0', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await sessionJwt({ memberId: 'member-1', role: 'ADMIN' }); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: jwt })); + + expect(mockUpdateMany.mock.calls[0][0].where.sessionVersion).toBe(0); + expect((await decode({ token: res.cookies.get(COOKIE)!.value, secret: SECRET }))?.sv).toBe( + 1, + ); + }); + + it('401s, sets no cookie, and writes nothing when a concurrent revocation won', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 0 }); + const jwt = await sessionJwt({ memberId: 'member-1', sv: 2 }); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: jwt })); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Your session has ended. Sign in again.' }); + expect(res.cookies.get(COOKIE)).toBeUndefined(); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(mockUpdateMember).not.toHaveBeenCalled(); + }); + + it('401s with no cookie on the request, or a token for another member', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + + expect((await PUT(makeRequest(passwordChange))).status).toBe(401); + const other = await sessionJwt({ memberId: 'someone-else', sv: 0 }); + expect((await PUT(makeRequest(passwordChange, { [COOKIE]: other }))).status).toBe(401); + expect(mockUpdateMany).not.toHaveBeenCalled(); + expect(mockUpdateMember).not.toHaveBeenCalled(); + }); + + it('expires leftover chunk cookies when the session was chunked', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await sessionJwt({ memberId: 'member-1', sv: 0 }); + const half = Math.ceil(jwt.length / 2); + + const res = await PUT( + makeRequest(passwordChange, { + [`${COOKIE}.0`]: jwt.slice(0, half), + [`${COOKIE}.1`]: jwt.slice(half), + }), + ); + + expect(res.status).toBe(200); + expect(res.cookies.get(`${COOKIE}.0`)?.maxAge).toBe(0); + expect(res.cookies.get(`${COOKIE}.1`)?.maxAge).toBe(0); + expect((await decode({ token: res.cookies.get(COOKIE)!.value, secret: SECRET }))?.sv).toBe( + 1, + ); + }); + + it('re-issues the cookie with maxAge equal to the token lifetime (exp - iat)', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await sessionJwt({ memberId: 'member-1', sv: 0 }); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: jwt })); + + const issued = res.cookies.get(COOKIE)!; + const claims = await decode({ token: issued.value, secret: SECRET }); + const lifetime = Number(claims!.exp) - Number(claims!.iat); + expect(lifetime).toBe(DEFAULT_SESSION_MAX_AGE); + expect(issued.maxAge).toBe(lifetime); + }); + + // A session larger than one cookie (e.g. a long avatarUrl in `picture`). Browsers + // drop a single Set-Cookie over 4096 bytes, which would sign the user out. + async function largeSessionJwt(): Promise { + const jwt = await sessionJwt({ + memberId: 'member-1', + sv: 0, + picture: `https://cdn.example/${'a'.repeat(5000)}`, + }); + expect(jwt.length).toBeGreaterThan(SESSION_COOKIE_CHUNK_SIZE); + return jwt; + } + + function chunkCookies(res: Awaited>) { + return res.cookies.getAll().filter((c) => c.name.startsWith(`${COOKIE}.`) && c.value); + } + + it('re-issues a chunked session as NextAuth-sized chunks with the session maxAge', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await largeSessionJwt(); + const half = SESSION_COOKIE_CHUNK_SIZE; + + const res = await PUT( + makeRequest(passwordChange, { + [`${COOKIE}.0`]: jwt.slice(0, half), + [`${COOKIE}.1`]: jwt.slice(half), + }), + ); + + expect(res.status).toBe(200); + // No oversized single cookie. + expect(res.cookies.get(COOKIE)).toBeUndefined(); + const chunks = chunkCookies(res); + expect(chunks.map((c) => c.name)).toEqual([`${COOKIE}.0`, `${COOKIE}.1`]); + // next-auth 4.24.14 CHUNK_SIZE: 4096 - 163. + expect(chunks[0].value.length).toBe(3933); + for (const c of chunks) { + expect(c.value.length).toBeLessThanOrEqual(SESSION_COOKIE_CHUNK_SIZE); + expect(c.maxAge).toBe(DEFAULT_SESSION_MAX_AGE); + expect(c.httpOnly).toBe(true); + expect(c.path).toBe('/'); + } + const claims = await decode({ token: chunks.map((c) => c.value).join(''), secret: SECRET }); + expect(claims).toMatchObject({ memberId: 'member-1', sv: 1 }); + expect(Number(claims!.exp) - Number(claims!.iat)).toBe(chunks[0].maxAge); + }); + + it('expires the bare cookie when the re-issue is chunked', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + mockUpdateMany.mockResolvedValue({ count: 1 }); + const jwt = await largeSessionJwt(); + + const res = await PUT(makeRequest(passwordChange, { [COOKIE]: jwt })); + + expect(res.status).toBe(200); + expect(res.cookies.get(COOKIE)).toMatchObject({ value: '', maxAge: 0 }); + expect(chunkCookies(res).map((c) => c.name)).toEqual([`${COOKIE}.0`, `${COOKIE}.1`]); + }); + + it('a name-only edit sets no cookie and does not bump', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockUpdateMember.mockResolvedValue({ ...sampleMember, name: 'New Name' }); + const jwt = await sessionJwt({ memberId: 'member-1', sv: 2 }); + + const res = await PUT(makeRequest({ name: 'New Name' }, { [COOKIE]: jwt })); + + expect(res.status).toBe(200); + expect(mockUpdateMember).toHaveBeenCalledWith({ + where: { id: 'member-1' }, + data: { name: 'New Name' }, }); + expect(mockUpdateMany).not.toHaveBeenCalled(); + expect(res.cookies.get(COOKIE)).toBeUndefined(); + }); + + it('GitHub mode: bumps without compare-and-set or re-issue', async () => { + process.env.AUTH_PROVIDER = 'github'; mockFindUnique.mockResolvedValue(sampleMember); mockVerifyPassword.mockResolvedValue(true); mockUpdateMember.mockResolvedValue(sampleMember); - const res = await PUT(makeRequest({ - name: 'Alice Admin', - currentPassword: 'oldpass123', - newPassword: 'newpass1234', - confirmNewPassword: 'newpass1234', - })); + const res = await PUT(makeRequest(passwordChange)); expect(res.status).toBe(200); - expect(mockHashPassword).toHaveBeenCalledWith('newpass1234'); + expect(mockUpdateMember).toHaveBeenCalledWith({ + where: { id: 'member-1' }, + data: { + name: 'Alice Admin', + passwordHash: 'new-hash-456', + sessionVersion: { increment: 1 }, + }, + }); + expect(mockUpdateMany).not.toHaveBeenCalled(); + expect(res.cookies.get(COOKIE)).toBeUndefined(); + }); + + it('rejects a new password over 72 bytes', async () => { + mockFindUnique.mockResolvedValue(sampleMember); + mockVerifyPassword.mockResolvedValue(true); + const p = 'é'.repeat(37); + const res = await PUT( + makeRequest({ ...passwordChange, newPassword: p, confirmNewPassword: p }), + ); + expect(res.status).toBe(400); + expect((await res.json()).errors).toContain('New password must be 72 bytes or fewer.'); + expect(mockUpdateMany).not.toHaveBeenCalled(); }); it('rejects password change with wrong current password', async () => { @@ -140,18 +448,28 @@ describe('PUT /api/profile', () => { mockFindUnique.mockResolvedValue(sampleMember); mockVerifyPassword.mockResolvedValue(false); - const res = await PUT(makeRequest({ - name: 'Alice Admin', - currentPassword: 'wrongpass', - newPassword: 'newpass1234', - confirmNewPassword: 'newpass1234', - })); + const jwt = await sessionJwt({ memberId: 'member-1', sv: 2 }); + + const res = await PUT( + makeRequest( + { + name: 'Alice Admin', + currentPassword: 'wrongpass', + newPassword: 'newpass1234', + confirmNewPassword: 'newpass1234', + }, + { [COOKIE]: jwt }, + ), + ); expect(res.status).toBe(400); const body = await res.json(); - expect(body.errors).toEqual( - expect.arrayContaining([expect.stringContaining('incorrect')]), - ); + expect(body.errors).toEqual(expect.arrayContaining([expect.stringContaining('incorrect')])); + // No bump and no re-issue: nothing is written and no cookie is set. + expect(mockHashPassword).not.toHaveBeenCalled(); + expect(mockUpdateMany).not.toHaveBeenCalled(); + expect(mockUpdateMember).not.toHaveBeenCalled(); + expect(res.headers.get('set-cookie')).toBeNull(); }); it('rejects password change with mismatched new passwords', async () => { @@ -161,12 +479,14 @@ describe('PUT /api/profile', () => { mockFindUnique.mockResolvedValue(sampleMember); mockVerifyPassword.mockResolvedValue(true); - const res = await PUT(makeRequest({ - name: 'Alice Admin', - currentPassword: 'oldpass123', - newPassword: 'newpass1234', - confirmNewPassword: 'different5678', - })); + const res = await PUT( + makeRequest({ + name: 'Alice Admin', + currentPassword: 'oldpass123', + newPassword: 'newpass1234', + confirmNewPassword: 'different5678', + }), + ); expect(res.status).toBe(400); const body = await res.json(); @@ -181,12 +501,14 @@ describe('PUT /api/profile', () => { }); mockFindUnique.mockResolvedValue(sampleMember); - const res = await PUT(makeRequest({ - name: 'Alice Admin', - currentPassword: 'oldpass123', - newPassword: 'short', - confirmNewPassword: 'short', - })); + const res = await PUT( + makeRequest({ + name: 'Alice Admin', + currentPassword: 'oldpass123', + newPassword: 'short', + confirmNewPassword: 'short', + }), + ); expect(res.status).toBe(400); const body = await res.json(); diff --git a/apps/web/src/__tests__/profile-page.test.tsx b/apps/web/src/__tests__/profile-page.test.tsx new file mode 100644 index 0000000..285573b --- /dev/null +++ b/apps/web/src/__tests__/profile-page.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const mockReplace = vi.fn(); +const mockUpdate = vi.fn(); +// Stable objects: the page lists `session` in an effect dependency array, so a fresh +// object per render would refetch forever. +const ROUTER = { replace: mockReplace }; +const SESSION = { data: { user: { name: 'Alice' } }, update: mockUpdate }; +vi.mock('next/navigation', () => ({ useRouter: () => ROUTER })); +vi.mock('next-auth/react', () => ({ useSession: () => SESSION })); +vi.mock('@/components/page-header', () => ({ PageHeader: () => null })); + +const mockApiFetch = vi.fn(); +vi.mock('@/lib/api-fetch', () => ({ apiFetch: (...a: unknown[]) => mockApiFetch(...a) })); + +import ProfilePage from '@/app/settings/profile/page'; + +const profile = { id: 'm1', name: 'Alice', email: 'alice@x.io', avatarUrl: null, role: 'MEMBER' }; + +function respondPut(status: number, body: unknown) { + mockApiFetch.mockImplementation(async (_url: string, init?: RequestInit) => + init?.method === 'PUT' + ? { status, ok: status < 300, json: async () => body } + : { status: 200, ok: true, json: async () => profile }, + ); +} + +async function changePassword() { + render(); + fireEvent.change(await screen.findByLabelText('Current Password'), { + target: { value: 'oldpass123' }, + }); + fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'newpass1234' } }); + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'newpass1234' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Save Changes/ })); +} + +describe('profile page password change', () => { + beforeEach(() => { + mockReplace.mockReset(); + mockUpdate.mockReset(); + mockApiFetch.mockReset(); + }); + + it('says the other sessions were signed out', async () => { + respondPut(200, profile); + await changePassword(); + expect( + await screen.findByText('Password changed. Your other sessions have been signed out.'), + ).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + // The server re-issues the session cookie; the client must not race it with update(). + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('redirects to /login on 401', async () => { + respondPut(401, { error: 'Your session has ended. Sign in again.' }); + await changePassword(); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/login')); + expect( + screen.queryByText('Your session has ended. Sign in again.'), + ).not.toBeInTheDocument(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('keeps the plain message for a name-only save', async () => { + respondPut(200, profile); + render(); + await screen.findByLabelText('Current Password'); + fireEvent.click(screen.getByRole('button', { name: /Save Changes/ })); + expect(await screen.findByText('Profile updated successfully.')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/__tests__/session-cookie.test.ts b/apps/web/src/__tests__/session-cookie.test.ts new file mode 100644 index 0000000..28f15e6 --- /dev/null +++ b/apps/web/src/__tests__/session-cookie.test.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('@copilotkit/outpost/db', () => ({ prisma: {} })); +vi.mock('@copilotkit/outpost/shared', () => ({ verifyPassword: vi.fn() })); + +import type { NextAuthOptions } from 'next-auth'; +import { sessionCookie, DEFAULT_SESSION_MAX_AGE } from '@/lib/session-cookie'; +import { authOptions } from '@/lib/auth'; + +// NextAuth's own option resolver (core/init.js). It is not in next-auth's `exports` map, so +// it is loaded by file path from the installed package with Node's require: the real code +// that turns authOptions + the request origin into the cookie names and session maxAge. +interface ResolvedOptions { + cookies: { sessionToken: { name: string; options: Record } }; + session: { maxAge: number }; +} +type NextAuthInit = (params: { + authOptions: NextAuthOptions; + origin: string; + action: string; + isPost: boolean; + cookies: Record; +}) => Promise<{ options: ResolvedOptions }>; + +const nodeRequire = createRequire(import.meta.url); +const nextAuthDir = path.dirname(nodeRequire.resolve('next-auth')); +const { init } = nodeRequire(path.join(nextAuthDir, 'core/init.js')) as { init: NextAuthInit }; + +async function resolvedByNextAuth(origin: string): Promise { + const { options } = await init({ + authOptions, + origin, + action: 'session', + isPost: false, + cookies: {}, + }); + return options; +} + +describe('sessionCookie()', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('uses the __Secure- name and secure flag over https', () => { + vi.stubEnv('NEXTAUTH_URL', 'https://outpost.copilotkit.ai'); + expect(sessionCookie()).toEqual({ + name: '__Secure-next-auth.session-token', + options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true }, + }); + }); + + it('uses the plain name over http', () => { + vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000'); + expect(sessionCookie()).toEqual({ + name: 'next-auth.session-token', + options: { httpOnly: true, sameSite: 'lax', path: '/', secure: false }, + }); + }); + + it("matches NextAuth's default max age", () => { + expect(DEFAULT_SESSION_MAX_AGE).toBe(30 * 24 * 60 * 60); + }); + + it('stays in sync with authOptions: no cookies or useSecureCookies override', () => { + expect(authOptions.cookies).toBeUndefined(); + expect(authOptions.useSecureCookies).toBeUndefined(); + }); + + it.each(['https://outpost.copilotkit.ai', 'http://localhost:3000'])( + 'equals the session cookie the installed next-auth resolves for authOptions at %s', + async (url) => { + vi.stubEnv('NEXTAUTH_URL', url); + const resolved = await resolvedByNextAuth(url); + expect(sessionCookie()).toEqual(resolved.cookies.sessionToken); + }, + ); + + it('falls back to the session maxAge the installed next-auth resolves for authOptions', async () => { + const resolved = await resolvedByNextAuth('https://outpost.copilotkit.ai'); + // The expression a server-side re-issue uses for the cookie and JWT lifetime. + expect(authOptions.session?.maxAge ?? DEFAULT_SESSION_MAX_AGE).toBe( + resolved.session.maxAge, + ); + }); +}); diff --git a/apps/web/src/__tests__/team-api.test.ts b/apps/web/src/__tests__/team-api.test.ts index 1116e15..5d98f66 100644 --- a/apps/web/src/__tests__/team-api.test.ts +++ b/apps/web/src/__tests__/team-api.test.ts @@ -6,6 +6,7 @@ const mockTeamMemberFindMany = vi.fn(); const mockTeamMemberFindUnique = vi.fn(); const mockTeamMemberCreate = vi.fn(); const mockTeamMemberUpdate = vi.fn(); +const mockTeamMemberUpdateMany = vi.fn(); const mockTeamMemberDelete = vi.fn(); const mockTeamMemberCount = vi.fn(); const mockInviteTokenFindUnique = vi.fn(); @@ -22,6 +23,7 @@ vi.mock('@copilotkit/outpost/db', () => ({ findUnique: (...args: unknown[]) => mockTeamMemberFindUnique(...args), create: (...args: unknown[]) => mockTeamMemberCreate(...args), update: (...args: unknown[]) => mockTeamMemberUpdate(...args), + updateMany: (...args: unknown[]) => mockTeamMemberUpdateMany(...args), delete: (...args: unknown[]) => mockTeamMemberDelete(...args), count: (...args: unknown[]) => mockTeamMemberCount(...args), }, @@ -184,7 +186,7 @@ describe('Team API', () => { token: 'valid-token', expiresAt: new Date(Date.now() + 3600000), usedAt: null, - member: { email: 'invited@test.com' }, + member: { email: 'invited@test.com', status: 'INVITED' }, }); mockOrganizationFindFirst.mockResolvedValue({ name: 'TestOrg', logoUrl: null }); @@ -255,18 +257,16 @@ describe('Team API', () => { token: 'valid-token', expiresAt: new Date(Date.now() + 3600000), usedAt: null, - member: { email: 'invited@test.com' }, + member: { email: 'invited@test.com', status: 'INVITED' }, + }); + // The route runs an interactive transaction that returns the activated member. + mockTransaction.mockResolvedValue({ + id: 'invited-1', + name: 'New User', + email: 'invited@test.com', + role: 'MEMBER', + status: 'ACTIVE', }); - mockTransaction.mockResolvedValue([ - { - id: 'invited-1', - name: 'New User', - email: 'invited@test.com', - role: 'MEMBER', - status: 'ACTIVE', - }, - {}, - ]); const res = await acceptPost( jsonRequest('http://localhost:3000/api/team/invite/accept', { @@ -336,6 +336,148 @@ describe('Team API', () => { const body = await res.json(); expect(body.error).toContain('own role'); }); + + it('prevents self-disable, with no write', async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + + const res = await memberPatch( + jsonRequest( + 'http://localhost:3000/api/team/admin-1', + { status: 'DISABLED' }, + 'PATCH', + ), + { params: Promise.resolve({ id: 'admin-1' }) }, + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('own status'); + expect(mockTeamMemberUpdate).not.toHaveBeenCalled(); + }); + + it('bumps sessionVersion when disabling a member', async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + mockTeamMemberUpdate.mockResolvedValue({ id: 'member-2', status: 'DISABLED' }); + + const res = await memberPatch( + jsonRequest( + 'http://localhost:3000/api/team/member-2', + { status: 'DISABLED' }, + 'PATCH', + ), + { params: Promise.resolve({ id: 'member-2' }) }, + ); + + expect(res.status).toBe(200); + expect(mockTeamMemberUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'member-2' }, + data: { status: 'DISABLED', sessionVersion: { increment: 1 } }, + }), + ); + }); + + // A member who has joined (OIDC) or has a password (create-admin, seed) can sign + // in, so enabling them sets ACTIVE. + const CAN_SIGN_IN = [{ joinedAt: { not: null } }, { passwordHash: { not: null } }]; + + it('enables a joined member as ACTIVE, without a bump', async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + // The never-joined write matches nothing; the joined write matches. + mockTeamMemberUpdateMany + .mockResolvedValueOnce({ count: 0 }) + .mockResolvedValueOnce({ count: 1 }); + mockTeamMemberFindUnique.mockResolvedValue({ id: 'member-2', status: 'ACTIVE' }); + + const res = await memberPatch( + jsonRequest( + 'http://localhost:3000/api/team/member-2', + { status: 'ACTIVE' }, + 'PATCH', + ), + { params: Promise.resolve({ id: 'member-2' }) }, + ); + + expect(res.status).toBe(200); + expect((await res.json()).status).toBe('ACTIVE'); + expect(mockTeamMemberUpdateMany).toHaveBeenNthCalledWith(1, { + where: { id: 'member-2', joinedAt: null, passwordHash: null }, + data: { status: 'INVITED' }, + }); + expect(mockTeamMemberUpdateMany).toHaveBeenNthCalledWith(2, { + where: { id: 'member-2', OR: CAN_SIGN_IN }, + data: { status: 'ACTIVE' }, + }); + expect(mockTeamMemberUpdate).not.toHaveBeenCalled(); + }); + + // Invite accept takes only INVITED members, so ACTIVE with no password would + // leave a never-joined member with no way in. Enabling restores INVITED. + it.each(['INVITED', 'DISABLED'])( + 'enables a never-joined %s member as INVITED, never ACTIVE', + async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + mockTeamMemberUpdateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + mockTeamMemberFindUnique.mockResolvedValue({ + id: 'member-3', + role: 'ADMIN', + status: 'INVITED', + }); + + const res = await memberPatch( + jsonRequest( + 'http://localhost:3000/api/team/member-3', + { status: 'ACTIVE', role: 'ADMIN' }, + 'PATCH', + ), + { params: Promise.resolve({ id: 'member-3' }) }, + ); + + expect(res.status).toBe(200); + expect((await res.json()).status).toBe('INVITED'); + expect(mockTeamMemberUpdateMany).toHaveBeenNthCalledWith(1, { + where: { id: 'member-3', joinedAt: null, passwordHash: null }, + data: { role: 'ADMIN', status: 'INVITED' }, + }); + // No write may set ACTIVE on a row with neither joinedAt nor a password. + for (const [arg] of mockTeamMemberUpdateMany.mock.calls) { + if (arg.data.status === 'ACTIVE') { + expect(arg.where.OR).toEqual(CAN_SIGN_IN); + } + } + }, + ); + + it('returns 404 when enabling a member that does not exist', async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + mockTeamMemberUpdateMany.mockResolvedValue({ count: 0 }); + + const res = await memberPatch( + jsonRequest( + 'http://localhost:3000/api/team/missing', + { status: 'ACTIVE' }, + 'PATCH', + ), + { params: Promise.resolve({ id: 'missing' }) }, + ); + + expect(res.status).toBe(404); + expect(mockTeamMemberFindUnique).not.toHaveBeenCalled(); + }); + + it('does not bump on a role-only change', async () => { + mockGetServerSession.mockResolvedValue(adminSession()); + mockTeamMemberUpdate.mockResolvedValue({ id: 'member-2', role: 'ADMIN' }); + + await memberPatch( + jsonRequest('http://localhost:3000/api/team/member-2', { role: 'ADMIN' }, 'PATCH'), + { params: Promise.resolve({ id: 'member-2' }) }, + ); + + expect(mockTeamMemberUpdate.mock.calls[0][0].data).toEqual({ role: 'ADMIN' }); + }); }); // ── MEMBER cannot access team management ─────────────────────────────── @@ -420,10 +562,9 @@ describe('Team API', () => { mockGetServerSession.mockResolvedValue(adminSession()); mockTeamMemberDelete.mockResolvedValue({}); - const res = await memberDelete( - makeRequest('http://localhost:3000/api/team/member-1'), - { params: Promise.resolve({ id: 'member-1' }) }, - ); + const res = await memberDelete(makeRequest('http://localhost:3000/api/team/member-1'), { + params: Promise.resolve({ id: 'member-1' }), + }); expect(res.status).toBe(200); }); @@ -431,10 +572,9 @@ describe('Team API', () => { it('prevents self-removal', async () => { mockGetServerSession.mockResolvedValue(adminSession()); - const res = await memberDelete( - makeRequest('http://localhost:3000/api/team/admin-1'), - { params: Promise.resolve({ id: 'admin-1' }) }, - ); + const res = await memberDelete(makeRequest('http://localhost:3000/api/team/admin-1'), { + params: Promise.resolve({ id: 'admin-1' }), + }); expect(res.status).toBe(400); const body = await res.json(); diff --git a/apps/web/src/__tests__/team-status-db.test.ts b/apps/web/src/__tests__/team-status-db.test.ts new file mode 100644 index 0000000..52d60f9 --- /dev/null +++ b/apps/web/src/__tests__/team-status-db.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { randomUUID } from 'crypto'; +import type { NextAuthOptions } from 'next-auth'; +import type * as TeamMemberRoute from '@/app/api/team/[id]/route'; +import type * as AcceptRoute from '@/app/api/team/invite/accept/route'; +import type { prisma as PrismaInstance } from '@copilotkit/outpost/db'; + +// Only the admin session is stubbed. The PATCH and invite-accept handlers, the +// credentials authorize(), Prisma, Postgres and bcrypt are all real. +const caller = vi.hoisted(() => ({ memberId: 'status-db-admin' })); +vi.mock('@/lib/require-admin', () => ({ + requireAdmin: async () => ({ + error: null, + session: { user: { id: caller.memberId, role: 'ADMIN', memberId: caller.memberId } }, + }), +})); + +type AuthorizeFn = (credentials: Record, req: unknown) => Promise; + +const url = process.env.OUTPOST_TEST_DATABASE_URL; +const requireDb = process.env.OUTPOST_REQUIRE_DB_TESTS === '1'; + +describe.skipIf(!url && !requireDb)('PATCH /api/team/[id] status against real Postgres', () => { + let realPATCH: typeof TeamMemberRoute.PATCH; + let realAccept: typeof AcceptRoute.POST; + let prisma: typeof PrismaInstance; + let authorize: AuthorizeFn; + let hashPassword: (plain: string) => Promise; + const memberIds: string[] = []; + + beforeAll(async () => { + if (!url) { + throw new Error('OUTPOST_REQUIRE_DB_TESTS=1 but OUTPOST_TEST_DATABASE_URL is not set'); + } + process.env.DATABASE_URL = url; + process.env.AUTH_PROVIDER = 'credentials'; + vi.resetModules(); + ({ PATCH: realPATCH } = await import('@/app/api/team/[id]/route')); + ({ POST: realAccept } = await import('@/app/api/team/invite/accept/route')); + ({ prisma } = await import('@copilotkit/outpost/db')); + ({ hashPassword } = await import('@copilotkit/outpost/shared')); + const { authOptions }: { authOptions: NextAuthOptions } = await import('@/lib/auth'); + // next-auth v4 keeps the user-supplied authorize under `options`. + authorize = (authOptions.providers[0] as unknown as { options: { authorize: AuthorizeFn } }) + .options.authorize; + }); + + afterEach(() => { + caller.memberId = 'status-db-admin'; + }); + + afterAll(async () => { + if (!prisma) return; + if (memberIds.length) { + await prisma.teamMember.deleteMany({ where: { id: { in: memberIds } } }); + } + await prisma.$disconnect(); + }); + + async function seedNeverJoined(status: 'INVITED' | 'DISABLED') { + const member = await prisma.teamMember.create({ + data: { + name: 'Pending', + email: `status-${randomUUID()}@example.test`, + status, + invitedAt: new Date(), + }, + }); + memberIds.push(member.id); + const token = `invite-${randomUUID()}`; + await prisma.inviteToken.create({ + data: { memberId: member.id, token, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + return { member, token }; + } + + async function seedJoined() { + const member = await prisma.teamMember.create({ + data: { + name: 'Joined', + email: `status-${randomUUID()}@example.test`, + status: 'ACTIVE', + invitedAt: new Date(), + joinedAt: new Date(), + passwordHash: 'existing-hash', + }, + }); + memberIds.push(member.id); + return member; + } + + const PASSWORD = 'correct-horse-battery'; + // Real bcrypt at cost 12, one hash plus several verifies per test: past the 5s + // default on a loaded machine. + const BCRYPT_TIMEOUT_MS = 30_000; + + /** + * The row scripts/create-admin.ts and packages/outpost/db/src/seed.ts write: a + * password and the default ACTIVE status, but no joinedAt. + */ + async function seedPasswordWithoutJoinedAt() { + const member = await prisma.teamMember.create({ + data: { + name: 'Created Admin', + email: `status-${randomUUID()}@example.test`, + passwordHash: await hashPassword(PASSWORD), + role: 'ADMIN', + }, + }); + memberIds.push(member.id); + expect(member.status).toBe('ACTIVE'); + expect(member.joinedAt).toBeNull(); + return member; + } + + function signIn(email: string) { + return authorize({ email, password: PASSWORD }, {}); + } + + function patch(id: string, body: Record) { + return realPATCH( + new Request(`http://localhost:3000/api/team/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); + } + + function accept(token: string) { + return realAccept( + new Request('http://localhost:3000/api/team/invite/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token, + name: 'Accepted', + password: 'correct-horse-battery', + confirmPassword: 'correct-horse-battery', + }), + }), + ); + } + + for (const start of ['INVITED', 'DISABLED'] as const) { + it(`enabling a never-joined ${start} member restores INVITED, and the invite still works`, async () => { + const { member, token } = await seedNeverJoined(start); + + const res = await patch(member.id, { status: 'ACTIVE' }); + + expect(res.status).toBe(200); + expect((await res.json()).status).toBe('INVITED'); + const enabled = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(enabled.status).toBe('INVITED'); + expect(enabled.passwordHash).toBeNull(); + expect(enabled.joinedAt).toBeNull(); + + const accepted = await accept(token); + + expect(accepted.status).toBe(200); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(after.status).toBe('ACTIVE'); + expect(after.passwordHash).not.toBeNull(); + expect(after.joinedAt).not.toBeNull(); + const invite = await prisma.inviteToken.findUniqueOrThrow({ where: { token } }); + expect(invite.usedAt).not.toBeNull(); + }); + } + + it('disables and re-enables a joined member as ACTIVE; only disabling bumps sessionVersion', async () => { + const member = await seedJoined(); + + const disabled = await patch(member.id, { status: 'DISABLED' }); + + expect(disabled.status).toBe(200); + const afterDisable = await prisma.teamMember.findUniqueOrThrow({ + where: { id: member.id }, + }); + expect(afterDisable.status).toBe('DISABLED'); + expect(afterDisable.sessionVersion).toBe(member.sessionVersion + 1); + + const enabled = await patch(member.id, { status: 'ACTIVE' }); + + expect(enabled.status).toBe(200); + expect((await enabled.json()).status).toBe('ACTIVE'); + const afterEnable = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(afterEnable.status).toBe('ACTIVE'); + expect(afterEnable.sessionVersion).toBe(member.sessionVersion + 1); + expect(afterEnable.passwordHash).toBe('existing-hash'); + }); + + it( + 're-enables a member with a password but no joinedAt as ACTIVE, and they can sign in', + async () => { + const member = await seedPasswordWithoutJoinedAt(); + expect(await signIn(member.email)).toMatchObject({ id: member.id }); + + const disabled = await patch(member.id, { status: 'DISABLED' }); + + expect(disabled.status).toBe(200); + expect(await signIn(member.email)).toBeNull(); + + const enabled = await patch(member.id, { status: 'ACTIVE' }); + + expect(enabled.status).toBe(200); + expect((await enabled.json()).status).toBe('ACTIVE'); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: member.id } }); + expect(after.status).toBe('ACTIVE'); + expect(after.passwordHash).toBe(member.passwordHash); + expect(await signIn(member.email)).toMatchObject({ id: member.id }); + }, + BCRYPT_TIMEOUT_MS, + ); + + it( + 'an admin with a password but no joinedAt who PATCHes their own status to ACTIVE stays ACTIVE', + async () => { + const admin = await seedPasswordWithoutJoinedAt(); + caller.memberId = admin.id; + + const res = await patch(admin.id, { status: 'ACTIVE' }); + + expect(res.status).toBe(200); + expect((await res.json()).status).toBe('ACTIVE'); + const after = await prisma.teamMember.findUniqueOrThrow({ where: { id: admin.id } }); + expect(after.status).toBe('ACTIVE'); + expect(await signIn(admin.email)).toMatchObject({ id: admin.id }); + }, + BCRYPT_TIMEOUT_MS, + ); +}); diff --git a/apps/web/src/app/api/admin/reset/route.ts b/apps/web/src/app/api/admin/reset/route.ts index ad28539..4d3f4fa 100644 --- a/apps/web/src/app/api/admin/reset/route.ts +++ b/apps/web/src/app/api/admin/reset/route.ts @@ -52,6 +52,9 @@ export async function POST(request: Request) { avatarUrl: adminMember.avatarUrl, passwordHash: adminMember.passwordHash, joinedAt: adminMember.joinedAt, + // Without this the rebuilt row falls back to the default 0, and an + // admin whose version was ever bumped is signed out by their own reset. + sessionVersion: adminMember.sessionVersion, }, }); }); @@ -67,6 +70,8 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, reseeded, - ...(!reseeded && reseed ? { note: 'Manual re-seed required. Run: npx prisma db seed' } : {}), + ...(!reseeded && reseed + ? { note: 'Manual re-seed required. Run: npx prisma db seed' } + : {}), }); } diff --git a/apps/web/src/app/api/profile/route.ts b/apps/web/src/app/api/profile/route.ts index 0afb915..2a9d7d2 100644 --- a/apps/web/src/app/api/profile/route.ts +++ b/apps/web/src/app/api/profile/route.ts @@ -1,8 +1,28 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; import { getServerSession } from 'next-auth/next'; +import { encode, getToken } from 'next-auth/jwt'; import { authOptions } from '@/lib/auth'; import { prisma } from '@copilotkit/outpost/db'; import { hashPassword, verifyPassword } from '@copilotkit/outpost/shared'; +import { authProvider, providerHasSessionCheck } from '@/lib/auth-provider'; +import { validateNewPassword } from '@/lib/password-policy'; +import { DEFAULT_SESSION_MAX_AGE, sessionCookie, sessionCookieWrites } from '@/lib/session-cookie'; + +type ProfileRow = { + id: string; + name: string; + email: string; + avatarUrl: string | null; + role: string; +}; + +function profileBody(m: ProfileRow) { + return { id: m.id, name: m.name, email: m.email, avatarUrl: m.avatarUrl, role: m.role }; +} + +function sessionEnded() { + return NextResponse.json({ error: 'Your session has ended. Sign in again.' }, { status: 401 }); +} export async function GET() { const session = await getServerSession(authOptions); @@ -28,7 +48,7 @@ export async function GET() { }); } -export async function PUT(request: Request) { +export async function PUT(request: NextRequest) { const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -59,14 +79,12 @@ export async function PUT(request: Request) { if (!currentPassword) { errors.push('Current password is required to change password.'); } - if (!newPassword || typeof newPassword !== 'string') { - errors.push('New password is required.'); - } else if (newPassword.length < 8) { - errors.push('New password must be at least 8 characters.'); - } - if (newPassword !== confirmNewPassword) { - errors.push('New passwords do not match.'); - } + errors.push( + ...validateNewPassword(newPassword, confirmNewPassword, { + noun: 'New password', + mismatch: 'New passwords do not match.', + }), + ); // Verify current password if (currentPassword && member.passwordHash) { @@ -86,18 +104,61 @@ export async function PUT(request: Request) { const updateData: Record = {}; if (name !== undefined) updateData.name = name.trim(); if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl?.trim() || null; - if (newPassword) updateData.passwordHash = await hashPassword(newPassword); - const updated = await prisma.teamMember.update({ - where: { id: memberId }, - data: updateData, - }); + if (!newPassword) { + const updated = await prisma.teamMember.update({ + where: { id: memberId }, + data: updateData, + }); + return NextResponse.json(profileBody(updated)); + } - return NextResponse.json({ - id: updated.id, - name: updated.name, - email: updated.email, - avatarUrl: updated.avatarUrl, - role: updated.role, + // Hashed before any database write. + updateData.passwordHash = await hashPassword(newPassword); + + if (!providerHasSessionCheck(authProvider())) { + // GitHub mode has no session check, so there is nothing to re-issue (spec §6). + const updated = await prisma.teamMember.update({ + where: { id: memberId }, + data: { ...updateData, sessionVersion: { increment: 1 } }, + }); + return NextResponse.json(profileBody(updated)); + } + + // Keep THIS session and revoke the member's others (spec §6, decision 4). + // getServerSession above already accepted this cookie and the current password was + // verified. getToken is used only to copy the claims for the re-issue, never to + // authorize. It needs a NextRequest: it reads req.cookies.getAll(). + const secret = process.env.NEXTAUTH_SECRET; + const cookie = sessionCookie(); + const token = secret ? await getToken({ req: request, secret, cookieName: cookie.name }) : null; + if (!secret || !token || token.memberId !== memberId) { + return sessionEnded(); + } + const oldSv = typeof token.sv === 'number' ? token.sv : 0; + + // Compare-and-set from this token's own version to exactly +1. If anything else + // (a reset, a disable, another password change) moved the version since the + // session check, this matches nothing and the session is NOT revived. + const { count } = await prisma.teamMember.updateMany({ + where: { id: memberId, status: 'ACTIVE', sessionVersion: oldSv }, + data: { ...updateData, sessionVersion: { increment: 1 } }, }); + if (count !== 1) { + return sessionEnded(); + } + + const maxAge = authOptions.session?.maxAge ?? DEFAULT_SESSION_MAX_AGE; + // encode() sets fresh iat/exp/jti: the same rolling refresh NextAuth does per read. + const reissued = await encode({ token: { ...token, sv: oldSv + 1 }, secret, maxAge }); + const updated = await prisma.teamMember.findUnique({ where: { id: memberId } }); + + const response = NextResponse.json(profileBody(updated ?? member)); + // Same chunking and cleanup as NextAuth: a token over one cookie is split into + // name.0, name.1, …, and stale request chunks (or the bare cookie) are expired. + const requestCookieNames = request.cookies.getAll().map((c) => c.name); + for (const w of sessionCookieWrites(cookie, reissued, maxAge, requestCookieNames)) { + response.cookies.set(w.name, w.value, w.options); + } + return response; } diff --git a/apps/web/src/app/api/team/[id]/route.ts b/apps/web/src/app/api/team/[id]/route.ts index ac4e279..7567b09 100644 --- a/apps/web/src/app/api/team/[id]/route.ts +++ b/apps/web/src/app/api/team/[id]/route.ts @@ -2,10 +2,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@copilotkit/outpost/db'; import { requireAdmin } from '@/lib/require-admin'; -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const { error, session } = await requireAdmin(); if (error) return error; @@ -16,10 +13,12 @@ export async function PATCH( // Prevent self-demotion const memberId = (session!.user as Record).memberId; if (id === memberId && role && role !== 'ADMIN') { - return NextResponse.json( - { error: 'Cannot change your own role' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 }); + } + // Prevent self-disable: it bumps sessionVersion and blocks sign-in, so the caller + // would lock themselves out (and a sole admin would lock out the instance). + if (id === memberId && status && status !== 'ACTIVE') { + return NextResponse.json({ error: 'Cannot change your own status' }, { status: 400 }); } const data: Record = {}; @@ -28,34 +27,49 @@ export async function PATCH( } if (status && ['ACTIVE', 'DISABLED'].includes(status)) { data.status = status; + // The status check alone revokes a disabled member's sessions; the bump makes + // sure re-enabling them does not revive those old sessions (spec §4). + if (status === 'DISABLED') { + data.sessionVersion = { increment: 1 }; + } } if (Object.keys(data).length === 0) { - return NextResponse.json( - { error: 'No valid fields to update' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }); + } + + const select = { id: true, name: true, email: true, role: true, status: true } as const; + + if (data.status !== 'ACTIVE') { + const member = await prisma.teamMember.update({ where: { id }, data, select }); + return NextResponse.json(member); } - const member = await prisma.teamMember.update({ - where: { id }, + // Enabling a member who never joined (no password and no joinedAt) restores INVITED + // instead. ACTIVE would strand them: invite accept takes only INVITED members, and + // a reset link needs a password. As INVITED, the outstanding invite works again and + // the admin can resend it. Both fields are tested because neither alone means + // "never joined": create-admin and the seed write a password without joinedAt, and + // OIDC members have joinedAt without a password. Either one means they can sign + // in, so they go back to ACTIVE. The predicate is in each WHERE, so an accept that + // lands between the two writes is never undone. + const neverJoined = await prisma.teamMember.updateMany({ + where: { id, joinedAt: null, passwordHash: null }, + data: { ...data, status: 'INVITED' }, + }); + const joined = await prisma.teamMember.updateMany({ + where: { id, OR: [{ joinedAt: { not: null } }, { passwordHash: { not: null } }] }, data, - select: { - id: true, - name: true, - email: true, - role: true, - status: true, - }, }); + if (neverJoined.count + joined.count === 0) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }); + } + const member = await prisma.teamMember.findUnique({ where: { id }, select }); return NextResponse.json(member); } -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { error, session } = await requireAdmin(); if (error) return error; @@ -64,10 +78,7 @@ export async function DELETE( // Prevent self-removal const memberId = (session!.user as Record).memberId; if (id === memberId) { - return NextResponse.json( - { error: 'Cannot remove yourself' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot remove yourself' }, { status: 400 }); } await prisma.teamMember.delete({ where: { id } }); diff --git a/apps/web/src/app/settings/profile/page.tsx b/apps/web/src/app/settings/profile/page.tsx index 9c61c5d..9dd0a00 100644 --- a/apps/web/src/app/settings/profile/page.tsx +++ b/apps/web/src/app/settings/profile/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import { UserCircle, Save } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; @@ -15,6 +16,7 @@ interface ProfileData { } export default function ProfilePage() { + const router = useRouter(); const { data: session } = useSession(); const [profile, setProfile] = useState(null); const [name, setName] = useState(''); @@ -24,7 +26,7 @@ export default function ProfilePage() { const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState([]); - const [success, setSuccess] = useState(false); + const [success, setSuccess] = useState(null); const fetchProfile = useCallback(async () => { try { @@ -46,7 +48,7 @@ export default function ProfilePage() { const handleSave = async () => { setSaving(true); setErrors([]); - setSuccess(false); + setSuccess(null); const payload: Record = { name, avatarUrl }; if (currentPassword || newPassword || confirmNewPassword) { @@ -62,6 +64,12 @@ export default function ProfilePage() { body: JSON.stringify(payload), }); + if (res.status === 401) { + // The session was revoked (or a concurrent change won): sign in again. + router.replace('/login'); + return; + } + const data = await res.json(); if (!res.ok) { @@ -69,12 +77,18 @@ export default function ProfilePage() { return; } + const changedPassword = Boolean(payload.newPassword); setProfile(data); setCurrentPassword(''); setNewPassword(''); setConfirmNewPassword(''); - setSuccess(true); - setTimeout(() => setSuccess(false), 3000); + if (changedPassword) { + // Stays on screen: the user needs to know their other devices were signed out. + setSuccess('Password changed. Your other sessions have been signed out.'); + } else { + setSuccess('Profile updated successfully.'); + setTimeout(() => setSuccess(null), 3000); + } } catch { setErrors(['An unexpected error occurred.']); } finally { @@ -104,7 +118,7 @@ export default function ProfilePage() { {success && (
- Profile updated successfully. + {success}
)} @@ -113,7 +127,10 @@ export default function ProfilePage() { {/* Basic info */}
-
-
-