diff --git a/apps/api/.env.example b/apps/api/.env.example index de0b5f4..6485511 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -53,3 +53,44 @@ LOG_LEVEL=info # Default: 30 requests per 60 seconds. RATE_LIMIT_MAX=30 RATE_LIMIT_WINDOW_MS=60000 + +# --- Auth (OAuth 2.1 + sessions) --------------------------------------------- +# Auth is implemented behind the REQUIRE_AUTH toggle. While REQUIRE_AUTH=false +# the API boots without the crypto/SMTP vars and the auth endpoints return 503. +# When REQUIRE_AUTH=true, the following are required at boot or the server +# exits with a clear error: JWT_SIGNING_KEY, JWT_PUBLIC_KEY, GOOGLE_CLIENT_ID, +# GOOGLE_CLIENT_SECRET, AUTH_STATE_SECRET, SMTP_HOST, SMTP_USER, SMTP_PASS. +# See docs/superpowers/specs/2026-05-17-auth-design.md. + +# Master switch. Set to true to require auth on mutation endpoints (e.g. POST /new). +# Leave false during the grace-period rollout. +REQUIRE_AUTH=false + +# Ed25519 JWT keys for the access-token signer. Base64url-encoded DER bytes +# (generate with `openssl genpkey -algorithm ed25519`). Required when REQUIRE_AUTH=true. +JWT_SIGNING_KEY= +JWT_PUBLIC_KEY= + +# Google OAuth 2.0 credentials (https://console.cloud.google.com/apis/credentials). +# Required when REQUIRE_AUTH=true. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Defaults to {PUBLIC_URL}/oauth/callback/google when unset. +GOOGLE_REDIRECT_URI= + +# HMAC key for OAuth state JWTs (e.g. `openssl rand -base64 32`). +# Required when REQUIRE_AUTH=true. +AUTH_STATE_SECRET= + +# Token lifetimes (defaults match the auth spec). +SESSION_MAX_AGE_DAYS=30 +REFRESH_TOKEN_MAX_DAYS=90 +ACCESS_TOKEN_TTL_SECONDS=3600 + +# SMTP for sending magic-link emails. Required when REQUIRE_AUTH=true (except +# SMTP_PORT and SMTP_FROM, which have defaults). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@pagent.link diff --git a/apps/api/app.test.ts b/apps/api/app.test.ts index be5f920..6b70e9a 100644 --- a/apps/api/app.test.ts +++ b/apps/api/app.test.ts @@ -16,6 +16,11 @@ vi.mock('./db.ts', () => ({ deletePage: vi.fn(() => Promise.resolve()), deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })), ping: vi.fn().mockResolvedValue(undefined), + // Session lookup powers the cookie auth path; default to "no session" so + // every test stays anonymous unless it opts in by overriding the mock. + getSessionWithUserByTokenHash: vi.fn(() => Promise.resolve(null)), + extendSessionExpiry: vi.fn(() => Promise.resolve()), + deleteSessionByTokenHash: vi.fn(() => Promise.resolve()), })); import * as db from './db.ts'; @@ -722,6 +727,86 @@ describe('OpenAPI surface', () => { // Error message field // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// owner_id propagation on POST /new +// --------------------------------------------------------------------------- +// Verifies that an authenticated POST /new (via session cookie) propagates +// the user's UUID onto db.insertPage(page.ownerId), and that an anonymous +// POST /new (grace period) leaves ownerId = null. The Bearer path mirrors +// the cookie path through resolveAuth() — covered by the middleware tests +// in apps/api/auth/middleware.test.ts. + +describe('POST /new owner_id propagation', () => { + // Matches what the auth session uses internally: SHA-256(raw token) hex. + // We don't bother computing it here — the mock matches any token by always + // returning the same row. + const FAKE_USER_ID = '11111111-2222-3333-4444-555555555555'; + + function authedRequest(method: string, path: string, body?: unknown): Request { + return new Request(`${BASE}${path}`, { + method, + headers: { + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + // Raw cookie token — the auth session module hashes it before lookup; + // the mocked getSessionWithUserByTokenHash returns a hit regardless. + Cookie: 'pagent_session=raw-cookie-token-value', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + it('passes ownerId = user.id to db.insertPage when authenticated via cookie', async () => { + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValueOnce({ + session_id: 'session-uuid', + user_id: FAKE_USER_ID, + email: 'alice@example.com', + handle: 'alice', + expires_at: new Date(Date.now() + 86_400_000), + }); + const res = await app.fetch(authedRequest('POST', '/new', { spec: { anything: 1 } })); + expect(res.status).toBe(201); + expect(db.insertPage).toHaveBeenCalledOnce(); + const [calledPage] = vi.mocked(db.insertPage).mock.calls[0]; + expect(calledPage.ownerId).toBe(FAKE_USER_ID); + }); + + it('passes ownerId = null to db.insertPage on anonymous POST /new (grace period)', async () => { + // Default mock returns null — no session → c.var.user is null → ownerId null. + const res = await app.fetch(req('POST', '/new', { spec: { anything: 1 } })); + expect(res.status).toBe(201); + expect(db.insertPage).toHaveBeenCalledOnce(); + const [calledPage] = vi.mocked(db.insertPage).mock.calls[0]; + expect(calledPage.ownerId).toBeNull(); + }); + + it('passes ownerId on format=html pages too', async () => { + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValueOnce({ + session_id: 'session-uuid', + user_id: FAKE_USER_ID, + email: 'alice@example.com', + handle: 'alice', + expires_at: new Date(Date.now() + 86_400_000), + }); + const res = await app.fetch( + authedRequest('POST', '/new', { format: 'html', spec: '

hi

' }), + ); + expect(res.status).toBe(201); + expect(db.insertPage).toHaveBeenCalledOnce(); + const [calledPage] = vi.mocked(db.insertPage).mock.calls[0]; + expect(calledPage.format).toBe('html'); + expect(calledPage.ownerId).toBe(FAKE_USER_ID); + }); + + it('anonymous format=html POST /new also lands with ownerId = null', async () => { + const res = await app.fetch(req('POST', '/new', { format: 'html', spec: '

hi

' })); + expect(res.status).toBe(201); + expect(db.insertPage).toHaveBeenCalledOnce(); + const [calledPage] = vi.mocked(db.insertPage).mock.calls[0]; + expect(calledPage.format).toBe('html'); + expect(calledPage.ownerId).toBeNull(); + }); +}); + describe('error message field', () => { it('500 body includes non-empty message field', async () => { (db.getActivePage as ReturnType).mockRejectedValueOnce(new Error('boom')); diff --git a/apps/api/app.ts b/apps/api/app.ts index ff24d48..0437e03 100644 --- a/apps/api/app.ts +++ b/apps/api/app.ts @@ -18,6 +18,9 @@ import { logger } from './logger.ts'; import { metrics, statusClassFor } from './metrics.ts'; import type { RequestIdVariables } from './request-id.ts'; import { requestId, getLog, getRequestId } from './request-id.ts'; +import { authRoutes } from './auth/routes.ts'; +import type { AuthVariables } from './auth/middleware.ts'; +import { resolveAuth, requireAuth } from './auth/middleware.ts'; // --- OpenAPI spec (loaded once at boot, served from memory) ------------------ @@ -68,7 +71,7 @@ const newPageLimiter = rateLimiter({ // --- App --------------------------------------------------------------------- -export const app = new Hono<{ Variables: RequestIdVariables }>(); +export const app = new Hono<{ Variables: RequestIdVariables & AuthVariables }>(); app.use('*', requestId()); app.use( '*', @@ -201,6 +204,39 @@ app.get( }), ); +// --- Auth resolution --------------------------------------------------------- +// Populate c.var.user from the session cookie or Bearer JWT on EVERY route. +// Never short-circuits — `requireAuth()` is the gatekeeper for protected +// endpoints. Mounted before any route handler so subsequent middlewares +// (e.g. requireAuth on POST /new) can read c.var.user. + +app.use('*', resolveAuth()); + +// --- Auth / OAuth discovery -------------------------------------------------- +// Mounts the three .well-known endpoints (AS metadata, protected resource +// metadata, JWKS). Mounted at root so the literal RFC-defined paths land +// where MCP clients expect them. No auth required. + +app.route('/', authRoutes); + +/** + * No-op or 401-gating middleware, chosen at module load based on the + * REQUIRE_AUTH env var. Centralizing the branch here means route declarations + * stay clean and the grace-period behavior (REQUIRE_AUTH=false → no rejection) + * is the boring path. + * + * When REQUIRE_AUTH=false: passes through. POST /new still gets c.var.user + * populated by resolveAuth, but anonymous requests succeed (matches the spec's + * phased rollout — see §8.2 "Grace period"). + * When REQUIRE_AUTH=true: returns 401 for anonymous requests on protected + * routes (§8.3). + */ +const requireAuthIfEnabled: ReturnType = env.REQUIRE_AUTH + ? requireAuth() + : async (_c, next) => { + await next(); + }; + // --- Route handlers ---------------------------------------------------------- const newPageHandler = async (c: Context) => { @@ -236,11 +272,17 @@ const newPageHandler = async (c: Context) => { } } + // Authenticated user id flows from resolveAuth() (cookie or Bearer JWT) onto + // c.var.user. Null during the grace period; the row goes in with + // owner_id = NULL. When REQUIRE_AUTH=true, requireAuthIfEnabled has already + // rejected anonymous requests with 401 — so this read is non-null in that path. + const ownerId = c.var.user?.id ?? null; + if (format === 'html') { try { const created = await store.createHtmlPage( spec as string, - { publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS }, + { publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS, ownerId }, getLog(c), ); return c.json(created, 201); @@ -262,6 +304,7 @@ const newPageHandler = async (c: Context) => { const created = await store.createPage(spec, format, { publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS, + ownerId, }); return c.json(created, 201); }; @@ -351,7 +394,9 @@ const getResultHandler = async (c: Context) => { // --- Routes ------------------------------------------------------------------ -app.post('/new', newPageLimiter, newPageHandler); +// POST /new — gated by requireAuth when REQUIRE_AUTH=true; otherwise the +// requireAuthIfEnabled middleware is a no-op pass-through. +app.post('/new', requireAuthIfEnabled, newPageLimiter, newPageHandler); app.get('/:id', getPageHandler); app.post('/:id/result', submitResultHandler); app.get('/:id/result', getResultHandler); diff --git a/apps/api/auth/clients-store.test.ts b/apps/api/auth/clients-store.test.ts new file mode 100644 index 0000000..bde6c1c --- /dev/null +++ b/apps/api/auth/clients-store.test.ts @@ -0,0 +1,246 @@ +/** + * OAuth dynamic client registration store tests. + * + * Unit tests for the validation + mapping layer. The DB layer (db.ts) is + * mocked so we can exercise registerClient/getClient without a live Postgres. + * The route-level tests (POST /oauth/register, including rate limit) live in + * routes.test.ts where the full Hono app is available. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../db.ts', () => ({ + insertOAuthClient: vi.fn(), + getOAuthClientById: vi.fn(), +})); + +import * as db from '../db.ts'; +import { registerClient, getClient, InvalidClientMetadataError } from './clients-store.ts'; + +type Row = Awaited>; + +const NOW = new Date('2026-05-17T12:00:00Z'); + +/** Build a row shaped like what db.insertOAuthClient returns. */ +function row(overrides: Partial = {}): Row { + return { + client_id: 'a1b2c3d4-e5f6-4321-9876-abcdef012345', + client_secret: null, + client_secret_expires_at: null, + client_id_issued_at: NOW, + client_name: null, + client_uri: null, + logo_uri: null, + redirect_uris: ['http://localhost:9876/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: null, + token_endpoint_auth_method: 'none', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// registerClient — happy path +// --------------------------------------------------------------------------- + +describe('registerClient', () => { + it('inserts the client and returns OAuthClientInformationFull', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce(row({ client_name: 'Claude Code' })); + + const result = await registerClient({ + redirect_uris: ['http://localhost:9876/callback'], + client_name: 'Claude Code', + }); + + expect(result.client_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(result.client_name).toBe('Claude Code'); + expect(result.redirect_uris).toEqual(['http://localhost:9876/callback']); + expect(result.token_endpoint_auth_method).toBe('none'); + expect(result.client_id_issued_at).toBe(Math.floor(NOW.getTime() / 1000)); + // No client_secret for public clients. + expect(result.client_secret).toBeUndefined(); + expect(result.client_secret_expires_at).toBeUndefined(); + }); + + it('defaults grant_types and response_types when caller omits them', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce(row()); + + await registerClient({ redirect_uris: ['http://localhost:9876/callback'] }); + + expect(db.insertOAuthClient).toHaveBeenCalledTimes(1); + const arg = vi.mocked(db.insertOAuthClient).mock.calls[0]![0]; + expect(arg.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(arg.response_types).toEqual(['code']); + expect(arg.token_endpoint_auth_method).toBe('none'); + }); + + it('passes through caller-provided grant_types/response_types', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce( + row({ grant_types: ['refresh_token'], response_types: ['code'] }), + ); + + await registerClient({ + redirect_uris: ['http://localhost:9876/callback'], + grant_types: ['refresh_token'], + response_types: ['code'], + }); + + const arg = vi.mocked(db.insertOAuthClient).mock.calls[0]![0]; + expect(arg.grant_types).toEqual(['refresh_token']); + expect(arg.response_types).toEqual(['code']); + }); + + it('client_id is a fresh UUID per call', async () => { + vi.mocked(db.insertOAuthClient).mockImplementation(async (input) => + row({ client_id: input.client_id }), + ); + + const a = await registerClient({ redirect_uris: ['http://localhost/cb'] }); + const b = await registerClient({ redirect_uris: ['http://localhost/cb'] }); + + expect(a.client_id).not.toBe(b.client_id); + }); + + it('client_id_issued_at is Unix seconds, not milliseconds or ISO', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce(row()); + const result = await registerClient({ + redirect_uris: ['http://localhost:9876/callback'], + }); + expect(typeof result.client_id_issued_at).toBe('number'); + expect(result.client_id_issued_at).toBe(Math.floor(NOW.getTime() / 1000)); + // Sanity: Unix seconds for 2026-05-17 is ~1.78e9, not ms (~1.78e12). + expect(result.client_id_issued_at).toBeLessThan(2_000_000_000); + }); +}); + +// --------------------------------------------------------------------------- +// registerClient — validation failures +// --------------------------------------------------------------------------- + +describe('registerClient validation', () => { + it('rejects request body that is not an object', async () => { + await expect(registerClient(null)).rejects.toBeInstanceOf(InvalidClientMetadataError); + await expect(registerClient('string')).rejects.toBeInstanceOf(InvalidClientMetadataError); + await expect(registerClient(42)).rejects.toBeInstanceOf(InvalidClientMetadataError); + expect(db.insertOAuthClient).not.toHaveBeenCalled(); + }); + + it('rejects missing redirect_uris', async () => { + await expect(registerClient({})).rejects.toThrow(/redirect_uris/); + expect(db.insertOAuthClient).not.toHaveBeenCalled(); + }); + + it('rejects redirect_uris that is not an array', async () => { + await expect(registerClient({ redirect_uris: 'http://localhost/cb' })).rejects.toThrow( + /redirect_uris/, + ); + }); + + it('rejects empty redirect_uris array', async () => { + await expect(registerClient({ redirect_uris: [] })).rejects.toThrow(/non-empty/); + }); + + it('rejects redirect_uris with invalid URI', async () => { + await expect(registerClient({ redirect_uris: ['not a uri'] })).rejects.toBeInstanceOf( + InvalidClientMetadataError, + ); + await expect(registerClient({ redirect_uris: ['http://ok/cb', ''] })).rejects.toBeInstanceOf( + InvalidClientMetadataError, + ); + await expect( + registerClient({ redirect_uris: ['http://ok/cb', null as unknown as string] }), + ).rejects.toBeInstanceOf(InvalidClientMetadataError); + }); + + it('accepts custom URI schemes (MCP clients commonly use myapp:// etc.)', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce( + row({ redirect_uris: ['myapp://callback'] }), + ); + await expect(registerClient({ redirect_uris: ['myapp://callback'] })).resolves.toBeDefined(); + }); + + it('rejects non-array grant_types', async () => { + await expect( + registerClient({ + redirect_uris: ['http://localhost/cb'], + grant_types: 'authorization_code', + }), + ).rejects.toThrow(/grant_types/); + }); + + it('rejects non-string entries in grant_types', async () => { + await expect( + registerClient({ + redirect_uris: ['http://localhost/cb'], + grant_types: [42], + }), + ).rejects.toThrow(/grant_types/); + }); + + it('rejects non-string client_name', async () => { + await expect( + registerClient({ + redirect_uris: ['http://localhost/cb'], + client_name: 42, + }), + ).rejects.toThrow(/client_name/); + }); +}); + +// --------------------------------------------------------------------------- +// getClient +// --------------------------------------------------------------------------- + +describe('getClient', () => { + it('returns the registered client', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(row({ client_name: 'Claude Code' })); + + const result = await getClient('a1b2c3d4-e5f6-4321-9876-abcdef012345'); + + expect(result).toBeDefined(); + expect(result!.client_id).toBe('a1b2c3d4-e5f6-4321-9876-abcdef012345'); + expect(result!.client_name).toBe('Claude Code'); + expect(result!.redirect_uris).toEqual(['http://localhost:9876/callback']); + }); + + it('returns undefined for unknown client_id', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(null); + + const result = await getClient('not-a-real-id'); + + expect(result).toBeUndefined(); + }); + + it('exposes client_secret + expiry when row has them (confidential clients)', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce( + row({ + client_secret: 'shhh', + client_secret_expires_at: new Date('2027-01-01T00:00:00Z'), + }), + ); + + const result = await getClient('a-confidential-client'); + + expect(result!.client_secret).toBe('shhh'); + expect(result!.client_secret_expires_at).toBe( + Math.floor(new Date('2027-01-01T00:00:00Z').getTime() / 1000), + ); + }); + + it('omits null scalar fields from the result (no scope, no client_name)', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(row()); + + const result = await getClient('x'); + + expect(result!.client_name).toBeUndefined(); + expect(result!.client_uri).toBeUndefined(); + expect(result!.logo_uri).toBeUndefined(); + expect(result!.scope).toBeUndefined(); + }); +}); diff --git a/apps/api/auth/clients-store.ts b/apps/api/auth/clients-store.ts new file mode 100644 index 0000000..a250b5b --- /dev/null +++ b/apps/api/auth/clients-store.ts @@ -0,0 +1,221 @@ +/** + * OAuth dynamic client registration store (RFC 7591). + * + * Implements the MCP SDK's `OAuthRegisteredClientsStore` interface — the same + * contract `mcpAuthRouter` consumes. Pagent's MCP clients are public (no + * client_secret), so registration only writes metadata + a fresh UUID client + * ID. Validation enforces RFC 7591 §2's required `redirect_uris` field; everything + * else gets sensible OAuth 2.1 defaults (S256 / code / authorization_code). + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §3.3, §10 (MCP SDK + * mapping table). + */ +import { randomUUID } from 'node:crypto'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import * as db from '../db.ts'; + +// --- Defaults --------------------------------------------------------------- +// OAuth 2.1 / RFC 7591 defaults that match the AS metadata advertised at +// /.well-known/oauth-authorization-server. If those advertised values change, +// keep them in sync here — the AS metadata is the source of truth. + +const DEFAULT_GRANT_TYPES = ['authorization_code', 'refresh_token'] as const; +const DEFAULT_RESPONSE_TYPES = ['code'] as const; +// MCP clients are public — they cannot keep a secret. `none` opts them out of +// client authentication entirely; PKCE substitutes for the missing secret. +const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = 'none'; + +// --- Errors ----------------------------------------------------------------- + +/** + * Thrown when the client metadata fails RFC 7591 validation. The route layer + * catches this and returns 400 `invalid_client_metadata` with the description + * surfaced to the client. + */ +export class InvalidClientMetadataError extends Error { + constructor(public readonly description: string) { + super(description); + this.name = 'InvalidClientMetadataError'; + } +} + +// --- Helpers ---------------------------------------------------------------- + +/** + * Validates a redirect URI per RFC 7591 §2: each MUST be a valid URI. We use + * the URL constructor for parsing — it accepts any absolute URI with a + * scheme, including custom schemes (e.g. `myapp://callback`) which native + * MCP clients commonly use. URIs without a scheme (relative paths, bare + * identifiers) are rejected. + */ +function isValidRedirectUri(uri: unknown): uri is string { + if (typeof uri !== 'string' || uri.length === 0) return false; + try { + // `new URL(uri)` throws on relative URIs (no scheme) and on syntactically + // malformed values. That's exactly the discriminator RFC 7591 calls for. + new URL(uri); + return true; + } catch { + return false; + } +} + +/** + * Validates `redirect_uris` and returns the canonical array. Per RFC 7591 + * §2 this field is required for clients using the `authorization_code` or + * `implicit` grants — which is every client we accept. We additionally + * enforce non-empty (RFC 7591 leaves array semantics implementation-defined, + * but a zero-element array is meaningless for the authorization code flow). + */ +function validateRedirectUris(input: unknown): string[] { + if (!Array.isArray(input)) { + throw new InvalidClientMetadataError( + "'redirect_uris' is required and must be an array of URIs", + ); + } + if (input.length === 0) { + throw new InvalidClientMetadataError("'redirect_uris' must be a non-empty array"); + } + for (const uri of input) { + if (!isValidRedirectUri(uri)) { + throw new InvalidClientMetadataError( + `'redirect_uris' contains an invalid URI: ${typeof uri === 'string' ? uri : typeof uri}`, + ); + } + } + return input as string[]; +} + +/** + * Optional string-array fields (`grant_types`, `response_types`). Falls back + * to the provided default if absent. Rejects non-arrays so callers can't + * sneak in malformed values that would later confuse the token endpoint. + */ +function validateOptionalStringArray( + input: unknown, + field: string, + fallback: readonly string[], +): string[] { + if (input === undefined || input === null) return [...fallback]; + if (!Array.isArray(input)) { + throw new InvalidClientMetadataError(`'${field}' must be an array of strings`); + } + for (const v of input) { + if (typeof v !== 'string' || v.length === 0) { + throw new InvalidClientMetadataError(`'${field}' must be an array of non-empty strings`); + } + } + return input as string[]; +} + +/** + * Optional string fields (`client_name`, `client_uri`, `logo_uri`, `scope`, + * `token_endpoint_auth_method`). Returns null on absent/blank input so + * postgres-js binds SQL NULL rather than the literal "". + */ +function optionalString(input: unknown, field: string): string | null { + if (input === undefined || input === null) return null; + if (typeof input !== 'string') { + throw new InvalidClientMetadataError(`'${field}' must be a string`); + } + return input.length === 0 ? null : input; +} + +/** + * Map a DB row back to the SDK's `OAuthClientInformationFull` shape. + * + * Postgres returns timestamptz as a JS Date; the wire format expects + * `client_id_issued_at` as Unix *seconds* (RFC 7591 §3.2.1, §3). Drops NULL + * scalar fields so the JSON serialization omits them (matches RFC 7591 + * example payloads, which only include explicitly set metadata). + */ +function rowToClientInformation(row: db.OAuthClientRow): OAuthClientInformationFull { + const out: OAuthClientInformationFull = { + client_id: row.client_id, + client_id_issued_at: Math.floor(row.client_id_issued_at.getTime() / 1000), + redirect_uris: row.redirect_uris, + grant_types: row.grant_types, + response_types: row.response_types, + token_endpoint_auth_method: row.token_endpoint_auth_method, + }; + if (row.client_secret !== null) { + out.client_secret = row.client_secret; + if (row.client_secret_expires_at !== null) { + out.client_secret_expires_at = Math.floor(row.client_secret_expires_at.getTime() / 1000); + } + } + if (row.client_name !== null) out.client_name = row.client_name; + if (row.client_uri !== null) out.client_uri = row.client_uri; + if (row.logo_uri !== null) out.logo_uri = row.logo_uri; + if (row.scope !== null) out.scope = row.scope; + return out; +} + +// --- Public API ------------------------------------------------------------- + +/** + * Register a new OAuth client per RFC 7591. The MCP SDK's interface signature + * takes `Omit`, + * but real-world callers (including the dynamic-registration endpoint) pass + * arbitrary JSON. We accept `unknown` here and do the validation in-house; + * the route layer is responsible for the 400 response shape. + * + * Returns the canonical `OAuthClientInformationFull` echo per RFC 7591 §3.2. + */ +export async function registerClient(metadata: unknown): Promise { + if (typeof metadata !== 'object' || metadata === null) { + throw new InvalidClientMetadataError('request body must be a JSON object'); + } + const m = metadata as Record; + + // RFC 7591 §2 — redirect_uris is required for our grants. The other fields + // are optional with sensible defaults applied below. + const redirect_uris = validateRedirectUris(m.redirect_uris); + const grant_types = validateOptionalStringArray( + m.grant_types, + 'grant_types', + DEFAULT_GRANT_TYPES, + ); + const response_types = validateOptionalStringArray( + m.response_types, + 'response_types', + DEFAULT_RESPONSE_TYPES, + ); + + const token_endpoint_auth_method = + optionalString(m.token_endpoint_auth_method, 'token_endpoint_auth_method') ?? + DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD; + + const client_name = optionalString(m.client_name, 'client_name'); + const client_uri = optionalString(m.client_uri, 'client_uri'); + const logo_uri = optionalString(m.logo_uri, 'logo_uri'); + const scope = optionalString(m.scope, 'scope'); + + // Use crypto.randomUUID — 122 bits of entropy is plenty for a public + // identifier and matches the format spec'd in §3.3 ("a1b2c3d4-e5f6-..."). + const client_id = randomUUID(); + + const row = await db.insertOAuthClient({ + client_id, + client_name, + client_uri, + logo_uri, + redirect_uris, + grant_types, + response_types, + scope, + token_endpoint_auth_method, + }); + return rowToClientInformation(row); +} + +/** + * Fetch a registered client by ID. Returns `undefined` (not `null`) when + * absent — that's the contract `OAuthRegisteredClientsStore.getClient` + * expects, and `mcpAuthRouter` treats undefined as "unknown client" → 401. + */ +export async function getClient(clientId: string): Promise { + const row = await db.getOAuthClientById(clientId); + if (!row) return undefined; + return rowToClientInformation(row); +} diff --git a/apps/api/auth/google.test.ts b/apps/api/auth/google.test.ts new file mode 100644 index 0000000..499d370 --- /dev/null +++ b/apps/api/auth/google.test.ts @@ -0,0 +1,672 @@ +/** + * Google OAuth helpers + state JWT + login flow tests. + * + * Covers: + * - buildGoogleAuthUrl: URL shape, scopes, encoded state + * - signStateJwt / verifyStateJwt: round-trip, tampering, expiry + * - renderLoginPage: HTML validity, XSS escaping + * - GET /oauth/authorize: validates client_id / redirect_uri / PKCE + * - GET /oauth/callback/google: exchange + upsert + auth code + redirect + * + * The Google token endpoint is mocked via `vi.spyOn(global, 'fetch')`, and the + * db layer is mocked the same way clients-store.test.ts does so we can run + * without a live Postgres. + */ +import { generateKeyPairSync, type KeyObject } from 'node:crypto'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportJWK, SignJWT } from 'jose'; + +// Mocking db.ts has to happen BEFORE we import anything that transitively +// pulls it in (app.ts, routes.ts). Every function the routes touch is +// stubbed; the auth-code happy path checks the calls per-test. +vi.mock('../db.ts', () => ({ + init: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(() => Promise.resolve()), + insertPage: vi.fn(() => Promise.resolve()), + getActivePage: vi.fn(() => Promise.resolve(null)), + submitPage: vi.fn(() => Promise.resolve({ kind: 'not_found' })), + fetchAndAdvanceResult: vi.fn(() => Promise.resolve(null)), + deletePage: vi.fn(() => Promise.resolve()), + deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })), + ping: vi.fn().mockResolvedValue(undefined), + insertOAuthClient: vi.fn(), + getOAuthClientById: vi.fn(), + upsertUser: vi.fn(), + getUserByHandle: vi.fn(), + insertAuthCode: vi.fn(), +})); + +import * as db from '../db.ts'; +import { env } from '../schemas.ts'; +import { app } from '../app.ts'; +import { initKeys } from './jwt.ts'; +import { buildGoogleAuthUrl } from './google.ts'; +import { renderLoginPage } from './login-page.ts'; +import { signStateJwt, verifyStateJwt } from './state-jwt.ts'; +import { sanitizeHandle, generateUniqueHandle, upsertUser, createAuthCode } from './provider.ts'; + +const BASE = 'http://localhost'; + +// --- Setup ------------------------------------------------------------------- +// Env vars need to be set on the parsed env object — the schema parse already +// ran at module import. Mutating the in-memory copy is what jwt.test.ts does +// to flip PUBLIC_URL; same pattern here for the auth secrets. + +beforeAll(async () => { + // Ed25519 keys for the access-token signer (consumed by the well-known JWKS + // route — not exercised here but required so app.ts boots cleanly). + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + await initKeys( + privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'), + publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + ); + // Set the auth secrets in-memory. The schema's optional() means they default + // to undefined; tests need them populated to exercise the signing path. + (env as { GOOGLE_CLIENT_ID: string | undefined }).GOOGLE_CLIENT_ID = 'test-google-client-id'; + (env as { GOOGLE_CLIENT_SECRET: string | undefined }).GOOGLE_CLIENT_SECRET = + 'test-google-client-secret'; + (env as { GOOGLE_REDIRECT_URI: string | undefined }).GOOGLE_REDIRECT_URI = + 'http://localhost/oauth/callback/google'; + (env as { AUTH_STATE_SECRET: string | undefined }).AUTH_STATE_SECRET = + 'test-auth-state-secret-very-long-random-value-32-bytes'; + + // RSA-2048 key pair used to sign mock Google ID tokens. The matching + // public JWK is served by the fetch spy in `mockGoogleTokenResponse` + // when the SUT calls Google's JWKS endpoint. + googleKeyPair = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const publicJwk = await exportJWK(googleKeyPair.publicKey); + googleJwks = { + keys: [{ ...publicJwk, alg: 'RS256', use: 'sig', kid: GOOGLE_KID }], + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// buildGoogleAuthUrl +// --------------------------------------------------------------------------- + +describe('buildGoogleAuthUrl', () => { + it('returns a Google auth URL with all required parameters', () => { + const url = buildGoogleAuthUrl('signed-state-jwt'); + expect(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?')).toBe(true); + const parsed = new URL(url); + expect(parsed.searchParams.get('client_id')).toBe('test-google-client-id'); + expect(parsed.searchParams.get('redirect_uri')).toBe('http://localhost/oauth/callback/google'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('scope')).toBe('openid email profile'); + expect(parsed.searchParams.get('state')).toBe('signed-state-jwt'); + }); + + it('throws when GOOGLE_CLIENT_ID is not configured', () => { + const original = env.GOOGLE_CLIENT_ID; + (env as { GOOGLE_CLIENT_ID: string | undefined }).GOOGLE_CLIENT_ID = undefined; + try { + expect(() => buildGoogleAuthUrl('x')).toThrow(/GOOGLE_CLIENT_ID/); + } finally { + (env as { GOOGLE_CLIENT_ID: string | undefined }).GOOGLE_CLIENT_ID = original; + } + }); +}); + +// --------------------------------------------------------------------------- +// signStateJwt / verifyStateJwt +// --------------------------------------------------------------------------- + +describe('state JWT', () => { + it('round-trip preserves every claim', async () => { + const claims = { + clientId: 'mcp-cli', + redirectUri: 'http://localhost:9876/cb', + codeChallenge: 'abc', + scope: 'page:create page:read', + state: 'csrf-state-from-client', + }; + const token = await signStateJwt(claims); + const decoded = await verifyStateJwt(token); + expect(decoded).toEqual(claims); + }); + + it('round-trip preserves browser_session flag', async () => { + const token = await signStateJwt({ browserSession: true }); + const decoded = await verifyStateJwt(token); + expect(decoded.browserSession).toBe(true); + expect(decoded.clientId).toBeUndefined(); + }); + + it('rejects a tampered token (modified payload, original signature)', async () => { + const token = await signStateJwt({ clientId: 'mcp-cli', redirectUri: 'http://x' }); + // Surgically replace the middle segment with a payload that claims a + // different redirect_uri but keep the original signature — the HMAC + // verify must reject. + const [h, _p, s] = token.split('.'); + const evil = Buffer.from( + JSON.stringify({ + client_id: 'mcp-cli', + redirect_uri: 'http://attacker', + iss: 'pagent:oauth:state', + }), + ).toString('base64url'); + const tampered = `${h}.${evil}.${s}`; + await expect(verifyStateJwt(tampered)).rejects.toThrow(); + }); + + it('rejects an expired token', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const token = await signStateJwt({ clientId: 'mcp-cli' }); + // State JWT TTL is 15 minutes; jump 30 minutes ahead. + vi.setSystemTime(new Date('2026-01-01T00:30:00Z')); + await expect(verifyStateJwt(token)).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects a token signed under a different secret', async () => { + const token = await signStateJwt({ clientId: 'mcp-cli' }); + const original = env.AUTH_STATE_SECRET; + (env as { AUTH_STATE_SECRET: string | undefined }).AUTH_STATE_SECRET = 'different-secret'; + try { + await expect(verifyStateJwt(token)).rejects.toThrow(); + } finally { + (env as { AUTH_STATE_SECRET: string | undefined }).AUTH_STATE_SECRET = original; + } + }); +}); + +// --------------------------------------------------------------------------- +// renderLoginPage +// --------------------------------------------------------------------------- + +describe('renderLoginPage', () => { + it('renders a complete HTML document with the Google link and email form', async () => { + const state = await signStateJwt({ clientId: 'mcp-cli', redirectUri: 'http://x' }); + const html = renderLoginPage({ signedState: state }); + expect(html).toContain(''); + expect(html).toContain('Sign in to Pagent'); + expect(html).toContain('Continue with Google'); + expect(html).toContain('accounts.google.com/o/oauth2/v2/auth'); + expect(html).toContain('
{ + const html = renderLoginPage({ error: '' }); + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); + }); + + it('omits the buttons when no signedState is supplied (hard error)', () => { + const html = renderLoginPage({ error: 'Unknown client_id' }); + expect(html).not.toContain('Continue with Google'); + expect(html).not.toContain(' { + it('lowercases and strips non-alphanumeric chars', () => { + expect(sanitizeHandle('Alex.Netto')).toBe('alexnetto'); + expect(sanitizeHandle('Alex_NETTO+work')).toBe('alexnettowork'); + }); + + it('pads short locals with "user"', () => { + expect(sanitizeHandle('a')).toBe('auser'); + expect(sanitizeHandle('')).toBe('user'); + }); + + it('truncates locals over 40 chars', () => { + const long = 'a'.repeat(60); + const out = sanitizeHandle(long); + expect(out.length).toBeLessThanOrEqual(40); + expect(out).toBe('a'.repeat(40)); + }); + + it('strips leading and trailing dashes', () => { + expect(sanitizeHandle('-alex-')).toBe('alex'); + expect(sanitizeHandle('---')).toBe('user'); + }); +}); + +describe('generateUniqueHandle', () => { + it('returns the base when not taken', async () => { + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + const h = await generateUniqueHandle('alex'); + expect(h).toBe('alex'); + }); + + it('appends a numeric suffix on collision', async () => { + // First two lookups return existing users; the third (alex3) is free. + vi.mocked(db.getUserByHandle) + .mockResolvedValueOnce({ id: '1' } as never) + .mockResolvedValueOnce({ id: '2' } as never) + .mockResolvedValueOnce(null); + const h = await generateUniqueHandle('alex'); + expect(h).toBe('alex3'); + }); +}); + +// --------------------------------------------------------------------------- +// upsertUser +// --------------------------------------------------------------------------- + +describe('upsertUser', () => { + it('generates a handle from the email local part and forwards to db.upsertUser', async () => { + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: 'user-uuid', + handle: 'alex', + email: 'alex@blockful.io', + name: 'Alex', + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }); + + const u = await upsertUser({ + email: 'alex@blockful.io', + name: 'Alex', + }); + + expect(u.id).toBe('user-uuid'); + expect(db.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'alex@blockful.io', + name: 'Alex', + avatarUrl: null, + handle: 'alex', + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// createAuthCode +// --------------------------------------------------------------------------- + +describe('createAuthCode', () => { + it('inserts a code with 10-minute expiry and forwards every field to db', async () => { + vi.mocked(db.insertAuthCode).mockResolvedValue(); + const before = Date.now(); + const code = await createAuthCode( + 'user-uuid', + 'client-id', + 'http://localhost:9876/cb', + 'challenge', + 'S256', + 'page:create', + ); + expect(code).toBeTruthy(); + // base64url is /^[A-Za-z0-9_-]+$/ — 32 bytes yields 43 chars. + expect(code).toMatch(/^[A-Za-z0-9_-]+$/); + expect(db.insertAuthCode).toHaveBeenCalledTimes(1); + const arg = vi.mocked(db.insertAuthCode).mock.calls[0]![0]; + expect(arg.code).toBe(code); + expect(arg.userId).toBe('user-uuid'); + expect(arg.clientId).toBe('client-id'); + expect(arg.redirectUri).toBe('http://localhost:9876/cb'); + expect(arg.codeChallenge).toBe('challenge'); + expect(arg.codeChallengeMethod).toBe('S256'); + expect(arg.scope).toBe('page:create'); + // expiresAt should be ~10 minutes from now. Allow generous tolerance to + // avoid clock-tick flake. + const ttlMs = arg.expiresAt.getTime() - before; + expect(ttlMs).toBeGreaterThanOrEqual(10 * 60 * 1000 - 100); + expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 100); + }); +}); + +// --------------------------------------------------------------------------- +// GET /oauth/authorize +// --------------------------------------------------------------------------- + +function authorizeUrl(params: Record): string { + return `${BASE}/oauth/authorize?${new URLSearchParams(params).toString()}`; +} + +// Standard valid params used as a baseline; individual tests override fields. +const VALID_AUTHORIZE = { + client_id: 'a1b2c3d4-e5f6-4321-9876-abcdef012345', + redirect_uri: 'http://localhost:9876/callback', + code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + code_challenge_method: 'S256', + scope: 'page:create page:read', + state: 'mcp-client-csrf-state', +} as const; + +const clientRow = { + client_id: VALID_AUTHORIZE.client_id, + client_secret: null, + client_secret_expires_at: null, + client_id_issued_at: new Date('2026-05-17T12:00:00Z'), + client_name: 'Claude Code', + client_uri: null, + logo_uri: null, + redirect_uris: [VALID_AUTHORIZE.redirect_uri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: null, + token_endpoint_auth_method: 'none', +}; + +describe('GET /oauth/authorize', () => { + it('renders the login page for valid parameters (200, text/html)', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(clientRow); + + const res = await app.fetch(new Request(authorizeUrl(VALID_AUTHORIZE))); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain(''); + expect(html).toContain('Continue with Google'); + expect(html).toContain('accounts.google.com'); + }); + + it('renders an error (not redirect) for invalid client_id', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(null); + + const res = await app.fetch( + new Request(authorizeUrl({ ...VALID_AUTHORIZE, client_id: 'nonexistent' })), + ); + + expect(res.status).toBe(400); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Unknown client_id'); + // Hard error → no Google button (user can't proceed). + expect(html).not.toContain('Continue with Google'); + }); + + it('renders an error for mismatched redirect_uri', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(clientRow); + + const res = await app.fetch( + new Request( + authorizeUrl({ ...VALID_AUTHORIZE, redirect_uri: 'http://attacker.example.com/cb' }), + ), + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain('redirect_uri does not match'); + }); + + it('renders an error when code_challenge is missing', async () => { + const params: Record = { ...VALID_AUTHORIZE }; + delete (params as { code_challenge?: string }).code_challenge; + const res = await app.fetch(new Request(authorizeUrl(params))); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain('code_challenge'); + }); + + it('renders an error for code_challenge_method=plain', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(clientRow); + const res = await app.fetch( + new Request(authorizeUrl({ ...VALID_AUTHORIZE, code_challenge_method: 'plain' })), + ); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain('S256'); + }); + + it('renders the login page for browser_session=1 without other params', async () => { + const res = await app.fetch(new Request(`${BASE}/oauth/authorize?browser_session=1`)); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('Continue with Google'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /oauth/callback/google +// --------------------------------------------------------------------------- +// Mocks both the Google token endpoint and the Google JWKS endpoint via +// vi.spyOn(global, 'fetch'). The ID token is signed with a real RSA key +// (generated per test run) and the JWKS endpoint serves the matching +// public key so `jose.jwtVerify` accepts the signature. + +const GOOGLE_KID = 'test-google-kid'; + +// RSA key pair + matching JWKS, populated in the top-level beforeAll. RS256 +// is what Google actually uses for ID tokens; staying on the same alg means +// the verification path here mirrors production. +let googleKeyPair: { publicKey: KeyObject; privateKey: KeyObject }; +let googleJwks: { keys: unknown[] }; + +async function makeSignedIdToken( + claims: Record, + overrides: { iss?: string; aud?: string; expSeconds?: number } = {}, +): Promise { + const issuer = overrides.iss ?? 'https://accounts.google.com'; + const audience = overrides.aud ?? 'test-google-client-id'; + const ttl = overrides.expSeconds ?? 600; + return await new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID, typ: 'JWT' }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime(`${ttl}s`) + .sign(googleKeyPair.privateKey); +} + +/** + * Install a fetch spy that serves Google's token endpoint (returns the + * supplied id_token claims) and Google's JWKS endpoint (returns the public + * key matching makeSignedIdToken). Caller MUST restore the spy. + */ +async function mockGoogleTokenResponse( + idTokenClaims: Record, + options: { iss?: string; aud?: string; expSeconds?: number } = {}, +) { + const idToken = await makeSignedIdToken(idTokenClaims, options); + const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(async (input) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes('oauth2.googleapis.com/token')) { + return new Response(JSON.stringify({ id_token: idToken, access_token: 'g-at' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('googleapis.com/oauth2/v3/certs')) { + return new Response(JSON.stringify(googleJwks), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected fetch in test: ${url}`); + }); + return fetchSpy; +} + +describe('GET /oauth/callback/google', () => { + it('exchanges the code, upserts the user, and redirects with code+state', async () => { + const fetchSpy = await mockGoogleTokenResponse({ + sub: 'google-sub-123', + email: 'alex@blockful.io', + name: 'Alex Netto', + picture: 'https://lh3.googleusercontent.com/abc', + }); + + vi.mocked(db.getOAuthClientById).mockResolvedValue(clientRow); + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: '11111111-2222-3333-4444-555555555555', + handle: 'alex', + email: 'alex@blockful.io', + name: 'Alex Netto', + avatar_url: 'https://lh3.googleusercontent.com/abc', + created_at: new Date(), + updated_at: new Date(), + }); + vi.mocked(db.insertAuthCode).mockResolvedValue(); + + const state = await signStateJwt({ + clientId: VALID_AUTHORIZE.client_id, + redirectUri: VALID_AUTHORIZE.redirect_uri, + codeChallenge: VALID_AUTHORIZE.code_challenge, + scope: VALID_AUTHORIZE.scope, + state: VALID_AUTHORIZE.state, + }); + const res = await app.fetch( + new Request( + `${BASE}/oauth/callback/google?code=google-code&state=${encodeURIComponent(state)}`, + ), + ); + + expect(res.status).toBe(302); + const location = res.headers.get('location')!; + const parsed = new URL(location); + expect(parsed.origin + parsed.pathname).toBe(VALID_AUTHORIZE.redirect_uri); + expect(parsed.searchParams.get('code')).toBeTruthy(); + expect(parsed.searchParams.get('state')).toBe(VALID_AUTHORIZE.state); + + // Verify the call shape into Google's token endpoint. The fetch spy also + // serves /oauth2/v3/certs for the JWKS lookup, so we filter calls by URL + // rather than asserting an exact total — `createRemoteJWKSet` may or may + // not hit the network depending on whether its in-memory cache is warm. + const tokenCall = fetchSpy.mock.calls.find((call) => { + const url = typeof call[0] === 'string' ? call[0] : (call[0] as URL | Request).toString(); + return url.includes('oauth2.googleapis.com/token'); + }); + expect(tokenCall).toBeDefined(); + expect(tokenCall![1]?.method).toBe('POST'); + + // Verify the user upsert was called with Google's profile data. + expect(db.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'alex@blockful.io', + name: 'Alex Netto', + avatarUrl: 'https://lh3.googleusercontent.com/abc', + }), + ); + + // Verify the auth code was inserted with the PKCE challenge. + expect(db.insertAuthCode).toHaveBeenCalledTimes(1); + const authCodeArg = vi.mocked(db.insertAuthCode).mock.calls[0]![0]; + expect(authCodeArg.userId).toBe('11111111-2222-3333-4444-555555555555'); + expect(authCodeArg.clientId).toBe(VALID_AUTHORIZE.client_id); + expect(authCodeArg.redirectUri).toBe(VALID_AUTHORIZE.redirect_uri); + expect(authCodeArg.codeChallenge).toBe(VALID_AUTHORIZE.code_challenge); + expect(authCodeArg.codeChallengeMethod).toBe('S256'); + + fetchSpy.mockRestore(); + }); + + it('rejects a tampered state JWT', async () => { + const state = await signStateJwt({ + clientId: VALID_AUTHORIZE.client_id, + redirectUri: VALID_AUTHORIZE.redirect_uri, + codeChallenge: VALID_AUTHORIZE.code_challenge, + }); + // Flip a character in the signature segment so HMAC verification fails. + const tampered = state.slice(0, -3) + (state.endsWith('AAA') ? 'BBB' : 'AAA'); + const res = await app.fetch( + new Request( + `${BASE}/oauth/callback/google?code=google-code&state=${encodeURIComponent(tampered)}`, + ), + ); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html.toLowerCase()).toContain('expired or invalid'); + }); + + it('rejects callback with missing code', async () => { + const state = await signStateJwt({ clientId: 'x', redirectUri: 'http://x' }); + const res = await app.fetch( + new Request(`${BASE}/oauth/callback/google?state=${encodeURIComponent(state)}`), + ); + expect(res.status).toBe(400); + }); + + it('rejects callback with missing state', async () => { + const res = await app.fetch(new Request(`${BASE}/oauth/callback/google?code=google-code`)); + expect(res.status).toBe(400); + }); + + it('handle collision: appends numeric suffix when base handle is taken', async () => { + await mockGoogleTokenResponse({ + sub: 'google-sub-456', + email: 'alex@another.example', + name: 'Alex Second', + }); + vi.mocked(db.getOAuthClientById).mockResolvedValue(clientRow); + // alex is taken, alex2 is free. + vi.mocked(db.getUserByHandle) + .mockResolvedValueOnce({ + id: 'existing-alex', + handle: 'alex', + email: 'alex@first.example', + name: null, + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }) + .mockResolvedValueOnce(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: 'new-user', + handle: 'alex2', + email: 'alex@another.example', + name: null, + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }); + vi.mocked(db.insertAuthCode).mockResolvedValue(); + + const state = await signStateJwt({ + clientId: VALID_AUTHORIZE.client_id, + redirectUri: VALID_AUTHORIZE.redirect_uri, + codeChallenge: VALID_AUTHORIZE.code_challenge, + }); + const res = await app.fetch( + new Request( + `${BASE}/oauth/callback/google?code=google-code&state=${encodeURIComponent(state)}`, + ), + ); + expect(res.status).toBe(302); + // The upsert call should have received handle="alex2" + expect(db.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ handle: 'alex2' })); + }); +}); + +// --------------------------------------------------------------------------- +// Rate limit on /oauth/authorize +// --------------------------------------------------------------------------- + +describe('GET /oauth/authorize rate limit', () => { + it('rate-limits at 30 per IP per minute (31st request → 429)', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValue(clientRow); + const ip = '198.51.100.42'; + const req = () => + new Request(authorizeUrl(VALID_AUTHORIZE), { + headers: { 'x-forwarded-for': ip }, + }); + + for (let i = 0; i < 30; i++) { + const res = await app.fetch(req()); + expect(res.status, `request ${i + 1} of 30 should be 200`).toBe(200); + } + const limited = await app.fetch(req()); + expect(limited.status).toBe(429); + const body = (await limited.json()) as Record; + expect(body.error).toBe('rate_limited'); + }); +}); diff --git a/apps/api/auth/google.ts b/apps/api/auth/google.ts new file mode 100644 index 0000000..8204fdd --- /dev/null +++ b/apps/api/auth/google.ts @@ -0,0 +1,180 @@ +/** + * Google OAuth 2.0 client — authorize URL builder + code-for-token exchange. + * + * Pagent is a relying party to Google: the user clicks "Continue with Google" + * on our login page, the browser hits Google's consent screen, and on + * approval Google redirects to /oauth/callback/google with an authorization + * code. We exchange that code at https://oauth2.googleapis.com/token for an + * ID token (JWT) carrying the user's `sub`, `email`, `name`, and `picture`. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §4.2 (Google OAuth + * flow), §3.7 (callback endpoint). + */ +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { env } from '../schemas.ts'; + +// Google's documented OAuth 2.0 endpoints. v2/auth is the modern consent +// screen; oauth2.googleapis.com/token is the universal token endpoint. Both +// are stable URLs published in Google's OIDC discovery document. +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +// Google's published JWKS for ID-token verification. createRemoteJWKSet +// caches the keys internally and refreshes on rotation. +const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'; + +// Both spellings are valid `iss` values per Google's OIDC discovery doc. +// Library-level verification rejects any other issuer. +const GOOGLE_ISSUERS = ['https://accounts.google.com', 'accounts.google.com']; + +// Module-scoped JWKS handle. Lazily initialized on first verify so the test +// harness can override the fetch boundary before the cache is built — and +// so module import doesn't perform network I/O. +let googleJwks: ReturnType | null = null; +function getGoogleJwks(): ReturnType { + if (!googleJwks) { + googleJwks = createRemoteJWKSet(new URL(GOOGLE_JWKS_URL)); + } + return googleJwks; +} + +// The three OIDC scopes Pagent needs: `openid` makes Google emit an ID token +// (without it the response is bare oauth without identity claims); `email` +// and `profile` populate the `email`, `name`, `picture` claims we read in +// the callback. We never request offline_access — Pagent doesn't store +// Google refresh tokens. +const GOOGLE_SCOPES = 'openid email profile'; + +/** + * The fields we extract from Google's ID token. Matches the OIDC standard + * claims for the requested scopes (`openid email profile`). + * + * `sub` is Google's stable per-user identifier — never reused, never + * mutates. We don't currently persist it (email is our natural key), but + * a future "link this account to another login method" flow would need it. + */ +export interface GoogleProfile { + sub: string; + email: string; + name?: string; + picture?: string; +} + +/** + * Returns the absolute redirect_uri sent to Google. Defaults to + * `${PUBLIC_URL}/oauth/callback/google` when GOOGLE_REDIRECT_URI is unset + * — matches the .env.example default and keeps dev/prod parity automatic. + */ +function getRedirectUri(): string { + if (env.GOOGLE_REDIRECT_URI) return env.GOOGLE_REDIRECT_URI; + const base = env.PUBLIC_URL ?? `http://localhost:${env.PORT}`; + return `${base}/oauth/callback/google`; +} + +/** + * Build the URL we redirect the user's browser to after they click "Continue + * with Google". Caller passes the state JWT — we don't sign it here so the + * builder stays a pure URL string operation suitable for tests and the + * route handler alike. + * + * Throws if GOOGLE_CLIENT_ID is unset. The auth routes return 503 in that + * case before reaching this builder, but the explicit throw protects against + * a future call site that forgets the env check. + */ +export function buildGoogleAuthUrl(state: string): string { + if (!env.GOOGLE_CLIENT_ID) { + throw new Error( + 'GOOGLE_CLIENT_ID is not configured — Google login unavailable. See docs/superpowers/specs/2026-05-17-auth-design.md §9.', + ); + } + // URLSearchParams emits the canonical x-www-form-urlencoded form Google + // expects (spaces as `+`, no leading `?`). We prepend `?` once at the + // end so the resulting URL is suitable for a `Location` header or + // `` value. + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: getRedirectUri(), + response_type: 'code', + scope: GOOGLE_SCOPES, + state, + // Force consent prompt on first-time users; subsequent logins will be + // silent. `select_account` lets users pick which Google account to use + // when they have multiple signed in. Combination matches what most + // OIDC RPs use. + access_type: 'online', + prompt: 'select_account', + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +/** + * Exchange Google's authorization code for an ID token, then verify it + * against Google's JWKS and return the OIDC profile claims. + * + * Signature verification (not just `decodeJwt`) is required because TLS to + * `oauth2.googleapis.com` only attests that *some* JSON came from Google's + * token endpoint — not that the embedded ID token wasn't replaced or + * tampered with by a misbehaving intermediary. `jwtVerify` against + * `createRemoteJWKSet(googleapis.com/oauth2/v3/certs)` enforces signature, + * issuer, audience, and `exp`; the JWKS is cached internally so we pay one + * remote fetch per key rotation. + */ +export async function exchangeGoogleCode(code: string): Promise { + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new Error( + 'GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are not configured — Google login unavailable.', + ); + } + // Google's token endpoint mandates application/x-www-form-urlencoded. + // URLSearchParams.toString() is the right shape; fetch sets the content-type + // header automatically when the body is a URLSearchParams instance. + const body = new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: getRedirectUri(), + grant_type: 'authorization_code', + }); + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + if (!res.ok) { + // Google returns a JSON error body (e.g. `{ "error": "invalid_grant" }`) + // on auth failures. We surface the status + body text so the route layer + // can log enough detail to debug a misconfigured client without echoing + // it to the end user. + const text = await res.text().catch(() => ''); + throw new Error( + `google token exchange failed: status=${res.status} body=${text.slice(0, 200)}`, + ); + } + const json = (await res.json()) as { id_token?: unknown }; + if (typeof json.id_token !== 'string' || json.id_token.length === 0) { + throw new Error('google token response missing id_token'); + } + // Verify signature against Google's JWKS + issuer/audience. jose rejects + // a bad signature, expired token, or wrong iss/aud with a thrown error; + // we let it propagate to the route layer where it's serialized as a 400. + const { payload } = await jwtVerify(json.id_token, getGoogleJwks(), { + issuer: GOOGLE_ISSUERS, + audience: env.GOOGLE_CLIENT_ID, + }); + if (typeof payload.sub !== 'string' || payload.sub.length === 0) { + throw new Error('google id_token missing sub claim'); + } + if (typeof payload.email !== 'string' || payload.email.length === 0) { + throw new Error('google id_token missing email claim'); + } + const profile: GoogleProfile = { + sub: payload.sub, + email: payload.email, + }; + if (typeof payload.name === 'string' && payload.name.length > 0) { + profile.name = payload.name; + } + if (typeof payload.picture === 'string' && payload.picture.length > 0) { + profile.picture = payload.picture; + } + return profile; +} diff --git a/apps/api/auth/jwt.test.ts b/apps/api/auth/jwt.test.ts new file mode 100644 index 0000000..a8ba540 --- /dev/null +++ b/apps/api/auth/jwt.test.ts @@ -0,0 +1,186 @@ +/** + * JWT module unit tests — pure crypto, no I/O, no DB. + * + * Test keys are generated in-process via node:crypto.generateKeyPairSync and + * fed to initKeys() directly. We never set JWT_SIGNING_KEY / JWT_PUBLIC_KEY + * on process.env — the schema-level env var contract is exercised in + * schemas.test.ts; here we only validate the cryptographic primitives. + */ +import { generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it, beforeAll, afterEach, vi } from 'vitest'; +import { + ALG, + KID, + TYP, + initKeys, + signAccessToken, + verifyAccessToken, + getJwks, + getIssuer, +} from './jwt.ts'; +import { decodeJwt, decodeProtectedHeader } from 'jose'; + +// --- Test setup -------------------------------------------------------------- + +const SAMPLE_CLAIMS = { + sub: '11111111-2222-3333-4444-555555555555', + email: 'alex@blockful.io', + handle: 'alex', + clientId: 'mcp-cli', + scope: 'page:create page:read', +}; + +function generateTestKeyEnv(): { signingKey: string; publicKey: string } { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + return { + signingKey: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'), + publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + }; +} + +beforeAll(async () => { + const { signingKey, publicKey } = generateTestKeyEnv(); + await initKeys(signingKey, publicKey); +}); + +afterEach(() => { + // db.test.ts uses fake timers in its own describe blocks; we follow the + // same hygiene — anything that flips to fake timers must restore real ones. + vi.useRealTimers(); +}); + +// --- Round-trip -------------------------------------------------------------- + +describe('signAccessToken / verifyAccessToken', () => { + it('signs a token whose header is alg=EdDSA, typ=at+jwt, kid=pagent-2026-05', async () => { + const token = await signAccessToken(SAMPLE_CLAIMS); + const header = decodeProtectedHeader(token); + expect(header.alg).toBe(ALG); + expect(header.typ).toBe(TYP); + expect(header.kid).toBe(KID); + }); + + it('round-trip: sign then verify returns every original claim', async () => { + const token = await signAccessToken(SAMPLE_CLAIMS); + const payload = await verifyAccessToken(token); + const issuer = getIssuer(); + expect(payload.iss).toBe(issuer); + expect(payload.aud).toBe(issuer); + expect(payload.sub).toBe(SAMPLE_CLAIMS.sub); + expect(payload.email).toBe(SAMPLE_CLAIMS.email); + expect(payload.handle).toBe(SAMPLE_CLAIMS.handle); + expect(payload.client_id).toBe(SAMPLE_CLAIMS.clientId); + expect(payload.scope).toBe(SAMPLE_CLAIMS.scope); + }); + + it('sets exp to iat + ACCESS_TOKEN_TTL_SECONDS (default 3600)', async () => { + const token = await signAccessToken(SAMPLE_CLAIMS); + const payload = await verifyAccessToken(token); + // Default ACCESS_TOKEN_TTL_SECONDS is 3600 from env schema. Tolerance of + // ±1 second covers the integer-boundary case where iat ticks across a + // second between SignJWT setting iat and reading payload here. + expect(payload.exp - payload.iat).toBe(3600); + }); + + it('generates a unique jti on every call', async () => { + const a = await signAccessToken(SAMPLE_CLAIMS); + const b = await signAccessToken(SAMPLE_CLAIMS); + const pa = decodeJwt(a); + const pb = decodeJwt(b); + expect(pa.jti).toBeDefined(); + expect(pb.jti).toBeDefined(); + expect(pa.jti).not.toBe(pb.jti); + }); + + it('rejects an expired token', async () => { + // Sign at t=0, then jump 2 hours into the future — well past the 1h TTL. + // Fake timers must wrap both calls because SignJWT reads Date.now() for + // iat and jose's verify also reads it for the exp check. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const token = await signAccessToken(SAMPLE_CLAIMS); + vi.setSystemTime(new Date('2026-01-01T02:00:00Z')); + await expect(verifyAccessToken(token)).rejects.toThrow(); + }); + + it('rejects a token with a tampered payload', async () => { + const token = await signAccessToken(SAMPLE_CLAIMS); + // Modify the payload segment: decode, mutate sub, re-encode without + // re-signing. Signature verification must fail. + const [headerB64, , sigB64] = token.split('.'); + const original = decodeJwt(token); + const tamperedPayload = { ...original, sub: '00000000-0000-0000-0000-000000000000' }; + const newPayloadB64 = Buffer.from(JSON.stringify(tamperedPayload)).toString('base64url'); + const tampered = `${headerB64}.${newPayloadB64}.${sigB64}`; + await expect(verifyAccessToken(tampered)).rejects.toThrow(); + }); + + it('rejects a token signed by a different key (wrong signature)', async () => { + // Sign with the live key, swap in a fresh key pair, attempt to verify. + // Exercises the signature-mismatch path directly (the "tampered" test + // covers the payload-mutation case). Tests within this file are + // order-independent, so no need to restore the original key pair. + const goodToken = await signAccessToken(SAMPLE_CLAIMS); + const { signingKey, publicKey } = generateTestKeyEnv(); + await initKeys(signingKey, publicKey); + await expect(verifyAccessToken(goodToken)).rejects.toThrow(); + }); + + it('rejects a token with the wrong issuer', async () => { + // Sign normally, then temporarily override env.PUBLIC_URL by stubbing + // getIssuer. Easier: sign with current iss, then verify after rotating + // env.PUBLIC_URL. Since getIssuer reads env on every call, mutating + // env.PUBLIC_URL changes what verifyAccessToken expects. + const token = await signAccessToken(SAMPLE_CLAIMS); + // Grab the env module to flip PUBLIC_URL — it's a Zod-parsed object so + // we mutate the in-memory copy directly. + const { env } = await import('../schemas.ts'); + const original = env.PUBLIC_URL; + (env as { PUBLIC_URL: string | undefined }).PUBLIC_URL = 'https://impostor.example.com'; + try { + await expect(verifyAccessToken(token)).rejects.toThrow(); + } finally { + (env as { PUBLIC_URL: string | undefined }).PUBLIC_URL = original; + } + }); + + it('rejects a malformed token', async () => { + await expect(verifyAccessToken('not.a.jwt')).rejects.toThrow(); + await expect(verifyAccessToken('')).rejects.toThrow(); + }); +}); + +// --- JWKS -------------------------------------------------------------------- + +describe('getJwks', () => { + it('returns a single Ed25519 OKP key with use=sig and the current kid', () => { + const jwks = getJwks(); + expect(jwks.keys).toHaveLength(1); + const key = jwks.keys[0]; + expect(key.kty).toBe('OKP'); + expect(key.crv).toBe('Ed25519'); + expect(key.use).toBe('sig'); + expect(key.kid).toBe(KID); + // `x` is the base64url-encoded raw public key (32 bytes → 43 base64url chars). + expect(typeof key.x).toBe('string'); + expect(key.x?.length).toBe(43); + // No private-key material should leak in the public JWKS — `d` is the + // Ed25519 seed, must never appear in a public JWK. + expect(key.d).toBeUndefined(); + }); +}); + +// --- Init guards ------------------------------------------------------------- + +describe('initKeys re-init', () => { + it('re-initializing with a new key pair invalidates tokens from the old one', async () => { + const tokenFromOldKey = await signAccessToken(SAMPLE_CLAIMS); + const { signingKey, publicKey } = generateTestKeyEnv(); + await initKeys(signingKey, publicKey); + await expect(verifyAccessToken(tokenFromOldKey)).rejects.toThrow(); + // New token signed under the new key still round-trips. + const tokenFromNewKey = await signAccessToken(SAMPLE_CLAIMS); + const payload = await verifyAccessToken(tokenFromNewKey); + expect(payload.sub).toBe(SAMPLE_CLAIMS.sub); + }); +}); diff --git a/apps/api/auth/jwt.ts b/apps/api/auth/jwt.ts new file mode 100644 index 0000000..d4143ef --- /dev/null +++ b/apps/api/auth/jwt.ts @@ -0,0 +1,201 @@ +/** + * Ed25519 JWT signing, verification, and JWKS serialization. + * + * Pagent acts as both Authorization Server and Resource Server (co-hosted), + * so `iss === aud` and the same key pair signs and verifies. Access tokens + * are self-contained: verification is purely cryptographic and never hits + * the database. The 1-hour TTL is V1's revocation mechanism. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §5. + */ +import { SignJWT, jwtVerify, importPKCS8, importSPKI, exportJWK } from 'jose'; +import type { JWK, JWTPayload } from 'jose'; +import { randomUUID } from 'node:crypto'; +import { env } from '../schemas.ts'; + +// --- Constants --------------------------------------------------------------- + +// Bumped when the signing key is rotated. External JWKS consumers select the +// matching public key by `kid`. +export const KID = 'pagent-2026-05'; +export const ALG = 'EdDSA'; +// `at+jwt` (RFC 9068) distinguishes OAuth 2.0 access tokens from ID tokens +// and other JWT types. Resource servers SHOULD reject tokens without this typ. +export const TYP = 'at+jwt'; + +// --- Module state ------------------------------------------------------------ + +// Loaded once via initKeys(). signAccessToken / verifyAccessToken throw a +// clear error if init wasn't called — better than a cryptic null deref. +let privateKey: CryptoKey | null = null; +let publicKey: CryptoKey | null = null; +let publicJwk: JWK | null = null; + +// --- Types ------------------------------------------------------------------- + +export interface AccessTokenClaims { + sub: string; + email: string; + handle: string; + clientId: string; + scope: string; +} + +// Shape of the verified payload returned by verifyAccessToken. Mirrors spec +// §5.1 — every claim listed there is required on a valid pagent access token. +export interface JwtPayload { + iss: string; + sub: string; + aud: string; + exp: number; + iat: number; + jti: string; + client_id: string; + scope: string; + email: string; + handle: string; +} + +// --- Key initialization ------------------------------------------------------ + +/** + * Decode a base64url string back to its raw bytes. + * + * The signing keys land here as base64url-encoded DER (per the key-gen + * snippet in spec §5.3). We turn them back into a PEM string because that's + * the only format `jose.importPKCS8` / `importSPKI` accept directly. + */ +function base64urlToPem(b64u: string, label: 'PRIVATE KEY' | 'PUBLIC KEY'): string { + const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/'); + // PEM bodies are conventionally line-wrapped at 64 chars. Not strictly + // required by importPKCS8/importSPKI, but matches `openssl`-style output + // and is easier on the eyes if these strings ever surface in logs. + const wrapped = (b64.match(/.{1,64}/g) ?? []).join('\n'); + return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----\n`; +} + +/** + * Import the Ed25519 key pair from base64url-encoded DER strings. + * + * Must be called before signAccessToken / verifyAccessToken / getJwks. + * Idempotent — calling again overwrites the cached keys (useful in tests). + */ +export async function initKeys(signingKeyB64u: string, publicKeyB64u: string): Promise { + const privatePem = base64urlToPem(signingKeyB64u, 'PRIVATE KEY'); + const publicPem = base64urlToPem(publicKeyB64u, 'PUBLIC KEY'); + privateKey = await importPKCS8(privatePem, ALG); + publicKey = await importSPKI(publicPem, ALG); + // exportJWK returns only the structural fields (kty, crv, x). RFC 7517 + // permits `use` and `kid` as additional members — we add them so the + // JWKS endpoint output matches spec §5.3 exactly. + const jwk = await exportJWK(publicKey); + publicJwk = { ...jwk, use: 'sig', kid: KID }; +} + +// --- Issuer / audience derivation ------------------------------------------- + +/** + * Issuer URL — derived from PUBLIC_URL with the same dev fallback as app.ts. + * + * For pagent's co-hosted AS+RS, `iss` and `aud` are identical. Computed on + * every call (rather than cached) so tests that mutate env / app config + * stay observable, and so a future config-reload story doesn't need to + * invalidate this module. + */ +export function getIssuer(): string { + return env.PUBLIC_URL ?? `http://localhost:${env.PORT}`; +} + +// --- Signing ----------------------------------------------------------------- + +/** + * Sign an access token for the given user/client. + * + * Sets every claim required by spec §5.1: iss, sub, aud, exp, iat, jti, + * client_id, scope, email, handle. Lifetime is ACCESS_TOKEN_TTL_SECONDS + * from env (default 3600s). + */ +export async function signAccessToken(claims: AccessTokenClaims): Promise { + if (!privateKey) { + throw new Error('JWT signing key not initialized — call initKeys() at boot'); + } + const issuer = getIssuer(); + // SignJWT sets `iat` automatically via setIssuedAt(); setExpirationTime + // accepts a relative duration and computes `exp` from the same `iat`. + return await new SignJWT({ + client_id: claims.clientId, + scope: claims.scope, + email: claims.email, + handle: claims.handle, + }) + .setProtectedHeader({ alg: ALG, typ: TYP, kid: KID }) + .setSubject(claims.sub) + .setIssuer(issuer) + .setAudience(issuer) + .setIssuedAt() + .setExpirationTime(`${env.ACCESS_TOKEN_TTL_SECONDS}s`) + .setJti(randomUUID()) + .sign(privateKey); +} + +// --- Verification ------------------------------------------------------------ + +/** + * Verify an access token's signature and standard claims. + * + * Throws on bad signature, expired token, or wrong iss/aud. No DB round-trip. + * Returns the decoded payload, narrowed to JwtPayload after we confirm the + * pagent-specific claims (jti, client_id, scope, email, handle) are present. + */ +export async function verifyAccessToken(token: string): Promise { + if (!publicKey) { + throw new Error('JWT public key not initialized — call initKeys() at boot'); + } + const issuer = getIssuer(); + // jose's jwtVerify checks signature, `exp` (against current time with a + // small clock-skew tolerance), `nbf`, and the issuer/audience options. + // It does NOT validate the `typ` header for us — that's a callers' + // concern; we set it on sign but don't gate on it here (RFC 9068 §4 + // requires RS-side typ enforcement, but pagent's verifier is only ever + // called on its own tokens, so the `iss` check is already enough). + const { payload } = await jwtVerify(token, publicKey, { + issuer, + audience: issuer, + algorithms: [ALG], + }); + assertPagentClaims(payload); + return payload; +} + +function assertPagentClaims(payload: JWTPayload): asserts payload is JwtPayload & JWTPayload { + // jose guarantees iss/sub/aud/exp/iat presence via setX() if they were set + // at sign time, but a *foreign* JWT (signed elsewhere, accidentally trusted + // by a misconfigured verifier) could lack the pagent-specific claims. + // Tight validation here means downstream code can read these as required. + const required = ['sub', 'iss', 'aud', 'exp', 'iat', 'jti'] as const; + for (const k of required) { + if (payload[k] === undefined) throw new Error(`missing required claim: ${k}`); + } + const stringClaims = ['client_id', 'scope', 'email', 'handle'] as const; + for (const k of stringClaims) { + if (typeof payload[k] !== 'string') { + throw new Error(`missing or non-string claim: ${k}`); + } + } +} + +// --- JWKS -------------------------------------------------------------------- + +/** + * Return the public key as a JWKS document, ready for GET /.well-known/jwks.json. + * + * Format matches spec §5.3: a single Ed25519 OKP key with use=sig and the + * current kid. Cached at initKeys() time — calling this is a synchronous + * object spread. + */ +export function getJwks(): { keys: JWK[] } { + if (!publicJwk) { + throw new Error('JWT public key not initialized — call initKeys() at boot'); + } + return { keys: [publicJwk] }; +} diff --git a/apps/api/auth/login-page.ts b/apps/api/auth/login-page.ts new file mode 100644 index 0000000..e60567e --- /dev/null +++ b/apps/api/auth/login-page.ts @@ -0,0 +1,185 @@ +/** + * Server-rendered HTML login page for `GET /oauth/authorize`. + * + * No JS framework, no client-side JS at all — just a static HTML document + * with two affordances: a "Continue with Google" link (already pointing at + * Google's consent screen) and a magic-link email form. The page is + * intentionally minimal so it loads instantly and is easy to audit for XSS. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §3.4 (login page). + */ +import { buildGoogleAuthUrl } from './google.ts'; + +export interface LoginPageParams { + /** Signed state JWT carrying the authorize-request context. Embedded both + * in the Google link's `state` query and in the magic-link form's hidden + * field so either path can resume the flow. May be undefined when the + * page is rendered for a hard error (no valid authorize request). */ + signedState?: string; + /** Optional user-facing error message. Renders as a styled banner above + * the buttons. Already escaped before display — callers pass plain text. */ + error?: string; +} + +/** + * Escape the five HTML special chars that can break out of attribute or + * text contexts. Used on every server-provided value before interpolation + * — `error` strings, the state JWT (purely defensive — the JWT charset is + * already URL-safe), etc. + */ +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => { + switch (c) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return c; + } + }); +} + +/** + * Render the login page HTML. Returns a complete document including the + * , , and — caller passes the result straight to + * `c.html(...)`. + * + * Three render modes: + * 1. Normal: `signedState` set → both buttons functional. + * 2. Error with state: `error` + `signedState` set → banner + functional + * buttons (user can retry; some errors are transient). + * 3. Hard error: `error` set, no `signedState` → banner only, no buttons. + * Used when the authorize request itself was invalid (unknown client_id, + * mismatched redirect_uri) so there's nothing to resume. + */ +export function renderLoginPage(params: LoginPageParams): string { + const { signedState, error } = params; + + // Pre-compute the Google href so a missing GOOGLE_CLIENT_ID throws at + // render time (the routes layer converts that to a 503 before reaching + // here, but this ensures a stray call doesn't emit a broken link). + const googleHref = signedState ? escapeHtml(buildGoogleAuthUrl(signedState)) : null; + + const errorBanner = error + ? ` \n` + : ''; + + const buttons = signedState + ? ` Continue with Google +
or
+ + + + + + +` + : ''; + + // Inline CSS keeps the page self-contained — no external stylesheet means + // no extra request, no FOUC, no CSP gymnastics. Subset of Tailwind-ish + // defaults; explicit pixel values avoid the system-fonts UA reset surprises. + return ` + + + + + + Sign in to Pagent + + + +
+

Sign in to Pagent

+${errorBanner}${buttons}
+ +`; +} diff --git a/apps/api/auth/magic-link.test.ts b/apps/api/auth/magic-link.test.ts new file mode 100644 index 0000000..d132acf --- /dev/null +++ b/apps/api/auth/magic-link.test.ts @@ -0,0 +1,561 @@ +/** + * Magic Link flow tests — unit (sendMagicLink / verifyMagicLink round-trip, + * expired/consumed token rejection) and integration via the Hono app + * (POST /oauth/magic/send rate limit, 503 when SMTP unset, GET /oauth/magic + * redirect happy path, anti-enumeration response shape). + * + * Strategy: + * - db.ts is fully mocked (same shape as google.test.ts) so we don't open + * a Postgres connection. Per-test mocks of `insertMagicLink` and + * `verifyAndConsumeMagicLink` let us simulate every state of the row. + * - nodemailer.createTransport is spied on so we can assert sendMail + * arguments without making a real SMTP connection. + * - env vars (SMTP_HOST, AUTH_STATE_SECRET, PUBLIC_URL) are mutated on the + * parsed env object — same trick google.test.ts uses. + */ +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock db.ts BEFORE app.ts is imported so the auth routes wire up against +// the stubs. Every method the routes touch (including the inherited Google +// callback path) gets a vi.fn() — tests configure per-case. +vi.mock('../db.ts', () => ({ + init: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(() => Promise.resolve()), + insertPage: vi.fn(() => Promise.resolve()), + getActivePage: vi.fn(() => Promise.resolve(null)), + submitPage: vi.fn(() => Promise.resolve({ kind: 'not_found' })), + fetchAndAdvanceResult: vi.fn(() => Promise.resolve(null)), + deletePage: vi.fn(() => Promise.resolve()), + deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })), + ping: vi.fn().mockResolvedValue(undefined), + insertOAuthClient: vi.fn(), + getOAuthClientById: vi.fn(), + upsertUser: vi.fn(), + getUserByHandle: vi.fn(), + insertAuthCode: vi.fn(), + insertMagicLink: vi.fn(), + verifyAndConsumeMagicLink: vi.fn(), +})); + +// Mock nodemailer's default export so sendMagicLink doesn't dial a real +// SMTP server. The mock transport tracks sendMail calls so we can assert +// the message contents (to, from, subject, link URL). +// +// Type the parameter as `Record` (mirrors the subset of +// nodemailer's `SendMailOptions` we actually populate) so call assertions +// don't need an explicit cast on every access. +const mockSendMail = vi.fn(async (_message: Record) => ({ + messageId: 'test-message-id', +})); +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn(() => ({ sendMail: mockSendMail })), + }, +})); + +import * as db from '../db.ts'; +import { env } from '../schemas.ts'; +import { app } from '../app.ts'; +import { initKeys } from './jwt.ts'; +import { + InvalidMagicLinkError, + SmtpUnavailableError, + createTransport, + sendMagicLink, + verifyMagicLink, +} from './magic-link.ts'; +import { magicSendLimiter } from './routes.ts'; +import { signStateJwt } from './state-jwt.ts'; + +const BASE = 'http://localhost'; + +// SHA-256(token) — matches the hash function in magic-link.ts. Helpers below +// use this so tests can compute the expected DB key without re-implementing +// the algorithm inline. +function sha256Hex(s: string): string { + return createHash('sha256').update(s).digest('hex'); +} + +beforeAll(async () => { + // Ed25519 keys for the JWT signer — required by app.ts boot path even + // though we don't exercise the token endpoint here. + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + await initKeys( + privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'), + publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + ); + // Populate the auth secrets in-memory. envSchema's optional fields default + // to undefined; tests need them set to exercise the signing + email path. + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = 'smtp.test.example'; + (env as { SMTP_USER: string | undefined }).SMTP_USER = 'test-user'; + (env as { SMTP_PASS: string | undefined }).SMTP_PASS = 'test-pass'; + (env as { AUTH_STATE_SECRET: string | undefined }).AUTH_STATE_SECRET = + 'test-auth-state-secret-very-long-random-value-32-bytes'; + (env as { PUBLIC_URL: string | undefined }).PUBLIC_URL = 'http://localhost:8787'; +}); + +beforeEach(() => { + vi.clearAllMocks(); + // Reset the module-level rate limiter so each test starts with a fresh + // bucket. The limiter lives across test files; without this, tests that + // share an email would interfere. + magicSendLimiter.reset(); +}); + +// --------------------------------------------------------------------------- +// createTransport +// --------------------------------------------------------------------------- + +describe('createTransport', () => { + it('returns null when SMTP_HOST is not configured', () => { + const original = env.SMTP_HOST; + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = undefined; + try { + expect(createTransport()).toBeNull(); + } finally { + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = original; + } + }); + + it('returns a transport when SMTP_HOST is set', () => { + expect(createTransport()).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// sendMagicLink / verifyMagicLink (round-trip + failure modes) +// --------------------------------------------------------------------------- + +describe('sendMagicLink', () => { + it('throws SmtpUnavailableError when SMTP_HOST is unset', async () => { + const original = env.SMTP_HOST; + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = undefined; + try { + await expect(sendMagicLink('alex@blockful.io', {})).rejects.toBeInstanceOf( + SmtpUnavailableError, + ); + // No DB write attempted when transport is unavailable. + expect(db.insertMagicLink).not.toHaveBeenCalled(); + } finally { + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = original; + } + }); + + it('inserts a 32-byte token hash with 15-minute expiry and sends an email', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + const before = Date.now(); + const { token } = await sendMagicLink('alex@blockful.io', { + clientId: 'mcp-cli', + redirectUri: 'http://localhost:9876/cb', + }); + + // base64url of 32 bytes = 43 chars, charset [A-Za-z0-9_-]. + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token.length).toBe(43); + + expect(db.insertMagicLink).toHaveBeenCalledTimes(1); + const insertArg = vi.mocked(db.insertMagicLink).mock.calls[0]![0]; + expect(insertArg.email).toBe('alex@blockful.io'); + // The DB sees the hash, never the raw token. + expect(insertArg.tokenHash).toBe(sha256Hex(token)); + expect(insertArg.tokenHash).not.toBe(token); + expect(insertArg.authorizeContext.clientId).toBe('mcp-cli'); + expect(insertArg.authorizeContext.redirectUri).toBe('http://localhost:9876/cb'); + // expiresAt ≈ now + 15 min. Allow generous tolerance to avoid clock-tick flake. + const ttlMs = insertArg.expiresAt.getTime() - before; + expect(ttlMs).toBeGreaterThanOrEqual(15 * 60 * 1000 - 100); + expect(ttlMs).toBeLessThanOrEqual(15 * 60 * 1000 + 100); + + expect(mockSendMail).toHaveBeenCalledTimes(1); + const mailArg = mockSendMail.mock.calls[0]![0]; + expect(mailArg.to).toBe('alex@blockful.io'); + expect(mailArg.from).toBe(env.SMTP_FROM); + expect(mailArg.subject).toBe('Sign in to Pagent'); + // Both text and HTML carry the link. + expect(mailArg.text).toContain(`/oauth/magic?token=${token}`); + expect(mailArg.html).toContain(`/oauth/magic?token=${token}`); + // The token itself appears in the email body — but it must not appear in + // any DB-bound payload (sanity check against accidental logging). + expect(insertArg.tokenHash).not.toContain(token); + }); + + it('writes the row before sending the email (DB failure aborts send)', async () => { + vi.mocked(db.insertMagicLink).mockRejectedValueOnce(new Error('db down')); + await expect(sendMagicLink('alex@blockful.io', {})).rejects.toThrow('db down'); + // Email is never attempted if the DB write fails — otherwise the user + // would receive a link with no row to verify against. + expect(mockSendMail).not.toHaveBeenCalled(); + }); +}); + +describe('verifyMagicLink', () => { + it('round-trips: sendMagicLink token verifies and returns the stored context', async () => { + let captured: Parameters[0] | null = null; + vi.mocked(db.insertMagicLink).mockImplementation(async (input) => { + captured = input; + }); + + const ctx = { + clientId: 'mcp-cli', + redirectUri: 'http://localhost:9876/cb', + codeChallenge: 'challenge-abc', + codeChallengeMethod: 'S256', + scope: 'page:create', + state: 'mcp-csrf', + }; + const { token } = await sendMagicLink('alex@blockful.io', ctx); + expect(captured).not.toBeNull(); + + // Simulate the DB returning the same row on verify (atomic UPDATE). + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce({ + email: 'alex@blockful.io', + authorizeContext: ctx, + }); + const result = await verifyMagicLink(token); + expect(result.email).toBe('alex@blockful.io'); + expect(result.authorizeContext).toEqual(ctx); + + // The verify call used the hash, not the raw token. + expect(db.verifyAndConsumeMagicLink).toHaveBeenCalledWith(sha256Hex(token)); + }); + + it('rejects an unknown / expired / consumed token (DB returns null)', async () => { + // Single failure path: the DB's atomic UPDATE returns no rows for every + // case — unknown hash, expired row, already-consumed row. We don't + // distinguish (would leak verification state) but assert all three. + for (const _ of ['unknown', 'expired', 'consumed']) { + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce(null); + await expect(verifyMagicLink('bogus-token')).rejects.toBeInstanceOf(InvalidMagicLinkError); + } + }); + + it('rejects empty / non-string input without a DB round-trip', async () => { + await expect(verifyMagicLink('')).rejects.toBeInstanceOf(InvalidMagicLinkError); + // The empty-string guard short-circuits the DB call. + expect(db.verifyAndConsumeMagicLink).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// POST /oauth/magic/send +// --------------------------------------------------------------------------- + +function postMagicSend( + body: Record, + opts: { contentType?: 'json' | 'form' } = {}, +): Request { + const headers: Record = {}; + let serialized: string; + if (opts.contentType === 'form' || opts.contentType === undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + serialized = new URLSearchParams(body).toString(); + } else { + headers['Content-Type'] = 'application/json'; + serialized = JSON.stringify(body); + } + return new Request(`${BASE}/oauth/magic/send`, { + method: 'POST', + headers, + body: serialized, + }); +} + +describe('POST /oauth/magic/send', () => { + it('returns 200 with a "check your email" message on valid request', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + const res = await app.fetch( + postMagicSend({ email: 'alex@blockful.io' }, { contentType: 'json' }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.ok).toBe(true); + expect(typeof body.message).toBe('string'); + expect((body.message as string).toLowerCase()).toContain('check your email'); + expect(db.insertMagicLink).toHaveBeenCalledTimes(1); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + it('accepts form-encoded body (the login page default)', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + const res = await app.fetch(postMagicSend({ email: 'alex@blockful.io' })); + expect(res.status).toBe(200); + expect(db.insertMagicLink).toHaveBeenCalledTimes(1); + }); + + it('returns 503 when SMTP_HOST is not configured', async () => { + const original = env.SMTP_HOST; + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = undefined; + try { + const res = await app.fetch(postMagicSend({ email: 'alex@blockful.io' })); + expect(res.status).toBe(503); + const body = (await res.json()) as Record; + expect(body.error).toBe('service_unavailable'); + // No DB write or email attempted. + expect(db.insertMagicLink).not.toHaveBeenCalled(); + expect(mockSendMail).not.toHaveBeenCalled(); + } finally { + (env as { SMTP_HOST: string | undefined }).SMTP_HOST = original; + } + }); + + it('returns 400 invalid_request for malformed email', async () => { + const res = await app.fetch(postMagicSend({ email: 'not-an-email' }, { contentType: 'json' })); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_request'); + expect(db.insertMagicLink).not.toHaveBeenCalled(); + }); + + it('returns 400 invalid_request for missing email', async () => { + const res = await app.fetch(postMagicSend({}, { contentType: 'json' })); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_request'); + }); + + it('anti-enumeration: identical response for existing and non-existing emails', async () => { + // Magic link doubles as sign-up, so the same code path runs for any + // email. Both responses should be byte-for-byte equal except for any + // entropy-bearing fields (none here). We assert the shape, not just the + // status code, to lock the invariant in. + vi.mocked(db.insertMagicLink).mockResolvedValue(); + + const res1 = await app.fetch( + postMagicSend({ email: 'existing@blockful.io' }, { contentType: 'json' }), + ); + const res2 = await app.fetch( + postMagicSend({ email: 'brand-new@example.org' }, { contentType: 'json' }), + ); + expect(res1.status).toBe(res2.status); + expect(await res1.json()).toEqual(await res2.json()); + // Same number of DB writes — sendMagicLink is called for every valid + // email regardless of registration. + expect(db.insertMagicLink).toHaveBeenCalledTimes(2); + }); + + it('rate-limits at 5 / email / 15 min — 6th request → 429', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValue(); + const email = 'limited@blockful.io'; + + for (let i = 0; i < 5; i++) { + const res = await app.fetch(postMagicSend({ email }, { contentType: 'json' })); + expect(res.status, `request ${i + 1} of 5 should succeed`).toBe(200); + } + const limited = await app.fetch(postMagicSend({ email }, { contentType: 'json' })); + expect(limited.status).toBe(429); + const body = (await limited.json()) as Record; + expect(body.error).toBe('rate_limited'); + expect(typeof body.retry_after_seconds).toBe('number'); + expect(limited.headers.get('Retry-After')).toBe(String(body.retry_after_seconds)); + + // Different email still works (per-email bucket). + const other = await app.fetch( + postMagicSend({ email: 'other@blockful.io' }, { contentType: 'json' }), + ); + expect(other.status).toBe(200); + }); + + it('rate-limit key is case-insensitive on email', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValue(); + for (let i = 0; i < 5; i++) { + await app.fetch(postMagicSend({ email: 'CaseTest@Blockful.io' }, { contentType: 'json' })); + } + const limited = await app.fetch( + postMagicSend({ email: 'casetest@blockful.io' }, { contentType: 'json' }), + ); + expect(limited.status).toBe(429); + }); + + it('extracts authorize context from a signed state JWT', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + const state = await signStateJwt({ + clientId: 'mcp-cli', + redirectUri: 'http://localhost:9876/cb', + codeChallenge: 'challenge', + scope: 'page:create', + state: 'mcp-csrf', + }); + + const res = await app.fetch( + postMagicSend({ email: 'alex@blockful.io', state }, { contentType: 'json' }), + ); + expect(res.status).toBe(200); + const arg = vi.mocked(db.insertMagicLink).mock.calls[0]![0]; + expect(arg.authorizeContext.clientId).toBe('mcp-cli'); + expect(arg.authorizeContext.redirectUri).toBe('http://localhost:9876/cb'); + expect(arg.authorizeContext.codeChallenge).toBe('challenge'); + expect(arg.authorizeContext.codeChallengeMethod).toBe('S256'); + expect(arg.authorizeContext.scope).toBe('page:create'); + expect(arg.authorizeContext.state).toBe('mcp-csrf'); + }); + + it('tolerates an invalid state JWT (proceeds with empty context)', async () => { + // An expired or tampered state shouldn't 400 — that would distinguish + // "valid state, unregistered email" from "invalid state" and leak + // enumeration info. We just drop the context and email anyway. + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + const res = await app.fetch( + postMagicSend( + { email: 'alex@blockful.io', state: 'not-a-valid-jwt' }, + { contentType: 'json' }, + ), + ); + expect(res.status).toBe(200); + const arg = vi.mocked(db.insertMagicLink).mock.calls[0]![0]; + expect(arg.authorizeContext).toEqual({}); + }); + + it('lowercases the email before sending and storing', async () => { + vi.mocked(db.insertMagicLink).mockResolvedValueOnce(); + + await app.fetch(postMagicSend({ email: 'Alex@Blockful.IO' }, { contentType: 'json' })); + const arg = vi.mocked(db.insertMagicLink).mock.calls[0]![0]; + expect(arg.email).toBe('alex@blockful.io'); + const mailArg = mockSendMail.mock.calls[0]![0]; + expect(mailArg.to).toBe('alex@blockful.io'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /oauth/magic +// --------------------------------------------------------------------------- + +const clientRow = { + client_id: 'a1b2c3d4-e5f6-4321-9876-abcdef012345', + client_secret: null, + client_secret_expires_at: null, + client_id_issued_at: new Date('2026-05-17T12:00:00Z'), + client_name: 'Claude Code', + client_uri: null, + logo_uri: null, + redirect_uris: ['http://localhost:9876/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: null, + token_endpoint_auth_method: 'none', +}; + +describe('GET /oauth/magic', () => { + it('verifies the token, upserts the user, and redirects with code + state', async () => { + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce({ + email: 'alex@blockful.io', + authorizeContext: { + clientId: clientRow.client_id, + redirectUri: 'http://localhost:9876/callback', + codeChallenge: 'challenge', + codeChallengeMethod: 'S256', + scope: 'page:create', + state: 'mcp-csrf', + }, + }); + vi.mocked(db.getOAuthClientById).mockResolvedValue(clientRow); + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: '11111111-2222-3333-4444-555555555555', + handle: 'alex', + email: 'alex@blockful.io', + name: null, + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }); + vi.mocked(db.insertAuthCode).mockResolvedValue(); + + const res = await app.fetch(new Request(`${BASE}/oauth/magic?token=fake-token`)); + + expect(res.status).toBe(302); + const location = res.headers.get('location')!; + const parsed = new URL(location); + expect(parsed.origin + parsed.pathname).toBe('http://localhost:9876/callback'); + expect(parsed.searchParams.get('code')).toBeTruthy(); + expect(parsed.searchParams.get('state')).toBe('mcp-csrf'); + + // The auth code was inserted with the same PKCE challenge from the + // stored context. + expect(db.insertAuthCode).toHaveBeenCalledTimes(1); + const codeArg = vi.mocked(db.insertAuthCode).mock.calls[0]![0]; + expect(codeArg.userId).toBe('11111111-2222-3333-4444-555555555555'); + expect(codeArg.clientId).toBe(clientRow.client_id); + expect(codeArg.redirectUri).toBe('http://localhost:9876/callback'); + expect(codeArg.codeChallenge).toBe('challenge'); + expect(codeArg.codeChallengeMethod).toBe('S256'); + expect(codeArg.scope).toBe('page:create'); + + // The token was hashed before lookup. + expect(db.verifyAndConsumeMagicLink).toHaveBeenCalledWith(sha256Hex('fake-token')); + }); + + it('renders an error page when the token is missing', async () => { + const res = await app.fetch(new Request(`${BASE}/oauth/magic`)); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html.toLowerCase()).toContain('token'); + expect(db.verifyAndConsumeMagicLink).not.toHaveBeenCalled(); + }); + + it('renders an error page for an unknown / expired / consumed token', async () => { + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce(null); + const res = await app.fetch(new Request(`${BASE}/oauth/magic?token=bogus`)); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html.toLowerCase()).toContain('expired'); + // No user upsert when verification fails. + expect(db.upsertUser).not.toHaveBeenCalled(); + }); + + it('renders an error when the client registration changed after the email was sent', async () => { + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce({ + email: 'alex@blockful.io', + authorizeContext: { + clientId: 'no-longer-registered', + redirectUri: 'http://localhost:9876/callback', + codeChallenge: 'challenge', + codeChallengeMethod: 'S256', + }, + }); + vi.mocked(db.getOAuthClientById).mockResolvedValueOnce(null); + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: 'uid', + handle: 'alex', + email: 'alex@blockful.io', + name: null, + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }); + + const res = await app.fetch(new Request(`${BASE}/oauth/magic?token=t`)); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html.toLowerCase()).toContain('client'); + // The auth code is NOT issued in this case. + expect(db.insertAuthCode).not.toHaveBeenCalled(); + }); + + it('renders an error when the authorize context has no redirect_uri', async () => { + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce({ + email: 'alex@blockful.io', + authorizeContext: {}, + }); + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValue({ + id: 'uid', + handle: 'alex', + email: 'alex@blockful.io', + name: null, + avatar_url: null, + created_at: new Date(), + updated_at: new Date(), + }); + + const res = await app.fetch(new Request(`${BASE}/oauth/magic?token=t`)); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/api/auth/magic-link.ts b/apps/api/auth/magic-link.ts new file mode 100644 index 0000000..f46bdbe --- /dev/null +++ b/apps/api/auth/magic-link.ts @@ -0,0 +1,198 @@ +/** + * Magic Link (passwordless email) generation + verification + email send. + * + * The flow: + * 1. `sendMagicLink(email, ctx)` mints a 32-byte random token, stores its + * SHA-256 hash + the authorize context in `magic_links` with a 15-min + * TTL, and emails the user the URL `${PUBLIC_URL}/oauth/magic?token=`. + * 2. The user clicks the link. `verifyMagicLink(token)` re-hashes the raw + * value, looks the row up, atomically flips `consumed_at`, and returns + * the stored email + context so the route can mint a Pagent auth code + * and 302 the browser back to the MCP client's `redirect_uri`. + * + * Hashing on the way in (`SHA-256(token)`) is the same defense-in-depth + * pattern we use for `refresh_tokens` and `sessions`: a DB leak yields hashes + * that aren't useful without inverting SHA-256. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §3.8, §4.3, §7.6. + */ +import { createHash, randomBytes } from 'node:crypto'; +import nodemailer, { type Transporter } from 'nodemailer'; +import * as db from '../db.ts'; +import { env } from '../schemas.ts'; + +// 32 bytes (256 bits) — matches the auth-code / refresh-token sizing. base64url +// yields 43 url-safe chars, fits trivially in a `mailto:` body or a ``. +const TOKEN_BYTES = 32; + +// 15-minute TTL per spec §2.6. Long enough for a user to switch tabs and read +// the email; short enough that a stolen URL becomes useless quickly. +const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; + +/** + * Hash a raw token to its DB-stored representation. Pure SHA-256 (hex) — no + * salt, no HMAC: the token itself is 256 bits of entropy, salting buys + * nothing, and a leaked HMAC key would expose every hash. Matches the + * `refresh_tokens.token_hash` / `sessions.token_hash` storage strategy. + */ +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Build the absolute magic link URL. We derive the base from PUBLIC_URL so + * dev (localhost:8787) and prod (api.pagent.link) both work without + * per-environment branching. The token is appended raw — URL-safe base64 + * doesn't need percent-encoding. + */ +function buildMagicUrl(token: string): string { + const base = env.PUBLIC_URL ?? `http://localhost:${env.PORT}`; + return `${base}/oauth/magic?token=${token}`; +} + +/** + * Create the nodemailer SMTP transport. Returns null when SMTP_HOST is unset + * so callers can render a 503 instead of trying to send. `secure: true` is + * implied by port 465 (SMTPS); 587 + STARTTLS is the modern default. We + * never call `verify()` here — that would add a round-trip on every cold + * start and most providers (SendGrid, Postmark) reject SMTP probes anyway. + */ +export function createTransport(): Transporter | null { + if (!env.SMTP_HOST) return null; + return nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.SMTP_PORT === 465, + auth: env.SMTP_USER && env.SMTP_PASS ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined, + }); +} + +/** Plain-text email body. Kept identical to the HTML body so screen readers + * and text-only clients see the same instructions. */ +function plainBody(url: string): string { + return [ + `Click this link to sign in to Pagent: ${url}`, + 'This link expires in 15 minutes and can only be used once.', + '', + "If you didn't request this email, you can safely ignore it.", + ].join('\n'); +} + +/** Minimal HTML body — a single paragraph + button-styled link. Inline + * styles so it renders in every email client without a stylesheet. */ +function htmlBody(url: string): string { + return ` + + +

Sign in to Pagent

+

Click the button below to sign in. This link expires in 15 minutes and can only be used once.

+

Sign in to Pagent

+

If the button doesn't work, copy and paste this URL into your browser:
${url}

+

If you didn't request this email, you can safely ignore it.

+ +`; +} + +/** + * Custom error for the route layer to distinguish "SMTP not configured" + * from "SMTP send failed" — the former is a 503 (operator misconfiguration), + * the latter is a 502 / 500 (transient infrastructure). We throw the same + * symbol the routes layer can `instanceof`-check without coupling them to a + * string match on the message. + */ +export class SmtpUnavailableError extends Error { + constructor() { + super('SMTP_HOST is not configured — magic link emails unavailable.'); + this.name = 'SmtpUnavailableError'; + } +} + +/** + * Send a magic link email. Generates a fresh 32-byte token, stores its + * SHA-256 hash + the authorize context, then dispatches the email via + * nodemailer. + * + * Throws SmtpUnavailableError when SMTP_HOST is not set. Other failures + * (DB error, transport error) propagate as-is — the route layer logs them + * via the global error handler and returns 500. + * + * The raw token is returned only for test purposes — production callers + * (the POST /oauth/magic/send route) discard it. The user receives the URL + * via email; never echo the token in the HTTP response. + */ +export async function sendMagicLink( + email: string, + authorizeContext: db.MagicLinkAuthorizeContext, +): Promise<{ token: string }> { + const transport = createTransport(); + if (!transport) throw new SmtpUnavailableError(); + + // base64url is URL-safe (no padding, no `+/=`) so the link works in `` and clipboard pastes without percent-encoding. + const token = randomBytes(TOKEN_BYTES).toString('base64url'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); + + // Insert first, send second. If the insert fails, no email goes out and the + // user can retry. If we sent first and the insert failed, the user would + // get a link that fails verification — worse UX than a transient 500. + await db.insertMagicLink({ + email, + tokenHash, + authorizeContext, + expiresAt, + }); + + const url = buildMagicUrl(token); + await transport.sendMail({ + from: env.SMTP_FROM, + to: email, + subject: 'Sign in to Pagent', + text: plainBody(url), + html: htmlBody(url), + }); + + return { token }; +} + +/** + * Custom error for verifyMagicLink — distinct from a thrown TypeError so the + * route layer can map it to a clean user-facing message. Surfaces the same + * generic text for unknown / expired / consumed tokens; distinguishing them + * would leak whether a token was ever issued for a given email. + */ +export class InvalidMagicLinkError extends Error { + constructor() { + super('Magic link is invalid, expired, or already used.'); + this.name = 'InvalidMagicLinkError'; + } +} + +/** + * Verify a magic link token. Re-hashes the raw value, atomically consumes + * the row (UPDATE ... RETURNING), and returns the email + stored authorize + * context. + * + * Throws InvalidMagicLinkError when the token is unknown / expired / already + * consumed. The route layer renders the login page with a generic error in + * each case — distinguishing them would leak verification state. + */ +export async function verifyMagicLink( + token: string, +): Promise<{ email: string; authorizeContext: db.MagicLinkAuthorizeContext }> { + // Reject the empty string up front — saves a DB round-trip and is the only + // input we can validate without leaking timing info (every other failure + // mode goes through the DB so timing is bounded by the same query). + if (typeof token !== 'string' || token.length === 0) { + throw new InvalidMagicLinkError(); + } + const tokenHash = hashToken(token); + const row = await db.verifyAndConsumeMagicLink(tokenHash); + if (!row) throw new InvalidMagicLinkError(); + return row; +} + +// Re-export the TTL so tests can assert the configured value without +// duplicating the constant. Not exported as `MAGIC_LINK_TTL_MS` to avoid +// shadowing the local constant on import. +export const MAGIC_LINK_TTL_SECONDS = MAGIC_LINK_TTL_MS / 1000; diff --git a/apps/api/auth/middleware.test.ts b/apps/api/auth/middleware.test.ts new file mode 100644 index 0000000..64a3397 --- /dev/null +++ b/apps/api/auth/middleware.test.ts @@ -0,0 +1,281 @@ +/** + * Hono auth middleware unit tests. + * + * Boots a tiny Hono app in-process, mounts `resolveAuth()` on every route, + * and `requireAuth()` on a designated protected route. The session.ts and + * jwt.ts modules are mocked so we can drive the resolution behaviour + * directly: a stub returns a user, the middleware should set c.var.user; + * a stub returns null, the middleware should set c.var.user to null. + * + * The test app deliberately avoids importing app.ts — we want this test + * isolated from the rest of the API surface (otherwise we'd be retesting + * cors, secureHeaders, rate limiting, etc.). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +// Mocks must come before importing the middleware module. +vi.mock('./session.ts', () => ({ + lookupSession: vi.fn(() => Promise.resolve(null)), +})); +vi.mock('./jwt.ts', () => ({ + verifyAccessToken: vi.fn(() => Promise.reject(new Error('not configured'))), +})); + +import { lookupSession } from './session.ts'; +import { verifyAccessToken } from './jwt.ts'; +import { + resolveAuth, + requireAuth, + type AuthUser, + type AuthVariables, + SESSION_COOKIE_NAME, +} from './middleware.ts'; + +const BASE = 'http://localhost'; + +/** + * Build a fresh test app for each case so c.var doesn't leak between tests. + * `/me` echoes c.var.user; `/private` is gated by requireAuth() so a 401 is + * the expected outcome for an anonymous request. + */ +function makeTestApp() { + const app = new Hono<{ Variables: AuthVariables }>(); + app.use('*', resolveAuth()); + app.get('/me', (c) => c.json(c.var.user)); + app.get('/private', requireAuth(), (c) => c.json({ ok: true, user: c.var.user })); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Defaults: every fake returns "not authenticated". + (lookupSession as ReturnType).mockResolvedValue(null); + (verifyAccessToken as ReturnType).mockRejectedValue(new Error('not configured')); +}); + +describe('resolveAuth — cookie path', () => { + it('sets c.var.user from a valid session cookie', async () => { + const fakeUser: AuthUser = { + id: 'user-uuid-1', + email: 'alex@blockful.io', + handle: 'alex', + authMethod: 'cookie', + }; + (lookupSession as ReturnType).mockResolvedValueOnce(fakeUser); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=session-token-1` }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe('user-uuid-1'); + expect(body.authMethod).toBe('cookie'); + expect(lookupSession).toHaveBeenCalledWith('session-token-1'); + // Bearer path should not have been consulted once a cookie resolved. + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it('falls through to anonymous when the session cookie is unknown', async () => { + (lookupSession as ReturnType).mockResolvedValueOnce(null); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=stale-or-expired` }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBeNull(); + }); +}); + +describe('resolveAuth — Bearer path', () => { + it('sets c.var.user from a valid Bearer JWT', async () => { + (verifyAccessToken as ReturnType).mockResolvedValueOnce({ + sub: 'user-uuid-2', + email: 'bob@example.com', + handle: 'bob', + client_id: 'mcp-cli', + scope: 'page:create page:read', + iss: 'http://test.local', + aud: 'http://test.local', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + jti: 'jti-1', + }); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { authorization: 'Bearer valid.jwt.token' }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe('user-uuid-2'); + expect(body.email).toBe('bob@example.com'); + expect(body.handle).toBe('bob'); + expect(body.authMethod).toBe('bearer'); + expect(verifyAccessToken).toHaveBeenCalledWith('valid.jwt.token'); + }); + + it('falls through to anonymous when Bearer JWT verification fails', async () => { + (verifyAccessToken as ReturnType).mockRejectedValueOnce(new Error('expired')); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { authorization: 'Bearer bad.jwt.token' }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBeNull(); + }); + + it('ignores a non-Bearer Authorization header', async () => { + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { authorization: 'Basic dXNlcjpwYXNz' }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBeNull(); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it('ignores an empty Bearer token (just the prefix)', async () => { + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { authorization: 'Bearer ' }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBeNull(); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it('handles null handle in JWT claims (handle is nullable per schema)', async () => { + (verifyAccessToken as ReturnType).mockResolvedValueOnce({ + sub: 'user-uuid-3', + email: 'newbie@example.com', + handle: '', // Empty string from a fresh signup that hasn't picked a handle yet + client_id: 'mcp-cli', + scope: 'page:create', + iss: 'http://test.local', + aud: 'http://test.local', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + jti: 'jti-2', + }); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { authorization: 'Bearer valid.jwt' }, + }), + ); + const body = await res.json(); + expect(body.handle).toBeNull(); + }); +}); + +describe('resolveAuth — anonymous', () => { + it('sets c.var.user to null when no cookie and no Authorization header', async () => { + const app = makeTestApp(); + const res = await app.fetch(new Request(`${BASE}/me`)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toBeNull(); + expect(lookupSession).not.toHaveBeenCalled(); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); +}); + +describe('resolveAuth — priority', () => { + it('cookie takes precedence over Bearer when both are present', async () => { + const fakeUser: AuthUser = { + id: 'user-from-cookie', + email: 'cookie@example.com', + handle: 'cookie-user', + authMethod: 'cookie', + }; + (lookupSession as ReturnType).mockResolvedValueOnce(fakeUser); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/me`, { + headers: { + cookie: `${SESSION_COOKIE_NAME}=valid-session`, + authorization: 'Bearer also.valid.token', + }, + }), + ); + const body = await res.json(); + expect(body.id).toBe('user-from-cookie'); + expect(body.authMethod).toBe('cookie'); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); +}); + +describe('requireAuth', () => { + it('returns 401 with structured JSON when c.var.user is null', async () => { + const app = makeTestApp(); + const res = await app.fetch(new Request(`${BASE}/private`)); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.message).toMatch(/auth/i); + // request_id is populated by the global request-id middleware in app.ts; + // here we mounted middleware in isolation so the field may be undefined. + // Whichever it is, it should not crash the response. + }); + + it('passes through when c.var.user is populated', async () => { + (lookupSession as ReturnType).mockResolvedValueOnce({ + id: 'user-uuid-1', + email: 'alex@blockful.io', + handle: 'alex', + authMethod: 'cookie', + }); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/private`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=valid-token` }, + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.user.id).toBe('user-uuid-1'); + }); + + it('returns 401 when Bearer JWT is invalid', async () => { + (verifyAccessToken as ReturnType).mockRejectedValueOnce(new Error('bad sig')); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/private`, { + headers: { authorization: 'Bearer broken.jwt' }, + }), + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + }); + + it('returns 401 when session cookie is expired (lookup returns null)', async () => { + (lookupSession as ReturnType).mockResolvedValueOnce(null); + const app = makeTestApp(); + const res = await app.fetch( + new Request(`${BASE}/private`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=expired-token` }, + }), + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + }); +}); diff --git a/apps/api/auth/middleware.ts b/apps/api/auth/middleware.ts new file mode 100644 index 0000000..437a91c --- /dev/null +++ b/apps/api/auth/middleware.ts @@ -0,0 +1,162 @@ +/** + * Hono auth middleware — cookie + Bearer JWT resolution. + * + * Two middlewares: + * - `resolveAuth()` runs on every request. Tries the `pagent_session` + * cookie first (browser sessions), then `Authorization: Bearer` + * (API / MCP clients), then sets `c.var.user = null` for anonymous + * requests. NEVER rejects — the caller decides whether to require auth. + * - `requireAuth()` runs only on protected routes. Rejects anonymous + * requests with 401 JSON. + * + * The split lets `c.var.user` be populated for handlers that want to log + * the user / inject `owner_id` (POST /new) but still serve unauthenticated + * read endpoints (GET /:id, GET /:id/result) without bouncing the request. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §6.2. + */ +import type { Context, MiddlewareHandler, Next } from 'hono'; +import { getCookie } from 'hono/cookie'; +import { verifyAccessToken } from './jwt.ts'; +import { lookupSession } from './session.ts'; +import { getRequestId } from '../request-id.ts'; + +// --- Types ------------------------------------------------------------------- + +/** + * The authenticated user shape consumed by downstream handlers. `handle` is + * nullable per the user schema — it's assigned during onboarding (not at + * user creation time), so a brand-new account can be authenticated without + * having picked one yet. + * + * `authMethod` distinguishes the two resolution paths so handlers can + * differentiate browser session from API/MCP client behaviour (e.g. CSRF + * defences only apply on cookie-authenticated state-changing requests). + */ +export type AuthUser = { + id: string; + email: string; + handle: string | null; + authMethod: 'cookie' | 'bearer'; +}; + +/** + * The Hono `Variables` shape this middleware contributes. Composed with + * `RequestIdVariables` in `app.ts` so handlers can read both via `c.var`. + * + * `user` is `AuthUser | null` rather than `AuthUser | undefined` so handlers + * that introspect it via `c.var.user` get a clear "anonymous request" + * signal rather than an "unset" / "middleware didn't run" ambiguity. + */ +export type AuthVariables = { + user: AuthUser | null; +}; + +// --- Cookie name ------------------------------------------------------------- + +/** + * Cookie name for browser sessions. Exported so the login / logout routes + * (and tests) refer to a single source of truth. Spec §6.2 mandates this + * exact name; changing it would invalidate every outstanding session. + */ +export const SESSION_COOKIE_NAME = 'pagent_session'; + +// --- Helpers ----------------------------------------------------------------- + +const BEARER_PREFIX = 'Bearer '; + +/** + * Try to authenticate via the session cookie. Returns the resolved user, or + * `null` if the cookie is missing or doesn't correspond to a live session. + * Always sets `authMethod: 'cookie'` on success. + */ +async function tryCookieAuth(c: Context): Promise { + const sessionToken = getCookie(c, SESSION_COOKIE_NAME); + if (!sessionToken) return null; + const user = await lookupSession(sessionToken); + if (!user) return null; + // lookupSession already sets authMethod: 'cookie', but we re-spread here + // to make the contract explicit at the resolution boundary. + return { ...user, authMethod: 'cookie' }; +} + +/** + * Try to authenticate via the Authorization: Bearer header. Returns the + * resolved user, or `null` if the header is missing / malformed / signature + * fails / claims are expired. We never throw — an invalid Bearer just + * falls through to the anonymous branch (then `requireAuth` decides 401). + */ +async function tryBearerAuth(c: Context): Promise { + const authHeader = c.req.header('authorization'); + if (!authHeader?.startsWith(BEARER_PREFIX)) return null; + const token = authHeader.slice(BEARER_PREFIX.length).trim(); + if (!token) return null; + try { + const claims = await verifyAccessToken(token); + return { + id: claims.sub, + email: claims.email, + handle: claims.handle || null, + authMethod: 'bearer', + }; + } catch { + // Invalid / expired bearer: fall through to anonymous. The route-level + // requireAuth() will turn that into a 401 with our standard shape. + return null; + } +} + +// --- Middleware -------------------------------------------------------------- + +/** + * Populate `c.var.user` from cookie or Bearer. Never short-circuits. + * + * Tried in priority order: + * 1. `pagent_session` cookie (browser sessions, sliding expiry) + * 2. `Authorization: Bearer ` (MCP / API clients) + * 3. Anonymous → `c.var.user = null` + * + * Cookie takes precedence because the renderer (which holds the session + * cookie) is the only client that would also accidentally send a stale + * Bearer; honoring the cookie there matches the spec's "browser identity" + * intent. + */ +export function resolveAuth(): MiddlewareHandler<{ Variables: AuthVariables }> { + return async (c, next) => { + const cookieUser = await tryCookieAuth(c); + if (cookieUser) { + c.set('user', cookieUser); + return next(); + } + const bearerUser = await tryBearerAuth(c); + if (bearerUser) { + c.set('user', bearerUser); + return next(); + } + c.set('user', null); + return next(); + }; +} + +/** + * Reject anonymous requests with a 401 JSON response. Designed to be mounted + * AFTER `resolveAuth()` — depends on `c.var.user` being set (to a user or + * null). The response shape mirrors the other error envelopes in the API + * (`{ error, message, request_id }`) so clients have a single parsing path. + */ +export function requireAuth(): MiddlewareHandler<{ Variables: AuthVariables }> { + return async (c: Context, next: Next) => { + const user = c.get('user') as AuthUser | null | undefined; + if (!user) { + return c.json( + { + error: 'unauthorized', + message: 'Authentication required', + request_id: getRequestId(c), + }, + 401, + ); + } + return next(); + }; +} diff --git a/apps/api/auth/provider.test.ts b/apps/api/auth/provider.test.ts new file mode 100644 index 0000000..955c996 --- /dev/null +++ b/apps/api/auth/provider.test.ts @@ -0,0 +1,519 @@ +/** + * Token endpoint provider tests — pure unit, no live DB. + * + * Strategy mirrors clients-store.test.ts and google.test.ts: + * - db.ts is fully mocked; per-test stubs configure the rows the provider + * would see in production. + * - clients-store.ts is mocked so getClient() can be primed independently + * of the DB stubs. + * - jwt.ts is initialized with a fresh Ed25519 pair in beforeAll so + * signAccessToken() actually mints a token we can decode and assert + * against. We don't mock signAccessToken itself — that would let a bad + * contract slip through. + * + * Covers every case from the Task 07 acceptance criteria: + * 1. authorization_code happy path + * 2. PKCE failure + * 3. Expired auth code + * 4. Already-consumed auth code (replay triggers family revocation) + * 5. refresh_token happy path + * 6. Old refresh token revoked after rotation + * 7. Revoked refresh token replay triggers family revocation + * 8. Unknown client_id → invalid_client + * 9. Token revocation always succeeds (idempotent) + */ +import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock db.ts — every helper the provider touches gets a stub. Type narrows +// happen per-test via vi.mocked(). +vi.mock('../db.ts', () => ({ + init: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(() => Promise.resolve()), + insertPage: vi.fn(() => Promise.resolve()), + getActivePage: vi.fn(() => Promise.resolve(null)), + submitPage: vi.fn(() => Promise.resolve({ kind: 'not_found' })), + fetchAndAdvanceResult: vi.fn(() => Promise.resolve(null)), + deletePage: vi.fn(() => Promise.resolve()), + deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })), + ping: vi.fn().mockResolvedValue(undefined), + insertOAuthClient: vi.fn(), + getOAuthClientById: vi.fn(), + upsertUser: vi.fn(), + getUserByHandle: vi.fn(), + getUserById: vi.fn(), + insertAuthCode: vi.fn(), + consumeAuthCode: vi.fn(), + getAuthCodeForReplay: vi.fn(), + insertRefreshToken: vi.fn(), + getRefreshTokenByHash: vi.fn(), + revokeRefreshToken: vi.fn(), + revokeAllRefreshTokensForFamily: vi.fn(), +})); + +// Mock clients-store.ts — getClient is the only function the provider uses. +// The route layer uses registerClient + getClient; provider only needs the +// latter so a single mock suffices. +vi.mock('./clients-store.ts', () => ({ + getClient: vi.fn(), +})); + +import * as db from '../db.ts'; +import { getClient } from './clients-store.ts'; +import { initKeys, verifyAccessToken } from './jwt.ts'; +import { TokenError, exchangeAuthCode, refreshToken, revokeToken } from './provider.ts'; + +// --- Test setup ------------------------------------------------------------- + +beforeAll(async () => { + // Ed25519 key pair for the JWT signer. signAccessToken throws without + // initKeys; mocking the signer would let a bad claim shape slip through + // so we run the real one against a per-test key. + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + await initKeys( + privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'), + publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + ); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --------------------------------------------------------------- + +/** Compute the S256 PKCE challenge from a verifier. Mirrors the + * client-side computation. */ +function pkceChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +/** SHA-256(hex) — matches the storage hash used by provider.ts. */ +function sha256Hex(s: string): string { + return createHash('sha256').update(s).digest('hex'); +} + +const CLIENT_ID = 'a1b2c3d4-e5f6-4321-9876-abcdef012345'; +const REDIRECT_URI = 'http://localhost:9876/callback'; +const SCOPE = 'page:create page:read'; + +const CLIENT_INFO = { + client_id: CLIENT_ID, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', +}; + +const USER_ROW: db.UserRow = { + id: '11111111-2222-3333-4444-555555555555', + handle: 'alex', + email: 'alex@blockful.io', + name: 'Alex', + avatar_url: null, + created_at: new Date('2026-05-01T00:00:00Z'), + updated_at: new Date('2026-05-01T00:00:00Z'), +}; + +// --------------------------------------------------------------------------- +// authorization_code grant +// --------------------------------------------------------------------------- + +describe('exchangeAuthCode', () => { + it('exchanges a valid code + verifier for a JWT access token + refresh token', async () => { + const verifier = 'test-verifier-string-with-enough-entropy-12345'; + const challenge = pkceChallenge(verifier); + + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce({ + userId: USER_ROW.id, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + scope: SCOPE, + resource: null, + }); + vi.mocked(db.getUserById).mockResolvedValueOnce(USER_ROW); + vi.mocked(db.insertRefreshToken).mockImplementation(async (input) => ({ + id: 'rt-row-id', + user_id: input.userId, + client_id: input.clientId, + token_hash: input.tokenHash, + scope: input.scope, + created_at: new Date(), + expires_at: input.expiresAt, + revoked_at: null, + })); + + const response = await exchangeAuthCode('auth-code-abc', CLIENT_ID, REDIRECT_URI, verifier); + + // TokenResponse shape. + expect(response.token_type).toBe('Bearer'); + expect(response.expires_in).toBe(3600); + expect(response.scope).toBe(SCOPE); + + // Access token is a JWT signed by the Ed25519 key — round-trip via + // verifyAccessToken proves the signature is valid and the claims are + // populated. + const payload = await verifyAccessToken(response.access_token); + expect(payload.sub).toBe(USER_ROW.id); + expect(payload.email).toBe(USER_ROW.email); + expect(payload.handle).toBe(USER_ROW.handle); + expect(payload.client_id).toBe(CLIENT_ID); + expect(payload.scope).toBe(SCOPE); + + // Refresh token format: rt_ + 64 hex chars (32 bytes hex-encoded). + expect(response.refresh_token).toMatch(/^rt_[0-9a-f]{64}$/); + + // The DB row stores the SHA-256 hash, not the raw token. + const insertArg = vi.mocked(db.insertRefreshToken).mock.calls[0]![0]; + expect(insertArg.tokenHash).toBe(sha256Hex(response.refresh_token)); + expect(insertArg.tokenHash).not.toBe(response.refresh_token); + expect(insertArg.userId).toBe(USER_ROW.id); + expect(insertArg.clientId).toBe(CLIENT_ID); + expect(insertArg.scope).toBe(SCOPE); + // 90-day expiry by default (REFRESH_TOKEN_MAX_DAYS). + const ttlMs = insertArg.expiresAt.getTime() - Date.now(); + expect(ttlMs).toBeGreaterThan(89 * 24 * 60 * 60 * 1000); + expect(ttlMs).toBeLessThan(91 * 24 * 60 * 60 * 1000); + }); + + it('rejects invalid PKCE verifier with invalid_grant', async () => { + const verifier = 'correct-verifier-string-with-enough-entropy'; + const challenge = pkceChallenge(verifier); + + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce({ + userId: USER_ROW.id, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + scope: SCOPE, + resource: null, + }); + + await expect( + exchangeAuthCode( + 'auth-code-abc', + CLIENT_ID, + REDIRECT_URI, + // Off-by-one: change a single char so the SHA-256 mismatches. + verifier.slice(0, -1) + 'X', + ), + ).rejects.toMatchObject({ + code: 'invalid_grant', + }); + + // No refresh token issued on PKCE failure. + expect(db.insertRefreshToken).not.toHaveBeenCalled(); + }); + + it('rejects unknown / expired authorization code with invalid_grant', async () => { + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + // consumeAuthCode returns null for unknown / expired / consumed. + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce(null); + // No row exists for the replay disambiguation. + vi.mocked(db.getAuthCodeForReplay).mockResolvedValueOnce(null); + + await expect( + exchangeAuthCode('expired-code', CLIENT_ID, REDIRECT_URI, 'verifier'), + ).rejects.toMatchObject({ + code: 'invalid_grant', + }); + }); + + it('detects auth code replay and revokes the issued refresh-token family', async () => { + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce(null); + // The code exists but has been consumed — classic replay. + vi.mocked(db.getAuthCodeForReplay).mockResolvedValueOnce({ + code: 'replay-code', + user_id: USER_ROW.id, + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + code_challenge: 'irrelevant', + code_challenge_method: 'S256', + scope: SCOPE, + resource: null, + created_at: new Date(Date.now() - 60_000), + expires_at: new Date(Date.now() + 60_000), + consumed_at: new Date(Date.now() - 30_000), + }); + + await expect( + exchangeAuthCode('replay-code', CLIENT_ID, REDIRECT_URI, 'verifier'), + ).rejects.toMatchObject({ code: 'invalid_grant' }); + + // RFC 6749 §4.1.2 SHOULD: revoke any tokens issued from the replayed code. + expect(db.revokeAllRefreshTokensForFamily).toHaveBeenCalledWith(USER_ROW.id, CLIENT_ID); + }); + + it('rejects unknown client_id with invalid_client (401)', async () => { + vi.mocked(getClient).mockResolvedValueOnce(undefined); + + await expect( + exchangeAuthCode('code', 'no-such-client', REDIRECT_URI, 'verifier'), + ).rejects.toMatchObject({ + code: 'invalid_client', + status: 401, + }); + // We don't even reach the DB if the client is unknown. + expect(db.consumeAuthCode).not.toHaveBeenCalled(); + }); + + it('rejects redirect_uri mismatch with invalid_grant', async () => { + const verifier = 'test-verifier-string-with-enough-entropy-12345'; + const challenge = pkceChallenge(verifier); + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce({ + userId: USER_ROW.id, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + scope: SCOPE, + resource: null, + }); + + await expect( + exchangeAuthCode('code', CLIENT_ID, 'http://attacker.example.com/cb', verifier), + ).rejects.toMatchObject({ + code: 'invalid_grant', + }); + }); + + it('rejects missing required parameters with invalid_request', async () => { + await expect(exchangeAuthCode('', CLIENT_ID, REDIRECT_URI, 'verifier')).rejects.toMatchObject({ + code: 'invalid_request', + }); + await expect(exchangeAuthCode('code', '', REDIRECT_URI, 'verifier')).rejects.toMatchObject({ + code: 'invalid_request', + }); + }); +}); + +// --------------------------------------------------------------------------- +// refresh_token grant — rotation + family revocation +// --------------------------------------------------------------------------- + +describe('refreshToken', () => { + it('exchanges a valid refresh token for a new access+refresh pair, revoking the old one', async () => { + const oldRaw = 'rt_' + randomBytes(32).toString('hex'); + const oldRowId = 'rt-row-old'; + + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: oldRowId, + user_id: USER_ROW.id, + client_id: CLIENT_ID, + token_hash: sha256Hex(oldRaw), + scope: SCOPE, + created_at: new Date(Date.now() - 60_000), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + revoked_at: null, + }); + vi.mocked(db.getUserById).mockResolvedValueOnce(USER_ROW); + vi.mocked(db.insertRefreshToken).mockImplementation(async (input) => ({ + id: 'rt-row-new', + user_id: input.userId, + client_id: input.clientId, + token_hash: input.tokenHash, + scope: input.scope, + created_at: new Date(), + expires_at: input.expiresAt, + revoked_at: null, + })); + + const response = await refreshToken(oldRaw, CLIENT_ID); + + // New access token is a JWT. + expect(response.token_type).toBe('Bearer'); + const payload = await verifyAccessToken(response.access_token); + expect(payload.sub).toBe(USER_ROW.id); + + // New refresh token is different and matches the format. + expect(response.refresh_token).toMatch(/^rt_[0-9a-f]{64}$/); + expect(response.refresh_token).not.toBe(oldRaw); + + // The new refresh token was inserted, AND the old one was revoked. + expect(db.insertRefreshToken).toHaveBeenCalledTimes(1); + expect(db.revokeRefreshToken).toHaveBeenCalledWith(oldRowId); + + // The hashed lookup used the SHA-256 of the raw token (defense-in-depth). + expect(db.getRefreshTokenByHash).toHaveBeenCalledWith(sha256Hex(oldRaw)); + + // Family revocation NOT triggered on the happy path. + expect(db.revokeAllRefreshTokensForFamily).not.toHaveBeenCalled(); + }); + + it('revokes the entire token family when a revoked refresh token is replayed', async () => { + const replayedRaw = 'rt_' + randomBytes(32).toString('hex'); + + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-row-revoked', + user_id: USER_ROW.id, + client_id: CLIENT_ID, + token_hash: sha256Hex(replayedRaw), + scope: SCOPE, + created_at: new Date(Date.now() - 120_000), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + // Already revoked — this is the replay signal. + revoked_at: new Date(Date.now() - 60_000), + }); + + await expect(refreshToken(replayedRaw, CLIENT_ID)).rejects.toMatchObject({ + code: 'invalid_grant', + }); + + // The defining behaviour of token family revocation: every active refresh + // for (user_id, client_id) gets killed. + expect(db.revokeAllRefreshTokensForFamily).toHaveBeenCalledWith(USER_ROW.id, CLIENT_ID); + // No new tokens issued on replay. + expect(db.insertRefreshToken).not.toHaveBeenCalled(); + }); + + it('rejects an unknown refresh token with invalid_grant', async () => { + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce(null); + + await expect(refreshToken('rt_unknown', CLIENT_ID)).rejects.toMatchObject({ + code: 'invalid_grant', + }); + // No family revocation when we don't know who the token belongs to. + expect(db.revokeAllRefreshTokensForFamily).not.toHaveBeenCalled(); + }); + + it('rejects an expired refresh token with invalid_grant', async () => { + const raw = 'rt_' + randomBytes(32).toString('hex'); + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-row-expired', + user_id: USER_ROW.id, + client_id: CLIENT_ID, + token_hash: sha256Hex(raw), + scope: SCOPE, + created_at: new Date(Date.now() - 91 * 24 * 60 * 60 * 1000), + // Expired one day ago. + expires_at: new Date(Date.now() - 24 * 60 * 60 * 1000), + revoked_at: null, + }); + + await expect(refreshToken(raw, CLIENT_ID)).rejects.toMatchObject({ + code: 'invalid_grant', + }); + }); + + it('rejects a refresh token bound to a different client_id with invalid_grant', async () => { + const raw = 'rt_' + randomBytes(32).toString('hex'); + vi.mocked(getClient).mockResolvedValueOnce(CLIENT_INFO); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-row-other', + user_id: USER_ROW.id, + // Bound to a DIFFERENT client than the one in the request. + client_id: 'different-client', + token_hash: sha256Hex(raw), + scope: SCOPE, + created_at: new Date(), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + revoked_at: null, + }); + + await expect(refreshToken(raw, CLIENT_ID)).rejects.toMatchObject({ + code: 'invalid_grant', + }); + }); + + it('rejects unknown client_id with invalid_client (401)', async () => { + vi.mocked(getClient).mockResolvedValueOnce(undefined); + await expect(refreshToken('rt_anything', 'unknown')).rejects.toMatchObject({ + code: 'invalid_client', + status: 401, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Token revocation (RFC 7009) +// --------------------------------------------------------------------------- + +describe('revokeToken', () => { + it('marks a refresh token as revoked when it exists and is active', async () => { + const raw = 'rt_' + randomBytes(32).toString('hex'); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-id', + user_id: USER_ROW.id, + client_id: CLIENT_ID, + token_hash: sha256Hex(raw), + scope: SCOPE, + created_at: new Date(), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + revoked_at: null, + }); + + await revokeToken(raw, undefined, undefined); + + expect(db.revokeRefreshToken).toHaveBeenCalledWith('rt-id'); + }); + + it('silently succeeds for an unknown refresh token (no error, no revoke)', async () => { + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce(null); + // Per RFC 7009 the response is the same regardless — the function returns + // void and doesn't throw. + await expect(revokeToken('rt_unknown', undefined, undefined)).resolves.toBeUndefined(); + expect(db.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('does not re-revoke an already-revoked token', async () => { + const raw = 'rt_' + randomBytes(32).toString('hex'); + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-already', + user_id: USER_ROW.id, + client_id: CLIENT_ID, + token_hash: sha256Hex(raw), + scope: SCOPE, + created_at: new Date(), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + revoked_at: new Date(), + }); + + await revokeToken(raw, undefined, undefined); + expect(db.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('is a no-op for non-rt_-prefixed tokens (V1 access tokens have no denylist)', async () => { + // Per RFC 7009 §2.2, this is allowed — the server SHOULD revoke an access + // token if it can, but pagent V1 doesn't index JWTs server-side, so the + // call silently returns. + await revokeToken('eyJhbGciOiJFZERTQSJ9.fake-jwt-payload.fake-signature', undefined, undefined); + expect(db.getRefreshTokenByHash).not.toHaveBeenCalled(); + expect(db.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('silently succeeds for an empty token string', async () => { + await expect(revokeToken('', undefined, undefined)).resolves.toBeUndefined(); + expect(db.getRefreshTokenByHash).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// TokenError shape +// --------------------------------------------------------------------------- + +describe('TokenError', () => { + it('exposes OAuth 2.1 error_response shape: { error, error_description, status }', () => { + const err = new TokenError('invalid_grant', 'PKCE verification failed'); + expect(err.code).toBe('invalid_grant'); + expect(err.description).toBe('PKCE verification failed'); + // Default status for client errors is 400 (RFC 6749 §5.2). + expect(err.status).toBe(400); + }); + + it('uses 401 for invalid_client per RFC 6749 §5.2', () => { + const err = new TokenError('invalid_client', 'Unknown client_id', 401); + expect(err.status).toBe(401); + }); +}); diff --git a/apps/api/auth/provider.ts b/apps/api/auth/provider.ts new file mode 100644 index 0000000..f409a02 --- /dev/null +++ b/apps/api/auth/provider.ts @@ -0,0 +1,473 @@ +/** + * OAuthServerProvider — owns the application-side OAuth flow. + * + * Task 05 introduced the "post-Google-callback" half (turn a Google profile + * into a Pagent user + mint the authorization code). Task 07 fills in the + * token endpoint operations: exchange an authorization code for an + * access+refresh token pair, rotate refresh tokens, and revoke either kind + * on request. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §3.4–§3.6, §5.1–§5.2, + * §7.1. + */ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import * as db from '../db.ts'; +import { logger } from '../logger.ts'; +import { env } from '../schemas.ts'; +import { getClient } from './clients-store.ts'; +import { signAccessToken } from './jwt.ts'; + +// Handles are user-visible (URL slugs, display) so they share the +// constraints we'd apply to any short identifier: lowercase alphanumeric + +// dashes, 3-40 chars, must start/end with alphanumeric. The spec's regex is +// applied on read elsewhere; here we only need to produce a value that +// matches. +const HANDLE_REGEX = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/; + +// 10-minute auth-code TTL per spec §3.4. Enough for the browser redirect + +// the MCP client's POST /oauth/token; longer windows just extend the +// abuse-replay surface for a stolen code. +const AUTH_CODE_TTL_MS = 10 * 60 * 1000; + +// 32 bytes (256 bits) of entropy is overkill for a one-time code with a +// 10-minute window but matches the refresh-token sizing for consistency +// and gives operators a single number to reason about. +const AUTH_CODE_BYTES = 32; + +/** + * Reduce the email local part to handle-shaped characters. + * + * Steps: + * 1. lowercase + * 2. strip everything that isn't alphanumeric or dash + * 3. trim leading/trailing dashes (HANDLE_REGEX requires alphanumeric anchors) + * 4. enforce length: pad with "user" if too short, truncate if too long + * + * Returns a string that's guaranteed to satisfy HANDLE_REGEX. Callers then + * resolve collisions via generateUniqueHandle. + */ +export function sanitizeHandle(local: string): string { + let h = local.toLowerCase().replace(/[^a-z0-9-]/g, ''); + // Strip leading/trailing dashes — HANDLE_REGEX requires the first and + // last char to be alphanumeric. + h = h.replace(/^-+/, '').replace(/-+$/, ''); + // Pad short locals with "user" so we always end up >= 3 chars. A 0-length + // input (e.g. all special chars stripped) yields "user". + if (h.length < 3) h = (h + 'user').slice(0, 40); + // Truncate long locals. + if (h.length > 40) h = h.slice(0, 40); + // Re-trim in case truncation re-exposed a trailing dash. + h = h.replace(/^-+/, '').replace(/-+$/, ''); + // Final sanity check — if any of the above produced an invalid value + // (e.g. all dashes input), fall back to a stable default. Vanishingly + // rare in practice but keeps the contract iron-clad. + if (!HANDLE_REGEX.test(h)) { + h = 'user'; + } + return h; +} + +/** + * Pick a handle that isn't already taken by another user. + * + * Tries the sanitized base, then `${base}2`, `${base}3`, etc., shortening + * the base if needed to fit the suffix within the 40-char cap. Bails after + * 999 attempts — at that point we'd rather fail loudly than spin. + */ +export async function generateUniqueHandle(local: string): Promise { + const base = sanitizeHandle(local); + if (!(await db.getUserByHandle(base))) return base; + for (let suffix = 2; suffix <= 999; suffix++) { + const suffixStr = String(suffix); + // Slice the base so base+suffix fits in 40 chars. For short bases this + // is a no-op; for max-length bases we lose a few chars off the end. + const trimmed = base.slice(0, 40 - suffixStr.length).replace(/-+$/, ''); + const candidate = `${trimmed}${suffixStr}`; + if (HANDLE_REGEX.test(candidate) && !(await db.getUserByHandle(candidate))) { + return candidate; + } + } + throw new Error('handle generation exhausted'); +} + +/** + * Profile fields extracted from Google's ID token (or a Magic Link). + * `avatarUrl` may be null for Magic Link users (no profile picture). + */ +export interface UserProfile { + email: string; + name?: string; + avatarUrl?: string; +} + +/** + * Insert-or-update a user by email. On a brand-new email the row is created + * with a freshly generated handle; on a returning email name/avatar_url are + * refreshed but the handle is left alone (it's the user-visible identifier + * and shouldn't churn). + * + * Returns the resulting user row so the callback can reference its `id` / + * `handle` when issuing the authorization code. + */ +export async function upsertUser(profile: UserProfile): Promise { + // Local part of the email is the seed for the handle. RFC 5321 caps local + // parts at 64 chars, sanitizeHandle further truncates to 40 — so even + // pathologically long inputs are bounded. + const localPart = profile.email.split('@')[0] ?? ''; + const handle = await generateUniqueHandle(localPart); + return db.upsertUser({ + email: profile.email, + name: profile.name ?? null, + avatarUrl: profile.avatarUrl ?? null, + handle, + }); +} + +/** + * Mint a fresh authorization code, persist it with PKCE + redirect_uri so + * the token endpoint can verify the binding, and return the code string to + * the caller (so they can build the redirect to the MCP client). + * + * The code is URL-safe base64 (`randomBytes().toString('base64url')`) — fits + * in a query parameter without encoding and is opaque to clients. + */ +export async function createAuthCode( + userId: string, + clientId: string, + redirectUri: string, + codeChallenge: string, + codeChallengeMethod: string, + scope: string | null, +): Promise { + const code = randomBytes(AUTH_CODE_BYTES).toString('base64url'); + const expiresAt = new Date(Date.now() + AUTH_CODE_TTL_MS); + await db.insertAuthCode({ + code, + userId, + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod, + scope, + expiresAt, + }); + return code; +} + +// --------------------------------------------------------------------------- +// Token endpoint operations +// --------------------------------------------------------------------------- + +// 32-byte (256-bit) refresh tokens. Hex doubles the length to 64 chars so the +// raw value is ~67 chars including the `rt_` prefix — small enough to fit in +// a JSON response without bloat, large enough that brute force is infeasible. +const REFRESH_TOKEN_BYTES = 32; +const REFRESH_TOKEN_PREFIX = 'rt_'; + +/** + * Token endpoint success response. Matches RFC 6749 §4.1.4 — every successful + * exchange returns the same shape regardless of grant type. + * + * `expires_in` is the access-token lifetime in seconds (the refresh token's + * own lifetime is not exposed — clients learn it implicitly by trying to + * refresh and observing the failure). + */ +export interface TokenResponse { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + refresh_token: string; + scope?: string; +} + +/** + * Error class for the token endpoint. Each instance maps to an OAuth 2.1 + * error response: `{ error, error_description }` plus an HTTP status (400 for + * client errors, 401 for invalid_client). The route layer catches these and + * serializes them. + */ +export class TokenError extends Error { + constructor( + public readonly code: + | 'invalid_grant' + | 'invalid_client' + | 'invalid_request' + | 'unsupported_grant_type' + | 'invalid_scope', + public readonly description: string, + public readonly status: 400 | 401 = 400, + ) { + super(description); + this.name = 'TokenError'; + } +} + +/** + * Hash a raw refresh token for DB storage. Pure SHA-256 (hex) — no salt, no + * HMAC: the raw token already carries 256 bits of entropy so salting buys + * nothing, and a leaked HMAC key would compromise every hash. Matches the + * `magic_links.token_hash` / `sessions.token_hash` storage strategy. + */ +function hashRefreshToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** + * Generate a fresh refresh token. Returns both the raw value (to give back + * to the caller) and the SHA-256 hash (to persist). + */ +function generateRefreshToken(): { raw: string; hash: string } { + const random = randomBytes(REFRESH_TOKEN_BYTES).toString('hex'); + const raw = `${REFRESH_TOKEN_PREFIX}${random}`; + const hash = hashRefreshToken(raw); + return { raw, hash }; +} + +/** + * Verify a PKCE challenge against the supplied verifier (RFC 7636). + * + * S256 only: pagent advertises S256 as the sole supported method (see AS + * metadata), so a non-S256 method here is an internal contract violation. + * Uses `crypto.timingSafeEqual` for defense-in-depth — timing leakage on + * base64url SHA-256 comparison is largely academic, but the cost is zero + * and it keeps every hash comparison on the auth surface constant-time. + */ +function pkceVerify(codeVerifier: string, codeChallenge: string, method: string): boolean { + if (method !== 'S256') return false; + const expected = createHash('sha256').update(codeVerifier).digest('base64url'); + // timingSafeEqual throws when buffer lengths differ — pre-check so we + // return false instead of crashing on a malformed challenge. + if (expected.length !== codeChallenge.length) return false; + return timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge)); +} + +/** + * Mint the access+refresh pair given a verified context (user, client, + * scope). Shared between the authorization_code and refresh_token grants so + * the JWT claim shape and refresh-token persistence stay in lockstep. + * + * `user` is the pagent user row — we need `id`, `email`, and `handle` for + * the JWT claims. The handle must be non-null at this point (we generate one + * at upsertUser time), but we defensively fall back to the email local part + * if it's somehow missing. + */ +async function mintTokens( + user: db.UserRow, + clientId: string, + scope: string | null, +): Promise { + const handle = user.handle ?? user.email.split('@')[0] ?? 'user'; + const accessToken = await signAccessToken({ + sub: user.id, + email: user.email, + handle, + clientId, + // Scope on the JWT is the empty string when none was negotiated — the + // claim shape from spec §5.1 requires a string, not null/undefined. + scope: scope ?? '', + }); + + const { raw: refreshToken, hash: refreshHash } = generateRefreshToken(); + const refreshExpiresAt = new Date(Date.now() + env.REFRESH_TOKEN_MAX_DAYS * 24 * 60 * 60 * 1000); + await db.insertRefreshToken({ + userId: user.id, + clientId, + tokenHash: refreshHash, + scope, + expiresAt: refreshExpiresAt, + }); + + const response: TokenResponse = { + access_token: accessToken, + token_type: 'Bearer', + expires_in: env.ACCESS_TOKEN_TTL_SECONDS, + refresh_token: refreshToken, + }; + if (scope !== null) response.scope = scope; + return response; +} + +/** + * Exchange an authorization code (+ PKCE verifier) for an access+refresh + * pair. Implements the authorization_code grant from RFC 6749 §4.1.3 with + * PKCE per RFC 7636. + * + * Sequence: + * 1. Atomically consume the code (UPDATE ... WHERE consumed_at IS NULL). + * 2. If the code was unknown / expired / already consumed → invalid_grant. + * For the "already consumed" case (detectable via a second SELECT) we + * also revoke any refresh tokens issued from that code's user/client — + * RFC 6749 §4.1.2 SHOULD. + * 3. Verify the PKCE challenge → invalid_grant on mismatch. + * 4. Verify client_id and redirect_uri match the bound values → invalid_grant. + * 5. Mint access + refresh tokens. + */ +export async function exchangeAuthCode( + code: string, + clientId: string, + redirectUri: string, + codeVerifier: string, +): Promise { + if (!code || !clientId || !redirectUri || !codeVerifier) { + throw new TokenError('invalid_request', 'Missing required parameter'); + } + + // Verify the client exists. We don't authenticate it (public client, no + // secret) but we do require the client_id to resolve — otherwise the + // attacker could forge any client_id and we'd happily mint a token bound + // to it. + const client = await getClient(clientId); + if (!client) { + throw new TokenError('invalid_client', 'Unknown client_id', 401); + } + + // Atomic single-use consume. Returns null for "unknown / expired / already + // consumed" — we then SELECT to disambiguate the "already consumed" case + // and react accordingly. + const consumed = await db.consumeAuthCode(code); + if (!consumed) { + const replay = await db.getAuthCodeForReplay(code); + if (replay && replay.consumed_at !== null) { + // Replay attempt — revoke any refresh tokens already issued from this + // code's user/client pair. RFC 6749 §4.1.2 SHOULD; aligns with the + // refresh-token family revocation in `refreshToken` below. + logger.warn( + { + code: code.slice(0, 8) + '…', + user_id: replay.user_id, + client_id: replay.client_id, + }, + 'auth code replay attempt — revoking refresh token family', + ); + await db.revokeAllRefreshTokensForFamily(replay.user_id, replay.client_id); + } + throw new TokenError('invalid_grant', 'Authorization code is invalid or expired'); + } + + // PKCE first (cheaper than DB calls, catches the most common attacker + // case — forged code from another browser without the verifier). + if (!pkceVerify(codeVerifier, consumed.codeChallenge, consumed.codeChallengeMethod)) { + throw new TokenError('invalid_grant', 'PKCE verification failed'); + } + + // Binding checks: the code is single-use and bound to a specific + // client_id/redirect_uri at issue time. A request that doesn't match must + // fail invalid_grant — a mismatched redirect_uri is the canonical + // open-redirect / code-injection signal. + if (consumed.clientId !== clientId) { + throw new TokenError('invalid_grant', 'client_id does not match authorization code'); + } + if (consumed.redirectUri !== redirectUri) { + throw new TokenError('invalid_grant', 'redirect_uri does not match authorization code'); + } + + // Resolve the user so we can populate JWT claims. cascade delete would have + // purged the auth_code if the user disappeared, so this should always + // succeed — but a defensive null check keeps a missing row from crashing + // the request. + const user = await db.getUserById(consumed.userId); + if (!user) { + throw new TokenError('invalid_grant', 'User no longer exists'); + } + + return mintTokens(user, clientId, consumed.scope); +} + +/** + * Refresh an access token using a refresh token (RFC 6749 §6 + RFC 6749 §6 + * + OAuth 2.1 §6.1 rotation). + * + * Sequence: + * 1. Look up the refresh token by SHA-256(raw). + * 2. If unknown → invalid_grant. + * 3. If revoked → token family revocation: revoke every active refresh + * token for (user_id, client_id) and return invalid_grant. + * 4. If expired → invalid_grant. + * 5. If client_id doesn't match the bound client → invalid_grant. + * 6. Mint a new access+refresh pair, then revoke the old refresh token. + */ +export async function refreshToken( + rawRefreshToken: string, + clientId: string, +): Promise { + if (!rawRefreshToken || !clientId) { + throw new TokenError('invalid_request', 'Missing required parameter'); + } + + const client = await getClient(clientId); + if (!client) { + throw new TokenError('invalid_client', 'Unknown client_id', 401); + } + + const tokenHash = hashRefreshToken(rawRefreshToken); + const row = await db.getRefreshTokenByHash(tokenHash); + if (!row) { + throw new TokenError('invalid_grant', 'Refresh token is invalid'); + } + + // Replay of a revoked token → revoke entire family. Per OAuth 2.1 §6.1: + // the safe assumption is that the token leaked, so every still-active + // refresh for that user+client gets revoked. + if (row.revoked_at !== null) { + logger.warn( + { + refresh_token_id: row.id, + user_id: row.user_id, + client_id: row.client_id, + }, + 'revoked refresh token replay — revoking entire token family', + ); + await db.revokeAllRefreshTokensForFamily(row.user_id, row.client_id); + throw new TokenError('invalid_grant', 'Refresh token has been revoked'); + } + + if (row.expires_at.getTime() <= Date.now()) { + throw new TokenError('invalid_grant', 'Refresh token has expired'); + } + + if (row.client_id !== clientId) { + throw new TokenError('invalid_grant', 'client_id does not match refresh token'); + } + + const user = await db.getUserById(row.user_id); + if (!user) { + throw new TokenError('invalid_grant', 'User no longer exists'); + } + + // Mint the new pair first; only revoke the old token after the insert + // succeeds. If minting fails halfway through, the original token stays + // valid so the caller can retry rather than getting locked out. + const response = await mintTokens(user, clientId, row.scope); + await db.revokeRefreshToken(row.id); + return response; +} + +/** + * Revoke a refresh token (RFC 7009). The endpoint always returns success + * regardless of whether the token existed — distinguishing would leak token + * validity to an attacker probing. + * + * `tokenTypeHint` is informational (per RFC 7009 §2.1) — we ignore it because + * we only issue refresh tokens by opaque format and access tokens by JWT; + * the `rt_` prefix on refresh tokens disambiguates without needing the hint. + */ +export async function revokeToken( + token: string, + _tokenTypeHint: string | undefined, + _clientId: string | undefined, +): Promise { + if (!token) return; + // Refresh token: opaque, identified by the `rt_` prefix. Hash and look up. + if (token.startsWith(REFRESH_TOKEN_PREFIX)) { + const row = await db.getRefreshTokenByHash(hashRefreshToken(token)); + if (row && row.revoked_at === null) { + await db.revokeRefreshToken(row.id); + } + return; + } + // Access tokens (JWTs) aren't revocable in V1 — they're short-lived (1h) + // and verification is purely cryptographic. RFC 7009 §2.2 says the server + // SHOULD revoke the access token if revoking a refresh token; we have no + // index from access-token jti to refresh-token row, so this is a no-op + // until V2 introduces an explicit denylist. +} diff --git a/apps/api/auth/routes.test.ts b/apps/api/auth/routes.test.ts new file mode 100644 index 0000000..8258d3b --- /dev/null +++ b/apps/api/auth/routes.test.ts @@ -0,0 +1,1082 @@ +/** + * OAuth discovery / metadata endpoint tests. + * + * Exercises the three .well-known routes mounted in app.ts: + * - /.well-known/oauth-authorization-server (RFC 8414) + * - /.well-known/oauth-protected-resource (RFC 9728) + * - /.well-known/jwks.json (RFC 7517) + * + * The JWKS endpoint reaches into the jwt module's cached public key, so we + * call initKeys() once in beforeAll with a fresh Ed25519 pair generated + * in-process. The DB mock matches app.test.ts so importing app.ts doesn't + * try to open a real Postgres connection. + */ +import { generateKeyPairSync, type KeyObject } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; +import { SignJWT, exportJWK } from 'jose'; + +// Mock db.ts before importing app.ts (which imports it transitively). +vi.mock('../db.ts', () => ({ + init: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(() => Promise.resolve()), + insertPage: vi.fn(() => Promise.resolve()), + getActivePage: vi.fn(() => Promise.resolve(null)), + submitPage: vi.fn(() => Promise.resolve({ kind: 'not_found' })), + fetchAndAdvanceResult: vi.fn(() => Promise.resolve(null)), + deletePage: vi.fn(() => Promise.resolve()), + deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })), + ping: vi.fn().mockResolvedValue(undefined), + insertOAuthClient: vi.fn(), + getOAuthClientById: vi.fn(), + // Token endpoint helpers (Task 07) — used by /oauth/token and /oauth/revoke. + // Stubbed here so the existing well-known / register tests don't break when + // the routes file imports provider's new functions. + upsertUser: vi.fn(), + getUserByHandle: vi.fn(), + getUserById: vi.fn(), + insertAuthCode: vi.fn(), + consumeAuthCode: vi.fn(), + getAuthCodeForReplay: vi.fn(), + insertRefreshToken: vi.fn(), + getRefreshTokenByHash: vi.fn(), + revokeRefreshToken: vi.fn(), + revokeAllRefreshTokensForFamily: vi.fn(), + // Session helpers (Task 08) — used by resolveAuth middleware + /auth/me + + // /auth/logout + browser_session callback paths. Stubbed so tests can drive + // the cookie-session state directly without a live Postgres. + insertSession: vi.fn(() => Promise.resolve()), + getSessionWithUserByTokenHash: vi.fn(() => Promise.resolve(null)), + extendSessionExpiry: vi.fn(() => Promise.resolve()), + deleteSessionByTokenHash: vi.fn(() => Promise.resolve()), + // Magic link + Google callback helpers — referenced by the browser_session + // tests below so the verify path can succeed without a real magic link row. + insertMagicLink: vi.fn(() => Promise.resolve()), + verifyAndConsumeMagicLink: vi.fn(() => Promise.resolve(null)), +})); + +import * as db from '../db.ts'; +import { app } from '../app.ts'; +import { initKeys, KID, getIssuer } from './jwt.ts'; + +const BASE = 'http://localhost'; + +/** Generate a fresh Ed25519 key pair and feed it to initKeys(). */ +async function setupKeys(): Promise { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const signingKeyB64u = privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'); + const publicKeyB64u = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'); + await initKeys(signingKeyB64u, publicKeyB64u); +} + +beforeAll(async () => { + await setupKeys(); +}); + +async function json(res: Response) { + return res.json() as Promise>; +} + +// --------------------------------------------------------------------------- +// AS metadata (RFC 8414) +// --------------------------------------------------------------------------- + +describe('GET /.well-known/oauth-authorization-server', () => { + it('returns 200 with application/json content-type', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('issuer matches getIssuer() (derived from PUBLIC_URL)', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.issuer).toBe(getIssuer()); + }); + + it('endpoint URLs use PUBLIC_URL as the base (no hardcoded api.pagent.link)', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + const issuer = getIssuer(); + expect(body.authorization_endpoint).toBe(`${issuer}/oauth/authorize`); + expect(body.token_endpoint).toBe(`${issuer}/oauth/token`); + expect(body.registration_endpoint).toBe(`${issuer}/oauth/register`); + expect(body.revocation_endpoint).toBe(`${issuer}/oauth/revoke`); + // Guard against accidental reintroduction of the spec's example URL. + for (const v of Object.values(body)) { + if (typeof v === 'string') { + expect(v).not.toContain('api.pagent.link'); + } + } + }); + + it('has every field RFC 8414 requires for our profile', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + const required = [ + 'issuer', + 'authorization_endpoint', + 'token_endpoint', + 'registration_endpoint', + 'revocation_endpoint', + 'response_types_supported', + 'grant_types_supported', + 'token_endpoint_auth_methods_supported', + 'code_challenge_methods_supported', + 'scopes_supported', + 'service_documentation', + ]; + for (const k of required) { + expect(body).toHaveProperty(k); + } + }); + + it('response_types_supported = ["code"]', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.response_types_supported).toEqual(['code']); + }); + + it('grant_types_supported includes authorization_code and refresh_token', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + }); + + it('token_endpoint_auth_methods_supported = ["none"] (public clients only)', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.token_endpoint_auth_methods_supported).toEqual(['none']); + }); + + it('code_challenge_methods_supported = ["S256"] only — no plain', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.code_challenge_methods_supported).toEqual(['S256']); + expect(body.code_challenge_methods_supported).not.toContain('plain'); + }); + + it('scopes_supported = ["page:create", "page:read", "page:write"]', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + const body = await json(res); + expect(body.scopes_supported).toEqual(['page:create', 'page:read', 'page:write']); + }); +}); + +// --------------------------------------------------------------------------- +// Protected Resource metadata (RFC 9728) +// --------------------------------------------------------------------------- + +describe('GET /.well-known/oauth-protected-resource', () => { + it('returns 200 with application/json content-type', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('resource and authorization_servers[0] both equal PUBLIC_URL', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + const body = await json(res); + const issuer = getIssuer(); + expect(body.resource).toBe(issuer); + expect(body.authorization_servers).toEqual([issuer]); + }); + + it('has every field RFC 9728 requires for our profile', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + const body = await json(res); + const required = [ + 'resource', + 'authorization_servers', + 'scopes_supported', + 'bearer_methods_supported', + 'resource_name', + 'resource_documentation', + ]; + for (const k of required) { + expect(body).toHaveProperty(k); + } + }); + + it('scopes_supported matches the AS metadata', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + const body = await json(res); + expect(body.scopes_supported).toEqual(['page:create', 'page:read', 'page:write']); + }); + + it('bearer_methods_supported = ["header"] (header-only, no query/body)', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + const body = await json(res); + expect(body.bearer_methods_supported).toEqual(['header']); + }); + + it('resource_name = "Pagent API"', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + const body = await json(res); + expect(body.resource_name).toBe('Pagent API'); + }); +}); + +// --------------------------------------------------------------------------- +// JWKS (RFC 7517) +// --------------------------------------------------------------------------- + +describe('GET /.well-known/jwks.json', () => { + it('returns 200 with application/json content-type', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/jwks.json`)); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('returns a single Ed25519 OKP key', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/jwks.json`)); + const body = await json(res); + expect(Array.isArray(body.keys)).toBe(true); + const keys = body.keys as Array>; + expect(keys).toHaveLength(1); + const key = keys[0]; + expect(key.kty).toBe('OKP'); + expect(key.crv).toBe('Ed25519'); + expect(key.use).toBe('sig'); + expect(key.kid).toBe(KID); + }); + + it('does not leak the private-key material (no `d` field)', async () => { + // Defense in depth — the jwt module's getJwks already enforces this, but + // a regression at this layer (e.g. accidentally spreading a private JWK) + // would expose the signing key. Worth catching at the route boundary. + const res = await app.fetch(new Request(`${BASE}/.well-known/jwks.json`)); + const body = await json(res); + const key = (body.keys as Array>)[0]; + expect(key.d).toBeUndefined(); + }); + + it('public key x is a 43-char base64url string (Ed25519 raw 32 bytes)', async () => { + const res = await app.fetch(new Request(`${BASE}/.well-known/jwks.json`)); + const body = await json(res); + const key = (body.keys as Array>)[0]; + expect(typeof key.x).toBe('string'); + expect((key.x as string).length).toBe(43); + }); +}); + +// --------------------------------------------------------------------------- +// Public access (no auth middleware applied) +// --------------------------------------------------------------------------- + +describe('discovery endpoints are public', () => { + // The three .well-known routes must be reachable without any Authorization + // header. This isn't a "no auth middleware" assertion (there isn't one yet + // in app.ts) but it locks in the contract so a future addition of auth + // middleware can't accidentally cover these paths. + const paths = [ + '/.well-known/oauth-authorization-server', + '/.well-known/oauth-protected-resource', + '/.well-known/jwks.json', + ]; + + for (const path of paths) { + it(`GET ${path} returns 200 with no Authorization header`, async () => { + const res = await app.fetch(new Request(`${BASE}${path}`)); + expect(res.status).toBe(200); + }); + } +}); + +// --------------------------------------------------------------------------- +// POST /oauth/register (RFC 7591 dynamic client registration) +// --------------------------------------------------------------------------- +// Integration test through the Hono app: validates the route layer's +// argument forwarding, response shape, error mapping, and rate limiter. + +import { beforeEach } from 'vitest'; + +const NOW = new Date('2026-05-17T12:00:00Z'); + +function registerRow(overrides: Partial[0]> = {}) { + return { + client_id: overrides.client_id ?? 'a1b2c3d4-e5f6-4321-9876-abcdef012345', + client_secret: null, + client_secret_expires_at: null, + client_id_issued_at: NOW, + client_name: overrides.client_name ?? null, + client_uri: overrides.client_uri ?? null, + logo_uri: overrides.logo_uri ?? null, + redirect_uris: overrides.redirect_uris ?? ['http://localhost:9876/callback'], + grant_types: overrides.grant_types ?? ['authorization_code', 'refresh_token'], + response_types: overrides.response_types ?? ['code'], + scope: overrides.scope ?? null, + token_endpoint_auth_method: overrides.token_endpoint_auth_method ?? 'none', + }; +} + +function postRegister(body: unknown, xForwardedFor?: string): Request { + const headers: Record = { 'Content-Type': 'application/json' }; + if (xForwardedFor !== undefined) headers['x-forwarded-for'] = xForwardedFor; + return new Request(`${BASE}/oauth/register`, { + method: 'POST', + headers, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +describe('POST /oauth/register', () => { + beforeEach(() => { + vi.mocked(db.insertOAuthClient).mockReset(); + }); + + it('returns 201 with OAuthClientInformationFull on valid registration', async () => { + vi.mocked(db.insertOAuthClient).mockImplementation(async (input) => registerRow({ ...input })); + + const res = await app.fetch( + postRegister( + { + redirect_uris: ['http://localhost:9876/callback'], + client_name: 'Claude Code', + }, + '10.0.0.1', + ), + ); + + expect(res.status).toBe(201); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = (await res.json()) as Record; + expect(typeof body.client_id).toBe('string'); + expect(body.client_name).toBe('Claude Code'); + expect(body.redirect_uris).toEqual(['http://localhost:9876/callback']); + expect(body.token_endpoint_auth_method).toBe('none'); + expect(body.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(body.response_types).toEqual(['code']); + expect(typeof body.client_id_issued_at).toBe('number'); + expect(body.client_secret).toBeUndefined(); + }); + + it('returns 400 invalid_client_metadata when body is not JSON object', async () => { + vi.mocked(db.insertOAuthClient).mockResolvedValueOnce(registerRow()); + + const res = await app.fetch(postRegister('not-json', '10.0.0.2')); + + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_client_metadata'); + expect(typeof body.error_description).toBe('string'); + expect(db.insertOAuthClient).not.toHaveBeenCalled(); + }); + + it('returns 400 invalid_client_metadata when redirect_uris is missing', async () => { + const res = await app.fetch(postRegister({ client_name: 'No URI' }, '10.0.0.3')); + + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_client_metadata'); + expect((body.error_description as string).toLowerCase()).toContain('redirect_uris'); + expect(db.insertOAuthClient).not.toHaveBeenCalled(); + }); + + it('returns 400 invalid_client_metadata when redirect_uris has invalid URI', async () => { + const res = await app.fetch(postRegister({ redirect_uris: ['not a uri'] }, '10.0.0.4')); + + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_client_metadata'); + expect(db.insertOAuthClient).not.toHaveBeenCalled(); + }); + + it('returns 400 when redirect_uris is empty array', async () => { + const res = await app.fetch(postRegister({ redirect_uris: [] }, '10.0.0.5')); + + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_client_metadata'); + }); + + it('applies defaults when grant_types/response_types are omitted', async () => { + vi.mocked(db.insertOAuthClient).mockImplementation(async (input) => registerRow({ ...input })); + + const res = await app.fetch( + postRegister({ redirect_uris: ['http://localhost:9876/callback'] }, '10.0.0.6'), + ); + + expect(res.status).toBe(201); + const arg = vi.mocked(db.insertOAuthClient).mock.calls[0]![0]; + expect(arg.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(arg.response_types).toEqual(['code']); + expect(arg.token_endpoint_auth_method).toBe('none'); + }); + + it('rate-limits at 10 registrations per IP per hour (11th request → 429)', async () => { + // Rate limit bucket is keyed on the last X-Forwarded-For hop. Use a unique + // IP so the bucket is empty when this test runs (other tests above used + // different IPs). + const ip = '203.0.113.1'; + vi.mocked(db.insertOAuthClient).mockImplementation(async (input) => registerRow({ ...input })); + + for (let i = 0; i < 10; i++) { + const res = await app.fetch( + postRegister({ redirect_uris: ['http://localhost:9876/callback'] }, ip), + ); + expect(res.status, `request ${i + 1} of 10 should be 201`).toBe(201); + } + + const limited = await app.fetch( + postRegister({ redirect_uris: ['http://localhost:9876/callback'] }, ip), + ); + expect(limited.status).toBe(429); + const body = (await limited.json()) as Record; + expect(body.error).toBe('rate_limited'); + expect(typeof body.retry_after_seconds).toBe('number'); + expect(limited.headers.get('Retry-After')).toBe(String(body.retry_after_seconds)); + + // Different IP still works. + const other = await app.fetch( + postRegister({ redirect_uris: ['http://localhost:9876/callback'] }, '203.0.113.2'), + ); + expect(other.status).toBe(201); + }); +}); + +// --------------------------------------------------------------------------- +// POST /oauth/token + POST /oauth/revoke (Task 07) +// --------------------------------------------------------------------------- +// Integration tests through the Hono app. Validates body-parsing strictness +// (must be form-encoded), error-response shape (OAuth 2.1 `{ error, +// error_description }`), and the 20/IP/min rate limit. Per-grant happy paths +// are covered exhaustively in provider.test.ts — these focus on the route +// layer (body parsing, content-type enforcement, error mapping, rate limit). + +import { createHash } from 'node:crypto'; + +const TOKEN_CLIENT_ID = 'b1c2d3e4-f5a6-7890-1234-bcdef0123456'; +const TOKEN_REDIRECT_URI = 'http://localhost:9876/cb'; + +const TOKEN_CLIENT_ROW = { + client_id: TOKEN_CLIENT_ID, + client_secret: null, + client_secret_expires_at: null, + client_id_issued_at: NOW, + client_name: null, + client_uri: null, + logo_uri: null, + redirect_uris: [TOKEN_REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: null, + token_endpoint_auth_method: 'none', +}; + +const TOKEN_USER_ROW = { + id: '22222222-3333-4444-5555-666666666666', + handle: 'tester', + email: 'tester@example.com', + name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, +}; + +/** Build a POST /oauth/token request with form-encoded body. */ +function postToken( + body: Record, + opts: { contentType?: 'form' | 'json'; xForwardedFor?: string } = {}, +): Request { + const headers: Record = {}; + let serialized: string; + if (opts.contentType === 'json') { + headers['Content-Type'] = 'application/json'; + serialized = JSON.stringify(body); + } else { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + serialized = new URLSearchParams(body).toString(); + } + if (opts.xForwardedFor !== undefined) headers['x-forwarded-for'] = opts.xForwardedFor; + return new Request(`${BASE}/oauth/token`, { + method: 'POST', + headers, + body: serialized, + }); +} + +/** Same shape as postToken but for /oauth/revoke. */ +function postRevoke( + body: Record, + opts: { contentType?: 'form' | 'json'; xForwardedFor?: string } = {}, +): Request { + const headers: Record = {}; + let serialized: string; + if (opts.contentType === 'json') { + headers['Content-Type'] = 'application/json'; + serialized = JSON.stringify(body); + } else { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + serialized = new URLSearchParams(body).toString(); + } + if (opts.xForwardedFor !== undefined) headers['x-forwarded-for'] = opts.xForwardedFor; + return new Request(`${BASE}/oauth/revoke`, { + method: 'POST', + headers, + body: serialized, + }); +} + +function pkceS256(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +describe('POST /oauth/token', () => { + beforeEach(() => { + vi.mocked(db.getOAuthClientById).mockReset(); + vi.mocked(db.consumeAuthCode).mockReset(); + vi.mocked(db.getAuthCodeForReplay).mockReset(); + vi.mocked(db.getUserById).mockReset(); + vi.mocked(db.insertRefreshToken).mockReset(); + vi.mocked(db.getRefreshTokenByHash).mockReset(); + vi.mocked(db.revokeRefreshToken).mockReset(); + vi.mocked(db.revokeAllRefreshTokensForFamily).mockReset(); + }); + + it('exchanges authorization_code grant for tokens (form-encoded body)', async () => { + const verifier = 'integration-test-verifier-with-enough-entropy'; + const challenge = pkceS256(verifier); + + vi.mocked(db.getOAuthClientById).mockResolvedValue(TOKEN_CLIENT_ROW); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce({ + userId: TOKEN_USER_ROW.id, + clientId: TOKEN_CLIENT_ID, + redirectUri: TOKEN_REDIRECT_URI, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + scope: 'page:create', + resource: null, + }); + vi.mocked(db.getUserById).mockResolvedValueOnce(TOKEN_USER_ROW); + vi.mocked(db.insertRefreshToken).mockImplementation(async (input) => ({ + id: 'rt-id', + user_id: input.userId, + client_id: input.clientId, + token_hash: input.tokenHash, + scope: input.scope, + created_at: new Date(), + expires_at: input.expiresAt, + revoked_at: null, + })); + + const res = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'test-auth-code', + client_id: TOKEN_CLIENT_ID, + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: verifier, + }, + { xForwardedFor: '10.1.0.1' }, + ), + ); + + expect(res.status).toBe(200); + // RFC 6749 §5.1 mandates no-store on token responses. + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('pragma')).toBe('no-cache'); + + const body = (await res.json()) as Record; + expect(body.token_type).toBe('Bearer'); + expect(body.expires_in).toBe(3600); + expect(typeof body.access_token).toBe('string'); + expect((body.access_token as string).split('.')).toHaveLength(3); + expect((body.refresh_token as string).startsWith('rt_')).toBe(true); + expect(body.scope).toBe('page:create'); + }); + + it('rejects application/json body with invalid_request (400)', async () => { + const res = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'x', + client_id: TOKEN_CLIENT_ID, + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: 'v', + }, + { contentType: 'json', xForwardedFor: '10.1.0.2' }, + ), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_request'); + expect(typeof body.error_description).toBe('string'); + expect((body.error_description as string).toLowerCase()).toContain('x-www-form-urlencoded'); + }); + + it('returns unsupported_grant_type for unknown grants (400)', async () => { + const res = await app.fetch( + postToken( + { grant_type: 'password', username: 'x', password: 'y' }, + { xForwardedFor: '10.1.0.3' }, + ), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('unsupported_grant_type'); + expect(typeof body.error_description).toBe('string'); + }); + + it('returns invalid_request when grant_type is missing', async () => { + const res = await app.fetch(postToken({ code: 'x' }, { xForwardedFor: '10.1.0.4' })); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_request'); + }); + + it('returns invalid_grant for an invalid auth code (no consume row)', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValue(TOKEN_CLIENT_ROW); + vi.mocked(db.consumeAuthCode).mockResolvedValueOnce(null); + vi.mocked(db.getAuthCodeForReplay).mockResolvedValueOnce(null); + + const res = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'unknown', + client_id: TOKEN_CLIENT_ID, + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: 'v', + }, + { xForwardedFor: '10.1.0.5' }, + ), + ); + + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_grant'); + }); + + it('returns invalid_client (401) for unknown client_id', async () => { + vi.mocked(db.getOAuthClientById).mockResolvedValue(null); + + const res = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'x', + client_id: 'no-such-client', + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: 'v', + }, + { xForwardedFor: '10.1.0.6' }, + ), + ); + + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.error).toBe('invalid_client'); + }); + + it('rate-limits at 20 per IP per minute (21st request → 429)', async () => { + const ip = '203.0.113.50'; + vi.mocked(db.getOAuthClientById).mockResolvedValue(null); + + for (let i = 0; i < 20; i++) { + const res = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'x', + client_id: 'no-such-client', + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: 'v', + }, + { xForwardedFor: ip }, + ), + ); + // 401 invalid_client is the expected response — what matters is that we + // didn't get 429 yet. + expect(res.status, `request ${i + 1} of 20 should not be rate-limited`).not.toBe(429); + } + + const limited = await app.fetch( + postToken( + { + grant_type: 'authorization_code', + code: 'x', + client_id: 'no-such-client', + redirect_uri: TOKEN_REDIRECT_URI, + code_verifier: 'v', + }, + { xForwardedFor: ip }, + ), + ); + expect(limited.status).toBe(429); + const body = (await limited.json()) as Record; + expect(body.error).toBe('rate_limited'); + expect(typeof body.retry_after_seconds).toBe('number'); + expect(limited.headers.get('Retry-After')).toBe(String(body.retry_after_seconds)); + }); +}); + +describe('POST /oauth/revoke', () => { + beforeEach(() => { + vi.mocked(db.getRefreshTokenByHash).mockReset(); + vi.mocked(db.revokeRefreshToken).mockReset(); + }); + + it('returns 200 with no body for a successful revocation', async () => { + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce({ + id: 'rt-row', + user_id: TOKEN_USER_ROW.id, + client_id: TOKEN_CLIENT_ID, + token_hash: 'irrelevant', + scope: null, + created_at: NOW, + expires_at: new Date(Date.now() + 86400_000), + revoked_at: null, + }); + + const res = await app.fetch( + postRevoke({ token: 'rt_abc', client_id: TOKEN_CLIENT_ID }, { xForwardedFor: '10.2.0.1' }), + ); + + expect(res.status).toBe(200); + expect(db.revokeRefreshToken).toHaveBeenCalledWith('rt-row'); + }); + + it('returns 200 even when the token is unknown (RFC 7009 §2.2)', async () => { + vi.mocked(db.getRefreshTokenByHash).mockResolvedValueOnce(null); + + const res = await app.fetch(postRevoke({ token: 'rt_unknown' }, { xForwardedFor: '10.2.0.2' })); + + expect(res.status).toBe(200); + expect(db.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('returns 200 when the body is empty (no token field)', async () => { + const res = await app.fetch(postRevoke({}, { xForwardedFor: '10.2.0.3' })); + expect(res.status).toBe(200); + expect(db.getRefreshTokenByHash).not.toHaveBeenCalled(); + }); + + it('returns 200 even for a malformed JSON body (degrades to no-op)', async () => { + const res = await app.fetch( + postRevoke({ token: 'rt_anything' }, { contentType: 'json', xForwardedFor: '10.2.0.4' }), + ); + expect(res.status).toBe(200); + // JSON content-type isn't form-encoded; provider sees no body, no work + // gets done — but the response status is still 200 per RFC 7009. + expect(db.revokeRefreshToken).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// GET /auth/me + POST /auth/logout (Task 09 — browser session) +// --------------------------------------------------------------------------- +// /auth/me returns the cookie-authenticated user's profile. /auth/logout +// deletes the DB row and clears the cookie. Both depend on the resolveAuth +// middleware populating c.var.user from the pagent_session cookie — we drive +// that here by stubbing getSessionWithUserByTokenHash to return a session row. + +import { SESSION_COOKIE_NAME } from './middleware.ts'; + +const SESSION_USER_ROW = { + id: '33333333-4444-5555-6666-777777777777', + handle: 'alex', + email: 'alex@blockful.io', + name: 'Alex Netto', + avatar_url: 'https://example.com/avatar.png', + created_at: NOW, + updated_at: NOW, +}; + +const SESSION_LOOKUP_ROW = { + session_id: 'session-uuid-aabb', + user_id: SESSION_USER_ROW.id, + email: SESSION_USER_ROW.email, + handle: SESSION_USER_ROW.handle, + expires_at: new Date(Date.now() + 86400_000), +}; + +describe('GET /auth/me', () => { + beforeEach(() => { + vi.mocked(db.getSessionWithUserByTokenHash).mockReset(); + vi.mocked(db.extendSessionExpiry).mockReset(); + vi.mocked(db.getUserById).mockReset(); + }); + + it('returns the user profile when a valid session cookie is present', async () => { + vi.mocked(db.getSessionWithUserByTokenHash).mockResolvedValueOnce(SESSION_LOOKUP_ROW); + vi.mocked(db.extendSessionExpiry).mockResolvedValueOnce(undefined); + vi.mocked(db.getUserById).mockResolvedValueOnce(SESSION_USER_ROW); + + const res = await app.fetch( + new Request(`${BASE}/auth/me`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=valid-session-token` }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toEqual({ + id: SESSION_USER_ROW.id, + handle: SESSION_USER_ROW.handle, + email: SESSION_USER_ROW.email, + name: SESSION_USER_ROW.name, + avatar_url: SESSION_USER_ROW.avatar_url, + }); + }); + + it('returns 401 when no session cookie is provided', async () => { + const res = await app.fetch(new Request(`${BASE}/auth/me`)); + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.error).toBe('unauthorized'); + // No DB user lookup attempted when the request is anonymous. + expect(db.getUserById).not.toHaveBeenCalled(); + }); + + it('returns 401 when the session cookie is unknown', async () => { + vi.mocked(db.getSessionWithUserByTokenHash).mockResolvedValueOnce(null); + const res = await app.fetch( + new Request(`${BASE}/auth/me`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=stale-or-expired` }, + }), + ); + expect(res.status).toBe(401); + expect(db.getUserById).not.toHaveBeenCalled(); + }); + + it('returns 401 when the user row vanished after the cookie was issued', async () => { + // Cascade-delete edge case: session row exists (lookup returns it) but the + // user was deleted between issuing and now. We treat that as unauthorized. + vi.mocked(db.getSessionWithUserByTokenHash).mockResolvedValueOnce(SESSION_LOOKUP_ROW); + vi.mocked(db.extendSessionExpiry).mockResolvedValueOnce(undefined); + vi.mocked(db.getUserById).mockResolvedValueOnce(null); + + const res = await app.fetch( + new Request(`${BASE}/auth/me`, { + headers: { cookie: `${SESSION_COOKIE_NAME}=valid-but-orphaned` }, + }), + ); + expect(res.status).toBe(401); + }); +}); + +describe('POST /auth/logout', () => { + beforeEach(() => { + vi.mocked(db.getSessionWithUserByTokenHash).mockReset(); + vi.mocked(db.deleteSessionByTokenHash).mockReset(); + vi.mocked(db.extendSessionExpiry).mockReset(); + }); + + it('deletes the DB session and clears the cookie when a session is present', async () => { + vi.mocked(db.getSessionWithUserByTokenHash).mockResolvedValueOnce(SESSION_LOOKUP_ROW); + vi.mocked(db.extendSessionExpiry).mockResolvedValueOnce(undefined); + vi.mocked(db.deleteSessionByTokenHash).mockResolvedValueOnce(undefined); + + const res = await app.fetch( + new Request(`${BASE}/auth/logout`, { + method: 'POST', + headers: { cookie: `${SESSION_COOKIE_NAME}=raw-cookie-token-abc` }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.ok).toBe(true); + expect(db.deleteSessionByTokenHash).toHaveBeenCalledTimes(1); + + // Set-Cookie clears the value with Max-Age=0. + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toContain(`${SESSION_COOKIE_NAME}=`); + expect(setCookie?.toLowerCase()).toContain('max-age=0'); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie?.toLowerCase()).toContain('samesite=lax'); + expect(setCookie).toContain('Path=/'); + }); + + it('clears the cookie even when no session cookie was sent (idempotent)', async () => { + const res = await app.fetch(new Request(`${BASE}/auth/logout`, { method: 'POST' })); + expect(res.status).toBe(200); + expect(db.deleteSessionByTokenHash).not.toHaveBeenCalled(); + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toContain(`${SESSION_COOKIE_NAME}=`); + expect(setCookie?.toLowerCase()).toContain('max-age=0'); + }); +}); + +// --------------------------------------------------------------------------- +// Browser session login flow (browser_session=1) +// --------------------------------------------------------------------------- +// Verifies that: +// 1. GET /oauth/authorize?browser_session=1 renders the login page without +// requiring client_id / redirect_uri / code_challenge. +// 2. Google callback with browser_session state sets a session cookie and +// redirects to /. +// 3. Magic link verify with browser_session ctx sets a session cookie and +// redirects to /. + +import { signStateJwt } from './state-jwt.ts'; +import { env } from '../schemas.ts'; + +describe('Browser session login flow', () => { + beforeAll(() => { + // The state JWT signer needs AUTH_STATE_SECRET set. Other auth tests do + // this in their own beforeAll; here we set it defensively (idempotent — + // the routes test file may run in isolation). + (env as { AUTH_STATE_SECRET: string | undefined }).AUTH_STATE_SECRET = + 'test-auth-state-secret-very-long-random-value-32-bytes'; + (env as { GOOGLE_CLIENT_ID: string | undefined }).GOOGLE_CLIENT_ID = 'test-google-client-id'; + (env as { GOOGLE_CLIENT_SECRET: string | undefined }).GOOGLE_CLIENT_SECRET = + 'test-google-client-secret'; + (env as { GOOGLE_REDIRECT_URI: string | undefined }).GOOGLE_REDIRECT_URI = + 'http://localhost/oauth/callback/google'; + }); + + beforeEach(() => { + vi.mocked(db.insertSession).mockReset(); + vi.mocked(db.upsertUser).mockReset(); + vi.mocked(db.getUserByHandle).mockReset(); + vi.mocked(db.verifyAndConsumeMagicLink).mockReset(); + }); + + it('GET /oauth/authorize?browser_session=1 renders login page without other params', async () => { + // Use a unique IP so the per-IP rate limit doesn't interfere with tests + // run in the same file (the authorize bucket caps at 30/min/IP). + const res = await app.fetch( + new Request(`${BASE}/oauth/authorize?browser_session=1`, { + headers: { 'x-forwarded-for': '198.51.100.99' }, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Continue with Google'); + expect(html).toContain('
{ + // Mock the magic link DB row's authorize_context to flag browser_session. + vi.mocked(db.verifyAndConsumeMagicLink).mockResolvedValueOnce({ + email: 'alex@blockful.io', + authorizeContext: { browserSession: true }, + }); + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValueOnce(SESSION_USER_ROW); + vi.mocked(db.insertSession).mockResolvedValueOnce(undefined); + + const res = await app.fetch( + new Request(`${BASE}/oauth/magic?token=browser-flow-token`, { + headers: { + 'user-agent': 'MockBrowser/1.0', + 'x-forwarded-for': '10.5.0.1', + }, + }), + ); + + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/'); + + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toContain(`${SESSION_COOKIE_NAME}=`); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie?.toLowerCase()).toContain('samesite=lax'); + expect(setCookie).toContain('Path=/'); + expect(setCookie?.toLowerCase()).toContain('max-age=2592000'); + + // Session insert captured the IP and user-agent from the request. + expect(db.insertSession).toHaveBeenCalledTimes(1); + const insertArg = vi.mocked(db.insertSession).mock.calls[0]![0]; + expect(insertArg.userId).toBe(SESSION_USER_ROW.id); + expect(insertArg.ipAddress).toBe('10.5.0.1'); + expect(insertArg.userAgent).toBe('MockBrowser/1.0'); + + // No auth code minted — this is the cookie path, not the MCP-client path. + expect(db.insertAuthCode).not.toHaveBeenCalled(); + }); + + it('Google callback with browser_session=true sets cookie and redirects to /', async () => { + // Build a state JWT that carries browser_session=true. + const browserState = await signStateJwt({ browserSession: true }); + + // Sign an ID token with a fresh RSA key, and serve the matching JWKS + // from the same fetch spy. provider's exchangeGoogleCode now does real + // signature verification (createRemoteJWKSet + jwtVerify), so an + // unsigned `header.payload.fake-sig` triple no longer passes. + const rsaPair = generateKeyPairSync('rsa', { modulusLength: 2048 }) as { + publicKey: KeyObject; + privateKey: KeyObject; + }; + const publicJwk = await exportJWK(rsaPair.publicKey); + const jwksDoc = { + keys: [{ ...publicJwk, alg: 'RS256', use: 'sig', kid: 'browser-test-kid' }], + }; + const signedIdToken = await new SignJWT({ + sub: 'google-sub-browser', + email: 'alex@blockful.io', + name: 'Alex Netto', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'browser-test-kid', typ: 'JWT' }) + .setIssuer('https://accounts.google.com') + .setAudience('test-google-client-id') + .setIssuedAt() + .setExpirationTime('600s') + .sign(rsaPair.privateKey); + + const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(async (input) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes('oauth2.googleapis.com/token')) { + return new Response(JSON.stringify({ id_token: signedIdToken, access_token: 'g-at' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('googleapis.com/oauth2/v3/certs')) { + return new Response(JSON.stringify(jwksDoc), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + vi.mocked(db.getUserByHandle).mockResolvedValue(null); + vi.mocked(db.upsertUser).mockResolvedValueOnce(SESSION_USER_ROW); + vi.mocked(db.insertSession).mockResolvedValueOnce(undefined); + + try { + const res = await app.fetch( + new Request( + `${BASE}/oauth/callback/google?code=google-code&state=${encodeURIComponent(browserState)}`, + { + headers: { + 'user-agent': 'MockBrowser/2.0', + 'x-forwarded-for': '203.0.113.77', + }, + }, + ), + ); + + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/'); + + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toContain(`${SESSION_COOKIE_NAME}=`); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie?.toLowerCase()).toContain('samesite=lax'); + + // Session insert captured the IP and user-agent. + expect(db.insertSession).toHaveBeenCalledTimes(1); + const insertArg = vi.mocked(db.insertSession).mock.calls[0]![0]; + expect(insertArg.userId).toBe(SESSION_USER_ROW.id); + expect(insertArg.ipAddress).toBe('203.0.113.77'); + expect(insertArg.userAgent).toBe('MockBrowser/2.0'); + + // No auth code minted — this is the cookie path. + expect(db.insertAuthCode).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); +}); diff --git a/apps/api/auth/routes.ts b/apps/api/auth/routes.ts new file mode 100644 index 0000000..3890a6a --- /dev/null +++ b/apps/api/auth/routes.ts @@ -0,0 +1,895 @@ +/** + * OAuth discovery / metadata endpoints + dynamic client registration. + * + * The three .well-known routes MCP clients and OAuth tools probe to find + * the authorization server. All public — no auth middleware, no rate limit. + * + * POST /oauth/register implements RFC 7591 dynamic client registration so + * MCP clients can self-register before the authorization code flow. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §3.1, §3.2, §3.3, §5.3. + * RFCs: 7591 (Dynamic Client Registration), 8414 (AS metadata), 9728 + * (Protected Resource metadata), 7517 (JWKS). + */ +import type { Context } from 'hono'; +import { Hono } from 'hono'; +import { getCookie, setCookie } from 'hono/cookie'; +import { rateLimiter } from 'hono-rate-limiter'; +import { clientKey } from '../client-key.ts'; +import * as db from '../db.ts'; +import { RateLimiter } from '../mcp/rate-limit.ts'; +import { env } from '../schemas.ts'; +import { InvalidClientMetadataError, getClient, registerClient } from './clients-store.ts'; +import { exchangeGoogleCode } from './google.ts'; +import { getIssuer, getJwks } from './jwt.ts'; +import { renderLoginPage } from './login-page.ts'; +import { + InvalidMagicLinkError, + SmtpUnavailableError, + sendMagicLink, + verifyMagicLink, +} from './magic-link.ts'; +import type { AuthVariables } from './middleware.ts'; +import { SESSION_COOKIE_NAME } from './middleware.ts'; +import { + TokenError, + createAuthCode, + exchangeAuthCode, + refreshToken as providerRefreshToken, + revokeToken, + upsertUser, +} from './provider.ts'; +import { createSession, deleteSession } from './session.ts'; +import { signStateJwt, verifyStateJwt } from './state-jwt.ts'; + +// Pagent ships exactly three OAuth scopes. Listed in both metadata documents +// so MCP clients can decide what to request without parsing the JWT itself. +const SCOPES_SUPPORTED = ['page:create', 'page:read', 'page:write'] as const; + +// README link returned in both `service_documentation` and +// `resource_documentation`. Stable URL, doesn't depend on PUBLIC_URL. +const DOCS_URL = 'https://github.com/anthropics/agent-ui-session#readme'; + +export const authRoutes = new Hono<{ Variables: AuthVariables }>(); + +// --- Session cookie helpers -------------------------------------------------- +// `pagent_session` cookie attributes per spec §6.2 — HttpOnly, Secure in prod, +// SameSite=Lax, Path=/, 30-day Max-Age (mirroring SESSION_MAX_AGE_DAYS). +// +// We don't set `secure` in dev because the renderer at http://localhost would +// otherwise fail to receive the cookie — modern browsers reject Secure cookies +// over plain http even on localhost in many configurations. The production +// codepath stays correct: when NODE_ENV=production, Secure is set. + +const SESSION_COOKIE_MAX_AGE_SECONDS = env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60; + +function setSessionCookie(c: Context, token: string): void { + setCookie(c, SESSION_COOKIE_NAME, token, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: SESSION_COOKIE_MAX_AGE_SECONDS, + }); +} + +function clearSessionCookie(c: Context): void { + setCookie(c, SESSION_COOKIE_NAME, '', { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: 0, + }); +} + +/** + * Extract the client's IP from `x-forwarded-for`. Last hop wins — that's the + * one our load balancer added. Identical to the rate-limit key derivation + * (`clientKey`) but kept inline here so the cookie path doesn't depend on a + * helper designed for a different purpose. Returns undefined when the header + * is absent or empty so the DB column gets a clean NULL. + */ +function getClientIp(c: Context): string | undefined { + const xff = c.req.header('x-forwarded-for'); + if (!xff) return undefined; + const last = xff.split(',').pop()?.trim(); + return last && last.length > 0 ? last : undefined; +} + +// --- AS metadata (RFC 8414) -------------------------------------------------- +// Every endpoint URL is derived from PUBLIC_URL via getIssuer() — never +// hardcode api.pagent.link. getIssuer() reads env on every call so a test +// or future config-reload can mutate PUBLIC_URL and observe the change. + +authRoutes.get('/.well-known/oauth-authorization-server', (c) => { + const issuer = getIssuer(); + return c.json({ + issuer, + authorization_endpoint: `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, + revocation_endpoint: `${issuer}/oauth/revoke`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + // Public clients only — MCP clients can't keep a secret. Matches spec §3.3. + token_endpoint_auth_methods_supported: ['none'], + // S256 only; `plain` is explicitly excluded per the task and PKCE best + // practice (RFC 7636 §4.2 recommends S256 wherever the client can do it). + code_challenge_methods_supported: ['S256'], + scopes_supported: SCOPES_SUPPORTED, + service_documentation: DOCS_URL, + }); +}); + +// --- Protected Resource metadata (RFC 9728) --------------------------------- +// MCP clients fetch this after a 401 on /mcp to learn which AS to ask. Since +// pagent co-hosts AS and RS, `resource` and `authorization_servers[0]` are +// the same URL — both derive from PUBLIC_URL. + +authRoutes.get('/.well-known/oauth-protected-resource', (c) => { + const resource = getIssuer(); + return c.json({ + resource, + authorization_servers: [resource], + scopes_supported: SCOPES_SUPPORTED, + // Header-only — query/body bearer methods are deprecated (RFC 6750 §2.2, + // 2.3) and we don't accept them on /mcp. + bearer_methods_supported: ['header'], + resource_name: 'Pagent API', + resource_documentation: DOCS_URL, + }); +}); + +// --- JWKS (RFC 7517) --------------------------------------------------------- +// The Ed25519 public key that signs access tokens. Resource servers fetch +// this to verify token signatures. getJwks() returns the cached JWK built at +// initKeys() time — no I/O. Throws if initKeys() never ran; the global +// error handler in app.ts surfaces that as a 500 with a request_id. + +authRoutes.get('/.well-known/jwks.json', (c) => { + return c.json(getJwks()); +}); + +// --- Dynamic client registration (RFC 7591) --------------------------------- +// MCP clients self-register before starting the authorization code flow. +// Rate-limited per IP (10/hour) — registration is cheap but unbounded growth +// would let an attacker fill oauth_clients with junk rows. The cap mirrors +// auth-design spec §7.3 (Rate limiting table) and is intentionally distinct +// from the POST /new bucket so heavy MCP traffic can't lock out +// registrations. + +const REGISTER_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const REGISTER_LIMIT = 10; +const REGISTER_RETRY_AFTER_SECONDS = Math.ceil(REGISTER_WINDOW_MS / 1000); + +const registerLimiter = rateLimiter({ + windowMs: REGISTER_WINDOW_MS, + limit: REGISTER_LIMIT, + // IETF draft-7 RateLimit-* headers (same as POST /new) so clients can + // discover the budget without hard-coding it. + standardHeaders: 'draft-7', + // Same last-hop trust model as POST /new — see apps/api/client-key.ts. + keyGenerator: (c: Context) => clientKey(c.req.header('x-forwarded-for')), + handler: (c) => { + c.header('Retry-After', String(REGISTER_RETRY_AFTER_SECONDS)); + return c.json( + { + error: 'rate_limited', + retry_after_seconds: REGISTER_RETRY_AFTER_SECONDS, + message: `Too many client registrations from this IP; retry after ${REGISTER_RETRY_AFTER_SECONDS} seconds`, + }, + 429, + ); + }, +}); + +authRoutes.post('/oauth/register', registerLimiter, async (c) => { + // Parse-or-null is the same pattern as POST /new — gives us a single + // explicit branch for malformed JSON before zod / validation runs. + const raw = await c.req.json().catch(() => null); + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return c.json( + { + error: 'invalid_client_metadata', + error_description: 'request body must be a JSON object', + }, + 400, + ); + } + try { + const client = await registerClient(raw); + // RFC 7591 §3.2.1 — 201 Created with the client information document. + return c.json(client, 201); + } catch (err) { + if (err instanceof InvalidClientMetadataError) { + return c.json({ error: 'invalid_client_metadata', error_description: err.description }, 400); + } + // Anything else (e.g. transient DB error after withRetry exhausts) bubbles + // up to app.onError which surfaces a 500 with a request_id. + throw err; + } +}); + +// --- GET /oauth/authorize --------------------------------------------------- +// Login page. Per spec §3.4: validates the authorize request parameters +// (client_id, redirect_uri exact match, PKCE), then renders an HTML page +// with "Continue with Google" + magic-link form. Errors are shown on the +// page itself (not redirected) per OAuth 2.1 §4.1.2.1 — we only redirect to +// `redirect_uri` if we trust it. +// +// browser_session=1 is the renderer/dashboard path: no client_id required, +// the rendered page leads to a session cookie instead of an auth code. +// Rate-limited at 30/IP/min per spec §7.3. + +const AUTHORIZE_WINDOW_MS = 60 * 1000; // 1 minute +const AUTHORIZE_LIMIT = 30; +const AUTHORIZE_RETRY_AFTER_SECONDS = Math.ceil(AUTHORIZE_WINDOW_MS / 1000); + +const authorizeLimiter = rateLimiter({ + windowMs: AUTHORIZE_WINDOW_MS, + limit: AUTHORIZE_LIMIT, + standardHeaders: 'draft-7', + keyGenerator: (c: Context) => clientKey(c.req.header('x-forwarded-for')), + handler: (c) => { + c.header('Retry-After', String(AUTHORIZE_RETRY_AFTER_SECONDS)); + return c.json( + { + error: 'rate_limited', + retry_after_seconds: AUTHORIZE_RETRY_AFTER_SECONDS, + message: `Too many authorize requests from this IP; retry after ${AUTHORIZE_RETRY_AFTER_SECONDS} seconds`, + }, + 429, + ); + }, +}); + +/** + * Render the login page with an error banner. The state JWT is omitted on + * hard errors (invalid client_id / redirect_uri) so the user can't proceed + * — there's no recoverable flow to resume. + */ +async function renderError(c: Context, message: string, status: 400 | 503 = 400) { + return c.html(renderLoginPage({ error: message }), status); +} + +authRoutes.get('/oauth/authorize', authorizeLimiter, async (c) => { + const query = c.req.query(); + + // Browser-session path: no PKCE / client validation. Just stamp a state JWT + // and render the login page so the user can pick a provider. + if (query.browser_session === '1') { + const signedState = await signStateJwt({ browserSession: true }); + return c.html(renderLoginPage({ signedState })); + } + + // MCP-client path: every PKCE-relevant parameter is required. Surface a + // clean error on the login page itself when something's missing — the + // caller likely supplied a typo and a 400 with an explanation is more + // useful than a redirect-with-error to an untrusted URI. + const { client_id, redirect_uri, code_challenge, code_challenge_method, scope, state } = query; + + if (typeof client_id !== 'string' || client_id.length === 0) { + return renderError(c, 'Missing required parameter: client_id'); + } + if (typeof redirect_uri !== 'string' || redirect_uri.length === 0) { + return renderError(c, 'Missing required parameter: redirect_uri'); + } + if (typeof code_challenge !== 'string' || code_challenge.length === 0) { + return renderError(c, 'Missing required parameter: code_challenge'); + } + // S256-only — `plain` is explicitly excluded per AS metadata + RFC 7636 + // §4.2 (S256 is mandatory wherever the client can do it; modern MCP + // clients can). + if (code_challenge_method !== 'S256') { + return renderError(c, 'code_challenge_method must be S256'); + } + + const client = await getClient(client_id); + if (!client) { + return renderError(c, 'Unknown client_id'); + } + // Exact string match — spec §7.5 (Open redirect prevention). No wildcards, + // no host-only matching, no scheme tolerance. + if (!client.redirect_uris.includes(redirect_uri)) { + return renderError(c, 'redirect_uri does not match a registered URI for this client'); + } + + const signedState = await signStateJwt({ + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + scope: typeof scope === 'string' && scope.length > 0 ? scope : undefined, + state: typeof state === 'string' && state.length > 0 ? state : undefined, + }); + return c.html(renderLoginPage({ signedState })); +}); + +// --- GET /oauth/callback/google --------------------------------------------- +// Google's redirect after the user grants/denies consent. Two flows converge +// here: +// +// MCP-client flow (default): verify state JWT → exchange Google code → +// upsert user → mint Pagent auth code → 302 to redirect_uri?code=&state=. +// +// Browser-session flow (`browserSession: true` in state): verify state → +// exchange code → upsert user → createSession() → set Set-Cookie header +// → 302 to `/`. No auth code; the renderer reads /auth/me with the +// freshly-issued cookie. + +authRoutes.get('/oauth/callback/google', async (c) => { + const code = c.req.query('code'); + const state = c.req.query('state'); + if (typeof code !== 'string' || code.length === 0) { + return renderError(c, 'Google callback missing code parameter'); + } + if (typeof state !== 'string' || state.length === 0) { + return renderError(c, 'Google callback missing state parameter'); + } + + // verifyStateJwt throws on tamper/expiry/wrong-iss-aud. The catch maps + // every failure mode to a single user-facing message — distinguishing + // them would leak verification details and isn't actionable for the user + // (their only recourse is to restart from the MCP client). + let claims: Awaited>; + try { + claims = await verifyStateJwt(state); + } catch { + return renderError(c, 'Authorization session expired or invalid. Please restart sign-in.'); + } + + // For the browser-session path the only state-bound check is the JWT + // signature (already validated). The MCP-client path additionally validates + // every PKCE field — those checks would reject a browser-session callback, + // so split the flow here before re-validating. + if (claims.browserSession) { + let profile: Awaited>; + try { + profile = await exchangeGoogleCode(code); + } catch { + return renderError(c, 'Google sign-in failed. Please try again.'); + } + + const user = await upsertUser({ + email: profile.email, + name: profile.name, + avatarUrl: profile.picture, + }); + + const sessionToken = await createSession( + user.id, + getClientIp(c), + c.req.header('user-agent') ?? undefined, + ); + setSessionCookie(c, sessionToken); + // Redirect to the application root. PUBLIC_URL points at the API host, + // which isn't where the renderer lives — so we use a relative `/` and let + // the browser resolve it against the request origin. Operators who deploy + // the API on a separate hostname from the renderer can configure a CORS + // / reverse-proxy setup so this still lands on the dashboard. + return c.redirect('/', 302); + } + + // MCP-client flow: validate the resumed claims — they were signed by us 15 + // minutes ago but the client could have been deleted in between, or the + // redirect_uri could have changed (we re-validate to defend against that + // edge case). + if (!claims.clientId || !claims.redirectUri || !claims.codeChallenge) { + return renderError(c, 'Authorization state missing required fields.'); + } + const client = await getClient(claims.clientId); + if (!client || !client.redirect_uris.includes(claims.redirectUri)) { + return renderError(c, 'Client registration changed during sign-in. Please restart.'); + } + + // Exchange + upsert. Surface a single generic message if Google rejects + // the code (typically: stale or already-used) — the verbose error from + // exchangeGoogleCode is logged via the global error handler when it + // throws, but we don't want to echo Google's internals to end users. + let profile: Awaited>; + try { + profile = await exchangeGoogleCode(code); + } catch { + return renderError(c, 'Google sign-in failed. Please try again.'); + } + + const user = await upsertUser({ + email: profile.email, + name: profile.name, + avatarUrl: profile.picture, + }); + + // Issue the Pagent authorization code with the original PKCE challenge. + // The token endpoint (Task 06) will require the code_verifier matching + // this code_challenge before issuing access/refresh tokens. + const pagentCode = await createAuthCode( + user.id, + claims.clientId, + claims.redirectUri, + claims.codeChallenge, + 'S256', + claims.scope ?? null, + ); + + // Build the final redirect: redirect_uri?code=...&state=... + // (state is the MCP client's original CSRF state, not our internal JWT — + // they decode it back to recognize the response as theirs.) + const target = new URL(claims.redirectUri); + target.searchParams.set('code', pagentCode); + if (claims.state) target.searchParams.set('state', claims.state); + return c.redirect(target.toString(), 302); +}); + +// --- POST /oauth/magic/send ------------------------------------------------- +// Magic Link send endpoint. Accepts a form-encoded or JSON body with `email` +// and the signed state JWT from the login page, validates the email format, +// decodes the state to extract the authorize context, then dispatches an +// email via nodemailer. +// +// Returns 200 with the same response shape regardless of whether the email +// is registered — anti-enumeration per spec §7.6. The same code path runs +// for new and returning users (magic link doubles as sign-up). +// +// Rate-limited at 5 / email / 15 min per spec §7.3. We use the local +// `RateLimiter` (not hono-rate-limiter's middleware) because we need to +// inspect the request body to derive the key, and hono-rate-limiter's +// keyGenerator runs before the body is parsed — re-reading it from middleware +// would require buffering. + +const MAGIC_SEND_LIMIT = 5; +const MAGIC_SEND_WINDOW_MS = 15 * 60 * 1000; // 15 minutes + +/** + * Per-email rate limiter for POST /oauth/magic/send. Exported so tests can + * `reset()` it between cases — the module-level state persists across + * `app.fetch` calls in the same vitest run, and a test that exhausts the + * bucket would leak into the next test if not cleared. + */ +export const magicSendLimiter = new RateLimiter(MAGIC_SEND_LIMIT, MAGIC_SEND_WINDOW_MS); + +/** + * Minimal RFC 5322 email validation — enough to reject obvious malformations + * without going full RFC 5322 (which allows e.g. quoted local parts that no + * real provider accepts). Mirrors the regex used by HTML5's validation and is good enough for "did the user typo a space in" + * level checks. Real validation happens at the SMTP RCPT TO step. + */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Parse the magic/send request body. Accepts both + * application/x-www-form-urlencoded (the login-page form's default content + * type) and application/json (programmatic callers, tests). Both must include + * `email`; `state` is optional (browser-session flow has no MCP client to + * resume). + */ +async function parseMagicSendBody( + c: Context, +): Promise<{ email?: unknown; state?: unknown } | null> { + const contentType = (c.req.header('content-type') ?? '').toLowerCase(); + if (contentType.includes('application/json')) { + return c.req.json().catch(() => null); + } + // Default to form-encoded — that's what emits. + try { + const form = await c.req.parseBody(); + return form as { email?: unknown; state?: unknown }; + } catch { + return null; + } +} + +authRoutes.post('/oauth/magic/send', async (c) => { + // Refuse early if SMTP isn't configured. The error shape mirrors the other + // 503s in this file (e.g. openapi_unavailable) so monitoring can group them. + if (!env.SMTP_HOST) { + return c.json( + { + error: 'service_unavailable', + message: + 'Magic link sign-in is not configured on this deployment. Please use Google sign-in.', + }, + 503, + ); + } + + const body = await parseMagicSendBody(c); + if (!body || typeof body !== 'object') { + return c.json({ error: 'invalid_request', message: 'Request body is malformed.' }, 400); + } + const email = typeof body.email === 'string' ? body.email.trim() : ''; + const stateInput = typeof body.state === 'string' ? body.state : ''; + + if (!email || !EMAIL_REGEX.test(email)) { + return c.json( + { error: 'invalid_request', message: 'Please provide a valid email address.' }, + 400, + ); + } + const lowerEmail = email.toLowerCase(); + + // Rate-limit keyed on the lowercased email. Per spec §7.3 — keys by email + // (not IP) so a single malicious sender from many IPs still gets throttled, + // and so a victim's email can't be spammed from multiple IPs. + const rl = magicSendLimiter.check(lowerEmail); + if (!rl.allowed) { + c.header('Retry-After', String(rl.secondsUntilReset)); + return c.json( + { + error: 'rate_limited', + retry_after_seconds: rl.secondsUntilReset, + message: `Too many magic link requests for this email; retry after ${rl.secondsUntilReset} seconds`, + }, + 429, + ); + } + + // Decode the signed state JWT to recover the authorize context. The state + // is the same JWT the GET /oauth/authorize page embedded in the form's + // hidden field. A missing / invalid state still produces a magic link, but + // with an empty context — the verify step will then redirect to the + // configured fallback (or render an error if there's no redirect_uri). + let authorizeContext: Parameters[1] = {}; + if (stateInput) { + try { + const claims = await verifyStateJwt(stateInput); + authorizeContext = { + clientId: claims.clientId, + redirectUri: claims.redirectUri, + codeChallenge: claims.codeChallenge, + // Re-emit the method only when a challenge is present — the auth-code + // insert requires both or neither. + codeChallengeMethod: claims.codeChallenge ? 'S256' : undefined, + scope: claims.scope, + state: claims.state, + browserSession: claims.browserSession, + }; + } catch { + // Invalid / expired state — proceed with empty context. The verify + // endpoint will surface a clear error when the user clicks the link. + // We deliberately don't 400 here because that would distinguish "valid + // state but unregistered email" from "invalid state" via response + // shape — small enumeration leak. + } + } + + // Send. Any infrastructure failure (SMTP timeout, DB blip) bubbles up to + // the global error handler. We don't try/catch here because returning 200 + // on a failed send would silently swallow the problem — the user would + // never get an email and have no error to show. + try { + await sendMagicLink(lowerEmail, authorizeContext); + } catch (err) { + if (err instanceof SmtpUnavailableError) { + // Should already have been caught by the env.SMTP_HOST check above, + // but defensively return 503 here too in case env mutates between + // checks (e.g. in tests). + return c.json( + { + error: 'service_unavailable', + message: + 'Magic link sign-in is not configured on this deployment. Please use Google sign-in.', + }, + 503, + ); + } + throw err; + } + + return c.json({ + ok: true, + message: 'Check your email for a sign-in link. The link expires in 15 minutes.', + }); +}); + +// --- GET /oauth/magic ------------------------------------------------------- +// Magic Link verify endpoint. The user lands here after clicking the link in +// their email. We: +// 1. Look up the token (atomically consuming it). +// 2. Upsert the user by email — creates a row on first-time login. +// 3. Mint a Pagent auth code bound to the original PKCE challenge. +// 4. 302 to the MCP client's redirect_uri with code + state. +// +// Token reuse, expiry, and tampering all surface as the same error page so +// we don't leak verification state. This endpoint is intentionally not rate- +// limited per IP: the 32-byte random token is already brute-force-resistant, +// and a legitimate user clicking the link twice (e.g. via a link prefetcher) +// should see an error, not a 429. + +authRoutes.get('/oauth/magic', async (c) => { + const token = c.req.query('token'); + if (typeof token !== 'string' || token.length === 0) { + return renderError(c, 'Magic link is missing the token parameter.'); + } + + let consumed: Awaited>; + try { + consumed = await verifyMagicLink(token); + } catch (err) { + if (err instanceof InvalidMagicLinkError) { + return renderError( + c, + 'This sign-in link has expired or has already been used. Please request a new one.', + ); + } + throw err; + } + + // Upsert the user. The magic link flow doubles as sign-up so this is the + // first time we see the email for brand-new users. + const user = await upsertUser({ email: consumed.email }); + + const ctx = consumed.authorizeContext; + + // Browser-session flow: no MCP client to redirect back to. Issue a session + // cookie and send the user to the application root. Checked before the + // redirect_uri guard because this path is *expected* not to carry one. + if (ctx.browserSession) { + const sessionToken = await createSession( + user.id, + getClientIp(c), + c.req.header('user-agent') ?? undefined, + ); + setSessionCookie(c, sessionToken); + return c.redirect('/', 302); + } + + // Without a redirect_uri there's nowhere to send the user. + if (!ctx.redirectUri) { + return renderError( + c, + 'This sign-in link is not bound to an OAuth flow. Please restart sign-in from your client.', + ); + } + + if (!ctx.clientId || !ctx.codeChallenge) { + return renderError( + c, + 'Magic link is missing PKCE binding. Please restart sign-in from your client.', + ); + } + + // Re-validate the client + redirect_uri at consume time — the registration + // could have changed in the 15 minutes since the email was sent. + const client = await getClient(ctx.clientId); + if (!client || !client.redirect_uris.includes(ctx.redirectUri)) { + return renderError(c, 'Client registration changed during sign-in. Please restart.'); + } + + const pagentCode = await createAuthCode( + user.id, + ctx.clientId, + ctx.redirectUri, + ctx.codeChallenge, + ctx.codeChallengeMethod ?? 'S256', + ctx.scope ?? null, + ); + + const target = new URL(ctx.redirectUri); + target.searchParams.set('code', pagentCode); + if (ctx.state) target.searchParams.set('state', ctx.state); + return c.redirect(target.toString(), 302); +}); + +// --- POST /oauth/token ------------------------------------------------------ +// OAuth 2.1 token endpoint. Dispatches on `grant_type`: +// - authorization_code: exchange auth code + PKCE verifier for tokens +// - refresh_token: rotate refresh token, mint new access token +// Body must be application/x-www-form-urlencoded (RFC 6749 §3.2). JSON bodies +// are rejected with invalid_request — that's not the wire format the OAuth +// spec mandates and accepting both would invite confusion. +// +// Rate-limited 20/IP/min per spec §7.3. Same per-IP bucket pattern as the +// other auth endpoints (last-hop X-Forwarded-For via clientKey). + +const TOKEN_WINDOW_MS = 60 * 1000; // 1 minute +const TOKEN_LIMIT = 20; +const TOKEN_RETRY_AFTER_SECONDS = Math.ceil(TOKEN_WINDOW_MS / 1000); + +const tokenLimiter = rateLimiter({ + windowMs: TOKEN_WINDOW_MS, + limit: TOKEN_LIMIT, + standardHeaders: 'draft-7', + keyGenerator: (c: Context) => clientKey(c.req.header('x-forwarded-for')), + handler: (c) => { + c.header('Retry-After', String(TOKEN_RETRY_AFTER_SECONDS)); + return c.json( + { + error: 'rate_limited', + retry_after_seconds: TOKEN_RETRY_AFTER_SECONDS, + message: `Too many token requests from this IP; retry after ${TOKEN_RETRY_AFTER_SECONDS} seconds`, + }, + 429, + ); + }, +}); + +/** + * Serialize a TokenError into the OAuth 2.1 error response shape. Status + * comes from the error (401 for invalid_client, 400 for the rest). Body is + * always `{ error, error_description }` per RFC 6749 §5.2. + */ +function tokenErrorResponse(c: Context, err: TokenError) { + return c.json({ error: err.code, error_description: err.description }, err.status); +} + +/** + * Parse the body of a token-endpoint request. Strictly form-encoded — + * application/json is rejected with invalid_request because the OAuth spec + * mandates form encoding and accepting JSON would invite client confusion. + * + * Returns the parsed body as a string-keyed map (form values are always + * strings; multipart-uploaded files would be File objects but we don't + * accept multipart here). + */ +async function parseTokenBody(c: Context): Promise | null> { + const contentType = (c.req.header('content-type') ?? '').toLowerCase(); + // Strict form-encoded check. We accept the canonical and the variant with + // a charset suffix (e.g. `application/x-www-form-urlencoded; charset=UTF-8`). + if (!contentType.includes('application/x-www-form-urlencoded')) { + return null; + } + try { + const form = await c.req.parseBody(); + const out: Record = {}; + for (const [k, v] of Object.entries(form)) { + if (typeof v === 'string') out[k] = v; + } + return out; + } catch { + return null; + } +} + +authRoutes.post('/oauth/token', tokenLimiter, async (c) => { + const body = await parseTokenBody(c); + if (!body) { + return c.json( + { + error: 'invalid_request', + error_description: 'Content-Type must be application/x-www-form-urlencoded', + }, + 400, + ); + } + + const grantType = body.grant_type; + if (!grantType) { + return c.json( + { error: 'invalid_request', error_description: 'Missing grant_type parameter' }, + 400, + ); + } + + try { + if (grantType === 'authorization_code') { + const { code, client_id, redirect_uri, code_verifier } = body; + const response = await exchangeAuthCode( + code ?? '', + client_id ?? '', + redirect_uri ?? '', + code_verifier ?? '', + ); + // RFC 6749 §5.1 — token responses MUST set Cache-Control: no-store and + // Pragma: no-cache so intermediaries don't cache the bearer credential. + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + return c.json(response, 200); + } + if (grantType === 'refresh_token') { + const { refresh_token, client_id } = body; + const response = await providerRefreshToken(refresh_token ?? '', client_id ?? ''); + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + return c.json(response, 200); + } + return c.json( + { + error: 'unsupported_grant_type', + error_description: `Grant type '${grantType}' is not supported`, + }, + 400, + ); + } catch (err) { + if (err instanceof TokenError) { + return tokenErrorResponse(c, err); + } + throw err; + } +}); + +// --- POST /oauth/revoke ----------------------------------------------------- +// RFC 7009 token revocation endpoint. Always returns 200 — distinguishing +// "token revoked" from "token unknown" would let an attacker probe which +// tokens are valid. Same form-encoded body shape as /oauth/token; we don't +// even reject non-form content types here (RFC 7009 §2.1 doesn't require it) +// — a misshapen body just means we have nothing to revoke and return 200. +// +// Rate-limited 20/IP/min mirroring the token endpoint. The two share an +// abuse profile (cheap to call, valuable to an attacker enumerating tokens), +// so a single per-endpoint budget is the right granularity. + +const revokeLimiter = rateLimiter({ + windowMs: TOKEN_WINDOW_MS, + limit: TOKEN_LIMIT, + standardHeaders: 'draft-7', + keyGenerator: (c: Context) => clientKey(c.req.header('x-forwarded-for')), + handler: (c) => { + c.header('Retry-After', String(TOKEN_RETRY_AFTER_SECONDS)); + return c.json( + { + error: 'rate_limited', + retry_after_seconds: TOKEN_RETRY_AFTER_SECONDS, + message: `Too many revoke requests from this IP; retry after ${TOKEN_RETRY_AFTER_SECONDS} seconds`, + }, + 429, + ); + }, +}); + +authRoutes.post('/oauth/revoke', revokeLimiter, async (c) => { + // Best-effort parse. RFC 7009 §2.2 says invalid requests still return 200 + // (with the exception of unsupported_token_type, which we don't surface + // since we accept the prefix-based discriminator instead of relying on + // token_type_hint). A malformed body means there's nothing to revoke. + const body = await parseTokenBody(c); + if (body && typeof body.token === 'string' && body.token.length > 0) { + try { + await revokeToken(body.token, body.token_type_hint, body.client_id); + } catch { + // Swallow — RFC 7009 mandates 200 regardless of failure. The error is + // logged via the global error handler if it propagates from a deeper + // bug, but we don't want a transient DB blip to surface as 500. + } + } + return c.body(null, 200); +}); + +// --- GET /auth/me ----------------------------------------------------------- +// Browser-session profile endpoint. Returns the authenticated user's full +// profile (handle, email, name, avatar) for the renderer/dashboard to display. +// +// Cookie-only by design — Bearer-authenticated requests get 401 here. Bearer +// is for MCP / API clients which already received user claims in the JWT at +// /oauth/token time; pointing them at /auth/me would be redundant and would +// expose this endpoint to the API attack surface. By restricting to cookie +// auth we keep the browser path's identity model tight. + +authRoutes.get('/auth/me', async (c) => { + const user = c.var.user; + if (!user || user.authMethod !== 'cookie') { + return c.json({ error: 'unauthorized', message: 'Authentication required' }, 401); + } + // The middleware-supplied AuthUser only carries id/email/handle. /auth/me + // promises a fuller shape (name, avatar_url) so the dashboard can render a + // user card without a follow-up lookup. We re-query the user row here. + const row = await db.getUserById(user.id); + if (!row) { + return c.json({ error: 'unauthorized', message: 'User not found' }, 401); + } + return c.json({ + id: row.id, + handle: row.handle, + email: row.email, + name: row.name, + avatar_url: row.avatar_url, + }); +}); + +// --- POST /auth/logout ------------------------------------------------------ +// Browser-session logout. Deletes the DB row (so the cookie can't be re-used +// even if the browser keeps it) and clears the cookie via Set-Cookie with +// Max-Age=0. Idempotent — second logout is a no-op. +// +// We always clear the cookie, even if the session was already gone server-side +// (e.g. another tab logged out). That makes the client behavior predictable: +// after a successful POST, the browser jar no longer holds the credential. + +authRoutes.post('/auth/logout', async (c) => { + const sessionToken = getCookie(c, SESSION_COOKIE_NAME); + if (sessionToken) { + await deleteSession(sessionToken); + } + clearSessionCookie(c); + return c.json({ ok: true }); +}); diff --git a/apps/api/auth/session.test.ts b/apps/api/auth/session.test.ts new file mode 100644 index 0000000..bd6d47b --- /dev/null +++ b/apps/api/auth/session.test.ts @@ -0,0 +1,219 @@ +/** + * Session helpers unit tests — db is fully mocked so the createSession ↔ + * lookupSession ↔ deleteSession lifecycle can be verified without a real + * Postgres instance. We assert the contract every higher layer relies on: + * + * - createSession returns a 32-char hex token (16 bytes hex-encoded). + * - The DB only ever sees SHA-256(raw) — the raw token never lands in + * `insertSession`'s arguments as `tokenHash`. + * - lookupSession returns null for unknown / expired tokens. + * - lookupSession bumps `expires_at` by SESSION_MAX_AGE_DAYS on success + * (sliding expiry). + * - deleteSession passes the hash, not the raw token. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; + +vi.mock('../db.ts', () => ({ + insertSession: vi.fn(() => Promise.resolve()), + getSessionWithUserByTokenHash: vi.fn(() => Promise.resolve(null)), + extendSessionExpiry: vi.fn(() => Promise.resolve()), + deleteSessionByTokenHash: vi.fn(() => Promise.resolve()), +})); + +import * as db from '../db.ts'; +import { createSession, lookupSession, deleteSession } from './session.ts'; +import { env } from '../schemas.ts'; + +const HEX_32 = /^[a-f0-9]{32}$/; + +function sha256Hex(s: string): string { + return createHash('sha256').update(s).digest('hex'); +} + +beforeEach(() => { + vi.clearAllMocks(); + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValue(null); +}); + +describe('createSession', () => { + it('returns a 32-char hex token (16 bytes of entropy)', async () => { + const token = await createSession('user-uuid-1'); + expect(token).toMatch(HEX_32); + }); + + it('stores SHA-256(token) in the DB, never the raw token', async () => { + const token = await createSession('user-uuid-1'); + expect(db.insertSession).toHaveBeenCalledTimes(1); + const call = (db.insertSession as ReturnType).mock.calls[0][0]; + expect(call.tokenHash).toBe(sha256Hex(token)); + // Belt and suspenders — the raw token must not equal the hash. + expect(call.tokenHash).not.toBe(token); + // No raw-token field exposed on the insert. + expect(JSON.stringify(call)).not.toContain(token); + }); + + it('uses SESSION_MAX_AGE_DAYS for expires_at', async () => { + const before = Date.now(); + await createSession('user-uuid-1'); + const after = Date.now(); + const call = (db.insertSession as ReturnType).mock.calls[0][0]; + const expiresMs = (call.expiresAt as Date).getTime(); + const expectedMin = before + env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; + const expectedMax = after + env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; + expect(expiresMs).toBeGreaterThanOrEqual(expectedMin); + expect(expiresMs).toBeLessThanOrEqual(expectedMax); + }); + + it('passes through ip/userAgent when provided', async () => { + await createSession('user-uuid-1', '203.0.113.5', 'TestAgent/1.0'); + const call = (db.insertSession as ReturnType).mock.calls[0][0]; + expect(call.ipAddress).toBe('203.0.113.5'); + expect(call.userAgent).toBe('TestAgent/1.0'); + }); + + it('stores null for ip/userAgent when omitted', async () => { + await createSession('user-uuid-1'); + const call = (db.insertSession as ReturnType).mock.calls[0][0]; + expect(call.ipAddress).toBeNull(); + expect(call.userAgent).toBeNull(); + }); + + it('generates a different token on every call', async () => { + const a = await createSession('user-uuid-1'); + const b = await createSession('user-uuid-1'); + expect(a).not.toBe(b); + }); +}); + +describe('lookupSession', () => { + it('round-trip: creates a session, then resolves it by raw token', async () => { + // Simulate insertSession storing the hash; lookupSession then "sees" the + // same row coming back from getSessionWithUserByTokenHash. + let stored: { tokenHash: string } | null = null; + (db.insertSession as ReturnType).mockImplementation( + async (input: { tokenHash: string }) => { + stored = input; + }, + ); + (db.getSessionWithUserByTokenHash as ReturnType).mockImplementation( + async (hash: string) => + stored && stored.tokenHash === hash + ? { + session_id: 'session-uuid-1', + user_id: 'user-uuid-1', + email: 'alex@blockful.io', + handle: 'alex', + expires_at: new Date(Date.now() + 1000), + } + : null, + ); + + const token = await createSession('user-uuid-1'); + const user = await lookupSession(token); + + expect(user).not.toBeNull(); + expect(user?.id).toBe('user-uuid-1'); + expect(user?.email).toBe('alex@blockful.io'); + expect(user?.handle).toBe('alex'); + expect(user?.authMethod).toBe('cookie'); + }); + + it('returns null when the token hash is unknown to the DB', async () => { + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValue(null); + const user = await lookupSession('some-random-token'); + expect(user).toBeNull(); + }); + + it('returns null on empty token', async () => { + const user = await lookupSession(''); + expect(user).toBeNull(); + // Defensive: should NOT hit the DB at all for an empty input. + expect(db.getSessionWithUserByTokenHash).not.toHaveBeenCalled(); + }); + + it('extends expires_at on successful lookup (sliding expiry)', async () => { + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValue({ + session_id: 'session-uuid-2', + user_id: 'user-uuid-1', + email: 'alex@blockful.io', + handle: 'alex', + expires_at: new Date(Date.now() + 60_000), + }); + const before = Date.now(); + await lookupSession('any-token'); + expect(db.extendSessionExpiry).toHaveBeenCalledTimes(1); + const [sessionId, newExpires] = (db.extendSessionExpiry as ReturnType).mock + .calls[0]; + expect(sessionId).toBe('session-uuid-2'); + // newExpires should be ~ now + SESSION_MAX_AGE_DAYS. + const expectedMin = before + env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; + expect((newExpires as Date).getTime()).toBeGreaterThanOrEqual(expectedMin); + }); + + it('does not fail the request if extendSessionExpiry throws', async () => { + (db.getSessionWithUserByTokenHash as ReturnType).mockResolvedValue({ + session_id: 'session-uuid-3', + user_id: 'user-uuid-2', + email: 'bob@example.com', + handle: null, + expires_at: new Date(Date.now() + 60_000), + }); + (db.extendSessionExpiry as ReturnType).mockRejectedValueOnce( + new Error('db blip'), + ); + const user = await lookupSession('any-token'); + expect(user).not.toBeNull(); + expect(user?.id).toBe('user-uuid-2'); + expect(user?.handle).toBeNull(); + }); + + it('hashes the raw token before passing it to the DB', async () => { + await lookupSession('plain-token-value'); + expect(db.getSessionWithUserByTokenHash).toHaveBeenCalledWith(sha256Hex('plain-token-value')); + }); +}); + +describe('deleteSession', () => { + it('hashes the raw token before deletion', async () => { + await deleteSession('raw-cookie-token'); + expect(db.deleteSessionByTokenHash).toHaveBeenCalledWith(sha256Hex('raw-cookie-token')); + }); + + it('no-ops on empty token', async () => { + await deleteSession(''); + expect(db.deleteSessionByTokenHash).not.toHaveBeenCalled(); + }); + + it('after delete + DB now returning null, lookupSession yields null', async () => { + // Simulate: insert, then delete clears the stored row, then lookup. + let stored: { tokenHash: string } | null = null; + (db.insertSession as ReturnType).mockImplementation( + async (input: { tokenHash: string }) => { + stored = input; + }, + ); + (db.deleteSessionByTokenHash as ReturnType).mockImplementation( + async (hash: string) => { + if (stored && stored.tokenHash === hash) stored = null; + }, + ); + (db.getSessionWithUserByTokenHash as ReturnType).mockImplementation( + async (hash: string) => + stored && stored.tokenHash === hash + ? { + session_id: 'session-uuid-4', + user_id: 'user-uuid-3', + email: 'gone@example.com', + handle: null, + expires_at: new Date(Date.now() + 1000), + } + : null, + ); + + const token = await createSession('user-uuid-3'); + expect(await lookupSession(token)).not.toBeNull(); + await deleteSession(token); + expect(await lookupSession(token)).toBeNull(); + }); +}); diff --git a/apps/api/auth/session.ts b/apps/api/auth/session.ts new file mode 100644 index 0000000..419b980 --- /dev/null +++ b/apps/api/auth/session.ts @@ -0,0 +1,122 @@ +/** + * Browser session helpers. + * + * Sessions back the `pagent_session` cookie. The raw token is a 128-bit + * random hex string generated at login time and stored only in the user's + * cookie jar. Server-side we only ever persist the SHA-256 hash — a + * dropped database backup is therefore useless for resuming sessions. + * + * Sliding expiry: every successful `lookupSession` extends `expires_at` by + * SESSION_MAX_AGE_DAYS so an actively-used session never times out. + * Inactive sessions still age out after the configured window. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §6 (Middleware + * design), §7.2 (Token storage), §7.4 (CSRF protection). + */ +import { createHash, randomBytes } from 'node:crypto'; +import * as db from '../db.ts'; +import { env } from '../schemas.ts'; +import type { AuthUser } from './middleware.ts'; + +// 16 bytes (128 bits) of entropy is well past brute-force feasibility for a +// cookie that's also bound to user-agent/IP at issue time. Hex doubles the +// length to 32 chars — fits in any browser cookie size limit without breaking +// a sweat. Matches the page-id sizing for visual consistency in log lines. +const SESSION_TOKEN_BYTES = 16; + +// Number of milliseconds in one day. Used to translate +// SESSION_MAX_AGE_DAYS into an absolute Date for the expiry column. +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Hash a raw session token for DB storage. Pure SHA-256 hex — no salt, no + * HMAC. The raw token already carries 128 bits of entropy so salting would + * buy us nothing, and a leaked HMAC key would compromise every hash. + * Mirrors `refresh_tokens` / `magic_links` token storage strategy. + */ +function hashSessionToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** + * Compute the absolute expiry timestamp for a session based on the + * configured `SESSION_MAX_AGE_DAYS`. Computed at call time so a config + * reload (or a test stubbing the env) is observable. + */ +function computeExpiresAt(): Date { + return new Date(Date.now() + env.SESSION_MAX_AGE_DAYS * MS_PER_DAY); +} + +/** + * Create a fresh browser session. Generates a 128-bit random hex token, + * stores SHA-256(token) plus session metadata (ip, user-agent, expiry), + * and returns the raw token so the caller can set it on a cookie. + * + * The raw token leaves this function once. After that it lives in the + * user's cookie jar and (briefly, per request) in `lookupSession`'s local + * variable — never in the database, never in logs. + */ +export async function createSession( + userId: string, + ip?: string, + userAgent?: string, +): Promise { + const raw = randomBytes(SESSION_TOKEN_BYTES).toString('hex'); + const tokenHash = hashSessionToken(raw); + await db.insertSession({ + userId, + tokenHash, + ipAddress: ip ?? null, + userAgent: userAgent ?? null, + expiresAt: computeExpiresAt(), + }); + return raw; +} + +/** + * Resolve a raw session token to an `AuthUser`. Returns null when the + * token is unknown, expired, or otherwise unusable — the caller (auth + * middleware) treats every "not a valid live session" outcome the same + * way (anonymous request). + * + * On a successful lookup, the session's `expires_at` is bumped forward by + * `SESSION_MAX_AGE_DAYS` — the sliding-expiry behaviour from spec §6. We + * do this *after* resolving the user so a DB blip on the extend doesn't + * fail the request (the row stays valid until the original `expires_at`). + * + * `authMethod` is set to 'cookie' here. The middleware overwrites it + * downstream so both code paths converge on the same shape, but populating + * it here keeps this function's return type honest. + */ +export async function lookupSession(token: string): Promise { + if (!token) return null; + const tokenHash = hashSessionToken(token); + const row = await db.getSessionWithUserByTokenHash(tokenHash); + if (!row) return null; + // Slide the expiry forward. Errors here are logged via the global error + // handler but shouldn't fail the request — the existing `expires_at` is + // still valid, so the user shouldn't see a 500 because of a backend blip + // on an expiry-bump UPDATE. Swallowing keeps the auth path resilient. + try { + await db.extendSessionExpiry(row.session_id, computeExpiresAt()); + } catch { + // Intentional: see above. + } + return { + id: row.user_id, + email: row.email, + handle: row.handle, + authMethod: 'cookie', + }; +} + +/** + * Delete a session by its raw token. Idempotent — second call against the + * same token is a no-op. Used by `POST /auth/logout` (the cookie is also + * cleared on the client side via Set-Cookie with a past expiry). + */ +export async function deleteSession(token: string): Promise { + if (!token) return; + const tokenHash = hashSessionToken(token); + await db.deleteSessionByTokenHash(tokenHash); +} diff --git a/apps/api/auth/state-jwt.ts b/apps/api/auth/state-jwt.ts new file mode 100644 index 0000000..aea216c --- /dev/null +++ b/apps/api/auth/state-jwt.ts @@ -0,0 +1,107 @@ +/** + * HMAC-SHA256 state JWT for the Google OAuth round-trip. + * + * The `state` query parameter we send to Google encodes the full MCP-client + * authorize request (client_id, redirect_uri, code_challenge, scope, and the + * client's own CSRF state). When Google calls /oauth/callback/google we can + * resume the flow from the JWT alone — no server-side cache needed. + * + * Signed (not encrypted) is sufficient: every field is information the + * client already provided, an attacker doesn't gain anything by reading + * them. Tampering is what we have to prevent (e.g. swapping redirect_uri), + * and HMAC-SHA256 with AUTH_STATE_SECRET catches that. + * + * Spec: docs/superpowers/specs/2026-05-17-auth-design.md §4.2 (state encoding), + * §7.7 (security rationale). + */ +import { SignJWT, jwtVerify } from 'jose'; +import { env } from '../schemas.ts'; + +// 15-minute expiry mirrors the auth-code TTL — the user shouldn't be stuck on +// Google's consent screen for longer than that in practice, and a longer +// window would just extend the window in which a stolen state JWT is useful. +const STATE_TTL_SECONDS = 15 * 60; + +// HS256 is the standard symmetric JWT alg. AUTH_STATE_SECRET is a shared +// random value (e.g. `openssl rand -base64 32`). +const ALG = 'HS256'; + +// jose's verify rejects iss/aud mismatches; pinning them ensures a token +// signed under AUTH_STATE_SECRET but intended for a different purpose can't +// be replayed at the callback endpoint. +const ISS = 'pagent:oauth:state'; +const AUD = 'pagent:oauth:callback'; + +/** + * The encoded authorize-request context. All fields are optional because the + * browser_session path (no MCP client, just a session cookie) doesn't carry + * any of the PKCE bits — only `browserSession: true` survives the round-trip. + */ +export interface StateClaims { + clientId?: string; + redirectUri?: string; + codeChallenge?: string; + scope?: string; + state?: string; + browserSession?: boolean; +} + +function getKey(): Uint8Array { + if (!env.AUTH_STATE_SECRET) { + throw new Error( + 'AUTH_STATE_SECRET is not configured — auth endpoints unavailable. See docs/superpowers/specs/2026-05-17-auth-design.md §9.', + ); + } + // Buffer extends Uint8Array, but typing the return as Uint8Array is more + // honest about what jose accepts and avoids leaking node-specific types + // into the auth surface. + return new Uint8Array(Buffer.from(env.AUTH_STATE_SECRET, 'utf-8')); +} + +/** + * Sign a state JWT containing the authorize-request context. + * + * Each field is emitted only when present so a roundtrip preserves the exact + * shape of the input (a verify-then-sign loop wouldn't add stray nulls). + */ +export async function signStateJwt(claims: StateClaims): Promise { + const payload: Record = {}; + if (claims.clientId !== undefined) payload.client_id = claims.clientId; + if (claims.redirectUri !== undefined) payload.redirect_uri = claims.redirectUri; + if (claims.codeChallenge !== undefined) payload.code_challenge = claims.codeChallenge; + if (claims.scope !== undefined) payload.scope = claims.scope; + if (claims.state !== undefined) payload.state = claims.state; + if (claims.browserSession !== undefined) payload.browser_session = claims.browserSession; + return await new SignJWT(payload) + .setProtectedHeader({ alg: ALG, typ: 'JWT' }) + .setIssuer(ISS) + .setAudience(AUD) + .setIssuedAt() + .setExpirationTime(`${STATE_TTL_SECONDS}s`) + .sign(getKey()); +} + +/** + * Verify a state JWT and return the decoded claims. + * + * Throws on bad signature, expired token, or wrong iss/aud. Caller is + * responsible for surfacing the failure mode to the user (typically: render + * the login page with an error message — the original authorize parameters + * are unrecoverable at this point, so the user re-initiates from their + * MCP client). + */ +export async function verifyStateJwt(token: string): Promise { + const { payload } = await jwtVerify(token, getKey(), { + issuer: ISS, + audience: AUD, + algorithms: [ALG], + }); + const out: StateClaims = {}; + if (typeof payload.client_id === 'string') out.clientId = payload.client_id; + if (typeof payload.redirect_uri === 'string') out.redirectUri = payload.redirect_uri; + if (typeof payload.code_challenge === 'string') out.codeChallenge = payload.code_challenge; + if (typeof payload.scope === 'string') out.scope = payload.scope; + if (typeof payload.state === 'string') out.state = payload.state; + if (typeof payload.browser_session === 'boolean') out.browserSession = payload.browser_session; + return out; +} diff --git a/apps/api/db.test.ts b/apps/api/db.test.ts index 0184e1a..73c1e6f 100644 --- a/apps/api/db.test.ts +++ b/apps/api/db.test.ts @@ -1,7 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { withRetry, getActivePage } from './db'; import type { Page, PageFormat } from './db'; +// Source-of-truth read for structural SQL assertions. Real DB connections are +// out of scope for unit tests (DATABASE_URL is a placeholder in +// vitest.config.ts), so we verify init()'s CREATE TABLE / ALTER TABLE / CREATE +// INDEX statements by inspecting db.ts directly. If the SQL changes, the test +// fails — that's the point. +const dbSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'db.ts'), 'utf8'); + +/** Normalize whitespace so multi-line SQL templates match a single-line probe. */ +const flat = dbSource.replace(/\s+/g, ' '); + describe('withRetry', () => { beforeEach(() => { vi.useFakeTimers(); @@ -252,3 +265,214 @@ describe('Page format column (structural)', () => { expect(projected.spec).toBe('
hi
'); }); }); + +// --------------------------------------------------------------------------- +// init() schema bootstrap — structural assertions over db.ts source +// --------------------------------------------------------------------------- +// init() runs `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT +// EXISTS` / `CREATE INDEX IF NOT EXISTS` on every boot. These tests verify +// the SQL bootstrap matches the auth design spec (docs/superpowers/specs/ +// 2026-05-17-auth-design.md §2) without needing a live DB. + +describe('init() — auth tables', () => { + it('creates the users table idempotently with uuid PK and required email', () => { + expect(flat).toMatch(/create table if not exists users \(/i); + // Columns may have variable internal whitespace in the source; match on + // the column + type + key constraint only. + expect(flat).toMatch(/id\s+uuid\s+primary key default gen_random_uuid\(\)/i); + expect(flat).toMatch(/handle\s+text\s+unique/i); + expect(flat).toMatch(/email\s+text\s+unique not null/i); + }); + + it('creates unique indexes on lower(email) and lower(handle)', () => { + expect(flat).toMatch( + /create unique index if not exists users_email_idx on users \(lower\(email\)\)/i, + ); + expect(flat).toMatch( + /create unique index if not exists users_handle_idx on users \(lower\(handle\)\)/i, + ); + }); + + it('creates the sessions table with ON DELETE CASCADE to users', () => { + expect(flat).toMatch(/create table if not exists sessions \(/i); + expect(flat).toMatch(/user_id\s+uuid\s+not null references users\(id\) on delete cascade/i); + expect(flat).toMatch(/token_hash\s+text\s+not null/i); + }); + + it('creates user_id and expires_at indexes on sessions', () => { + expect(flat).toMatch( + /create index if not exists sessions_user_id_idx on sessions \(user_id\)/i, + ); + expect(flat).toMatch( + /create index if not exists sessions_expires_at_idx on sessions \(expires_at\)/i, + ); + }); + + it('creates a unique token_hash index on sessions (lookup-path)', () => { + // Every authenticated request looks up by token_hash. Without this index + // each request does a sequential scan on sessions. + expect(flat).toMatch( + /create unique index if not exists sessions_token_hash_idx on sessions \(token_hash\)/i, + ); + }); + + it('creates the oauth_clients table with array columns and defaults', () => { + expect(flat).toMatch(/create table if not exists oauth_clients \(/i); + expect(flat).toMatch(/client_id\s+text\s+primary key/i); + expect(flat).toMatch(/redirect_uris\s+text\[\]\s+not null/i); + expect(flat).toMatch( + /grant_types\s+text\[\]\s+not null default '\{authorization_code,refresh_token\}'/i, + ); + expect(flat).toMatch(/response_types\s+text\[\]\s+not null default '\{code\}'/i); + expect(flat).toMatch(/token_endpoint_auth_method\s+text\s+not null default 'none'/i); + }); + + it('creates auth_codes with FKs cascading from users and oauth_clients', () => { + expect(flat).toMatch(/create table if not exists auth_codes \(/i); + expect(flat).toMatch(/code\s+text\s+primary key/i); + expect(flat).toMatch(/user_id\s+uuid\s+not null references users\(id\) on delete cascade/i); + expect(flat).toMatch( + /client_id\s+text\s+not null references oauth_clients\(client_id\) on delete cascade/i, + ); + expect(flat).toMatch(/code_challenge_method\s+text\s+not null default 'S256'/i); + }); + + it('creates expires_at index on auth_codes', () => { + expect(flat).toMatch( + /create index if not exists auth_codes_expires_at_idx on auth_codes \(expires_at\)/i, + ); + }); + + it('creates refresh_tokens with unique token_hash and FK cascades', () => { + expect(flat).toMatch(/create table if not exists refresh_tokens \(/i); + expect(flat).toMatch(/id\s+uuid\s+primary key default gen_random_uuid\(\)/i); + expect(flat).toMatch(/token_hash\s+text\s+not null unique/i); + }); + + it('creates user_id and expires_at indexes on refresh_tokens', () => { + expect(flat).toMatch( + /create index if not exists refresh_tokens_user_id_idx on refresh_tokens \(user_id\)/i, + ); + expect(flat).toMatch( + /create index if not exists refresh_tokens_expires_at_idx on refresh_tokens \(expires_at\)/i, + ); + }); + + it('creates magic_links with unique token_hash and expires_at index', () => { + expect(flat).toMatch(/create table if not exists magic_links \(/i); + expect(flat).toMatch(/email\s+text\s+not null/i); + expect(flat).toMatch(/token_hash\s+text\s+not null unique/i); + expect(flat).toMatch(/consumed_at timestamptz/i); + expect(flat).toMatch( + /create index if not exists magic_links_expires_at_idx on magic_links \(expires_at\)/i, + ); + }); + + it('every auth table is created with IF NOT EXISTS (idempotent)', () => { + for (const t of [ + 'users', + 'sessions', + 'oauth_clients', + 'auth_codes', + 'refresh_tokens', + 'magic_links', + ]) { + expect(flat).toMatch(new RegExp(`create table if not exists ${t} \\(`, 'i')); + } + }); + + it('every expires_at index is created with IF NOT EXISTS', () => { + for (const t of ['sessions', 'auth_codes', 'refresh_tokens', 'magic_links']) { + expect(flat).toMatch( + new RegExp(`create index if not exists ${t}_expires_at_idx on ${t} \\(expires_at\\)`, 'i'), + ); + } + }); +}); + +describe('init() — pages.owner_id alteration', () => { + it('adds owner_id as a nullable FK with ON DELETE SET NULL', () => { + expect(flat).toMatch( + /alter table pages add column if not exists owner_id uuid references users\(id\) on delete set null/i, + ); + }); + + it('does not declare owner_id as NOT NULL (grace period requires nullable)', () => { + // Capture the owner_id ALTER statement up to (but not including) the next + // ALTER/CREATE/await and confirm it has no `not null`. + const m = flat + .toLowerCase() + .match(/alter table pages add column if not exists owner_id[\s\S]*?on delete set null/); + expect(m).not.toBeNull(); + expect(m![0]).not.toContain('not null'); + }); + + it('creates pages_owner_id_idx index', () => { + expect(flat).toMatch(/create index if not exists pages_owner_id_idx on pages \(owner_id\)/i); + }); +}); + +// --------------------------------------------------------------------------- +// insertPage — owner_id binding (structural) +// --------------------------------------------------------------------------- +// Real DB writes are out of scope for the unit suite; verify the INSERT +// statement references `owner_id` and binds `p.ownerId ?? null` so the SQL +// NULL path is exercised when the caller omits the field. Behavioural +// coverage (authenticated POST /new → owner_id set; anon → NULL) lives in +// app.test.ts which mocks db.insertPage. + +describe('insertPage — owner_id column wiring (structural)', () => { + it('INSERT statement includes the owner_id column', () => { + expect(flat).toMatch(/insert into pages \(id, spec, format, state, expires_at, owner_id\)/i); + }); + + it('owner_id value binds p.ownerId with a nullish-coalescing fallback to null', () => { + // The grace-period contract (REQUIRE_AUTH=false → owner_id IS NULL) hinges + // on this fallback — if the bind dropped `?? null`, an `undefined` would + // surface as the string 'undefined' or a 23502 not-null violation. + expect(flat).toMatch(/\$\{p\.ownerId \?\? null\}/); + }); +}); + +describe('Page type — ownerId field', () => { + it('Page accepts ownerId and reads it back unchanged', () => { + const p: Page = { + id: 'aabbccddeeff00112233445566778899', + spec: { foo: 1 }, + format: 'a2ui', + state: 'open', + result: null, + createdAt: 1, + expiresAt: 2, + ownerId: '11111111-2222-3333-4444-555555555555', + }; + expect(p.ownerId).toBe('11111111-2222-3333-4444-555555555555'); + }); + + it('Page accepts ownerId = null (anonymous grace-period page)', () => { + const p: Page = { + id: 'aabbccddeeff00112233445566778899', + spec: { foo: 1 }, + format: 'a2ui', + state: 'open', + result: null, + createdAt: 1, + expiresAt: 2, + ownerId: null, + }; + expect(p.ownerId).toBeNull(); + }); + + it('Page accepts omitted ownerId (treated as null at the DB layer)', () => { + const p: Page = { + id: 'aabbccddeeff00112233445566778899', + spec: { foo: 1 }, + format: 'a2ui', + state: 'open', + result: null, + createdAt: 1, + expiresAt: 2, + }; + expect(p.ownerId).toBeUndefined(); + }); +}); diff --git a/apps/api/db.ts b/apps/api/db.ts index 4e4377e..6ddbd76 100644 --- a/apps/api/db.ts +++ b/apps/api/db.ts @@ -35,6 +35,13 @@ export type Page = { result: unknown; createdAt: number; expiresAt: number; + /** + * Authenticated user that created this page. Optional / nullable to keep + * unauthenticated grace-period creation working — when REQUIRE_AUTH=true + * the POST /new middleware enforces a non-null user. `ON DELETE SET NULL` + * keeps pages live when the owning user is deleted. + */ + ownerId?: string | null; }; let sql: ReturnType | null = null; @@ -66,6 +73,141 @@ export async function init(connectionString: string): Promise { check (format in ('a2ui','html')) `; await sql`create index if not exists pages_expires_at_idx on pages (expires_at)`; + + // --- Auth tables --------------------------------------------------------- + // Bootstrap follows the same idempotent pattern as `pages`: every CREATE + // / ALTER / INDEX is `IF NOT EXISTS` so a second boot is a no-op. See + // docs/superpowers/specs/2026-05-17-auth-design.md §2. + // + // Users — `handle` is nullable (assigned during onboarding, not creation). + // Unique indexes are on `lower(email)` / `lower(handle)` so case-variant + // collisions are rejected at insert time, not at lookup time. + await sql` + create table if not exists users ( + id uuid primary key default gen_random_uuid(), + handle text unique, + email text unique not null, + name text, + avatar_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ) + `; + await sql`create unique index if not exists users_email_idx on users (lower(email))`; + await sql`create unique index if not exists users_handle_idx on users (lower(handle))`; + + // Sessions — browser cookies. `token_hash` is SHA-256(cookie); raw token + // never stored. Sliding window — `expires_at` is extended on every + // authenticated request. + await sql` + create table if not exists sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + token_hash text not null, + ip_address text, + user_agent text, + created_at timestamptz not null default now(), + expires_at timestamptz not null + ) + `; + await sql`create index if not exists sessions_user_id_idx on sessions (user_id)`; + await sql`create index if not exists sessions_expires_at_idx on sessions (expires_at)`; + // Every authenticated request looks up by token_hash; without this index + // each request does a sequential scan. UNIQUE also defends against + // hash-collision inserts at the storage layer. + await sql`create unique index if not exists sessions_token_hash_idx on sessions (token_hash)`; + + // OAuth clients — RFC 7591 dynamic registration. MCP clients are public + // (`token_endpoint_auth_method = 'none'`), so `client_secret` is null. + await sql` + create table if not exists oauth_clients ( + client_id text primary key, + client_secret text, + client_secret_expires_at timestamptz, + client_id_issued_at timestamptz not null default now(), + client_name text, + client_uri text, + logo_uri text, + redirect_uris text[] not null, + grant_types text[] not null default '{authorization_code,refresh_token}', + response_types text[] not null default '{code}', + scope text, + token_endpoint_auth_method text not null default 'none', + created_at timestamptz not null default now() + ) + `; + + // Auth codes — PKCE authorization codes (10-minute TTL). `consumed_at` is + // set on first use; second use is rejected and revokes the token family. + await sql` + create table if not exists auth_codes ( + code text primary key, + user_id uuid not null references users(id) on delete cascade, + client_id text not null references oauth_clients(client_id) on delete cascade, + redirect_uri text not null, + code_challenge text not null, + code_challenge_method text not null default 'S256', + scope text, + resource text, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + consumed_at timestamptz + ) + `; + await sql`create index if not exists auth_codes_expires_at_idx on auth_codes (expires_at)`; + + // Refresh tokens — opaque, rotated on each use. `token_hash` is + // SHA-256(raw). On rotation the old row gets revoked_at = now() and a + // new row is inserted; presenting a revoked token revokes the whole family. + await sql` + create table if not exists refresh_tokens ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + client_id text not null references oauth_clients(client_id) on delete cascade, + token_hash text not null unique, + scope text, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz + ) + `; + await sql`create index if not exists refresh_tokens_user_id_idx on refresh_tokens (user_id)`; + await sql`create index if not exists refresh_tokens_expires_at_idx on refresh_tokens (expires_at)`; + + // Magic links — passwordless email tokens (15-minute TTL). + await sql` + create table if not exists magic_links ( + id uuid primary key default gen_random_uuid(), + email text not null, + token_hash text not null unique, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + consumed_at timestamptz + ) + `; + await sql`create index if not exists magic_links_expires_at_idx on magic_links (expires_at)`; + // Carry the OAuth authorize context (client_id, redirect_uri, code_challenge, + // scope, state) keyed on the magic link token so the email link itself can + // stay short (just the raw token). Without this, we'd have to encode every + // PKCE parameter in the URL — leaks them into email logs and inflates the + // link length. Stored as JSONB so we can extend the shape (e.g. for browser + // session flag, future fields) without a migration. Idempotent — safe on + // pre-existing deployments that already have the base table. + await sql` + alter table magic_links + add column if not exists authorize_context jsonb + `; + + // Pages owner — nullable FK so unauthenticated page creation during the + // grace period still works. When REQUIRE_AUTH=true, the POST /new + // middleware enforces a non-null owner. ON DELETE SET NULL preserves + // pages when an owning user is deleted (avoids cascading loss of state). + await sql` + alter table pages + add column if not exists owner_id uuid + references users(id) on delete set null + `; + await sql`create index if not exists pages_owner_id_idx on pages (owner_id)`; } export async function ping(): Promise { @@ -185,13 +327,17 @@ export async function fetchAndAdvanceResult( export async function insertPage(p: Page): Promise { await withRetry(async () => { const c = client(); - await c`insert into pages (id, spec, format, state, expires_at) + // owner_id is nullable — postgres-js binds `null` as SQL NULL, which is + // what the grace-period path (unauthenticated POST /new) requires. When + // REQUIRE_AUTH=true the route middleware guarantees ownerId is set. + await c`insert into pages (id, spec, format, state, expires_at, owner_id) values ( ${p.id}, ${c.json(p.spec as Parameters[0])}, ${p.format}, 'open', - to_timestamp(${p.expiresAt} / 1000.0) + to_timestamp(${p.expiresAt} / 1000.0), + ${p.ownerId ?? null} )`; }); } @@ -219,3 +365,621 @@ export async function deleteExpiredPages(): Promise<{ total: number; abandoned: return { total: rows.length, abandoned }; }); } + +// --------------------------------------------------------------------------- +// OAuth clients (RFC 7591 dynamic registration) +// --------------------------------------------------------------------------- +// Backs apps/api/auth/clients-store.ts. The store module owns validation and +// mapping to/from the MCP SDK's OAuthClientInformationFull shape; this layer +// owns SQL — same split as insertPage / store.createPage. See spec §2.3 for +// the column layout. + +export type OAuthClientRow = { + client_id: string; + client_secret: string | null; + client_secret_expires_at: Date | null; + client_id_issued_at: Date; + client_name: string | null; + client_uri: string | null; + logo_uri: string | null; + redirect_uris: string[]; + grant_types: string[]; + response_types: string[]; + scope: string | null; + token_endpoint_auth_method: string; +}; + +/** + * Columns provided to INSERT. `created_at` and `client_id_issued_at` have DB + * defaults so we omit them; the row returned by `returning *` carries the + * server-assigned timestamps back. Nullable optional metadata uses `null` + * (not undefined) so postgres-js binds it as SQL NULL rather than the + * literal 'undefined' string. + */ +export type OAuthClientInsert = { + client_id: string; + client_name: string | null; + client_uri: string | null; + logo_uri: string | null; + redirect_uris: string[]; + grant_types: string[]; + response_types: string[]; + scope: string | null; + token_endpoint_auth_method: string; +}; + +/** + * Insert a new OAuth client. Returns the inserted row with server-defaulted + * timestamps so the caller can derive `client_id_issued_at` in Unix seconds. + * Wrapped in withRetry — registration is idempotent at the application layer + * (UUID PK collisions are vanishingly unlikely) so retrying a transient DB + * error is safe. + */ +export async function insertOAuthClient(input: OAuthClientInsert): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + insert into oauth_clients ( + client_id, + client_name, + client_uri, + logo_uri, + redirect_uris, + grant_types, + response_types, + scope, + token_endpoint_auth_method + ) values ( + ${input.client_id}, + ${input.client_name}, + ${input.client_uri}, + ${input.logo_uri}, + ${input.redirect_uris}, + ${input.grant_types}, + ${input.response_types}, + ${input.scope}, + ${input.token_endpoint_auth_method} + ) + returning + client_id, client_secret, client_secret_expires_at, client_id_issued_at, + client_name, client_uri, logo_uri, redirect_uris, grant_types, + response_types, scope, token_endpoint_auth_method + `; + return rows[0]!; + }); +} + +/** + * Look up a registered OAuth client by `client_id`. Returns null if no row + * exists. The clients-store wraps this to surface `undefined` per the MCP + * SDK's `OAuthRegisteredClientsStore` contract. + */ +export async function getOAuthClientById(clientId: string): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + select + client_id, client_secret, client_secret_expires_at, client_id_issued_at, + client_name, client_uri, logo_uri, redirect_uris, grant_types, + response_types, scope, token_endpoint_auth_method + from oauth_clients + where client_id = ${clientId} + `; + return rows[0] ?? null; + }); +} + +// --------------------------------------------------------------------------- +// Users (Google + Magic Link upsert) +// --------------------------------------------------------------------------- +// Backs the Google OAuth callback's user upsert. `handle` is assigned by the +// callback after collision-checking via getUserByHandle. Email is the natural +// key — Google guarantees uniqueness within their tenant, and the +// `users_email_idx` unique index defends against case-variant duplicates. +// See spec §2 (Schema) and §3.7 (Google callback flow). + +export type UserRow = { + id: string; + handle: string | null; + email: string; + name: string | null; + avatar_url: string | null; + created_at: Date; + updated_at: Date; +}; + +export type UserUpsertInput = { + email: string; + name: string | null; + avatarUrl: string | null; + handle: string; +}; + +/** + * Insert-or-update a user by email. On first sight, the row is created with + * the supplied handle. On subsequent logins, name/avatar_url/updated_at are + * refreshed but `handle` is preserved (it's the user-visible identifier and + * shouldn't churn just because Google reissued a different display name). + * + * Returns the canonical row — caller can rely on `id` being the durable user + * UUID regardless of whether the row is brand new. + */ +export async function upsertUser(input: UserUpsertInput): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + insert into users (email, name, avatar_url, handle) + values (${input.email}, ${input.name}, ${input.avatarUrl}, ${input.handle}) + on conflict (email) do update set + name = excluded.name, + avatar_url = excluded.avatar_url, + updated_at = now() + returning id, handle, email, name, avatar_url, created_at, updated_at + `; + return rows[0]!; + }); +} + +/** + * Lookup a user by handle. Used during handle generation to detect collisions + * before we attempt the upsert. Case-insensitive via the lower(handle) unique + * index, so we match the same comparison the DB constraint enforces. + */ +export async function getUserByHandle(handle: string): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + select id, handle, email, name, avatar_url, created_at, updated_at + from users + where lower(handle) = lower(${handle}) + `; + return rows[0] ?? null; + }); +} + +/** + * Lookup a user by primary key. Used by the token endpoint to populate JWT + * claims (email/handle) when exchanging an auth code or refresh token. Cascade + * delete keeps auth_codes / refresh_tokens in sync with users, so a missing + * row here means the user was deleted between issuing and exchanging — which + * the caller surfaces as `invalid_grant`. + */ +export async function getUserById(id: string): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + select id, handle, email, name, avatar_url, created_at, updated_at + from users + where id = ${id} + `; + return rows[0] ?? null; + }); +} + +// --------------------------------------------------------------------------- +// Auth codes (PKCE authorization codes) +// --------------------------------------------------------------------------- +// 10-minute TTL per spec §3.4. The `code` itself is the PK so a second-use +// race against `consumed_at` can be detected as a unique-violation. The +// callback issues these after a successful Google handshake; the token +// endpoint (Task 06) consumes them. + +export type AuthCodeInsert = { + code: string; + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + scope: string | null; + expiresAt: Date; +}; + +/** + * Insert a fresh authorization code. `consumed_at` is left NULL — the token + * endpoint flips it on first use. Not wrapped in withRetry: the code is a + * unique random value, a retry after success would attempt to insert a + * duplicate PK and falsely surface a unique-violation to the caller. + */ +export async function insertAuthCode(input: AuthCodeInsert): Promise { + const c = client(); + await c` + insert into auth_codes ( + code, user_id, client_id, redirect_uri, + code_challenge, code_challenge_method, scope, expires_at + ) values ( + ${input.code}, ${input.userId}, ${input.clientId}, ${input.redirectUri}, + ${input.codeChallenge}, ${input.codeChallengeMethod}, + ${input.scope}, ${input.expiresAt} + ) + `; +} + +/** + * Row returned by `consumeAuthCode` and `getAuthCodeForReplay`. Mirrors the + * `auth_codes` column layout but the caller usually only needs the fields the + * token endpoint compares against (user_id, client_id, redirect_uri, PKCE + * bits, scope/resource). + */ +export type AuthCodeRow = { + code: string; + user_id: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + code_challenge_method: string; + scope: string | null; + resource: string | null; + created_at: Date; + expires_at: Date; + consumed_at: Date | null; +}; + +/** + * Atomically consume an authorization code: set `consumed_at = now()` and + * return the row's binding fields, but only if the row exists, hasn't expired, + * and hasn't already been consumed. The single-statement UPDATE ... WHERE + * consumed_at IS NULL is what gives us the single-use guarantee — concurrent + * token requests race on this filter and at most one wins. + * + * Returns null when the code is unknown / expired / already consumed. The + * token endpoint then disambiguates via `getAuthCodeForReplay` to decide + * whether to treat the failure as a replay (which triggers family revocation + * per RFC 6749 §4.1.2). + */ +export async function consumeAuthCode(code: string): Promise<{ + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + scope: string | null; + resource: string | null; +} | null> { + const c = client(); + const rows = await c< + { + user_id: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + code_challenge_method: string; + scope: string | null; + resource: string | null; + }[] + >` + update auth_codes + set consumed_at = now() + where code = ${code} + and consumed_at is null + and expires_at > now() + returning user_id, client_id, redirect_uri, code_challenge, + code_challenge_method, scope, resource + `; + if (rows.length === 0) return null; + const r = rows[0]!; + return { + userId: r.user_id, + clientId: r.client_id, + redirectUri: r.redirect_uri, + codeChallenge: r.code_challenge, + codeChallengeMethod: r.code_challenge_method, + scope: r.scope, + resource: r.resource, + }; +} + +/** + * Look up an auth code without consuming it. Used by the token endpoint after + * `consumeAuthCode` returns null to disambiguate "unknown / expired" from + * "already consumed" — RFC 6749 §4.1.2 suggests revoking any tokens issued + * from a replayed code, which we can only do if we know the row exists. + * + * Returns null when the row doesn't exist. Expiry and prior consumption are + * NOT filtered here — the caller decides what to do with each state. + */ +export async function getAuthCodeForReplay(code: string): Promise { + const c = client(); + const rows = await c` + select code, user_id, client_id, redirect_uri, + code_challenge, code_challenge_method, scope, resource, + created_at, expires_at, consumed_at + from auth_codes + where code = ${code} + `; + return rows[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Refresh tokens (opaque, rotated on each use) +// --------------------------------------------------------------------------- +// `token_hash` is SHA-256(raw refresh token). Raw values are only ever held +// by the caller (memory + their HTTPS request). On rotation we insert a new +// row and revoke the old one; on detected replay (presenting a row already +// `revoked_at IS NOT NULL`) we revoke every row in the same (user_id, +// client_id) family per OAuth 2.1 §6.1. + +export type RefreshTokenRow = { + id: string; + user_id: string; + client_id: string; + token_hash: string; + scope: string | null; + created_at: Date; + expires_at: Date; + revoked_at: Date | null; +}; + +export type RefreshTokenInsert = { + userId: string; + clientId: string; + tokenHash: string; + scope: string | null; + expiresAt: Date; +}; + +/** + * Insert a fresh refresh token row. `revoked_at` is left NULL — the rotation + * path flips it on the old row before inserting the new one. Not wrapped in + * withRetry: a retry after a successful insert would race against the + * unique(token_hash) constraint and surface a spurious failure even though + * the original write succeeded. + */ +export async function insertRefreshToken(input: RefreshTokenInsert): Promise { + const c = client(); + const rows = await c` + insert into refresh_tokens (user_id, client_id, token_hash, scope, expires_at) + values ( + ${input.userId}, ${input.clientId}, ${input.tokenHash}, + ${input.scope}, ${input.expiresAt} + ) + returning id, user_id, client_id, token_hash, scope, + created_at, expires_at, revoked_at + `; + return rows[0]!; +} + +/** + * Look up a refresh token row by its SHA-256 hash. Returns null when the hash + * is unknown. Expiry and revoked state are NOT filtered here — the caller + * decides what to do with each state. In particular, the rotation path + * inspects `revoked_at` to detect replays and trigger family revocation. + */ +export async function getRefreshTokenByHash(tokenHash: string): Promise { + const c = client(); + const rows = await c` + select id, user_id, client_id, token_hash, scope, + created_at, expires_at, revoked_at + from refresh_tokens + where token_hash = ${tokenHash} + `; + return rows[0] ?? null; +} + +/** + * Mark a single refresh token revoked. Idempotent: a second call against the + * same id is a no-op. Used by both the rotation path (revoke old before + * issuing new) and the explicit /oauth/revoke endpoint. + */ +export async function revokeRefreshToken(id: string): Promise { + const c = client(); + await c` + update refresh_tokens + set revoked_at = now() + where id = ${id} and revoked_at is null + `; +} + +/** + * Revoke every still-active refresh token for a (user_id, client_id) pair. + * This is the "token family revocation" path triggered when a revoked token + * is replayed — per OAuth 2.1 §6.1, the safe response is to assume the whole + * family has been compromised and invalidate every outstanding refresh + * token for that client session. + */ +export async function revokeAllRefreshTokensForFamily( + userId: string, + clientId: string, +): Promise { + const c = client(); + await c` + update refresh_tokens + set revoked_at = now() + where user_id = ${userId} + and client_id = ${clientId} + and revoked_at is null + `; +} + +// --------------------------------------------------------------------------- +// Magic Links (passwordless email tokens, 15-minute TTL) +// --------------------------------------------------------------------------- +// `token_hash` is SHA-256(raw token) — the raw token is only ever seen by the +// user (in the email) and by the verify handler (in the query string). The +// authorize_context column carries the OAuth round-trip parameters keyed on +// the link so we can resume the flow without inflating the email URL. + +/** + * Authorize-request context stored alongside a magic link so the verify + * handler can resume the OAuth flow. Mirrors `StateClaims` from state-jwt.ts + * but without the JWT envelope — we already have a per-token row, so signing + * would just add overhead. + * + * Every field is optional because a future "log in without an OAuth client" + * path (e.g. browser session) doesn't need the PKCE bits. + */ +export type MagicLinkAuthorizeContext = { + clientId?: string; + redirectUri?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + scope?: string; + state?: string; + browserSession?: boolean; +}; + +export type MagicLinkInsert = { + email: string; + tokenHash: string; + authorizeContext: MagicLinkAuthorizeContext; + expiresAt: Date; +}; + +/** + * Insert a fresh magic link row. The raw token is never stored — only the + * SHA-256 hash from the caller. Not wrapped in withRetry: a retry after a + * successful insert would surface a unique-violation on token_hash and + * confuse the caller (the token is already valid). + */ +export async function insertMagicLink(input: MagicLinkInsert): Promise { + const c = client(); + await c` + insert into magic_links (email, token_hash, authorize_context, expires_at) + values ( + ${input.email}, + ${input.tokenHash}, + ${c.json(input.authorizeContext as Parameters[0])}, + ${input.expiresAt} + ) + `; +} + +/** + * Atomically consume a magic link: marks `consumed_at = now()` and returns + * the email + stored authorize context, but only if the row exists, hasn't + * expired, and hasn't already been consumed. Concurrent verifies race on the + * UPDATE WHERE clause — at most one succeeds. + * + * Returns null when the token is unknown / expired / already used. The + * caller surfaces that as a generic "expired or invalid" error — we don't + * distinguish to avoid leaking whether the token existed at all. + */ +export async function verifyAndConsumeMagicLink( + tokenHash: string, +): Promise<{ email: string; authorizeContext: MagicLinkAuthorizeContext } | null> { + const c = client(); + const rows = await c<{ email: string; authorize_context: MagicLinkAuthorizeContext | null }[]>` + update magic_links + set consumed_at = now() + where token_hash = ${tokenHash} + and expires_at > now() + and consumed_at is null + returning email, authorize_context + `; + if (rows.length === 0) return null; + const r = rows[0]!; + return { + email: r.email, + // authorize_context is JSONB; postgres-js returns parsed objects already. + // Treat NULL as an empty context — a legacy row missing the column would + // still be consumable (e.g. browser-session flow with no PKCE bits). + authorizeContext: r.authorize_context ?? {}, + }; +} + +// --------------------------------------------------------------------------- +// Sessions (browser cookies) +// --------------------------------------------------------------------------- +// Browser sessions back the `pagent_session` cookie. `token_hash` is SHA-256 +// of the raw cookie value; the raw token is only ever seen by the browser +// (in the cookie jar) and by `lookupSession` (during a request). Sliding +// expiry: every authenticated request extends `expires_at` by +// SESSION_MAX_AGE_DAYS so an actively-used session never bounces the user +// to re-login. See spec §6 and §7.2 (Token storage). + +export type SessionInsert = { + userId: string; + tokenHash: string; + ipAddress: string | null; + userAgent: string | null; + expiresAt: Date; +}; + +/** + * Insert a fresh session row. Not wrapped in withRetry: a retry after a + * successful insert would surface a unique-violation on token_hash and + * falsely report a duplicate. The raw token has 128 bits of entropy so + * collisions are vanishingly unlikely; a retry-on-collision strategy isn't + * worth the complexity here. + */ +export async function insertSession(input: SessionInsert): Promise { + const c = client(); + await c` + insert into sessions (user_id, token_hash, ip_address, user_agent, expires_at) + values ( + ${input.userId}, + ${input.tokenHash}, + ${input.ipAddress}, + ${input.userAgent}, + ${input.expiresAt} + ) + `; +} + +/** + * Row shape returned by `getSessionWithUserByTokenHash` — joins sessions to + * users so a single round-trip resolves both the session validity and the + * user identity that the middleware needs for `c.var.user`. + */ +export type SessionWithUserRow = { + session_id: string; + user_id: string; + email: string; + handle: string | null; + expires_at: Date; +}; + +/** + * Look up a non-expired session by its `token_hash`, joined to the owning + * user row. Returns null when the token is unknown or expired — the + * middleware treats both as "anonymous request" so we collapse them here. + * + * The `expires_at > now()` filter is part of the WHERE clause so an + * expired-but-not-yet-swept row reads as gone. The `sessions_token_hash_idx` + * unique index keeps this O(1). + */ +export async function getSessionWithUserByTokenHash( + tokenHash: string, +): Promise { + const c = client(); + const rows = await c` + select s.id as session_id, + u.id as user_id, + u.email as email, + u.handle as handle, + s.expires_at as expires_at + from sessions s + join users u on u.id = s.user_id + where s.token_hash = ${tokenHash} + and s.expires_at > now() + `; + return rows[0] ?? null; +} + +/** + * Extend a session's `expires_at` to a new absolute timestamp. Used by the + * sliding-expiry path in `lookupSession` — every authenticated request bumps + * the row out by SESSION_MAX_AGE_DAYS. Idempotent: a duplicate call simply + * overwrites with the same value. + */ +export async function extendSessionExpiry(sessionId: string, newExpiresAt: Date): Promise { + const c = client(); + await c` + update sessions + set expires_at = ${newExpiresAt} + where id = ${sessionId} + `; +} + +/** + * Delete a session row by its `token_hash`. Used by logout — once the row is + * gone, the cookie still in the browser is just an opaque string with no + * server-side state to resolve. Returns silently when the row doesn't exist + * (callers don't need to distinguish "logged out twice" from "first logout"). + */ +export async function deleteSessionByTokenHash(tokenHash: string): Promise { + const c = client(); + await c` + delete from sessions where token_hash = ${tokenHash} + `; +} diff --git a/apps/api/mcp/http.test.ts b/apps/api/mcp/http.test.ts index df0f436..ffb1537 100644 --- a/apps/api/mcp/http.test.ts +++ b/apps/api/mcp/http.test.ts @@ -27,6 +27,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { makeMcpHttpHandler } from './http.ts'; import { RateLimiter } from './rate-limit.ts'; +import { env } from '../schemas.ts'; +import * as jwt from '../auth/jwt.ts'; // --- Test fixture ----------------------------------------------------------- @@ -398,3 +400,189 @@ describe('rate limiting', () => { } }); }); + +// --------------------------------------------------------------------------- +// Bearer auth gating (REQUIRE_AUTH=true) +// --------------------------------------------------------------------------- +// These tests flip env.REQUIRE_AUTH in-process and use a per-test server so +// the toggle doesn't leak. Bearer validation calls verifyAccessToken; we +// vi.spyOn it to control the outcome without booting the full JWT keys. + +describe('Bearer auth gating', () => { + async function startProtectedServer() { + const handler = makeMcpHttpHandler({ + publicUrl: 'http://test.local', + pageTtlMs: 60_000, + rateLimiter: new RateLimiter(1000, 60_000), + }); + return startServer(handler); + } + + function withRequireAuth(fn: () => Promise): Promise { + const original = env.REQUIRE_AUTH; + (env as { REQUIRE_AUTH: boolean }).REQUIRE_AUTH = true; + return fn().finally(() => { + (env as { REQUIRE_AUTH: boolean }).REQUIRE_AUTH = original; + }); + } + + it('returns 401 with WWW-Authenticate when REQUIRE_AUTH=true and no Bearer', async () => { + await withRequireAuth(async () => { + const { url, close } = await startProtectedServer(); + try { + const res = await fetch(url, { + method: 'POST', + headers: { Accept: MCP_ACCEPT, 'Content-Type': 'application/json' }, + body: INITIALIZE_BODY, + }); + expect(res.status).toBe(401); + const wwwAuth = res.headers.get('WWW-Authenticate'); + expect(wwwAuth).toContain('Bearer'); + expect(wwwAuth).toContain( + 'resource_metadata="http://test.local/.well-known/oauth-protected-resource"', + ); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.message).toMatch(/bearer/i); + expect(typeof body.request_id).toBe('string'); + } finally { + await close(); + } + }); + }); + + it('returns 401 with invalid_token when REQUIRE_AUTH=true and Bearer fails verification', async () => { + await withRequireAuth(async () => { + const spy = vi.spyOn(jwt, 'verifyAccessToken').mockRejectedValue(new Error('expired')); + const { url, close } = await startProtectedServer(); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Accept: MCP_ACCEPT, + 'Content-Type': 'application/json', + authorization: 'Bearer expired.jwt.token', + }, + body: INITIALIZE_BODY, + }); + expect(res.status).toBe(401); + const wwwAuth = res.headers.get('WWW-Authenticate'); + expect(wwwAuth).toContain('error="invalid_token"'); + const body = await res.json(); + expect(body.error).toBe('invalid_token'); + } finally { + spy.mockRestore(); + await close(); + } + }); + }); + + it('passes through when REQUIRE_AUTH=true and a valid Bearer is presented', async () => { + await withRequireAuth(async () => { + const spy = vi.spyOn(jwt, 'verifyAccessToken').mockResolvedValue({ + sub: 'user-uuid', + email: 'a@b.co', + handle: 'a', + client_id: 'mcp-cli', + scope: 'page:create page:read', + iss: 'http://test.local', + aud: 'http://test.local', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + jti: 'jti-1', + }); + const { url, close } = await startProtectedServer(); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Accept: MCP_ACCEPT, + 'Content-Type': 'application/json', + authorization: 'Bearer valid.jwt', + }, + body: INITIALIZE_BODY, + }); + // 200 with the MCP initialize response — auth succeeded and the + // transport processed the body. + expect(res.status).toBe(200); + expect(res.headers.get('WWW-Authenticate')).toBeNull(); + await res.body?.cancel(); + } finally { + spy.mockRestore(); + await close(); + } + }); + }); + + it('does not require Bearer when REQUIRE_AUTH=false (existing behavior preserved)', async () => { + // env.REQUIRE_AUTH defaults to false in the vitest env. + const res = await fetch(mcpUrl, { + method: 'POST', + headers: { Accept: MCP_ACCEPT, 'Content-Type': 'application/json' }, + body: INITIALIZE_BODY, + }); + expect(res.status).toBe(200); + expect(res.headers.get('WWW-Authenticate')).toBeNull(); + await res.body?.cancel(); + }); + + it('show_ui via authed MCP forwards JWT sub as owner_id to db.insertPage', async () => { + // End-to-end pin for task 10 wiring: Bearer middleware → req.auth.extra.sub + // → SDK transport authInfo → tool handler → ops.showUi(ownerId) + // → store.createPage → db.insertPage with ownerId set. + await withRequireAuth(async () => { + const spy = vi.spyOn(jwt, 'verifyAccessToken').mockResolvedValue({ + sub: 'auth-flow-user-uuid', + email: 'flow@example.com', + handle: 'flow', + client_id: 'mcp-cli', + scope: 'page:create', + iss: 'http://test.local', + aud: 'http://test.local', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + jti: 'jti-flow', + }); + const { url, close } = await startProtectedServer(); + try { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = + await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const client = new Client({ name: 'test', version: '0.0.1' }); + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { headers: { authorization: 'Bearer valid.jwt' } }, + }), + ); + await client.callTool({ + name: 'show_ui', + arguments: { + spec: [{ createSurface: { surfaceId: 'm' } }], + }, + }); + expect(db.insertPage).toHaveBeenCalledTimes(1); + const [page] = (db.insertPage as ReturnType).mock.calls[0]; + expect(page.ownerId).toBe('auth-flow-user-uuid'); + await client.close(); + } finally { + spy.mockRestore(); + await close(); + } + }); + }); + + it('show_ui via unauthenticated MCP (REQUIRE_AUTH=false) leaves ownerId null', async () => { + // Grace-period contract: anonymous MCP show_ui still works, and the page + // row carries owner_id = NULL. + const client = await newSdkClient(); + await client.callTool({ + name: 'show_ui', + arguments: { spec: [{ createSurface: { surfaceId: 'm' } }] }, + }); + expect(db.insertPage).toHaveBeenCalledTimes(1); + const [page] = (db.insertPage as ReturnType).mock.calls[0]; + // store.createPage normalizes a missing ownerId to null before insert. + expect(page.ownerId).toBeNull(); + await client.close(); + }); +}); diff --git a/apps/api/mcp/http.ts b/apps/api/mcp/http.ts index 9467bed..8a8c464 100644 --- a/apps/api/mcp/http.ts +++ b/apps/api/mcp/http.ts @@ -21,6 +21,7 @@ import { clientKey } from '../client-key.ts'; import { env } from '../schemas.ts'; import * as store from '../store.ts'; import { logger } from '../logger.ts'; +import { verifyAccessToken } from '../auth/jwt.ts'; import { RateLimiter } from './rate-limit.ts'; import { registerPagentTools, type PageOps } from './tools.ts'; @@ -74,13 +75,19 @@ function applyBaseHeaders(req: IncomingMessage, res: ServerResponse, requestId: export function buildInProcessOps(cfg: McpHttpConfig): PageOps { return { - async showUi(spec) { + async showUi(spec, ownerId) { + // ownerId arrives from the SDK's RequestHandlerExtra.authInfo.extra.sub + // (set by the Bearer middleware below). Forwarded unchanged into the + // store so pages created via authenticated MCP carry the right + // owner_id. Anonymous MCP calls (REQUIRE_AUTH=false) leave ownerId + // undefined, which createPage turns into SQL NULL. return store.createPage(spec, 'a2ui', { publicUrl: cfg.publicUrl, pageTtlMs: cfg.pageTtlMs, + ownerId, }); }, - async showHtml(html) { + async showHtml(html, ownerId) { // No request context here — log at the module logger level. The REST // POST /new path passes a request-scoped child logger; this is the MCP // path. store.createHtmlPage handles sanitize+log+store in one ritual @@ -91,6 +98,7 @@ export function buildInProcessOps(cfg: McpHttpConfig): PageOps { { publicUrl: cfg.publicUrl, pageTtlMs: cfg.pageTtlMs, + ownerId, }, logger, ); @@ -158,6 +166,52 @@ export function makeMcpHttpHandler(cfg: McpHttpConfig) { } } + // Bearer auth — gated on REQUIRE_AUTH. On a 401 we surface the + // resource_metadata URL via WWW-Authenticate per RFC 9728 so MCP clients + // can discover the AS without an out-of-band config step. The check sits + // after rate-limit (no point validating tokens we'd throttle anyway) but + // before body parse (a 401 should be cheap and not trigger body reads). + if (env.REQUIRE_AUTH && req.method === 'POST') { + const authHeader = req.headers.authorization; + const resourceMetadataUrl = `${cfg.publicUrl}/.well-known/oauth-protected-resource`; + if (!authHeader?.startsWith('Bearer ')) { + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}"`); + respondJson(res, 401, { + error: 'unauthorized', + message: 'Bearer token required', + request_id: requestId, + }); + return; + } + const token = authHeader.slice('Bearer '.length).trim(); + try { + const claims = await verifyAccessToken(token); + // Attach auth info onto the request so the SDK transport can forward + // it to tool handlers (the StreamableHTTPServerTransport reads + // `req.auth` per the SDK's contract). We carry the verified claims + // plus the raw bearer so downstream code can re-mint scoped requests + // without re-decoding the JWT. + (req as unknown as { auth: unknown }).auth = { + token, + clientId: claims.client_id, + scopes: claims.scope.split(/\s+/).filter(Boolean), + expiresAt: claims.exp, + extra: { sub: claims.sub, email: claims.email, handle: claims.handle }, + }; + } catch { + res.setHeader( + 'WWW-Authenticate', + `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`, + ); + respondJson(res, 401, { + error: 'invalid_token', + message: 'Invalid or expired access token', + request_id: requestId, + }); + return; + } + } + let body: unknown; if (req.method === 'POST') { try { diff --git a/apps/api/mcp/tools.test.ts b/apps/api/mcp/tools.test.ts index 3095fcb..f6dc501 100644 --- a/apps/api/mcp/tools.test.ts +++ b/apps/api/mcp/tools.test.ts @@ -132,4 +132,132 @@ describe('registerPagentTools', () => { expect(out.structuredContent.result).toBe(null); expect(out.content[0]?.text).toMatch(/stop polling/i); }); + + // --------------------------------------------------------------------------- + // Auth context propagation + // --------------------------------------------------------------------------- + // The HTTP MCP transport stamps `req.auth.extra.sub` after Bearer verify + // and the SDK forwards that as `extra.authInfo.extra.sub` to tool handlers. + // These tests pin the contract that the handler lifts that out and passes + // it to ops.showUi / ops.showHtml as `ownerId`. + + it('show_ui handler forwards extra.authInfo.extra.sub to ops.showUi as ownerId', async () => { + const { server, tools } = makeServer(); + const captured: { spec?: unknown; ownerId?: string } = {}; + registerPagentTools( + server, + makeOps({ + showUi: async (spec, ownerId) => { + captured.spec = spec; + captured.ownerId = ownerId; + return { id: 'a'.repeat(32), url: 'http://x/a', expires_at: 0 }; + }, + }), + ); + const handler = tools.get('show_ui')!.handler; + await handler( + { spec: [{ createSurface: { surfaceId: 'm' } }] }, + { + authInfo: { + token: 'tok', + clientId: 'mcp-cli', + scopes: ['page:create'], + extra: { sub: 'user-uuid-abc', email: 'a@b.co' }, + }, + }, + ); + expect(captured.ownerId).toBe('user-uuid-abc'); + }); + + it('show_ui handler passes ownerId = undefined when no authInfo is present', async () => { + const { server, tools } = makeServer(); + let captured: string | undefined = 'sentinel'; + registerPagentTools( + server, + makeOps({ + showUi: async (_spec, ownerId) => { + captured = ownerId; + return { id: 'a'.repeat(32), url: 'http://x/a', expires_at: 0 }; + }, + }), + ); + const handler = tools.get('show_ui')!.handler; + // Stdio adapter / anon HTTP MCP: extra has no authInfo. + await handler({ spec: [{ createSurface: { surfaceId: 'm' } }] }, {}); + expect(captured).toBeUndefined(); + }); + + it('show_html handler forwards extra.authInfo.extra.sub to ops.showHtml as ownerId', async () => { + const { server, tools } = makeServer(); + let captured: string | undefined; + registerPagentTools( + server, + makeOps({ + showHtml: async (_html, ownerId) => { + captured = ownerId; + return { id: 'b'.repeat(32), url: 'http://x/b', expires_at: 0 }; + }, + }), + ); + const handler = tools.get('show_html')!.handler; + await handler( + { html: '

x

' }, + { + authInfo: { + token: 'tok', + clientId: 'mcp-cli', + scopes: ['page:create'], + extra: { sub: 'user-uuid-def' }, + }, + }, + ); + expect(captured).toBe('user-uuid-def'); + }); + + it('show_html handler passes ownerId = undefined when no authInfo is present', async () => { + const { server, tools } = makeServer(); + let captured: string | undefined = 'sentinel'; + registerPagentTools( + server, + makeOps({ + showHtml: async (_html, ownerId) => { + captured = ownerId; + return { id: 'b'.repeat(32), url: 'http://x/b', expires_at: 0 }; + }, + }), + ); + const handler = tools.get('show_html')!.handler; + await handler({ html: '

x

' }, {}); + expect(captured).toBeUndefined(); + }); + + it('handler tolerates non-string extra.authInfo.extra.sub (defensive)', async () => { + // If an upstream auth pipeline ever set `sub` to a number / object, the + // helper must not pass through garbage. ownerId should be undefined and + // the store will write owner_id = NULL. + const { server, tools } = makeServer(); + let captured: string | undefined = 'sentinel'; + registerPagentTools( + server, + makeOps({ + showUi: async (_spec, ownerId) => { + captured = ownerId; + return { id: 'a'.repeat(32), url: 'http://x/a', expires_at: 0 }; + }, + }), + ); + const handler = tools.get('show_ui')!.handler; + await handler( + { spec: [{ createSurface: { surfaceId: 'm' } }] }, + { + authInfo: { + token: 'tok', + clientId: 'mcp-cli', + scopes: [], + extra: { sub: 42 as unknown as string }, + }, + }, + ); + expect(captured).toBeUndefined(); + }); }); diff --git a/apps/api/mcp/tools.ts b/apps/api/mcp/tools.ts index 091637e..b427d1d 100644 --- a/apps/api/mcp/tools.ts +++ b/apps/api/mcp/tools.ts @@ -32,11 +32,32 @@ export type CheckResultOutcome = | { kind: 'state'; state: PageState; result: unknown; format: PageFormat }; export interface PageOps { - showUi(spec: unknown): Promise; - showHtml(html: string): Promise; + /** + * `ownerId` is the authenticated user's UUID, lifted from the MCP request's + * authInfo (Bearer-authenticated HTTP MCP) or undefined for unauthenticated + * paths (stdio adapter, REQUIRE_AUTH=false). Adapters pass it through to + * page creation so the resulting row carries the right owner_id. + */ + showUi(spec: unknown, ownerId?: string): Promise; + showHtml(html: string, ownerId?: string): Promise; checkResult(page_id: string): Promise; } +/** + * Extract the user id from the MCP tool handler's `extra.authInfo`. The HTTP + * MCP path (apps/api/mcp/http.ts) sets `req.auth.extra.sub = claims.sub` + * after Bearer verification; the SDK forwards that onto tool handlers as + * `extra.authInfo.extra.sub`. Returns undefined for the stdio adapter (no + * auth context) or for unauthenticated HTTP MCP calls in grace mode — the + * adapter then inserts the page with owner_id = NULL. + */ +function ownerIdFromExtra(extra: unknown): string | undefined { + if (!extra || typeof extra !== 'object') return undefined; + const authInfo = (extra as { authInfo?: { extra?: Record } }).authInfo; + const sub = authInfo?.extra?.sub; + return typeof sub === 'string' ? sub : undefined; +} + // --- Tool descriptions ------------------------------------------------------- // These are what the model sees when deciding whether to call the tools. // The polling pattern is baked in here so MCP clients without a separate @@ -95,8 +116,8 @@ export function registerPagentTools(server: McpServer, ops: PageOps): void { spec: z.array(z.record(z.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION), }, }, - async ({ spec }) => { - const created = await ops.showUi(spec); + async ({ spec }, extra) => { + const created = await ops.showUi(spec, ownerIdFromExtra(extra)); return { content: [ { @@ -122,8 +143,8 @@ export function registerPagentTools(server: McpServer, ops: PageOps): void { html: z.string().min(1).max(HTML_MAX_BYTES).describe(SHOW_HTML_INPUT_DESCRIPTION), }, }, - async ({ html }) => { - const created = await ops.showHtml(html); + async ({ html }, extra) => { + const created = await ops.showHtml(html, ownerIdFromExtra(extra)); return { content: [ { diff --git a/apps/api/package.json b/apps/api/package.json index 540405d..3d01a17 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,9 +23,12 @@ "@opentelemetry/sdk-metrics": "^2.7.1", "@opentelemetry/sdk-node": "^0.217.0", "@scalar/hono-api-reference": "^0.10.14", + "@types/nodemailer": "^8.0.0", "hono": "^4.6.14", "hono-rate-limiter": "^0.5.3", "isomorphic-dompurify": "^2.16.0", + "jose": "^6.2.3", + "nodemailer": "^8.0.7", "pino": "^10.3.1", "pino-opentelemetry-transport": "^3.0.0", "postgres": "^3.4.9", diff --git a/apps/api/schemas.test.ts b/apps/api/schemas.test.ts index a556828..df37d2f 100644 --- a/apps/api/schemas.test.ts +++ b/apps/api/schemas.test.ts @@ -283,3 +283,212 @@ describe('envSchema', () => { if (r.success) expect(r.data.PORT).toBe(8787); }); }); + +// --------------------------------------------------------------------------- +// envSchema — auth-related vars +// --------------------------------------------------------------------------- + +describe('envSchema (auth)', () => { + it('defaults REQUIRE_AUTH to false when unset', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(false); + }); + + it('treats empty-string REQUIRE_AUTH as unset (default false)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: '' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(false); + }); + + it('parses successfully with REQUIRE_AUTH=false and no auth vars', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: false }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(false); + }); + + it('treats REQUIRE_AUTH="false" (string) as false (process.env is always strings)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: 'false' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(false); + }); + + it('treats REQUIRE_AUTH="true" (string) as true and gates auth vars', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: 'true' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.some((i) => i.path.includes('JWT_SIGNING_KEY'))).toBe(true); + } + }); + + it('treats REQUIRE_AUTH="1" (string) as true', () => { + const r = envSchema.safeParse({ + DATABASE_URL: 'x', + REQUIRE_AUTH: '1', + JWT_SIGNING_KEY: 'k', + JWT_PUBLIC_KEY: 'k', + GOOGLE_CLIENT_ID: 'k', + GOOGLE_CLIENT_SECRET: 'k', + AUTH_STATE_SECRET: 'k', + SMTP_HOST: 'k', + SMTP_USER: 'k', + SMTP_PASS: 'k', + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(true); + }); + + it('treats REQUIRE_AUTH="0" (string) as false', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: '0' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(false); + }); + + it('applies session/token/SMTP defaults when unset', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x' }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.SESSION_MAX_AGE_DAYS).toBe(30); + expect(r.data.REFRESH_TOKEN_MAX_DAYS).toBe(90); + expect(r.data.ACCESS_TOKEN_TTL_SECONDS).toBe(3600); + expect(r.data.SMTP_PORT).toBe(587); + expect(r.data.SMTP_FROM).toBe('noreply@pagent.link'); + } + }); + + it('coerces SESSION_MAX_AGE_DAYS string to number', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', SESSION_MAX_AGE_DAYS: '60' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.SESSION_MAX_AGE_DAYS).toBe(60); + }); + + it('rejects SESSION_MAX_AGE_DAYS=0 (must be positive)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', SESSION_MAX_AGE_DAYS: '0' }); + expect(r.success).toBe(false); + }); + + it('rejects REFRESH_TOKEN_MAX_DAYS="-7" (non-positive)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REFRESH_TOKEN_MAX_DAYS: '-7' }); + expect(r.success).toBe(false); + }); + + it('rejects ACCESS_TOKEN_TTL_SECONDS="3.5" (non-int)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', ACCESS_TOKEN_TTL_SECONDS: '3.5' }); + expect(r.success).toBe(false); + }); + + it('rejects GOOGLE_REDIRECT_URI="not-a-url" (must be URL)', () => { + const r = envSchema.safeParse({ + DATABASE_URL: 'x', + GOOGLE_REDIRECT_URI: 'not-a-url', + }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.some((i) => i.path.includes('GOOGLE_REDIRECT_URI'))).toBe(true); + } + }); + + it('accepts GOOGLE_REDIRECT_URI as a valid URL', () => { + const r = envSchema.safeParse({ + DATABASE_URL: 'x', + GOOGLE_REDIRECT_URI: 'https://pagent.link/oauth/callback/google', + }); + expect(r.success).toBe(true); + }); + + it('rejects SMTP_FROM="not-an-email" (must be email)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', SMTP_FROM: 'not-an-email' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.some((i) => i.path.includes('SMTP_FROM'))).toBe(true); + } + }); + + it('treats empty-string GOOGLE_REDIRECT_URI as unset', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', GOOGLE_REDIRECT_URI: '' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.GOOGLE_REDIRECT_URI).toBeUndefined(); + }); + + it('treats empty-string SMTP_FROM as unset and applies default', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', SMTP_FROM: '' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.SMTP_FROM).toBe('noreply@pagent.link'); + }); + + // --- superRefine: REQUIRE_AUTH=true gating --------------------------------- + + it('fails with REQUIRE_AUTH=true and missing JWT_SIGNING_KEY', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: true }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.some((i) => i.path.includes('JWT_SIGNING_KEY'))).toBe(true); + } + }); + + it('reports every missing auth var (not just the first)', () => { + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: true }); + expect(r.success).toBe(false); + if (!r.success) { + const missingPaths = r.error.issues.map((i) => i.path[0]); + // All crypto/SMTP vars listed in the spec must appear as issues. + for (const key of [ + 'JWT_SIGNING_KEY', + 'JWT_PUBLIC_KEY', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'AUTH_STATE_SECRET', + 'SMTP_HOST', + 'SMTP_USER', + 'SMTP_PASS', + ]) { + expect(missingPaths).toContain(key); + } + } + }); + + it('accepts REQUIRE_AUTH=true when every required auth var is present', () => { + const r = envSchema.safeParse({ + DATABASE_URL: 'x', + REQUIRE_AUTH: true, + JWT_SIGNING_KEY: 'k1', + JWT_PUBLIC_KEY: 'k2', + GOOGLE_CLIENT_ID: 'g-id', + GOOGLE_CLIENT_SECRET: 'g-secret', + AUTH_STATE_SECRET: 'ass', + SMTP_HOST: 'smtp.example.com', + SMTP_USER: 'u', + SMTP_PASS: 'p', + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.REQUIRE_AUTH).toBe(true); + }); + + it('treats empty-string auth vars as unset (so superRefine reports them as missing)', () => { + // Regression for Railway: setting REQUIRE_AUTH=true with the auth vars as + // empty placeholders must fail-fast at boot, not pass silently. + const r = envSchema.safeParse({ + DATABASE_URL: 'x', + REQUIRE_AUTH: true, + JWT_SIGNING_KEY: '', + JWT_PUBLIC_KEY: '', + GOOGLE_CLIENT_ID: '', + GOOGLE_CLIENT_SECRET: '', + AUTH_STATE_SECRET: '', + SMTP_HOST: '', + SMTP_USER: '', + SMTP_PASS: '', + }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.some((i) => i.path.includes('JWT_SIGNING_KEY'))).toBe(true); + } + }); + + it('does NOT enforce auth vars when REQUIRE_AUTH=false (grace period)', () => { + // Pre-rollout, the API boots with auth code present but enforcement off. + // Crypto/SMTP can be absent; auth endpoints will 503 until configured. + const r = envSchema.safeParse({ DATABASE_URL: 'x', REQUIRE_AUTH: false }); + expect(r.success).toBe(true); + }); +}); diff --git a/apps/api/schemas.ts b/apps/api/schemas.ts index 0e87184..a7f6142 100644 --- a/apps/api/schemas.ts +++ b/apps/api/schemas.ts @@ -54,6 +54,20 @@ const stripEmptyStrings = (raw: unknown): unknown => { return out; }; +// Crypto/SMTP env vars that must be present when REQUIRE_AUTH=true. The auth +// implementation depends on every one of these; missing any would only crash +// later at request time. Validate up front so boot fails loudly instead. +const AUTH_REQUIRED_VARS = [ + 'JWT_SIGNING_KEY', + 'JWT_PUBLIC_KEY', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'AUTH_STATE_SECRET', + 'SMTP_HOST', + 'SMTP_USER', + 'SMTP_PASS', +] as const; + export const envSchema = z.preprocess( stripEmptyStrings, z @@ -80,6 +94,37 @@ export const envSchema = z.preprocess( LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).optional(), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(30), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + + // --- Auth --------------------------------------------------------------- + // Boots without these during the grace period; auth endpoints return 503 + // until configured. When REQUIRE_AUTH=true the superRefine below enforces + // every crypto/SMTP var (see AUTH_REQUIRED_VARS). + // + // NB: process.env values are always strings, so `z.coerce.boolean()` is a + // trap — it coerces every non-empty string (including 'false') to true. + // Explicit string handling matches the rest of the env layer. + REQUIRE_AUTH: z + .union([z.boolean(), z.string()]) + .optional() + .transform((v) => { + if (typeof v === 'boolean') return v; + if (v === undefined) return false; + return v === 'true' || v === '1'; + }), + JWT_SIGNING_KEY: z.string().optional(), + JWT_PUBLIC_KEY: z.string().optional(), + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GOOGLE_REDIRECT_URI: z.string().url().optional(), + AUTH_STATE_SECRET: z.string().optional(), + SESSION_MAX_AGE_DAYS: z.coerce.number().int().positive().optional().default(30), + REFRESH_TOKEN_MAX_DAYS: z.coerce.number().int().positive().optional().default(90), + ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().optional().default(3600), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().int().optional().default(587), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().email().optional().default('noreply@pagent.link'), }) .superRefine((cfg, ctx) => { if ( @@ -101,6 +146,17 @@ export const envSchema = z.preprocess( 'PUBLIC_URL is required in production. Set it to the renderer URL (e.g. https://pagent.link).', }); } + if (cfg.REQUIRE_AUTH) { + for (const key of AUTH_REQUIRED_VARS) { + if (!cfg[key]) { + ctx.addIssue({ + code: 'custom', + path: [key], + message: `${key} is required when REQUIRE_AUTH=true. See docs/superpowers/specs/2026-05-17-auth-design.md §9.`, + }); + } + } + } }), ); diff --git a/apps/api/server.ts b/apps/api/server.ts index aba532d..15e8ab5 100644 --- a/apps/api/server.ts +++ b/apps/api/server.ts @@ -4,6 +4,7 @@ import { getRequestListener } from '@hono/node-server'; import * as db from './db.ts'; import { env } from './schemas.ts'; import { app, PORT, PUBLIC_URL, PAGE_TTL_MS } from './app.ts'; +import { initKeys } from './auth/jwt.ts'; import { makeMcpHttpHandler } from './mcp/http.ts'; import { logger } from './logger.ts'; import { metrics } from './metrics.ts'; @@ -13,6 +14,19 @@ import { shutdownTracing } from './tracing.ts'; await db.init(env.DATABASE_URL); +// Initialize the Ed25519 JWT signing keys once at startup, so /oauth/token +// (signAccessToken) and /.well-known/jwks.json (getJwks) don't throw on +// first request. The env schema's superRefine guarantees both vars are +// present when REQUIRE_AUTH=true; the explicit error branch below is +// defense-in-depth against a future schema refactor that drops the check. +if (env.JWT_SIGNING_KEY && env.JWT_PUBLIC_KEY) { + await initKeys(env.JWT_SIGNING_KEY, env.JWT_PUBLIC_KEY); + logger.info('JWT signing keys initialized'); +} else if (env.REQUIRE_AUTH) { + logger.error('REQUIRE_AUTH=true but JWT keys missing — refusing to start'); + process.exit(1); +} + // Periodically reclaim expired DB rows. Correctness is enforced by // WHERE expires_at > now() on every read — this sweep is only for space. // Counts pages whose TTL fired while still 'open' as abandoned. diff --git a/apps/api/store.ts b/apps/api/store.ts index 72053f2..cfa0114 100644 --- a/apps/api/store.ts +++ b/apps/api/store.ts @@ -17,6 +17,13 @@ import type { ShowUiResult, CheckResultOutcome } from './mcp/tools.ts'; export type CreatePageConfig = { publicUrl: string; pageTtlMs: number; + /** + * UUID of the authenticated user creating the page. When set, the page row + * is inserted with `owner_id` so the user can list / manage their pages + * later. Null/undefined during the grace period (REQUIRE_AUTH=false) or + * for the in-process MCP stdio adapter, which has no auth context. + */ + ownerId?: string | null; }; /** @@ -48,6 +55,7 @@ export async function createPage( result: null, createdAt: now, expiresAt: now + cfg.pageTtlMs, + ownerId: cfg.ownerId ?? null, }; await db.insertPage(page); metrics.pagesCreated.add(1, { format }); diff --git a/apps/mcp/server.bundle.js b/apps/mcp/server.bundle.js index d36e061..445b2d4 100755 --- a/apps/mcp/server.bundle.js +++ b/apps/mcp/server.bundle.js @@ -21105,6 +21105,12 @@ import { pathToFileURL } from "node:url"; var HTML_MAX_BYTES = 1e6; // apps/api/mcp/tools.ts +function ownerIdFromExtra(extra) { + if (!extra || typeof extra !== "object") return void 0; + const authInfo = extra.authInfo; + const sub = authInfo?.extra?.sub; + return typeof sub === "string" ? sub : void 0; +} var SHOW_UI_DESCRIPTION = [ "Ask the user a question that needs a structured answer back. Forms, pickers, confirmations, multi-step wizards, surveys, dashboards-as-input.", "Returns { page_id, url, expires_at }. PRINT the URL so the user can open it. The agent never sees the user typing \u2014 only the final submitted result.", @@ -21151,8 +21157,8 @@ function registerPagentTools(server2, ops) { spec: external_exports.array(external_exports.record(external_exports.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION) } }, - async ({ spec }) => { - const created = await ops.showUi(spec); + async ({ spec }, extra) => { + const created = await ops.showUi(spec, ownerIdFromExtra(extra)); return { content: [ { @@ -21181,8 +21187,8 @@ expires_at: ${created.expires_at}` html: external_exports.string().min(1).max(HTML_MAX_BYTES).describe(SHOW_HTML_INPUT_DESCRIPTION) } }, - async ({ html }) => { - const created = await ops.showHtml(html); + async ({ html }, extra) => { + const created = await ops.showHtml(html, ownerIdFromExtra(extra)); return { content: [ { @@ -21263,7 +21269,13 @@ var envSchema = external_exports.preprocess( return out; }, external_exports.object({ - PAGENT_URL: external_exports.string().url("PAGENT_URL must be a valid URL").optional() + PAGENT_URL: external_exports.string().url("PAGENT_URL must be a valid URL").optional(), + // Bearer token for authenticated REST calls. The stdio transport has no + // auth context of its own — the agent that spawns this process exports + // PAGENT_TOKEN, we attach it to every outbound HTTP request, and the API + // populates owner_id from the JWT's `sub` claim. Optional so the grace + // period (REQUIRE_AUTH=false) keeps working without any env changes. + PAGENT_TOKEN: external_exports.string().optional() }) ); var env; @@ -21274,6 +21286,10 @@ try { process.exit(1); } var SERVICE_URL = (env.PAGENT_URL ?? "https://api.pagent.link").replace(/\/$/, ""); +var PAGENT_TOKEN = env.PAGENT_TOKEN; +function authHeaders() { + return PAGENT_TOKEN ? { Authorization: `Bearer ${PAGENT_TOKEN}` } : {}; +} async function readError(res, fallbackVerb) { const body = await res.json().catch(() => ({})); const hint = formatRetryHint(body); @@ -21281,19 +21297,19 @@ async function readError(res, fallbackVerb) { return new Error(`${fallbackVerb} failed (${res.status}): ${message}${hint ? `. ${hint}` : ""}`); } var restOps = { - async showUi(spec) { + async showUi(spec, _ownerId) { const res = await fetch(`${SERVICE_URL}/new`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...authHeaders() }, body: JSON.stringify({ spec }) }); if (!res.ok) throw await readError(res, "show_ui"); return await res.json(); }, - async showHtml(html) { + async showHtml(html, _ownerId) { const res = await fetch(`${SERVICE_URL}/new`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...authHeaders() }, body: JSON.stringify({ format: "html", spec: html }) }); if (!res.ok) throw await readError(res, "show_html"); @@ -21301,7 +21317,7 @@ var restOps = { }, async checkResult(page_id) { const res = await fetch(`${SERVICE_URL}/${page_id}/result`, { - headers: { accept: "application/json" } + headers: { accept: "application/json", ...authHeaders() } }); if (res.status === 404) return { kind: "not_found" }; if (!res.ok) throw await readError(res, "check_result"); diff --git a/apps/mcp/server.test.ts b/apps/mcp/server.test.ts index 9ebc374..a3461dd 100644 --- a/apps/mcp/server.test.ts +++ b/apps/mcp/server.test.ts @@ -1,11 +1,23 @@ /** - * Unit tests for the formatRetryHint helper exported from lib.ts. - * Only the pure helper is tested here; full show_ui / check_result flows - * are covered by the smoke script (apps/mcp/smoke.mjs). + * Unit tests for the formatRetryHint helper exported from lib.ts and a + * structural pin on the PAGENT_TOKEN → Authorization: Bearer wiring in + * server.ts. Full show_ui / check_result flows are covered by the smoke + * script (apps/mcp/smoke.mjs); PAGENT_TOKEN behaviour is structural because + * the env var is captured at module load — testing it dynamically would + * require re-importing on every case, which is more brittle than the SQL + * source-probe pattern db.test.ts already uses. */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { formatRetryHint } from './lib.ts'; +const serverSource = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'server.ts'), + 'utf8', +); + describe('formatRetryHint', () => { it('returns empty string for an empty body', () => { expect(formatRetryHint({})).toBe(''); @@ -23,3 +35,31 @@ describe('formatRetryHint', () => { expect(formatRetryHint({ retry_after_seconds: 30, max_bytes: 262144 })).toBe('Retry after 30s'); }); }); + +describe('PAGENT_TOKEN wiring (structural)', () => { + it('PAGENT_TOKEN is declared as an optional string in the env schema', () => { + expect(serverSource).toMatch(/PAGENT_TOKEN:\s*z\.string\(\)\.optional\(\)/); + }); + + it('authHeaders returns Authorization: Bearer ${PAGENT_TOKEN} when set', () => { + // The literal `Bearer ${PAGENT_TOKEN}` template ensures the header carries + // the exact token the agent supplied — no padding, no rotation, no decode. + expect(serverSource).toMatch(/Authorization:\s*`Bearer \$\{PAGENT_TOKEN\}`/); + }); + + it('authHeaders returns an empty object when PAGENT_TOKEN is unset (grace period)', () => { + expect(serverSource).toMatch(/PAGENT_TOKEN\s*\?\s*\{\s*Authorization:.*\}\s*:\s*\{\s*\}/); + }); + + it('showUi spreads authHeaders into the outbound POST', () => { + expect(serverSource).toMatch( + /headers:\s*\{[^}]*'content-type':\s*'application\/json',\s*\.\.\.authHeaders\(\)/, + ); + }); + + it('checkResult spreads authHeaders into the outbound GET', () => { + expect(serverSource).toMatch( + /headers:\s*\{[^}]*accept:\s*'application\/json',\s*\.\.\.authHeaders\(\)/, + ); + }); +}); diff --git a/apps/mcp/server.ts b/apps/mcp/server.ts index 80c15cb..f2fadf4 100755 --- a/apps/mcp/server.ts +++ b/apps/mcp/server.ts @@ -26,6 +26,12 @@ const envSchema = z.preprocess( }, z.object({ PAGENT_URL: z.string().url('PAGENT_URL must be a valid URL').optional(), + // Bearer token for authenticated REST calls. The stdio transport has no + // auth context of its own — the agent that spawns this process exports + // PAGENT_TOKEN, we attach it to every outbound HTTP request, and the API + // populates owner_id from the JWT's `sub` claim. Optional so the grace + // period (REQUIRE_AUTH=false) keeps working without any env changes. + PAGENT_TOKEN: z.string().optional(), }), ); @@ -39,6 +45,17 @@ try { // Read once at startup; fine for a short-lived stdio process. const SERVICE_URL = (env.PAGENT_URL ?? 'https://api.pagent.link').replace(/\/$/, ''); +const PAGENT_TOKEN = env.PAGENT_TOKEN; + +/** + * Returns the auth headers for every outbound REST call. Empty object when + * PAGENT_TOKEN is unset (grace period); a Bearer header otherwise. Computed + * once at startup and reused — the token doesn't rotate within a single + * stdio process lifetime. + */ +function authHeaders(): Record { + return PAGENT_TOKEN ? { Authorization: `Bearer ${PAGENT_TOKEN}` } : {}; +} type ApiErrorBody = { message?: string; @@ -53,20 +70,26 @@ async function readError(res: Response, fallbackVerb: string): Promise { return new Error(`${fallbackVerb} failed (${res.status}): ${message}${hint ? `. ${hint}` : ''}`); } +// The PageOps `ownerId` parameter is intentionally ignored on the stdio path. +// Stdio has no authInfo to extract from — the agent's identity flows via +// PAGENT_TOKEN (Bearer) on every REST call, and the API's middleware turns +// the JWT `sub` claim into the page's owner_id server-side. Forwarding the +// header is therefore enough; we don't need to plumb a second copy through +// the request body. const restOps: PageOps = { - async showUi(spec) { + async showUi(spec, _ownerId) { const res = await fetch(`${SERVICE_URL}/new`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...authHeaders() }, body: JSON.stringify({ spec }), }); if (!res.ok) throw await readError(res, 'show_ui'); return (await res.json()) as { id: string; url: string; expires_at: number }; }, - async showHtml(html) { + async showHtml(html, _ownerId) { const res = await fetch(`${SERVICE_URL}/new`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...authHeaders() }, body: JSON.stringify({ format: 'html', spec: html }), }); if (!res.ok) throw await readError(res, 'show_html'); @@ -74,7 +97,7 @@ const restOps: PageOps = { }, async checkResult(page_id) { const res = await fetch(`${SERVICE_URL}/${page_id}/result`, { - headers: { accept: 'application/json' }, + headers: { accept: 'application/json', ...authHeaders() }, }); if (res.status === 404) return { kind: 'not_found' }; if (!res.ok) throw await readError(res, 'check_result'); diff --git a/docs/superpowers/specs/2026-05-17-agent-submit-design.md b/docs/superpowers/specs/2026-05-17-agent-submit-design.md new file mode 100644 index 0000000..1b8f046 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-agent-submit-design.md @@ -0,0 +1,1397 @@ +# Agent Form Submission (`submit_form`) -- Design + +Status: draft, awaiting user review (2026-05-17). + +## Goal + +Allow agents to fill and submit Pagent forms programmatically, without a +browser. A new MCP tool `submit_form` and a dual-format REST endpoint let +an agent read a page's spec, validate field data locally, upload files, +and submit -- all over the existing API surface. + +## Motivation + +Today, only a human in a browser can submit a Pagent form. The browser +renderer captures user input as an A2UI client-action (`{ name, +surfaceId, context, timestamp }`) and POSTs it to `POST /:id/result`. +There is no path for a second agent to fill that form on behalf of a +user or as part of an agent-to-agent pipeline. + +### Use cases + +1. **Agent-to-agent coordination.** Agent A creates a form (approval + request, configuration wizard, intake form). Agent B -- running in a + different session, possibly a different host -- fills the form with + computed values and submits it. Agent A reads the result through the + existing `check_result` flow, unmodified. + +2. **Automated testing.** Integration tests can exercise the full + create-fill-submit-read cycle without spinning up a headless browser. + +3. **Workflow orchestration.** A central orchestrator agent creates a + page, dispatches the URL to a specialist agent, and collects the + result. The specialist agent uses `submit_form` to answer. + +4. **Bulk / batch operations.** An agent creates multiple pages (one per + item in a batch) and another agent fills them in a loop, enabling + parallelism across page boundaries. + +## Non-goals + +- **Replacing browser submission.** The browser renderer continues to + post A2UI client-actions exactly as it does today. `submit_form` is an + alternative submission channel, not a replacement. + +- **Multi-turn form filling.** Pages remain single-shot. `submit_form` + does not introduce a "save draft" or "partial submit" concept. + +- **Spec generation.** `submit_form` does not create pages. It submits + to existing pages created by `show_ui`. + +- **HTML page submission.** HTML pages are view-only by design (see + html-format-design.md). `submit_form` rejects HTML pages with the + existing `invalid_for_format` error. + +## Dependencies + +This spec depends on two features that ship in the same v2 batch: + +1. **Auth (OAuth tokens).** `submit_form` carries the submitting agent's + identity from its OAuth token. Agent identity comes from OAuth tokens; + the auth feature provides `user_id` and `email` claims on the token. + +2. **File uploads (`POST /:id/files`).** `submit_form` supports file + attachments. The MCP tool reads files from local disk, uploads them to + the file upload endpoint, and references the returned file IDs in the + submission payload. The file upload endpoint and Supabase Storage + integration must exist before `submit_form` can handle files. + +Both dependencies ship in the same v2 batch. `submit_form` can ship in a +reduced form (no files, anonymous identity) ahead of them, but the full +design assumes they exist. + +--- + +## 1. MCP Tool Definition + +### Tool: `submit_form` + +``` +Name: submit_form +Title: Submit a form on behalf of the agent +``` + +#### Description (model-facing) + +``` +Fill and submit a Pagent form programmatically. Use this when you need to +answer a form created by another agent (or yourself) without waiting for +a human in a browser. + +Fetches the page spec, validates your data against the declared fields, +uploads any files, and submits. Returns { submission_id } on success or +detailed field-level validation errors on failure. + +The page must be in state "open" (not already submitted). HTML pages +(format: html) cannot be submitted -- they are view-only. + +After submission, the creating agent reads the result through +check_result exactly as if a human had submitted in the browser. +``` + +#### Input schema + +```typescript +{ + page_id: z.string() + .regex(/^[a-f0-9]{32}$/) + .describe('The page_id of the form to submit. Must be an open a2ui page.'), + + data: z.record(z.unknown()) + .describe( + 'Field values keyed by component ID. Each key must match a component ' + + 'ID in the page spec that accepts input (TextField, CheckBox, Slider, ' + + 'ChoicePicker, DateTimeInput). Values must match the expected type for ' + + 'that component. Example: { "tf-email": "alice@example.com", "cb-terms": true }' + ), + + files: z.record(z.string()) + .optional() + .describe( + 'File attachments keyed by component ID. Each value is an absolute ' + + 'path to a local file. The tool reads the file, uploads it to the ' + + 'page\'s file endpoint, and includes the file reference in the ' + + 'submission. Example: { "upload-field": "/tmp/report.pdf" }' + ), +} +``` + +#### Response + +**Success:** +```json +{ + "content": [{ "type": "text", "text": "Form submitted successfully." }], + "structuredContent": { + "submission_id": "", + "page_id": "", + "submitted_at": "2026-05-17T12:00:00.000Z" + } +} +``` + +**Validation failure (does not submit):** +```json +{ + "content": [{ "type": "text", "text": "Validation failed for 2 fields." }], + "structuredContent": { + "page_id": "", + "valid": false, + "errors": [ + { "field": "tf-email", "message": "Expected string, got number" }, + { "field": "cb-terms", "message": "Required field missing" } + ] + } +} +``` + +**Error cases:** + +| Condition | Behavior | +|-----------|----------| +| Page not found / expired | MCP error: "Page {page_id} not found (expired or deleted)." | +| Page is HTML format | MCP error: "Page {page_id} is HTML (view-only). submit_form only works with a2ui pages." | +| Page already submitted | MCP error: "Page {page_id} was already submitted. Create a new page if you need another submission." | +| Validation failure | Returns structured validation errors (not an MCP error -- the tool succeeds but reports invalid data) | +| File not found on disk | MCP error: "File not found: /path/to/file" | +| File upload fails | MCP error: "File upload failed for field {field}: {reason}" | +| Auth: private page, email not in allowlist | MCP error: "Access denied. Your email ({email}) is not in this page's access list." | + +--- + +## 2. API Endpoint Changes + +### `POST /:id/result` -- dual-format + +The existing endpoint accepts A2UI client-actions from the browser. It +gains a second payload format for agent submissions, distinguished by +the `source` field. + +#### Payload discrimination + +The handler inspects the parsed JSON body: + +1. If the body has a `source` field with value `"agent"`, treat it as an + agent submission (new path). +2. Otherwise, treat it as a browser submission (existing path -- A2UI + client-action with `name`, `surfaceId`, etc.). + +This is backward-compatible: existing browser payloads never include a +`source` field. + +#### Agent submission payload + +```typescript +// Zod schema +const agentResultBodySchema = z.object({ + source: z.literal('agent'), + + data: z.record(z.unknown()), + // Field values keyed by component ID. + // Example: { "tf-email": "alice@co", "cb-terms": true, "sl-bright": 75 } + + file_refs: z.record(z.string()).optional(), + // File references keyed by component ID. + // Each value is a file_id returned by POST /:id/files. + // Example: { "upload-field": "file_abc123" } + + submitted_by: z.string().email().optional(), + // Populated server-side from the OAuth token. Agents do not set this + // directly; the server overwrites any client-supplied value. +}); +``` + +#### Full request body schema (updated) + +```typescript +const resultBodySchema = z.union([ + // Branch 1: Agent submission (new) + agentResultBodySchema, + + // Branch 2: Browser submission (existing, unchanged) + z.object({ + name: z.string().min(1), + surfaceId: z.string().min(1), + sourceComponentId: z.string().optional(), + context: z.record(z.unknown()).optional().default({}), + timestamp: z.string().datetime().optional(), + }).passthrough(), +]); +``` + +#### Stored result shape + +When an agent submits, the stored `result` in the `pages` table is: + +```json +{ + "source": "agent", + "data": { "tf-email": "alice@co", "cb-terms": true }, + "file_refs": { "upload-field": "file_abc123" }, + "submitted_by": "agent-b@example.com", + "submitted_at": "2026-05-17T12:00:00.000Z" +} +``` + +When a browser submits (existing), the stored `result` is the A2UI +client-action shape as before: + +```json +{ + "name": "submitted", + "surfaceId": "main", + "sourceComponentId": "submit", + "context": { "name": "Alex" }, + "timestamp": "2026-05-17T12:00:00.000Z" +} +``` + +The `check_result` consumer (Agent A) can distinguish by checking for +`result.source === "agent"`. + +#### Server-side validation (agent submissions) + +When the server receives an agent submission, it performs validation +before storing: + +1. **Page lookup.** Same as today: fetch the page, check it exists and + is not expired, check format is `a2ui`, check state is `open`. + +2. **Spec parsing.** Extract the component tree from the page's stored + spec. Build a map of `component_id -> component_definition`. + +3. **Field validation.** For each key in `data`, find the matching + component and validate the value against the component type (see + section 4). Collect all errors. + +4. **Required field check.** Walk the spec's input components. Any + component with no corresponding key in `data` and no default value is + flagged as missing (warning, not hard error in v1 -- the creating + agent may not have marked fields as required). + +5. **File reference check.** For each key in `file_refs`, verify the + file_id exists in storage and belongs to this page. + +6. If validation passes, store the result and transition `open -> + submitted` exactly as the existing path does. + +#### Response codes + +| Status | Condition | +|--------|-----------| +| 200 | Submitted successfully. Body: `{ ok: true, submission_id: "" }` | +| 400 `bad_request` | Malformed body (fails schema parse) | +| 400 `validation_failed` | Data does not match spec (field-level errors returned) | +| 400 `invalid_for_format` | Page is HTML (view-only) | +| 404 `not_found` | Page not found or expired | +| 403 `access_denied` | Private page, submitter email not in allowlist | +| 409 `conflict` | Page already submitted | + +#### Validation error response (400 `validation_failed`) + +```json +{ + "error": "validation_failed", + "message": "2 field(s) failed validation", + "fields": [ + { + "field": "tf-email", + "component": "TextField", + "expected": "string", + "got": "number", + "message": "Expected a string value for TextField" + }, + { + "field": "cb-terms", + "component": "CheckBox", + "expected": "boolean", + "got": "undefined", + "message": "Required field missing" + } + ] +} +``` + +--- + +## 3. Result body schema update in `schemas.ts` + +The `resultBodySchema` in `apps/api/schemas.ts` changes from a single +object schema to a discriminated union. + +**Current:** + +```typescript +export const resultBodySchema = z + .object({ + name: z.string().min(1), + surfaceId: z.string().min(1), + sourceComponentId: z.string().optional(), + context: z.record(z.unknown()).optional().default({}), + timestamp: z.string().datetime().optional(), + }) + .passthrough(); +``` + +**New:** + +```typescript +// Agent submission shape +export const agentResultBodySchema = z.object({ + source: z.literal('agent'), + data: z.record(z.unknown()), + file_refs: z.record(z.string()).optional(), +}); + +// Browser submission shape (unchanged) +export const browserResultBodySchema = z + .object({ + name: z.string().min(1), + surfaceId: z.string().min(1), + sourceComponentId: z.string().optional(), + context: z.record(z.unknown()).optional().default({}), + timestamp: z.string().datetime().optional(), + }) + .passthrough(); + +// Union: try agent shape first (has discriminant `source`), fall back +// to browser shape. +export const resultBodySchema = z.union([ + agentResultBodySchema, + browserResultBodySchema, +]); + +export type AgentResult = z.infer; +export type BrowserResult = z.infer; +export type ResultBody = z.infer; +``` + +Backward compatibility: existing browser POSTs never include +`source: "agent"`, so they match the second branch. The `.passthrough()` +on the browser branch means extra fields are tolerated, same as today. + +--- + +## 4. A2UI Spec-to-Validation Mapping + +When an agent submits via `submit_form`, each value in `data` must match +the type expected by the A2UI component it targets. The mapping below +defines the validation rules per component type from the basic catalog. + +### Component type table + +| Component | Value type | Validation rules | Notes | +|-----------|-----------|------------------|-------| +| `TextField` (variant: `text`) | `string` | Max 10,000 chars | | +| `TextField` (variant: `number`) | `number` | Must be finite | Strings that parse as numbers are coerced | +| `TextField` (variant: `obscured`) | `string` | Max 10,000 chars | Same as text | +| `TextField` (variant: `longText`) | `string` | Max 50,000 chars | Textarea equivalent | +| `CheckBox` | `boolean` | Must be `true` or `false` | | +| `Slider` | `number` | Must be within `[min, max]` if spec declares bounds | | +| `ChoicePicker` (variant: `singleSelection`) | `string[]` with exactly 1 element | Element must be one of `options[].value` | Array shape matches A2UI's internal model | +| `ChoicePicker` (variant: `multipleSelection`) | `string[]` | Each element must be one of `options[].value` | | +| `DateTimeInput` | `string` | ISO 8601 datetime. If `enableDate: false`, time-only format (`HH:mm`). If `enableTime: false`, date-only format (`YYYY-MM-DD`). | | +| `Button` | _not a data field_ | Ignored in `data` -- buttons are actions, not inputs | | +| `Text` | _not a data field_ | Ignored in `data` | | +| `Image` | _not a data field_ | Ignored in `data` | | +| `Icon` | _not a data field_ | Ignored in `data` | | +| `Divider` | _not a data field_ | Ignored in `data` | | +| `Card` | _not a data field_ | Layout container, ignored in `data` | | +| `Column` | _not a data field_ | Layout container, ignored in `data` | | +| `Row` | _not a data field_ | Layout container, ignored in `data` | | +| `Tabs` | _not a data field_ | Layout container, ignored in `data` | | +| `Modal` | _not a data field_ | Layout container, ignored in `data` | | +| `List` | _not a data field_ | Layout container, ignored in `data` | | + +### Input component set + +For validation purposes, only these components are "input components" +that accept values in `data`: + +```typescript +const INPUT_COMPONENTS = new Set([ + 'TextField', + 'CheckBox', + 'Slider', + 'ChoicePicker', + 'DateTimeInput', +]); +``` + +A key in `data` that targets a non-input component (or a component ID +that does not exist in the spec) produces a validation warning, not an +error. This is lenient by design -- agents may include extra context +keys that the spec does not declare, and stripping them silently is +better than hard-failing. + +### Extracting the component map from a spec + +The A2UI spec is an array of messages. The component tree lives inside +the `updateComponents` message: + +```typescript +function extractComponentMap( + spec: unknown +): Map { + const map = new Map(); + if (!Array.isArray(spec)) return map; + + for (const msg of spec) { + if (msg?.updateComponents?.components) { + for (const comp of msg.updateComponents.components) { + if (comp?.id && comp?.component) { + map.set(comp.id, comp); + } + } + } + } + return map; +} +``` + +### Validation function + +```typescript +type FieldError = { + field: string; + component: string; + expected: string; + got: string; + message: string; +}; + +function validateAgentData( + data: Record, + componentMap: Map +): FieldError[] { + const errors: FieldError[] = []; + + for (const [fieldId, value] of Object.entries(data)) { + const comp = componentMap.get(fieldId); + if (!comp) continue; // Unknown field: ignore (lenient) + if (!INPUT_COMPONENTS.has(comp.component)) continue; // Non-input: ignore + + const err = validateFieldValue(fieldId, comp, value); + if (err) errors.push(err); + } + + return errors; +} + +function validateFieldValue( + fieldId: string, + comp: { component: string; [k: string]: unknown }, + value: unknown +): FieldError | null { + switch (comp.component) { + case 'TextField': { + if (comp.variant === 'number') { + if (typeof value === 'string') { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { + field: fieldId, component: 'TextField', + expected: 'number', got: typeof value, + message: `String "${value}" cannot be parsed as a number`, + }; + } + // Coercion succeeds -- caller should use parsed value + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { + field: fieldId, component: 'TextField', + expected: 'number', got: typeof value, + message: 'Expected a finite number', + }; + } + } else { + if (typeof value !== 'string') { + return { + field: fieldId, component: 'TextField', + expected: 'string', got: typeof value, + message: 'Expected a string value for TextField', + }; + } + const maxLen = comp.variant === 'longText' ? 50_000 : 10_000; + if (value.length > maxLen) { + return { + field: fieldId, component: 'TextField', + expected: `string (max ${maxLen} chars)`, got: `string (${value.length} chars)`, + message: `Value exceeds ${maxLen} character limit`, + }; + } + } + return null; + } + + case 'CheckBox': { + if (typeof value !== 'boolean') { + return { + field: fieldId, component: 'CheckBox', + expected: 'boolean', got: typeof value, + message: 'Expected true or false', + }; + } + return null; + } + + case 'Slider': { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { + field: fieldId, component: 'Slider', + expected: 'number', got: typeof value, + message: 'Expected a finite number', + }; + } + const min = typeof comp.min === 'number' ? comp.min : -Infinity; + const max = typeof comp.max === 'number' ? comp.max : Infinity; + if (value < min || value > max) { + return { + field: fieldId, component: 'Slider', + expected: `number in [${min}, ${max}]`, got: String(value), + message: `Value ${value} is outside the allowed range [${min}, ${max}]`, + }; + } + return null; + } + + case 'ChoicePicker': { + if (!Array.isArray(value)) { + return { + field: fieldId, component: 'ChoicePicker', + expected: 'string[]', got: typeof value, + message: 'Expected an array of selected option values', + }; + } + const options = Array.isArray(comp.options) + ? (comp.options as { value: string }[]).map(o => o.value) + : []; + for (const v of value) { + if (typeof v !== 'string') { + return { + field: fieldId, component: 'ChoicePicker', + expected: 'string', got: typeof v, + message: 'Each selected value must be a string', + }; + } + if (options.length > 0 && !options.includes(v)) { + return { + field: fieldId, component: 'ChoicePicker', + expected: `one of [${options.join(', ')}]`, got: v, + message: `"${v}" is not a valid option`, + }; + } + } + if (comp.variant === 'singleSelection' && value.length !== 1) { + return { + field: fieldId, component: 'ChoicePicker', + expected: 'exactly 1 selected value', got: `${value.length} values`, + message: 'Single-selection ChoicePicker requires exactly one value', + }; + } + return null; + } + + case 'DateTimeInput': { + if (typeof value !== 'string') { + return { + field: fieldId, component: 'DateTimeInput', + expected: 'string (ISO 8601)', got: typeof value, + message: 'Expected an ISO 8601 date/time string', + }; + } + // Basic format check. Full ISO 8601 parsing is complex; we check + // the common shapes the renderer produces. + const dateOnly = /^\d{4}-\d{2}-\d{2}$/; + const timeOnly = /^\d{2}:\d{2}(:\d{2})?$/; + const dateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/; + if (comp.enableDate === false && comp.enableTime !== false) { + if (!timeOnly.test(value)) { + return { + field: fieldId, component: 'DateTimeInput', + expected: 'HH:mm or HH:mm:ss', got: value, + message: 'Time-only DateTimeInput expects HH:mm format', + }; + } + } else if (comp.enableTime === false && comp.enableDate !== false) { + if (!dateOnly.test(value)) { + return { + field: fieldId, component: 'DateTimeInput', + expected: 'YYYY-MM-DD', got: value, + message: 'Date-only DateTimeInput expects YYYY-MM-DD format', + }; + } + } else { + if (!dateTime.test(value) && !dateOnly.test(value)) { + return { + field: fieldId, component: 'DateTimeInput', + expected: 'ISO 8601 datetime', got: value, + message: 'Expected ISO 8601 datetime (e.g., 2026-05-17T12:00:00Z)', + }; + } + } + return null; + } + + default: + return null; + } +} +``` + +### Where validation runs + +Validation runs in **two places** with the same logic: + +1. **MCP tool (client-side).** The `submit_form` tool fetches the spec + via `GET /:id`, extracts the component map, and validates locally. + This provides fast feedback: the agent sees errors without a + round-trip to the submit endpoint. + +2. **API server (server-side).** The `POST /:id/result` handler + validates agent submissions before storing. This is the authoritative + check -- even if a caller bypasses the MCP tool and POSTs directly, + the server catches invalid data. + +The validation module is shared code that both the MCP server and the +API import from a common location (new file: +`apps/api/validate-agent-data.ts`). The MCP stdio server bundles it; +the API server imports it directly. + +--- + +## 5. File Handling + +### Flow: local path to file reference + +``` +Agent (MCP tool) API Storage + | | | + | 1. read file from | | + | local disk | | + | | | + | 2. POST /:id/files | | + | multipart/form-data | | + | ---------------------->| 3. validate, store | + | | --------------------->| + | | <-- file_id ----------| + | <-- { file_id } -------| | + | | | + | 4. POST /:id/result | | + | { source: "agent", | | + | data: {...}, | | + | file_refs: { | | + | "field": "id" | | + | } | | + | ---------------------->| | + | | 5. verify file_id | + | | belongs to page | + | | | + | <-- { ok: true } ------| | +``` + +### MCP tool file handling (stdio server) + +```typescript +// In the submit_form handler: + +async function uploadFiles( + pageId: string, + files: Record, + serviceUrl: string +): Promise> { + const fileRefs: Record = {}; + + for (const [fieldId, filePath] of Object.entries(files)) { + // 1. Verify file exists + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + throw new Error(`Not a file: ${filePath}`); + } + + // 2. Read file + const fileBuffer = await fs.readFile(filePath); + const fileName = path.basename(filePath); + const mimeType = guessMimeType(fileName); // simple extension lookup + + // 3. Upload via multipart + const form = new FormData(); + form.append('file', new Blob([fileBuffer], { type: mimeType }), fileName); + form.append('field_name', fieldId); + + const res = await fetch(`${serviceUrl}/${pageId}/files`, { + method: 'POST', + body: form, + // Auth header: include OAuth Bearer token for agent identity + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + `File upload failed for field "${fieldId}": ${body.message ?? res.status}` + ); + } + + const result = await res.json() as { file_id: string }; + fileRefs[fieldId] = result.file_id; + } + + return fileRefs; +} +``` + +### File constraints + +- Max file size: 10 MB per file (enforced by `POST /:id/files`). +- Max files per page: 10 (enforced by the file upload endpoint). +- Allowed MIME types: configurable per deployment; default allowlist + includes common document, image, and archive types. +- File IDs are scoped to a page -- a file_id from page A cannot be + referenced in page B's submission. + +--- + +## 6. Auth and Access Control + +### Token-based identity + +Agent submissions carry identity via OAuth tokens (ships in the same v2 +batch): + +1. The MCP tool includes the OAuth access token in the + `Authorization: Bearer ` header on both `POST /:id/files` + and `POST /:id/result`. + +2. The server extracts the `email` claim from the token and sets + `submitted_by` on the stored result. + +3. The audit log records `submitted_by` alongside the `page_id` and + timestamp. + +### Private pages + +Private pages have an `access_emails` allowlist (array of email +addresses). When a submission arrives: + +``` +1. Extract email from OAuth token +2. If page.access_emails is non-null and non-empty: + a. If email is in access_emails -> allow + b. If email is NOT in access_emails -> 403 access_denied +3. If page.access_emails is null (public page) -> allow +``` + +Error response for access denied: + +```json +{ + "error": "access_denied", + "message": "Your email (agent-b@example.com) is not in this page's access list", + "request_id": "..." +} +``` + +### Anonymous fallback + +When the submitting agent has no token (e.g., token expired or not yet +configured), submissions are accepted without identity. The +`submitted_by` field is `null`. Private pages with an `access_emails` +allowlist reject anonymous submissions with 401 (not 403). + +### Public mode pages + +Pages created with `mode: "public"` accept submissions from anyone +(including agents) without access checks. The `submit_form` tool works +identically regardless of mode -- public mode simply skips the email +allowlist check. + +--- + +## 7. Error Responses + +### Error taxonomy for `submit_form` + +All errors from the MCP tool are surfaced as either: + +- **MCP errors** (tool throws): for conditions where submission cannot + proceed at all (page not found, access denied, file I/O failure). + The MCP protocol surfaces these to the calling agent as error + responses. + +- **Structured validation results** (tool returns normally): for data + validation failures. The tool returns a success response with + `valid: false` and a `fields` array, so the agent can inspect + individual field errors and correct them. + +This distinction matters: MCP errors are terminal (the agent should not +retry the same call), while validation results are actionable (the agent +can fix the data and resubmit). + +### Complete error catalog + +| Error code | HTTP | MCP surface | Description | +|------------|------|-------------|-------------| +| `not_found` | 404 | throw | Page does not exist or has expired | +| `invalid_for_format` | 400 | throw | Page is HTML (view-only) | +| `conflict` | 409 | throw | Page already submitted | +| `access_denied` | 403 | throw | Private page, email not in allowlist | +| `unauthorized` | 401 | throw | Private page, no auth token | +| `validation_failed` | 400 | return `{ valid: false, errors }` | Data does not match spec | +| `file_not_found` | -- | throw | Local file path does not exist (MCP only) | +| `file_upload_failed` | varies | throw | Server rejected the file upload | +| `bad_request` | 400 | throw | Malformed request body | +| `rate_limited` | 429 | throw | Too many requests | +| `internal_error` | 500 | throw | Unexpected server error | + +--- + +## 8. Agent-to-Agent Workflow Examples + +### Example 1: Approval workflow + +Agent A (orchestrator) needs Agent B (reviewer) to approve a deployment. + +``` +Step 1: Agent A creates the form +──────────────────────────────── +Agent A calls show_ui with a spec: + - Text: "Deployment Approval for service-x v2.3.1" + - ChoicePicker (id: "decision"): + variant: singleSelection + options: [{ label: "Approve", value: "approve" }, + { label: "Reject", value: "reject" }] + - TextField (id: "notes"): + label: "Notes (optional)" + variant: longText + - Button: "Submit" with action referencing /decision and /notes + +show_ui returns { page_id: "abc123...", url: "https://pagent.link/abc123..." } + +Step 2: Agent A sends page_id to Agent B +───────────────────────────────────────── +Agent A communicates the page_id to Agent B through whatever channel +connects them (shared context, message queue, file, etc.). + +Step 3: Agent B submits programmatically +──────────────────────────────────────── +Agent B calls submit_form: + page_id: "abc123..." + data: { + "decision": ["approve"], + "notes": "LGTM. Canary metrics look clean." + } + +The tool: + 1. Fetches GET /abc123... to read the spec + 2. Validates: "decision" is ChoicePicker (singleSelection), + ["approve"] is valid (1 element, in options). "notes" is + TextField (longText), string value OK. + 3. POSTs to /abc123.../result with source: "agent" + 4. Returns { submission_id: "abc123...", submitted_at: "..." } + +Step 4: Agent A reads the result +──────────────────────────────── +Agent A calls check_result("abc123..."): + { + state: "submitted", + result: { + source: "agent", + data: { "decision": ["approve"], "notes": "LGTM..." }, + submitted_by: "agent-b@example.com", + submitted_at: "2026-05-17T12:00:00Z" + } + } + +Agent A proceeds with the deployment. +``` + +### Example 2: Data collection pipeline with files + +Agent A (collector) creates a form for Agent B (analyst) to attach a +report. + +``` +Step 1: Agent A creates the form +──────────────────────────────── +show_ui with a spec containing: + - TextField (id: "title"): label "Report title" + - TextField (id: "summary"): label "Summary", variant "longText" + - (Future: FileUpload component for the attachment) + +page_id: "def456..." + +Step 2: Agent B submits with a file +─────────────────────────────────── +Agent B calls submit_form: + page_id: "def456..." + data: { + "title": "Q2 Revenue Analysis", + "summary": "Revenue up 12% YoY..." + } + files: { + "attachment": "/tmp/q2-revenue.pdf" + } + +The tool: + 1. Fetches spec, validates text fields + 2. Reads /tmp/q2-revenue.pdf from disk (8.2 MB) + 3. POSTs to /def456.../files with multipart body -> gets file_id + 4. POSTs to /def456.../result with: + { source: "agent", data: {...}, file_refs: { "attachment": "file_id" } } + +Step 3: Agent A reads the result +──────────────────────────────── +check_result returns the data + file references. Agent A can +download the file via GET /def456.../files/. +``` + +### Example 3: Validation failure and retry + +``` +Step 1: Agent B submits with bad data +───────────────────────────────────── +submit_form: + page_id: "ghi789..." + data: { + "decision": "approve", // Wrong: should be ["approve"] + "amount": "not a number" // Wrong: TextField variant=number + } + +The tool validates locally and returns: + { + valid: false, + errors: [ + { field: "decision", component: "ChoicePicker", + expected: "string[]", got: "string", + message: "Expected an array of selected option values" }, + { field: "amount", component: "TextField", + expected: "number", got: "string", + message: "String \"not a number\" cannot be parsed as a number" } + ] + } + +Step 2: Agent B fixes and retries +───────────────────────────────── +submit_form: + page_id: "ghi789..." + data: { + "decision": ["approve"], + "amount": 42 + } + +Returns: { submission_id: "ghi789...", submitted_at: "..." } +``` + +--- + +## 9. MCP Stdio Server Implementation + +### New tool registration + +The `submit_form` tool is registered alongside `show_ui`, `show_html`, +and `check_result` in `apps/api/mcp/tools.ts` via +`registerPagentTools`. + +#### PageOps extension + +```typescript +export interface PageOps { + showUi(spec: unknown): Promise; + showHtml(html: string): Promise; + checkResult(page_id: string): Promise; + + // New: + getPage(page_id: string): Promise; + submitForm(page_id: string, body: AgentResultBody): Promise; + uploadFile(page_id: string, fieldId: string, file: FilePayload): Promise; +} + +export type GetPageResult = + | { kind: 'not_found' } + | { kind: 'ok'; spec: unknown; format: PageFormat; state: PageState }; + +export type SubmitFormResult = + | { kind: 'ok'; submission_id: string; submitted_at: string } + | { kind: 'validation_failed'; errors: FieldError[] } + | { kind: 'not_found' } + | { kind: 'conflict' } + | { kind: 'invalid_format'; format: string } + | { kind: 'access_denied'; message: string }; + +export type FilePayload = { + buffer: Buffer; + fileName: string; + mimeType: string; +}; +``` + +#### Stdio adapter (apps/mcp/server.ts) + +```typescript +const restOps: PageOps = { + // ... existing ops unchanged ... + + async getPage(page_id) { + const res = await fetch(`${SERVICE_URL}/${page_id}`, { + headers: { accept: 'application/json' }, + }); + if (res.status === 404) return { kind: 'not_found' }; + if (!res.ok) throw await readError(res, 'getPage'); + const body = await res.json() as { + spec: unknown; format: PageFormat; state: PageState; + }; + return { kind: 'ok', spec: body.spec, format: body.format, state: body.state }; + }, + + async uploadFile(page_id, fieldId, file) { + const form = new FormData(); + form.append('file', new Blob([file.buffer], { type: file.mimeType }), file.fileName); + form.append('field_name', fieldId); + const res = await fetch(`${SERVICE_URL}/${page_id}/files`, { + method: 'POST', + body: form, + }); + if (!res.ok) throw await readError(res, 'uploadFile'); + const result = await res.json() as { file_id: string }; + return result.file_id; + }, + + async submitForm(page_id, body) { + const res = await fetch(`${SERVICE_URL}/${page_id}/result`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (res.status === 404) return { kind: 'not_found' }; + if (res.status === 409) return { kind: 'conflict' }; + if (res.status === 403) { + const b = await res.json().catch(() => ({})) as { message?: string }; + return { kind: 'access_denied', message: b.message ?? 'Access denied' }; + } + if (res.status === 400) { + const b = await res.json().catch(() => ({})) as { + error?: string; fields?: FieldError[]; + }; + if (b.error === 'validation_failed' && b.fields) { + return { kind: 'validation_failed', errors: b.fields }; + } + if (b.error === 'invalid_for_format') { + return { kind: 'invalid_format', format: 'html' }; + } + throw new Error(`Submit failed (400): ${JSON.stringify(b)}`); + } + if (!res.ok) throw await readError(res, 'submitForm'); + const result = await res.json() as { submission_id: string }; + return { + kind: 'ok', + submission_id: result.submission_id, + submitted_at: new Date().toISOString(), + }; + }, +}; +``` + +#### Tool handler orchestration + +```typescript +// In registerPagentTools: + +server.registerTool( + 'submit_form', + { + title: 'Submit a form on behalf of the agent', + description: SUBMIT_FORM_DESCRIPTION, + inputSchema: { + page_id: z.string().regex(/^[a-f0-9]{32}$/), + data: z.record(z.unknown()), + files: z.record(z.string()).optional(), + }, + }, + async ({ page_id, data, files }) => { + // Step 1: Fetch spec + const page = await ops.getPage(page_id); + if (page.kind === 'not_found') { + throw new Error( + `Page ${page_id} not found (expired or deleted). ` + + `Don't retry -- the page no longer exists.` + ); + } + if (page.format === 'html') { + throw new Error( + `Page ${page_id} is HTML (view-only). ` + + `submit_form only works with a2ui pages.` + ); + } + if (page.state !== 'open') { + throw new Error( + `Page ${page_id} is already ${page.state}. ` + + `Create a new page if you need another submission.` + ); + } + + // Step 2: Local validation + const componentMap = extractComponentMap(page.spec); + const errors = validateAgentData(data, componentMap); + if (errors.length > 0) { + return { + content: [{ + type: 'text', + text: `Validation failed for ${errors.length} field(s):\n` + + errors.map(e => ` - ${e.field}: ${e.message}`).join('\n'), + }], + structuredContent: { + page_id, + valid: false, + errors, + }, + }; + } + + // Step 3: Upload files (if any) + let fileRefs: Record | undefined; + if (files && Object.keys(files).length > 0) { + fileRefs = {}; + for (const [fieldId, filePath] of Object.entries(files)) { + const stat = await fs.stat(filePath).catch(() => null); + if (!stat || !stat.isFile()) { + throw new Error(`File not found: ${filePath}`); + } + const buffer = await fs.readFile(filePath); + const fileName = path.basename(filePath); + const mimeType = guessMimeType(fileName); + fileRefs[fieldId] = await ops.uploadFile( + page_id, fieldId, { buffer, fileName, mimeType } + ); + } + } + + // Step 4: Submit + const body = { + source: 'agent' as const, + data, + ...(fileRefs ? { file_refs: fileRefs } : {}), + }; + const result = await ops.submitForm(page_id, body); + + if (result.kind === 'not_found') { + throw new Error(`Page ${page_id} expired between validation and submit.`); + } + if (result.kind === 'conflict') { + throw new Error( + `Page ${page_id} was submitted by someone else between ` + + `validation and submit. Create a new page.` + ); + } + if (result.kind === 'invalid_format') { + throw new Error(`Page ${page_id} is ${result.format} (view-only).`); + } + if (result.kind === 'access_denied') { + throw new Error(result.message); + } + if (result.kind === 'validation_failed') { + // Server-side validation caught something the local check missed + return { + content: [{ + type: 'text', + text: `Server validation failed for ${result.errors.length} field(s).`, + }], + structuredContent: { + page_id, + valid: false, + errors: result.errors, + }, + }; + } + + return { + content: [{ + type: 'text', + text: `Form submitted successfully.\n\n` + + `submission_id: ${result.submission_id}\n` + + `submitted_at: ${result.submitted_at}`, + }], + structuredContent: { + submission_id: result.submission_id, + page_id, + submitted_at: result.submitted_at, + }, + }; + }, +); +``` + +--- + +## 10. Backward Compatibility + +### Browser submissions: unchanged + +The existing browser submission path is completely unaffected: + +- The `resultBodySchema` union tries the agent branch first (requires + `source: "agent"`), then falls back to the browser branch. Browser + payloads never include `source: "agent"`, so they always match the + second branch. + +- The browser renderer (`apps/web/main.ts`) continues to POST the same + A2UI client-action shape. No changes to the renderer. + +- The `submitResultHandler` in `apps/api/app.ts` handles both branches + after schema parsing. For browser submissions, behavior is identical + to today. + +### check_result: consumers see a different result shape + +The only visible change for existing `check_result` consumers is that +`result` may now be an agent submission object (with `source: "agent"`) +instead of an A2UI client-action. Consumers that inspect `result.name` +or `result.context` need to check `result.source` first. + +**Migration guidance for check_result consumers:** + +```typescript +const outcome = await ops.checkResult(page_id); +if (outcome.result) { + if (outcome.result.source === 'agent') { + // Agent submission: data is in result.data + const data = outcome.result.data; + const submittedBy = outcome.result.submitted_by; + } else { + // Browser submission: A2UI client-action + const context = outcome.result.context; + const name = outcome.result.name; + } +} +``` + +### Database: no schema changes + +The `pages` table stores `result` as `jsonb`. Both the A2UI +client-action shape and the agent submission shape are valid JSON +objects. No migration is needed. + +### MCP tool list: additive only + +`submit_form` is a new tool alongside the existing three. No existing +tool signatures change. Clients that only use `show_ui`, `show_html`, +and `check_result` are unaffected. + +--- + +## 11. Implementation Plan + +### Phase 1: Core (no files, no auth) + +_Ship first. Covers use cases 1, 2, 3 from motivation._ + +1. **Shared validation module** (`apps/api/validate-agent-data.ts`) + - `extractComponentMap(spec)` function + - `validateAgentData(data, componentMap)` function + - `INPUT_COMPONENTS` set + - Unit tests covering every component type + +2. **Schema update** (`apps/api/schemas.ts`) + - Add `agentResultBodySchema` + - Update `resultBodySchema` to a union + - Export types + +3. **API handler update** (`apps/api/app.ts`) + - `submitResultHandler` branches on parsed body shape + - Agent branch: extract component map from stored spec, validate, + store, transition state + - Browser branch: unchanged + - New 400 `validation_failed` error response + +4. **MCP tool registration** (`apps/api/mcp/tools.ts`) + - `submit_form` tool definition and description + - `PageOps` extended with `getPage` and `submitForm` + - Tool handler with fetch-validate-submit orchestration + +5. **Stdio adapter** (`apps/mcp/server.ts`) + - `restOps.getPage` implementation + - `restOps.submitForm` implementation + - No file handling yet + +6. **In-process adapter** (`apps/api/mcp/http.ts`) + - `buildInProcessOps` extended with `getPage` and `submitForm` + +7. **Tests** + - Unit: validation module (all component types, edge cases) + - Integration: `POST /:id/result` with agent payload + - Integration: `submit_form` MCP tool via in-process transport + - Backward compat: browser submissions still work unchanged + +### Phase 2: File handling + +_Ships after the file upload feature lands._ + +1. **MCP tool update**: add `files` parameter handling +2. **PageOps**: add `uploadFile` method +3. **Stdio adapter**: file read + upload flow +4. **API handler**: validate `file_refs` against stored file IDs +5. **Tests**: file upload + submit integration + +### Phase 3: Auth integration + +_Ships in the same v2 batch as OAuth. Agent identity comes from OAuth +tokens._ + +1. **Token extraction**: server reads `email` from Bearer token +2. **`submitted_by` population**: set on agent submission result +3. **Access control**: private page email allowlist check +4. **Audit log**: record agent identity +5. **Tests**: auth + private page access scenarios + +### File locations (new and modified) + +| File | Change | +|------|--------| +| `apps/api/validate-agent-data.ts` | **New.** Shared validation module | +| `apps/api/schemas.ts` | Modified. Add agent result schema, update union | +| `apps/api/app.ts` | Modified. `submitResultHandler` branches on body shape | +| `apps/api/mcp/tools.ts` | Modified. Register `submit_form`, extend `PageOps` | +| `apps/mcp/server.ts` | Modified. Implement `restOps.getPage`, `submitForm` | +| `apps/api/mcp/http.ts` | Modified. Extend `buildInProcessOps` | +| `apps/api/validate-agent-data.test.ts` | **New.** Validation unit tests | +| `apps/api/app.test.ts` | Modified. Add agent submission test cases | +| `apps/api/mcp/tools.test.ts` | Modified. Add `submit_form` tool tests | + +--- + +## Open questions + +1. **Required fields.** A2UI does not have a standard `required` + attribute on input components. Should `submit_form` treat all input + fields as optional (lenient) or should it require agents to supply + every input component (strict)? This spec takes the lenient approach: + missing fields are not errors. The creating agent can encode + "required" in the button action's context paths if needed. + +2. **Value coercion scope.** Currently only TextField variant=number + coerces strings to numbers. Should other coercions exist (e.g., + `"true"` -> `true` for CheckBox)? This spec keeps coercion minimal + to avoid surprising behavior. + +3. **Spec-less submission.** Should agents be allowed to submit + arbitrary data without validation (skip the spec check)? This spec + says no -- spec awareness is a feature, not overhead. An agent that + wants to submit raw data can use the REST endpoint directly with a + browser-style payload (no `source: "agent"`). + +4. **Action name.** Browser submissions include `name` (the button + event name, e.g., "submitted"). Agent submissions do not -- they + submit data, not actions. Should the server synthesize a + `name: "agent_submit"` on the stored result for consumers that + switch on `result.name`? This spec stores the agent shape as-is and + leaves discrimination to the consumer via `result.source`. diff --git a/docs/superpowers/specs/2026-05-17-audit-log-design.md b/docs/superpowers/specs/2026-05-17-audit-log-design.md new file mode 100644 index 0000000..2375125 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-audit-log-design.md @@ -0,0 +1,980 @@ +# Audit Log — Design + +Status: draft, awaiting user review (2026-05-17). + +## 1. Overview and motivation + +Pagent currently stores pages and their results but has no record of +_what happened_ or _who did what_. When a page is submitted, expired, +or fetched, the event is lost once the page row is garbage-collected. +Operators cannot answer: + +- "Which agent created this page?" +- "When was the result read back?" +- "How many pages expired without any submission last week?" +- "Did the webhook for page X succeed or fail?" + +An append-only audit log records every significant lifecycle event with +enough context to answer these questions, support future compliance +requirements, and power product analytics. + +### Design principles + +1. **Append-only.** Events are immutable once written. No UPDATE, no + DELETE (except the 90-day retention purge). +2. **Fire-and-forget writes.** Audit emission must never block or fail + the primary operation. If the audit INSERT throws, log the error and + continue. +3. **Opt-in identity.** When a `user_id` is available (from the auth + layer shipping in v2), it is captured. Otherwise `user_id = null`. +4. **Minimal PII.** Only IP address and User-Agent are stored as + request-level context. No email, no name, no bearer token. + +### Non-goals + +- **Real-time alerting on audit events.** The log is queryable, not + streamable. Alerting belongs in the metrics/observability layer. +- **Tamper-proof / cryptographic chain.** This is an operational audit + log, not a compliance ledger. Hash-chaining is out of scope. +- **Cross-resource joins.** The audit log does not join to the `pages` + table. Events carry denormalized metadata so they remain useful after + the page row is garbage-collected. + +--- + +## 2. Database schema + +### Table + +```sql +CREATE TABLE IF NOT EXISTS audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + resource_type text NOT NULL CHECK (resource_type IN ('page', 'file', 'webhook')), + resource_id text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + ip_address text, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +Notes on column choices: + +- **id** — UUID v7 (time-sortable) would be ideal but Postgres + `gen_random_uuid()` produces v4. Acceptable because queries sort on + `created_at`, not `id`. If UUID v7 becomes available via an extension, + switch — but do not block on it. +- **user_id** — Nullable FK to `users(id)` with `ON DELETE SET NULL`. + Auth ships in v2 alongside the audit log, so the FK constraint is + present from day one. `user_id` is `NULL` for system-initiated events + (e.g. `page.expired`) and for unauthenticated requests. +- **action** — Free-text, not an enum. Enums require a migration to + add a value; free-text with an application-level allowlist is cheaper + to evolve. +- **resource_type** — CHECK constraint limits to known types. A new + type (e.g. `'user'`) requires a migration to alter the CHECK, which + is intentional — adding a resource type should be deliberate. +- **metadata** — JSONB bag for action-specific details. Schema per + action type is documented in section 3 below. +- **ip_address** — Last-hop IP from `X-Forwarded-For`, extracted by + the existing `clientKey()` utility. Stored as text, not inet, to + avoid parse failures on malformed headers. +- **user_agent** — Raw `User-Agent` header value, truncated to 512 + characters at write time. + +### Initial migration (in `db.init()`) + +Because Pagent uses boot-time DDL (not a migration runner), the audit +log table is created in the same `db.init()` function that creates +`pages`. Auth ships in the same v2 batch, so the `users` FK is present +from day one. + +```sql +-- In db.init(), after the pages table: +CREATE TABLE IF NOT EXISTS audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + resource_type text NOT NULL CHECK (resource_type IN ('page', 'file', 'webhook')), + resource_id text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + ip_address text, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +### Indexes + +```sql +-- Primary query path: "show me all events for this resource" +CREATE INDEX IF NOT EXISTS audit_log_resource_idx + ON audit_log (resource_type, resource_id, created_at DESC); + +-- Secondary query path: "show me all events by this user" +CREATE INDEX IF NOT EXISTS audit_log_user_idx + ON audit_log (user_id, created_at DESC) + WHERE user_id IS NOT NULL; + +-- Retention purge: delete rows older than 90 days +CREATE INDEX IF NOT EXISTS audit_log_created_at_idx + ON audit_log (created_at); + +-- Action filtering: "show me all page.expired events" +CREATE INDEX IF NOT EXISTS audit_log_action_idx + ON audit_log (action, created_at DESC); +``` + +Index rationale: + +| Index | Serves | Why not a different shape | +|---|---|---| +| `resource_idx` | `GET /audit?resource_id=X` — the primary read path. Composite on `(resource_type, resource_id, created_at DESC)` so the planner can satisfy the filter + sort in one scan. | A single-column index on `resource_id` would still require a sort step for `ORDER BY created_at DESC`. | +| `user_idx` | `GET /audit?user_id=X` — secondary path. Partial index (`WHERE user_id IS NOT NULL`) saves space while the feature runs without auth (all rows have `user_id = NULL`). | Full index would waste pages on NULL-keyed rows that the query never matches. | +| `created_at_idx` | Retention `DELETE ... WHERE created_at < now() - interval '90 days'`. | Without this, the purge does a full table scan. | +| `action_idx` | Optional action filter on the API: `GET /audit?action=page.expired`. | Compound with `created_at DESC` so the planner can push both predicates + sort. | + +--- + +## 3. Event catalog + +Every event type, its trigger, and the shape of its `metadata` JSONB. + +### 3.1 `page.created` + +| Field | Value | +|---|---| +| **Trigger** | `store.createPage()` or `store.createHtmlPage()` — after the page INSERT succeeds. | +| **resource_type** | `'page'` | +| **resource_id** | The page `id` (32-char hex). | +| **ip_address** | From the request that called `POST /new` or the MCP `show_ui` / `show_html` tool. | +| **user_agent** | From the same request. | + +```jsonc +// metadata +{ + "format": "a2ui" | "html", + "spec_bytes": 14320, // byte length of JSON.stringify(spec) or raw HTML + "expires_at": 1747509600000, // ms epoch + "url": "https://pagent.link/abc123..." +} +``` + +### 3.2 `page.submitted` + +| Field | Value | +|---|---| +| **Trigger** | `db.submitPage()` returns `{ kind: 'ok' }`. | +| **resource_type** | `'page'` | +| **resource_id** | The page `id`. | +| **ip_address** | From the `POST /:id/result` request (the browser user). | +| **user_agent** | From the same request (browser UA). | + +```jsonc +// metadata +{ + "format": "a2ui", + "action_name": "submit", // action.name from the A2UI client-action + "action_surface_id": "root", // action.surfaceId + "latency_ms": 12345 // time from page creation to submission +} +``` + +Note: the full `result` payload is NOT stored in audit metadata. It +lives on the `pages` row (and is available to the agent via +`GET /:id/result`). Duplicating it in the audit log would bloat the +table and raise PII concerns (user-typed input is unbounded). + +### 3.3 `page.received` + +| Field | Value | +|---|---| +| **Trigger** | `db.fetchAndAdvanceResult()` performs the `submitted -> received` state transition (first read). | +| **resource_type** | `'page'` | +| **resource_id** | The page `id`. | +| **ip_address** | From the `GET /:id/result` request (the agent). | +| **user_agent** | From the same request (agent's HTTP client UA). | + +```jsonc +// metadata +{ + "format": "a2ui", + "read_latency_ms": 5432 // time from submission to agent's first read +} +``` + +Only emitted on the _first_ read that flips the state. Subsequent +`GET /:id/result` calls (state is already `received`) do NOT emit +another event. + +### 3.4 `page.expired` + +| Field | Value | +|---|---| +| **Trigger** | `db.deleteExpiredPages()` — the TTL sweep in `server.ts`. | +| **resource_type** | `'page'` | +| **resource_id** | The page `id`. | +| **ip_address** | `null` (system-initiated). | +| **user_agent** | `null` (system-initiated). | + +```jsonc +// metadata +{ + "state_at_expiry": "open" | "submitted" | "received", + "format": "a2ui" | "html", + "age_ms": 1800000 // time from creation to expiry sweep +} +``` + +Emitted per expired row. When the sweep deletes 50 rows, 50 audit +events are inserted. This is batched in a single multi-row INSERT (see +section 8). + +### 3.5 `page.closed` + +| Field | Value | +|---|---| +| **Trigger** | Owner explicitly closes a public page before TTL (Public Forms, shipping in v2). | +| **ip_address** | From the request that called the close endpoint. | +| **user_agent** | From the same request. | +| **resource_type** | `'page'` | +| **resource_id** | The page `id`. | + +```jsonc +// metadata +{ + "format": "a2ui" | "html", + "state_at_close": "open" | "submitted" | "received", + "remaining_ttl_ms": 120000 +} +``` + +### 3.6 `file.uploaded` + +| Field | Value | +|---|---| +| **Trigger** | File upload endpoint accepts a file (File Uploads, shipping in v2). | +| **ip_address** | From the upload request. | +| **user_agent** | From the same request. | +| **resource_type** | `'file'` | +| **resource_id** | The file identifier. | + +```jsonc +// metadata +{ + "filename": "report.pdf", + "content_type": "application/pdf", + "size_bytes": 204800, + "page_id": "abc123..." // page the file is attached to, if any +} +``` + +### 3.7 `webhook.delivered` + +| Field | Value | +|---|---| +| **Trigger** | Webhook delivery succeeds (HTTP 2xx from the target). Webhooks ship in v2. | +| **resource_type** | `'webhook'` | +| **resource_id** | The webhook configuration identifier. | + +```jsonc +// metadata +{ + "page_id": "abc123...", + "target_url": "https://example.com/hook", + "http_status": 200, + "latency_ms": 342, + "attempt": 1 +} +``` + +### 3.8 `webhook.failed` + +| Field | Value | +|---|---| +| **Trigger** | Webhook delivery fails after all retries are exhausted. Webhooks ship in v2. | +| **resource_type** | `'webhook'` | +| **resource_id** | The webhook configuration identifier. | + +```jsonc +// metadata +{ + "page_id": "abc123...", + "target_url": "https://example.com/hook", + "http_status": 503, + "error": "timeout after 10s", + "latency_ms": 10042, + "attempt": 3, + "max_attempts": 3 +} +``` + +--- + +## 4. REST API endpoint + +### `GET /audit` + +Query audit log events. Supports filtering by resource, user, and +action, with cursor-based pagination. + +#### Query parameters + +| Param | Type | Required | Default | Description | +|---|---|---|---|---| +| `resource_id` | string | no | — | Filter to events for this resource. When set, `resource_type` should also be set. | +| `resource_type` | `page` \| `file` \| `webhook` | no | — | Filter to a resource type. Ignored if `resource_id` is set (inferred from the resource). | +| `user_id` | uuid | no | — | Filter to events by this user. | +| `action` | string | no | — | Filter to a specific action (e.g. `page.created`). | +| `cursor` | string | no | — | Opaque pagination cursor from a previous response. | +| `limit` | integer | no | 50 | Page size, 1..200. | + +At least one of `resource_id` or `user_id` MUST be provided. Open-ended +`GET /audit` with no filter returns 400. This prevents full-table scans +and enforces the access-control model (you must know what you are +querying for). + +#### Response (200) + +```jsonc +{ + "events": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": null, + "action": "page.created", + "resource_type": "page", + "resource_id": "aabbccddeeff00112233445566778899", + "metadata": { + "format": "a2ui", + "spec_bytes": 14320, + "expires_at": 1747509600000, + "url": "https://pagent.link/aabbccddeeff00112233445566778899" + }, + "ip_address": "203.0.113.42", + "user_agent": "claude-code/1.0", + "created_at": "2026-05-17T14:30:00.000Z" + } + // ...more events + ], + "cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0xN1QxNDoyOTowMC4wMDBaIiwiaWQiOiIuLi4ifQ==", + "has_more": true +} +``` + +#### Pagination + +Cursor-based, not offset-based. The cursor encodes `(created_at, id)` +of the last event in the current page. The next request uses: + +```sql +WHERE (created_at, id) < ($cursor_created_at, $cursor_id) +ORDER BY created_at DESC, id DESC +LIMIT $limit +``` + +This gives stable results under concurrent writes (no skipped or +duplicated rows) and performs well with the `created_at DESC` index +ordering. + +The cursor is a base64-encoded JSON object: +```jsonc +{ "created_at": "2026-05-17T14:29:00.000Z", "id": "550e8400-..." } +``` + +Clients treat it as opaque. The server validates the decoded shape and +returns 400 on a malformed cursor. + +#### Error responses + +| Status | `error` | When | +|---|---|---| +| 400 | `bad_request` | Missing `resource_id` and `user_id`, invalid `limit`, malformed `cursor`. | +| 400 | `bad_request` | Unknown `resource_type` or `action` value. | +| 500 | `internal_error` | Database failure. | + +#### Zod schema (validation) + +```typescript +const auditQuerySchema = z.object({ + resource_id: z.string().optional(), + resource_type: z.enum(['page', 'file', 'webhook']).optional(), + user_id: z.string().uuid().optional(), + action: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), +}).refine( + (q) => q.resource_id || q.user_id, + { message: 'At least one of resource_id or user_id is required' }, +); +``` + +#### Route registration + +In `app.ts`, after the existing routes: + +```typescript +app.get('/audit', auditHandler); +``` + +No rate limiter on the read path — it is bounded by the mandatory +filter requirement and the access-control check. + +--- + +## 5. MCP tool + +### `get_audit_log` + +A new MCP tool registered alongside `show_ui`, `show_html`, and +`check_result` in `apps/api/mcp/tools.ts`. + +#### Parameters + +| Param | Type | Required | Description | +|---|---|---|---| +| `page_id` | string (32-char hex) | yes | The page to query audit events for. | +| `limit` | number (1..100) | no | Max events to return. Default 20. | + +The tool only supports `page_id` queries — it cannot query by +`user_id` (the agent does not know the user's identity) or by action +type (too niche for the tool surface). Agents that need richer queries +can call `GET /audit` directly. + +#### Response + +```jsonc +{ + "content": [ + { + "type": "text", + "text": "Audit log for page aabb...:\n\n2026-05-17T14:30:00Z page.created (format: a2ui, 14320 bytes)\n2026-05-17T14:32:15Z page.submitted (latency: 135s)\n2026-05-17T14:32:18Z page.received (read latency: 3s)" + } + ], + "structuredContent": { + "page_id": "aabb...", + "events": [ + { + "action": "page.created", + "created_at": "2026-05-17T14:30:00.000Z", + "metadata": { "format": "a2ui", "spec_bytes": 14320 } + } + // ... + ] + } +} +``` + +The text content is a human-readable summary. The structuredContent +carries the full event list for programmatic consumption. + +#### Registration + +```typescript +// In registerPagentTools(), after check_result: + +server.registerTool( + 'get_audit_log', + { + title: 'Get audit log for a page', + description: GET_AUDIT_LOG_DESCRIPTION, + inputSchema: { + page_id: z.string().regex(/^[a-f0-9]{32}$/, 'invalid page_id') + .describe('The page_id to fetch audit events for.'), + limit: z.number().int().min(1).max(100).optional().default(20) + .describe('Max events to return (default 20).'), + }, + }, + async ({ page_id, limit }) => { + const events = await ops.getAuditLog(page_id, limit); + // ... format text + structuredContent + }, +); +``` + +#### PageOps extension + +```typescript +export interface PageOps { + showUi(spec: unknown): Promise; + showHtml(html: string): Promise; + checkResult(page_id: string): Promise; + getAuditLog(page_id: string, limit: number): Promise; +} +``` + +The in-process adapter (`mcp/http.ts`) queries the DB directly. The +stdio adapter (`apps/mcp/server.ts`) calls `GET /audit?resource_id=&resource_type=page&limit=`. + +--- + +## 6. Integration points + +Where in existing code each audit event is emitted. Every call site +uses the `emitAuditEvent()` helper (section 6.1). + +### 6.1 Audit emitter module + +New file: `apps/api/audit.ts`. + +```typescript +import * as db from './db.ts'; +import { logger } from './logger.ts'; + +export type AuditEvent = { + user_id?: string | null; + action: string; + resource_type: 'page' | 'file' | 'webhook'; + resource_id: string; + metadata?: Record; + ip_address?: string | null; + user_agent?: string | null; +}; + +/** + * Fire-and-forget audit event insertion. + * + * Never throws — catches any DB error and logs it. The caller's + * primary operation must not fail because the audit log is down. + */ +export function emitAuditEvent(event: AuditEvent): void { + db.insertAuditEvent(event).catch((err) => { + logger.error({ err, audit_event: event }, 'failed to emit audit event'); + }); +} + +/** + * Batch-emit audit events (used by the TTL sweep for page.expired). + * Same fire-and-forget contract as emitAuditEvent. + */ +export function emitAuditEvents(events: AuditEvent[]): void { + if (events.length === 0) return; + db.insertAuditEvents(events).catch((err) => { + logger.error( + { err, count: events.length }, + 'failed to emit batch audit events', + ); + }); +} +``` + +### 6.2 Call sites + +#### `page.created` — `apps/api/store.ts` + +In `createPage()` and `createHtmlPage()`, after `db.insertPage()` +succeeds: + +```typescript +// store.ts — createPage() +emitAuditEvent({ + action: 'page.created', + resource_type: 'page', + resource_id: page.id, + metadata: { + format, + spec_bytes: JSON.stringify(spec).length, + expires_at: page.expiresAt, + url: `${cfg.publicUrl}/${page.id}`, + }, + ip_address: ctx?.ipAddress, + user_agent: ctx?.userAgent, +}); +``` + +The `ctx` (request context — IP + UA) must be threaded through from +the route handler. Today `createPage()` does not receive request +context. The signature changes to: + +```typescript +export type RequestContext = { + ipAddress?: string | null; + userAgent?: string | null; +}; + +export async function createPage( + spec: unknown, + format: PageFormat, + cfg: CreatePageConfig, + ctx?: RequestContext, // NEW — optional for backward compat +): Promise; +``` + +The REST handler (`app.ts` `newPageHandler`) extracts IP/UA from the +Hono context. The MCP in-process handler (`mcp/http.ts`) extracts from +the raw `IncomingMessage`. The MCP stdio handler has no request context +(events emitted by the REST API it calls, not by the stdio process). + +#### `page.submitted` — `apps/api/app.ts` + +In `submitResultHandler`, after `db.submitPage()` returns `{ kind: 'ok' }`: + +```typescript +emitAuditEvent({ + action: 'page.submitted', + resource_type: 'page', + resource_id: idResult.data, + metadata: { + format: page.format, + action_name: action.name, + action_surface_id: action.surfaceId, + latency_ms: Date.now() - outcome.createdAt.getTime(), + }, + ip_address: clientKey(c.req.header('x-forwarded-for')), + user_agent: c.req.header('user-agent')?.slice(0, 512), +}); +``` + +#### `page.received` — `apps/api/db.ts` or `apps/api/store.ts` + +In `fetchAndAdvanceResult()`, when `state === 'submitted'` and the +UPDATE to `'received'` succeeds. This is the trickiest call site +because `fetchAndAdvanceResult` currently has no request context. Two +options: + +- **Option A (preferred):** Thread request context into + `store.advanceResult()` and emit there. +- **Option B:** Emit in `getResultHandler` in `app.ts` by checking the + returned `stateAtRead === 'submitted'` (meaning it just transitioned). + +Option A is preferred because the MCP in-process path also calls +`store.advanceResult()`, and we want both paths to emit. + +#### `page.expired` — `apps/api/server.ts` + +In the sweep timer callback, after `db.deleteExpiredPages()`. The sweep +function must be extended to return the expired rows' metadata (id, +state, format, created_at) so the audit events can be populated: + +```typescript +// Enhanced deleteExpiredPages return type: +export async function deleteExpiredPages(): Promise<{ + total: number; + abandoned: number; + expired: Array<{ id: string; state: PageState; format: PageFormat; created_at: Date }>; +}>; +``` + +Then in the sweep: + +```typescript +const { total, abandoned, expired } = await db.deleteExpiredPages(); +emitAuditEvents(expired.map((row) => ({ + action: 'page.expired', + resource_type: 'page', + resource_id: row.id, + metadata: { + state_at_expiry: row.state, + format: row.format, + age_ms: Date.now() - row.created_at.getTime(), + }, +}))); +``` + +--- + +## 7. Access control + +Auth ships in the same v2 batch. The access-control rules are: + +| Query | Who can read | +|---|---| +| `GET /audit?resource_id=X` | The user who owns resource X (i.e. the user whose `user_id` matches the page creator). | +| `GET /audit?user_id=X` | Only user X themselves (authenticated via bearer token). | +| MCP `get_audit_log({ page_id })` | The agent session that created the page (validated by matching the session's user_id against the page creator). | + +Implementation: + +```typescript +// Pseudocode in auditHandler: +const authedUser = c.get('userId'); // set by auth middleware + +if (query.user_id && query.user_id !== authedUser) { + return c.json({ error: 'forbidden', message: 'Cannot query another user\'s audit log' }, 403); +} + +if (query.resource_id) { + const owner = await db.getResourceOwner(query.resource_type, query.resource_id); + if (owner && owner !== authedUser) { + return c.json({ error: 'forbidden', message: 'Cannot query audit log for a resource you do not own' }, 403); + } +} +``` + +### Unauthenticated fallback + +If a request arrives without a valid bearer token, `GET /audit` returns +401. Page IDs are 128-bit random hex (unguessable), but the audit log +should not be accessible without authentication. + +--- + +## 8. Performance considerations + +### Write path (hot) + +Audit events are written on every page lifecycle transition. The +primary concern is that audit writes do not add latency to user-facing +operations. + +**Strategy: fire-and-forget async INSERT.** + +`emitAuditEvent()` calls `db.insertAuditEvent()` and catches errors +without awaiting the result in the caller's response path. The Promise +floats — if it rejects, the error is logged and swallowed. + +This means: +- The `POST /new` response returns _before_ the audit INSERT commits. +- If Postgres is slow, the response is unaffected. +- If Postgres is down, the primary operation still succeeds (the page + row was already inserted before the audit emit), and the audit event + is lost. This is an acceptable trade-off. + +### Batch insert for sweep + +`page.expired` events from the TTL sweep are inserted in a single +multi-row INSERT to avoid N round-trips: + +```sql +INSERT INTO audit_log (action, resource_type, resource_id, metadata, created_at) +VALUES + ('page.expired', 'page', $1, $2, now()), + ('page.expired', 'page', $3, $4, now()), + ... +``` + +The sweep currently runs every 60 seconds. In pathological cases it +might delete thousands of rows. The batch INSERT is capped at 500 rows +per statement to avoid building a massive query string. If there are +more than 500 expired rows, emit in chunks. + +### Read path + +Reads go through the indexed queries described in section 2. The +mandatory filter requirement (`resource_id` or `user_id`) ensures +every query hits an index — there are no full-table scans. + +Pagination is cursor-based, which is O(1) per page regardless of +offset depth (unlike OFFSET-based pagination, which degrades linearly). + +### Table size estimate + +Assumptions for a moderate deployment: +- 10,000 pages/day +- ~3 events per page (created + submitted + received) +- ~30,000 audit rows/day +- Average row size: ~500 bytes (including JSONB + indexes) +- Daily growth: ~15 MB +- 90-day retention: ~1.35 GB + +This is well within Postgres capacity for a single-node deployment. + +### Index overhead + +Four indexes on a write-heavy append-only table add insertion overhead. +For the expected volume (~30K rows/day), this is negligible. If volume +grows 100x, consider: + +1. Dropping the `action_idx` (least-used query pattern). +2. Partitioning by month (range partition on `created_at`), which also + makes retention purge instant (DROP PARTITION). + +--- + +## 9. Retention policy + +### 90-day purge + +Audit log rows older than 90 days are deleted by a background job. + +**Implementation:** Extend the existing TTL sweep timer in `server.ts` +(which already runs every 60 seconds for `pages`) to also purge old +audit log rows. Run the audit purge less frequently — every 6 hours +(every 21,600th tick of the 1-second interval... or more practically, +use a separate `setInterval`). + +```typescript +// In server.ts, after the existing sweepTimer: +const auditPurgeTimer = setInterval(async () => { + try { + const deleted = await db.purgeOldAuditEvents(90); + if (deleted > 0) { + logger.info({ deleted }, 'audit log retention purge'); + } + } catch (err) { + logger.error({ err }, 'audit log retention purge failed'); + } +}, 6 * 60 * 60 * 1000); // every 6 hours +auditPurgeTimer.unref(); +``` + +DB function: + +```typescript +// In db.ts: +export async function purgeOldAuditEvents(retentionDays: number): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c<{ count: string }[]>` + WITH deleted AS ( + DELETE FROM audit_log + WHERE created_at < now() - make_interval(days => ${retentionDays}) + RETURNING id + ) + SELECT count(*)::text AS count FROM deleted + `; + return parseInt(rows[0]?.count ?? '0', 10); + }); +} +``` + +For large purges, this DELETE can lock many rows. If the table grows +large enough for this to matter, switch to batched deletes: + +```sql +DELETE FROM audit_log +WHERE id IN ( + SELECT id FROM audit_log + WHERE created_at < now() - interval '90 days' + LIMIT 10000 +) +``` + +Run in a loop until 0 rows are deleted. + +### Configuration + +The retention period (90 days) is hardcoded in V1. If it needs to be +configurable, add `AUDIT_RETENTION_DAYS` to the env schema with a +default of 90. + +--- + +## 10. Privacy considerations + +### PII inventory + +| Column | PII? | Content | Risk | +|---|---|---|---| +| `user_id` | Yes (when auth ships) | Links to user identity. | Medium. Mitigated by access control. | +| `ip_address` | Yes | Client IP address, last hop from X-Forwarded-For. | Medium. IP is PII under GDPR. | +| `user_agent` | Borderline | Browser/agent UA string. Can fingerprint devices. | Low. Generic string, not unique to a person. | +| `metadata` | Depends | Action-specific JSONB. Never contains the full result payload, but does contain the URL, format, and byte sizes. | Low. No user-typed input is stored in metadata. | + +### GDPR implications + +1. **Right to erasure.** If a user requests deletion of their data, all + `audit_log` rows where `user_id` matches must be deleted. The + `ON DELETE SET NULL` FK means deleting the user row nullifies the + audit entries rather than cascading — this may not satisfy a strict + GDPR erasure request. When auth ships, implement a dedicated + `deleteUserAuditData(userId)` function that hard-deletes rows. + +2. **Right to access.** `GET /audit?user_id=X` already provides this. + The response can be exported as JSON for a data portability request. + +3. **IP address retention.** 90-day retention limits the exposure + window. For stricter compliance, IP addresses could be hashed (one-way) + or anonymized (zero the last octet for IPv4, last 80 bits for IPv6) + at write time. V1 stores raw IPs for operational debugging; revisit + if the product moves into EU-regulated verticals. + +4. **Data minimization.** The schema deliberately excludes: + - Email addresses + - Authentication tokens + - Full result payloads (user-typed input) + - Page specs (agent-generated content; could contain PII if the + agent included it, but that is the agent's responsibility) + +### Operational access + +Only the API process writes to `audit_log`. There is no admin UI in V1. +Operators query the table via `psql` or the REST endpoint. When an +admin dashboard ships, it should enforce the same access-control rules +as the REST endpoint. + +--- + +## Appendix A — DB function signatures for `db.ts` + +```typescript +// --- New functions to add to db.ts --- + +export type AuditEventRow = { + id: string; + user_id: string | null; + action: string; + resource_type: string; + resource_id: string; + metadata: Record; + ip_address: string | null; + user_agent: string | null; + created_at: Date; +}; + +export async function insertAuditEvent(event: { + user_id?: string | null; + action: string; + resource_type: string; + resource_id: string; + metadata?: Record; + ip_address?: string | null; + user_agent?: string | null; +}): Promise; + +export async function insertAuditEvents(events: Array<{ + user_id?: string | null; + action: string; + resource_type: string; + resource_id: string; + metadata?: Record; + ip_address?: string | null; + user_agent?: string | null; +}>): Promise; + +export async function queryAuditLog(params: { + resource_id?: string; + resource_type?: string; + user_id?: string; + action?: string; + cursor?: { created_at: Date; id: string }; + limit: number; +}): Promise<{ events: AuditEventRow[]; cursor: string | null; has_more: boolean }>; + +export async function purgeOldAuditEvents(retentionDays: number): Promise; +``` + +## Appendix B — OpenAPI addition + +The `GET /audit` endpoint should be added to `docs/openapi.yaml` under +a new `Audit` tag. The response schema references the event shape from +section 4. The OpenAPI doc is served from memory at boot, so the YAML +file is the single source of truth. + +## Appendix C — Metrics + +Two new OTel metrics in `metrics.ts`: + +```typescript +auditEventsEmitted: meter.createCounter('pagent.audit.events.emitted', { + description: 'Audit events successfully inserted', +}), +auditEventsFailed: meter.createCounter('pagent.audit.events.failed', { + description: 'Audit events that failed to insert', +}), +``` + +Increment `auditEventsEmitted` in `db.insertAuditEvent(s)` on success. +Increment `auditEventsFailed` in the catch block of `emitAuditEvent()`. + +## Appendix D — Test plan + +| Test | Location | What it covers | +|---|---|---| +| `db.test.ts` — audit INSERT/query | `apps/api/db.test.ts` | `insertAuditEvent`, `insertAuditEvents`, `queryAuditLog`, `purgeOldAuditEvents` against a mocked SQL client. | +| `app.test.ts` — GET /audit | `apps/api/app.test.ts` | Route handler: query param validation, pagination, cursor decode, 400 on missing filters. DB module mocked. | +| `app.test.ts` — audit emission | `apps/api/app.test.ts` | Verify that `POST /new`, `POST /:id/result`, and `GET /:id/result` call `emitAuditEvent` with the correct action and metadata. | +| `tools.test.ts` — get_audit_log | `apps/api/mcp/tools.test.ts` | MCP tool registration, input validation, text + structured output formatting. | +| `server integration` — sweep emits page.expired | Test or manual | Verify the TTL sweep calls `emitAuditEvents` with `page.expired` for each deleted row. | diff --git a/docs/superpowers/specs/2026-05-17-auth-design.md b/docs/superpowers/specs/2026-05-17-auth-design.md new file mode 100644 index 0000000..0f2bdd3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-auth-design.md @@ -0,0 +1,1331 @@ +# Auth — Design + +Status: draft, awaiting user review (2026-05-17). + +## 1. Overview and motivation + +Pagent has no authentication. Every page is anonymous, every API call is +unauthenticated, and every MCP tool invocation is unguarded. This was +acceptable for an MVP where the blast radius of abuse is capped by the +30-minute TTL and per-IP rate limits, but it blocks every feature on the +V2 roadmap: page ownership, user dashboards, audit logs, custom URLs, +webhooks — all require a notion of "who." + +This spec introduces: + +- **Users** — identified by email, created via Google OAuth or Magic + Link (passwordless email). +- **Sessions** — httpOnly cookies for browser clients (the renderer at + `pagent.link` and any future dashboard). +- **OAuth 2.1 Authorization Server** — co-hosted with the API, issuing + JWT access tokens and opaque refresh tokens. MCP clients authenticate + via the MCP OAuth flow (spec 2025-11-25): 401 discovery, PKCE + authorization code, Bearer tokens. +- **Page ownership** — pages gain an `owner_id` FK. During a grace + period, unauthenticated page creation still works (`owner_id = NULL`). + +The design is custom (no Clerk, no Auth0, no Supabase Auth). Pagent acts +as both the OAuth 2.1 Authorization Server (AS) and the Resource Server +(RS), co-hosted on the same origin per the MCP spec's recommendation for +simple deployments. + +### Why custom + +Third-party auth services add a runtime dependency, a billing +relationship, and (for Supabase Auth specifically) a tight coupling to +Supabase's session model that doesn't map cleanly to the MCP OAuth +flow's requirement for the RS to also be the AS. The MCP TypeScript SDK +already ships `mcpAuthRouter`, `requireBearerAuth`, and +`OAuthServerProvider` — implementing the provider interface against +Postgres is less work than adapting an external service to satisfy it. + +## 2. Database schema + +All tables live in the existing Supabase Postgres database. Schema +bootstrap follows the same pattern as the existing `pages` table: +`CREATE TABLE IF NOT EXISTS` in `db.ts`'s `init()`, run on every boot, +idempotent. + +### 2.1 `users` + +```sql +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + handle text UNIQUE, -- nullable: set during onboarding (Custom URLs feature), not at creation + email text UNIQUE NOT NULL, + name text, + avatar_url text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email)); +CREATE UNIQUE INDEX IF NOT EXISTS users_handle_idx ON users (lower(handle)); +``` + +**`handle`** is a short, URL-safe username (e.g. `alex`). Auto-generated +from the email local part on first login, with a numeric suffix if +taken. Used in future features (custom page URLs, public profiles). +Validated: `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` (3-40 chars, lowercase +alphanumeric, internal hyphens only — no underscores, which are non-conventional in URLs). + +### 2.2 `sessions` + +Browser sessions. One user can have multiple active sessions (multiple +devices/browsers). + +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL, + ip_address text, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id); +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions (expires_at); +``` + +`token_hash` stores `SHA-256(session_token)`. The raw session token +lives only in the httpOnly cookie; the server never stores it in +cleartext. Lookup is by hash: `WHERE token_hash = SHA256(cookie_value) +AND expires_at > now()`. + +Session lifetime: 30 days, sliding — each authenticated request extends +`expires_at` by 30 days. A TTL sweep (same pattern as the existing page +sweep) reaps expired rows. + +### 2.3 `oauth_clients` + +Dynamic client registration per RFC 7591. MCP clients self-register +before starting the authorization code flow. + +```sql +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id text PRIMARY KEY, + client_secret text, + client_secret_expires_at timestamptz, + client_id_issued_at timestamptz NOT NULL DEFAULT now(), + client_name text, + client_uri text, + logo_uri text, + redirect_uris text[] NOT NULL, + grant_types text[] NOT NULL DEFAULT '{authorization_code,refresh_token}', + response_types text[] NOT NULL DEFAULT '{code}', + scope text, + token_endpoint_auth_method text NOT NULL DEFAULT 'none', + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +Public clients (`token_endpoint_auth_method = 'none'`) are the default +for MCP. The SDK's `OAuthClientInformationFull` type maps directly to +this table. `client_id` is a `randomUUID()`. `client_secret` is +generated only for confidential clients; MCP clients are always public. + +### 2.4 `auth_codes` + +Authorization codes issued during the PKCE flow. Short-lived (10 +minutes). + +```sql +CREATE TABLE IF NOT EXISTS auth_codes ( + code text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id text NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + redirect_uri text NOT NULL, + code_challenge text NOT NULL, + code_challenge_method text NOT NULL DEFAULT 'S256', + scope text, + resource text, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + consumed_at timestamptz +); + +CREATE INDEX IF NOT EXISTS auth_codes_expires_at_idx ON auth_codes (expires_at); +``` + +`consumed_at` is set on first use. A code that has `consumed_at IS NOT +NULL` is rejected on second use, and the server SHOULD revoke all tokens +issued from that code (per OAuth 2.1 Section 4.1.2 security guidance on +authorization code replay). + +### 2.5 `refresh_tokens` + +Opaque refresh tokens. Long-lived (90 days), rotated on each use. + +```sql +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id text NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + scope text, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz +); + +CREATE INDEX IF NOT EXISTS refresh_tokens_user_id_idx ON refresh_tokens (user_id); +CREATE INDEX IF NOT EXISTS refresh_tokens_expires_at_idx ON refresh_tokens (expires_at); +``` + +`token_hash` stores `SHA-256(raw_refresh_token)`. Like session tokens, +the raw value is never stored server-side. On rotation the old row gets +`revoked_at = now()` and a new row is inserted. If a revoked token is +presented, all refresh tokens for that `(user_id, client_id)` pair are +revoked (token family revocation — defense against stolen refresh +tokens per OAuth 2.1 Section 6.1). + +### 2.6 `magic_links` + +Passwordless email login tokens. Short-lived (15 minutes). + +```sql +CREATE TABLE IF NOT EXISTS magic_links ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + token_hash text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + consumed_at timestamptz +); + +CREATE INDEX IF NOT EXISTS magic_links_expires_at_idx ON magic_links (expires_at); +``` + +### 2.7 Changes to `pages` + +Add `owner_id` as a nullable FK: + +```sql +ALTER TABLE pages + ADD COLUMN IF NOT EXISTS owner_id uuid + REFERENCES users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS pages_owner_id_idx ON pages (owner_id); +``` + +Nullable: during the grace period, unauthenticated page creation sets +`owner_id = NULL`. When `REQUIRE_AUTH=true`, the `POST /new` middleware +rejects unauthenticated requests before the handler runs, so all new +pages have an owner. + +## 3. API endpoints + +All auth endpoints live under `/oauth/` on the API server +(`api.pagent.link`). The well-known metadata endpoints live at the +standard RFC-defined paths. + +### 3.1 Authorization Server metadata + +``` +GET /.well-known/oauth-authorization-server +``` + +**Response** `200 application/json`: + +```json +{ + "issuer": "https://api.pagent.link", + "authorization_endpoint": "https://api.pagent.link/oauth/authorize", + "token_endpoint": "https://api.pagent.link/oauth/token", + "registration_endpoint": "https://api.pagent.link/oauth/register", + "revocation_endpoint": "https://api.pagent.link/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["page:create", "page:read", "page:write"], + "service_documentation": "https://github.com/anthropics/agent-ui-session#readme" +} +``` + +This endpoint is served by the MCP SDK's `mcpAuthRouter` or manually +if we need to customize. It MUST be public (no auth required). + +### 3.2 Protected Resource metadata (RFC 9728) + +``` +GET /.well-known/oauth-protected-resource +``` + +**Response** `200 application/json`: + +```json +{ + "resource": "https://api.pagent.link", + "authorization_servers": ["https://api.pagent.link"], + "scopes_supported": ["page:create", "page:read", "page:write"], + "bearer_methods_supported": ["header"], + "resource_name": "Pagent API", + "resource_documentation": "https://github.com/anthropics/agent-ui-session#readme" +} +``` + +This is the entry point for MCP clients that receive a 401 on `/mcp`. +The `authorization_servers` array points back to the same origin (AS and +RS are co-hosted). + +### 3.3 Dynamic client registration (RFC 7591) + +``` +POST /oauth/register +Content-Type: application/json + +{ + "redirect_uris": ["http://localhost:9876/callback"], + "client_name": "Claude Code", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" +} +``` + +**Response** `201 application/json`: + +```json +{ + "client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "client_name": "Claude Code", + "redirect_uris": ["http://localhost:9876/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "client_id_issued_at": 1747500000 +} +``` + +**Error cases:** + +| Status | Error | When | +| ------ | ------------------------- | ------------------------------------------------ | +| 400 | `invalid_client_metadata` | Missing `redirect_uris`, invalid URI, etc. | +| 429 | `rate_limited` | Too many registrations from this IP | + +No `client_secret` is issued for public clients. This matches the MCP +spec's guidance: MCP clients are public (they can't keep a secret). + +### 3.4 Authorization endpoint + +``` +GET /oauth/authorize? + response_type=code& + client_id=...& + redirect_uri=...& + state=...& + code_challenge=...& + code_challenge_method=S256& + scope=page:create+page:read +``` + +This endpoint serves a login page. The login page is a minimal HTML +page (server-rendered, not the Vite SPA) with two options: + +1. **"Continue with Google"** — redirects to Google's OAuth consent + screen with Pagent as the relying party. +2. **"Sign in with email"** — shows an email input. On submit, sends a + Magic Link email and shows a "check your email" message. + +After successful authentication (Google callback or Magic Link click), +the server: + +1. Upserts the user in the `users` table (create on first login, update + `name`/`avatar_url` on subsequent logins). +2. Generates an authorization code. +3. Redirects to `redirect_uri?code=...&state=...`. + +If the request came from a browser session (not an MCP client), the +server also sets a session cookie. + +**Error cases:** + +| Status | Error | When | +| ------ | ---------------------- | ------------------------------------------------- | +| 400 | `invalid_request` | Missing required parameters | +| 400 | `invalid_client` | `client_id` not found | +| 400 | `invalid_redirect_uri` | `redirect_uri` not in client's registered URIs | + +Errors on the authorize endpoint are shown on the login page itself +(not redirected), per OAuth 2.1 Section 4.1.2.1 — redirect-based +errors only go to the redirect URI if we trust it. + +### 3.5 Token endpoint + +``` +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=...& +client_id=...& +redirect_uri=...& +code_verifier=... +``` + +**Response** `200 application/json`: + +```json +{ + "access_token": "eyJhbGciOiJFZERTQSIs...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rt_a1b2c3d4e5f6...", + "scope": "page:create page:read" +} +``` + +**Refresh token grant:** + +``` +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token& +refresh_token=rt_...& +client_id=... +``` + +Returns a new access token and a rotated refresh token. The old refresh +token is revoked. + +**Error cases:** + +| Status | Error | When | +| ------ | ---------------------- | ------------------------------------------------- | +| 400 | `invalid_grant` | Code expired, already consumed, or verifier fails | +| 400 | `invalid_client` | `client_id` not found or mismatch | +| 400 | `invalid_request` | Missing required parameters | +| 400 | `unsupported_grant_type` | Not `authorization_code` or `refresh_token` | +| 429 | `rate_limited` | Too many token requests | + +### 3.6 Token revocation (RFC 7009) + +``` +POST /oauth/revoke +Content-Type: application/x-www-form-urlencoded + +token=...& +token_type_hint=refresh_token& +client_id=... +``` + +**Response** `200` (always — per RFC 7009, even if the token was already +revoked or invalid). + +### 3.7 Google OAuth callback (internal) + +``` +GET /oauth/callback/google?code=...&state=... +``` + +Internal endpoint. Not part of the public OAuth contract. Receives the +authorization code from Google, exchanges it for user info, upserts the +user, then redirects back into the Pagent authorize flow (issues a +Pagent auth code and redirects to the MCP client's `redirect_uri`). + +### 3.8 Magic Link verification (internal) + +``` +GET /oauth/magic?token=... +``` + +Internal endpoint. When the user clicks the link in their email, this +endpoint validates the token, upserts the user, and redirects back into +the Pagent authorize flow. + +### 3.9 Browser session endpoints + +These are for the web renderer and future dashboard, not for MCP +clients. + +``` +POST /auth/logout +Cookie: pagent_session=... +``` + +Deletes the session row and clears the cookie. + +``` +GET /auth/me +Cookie: pagent_session=... +``` + +Returns the current user's profile. Used by the renderer to show a +logged-in state. + +**Response** `200`: + +```json +{ + "id": "uuid", + "handle": "alex", + "email": "alex@blockful.io", + "name": "Alexandro Netto", + "avatar_url": "https://lh3.googleusercontent.com/..." +} +``` + +**Response** `401` if no valid session cookie. + +## 4. Auth flows + +### 4.1 MCP OAuth flow (MCP client connecting via `/mcp`) + +This is the primary auth flow for AI agents. Follows the MCP +specification 2025-11-25. + +``` +MCP Client Pagent API (AS+RS) Google / Email + │ │ │ + │ POST /mcp (no Bearer) │ │ + │────────────────────────────▶│ │ + │ 401 + WWW-Authenticate: │ │ + │ Bearer resource_metadata=│ │ + │ "/.well-known/oauth- │ │ + │ protected-resource" │ │ + │◀────────────────────────────│ │ + │ │ │ + │ GET /.well-known/oauth- │ │ + │ protected-resource │ │ + │────────────────────────────▶│ │ + │ { authorization_servers: │ │ + │ ["https://api.pagent. │ │ + │ link"] } │ │ + │◀────────────────────────────│ │ + │ │ │ + │ GET /.well-known/oauth- │ │ + │ authorization-server │ │ + │────────────────────────────▶│ │ + │ { registration_endpoint, │ │ + │ authorization_endpoint, │ │ + │ token_endpoint, ... } │ │ + │◀────────────────────────────│ │ + │ │ │ + │ POST /oauth/register │ │ + │ { redirect_uris, ... } │ │ + │────────────────────────────▶│ │ + │ { client_id } │ │ + │◀────────────────────────────│ │ + │ │ │ + │ Generate code_verifier, │ │ + │ code_challenge = S256(v) │ │ + │ │ │ + │ Open browser: │ │ + │ GET /oauth/authorize? │ │ + │ client_id=...& │ │ + │ code_challenge=...& │ │ + │ redirect_uri= │ │ + │ http://localhost:PORT/ │ │ + │ callback&state=... │ │ + │─ ─ ─ ─ ─(browser)─ ─ ─ ─ ▶│ │ + │ │ Login page shown │ + │ │ User picks Google │ + │ │────────────────────────▶│ + │ │ Google consent screen │ + │ │◀────────────────────────│ + │ │ /oauth/callback/google │ + │ │ code exchange, upsert │ + │ │ user, issue auth code │ + │ │ │ + │ Redirect to │ │ + │ http://localhost:PORT/ │ │ + │ callback?code=...&state=...│ │ + │◀─ ─ ─(browser redirect)─ ─ │ │ + │ │ │ + │ POST /oauth/token │ │ + │ grant_type= │ │ + │ authorization_code& │ │ + │ code=...&code_verifier=... │ │ + │────────────────────────────▶│ │ + │ { access_token (JWT), │ │ + │ refresh_token } │ │ + │◀────────────────────────────│ │ + │ │ │ + │ POST /mcp │ │ + │ Authorization: Bearer JWT │ │ + │────────────────────────────▶│ │ + │ (MCP response) │ │ + │◀────────────────────────────│ │ +``` + +### 4.2 Google OAuth flow (identity provider leg) + +Pagent is a relying party to Google. The user's browser is redirected +to Google's authorization endpoint. Google returns an authorization code +to Pagent's callback. Pagent exchanges it for an ID token and uses the +claims (`sub`, `email`, `name`, `picture`) to upsert the user. + +``` +User Browser Pagent API Google OAuth + │ │ │ + │ GET /oauth/authorize │ │ + │ (login page shown) │ │ + │ Clicks "Google" │ │ + │──────────────────────▶│ │ + │ │ 302 to │ + │ │ accounts.google.com/ │ + │ │ o/oauth2/v2/auth? │ + │ │ client_id=GOOGLE_ID& │ + │ │ redirect_uri=/oauth/ │ + │ │ callback/google& │ + │ │ scope=openid+email+ │ + │ │ profile& │ + │ │ state=STATE& │ + │ │ response_type=code │ + │◀──────────────────────│ │ + │ │ │ + │ Google consent │ │ + │─────────────────────────────────────────────────▶│ + │ (user approves) │ │ + │◀─────────────────────────────────────────────────│ + │ │ │ + │ GET /oauth/callback/ │ │ + │ google?code=...& │ │ + │ state=... │ │ + │──────────────────────▶│ │ + │ │ POST googleapis.com/ │ + │ │ token (exchange code) │ + │ │──────────────────────────▶│ + │ │ { id_token, access_token }│ + │ │◀──────────────────────────│ + │ │ │ + │ │ Decode id_token: │ + │ │ { sub, email, name, │ + │ │ picture } │ + │ │ Upsert user │ + │ │ Issue Pagent auth code │ + │ │ │ + │ 302 to redirect_uri │ │ + │ ?code=PAGENT_CODE │ │ + │ &state=STATE │ │ + │◀──────────────────────│ │ +``` + +**State parameter encoding:** The `state` parameter sent to Google +encodes both: +- The original MCP client's CSRF `state` value. +- The original authorize request parameters (client_id, redirect_uri, + code_challenge, scope) so the callback can resume the flow. + +This is a signed, encrypted JWT (JWE) to prevent tampering. It is +short-lived (15 min) and single-use. + +### 4.3 Magic Link flow + +``` +User Browser Pagent API Email Service + │ │ │ + │ GET /oauth/authorize │ │ + │ (login page shown) │ │ + │ Enters email, clicks │ │ + │ "Send link" │ │ + │──────────────────────▶│ │ + │ │ POST /oauth/magic/send │ + │ │ (internal) │ + │ │ │ + │ │ Generate token (32 bytes)│ + │ │ Store SHA256(token) in │ + │ │ magic_links table │ + │ │ Build link: │ + │ │ /oauth/magic?token=... │ + │ │──────────────────────────▶│ + │ │ Send email with link │ + │ │ │ + │ "Check your email" │ │ + │◀──────────────────────│ │ + │ │ │ + │ User clicks link │ │ + │ GET /oauth/magic? │ │ + │ token=... │ │ + │──────────────────────▶│ │ + │ │ Validate: │ + │ │ - token_hash exists │ + │ │ - not expired │ + │ │ - not consumed │ + │ │ Mark consumed │ + │ │ Upsert user by email │ + │ │ Issue Pagent auth code │ + │ │ │ + │ 302 to redirect_uri │ │ + │ ?code=PAGENT_CODE │ │ + │ &state=STATE │ │ + │◀──────────────────────│ │ +``` + +The Magic Link email includes the full authorize context (client_id, +redirect_uri, code_challenge, scope, state) encoded in the magic link +URL or stored server-side keyed by the magic link token. Server-side +storage is preferred — it keeps the email link shorter and avoids +leaking OAuth parameters in email logs. + +### 4.4 Browser session flow (renderer / dashboard) + +For browser-based access (the renderer, a future dashboard), users +authenticate via the same `/oauth/authorize` login page. After +authentication, in addition to issuing an auth code for the OAuth flow, +the server sets an httpOnly session cookie: + +``` +Set-Cookie: pagent_session=; + HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000 +``` + +The cookie is set only when the authorize request comes from a +browser context (detected by the presence of a session-initiating query +parameter `browser_session=1` or by the absence of a registered +`client_id` — the renderer doesn't register as an OAuth client, it just +needs a session). + +Direct browser login (not part of an MCP OAuth flow) uses a simplified +path: + +``` +GET /oauth/authorize?browser_session=1 +``` + +No `client_id`, `redirect_uri`, or `code_challenge`. After login, the +server sets the session cookie and redirects to `/` (the renderer +homepage or a dashboard). + +## 5. Token management + +### 5.1 Access tokens (JWT) + +Access tokens are JSON Web Tokens signed with Ed25519 (EdDSA algorithm). +Ed25519 provides 128-bit security in a compact signature (64 bytes) +with fast verification. The key pair is generated once and stored in +environment variables. + +**JWT header:** + +```json +{ + "alg": "EdDSA", + "typ": "at+jwt", + "kid": "pagent-2026-05" +} +``` + +**JWT payload:** + +```json +{ + "iss": "https://api.pagent.link", + "sub": "uuid-of-user", + "aud": "https://api.pagent.link", + "exp": 1747503600, + "iat": 1747500000, + "jti": "unique-token-id", + "client_id": "registered-client-id", + "scope": "page:create page:read", + "email": "alex@blockful.io", + "handle": "alex" +} +``` + +**Claims explained:** + +| Claim | Value | Purpose | +| ----------- | ---------------------------------- | ------------------------------------------------- | +| `iss` | `https://api.pagent.link` | Issuer — must match the AS metadata issuer | +| `sub` | User UUID | Subject — the authenticated user | +| `aud` | `https://api.pagent.link` | Audience — the RS (same as issuer, co-hosted) | +| `exp` | Unix timestamp | Expiry — 1 hour from issuance | +| `iat` | Unix timestamp | Issued at | +| `jti` | Random UUID | Token ID — for revocation checks if needed | +| `client_id` | Registered client ID | Which OAuth client obtained this token | +| `scope` | Space-separated scope string | Authorized scopes | +| `email` | User email | Convenience claim — avoids a DB lookup per request | +| `handle` | User handle | Convenience claim | + +**Lifetime:** 1 hour. Short enough that a leaked token has limited +blast radius; long enough that a typical agent session doesn't need +more than 1-2 refreshes. + +### 5.2 Refresh tokens (opaque) + +Refresh tokens are opaque 256-bit random values, prefixed with `rt_` +for debuggability. Stored as `SHA-256(token)` in the `refresh_tokens` +table. + +**Lifetime:** 90 days. Rotated on every use — the exchange returns a +new refresh token and revokes the old one. + +**Token family revocation:** If a revoked refresh token is presented, +all refresh tokens for that `(user_id, client_id)` pair are revoked +immediately. This detects token theft: the legitimate client used the +refresh token (rotating it), and now the attacker tries to use the old +one. Both parties lose their tokens, forcing re-authentication. This +follows OAuth 2.1 Section 6.1 guidance. + +### 5.3 Signing key management + +The Ed25519 key pair is stored as environment variables: + +- `JWT_SIGNING_KEY` — the 64-byte Ed25519 private key, base64url-encoded. +- `JWT_PUBLIC_KEY` — the 32-byte Ed25519 public key, base64url-encoded. + +Key generation (run once, store the output): + +```bash +node -e " + const { generateKeyPairSync } = require('crypto'); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + console.log('JWT_SIGNING_KEY=' + privateKey.export({type:'pkcs8',format:'der'}).toString('base64url')); + console.log('JWT_PUBLIC_KEY=' + publicKey.export({type:'spki',format:'der'}).toString('base64url')); +" +``` + +A `JWKS` endpoint (`GET /.well-known/jwks.json`) exposes the public +key so external verifiers (if needed in the future) can validate tokens +without sharing the private key: + +```json +{ + "keys": [{ + "kty": "OKP", + "crv": "Ed25519", + "use": "sig", + "kid": "pagent-2026-05", + "x": "" + }] +} +``` + +### 5.4 Token validation + +The `OAuthTokenVerifier` implementation (for the MCP SDK's +`requireBearerAuth` middleware) validates JWTs locally: + +```ts +class PagentTokenVerifier implements OAuthTokenVerifier { + async verifyAccessToken(token: string): Promise { + // 1. Decode and verify JWT signature (Ed25519) + // 2. Check exp > now (reject expired) + // 3. Check iss === expected issuer + // 4. Check aud === expected audience + // 5. Return AuthInfo { token, clientId, scopes, expiresAt, extra: { sub, email, handle } } + } +} +``` + +No DB roundtrip on every request. The JWT is self-contained. The only +reason to hit the DB would be for revocation checks (checking `jti` +against a revocation list), which is deferred to V2 — the 1-hour +lifetime is the revocation mechanism for V1. + +### 5.5 Scopes + +| Scope | Grants | +| -------------- | --------------------------------------------------- | +| `page:create` | `POST /new`, `show_ui`, `show_html` MCP tools | +| `page:read` | `GET /:id`, `GET /:id/result`, `check_result` tool | +| `page:write` | `POST /:id/result` (submit from browser) | + +Default scope (if none requested): `page:create page:read`. The +`page:write` scope is implicitly granted to session-cookie-authenticated +browser users (the renderer needs it to submit forms). + +## 6. Middleware design + +### 6.1 Architecture + +Auth integrates into the existing Hono app and the raw Node HTTP MCP +handler via two middleware layers: + +``` + ┌─────────────────────────────────────┐ + │ server.ts (Node HTTP) │ + │ │ + ┌─────────────────────┐ │ path = /mcp ? │ + │ MCP SDK auth │◀──│ YES → mcpHandler (raw Node) │ + │ requireBearerAuth │ │ ↓ │ + │ (Express compat) │ │ bearerAuthMiddleware │ + └─────────────────────┘ │ ↓ │ + │ StreamableHTTPServerTransport │ + │ │ + ┌─────────────────────┐ │ path != /mcp ? │ + │ Hono middleware │◀──│ YES → Hono app │ + │ authMiddleware() │ │ ↓ │ + │ (cookie + Bearer) │ │ resolve user from cookie or JWT │ + └─────────────────────┘ │ ↓ │ + │ route handlers │ + └─────────────────────────────────────┘ +``` + +### 6.2 Hono auth middleware + +New file: `apps/api/auth/middleware.ts`. + +```ts +import type { Context, Next } from 'hono'; + +type AuthUser = { + id: string; // user UUID + email: string; + handle: string; + authMethod: 'cookie' | 'bearer'; +}; + +type AuthVariables = { + user: AuthUser | null; +}; + +/** + * Resolves the authenticated user from either: + * 1. A session cookie (`pagent_session`) — browser clients + * 2. A Bearer JWT in the Authorization header — API/MCP clients + * + * Sets c.var.user to the resolved user or null if unauthenticated. + * Does NOT reject unauthenticated requests — that's the job of + * requireAuth(), which wraps this and returns 401. + */ +export function resolveAuth(): MiddlewareHandler { + return async (c: Context, next: Next) => { + // Try cookie first (browser sessions) + const sessionToken = getCookie(c, 'pagent_session'); + if (sessionToken) { + const user = await lookupSession(sessionToken); + if (user) { + c.set('user', { ...user, authMethod: 'cookie' }); + return next(); + } + } + + // Try Bearer token (API / MCP clients) + const authHeader = c.req.header('authorization'); + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + const user = await verifyJwt(token); + if (user) { + c.set('user', { ...user, authMethod: 'bearer' }); + return next(); + } + } + + c.set('user', null); + return next(); + }; +} + +/** + * Rejects unauthenticated requests with 401. + * Applied to protected routes (POST /new, etc.) when REQUIRE_AUTH=true. + */ +export function requireAuth(): MiddlewareHandler { + return async (c: Context, next: Next) => { + if (!c.var.user) { + return c.json({ + error: 'unauthorized', + message: 'Authentication required', + }, 401); + } + return next(); + }; +} +``` + +### 6.3 MCP auth middleware + +The MCP handler in `apps/api/mcp/http.ts` currently writes directly to +the Node response stream. Auth is added before the +`StreamableHTTPServerTransport`: + +```ts +// In makeMcpHttpHandler: +if (env.REQUIRE_AUTH) { + // Check for Bearer token in Authorization header + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + // Return 401 with WWW-Authenticate pointing to resource metadata + res.setHeader('WWW-Authenticate', + `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"` + ); + respondJson(res, 401, { + error: 'unauthorized', + message: 'Bearer token required', + }); + return; + } + + const token = authHeader.slice(7); + try { + const authInfo = await tokenVerifier.verifyAccessToken(token); + // Attach auth info for the transport + (req as any).auth = authInfo; + } catch (err) { + respondJson(res, 401, { + error: 'invalid_token', + message: 'Invalid or expired access token', + }); + return; + } +} + +// Pass auth info through to the transport +await transport.handleRequest(req, res, body); +``` + +The `StreamableHTTPServerTransport.handleRequest` accepts an optional +`auth` property on the request object (per the SDK's type definition), +which is forwarded to tool handlers. + +### 6.4 Integration with existing routes + +In `apps/api/app.ts`, the middleware chain becomes: + +```ts +// Always resolve auth (sets c.var.user or null) +app.use('*', resolveAuth()); + +// Conditionally require auth on mutation endpoints +if (env.REQUIRE_AUTH) { + app.use('/new', requireAuth()); +} +``` + +Read endpoints (`GET /:id`, `GET /:id/result`) remain public — pages +are accessed by their unguessable 128-bit ID. Ownership checks (e.g., +"only the owner can delete") are deferred to V2 when we add +page-management endpoints. + +### 6.5 `owner_id` injection + +When auth is resolved and a user is present, `POST /new` injects +`owner_id` into the page record: + +```ts +// In store.ts createPage/createHtmlPage: +// Accept optional ownerId parameter +export async function createPage( + spec: unknown, + format: PageFormat, + cfg: CreatePageConfig & { ownerId?: string }, +): Promise { + // ... existing logic ... + // Pass ownerId to db.insertPage +} +``` + +The `insertPage` function gains an optional `owner_id` column in the +INSERT. + +## 7. Security considerations + +### 7.1 PKCE + +PKCE (Proof Key for Code Exchange) is mandatory. The server MUST +reject authorization code exchanges that don't include a valid +`code_verifier`. Only `S256` is supported (`plain` is forbidden per +OAuth 2.1). The challenge is stored with the authorization code and +validated at the token endpoint. + +### 7.2 Token storage + +| Token type | Storage location | Protection | +| -------------- | ------------------------- | ----------------------------------- | +| Session token | httpOnly, Secure cookie | Not accessible to JS; HTTPS only | +| Access token | MCP client memory | Short-lived (1h); in-memory only | +| Refresh token | MCP client persistent | Stored by SDK; rotated on use | +| Auth code | URL parameter (transient) | Single-use; 10-minute expiry | +| Magic link | Email (transient) | Single-use; 15-minute expiry | + +Server-side, all secrets are stored as SHA-256 hashes — session tokens, +refresh tokens, magic link tokens, authorization codes. The raw values +exist only in transit (cookie, URL, email). + +### 7.3 Rate limiting on auth endpoints + +Auth endpoints are high-value targets for brute-force and enumeration +attacks. Separate rate limits from the existing page-creation limiter: + +| Endpoint | Limit | Window | Key | +| --------------------- | ------------ | ------- | ------------------ | +| `POST /oauth/register`| 10 per IP | 1 hour | IP | +| `POST /oauth/token` | 20 per IP | 1 min | IP | +| `POST /oauth/magic/send` | 5 per email | 15 min | Email | +| `GET /oauth/authorize`| 30 per IP | 1 min | IP | + +These are in-process (same `RateLimiter` class from +`apps/api/mcp/rate-limit.ts`), which is acceptable for the single- +instance deployment. If we scale horizontally, these move to +Redis/Upstash. + +### 7.4 CSRF protection + +- **OAuth flows:** CSRF is mitigated by the `state` parameter (MCP + clients generate it, Pagent echoes it back) and PKCE (the code + verifier is never exposed to the browser). +- **Session cookies:** `SameSite=Lax` prevents CSRF on state-changing + requests (POST). The renderer and API are on different origins + (`pagent.link` vs `api.pagent.link`), but `SameSite=Lax` allows + top-level navigations (GET) while blocking cross-origin POST. +- **Logout:** `POST /auth/logout` requires the session cookie and is + protected by `SameSite=Lax`. + +### 7.5 Open redirect prevention + +The `redirect_uri` in the authorize request is validated against the +client's registered `redirect_uris` array. Exact string match — no +wildcards, no pattern matching. This prevents an attacker from using +Pagent's authorize endpoint to redirect a user to a malicious site. + +### 7.6 Email enumeration + +The Magic Link flow does not reveal whether an email is registered. +Both "email found" and "email not found" show the same "check your +email" message. On the backend, if the email is not registered, no +email is sent (but the response is identical to avoid timing attacks — +add a small random delay to normalize response times). + +### 7.7 Google OAuth state parameter + +The `state` parameter sent to Google encodes the full authorize context +as a signed JWT (HMAC-SHA256 with a server-side secret). This prevents: +- Tampering with the redirect URI or code challenge during the Google + round-trip. +- CSRF attacks on the Google callback (the state is unpredictable). + +## 8. Migration plan + +### 8.1 Phase 1: Schema + endpoints (auth optional) + +1. Add all auth tables to `db.ts`'s `init()` via `CREATE TABLE IF NOT + EXISTS`. Add `owner_id` column to `pages` via `ALTER TABLE ... ADD + COLUMN IF NOT EXISTS`. +2. Deploy all OAuth and auth endpoints. +3. `REQUIRE_AUTH` defaults to `false`. Everything works exactly as + before — no user needs to log in, pages are created without owners. +4. Pages created by authenticated users get `owner_id` set; pages + created by unauthenticated users get `owner_id = NULL`. + +### 8.2 Phase 2: Grace period (auth encouraged) + +1. The renderer shows a "Sign in" option but doesn't require it. +2. MCP clients that support OAuth (e.g. Claude Code with the MCP SDK) + will go through the auth flow on first connect. MCP clients that + don't support OAuth continue to work (the `/mcp` endpoint returns + MCP responses, not 401). +3. Monitor: what percentage of pages have `owner_id IS NOT NULL`? + +### 8.3 Phase 3: Auth required + +1. Set `REQUIRE_AUTH=true` in Railway. +2. `POST /new` and `POST /mcp` (for tool calls that create pages) + return 401 without a valid token. +3. Unauthenticated read access (`GET /:id`, `GET /:id/result`) still + works — pages are accessed by unguessable ID. +4. The stdio MCP server (`apps/mcp`) now needs to send Bearer tokens + with its HTTP requests to `SERVICE_URL`. The user provides their + token via the `PAGENT_TOKEN` env var (or the SDK handles the OAuth + flow). + +### 8.4 Backward compatibility guarantees + +| Behavior | During grace period | After REQUIRE_AUTH=true | +| ------------------------------------- | ------------------- | ----------------------- | +| `POST /new` without auth | Works (owner=NULL) | 401 | +| `GET /:id` without auth | Works | Works | +| `GET /:id/result` without auth | Works | Works | +| `POST /:id/result` without auth | Works | Works (cookie auth) | +| `POST /mcp` without Bearer | Works | 401 with discovery | +| Existing pages (owner_id=NULL) | Readable | Readable | + +## 9. Environment variables + +New environment variables for the API (`apps/api`): + +| Variable | Required | Default | Description | +| -------------------------- | -------- | ------------ | ----------------------------------------------- | +| `REQUIRE_AUTH` | No | `false` | If `true`, mutation endpoints require auth | +| `JWT_SIGNING_KEY` | Yes* | - | Ed25519 private key, base64url-encoded (DER) | +| `JWT_PUBLIC_KEY` | Yes* | - | Ed25519 public key, base64url-encoded (DER) | +| `GOOGLE_CLIENT_ID` | Yes* | - | Google OAuth 2.0 client ID | +| `GOOGLE_CLIENT_SECRET` | Yes* | - | Google OAuth 2.0 client secret | +| `GOOGLE_REDIRECT_URI` | No | `{PUBLIC_URL}/oauth/callback/google` | Google OAuth callback URI | +| `MAGIC_LINK_SECRET` | Yes* | - | HMAC key for signing magic link tokens | +| `AUTH_STATE_SECRET` | Yes* | - | HMAC key for signing OAuth state JWTs | +| `SESSION_MAX_AGE_DAYS` | No | `30` | Session cookie lifetime in days | +| `REFRESH_TOKEN_MAX_DAYS` | No | `90` | Refresh token lifetime in days | +| `ACCESS_TOKEN_TTL_SECONDS`| No | `3600` | JWT access token lifetime in seconds | +| `SMTP_HOST` | Yes* | - | SMTP server for magic link emails | +| `SMTP_PORT` | No | `587` | SMTP port | +| `SMTP_USER` | Yes* | - | SMTP username | +| `SMTP_PASS` | Yes* | - | SMTP password | +| `SMTP_FROM` | No | `noreply@pagent.link` | From address for magic link emails | + +*Required when `REQUIRE_AUTH=true` or when auth endpoints are +used. The API boots without them during the grace period (auth +endpoints return 503 "auth not configured"). + +New environment variable for the stdio MCP (`apps/mcp`): + +| Variable | Required | Default | Description | +| -------------- | -------- | ------- | ------------------------------------------------ | +| `PAGENT_TOKEN` | No | - | Pre-obtained Bearer token for authenticated API | + +### Schema validation + +The existing `envSchema` in `schemas.ts` is extended: + +```ts +// Auth-related env vars — optional unless REQUIRE_AUTH is true +REQUIRE_AUTH: z.coerce.boolean().optional().default(false), +JWT_SIGNING_KEY: z.string().optional(), +JWT_PUBLIC_KEY: z.string().optional(), +GOOGLE_CLIENT_ID: z.string().optional(), +GOOGLE_CLIENT_SECRET: z.string().optional(), +GOOGLE_REDIRECT_URI: z.string().url().optional(), +MAGIC_LINK_SECRET: z.string().optional(), +AUTH_STATE_SECRET: z.string().optional(), +SESSION_MAX_AGE_DAYS: z.coerce.number().int().positive().optional().default(30), +REFRESH_TOKEN_MAX_DAYS: z.coerce.number().int().positive().optional().default(90), +ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().optional().default(3600), +SMTP_HOST: z.string().optional(), +SMTP_PORT: z.coerce.number().int().optional().default(587), +SMTP_USER: z.string().optional(), +SMTP_PASS: z.string().optional(), +SMTP_FROM: z.string().email().optional().default('noreply@pagent.link'), +``` + +With a `superRefine` that ensures the crypto/SMTP vars are present when +`REQUIRE_AUTH=true`. + +## 10. Dependencies + +New npm packages for `@pagent/api`: + +| Package | Version | Purpose | +| --------------- | ------- | ------------------------------------------------- | +| `jose` | `^6.x` | JWT signing, verification, JWK/JWKS, Ed25519 | +| `nodemailer` | `^6.x` | Sending magic link emails via SMTP | + +**Why `jose`?** The `jose` library is the standard choice for JWT in +Node.js. It supports Ed25519 natively, has zero dependencies, handles +JWK/JWKS serialization, and is maintained by the author of the +`openid-client` library. + +**Why not `jsonwebtoken`?** It doesn't support Ed25519/EdDSA. It +also has a `node-jws` dependency chain that is heavier than `jose`. + +**Why `nodemailer`?** It is the de facto standard for sending email +from Node.js. Supports SMTP, has TypeScript types, and is well- +maintained. + +No new packages for `@pagent/web` (the renderer). The login page is +server-rendered by the API; the renderer only reads the session cookie. + +No new packages for `@pagent/mcp` (the stdio server). It sends Bearer +tokens read from `PAGENT_TOKEN` in its existing `fetch` calls. + +### MCP SDK usage + +The auth implementation uses these existing SDK exports: + +| Export | From | Usage | +| ----------------------------- | --------------------------------------------- | ------------------------------------------------ | +| `OAuthServerProvider` | `@modelcontextprotocol/sdk/server/auth/provider` | Interface — implement for Pagent's Postgres store | +| `OAuthRegisteredClientsStore` | `@modelcontextprotocol/sdk/server/auth/clients` | Interface — implement for `oauth_clients` table | +| `mcpAuthRouter` | `@modelcontextprotocol/sdk/server/auth/router` | Express router for AS metadata + endpoints | +| `requireBearerAuth` | `@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth` | Express middleware for /mcp auth | +| `AuthInfo` | `@modelcontextprotocol/sdk/server/auth/types` | Type for verified token info | + +**Important:** The SDK's `mcpAuthRouter` and `requireBearerAuth` are +Express middleware. Since Pagent's MCP handler already bypasses Hono +and writes directly to Node's `IncomingMessage`/`ServerResponse`, +Express compatibility is straightforward — Express middleware works on +raw Node HTTP objects. For the well-known metadata endpoints, we have +two options: + +1. **Use the SDK's Express router** — mount it on a minimal Express app + that handles only `/.well-known/*` and `/oauth/*`, multiplexed in + `server.ts` alongside the Hono listener and MCP handler. +2. **Implement the metadata endpoints in Hono directly** — serve the + JSON responses from Hono routes, which avoids adding Express as a + dependency. + +Option 2 is preferred. The metadata endpoints are static JSON — there +is no benefit to pulling in Express just to serve two `GET` routes. The +`mcpAuthRouter`'s real value is in the `/oauth/register`, +`/oauth/authorize`, and `/oauth/token` handlers, which implement +non-trivial OAuth logic. We implement those ourselves using Hono routes +backed by the `OAuthServerProvider` interface, keeping the provider +implementation (which is the complex part) reusable. + +## File layout + +New files under `apps/api/auth/`: + +``` +apps/api/auth/ + provider.ts # OAuthServerProvider implementation (Postgres-backed) + clients-store.ts # OAuthRegisteredClientsStore implementation + jwt.ts # JWT signing, verification, JWKS + middleware.ts # Hono resolveAuth() + requireAuth() middleware + magic-link.ts # Magic link generation, validation, email sending + google.ts # Google OAuth helper (redirect URL builder, token exchange) + login-page.ts # Server-rendered HTML login page + routes.ts # Hono routes for /oauth/*, /auth/*, /.well-known/* + session.ts # Session create/validate/delete helpers +``` + +New files under `apps/api/auth/` tests: + +``` +apps/api/auth/ + jwt.test.ts + middleware.test.ts + provider.test.ts + routes.test.ts + session.test.ts +``` + +## Decisions summary + +| Decision | Chosen | Rejected | +| -------------------------------------- | ------------------------------------------------- | -------------------------------------------------- | +| Auth provider | Custom (Postgres-backed) | Clerk, Auth0, Supabase Auth | +| AS/RS co-hosting | Same origin | Separate AS service | +| JWT algorithm | EdDSA (Ed25519) | RS256, HS256, ES256 | +| JWT library | `jose` | `jsonwebtoken` (no Ed25519) | +| Access token format | JWT (self-contained) | Opaque (requires DB lookup per request) | +| Refresh token format | Opaque (SHA-256 stored) | JWT (no revocation benefit) | +| PKCE method | S256 only | S256 + plain | +| Session storage | DB-backed (Postgres) | JWT cookies, Redis | +| Identity providers | Google + Magic Link | GitHub, Apple, SMS OTP | +| Login page rendering | Server-rendered HTML | Vite SPA route, separate frontend | +| Express for SDK auth middleware | No (Hono-native implementation) | Mount Express alongside Hono | +| Auth enforcement | Env var toggle (`REQUIRE_AUTH`) | Compile-time flag, gradual rollout via feature flag | +| Page read auth | None (unguessable ID is sufficient) | Require auth for all reads | +| Token revocation (V1) | Short TTL (1h); no revocation list | JTI blacklist in DB/Redis | +| Refresh token rotation | Rotate on every use + family revocation | Reuse until expiry | +| Email for magic links | SMTP via `nodemailer` | SendGrid, Resend, AWS SES SDK | + +## Open questions + +None blocking implementation. Future considerations: + +- **Additional identity providers** (GitHub, Apple) — straightforward + to add as additional handlers in the Google OAuth pattern. Defer + until user demand signals which ones matter. +- **Token revocation list** — if the 1-hour JWT lifetime proves too + long for abuse response, add a `jti` blacklist (small in-memory set + with TTL sync from DB). The JWT `jti` claim is already present. +- **Rate limiter persistence** — in-memory rate limiters reset on + deploy. If auth endpoint abuse becomes a real signal, move to + Redis/Upstash. +- **SMTP provider** — `nodemailer` with raw SMTP is the simplest start. + If deliverability becomes an issue, swap to Resend or SendGrid (the + `magic-link.ts` module abstracts the transport). +- **Account linking** — a user who first logs in via Magic Link and + later via Google (same email) should be the same user. The `email` + column's uniqueness constraint handles this: upsert on email. But + there's no "link your Google account to your Magic Link account" UI + yet. +- **Admin endpoints** — user management, client management, session + revocation. Deferred to V2. diff --git a/docs/superpowers/specs/2026-05-17-custom-urls-design.md b/docs/superpowers/specs/2026-05-17-custom-urls-design.md new file mode 100644 index 0000000..f2b95d6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-custom-urls-design.md @@ -0,0 +1,981 @@ +# Custom URLs — Design + +Status: draft, awaiting user review (2026-05-17). + +Depends on: Auth (roadmap item 1 — users must exist before they can own handles). + +## 1. Overview and motivation + +Today every pagent page is addressed by a 32-character hex ID: + +``` +https://pagent.link/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 +``` + +These IDs are intentionally opaque — great for single-shot ephemeral forms, but +they fail when pages become durable or shareable: + +- **Unreadable** — users cannot glance at a URL and know what it leads to. +- **Unrecognizable** — there is no author provenance; a link could be from anyone. +- **Unsharable** — pasting a hex blob into Slack or an email looks suspicious. +- **No namespace** — agents cannot reference a stable slug across sessions + ("open my quarterly-review form") because every page gets a fresh random ID. + +Custom URLs solve this by introducing **user handles** and **page slugs**: + +``` +https://pagent.link/alex/quarterly-review +``` + +The hex-ID scheme continues to work for backward compatibility. Custom URLs are +opt-in: a page only gets a slug if the agent passes one to `show_ui` / `show_html`. + +### Design principles + +1. **Additive** — existing hex URLs never break; no flag day. +2. **Agent-driven slugs** — the agent picks the slug at page-creation time via + the MCP tool. The user picks their handle once during onboarding. +3. **Stable references** — `(owner_id, slug)` is unique, so an agent can later + reference "the page at /alex/quarterly-review" without remembering the hex ID. +4. **Simple routing** — the renderer resolves `/:handle/:slug` to a page ID and + proceeds exactly as it does today for `/:id`. + +## 2. Database schema changes + +### 2.1 `users` table (created by Auth — not modified here) + +Auth creates the `users` table with a nullable `handle text` column and the +partial unique index `users_handle_unique`. This spec does **not** alter the +`users` table — it only requires that `handle` is set (via `PUT /me/handle`) +before slug-based custom URLs work. + +```sql +-- Reference: Auth creates the table with these relevant columns/indexes. +-- Repeated here for context; Custom URLs does NOT run these statements. +-- +-- CREATE TABLE IF NOT EXISTS users ( +-- id uuid PRIMARY KEY DEFAULT gen_random_uuid(), +-- ... +-- handle text, -- nullable until onboarding sets it +-- ... +-- ); +-- +-- CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique +-- ON users (handle) +-- WHERE handle IS NOT NULL; +``` + +**Column details (defined by Auth, consumed by Custom URLs):** + +| Column | Type | Nullable | Default | Constraint | +|----------|--------|----------|---------|--------------------------------------------------| +| `handle` | `text` | Yes* | `NULL` | Unique (partial, where not null), validated regex | + +*Nullable until the user completes onboarding. The `PUT /me/handle` endpoint +(section 3) sets the value; the application layer enforces NOT NULL at the +endpoint level for new registrations. + +### 2.2 `pages` table (existing — extended) + +```sql +-- Add slug column. Nullable because most existing pages (and future HTML +-- show_html pages) won't have a slug. +ALTER TABLE pages + ADD COLUMN IF NOT EXISTS slug text; + +-- Add owner_id column. Nullable for backward compat with anonymous pages +-- created before Auth ships. Once Auth is mandatory, tighten to NOT NULL. +ALTER TABLE pages + ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; + +-- Compound unique: within a single user's namespace, slugs must be unique. +-- Partial index excludes NULL slugs (anonymous / slug-less pages). +CREATE UNIQUE INDEX IF NOT EXISTS pages_owner_slug_unique + ON pages (owner_id, slug) + WHERE slug IS NOT NULL; + +-- Lookup index for the resolution endpoint: given a handle and slug, find the +-- page. This is a covering index — the query joins users.handle to pages.owner_id +-- and filters on pages.slug, so indexing (owner_id, slug) is sufficient. The +-- users.handle unique index handles the handle → user_id lookup. +-- (pages_owner_slug_unique already covers this; no additional index needed.) +``` + +**New columns on `pages`:** + +| Column | Type | Nullable | Default | Constraint | +|------------|--------|----------|---------|----------------------------------------------| +| `slug` | `text` | Yes | `NULL` | Unique per (owner_id) where slug IS NOT NULL | +| `owner_id` | `uuid` | Yes* | `NULL` | FK → users(id) ON DELETE SET NULL | + +*Nullable during the Auth transition. Anonymous pages created before Auth have +no owner and therefore no slug. + +### 2.3 Validation constraints (application-level) + +Handle and slug formats are enforced in the application layer (Zod schemas) +rather than as CHECK constraints. This keeps validation rules co-located with +the API schemas and allows richer error messages. + +```typescript +// apps/api/schemas.ts +export const handleSchema = z + .string() + .regex( + /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/, + 'Handle must be 3-40 characters, lowercase alphanumeric and hyphens, cannot start/end with a hyphen', + ); + +export const slugSchema = z + .string() + .regex( + /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, + 'Slug must be 3-64 characters, lowercase alphanumeric and hyphens, cannot start/end with a hyphen', + ); +``` + +### 2.4 Full migration script + +```sql +-- 001_custom_urls.sql +-- Idempotent — safe to run on every deploy. +-- NOTE: users.handle already exists (created by the Auth migration). + +BEGIN; + +-- pages.slug + pages.owner_id +ALTER TABLE pages ADD COLUMN IF NOT EXISTS slug text; +ALTER TABLE pages ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +CREATE UNIQUE INDEX IF NOT EXISTS pages_owner_slug_unique + ON pages (owner_id, slug) WHERE slug IS NOT NULL; + +COMMIT; +``` + +## 3. Handle registration + +### 3.1 Onboarding flow + +After Auth creates the user record (Google / magic link), the user is prompted +to choose a handle. The handle is required before the user can create pages +(enforced by a middleware that checks `users.handle IS NOT NULL`). + +**Endpoint: `PUT /me/handle`** + +``` +PUT /me/handle +Authorization: Bearer +Content-Type: application/json + +{ "handle": "alex" } +``` + +**Responses:** + +| Status | Body | When | +|--------|-----------------------------------------|-----------------------------------------------| +| 200 | `{ "handle": "alex" }` | Handle set successfully | +| 400 | `{ "error": "bad_request", ... }` | Validation failed (regex, length) | +| 409 | `{ "error": "handle_taken", ... }` | Another user already has this handle | +| 422 | `{ "error": "handle_immutable", ... }` | User already has a handle (cannot change v2) | + +**Handler logic:** + +``` +1. Authenticate request (Bearer token → user_id) +2. Validate handle against handleSchema +3. Check handle is not in RESERVED_HANDLES +4. Check handle does not match /^[a-f0-9]{32}$/ (collision avoidance) +5. If user already has a handle → 422 +6. UPDATE users SET handle = $handle WHERE id = $user_id AND handle IS NULL + - If 0 rows affected → race: another request set it first → 422 + - If unique constraint violated → 409 +7. Return { handle } +``` + +### 3.2 Settings read endpoint + +**Endpoint: `GET /me`** + +Returns the current user profile including the handle. Part of the Auth +feature; this spec adds `handle` to the response shape. + +```json +{ + "id": "uuid", + "email": "alex@blockful.io", + "handle": "alex", + "created_at": "2026-05-17T..." +} +``` + +### 3.3 Handle validation rules + +| Rule | Regex / Check | Rationale | +|------------------------------|-----------------------------------------------------|------------------------------------------| +| Lowercase alphanumeric + `-` | `/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/` | URL-safe, no encoding needed | +| 3-40 characters | Embedded in regex | Short enough to type, long enough for names | +| No leading/trailing hyphen | First/last char `[a-z0-9]` | Avoids confusion with CLI flags | +| No consecutive hyphens | Application-level `.refine(h => !h.includes('--'))` | Prevents visual confusion | +| Not a 32-char hex string | `/^[a-f0-9]{32}$/.test(h) → reject` | Collision avoidance with page IDs | +| Not in reserved list | `RESERVED_HANDLES.has(h) → reject` | Protects system routes | +| Case-insensitive uniqueness | Stored lowercase; regex enforces lowercase input | Prevents `Alex` vs `alex` collisions | + +### 3.4 Immutability + +Handles are immutable after creation in v2. Changing handles requires: + +- Updating all external references (bookmarks, shared links) +- Potentially redirecting old URLs +- Handling the window where both old and new handles exist + +This complexity is deferred to v3. The `PUT /me/handle` endpoint rejects +requests when the user already has a handle set. + +## 4. Slug assignment + +### 4.1 How slugs flow through the system + +``` +Agent calls show_ui({ spec, slug: "quarterly-review" }) + │ + ▼ +MCP tool validates slug against slugSchema + │ + ▼ +store.createPage() receives (spec, format, { slug, ownerId }) + │ + ▼ +db.insertPage() writes row with slug + owner_id + │ + ▼ +Response includes url: "https://pagent.link/alex/quarterly-review" +``` + +### 4.2 MCP tool changes (show_ui) + +The `show_ui` tool gains an optional `slug` parameter: + +```typescript +// apps/api/mcp/tools.ts — updated inputSchema +inputSchema: { + spec: z.array(z.record(z.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION), + slug: slugSchema.optional().describe( + 'Optional URL slug for this page. If provided, the page will be ' + + 'addressable at pagent.link//. Must be 3-64 chars, ' + + 'lowercase alphanumeric + hyphens, no leading/trailing hyphens. ' + + 'Must be unique within your namespace — if the slug is taken, the ' + + 'call fails with an error.' + ), +}, +``` + +The `show_html` tool also gains the same optional `slug` parameter with +identical validation. + +### 4.3 Uniqueness enforcement + +The compound unique index `pages_owner_slug_unique` enforces that a given +user cannot have two active pages with the same slug. When a slug collision +occurs: + +``` +INSERT INTO pages (..., slug, owner_id) VALUES (..., $slug, $owner_id) +-- Unique violation → Postgres error code 23505 +``` + +The store layer catches this and returns a structured error: + +```typescript +// apps/api/store.ts +export class SlugConflictError extends Error { + constructor(public slug: string) { + super(`A page with slug "${slug}" already exists in your namespace`); + this.name = 'SlugConflictError'; + } +} +``` + +Both REST and MCP handlers map `SlugConflictError` to: + +- REST: `409 { error: "slug_conflict", slug, message: "..." }` +- MCP: `throw new Error(...)` (surfaced as tool error to the agent) + +### 4.4 Slug lifecycle and expired pages + +When a page expires and is swept by the TTL cleanup, its slug is freed. This +means a slug can be reused after the previous page using it has expired. This is +intentional — slugs are a namespace convenience, not a permanent reservation. + +The unique index is on `(owner_id, slug)` without an `expires_at` filter, which +means an expired-but-not-yet-swept page's slug is still "taken" until the sweep +runs. This is acceptable because: + +1. Sweeps run every 60 seconds. +2. The agent gets a clear error message and can retry after a moment. +3. Adding `WHERE expires_at > now()` to the unique index would make it a + partial-expression index, which Postgres supports but complicates reasoning + about uniqueness guarantees. + +### 4.5 Slugs without Auth + +Before Auth ships, there is no `owner_id`. Pages created without authentication +cannot have slugs — the slug parameter is silently ignored (or rejected with an +error if we want to be strict). The recommendation is to reject: + +``` +if (slug && !ownerId) { + throw new Error('slug requires authentication; set up your handle first'); +} +``` + +This keeps the contract clean: slugs always imply an owner and a handle. + +## 5. URL resolution + +### 5.1 Routing logic and precedence + +The web renderer (Vite SPA) handles all URL patterns. The resolution decision +tree for a path like `/foo/bar`: + +``` +pathname = location.pathname + +1. Is pathname === "/" ? + → render home page + +2. Is pathname === "/_components" ? + → render component showcase + +3. Does pathname match /^\/[a-f0-9]{32}$/ ? + → hex page ID → fetch API_BASE/:id → render page + (backward compatible, existing behavior) + +4. Does pathname match /^\/([^/]+)\/([^/]+)$/ ? + → candidate handle/slug → fetch API_BASE/resolve/:handle/:slug + → if 200, extract page_id → fetch API_BASE/:id → render page + → if 404, show "page not found" + +5. Does pathname match /^\/([^/]+)$/ and segment is NOT a 32-char hex? + → candidate handle with no slug → show user profile page (future) + → or 404 for now + +6. Otherwise → 404 +``` + +**Precedence rule:** Hex IDs are always tried first. A path segment that happens +to be exactly 32 hex characters is always treated as a page ID, never as a +handle. This is enforced by the collision avoidance rule (handles cannot be +32-char hex strings). + +### 5.2 API resolution endpoint + +**Endpoint: `GET /resolve/:handle/:slug`** + +``` +GET /resolve/alex/quarterly-review +``` + +**Responses:** + +| Status | Body | When | +|--------|----------------------------------------------|-----------------------------------------| +| 200 | `{ "id": "", "handle": "...", ... }` | Page found and active | +| 404 | `{ "error": "not_found", ... }` | Handle or slug not found, or page expired | + +**Query:** + +```sql +SELECT p.id, p.format, p.state, p.expires_at +FROM pages p +JOIN users u ON u.id = p.owner_id +WHERE u.handle = $handle + AND p.slug = $slug + AND p.expires_at > now() +LIMIT 1; +``` + +The endpoint returns only the page ID and metadata (not the full spec) so the +renderer can then call `GET /:id` as it does today. This keeps the resolution +endpoint lightweight and avoids duplicating the full page-fetch logic. + +**Alternative considered:** Having `GET /resolve/:handle/:slug` return the full +page payload directly. Rejected because it would duplicate the `getPageHandler` +logic and complicate caching/metrics — better to resolve-then-fetch. + +### 5.3 Redirect behavior + +No HTTP-level redirects. The renderer resolves `/:handle/:slug` client-side and +fetches the page by ID from the API. The browser URL stays as +`pagent.link/alex/quarterly-review` — it never rewrites to the hex ID. + +This preserves the human-readable URL in the address bar and in shared links. + +### 5.4 API route registration + +```typescript +// apps/api/app.ts — new route, added BEFORE the /:id catch-all +app.get('/resolve/:handle/:slug', resolveHandleSlugHandler); + +// Existing routes (unchanged) +app.post('/new', newPageLimiter, newPageHandler); +app.get('/:id', getPageHandler); +app.post('/:id/result', submitResultHandler); +app.get('/:id/result', getResultHandler); +``` + +The `/resolve/:handle/:slug` route is registered before `/:id` so Hono matches +the more specific pattern first. Since `resolve` is not a valid 32-char hex +string, there is no ambiguity with the existing `/:id` route. + +## 6. Frontend routing changes + +### 6.1 Current routing (main.ts) + +```typescript +// Current: extract first path segment as page ID +const pageId = location.pathname.replace(/^\/+/, '').split('/')[0]; +``` + +### 6.2 Updated routing + +```typescript +// apps/web/main.ts — new routing logic + +const HEX_ID_RE = /^[a-f0-9]{32}$/; +const HANDLE_SLUG_RE = /^\/([a-z0-9][a-z0-9-]{1,38}[a-z0-9])\/([a-z0-9][a-z0-9-]{1,62}[a-z0-9])$/; + +type PageRef = + | { kind: 'id'; id: string } + | { kind: 'handle-slug'; handle: string; slug: string } + | { kind: 'home' } + | { kind: 'showcase' }; + +function parseRoute(pathname: string): PageRef { + if (pathname === '/' || pathname === '') return { kind: 'home' }; + if (pathname === '/_components') return { kind: 'showcase' }; + + // Strip trailing slash + const clean = pathname.replace(/\/+$/, ''); + const segments = clean.replace(/^\/+/, '').split('/'); + + // Single segment: hex ID or unknown + if (segments.length === 1) { + if (HEX_ID_RE.test(segments[0])) { + return { kind: 'id', id: segments[0] }; + } + // Future: user profile page at /:handle + return { kind: 'home' }; // fallback for now + } + + // Two segments: handle/slug candidate + if (segments.length === 2) { + const match = HANDLE_SLUG_RE.exec(clean); + if (match) { + return { kind: 'handle-slug', handle: match[1], slug: match[2] }; + } + } + + return { kind: 'home' }; // fallback +} +``` + +### 6.3 Resolution in AgentUIApp + +When the route is `handle-slug`, the app first calls the resolution endpoint, +then proceeds as it does today with the resolved ID: + +```typescript +// Inside AgentUIApp +private async loadPage() { + let resolvedId: string; + + if (this.ref.kind === 'id') { + resolvedId = this.ref.id; + } else if (this.ref.kind === 'handle-slug') { + try { + const res = await fetch( + `${API_BASE}/resolve/${this.ref.handle}/${this.ref.slug}`, + { headers: { accept: 'application/json' } }, + ); + if (res.status === 404) { + this.status = 'error'; + this.error = 'Page not found or expired.'; + return; + } + if (!res.ok) { + this.status = 'error'; + this.error = `Failed to resolve page (${res.status}).`; + return; + } + const { id } = (await res.json()) as { id: string }; + resolvedId = id; + } catch (err) { + console.error('resolve failed', err); + this.status = 'error'; + this.error = 'Failed to load page.'; + return; + } + } else { + return; // home / showcase handled elsewhere + } + + // Existing fetch-by-id logic continues from here... + const res = await fetch(`${API_BASE}/${resolvedId}`, { ... }); + // ... +} +``` + +### 6.4 Vite dev-server proxy changes + +The dev proxy in `vite.config.ts` needs new rules for handle/slug patterns: + +```typescript +// apps/web/vite.config.ts — additional proxy rules + +// /resolve/:handle/:slug — always API +[`^/resolve/[a-z0-9][a-z0-9-]{1,38}[a-z0-9]/[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`]: { + target: API_TARGET, + changeOrigin: true, +}, + +// /:handle/:slug — content-negotiated (same logic as /:id) +// Browser navigation → SPA; fetch() → API +[`^/[a-z0-9][a-z0-9-]{1,38}[a-z0-9]/[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`]: { + target: API_TARGET, + changeOrigin: true, + bypass(req) { + const accept = req.headers.accept ?? ''; + if (accept.includes('text/html')) return '/index.html'; + }, +}, +``` + +### 6.5 Vercel rewrite (no change needed) + +The existing Vercel catch-all rewrite handles this: + +```json +{ "source": "/(.*)", "destination": "/index.html" } +``` + +All paths that don't match a static asset already fall through to `index.html`, +so `/:handle/:slug` paths are served the SPA shell. The client-side routing in +`main.ts` takes over from there. + +## 7. MCP tool changes + +### 7.1 `show_ui` — slug parameter + +```typescript +// Updated tool registration +server.registerTool( + 'show_ui', + { + title: 'Show UI to the user', + description: SHOW_UI_DESCRIPTION, // updated, see 7.3 + inputSchema: { + spec: z.array(z.record(z.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION), + slug: slugSchema.optional().describe( + 'URL-friendly slug for this page (e.g. "quarterly-review"). ' + + 'When set, the page URL will be pagent.link// ' + + 'instead of a random hex ID. Must be unique in your namespace.' + ), + }, + }, + async ({ spec, slug }) => { + const created = await ops.showUi(spec, slug); + return { + content: [ + { + type: 'text', + text: `UI ready. Share this URL with the user:\n${created.url}\n\n` + + `page_id: ${created.id}\nexpires_at: ${created.expires_at}`, + }, + ], + structuredContent: { + page_id: created.id, + url: created.url, + expires_at: created.expires_at, + }, + }; + }, +); +``` + +### 7.2 `show_html` — same slug parameter + +Identical change: add optional `slug` to `show_html`'s input schema. + +### 7.3 Updated tool description + +Add to `SHOW_UI_DESCRIPTION`: + +``` +'You can optionally pass a `slug` to make the page addressable at a +human-readable URL (pagent.link//) instead of a random hex ID. +Slugs must be unique within your namespace — if the slug is already taken by +another active page, the call will fail.' +``` + +### 7.4 `check_result` — page_id format update + +Once custom URLs exist, agents may try to pass a handle/slug to `check_result` +instead of the hex page_id. The tool description should clarify: + +``` +'page_id must be the 32-character hex ID returned by show_ui, not a URL slug.' +``` + +The `page_id` schema validation (`/^[a-f0-9]{32}$/`) already rejects non-hex +inputs, so no code change is needed — just description clarity. + +### 7.5 Response URL format + +When a page has a slug and the creating user has a handle, the response URL +uses the custom format: + +```json +{ + "page_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "url": "https://pagent.link/alex/quarterly-review", + "expires_at": 1747500000000 +} +``` + +When no slug is provided (or the user has no handle), the URL falls back to +the hex format: + +```json +{ + "url": "https://pagent.link/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" +} +``` + +### 7.6 PageOps interface update + +```typescript +export interface PageOps { + showUi(spec: unknown, slug?: string): Promise; + showHtml(html: string, slug?: string): Promise; + checkResult(page_id: string): Promise; +} +``` + +### 7.7 Stdio MCP adapter (apps/mcp/server.ts) + +The stdio adapter's `restOps` forwards the slug to the REST API: + +```typescript +const restOps: PageOps = { + async showUi(spec, slug) { + const res = await fetch(`${SERVICE_URL}/new`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ spec, ...(slug && { slug }) }), + }); + // ... + }, + async showHtml(html, slug) { + const res = await fetch(`${SERVICE_URL}/new`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ format: 'html', spec: html, ...(slug && { slug }) }), + }); + // ... + }, + // checkResult unchanged +}; +``` + +### 7.8 REST `POST /new` body schema update + +```typescript +// apps/api/schemas.ts — updated newPageBodySchema +export const newPageBodySchema = z.union([ + z + .object({ + format: z.literal('a2ui').optional().default('a2ui'), + spec: z.unknown(), + slug: slugSchema.optional(), + }) + .refine((b) => 'spec' in b, { message: "missing 'spec'" }), + z.object({ + format: z.literal('html'), + spec: z.string().min(1).max(HTML_MAX_BYTES), + slug: slugSchema.optional(), + }), +]); +``` + +## 8. Collision avoidance + +### 8.1 Reserved handles + +Handles that collide with existing or planned routes are rejected at +registration time. + +```typescript +// apps/api/schemas.ts or a dedicated constants file +export const RESERVED_HANDLES = new Set([ + // Existing API routes + 'new', + 'health', + 'docs', + 'mcp', + 'resolve', + + // Existing web routes + '_components', + + // Planned / reserved + 'api', + 'oauth', + 'admin', + 'settings', + 'audit', + 'webhooks', + 'login', + 'signup', + 'logout', + 'callback', + 'profile', + + // Infrastructure + 'www', + 'mail', + 'ftp', + 'cdn', + 'static', + 'assets', + + // Generic reserved + 'pagent', + 'support', + 'help', + 'about', + 'blog', + 'status', + 'pricing', + 'terms', + 'privacy', +]); +``` + +### 8.2 Hex ID disambiguation + +Handles are rejected if they match the 32-char hex pattern: + +```typescript +if (/^[a-f0-9]{32}$/.test(handle)) { + return c.json({ + error: 'bad_request', + message: 'Handle cannot be a 32-character hex string (conflicts with page IDs)', + }, 400); +} +``` + +This check is technically redundant with the 40-char max (a 32-char hex string +within the 3-40 range could match), so it's an explicit guard. The handle regex +already requires at least 3 characters, and a 32-char all-hex string like +`a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4` would pass the regex but must be rejected. + +### 8.3 Slug collision with API sub-paths + +Slugs are scoped under a handle (`/:handle/:slug`), so they cannot collide with +top-level API routes. However, if a user's handle is `resolve`, the path +`/resolve/foo` would be ambiguous. This is why `resolve` is in the reserved +handles list. + +### 8.4 Route matching order (API) + +``` +1. /health → health check +2. /new → create page +3. /docs → API reference +4. /openapi.json → OpenAPI spec +5. /openapi.yaml → OpenAPI spec +6. /mcp → MCP HTTP transport +7. /me/handle → handle registration +8. /me → user profile +9. /resolve/:handle/:slug → handle/slug resolution +10. /:id → page by hex ID +11. /:id/result → page result +``` + +Named routes are registered first. The `/:id` wildcard only matches valid +32-char hex strings (enforced by `pageIdSchema` validation inside the handler, +not by the route pattern). The `resolve` prefix ensures handle/slug resolution +never shadows the page-by-ID route. + +## 9. SEO and sharing considerations + +### 9.1 Canonical URLs + +Pages with slugs should declare their custom URL as canonical: + +```html + +``` + +For hex-ID-only pages, the canonical is the hex URL: + +```html + +``` + +The SPA shell (`index.html`) does not have a canonical tag. The renderer +injects it dynamically after resolving the page. Since pagent pages are rendered +client-side and search engines may not execute JavaScript, actual SEO indexing +is limited — but the canonical tag is still good practice for crawlers that do +render JS (Googlebot). + +### 9.2 Open Graph tags + +For link previews in Slack, Discord, Twitter, etc., the renderer should inject +OG tags. Since the SPA renders client-side, a server-side middleware or edge +function is needed for proper unfurling: + +**Phase 1 (this spec):** No server-side OG rendering. Link previews show the +generic pagent branding from `index.html`. This is acceptable for v2. + +**Phase 2 (future):** Add a Vercel edge middleware or a dedicated +`/og/:handle/:slug` endpoint that returns a minimal HTML page with OG meta tags +for crawlers (detected via User-Agent). The OG tags would include: + +```html + + + + +``` + +### 9.3 `robots.txt` + +Pagent pages are ephemeral (30-minute TTL by default). Indexing them would +create broken links. The existing `robots.txt` (or lack thereof) should +disallow crawling of page paths: + +``` +User-agent: * +Allow: / +Disallow: /*/ # handle/slug pages +``` + +However, as pagent evolves toward durable pages, this policy will change. For +now, no `robots.txt` changes are needed — the TTL naturally deters indexing. + +## 10. Migration plan + +### 10.1 Existing pages (pre-custom-URLs) + +All existing pages have `slug = NULL` and `owner_id = NULL`. They continue to +work exactly as before — addressed by hex ID, no resolution needed. + +### 10.2 Deployment sequence + +Because this feature depends on Auth, the deployment order is: + +``` +1. Auth ships (users table, authentication middleware, onboarding) +2. Run migration 001_custom_urls.sql (adds handle, slug, owner_id columns) +3. Deploy API with handle registration endpoint (PUT /me/handle) +4. Deploy API with slug support in POST /new + resolve endpoint +5. Deploy MCP with slug parameter in show_ui / show_html +6. Deploy web renderer with handle/slug routing +``` + +Steps 2-6 can ship as a single deploy since the columns are nullable and the +new code paths are additive (old clients that don't send `slug` still work). + +### 10.3 Backward compatibility guarantees + +| Scenario | Behavior | +|-------------------------------------------|-----------------------------------------| +| Old agent, no slug | Works as before, hex URL returned | +| Old agent, POST /new without slug | Works as before | +| New agent, slug provided | Custom URL returned if user has handle | +| Browser opens hex URL | Works as before | +| Browser opens custom URL | Resolves via API, then renders | +| Agent polls check_result with hex page_id | Works as before | +| Page expires | Slug freed, hex URL returns 404 (same) | + +### 10.4 Rollback plan + +If custom URLs need to be rolled back: + +1. Remove the `GET /resolve/:handle/:slug` route from the API. +2. Remove the slug parameter from `POST /new` schema (ignore if present). +3. Revert the web renderer routing to hex-only. +4. Leave the database columns in place (nullable, no harm). +5. Handle registration endpoint can remain (no harm, prep for retry). + +The migration is fully backward-compatible and the columns are nullable, so +rollback does not require a schema migration. + +## Appendix A: Full file change inventory + +| File | Change | +|-------------------------------|----------------------------------------------------------------| +| `apps/api/schemas.ts` | Add `handleSchema`, `slugSchema`, `RESERVED_HANDLES`; update `newPageBodySchema` with optional `slug` | +| `apps/api/db.ts` | Add `slug` and `owner_id` to `Page` type, `insertPage`, `getActivePage`; add `resolveHandleSlug()` query; boot migration for new columns | +| `apps/api/store.ts` | Add `SlugConflictError`; update `createPage` / `createHtmlPage` to accept slug + ownerId; build custom URL when both exist | +| `apps/api/app.ts` | Add `GET /resolve/:handle/:slug` route + handler; add `PUT /me/handle` + `GET /me` routes (Auth integration); update `newPageHandler` to extract slug | +| `apps/api/mcp/tools.ts` | Add `slug` to `show_ui` and `show_html` input schemas; update `PageOps` interface; update tool descriptions | +| `apps/api/mcp/http.ts` | Update `buildInProcessOps` to forward slug to store functions | +| `apps/mcp/server.ts` | Update `restOps.showUi` / `showHtml` to forward slug in REST body | +| `apps/web/main.ts` | New route parser (`parseRoute`); resolution fetch for handle/slug; pass resolved ID to existing page-load logic | +| `apps/web/vite.config.ts` | Add dev-proxy rules for `/resolve/...` and `/:handle/:slug` patterns | +| `docs/openapi.yaml` | Document `GET /resolve/:handle/:slug`, `PUT /me/handle`; update `POST /new` with `slug` field | +| `infra/` or migration script | `001_custom_urls.sql` — ALTER TABLE + CREATE INDEX statements | + +## Appendix B: Example end-to-end flow + +``` +1. User signs up via Google OAuth → users row created (id=uuid, email=alex@blockful.io) +2. Onboarding screen prompts for handle → user types "alex" +3. PUT /me/handle { "handle": "alex" } → 200 { "handle": "alex" } +4. Agent calls show_ui({ spec: [...], slug: "quarterly-review" }) +5. API creates page: + - id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" (random) + - slug = "quarterly-review" + - owner_id = +6. API returns: + { + "page_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "url": "https://pagent.link/alex/quarterly-review", + "expires_at": 1747500000000 + } +7. Agent prints URL to user +8. User opens https://pagent.link/alex/quarterly-review in browser +9. Web renderer: + a. parseRoute("/alex/quarterly-review") → { kind: 'handle-slug', handle: 'alex', slug: 'quarterly-review' } + b. fetch("https://api.pagent.link/resolve/alex/quarterly-review") + c. Response: { "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" } + d. fetch("https://api.pagent.link/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4") + e. Renders page as normal +10. User submits form → POST /:id/result (unchanged) +11. Agent polls check_result("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4") → gets result +``` + +## Appendix C: Open questions + +1. **Should slugs be mutable?** Currently, once a page is created with a slug, + the slug cannot be changed. This mirrors handle immutability. If we want + agents to update slugs on existing pages, we need an `UPDATE` endpoint. + Recommendation: defer to v3 alongside handle changes. + +2. **Should we support slug-only URLs?** e.g., `pagent.link/quarterly-review` + without a handle. This would require globally unique slugs (much harder + namespace). Recommendation: no. Always require `/:handle/:slug`. + +3. **Should expired pages redirect?** When a user opens a custom URL for an + expired page, should we show "this page existed but has expired" with the + author's handle? Recommendation: yes, but this is a UX polish item, not a + routing concern. The 404 page can include the handle if the resolution + endpoint returns it. + +4. **Rate limiting on resolve?** The resolution endpoint is read-only and cheap + (indexed lookup). It should be rate-limited to prevent enumeration of + handle/slug combinations. Recommendation: share the same rate limiter as + `GET /:id` (generous, since it's read-only). diff --git a/docs/superpowers/specs/2026-05-17-file-uploads-design.md b/docs/superpowers/specs/2026-05-17-file-uploads-design.md new file mode 100644 index 0000000..d5a3729 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-file-uploads-design.md @@ -0,0 +1,1053 @@ +# File Uploads — Design + +Status: draft, awaiting user review (2026-05-17). + +## 1. Overview and motivation + +Pagent pages today accept structured input (A2UI forms) and display +rich content (HTML). Neither path lets a user attach a file — a resume +PDF, an invoice screenshot, a CSV data dump — and hand it back to the +agent. + +This spec adds file upload support to A2UI forms. A new field type +`file` lets spec authors declare file inputs with accept filters, size +caps, and required/optional semantics. The user picks files in the +browser; the renderer uploads them to Supabase Storage; the agent +receives file metadata (and download URLs) alongside the form result. + +Three submission paths are supported: + +| Caller | How files arrive | +| -------------- | ------------------------------------------------------------------- | +| Browser user | `` in the renderer, multipart upload to the API | +| MCP agent | `submit_form` passes local file paths; the MCP server reads + uploads | +| REST agent | Two-step: `POST /:id/files` (upload) then `POST /:id/result` (submit with file_id refs) | + +Files share the page's TTL — when the page expires and the background +sweep deletes it, the sweep also removes all associated files from +Supabase Storage. No files outlive their page. + +### Non-goals + +- **File uploads on HTML pages.** HTML pages are view-only. They never + produce a result, so there is no submission pipeline to attach files + to. If a future format needs file uploads, it must define its own + result semantics. +- **Direct download from the browser.** The renderer does not need to + download uploaded files — only the agent reads them via signed URLs in + `GET /:id/result`. If a future requirement adds user-visible file + previews, that is a separate spec. +- **Virus/malware scanning.** V1 stores files as opaque blobs. The agent + is responsible for what it does with them. If abuse becomes a signal, + add ClamAV or a cloud scanning step in a follow-up. +- **Multi-page file references.** Files are scoped to a single page. + There is no mechanism to reference a file uploaded to page A in + page B's result. +- **Resumable uploads.** The 10 MB cap is small enough that retry-on- + failure is adequate. tus/resumable-upload is out of scope. + +--- + +## 2. Database schema + +A new `files` table tracks uploaded files. Each row maps a file to the +page and field that owns it. The `uploaded_by` column is nullable — +browser submissions are anonymous; future auth will populate it. + +```sql +-- Migration: add files table +CREATE TABLE IF NOT EXISTS files ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + page_id text NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + field_name text NOT NULL, + storage_path text NOT NULL, + original_name text NOT NULL, + mime_type text NOT NULL, + size_bytes integer NOT NULL CHECK (size_bytes > 0), + uploaded_by uuid, -- nullable; reserved for future auth + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS files_page_id_idx ON files (page_id); +``` + +### Design notes + +- **ON DELETE CASCADE**: when `deleteExpiredPages` deletes a `pages` row, + Postgres cascades to `files` automatically. The sweep still needs to + delete the blobs from Supabase Storage before deleting the DB rows + (see section 9). +- **`size_bytes` is integer** (max ~2 GB), not bigint. The enforced + per-file cap is 10 MB, so integer is sufficient and avoids JS + BigInt friction. +- **No unique constraint on `(page_id, field_name)`**: a single field + may accept multiple files in a future extension. V1 enforces + single-file-per-field at the API validation layer, not the schema. +- **`storage_path`** is the full Supabase Storage object path within the + `page-files` bucket (e.g., `abc123/resume/7f3a...b2c1.pdf`). + +### db.ts additions + +```typescript +export type FileRow = { + id: string; + page_id: string; + field_name: string; + storage_path: string; + original_name: string; + mime_type: string; + size_bytes: number; + uploaded_by: string | null; + created_at: Date; +}; + +export async function insertFile(f: Omit): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c` + INSERT INTO files (id, page_id, field_name, storage_path, original_name, mime_type, size_bytes, uploaded_by) + VALUES (${f.id}, ${f.page_id}, ${f.field_name}, ${f.storage_path}, ${f.original_name}, ${f.mime_type}, ${f.size_bytes}, ${f.uploaded_by}) + RETURNING * + `; + return rows[0]!; + }); +} + +export async function getFilesByPageId(pageId: string): Promise { + return withRetry(async () => { + const c = client(); + return c` + SELECT * FROM files WHERE page_id = ${pageId} ORDER BY created_at + `; + }); +} + +export async function getFileById(id: string): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c`SELECT * FROM files WHERE id = ${id}`; + return rows[0] ?? null; + }); +} + +/** + * Returns storage_paths for all files belonging to pages that have expired. + * Called by the sweep before deleting the DB rows (CASCADE handles the row + * deletion, but blobs must be removed from storage explicitly). + */ +export async function getExpiredFilesPaths(): Promise { + return withRetry(async () => { + const c = client(); + const rows = await c<{ storage_path: string }[]>` + SELECT f.storage_path + FROM files f + JOIN pages p ON f.page_id = p.id + WHERE p.expires_at <= now() + `; + return rows.map((r) => r.storage_path); + }); +} +``` + +The `init()` function in `db.ts` gains the `CREATE TABLE IF NOT EXISTS +files` migration (and the index), executed after the `pages` table +migration, identical to how the `format` column migration runs today. + +--- + +## 3. API endpoints + +### 3.1. `POST /:id/files` — Upload a file + +Uploads a single file for a specific page and field. The page must be +in state `open` and must have a `file` field in its spec matching the +provided `field_name`. + +**Request:** + +``` +POST /:id/files +Content-Type: multipart/form-data + +Parts: + field_name: "resume" (text part — the A2UI field name) + file: (file part) +``` + +**Success response (201):** + +```json +{ + "file_id": "7f3a0b1c-...-b2c1", + "field_name": "resume", + "original_name": "my-resume.pdf", + "mime_type": "application/pdf", + "size_bytes": 245760 +} +``` + +**Error responses:** + +| Status | Error code | When | +| ------ | ----------------------- | -------------------------------------------------------------------- | +| 400 | `bad_request` | Missing `field_name` or `file` part; `field_name` not in page spec | +| 400 | `invalid_field_type` | The named field exists but is not `type: "file"` | +| 400 | `invalid_for_format` | Page format is `html` (view-only, no uploads) | +| 400 | `file_too_large` | File exceeds the field's `maxSizeMB` (or the global 10 MB default) | +| 400 | `invalid_mime_type` | File MIME type does not match the field's `accept` filter | +| 400 | `file_already_uploaded` | A file was already uploaded for this field (V1: one file per field) | +| 404 | `not_found` | Page not found or expired | +| 409 | `conflict` | Page already submitted (state is not `open`) | +| 413 | `payload_too_large` | Wire body exceeds the multipart body limit (11 MB, see below) | +| 429 | `rate_limited` | Per-IP rate limit exceeded | +| 500 | `internal_error` | Storage or database failure | + +**Body limit:** The `bodyLimit` middleware for this route is set to +11 MB (10 MB file + 1 MB overhead for multipart boundaries and the +`field_name` text part). This is separate from the existing 1 MB cap +on `POST /new`. + +**Validation sequence:** + +1. Parse page ID from path; 404 if invalid or expired. +2. Verify page format is `a2ui`; 400 `invalid_for_format` if `html`. +3. Verify page state is `open`; 409 if already submitted. +4. Parse multipart body; extract `field_name` text part and `file` part. +5. Look up `field_name` in the page spec's component tree. +6. Verify the component is `type: "file"`; 400 `invalid_field_type` if not. +7. Check no file already exists for `(page_id, field_name)` in the + `files` table; 400 `file_already_uploaded` if so. +8. Validate file size against `maxSizeMB` from the field spec (default 10). +9. Validate MIME type against the field's `accept` list (if specified). +10. Upload to Supabase Storage at `{page_id}/{field_name}/{uuid}.{ext}`. +11. Insert row into `files` table. +12. Return 201 with file metadata. + +### 3.2. `POST /:id/result` — Submit with file references (updated) + +The existing `POST /:id/result` endpoint is updated to accept two +content types: + +1. **`application/json`** (existing) — Body is an A2UI action object. + If the page spec has `file` fields, the `context` object may contain + `file_id` references: + + ```json + { + "name": "submitted", + "surfaceId": "main", + "sourceComponentId": "submit-btn", + "context": { + "resume": { "__file_id": "7f3a0b1c-...-b2c1" }, + "cover_letter": "I am excited to apply..." + } + } + ``` + + The server validates that each `__file_id` reference points to a + file that exists in the `files` table and belongs to this page. + +2. **`multipart/form-data`** (new) — For browser submissions where the + file is uploaded inline with the form result. The renderer uses this + path. Parts: + + - `action`: JSON-encoded A2UI action (text part) + - `{field_name}`: file binary (file part, one per file field) + + The server uploads each file part to Supabase Storage, inserts rows + into the `files` table, and rewrites the action's context to include + `__file_id` references before storing the result. + +**Validation additions:** + +- For each file field in the page spec marked `required: true`, the + submission must include either an inline file or a valid `__file_id` + reference. If missing, return 400 `missing_required_file`. +- For each `__file_id` reference, verify the file belongs to this page. + If not, return 400 `invalid_file_reference`. + +### 3.3. `GET /:id/result` — Result with download URLs (updated) + +When the result contains file references, the response replaces +`__file_id` values with download metadata including a signed URL: + +```json +{ + "state": "submitted", + "format": "a2ui", + "result": { + "name": "submitted", + "surfaceId": "main", + "context": { + "resume": { + "file_id": "7f3a0b1c-...-b2c1", + "original_name": "my-resume.pdf", + "mime_type": "application/pdf", + "size_bytes": 245760, + "download_url": "https://xyz.supabase.co/storage/v1/object/sign/page-files/abc123/resume/7f3a...pdf?token=..." + }, + "cover_letter": "I am excited to apply..." + } + } +} +``` + +The signed URL is generated on-the-fly with a short expiry (60 minutes, +matching the remaining page TTL or a floor of 5 minutes, whichever is +greater). The agent must download the file within that window. + +**Implementation in `store.ts`:** + +A new `hydrateFileUrls(result, pageId)` function: +1. Walks the result's `context` object. +2. For each value that is an object with a `__file_id` key, looks up the + file in the `files` table. +3. Generates a Supabase Storage signed URL via `storage.from('page-files') + .createSignedUrl(path, expirySeconds)`. +4. Replaces the `__file_id` object with the full file metadata object + (including `download_url`). + +This function is called in `advanceResult()` before returning the result. + +--- + +## 4. Supabase Storage integration + +### 4.1. Bucket configuration + +Create a `page-files` bucket in Supabase Storage: + +```sql +-- Run once via Supabase dashboard or migration +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'page-files', + 'page-files', + false, -- private bucket; access via signed URLs only + 10485760, -- 10 MB per file + NULL -- MIME filtering done at app layer, not bucket layer +); +``` + +The bucket is **private** — no anonymous access. All reads go through +signed URLs generated by the API server using the service role key. + +### 4.2. Path structure + +``` +page-files/ + {page_id}/ + {field_name}/ + {uuid}.{ext} +``` + +Example: `page-files/c0f2ec161aac8b1a8d26222f45ca812d/resume/7f3a0b1c-d4e5-4f6a-b7c8-9d0e1f2a3b4c.pdf` + +- **page_id** groups all files for one page, making bulk deletion easy. +- **field_name** disambiguates when a form has multiple file fields. +- **uuid.ext** prevents name collisions and preserves the original + extension for MIME-type hinting by download clients. + +### 4.3. Signed URLs + +Generated via the Supabase JS client: + +```typescript +import { createClient } from '@supabase/supabase-js'; + +const supabase = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, +); + +async function createSignedUrl(storagePath: string, expirySeconds: number): Promise { + const { data, error } = await supabase.storage + .from('page-files') + .createSignedUrl(storagePath, expirySeconds); + if (error) throw new Error(`Failed to create signed URL: ${error.message}`); + return data.signedUrl; +} +``` + +Signed URL expiry is calculated as: + +```typescript +const remainingTtlSeconds = Math.floor((page.expiresAt - Date.now()) / 1000); +const expirySeconds = Math.max(remainingTtlSeconds, 300); // floor: 5 minutes +``` + +### 4.4. Upload + +```typescript +async function uploadFile( + storagePath: string, + fileBuffer: Buffer, + mimeType: string, +): Promise { + const { error } = await supabase.storage + .from('page-files') + .upload(storagePath, fileBuffer, { + contentType: mimeType, + upsert: false, // fail if path already exists (defensive) + }); + if (error) throw new Error(`Storage upload failed: ${error.message}`); +} +``` + +### 4.5. Bulk deletion + +```typescript +async function deletePageFiles(pageId: string): Promise { + const { data: files, error: listError } = await supabase.storage + .from('page-files') + .list(pageId, { limit: 1000 }); + + if (listError) throw new Error(`Storage list failed: ${listError.message}`); + if (!files || files.length === 0) return; + + // Supabase Storage .remove() accepts up to 1000 paths per call. + // For V1 with single-file-per-field, a page will have at most ~10 files. + // Collect all paths recursively (field_name subdirectories). + const paths = await collectAllPaths(pageId); + if (paths.length === 0) return; + + const { error: removeError } = await supabase.storage + .from('page-files') + .remove(paths); + + if (removeError) throw new Error(`Storage remove failed: ${removeError.message}`); +} +``` + +A simpler approach since Supabase Storage does not support recursive +directory deletion natively: use the `files` DB table to look up +`storage_path` values before the CASCADE delete runs. + +### 4.6. CORS + +Supabase Storage buckets inherit the project-level CORS configuration. +Ensure the Supabase project CORS settings include the renderer origin: + +```json +{ + "allowedOrigins": ["https://pagent.link", "http://localhost:5173"], + "allowedMethods": ["GET", "POST", "PUT", "DELETE"], + "allowedHeaders": ["*"], + "maxAge": 3600 +} +``` + +This is configured in the Supabase dashboard under Storage > Settings. +The API server (not the browser) performs all uploads and signed-URL +generation, so browser CORS is only needed if the renderer ever uploads +directly. V1 proxies everything through the API, so CORS on Storage is +a defense-in-depth setting rather than a functional requirement. + +--- + +## 5. A2UI spec extension — file field type + +### 5.1. Field schema + +A new component type `FileInput` is added to the A2UI spec vocabulary. +It is not part of the upstream basic catalog — it is a Pagent-specific +extension recognized by the Pagent renderer. + +```typescript +type FileInputComponent = { + id: string; + component: 'FileInput'; + /** Comma-separated list of accepted file extensions or MIME types. + * Maps directly to the HTML attribute. + * Examples: ".pdf,.png,.jpg", "image/*", "application/pdf" */ + accept?: string; + /** Maximum file size in megabytes. Default: 10. Max: 10. */ + maxSizeMB?: number; + /** Whether a file is required for form submission. Default: false. */ + required?: boolean; + /** Human-readable label displayed above the file input. */ + label?: string; + /** Help text displayed below the file input. */ + description?: string; +}; +``` + +### 5.2. Spec example + +```json +[ + { + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["title", "resume-upload", "submit-btn"] + }, + { + "id": "title", + "component": "Text", + "text": "Upload your resume", + "variant": "h2" + }, + { + "id": "resume-upload", + "component": "FileInput", + "accept": ".pdf,.doc,.docx", + "maxSizeMB": 5, + "required": true, + "label": "Resume", + "description": "PDF or Word document, up to 5 MB" + }, + { + "id": "submit-btn", + "component": "Button", + "child": "submit-btn-txt", + "variant": "primary", + "action": { + "event": { + "name": "submitted", + "context": { + "resume": { "path": "/resume-upload" } + } + } + } + }, + { + "id": "submit-btn-txt", + "component": "Text", + "text": "Submit" + } + ] + } + } +] +``` + +### 5.3. Validation in the API + +When `POST /:id/files` receives a file, the API must locate the +`FileInput` component in the page's stored spec to read its constraints. +This requires a lightweight spec walker: + +```typescript +function findFileComponent( + spec: unknown, + fieldName: string, +): FileInputComponent | null { + // Walk the updateComponents messages looking for a component + // with id === fieldName and component === 'FileInput'. + if (!Array.isArray(spec)) return null; + for (const msg of spec) { + const uc = (msg as Record)?.updateComponents; + if (!uc || typeof uc !== 'object') continue; + const components = (uc as Record).components; + if (!Array.isArray(components)) continue; + for (const comp of components) { + if ( + comp && + typeof comp === 'object' && + (comp as Record).id === fieldName && + (comp as Record).component === 'FileInput' + ) { + return comp as FileInputComponent; + } + } + } + return null; +} +``` + +--- + +## 6. Frontend renderer changes + +### 6.1. FileInput component + +The renderer (`apps/web/main.ts`) gains a new Lit component that +renders when it encounters a `FileInput` component type in the A2UI +surface tree. Since `FileInput` is not in the upstream basic catalog, +the renderer handles it as a Pagent-specific override. + +**New file: `apps/web/file-input.ts`** + +```typescript +import { LitElement, html, css } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +export class PagentFileInput extends LitElement { + @property() accept = ''; + @property({ type: Number }) maxSizeMB = 10; + @property({ type: Boolean }) required = false; + @property() label = ''; + @property() description = ''; + @property() fieldName = ''; + + @state() private selectedFile: File | null = null; + @state() private uploading = false; + @state() private uploadProgress = 0; + @state() private uploadedFileId: string | null = null; + @state() private error: string | null = null; + + static styles = css` + :host { display: block; } + /* ... shadcn-aligned styles ... */ + `; + + render() { + return html` + ${this.label ? html`` : ''} +
+ + ${this.selectedFile ? this.renderFilePreview() : this.renderPlaceholder()} +
+ ${this.uploading ? html`` : ''} + ${this.error ? html`
${this.error}
` : ''} + ${this.description ? html`

${this.description}

` : ''} + `; + } + + private renderPlaceholder() { + return html`

Drag a file here or click to select

`; + } + + private renderFilePreview() { + const f = this.selectedFile!; + const sizeKB = (f.size / 1024).toFixed(1); + return html` +
+ ${f.name} + ${sizeKB} KB + ${this.uploadedFileId + ? html`Uploaded` + : ''} + +
+ `; + } + + // ... event handlers: onFileSelected, onDrop, onDragOver, removeFile, upload +} + +customElements.define('pagent-file-input', PagentFileInput); +``` + +### 6.2. Upload flow + +When the user selects a file: + +1. **Client-side validation**: Check file size against `maxSizeMB` and + file extension/type against `accept`. Show an inline error if + invalid. Do not upload. +2. **Immediate upload**: On valid selection, upload the file to + `POST /${pageId}/files` with `field_name` in the multipart body. + Show a progress bar. +3. **Store file_id**: On success, store the returned `file_id` in the + component state. The submit button's action context will reference + it. + +### 6.3. Form submission integration + +The existing action handler in `main.ts` (the `MessageProcessor` +callback) is updated: + +- Before POSTing to `/:id/result`, scan the action's `context` for + any keys whose corresponding component is a `FileInput`. +- For each such key, replace the value with `{ "__file_id": fileId }` + where `fileId` came from the upload step. +- If a `FileInput` with `required: true` has no `uploadedFileId`, block + submission and show a validation error. + +### 6.4. Alternative: inline multipart submission + +Instead of pre-uploading via `POST /:id/files`, the renderer could +submit everything in one `multipart/form-data` POST to `/:id/result`. +This is simpler for the renderer but means the user sees no upload +progress until they hit Submit. The pre-upload approach (6.2) is +preferred because: + +- Upload progress is shown immediately on file selection. +- If the upload fails, the user can retry before submitting. +- The submit action stays a fast JSON POST. + +Both paths are supported by the API (section 3.2), but the renderer +uses the pre-upload path by default. + +--- + +## 7. MCP tool changes + +### 7.1. `show_ui` — no changes + +The `show_ui` tool already accepts arbitrary A2UI specs. A spec +containing `FileInput` components works without tool changes — the +renderer handles them. + +### 7.2. `submit_form` — file path support (stdio MCP only) + +The stdio MCP server (`apps/mcp/server.ts`) gains awareness of file +fields. When the agent calls `submit_form` (a hypothetical future tool) +or when the agent builds a result with file paths, the MCP server: + +1. Reads the file from the local filesystem via `fs.readFile(path)`. +2. Uploads it to `POST /${pageId}/files` with the appropriate + `field_name`. +3. Receives the `file_id`. +4. Includes `{ "__file_id": fileId }` in the result context. +5. Submits the result via `POST /${pageId}/result`. + +This is a convenience for agents that run locally alongside the MCP +server. Agents using the HTTP MCP transport or the REST API must use +the two-step upload flow directly. + +### 7.3. Tool description updates + +The `SHOW_UI_DESCRIPTION` and `SHOW_UI_INPUT_DESCRIPTION` strings in +`apps/api/mcp/tools.ts` are updated to mention the `FileInput` +component type: + +``` +The basic catalog provides Column, Row, Card, Text, TextField, Button, +Checkbox, Image, Divider, List, Tabs, Slider. Additionally, Pagent +supports a FileInput component for file uploads: + { id: "upload", component: "FileInput", accept: ".pdf,.png", maxSizeMB: 5, required: true, label: "Upload file" } +``` + +### 7.4. `check_result` — no changes + +The `check_result` tool returns the result as-is. File fields in the +result will contain the hydrated file metadata (with `download_url`) +from section 3.3. The agent reads the `download_url` and fetches the +file via a standard HTTP GET. + +--- + +## 8. File validation + +### 8.1. MIME type checking + +MIME type validation happens at two levels: + +1. **Extension-based (client-side)**: The `` + attribute filters the file picker. This is a UX convenience, not a + security boundary. + +2. **Content-based (server-side)**: The API inspects the file's actual + content to determine the MIME type, rather than trusting the + `Content-Type` header from the multipart upload. Use the `file-type` + npm package for magic-number-based detection: + + ```typescript + import { fileTypeFromBuffer } from 'file-type'; + + async function detectMimeType(buffer: Buffer): Promise { + const detected = await fileTypeFromBuffer(buffer); + return detected?.mime ?? 'application/octet-stream'; + } + ``` + + If the field spec has an `accept` list, the server validates the + detected MIME type against it. The matching logic handles: + + - Exact MIME types: `application/pdf` matches `application/pdf`. + - Wildcard MIME types: `image/*` matches `image/png`, `image/jpeg`, + etc. + - Extensions: `.pdf` maps to `application/pdf` via a lookup table. + +### 8.2. Size limits + +| Limit | Value | Enforced by | +| ------------------ | -------- | ------------------------------------------- | +| Per-file default | 10 MB | API validation + Supabase bucket config | +| Per-file custom | ≤ 10 MB | `maxSizeMB` in the `FileInput` spec | +| Multipart body cap | 11 MB | `bodyLimit` middleware on `POST /:id/files` | +| Supabase bucket | 10 MB | `file_size_limit` on `page-files` bucket | + +The `maxSizeMB` field in the spec must be between 0 (exclusive) and 10 +(inclusive). Values above 10 are clamped to 10 at validation time with +a warning log. + +### 8.3. Malware considerations + +V1 does not scan uploaded files for malware. Mitigations: + +- Files are stored in a private Supabase Storage bucket. No public URLs + exist. Access requires a signed URL generated by the API. +- Signed URLs are short-lived (capped to page TTL). +- Files are auto-deleted when the page expires (default 30 minutes). +- The `Content-Disposition: attachment` header is set on signed URLs to + prevent in-browser rendering of uploaded files. + +Future consideration: integrate ClamAV via a sidecar container or use +Supabase's built-in virus scanning if it becomes available. + +### 8.4. Filename sanitization + +The `original_name` stored in the database is sanitized: + +- Strip path separators (`/`, `\`) — prevent directory traversal. +- Limit to 255 characters. +- Remove null bytes. +- Preserve the original extension for display purposes. + +The actual storage path uses a UUID, so the original filename is purely +metadata — it never appears in a filesystem path. + +--- + +## 9. Cleanup logic — TTL-based deletion + +### 9.1. Updated sweep in `server.ts` + +The existing `deleteExpiredPages` sweep (60s interval in `server.ts`) is +extended: + +```typescript +const sweepTimer = setInterval(async () => { + try { + // Step 1: Collect storage paths for files belonging to expired pages. + const expiredPaths = await db.getExpiredFilesPaths(); + + // Step 2: Delete blobs from Supabase Storage (before CASCADE deletes DB rows). + if (expiredPaths.length > 0) { + await storage.deleteFiles(expiredPaths); + logger.debug({ count: expiredPaths.length }, 'ttl sweep removed expired file blobs'); + } + + // Step 3: Delete expired page rows (CASCADE removes file rows too). + const { total, abandoned } = await db.deleteExpiredPages(); + if (abandoned > 0) metrics.pagesAbandoned.add(abandoned); + if (total > 0) logger.debug({ total, abandoned }, 'ttl sweep removed expired pages'); + } catch (err) { + logger.error({ err }, 'ttl sweep failed'); + } +}, 60_000); +``` + +### 9.2. Storage deletion helper + +```typescript +// apps/api/storage.ts + +import { createClient } from '@supabase/supabase-js'; +import { env } from './schemas.ts'; + +const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +/** + * Delete files from the page-files bucket. Supabase Storage .remove() + * accepts up to 1000 paths per call. Batch if needed. + */ +export async function deleteFiles(paths: string[]): Promise { + const BATCH_SIZE = 1000; + for (let i = 0; i < paths.length; i += BATCH_SIZE) { + const batch = paths.slice(i, i + BATCH_SIZE); + const { error } = await supabase.storage + .from('page-files') + .remove(batch); + if (error) { + // Log but don't throw — orphaned blobs are less harmful than + // failing the entire sweep. Supabase lifecycle policies can + // clean them up later. + console.error(`Storage delete batch failed: ${error.message}`); + } + } +} + +export async function uploadFile( + storagePath: string, + fileBuffer: Buffer, + mimeType: string, +): Promise { + const { error } = await supabase.storage + .from('page-files') + .upload(storagePath, fileBuffer, { + contentType: mimeType, + upsert: false, + }); + if (error) throw new Error(`Storage upload failed: ${error.message}`); +} + +export async function createSignedUrl( + storagePath: string, + expirySeconds: number, +): Promise { + const { data, error } = await supabase.storage + .from('page-files') + .createSignedUrl(storagePath, expirySeconds); + if (error) throw new Error(`Failed to create signed URL: ${error.message}`); + return data.signedUrl; +} +``` + +### 9.3. Orphan protection + +If the storage delete fails but the DB delete succeeds (CASCADE), the +blobs become orphans. Mitigations: + +- **Log the failure**: the sweep logs storage errors so operators see + them in Grafana. +- **Supabase lifecycle policy**: configure the `page-files` bucket with + a lifecycle rule that auto-deletes objects older than 24 hours. This + catches orphans that the sweep missed. +- **Metric**: add `pagent.files.orphaned` counter, incremented when a + storage delete batch fails. + +### 9.4. Ordering guarantee + +The sweep MUST delete storage blobs BEFORE deleting DB rows. If it +deleted DB rows first, the CASCADE would remove `files` records and +the sweep would lose the `storage_path` values needed to clean up +blobs. The sequence is: + +1. Query `getExpiredFilesPaths()` — reads paths from `files` JOIN + `pages` WHERE `expires_at <= now()`. +2. Delete blobs from Supabase Storage. +3. Delete expired `pages` rows (CASCADE deletes `files` rows). + +--- + +## 10. Environment variables + +New variables added to `apps/api/schemas.ts` `envSchema`: + +| Variable | Required | Default | Description | +| ------------------------------ | -------- | ------- | ---------------------------------------------- | +| `SUPABASE_URL` | Yes | — | Supabase project URL (e.g., `https://xyz.supabase.co`) | +| `SUPABASE_SERVICE_ROLE_KEY` | Yes | — | Supabase service role key (secret, not anon) | +| `FILE_MAX_SIZE_MB` | No | `10` | Global per-file size cap in MB | + +```typescript +// Addition to envSchema in schemas.ts +SUPABASE_URL: z.string().url(), +SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), +FILE_MAX_SIZE_MB: z.coerce.number().int().positive().max(50).default(10), +``` + +The `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are required in all +environments (dev, production). In development, they point to a local +Supabase instance or a dev project. + +### Railway environment + +Add both variables to the Railway service environment. The service role +key must be added as a secret (not a shared variable). + +### Local development + +Add to `apps/api/.env`: + +``` +SUPABASE_URL=http://localhost:54321 +SUPABASE_SERVICE_ROLE_KEY= +``` + +--- + +## 11. Dependencies + +### 11.1. `apps/api` — new dependencies + +```bash +npm install -w @pagent/api @supabase/supabase-js file-type +``` + +| Package | Version | Purpose | +| -------------------- | -------- | ---------------------------------------------------- | +| `@supabase/supabase-js` | `^2.49` | Supabase Storage client (upload, signed URLs, delete) | +| `file-type` | `^19.6` | Magic-number MIME type detection from file buffers | + +### 11.2. `apps/web` — no new dependencies + +The `FileInput` Lit component uses only `lit` (already a dependency). +No additional npm packages are needed for the renderer. + +### 11.3. `apps/mcp` — no new dependencies + +The stdio MCP server uses `node:fs` (built-in) for reading local files +and the existing `fetch` API for uploading them to the API. No new +packages needed. + +### 11.4. Hono multipart parsing + +Hono has built-in multipart/form-data support via `c.req.parseBody()`. +No additional package is needed. For the `POST /:id/files` route, the +handler uses: + +```typescript +const body = await c.req.parseBody({ all: true }); +const fieldName = body['field_name']; // string +const file = body['file']; // File object +``` + +The `bodyLimit` middleware for this specific route is set to 11 MB: + +```typescript +app.post( + '/:id/files', + bodyLimit({ maxSize: 11 * 1024 * 1024 }), + uploadFileHandler, +); +``` + +--- + +## Appendix A: Migration checklist + +1. [ ] Add `files` table migration to `db.ts` `init()`. +2. [ ] Add `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` to `envSchema`. +3. [ ] Create `page-files` bucket in Supabase Storage. +4. [ ] Install `@supabase/supabase-js` and `file-type` in `apps/api`. +5. [ ] Create `apps/api/storage.ts` with upload/delete/signedUrl helpers. +6. [ ] Add `POST /:id/files` route to `app.ts`. +7. [ ] Update `POST /:id/result` to handle multipart and `__file_id` refs. +8. [ ] Update `GET /:id/result` to hydrate file URLs via `hydrateFileUrls`. +9. [ ] Update sweep in `server.ts` to delete storage blobs before DB rows. +10. [ ] Create `apps/web/file-input.ts` Lit component. +11. [ ] Update renderer action handler to include file references in context. +12. [ ] Update MCP tool descriptions to mention `FileInput`. +13. [ ] Add `FILE_MAX_SIZE_MB` to Railway environment. +14. [ ] Add metrics: `pagent.files.uploaded`, `pagent.files.orphaned`. +15. [ ] Update `docs/openapi.yaml` with new endpoint and updated schemas. +16. [ ] Write tests for file upload, validation, result hydration, and sweep. + +## Appendix B: Metrics additions + +```typescript +// In metrics.ts +filesUploaded: meter.createCounter('pagent.files.uploaded', { + description: 'Files uploaded via POST /:id/files', +}), +filesOrphaned: meter.createCounter('pagent.files.orphaned', { + description: 'File blobs that failed to delete from storage during sweep', +}), +fileUploadSize: meter.createHistogram('pagent.files.upload.size', { + description: 'Size of uploaded files in bytes', + unit: 'bytes', +}), +``` + +## Appendix C: Security model + +| Layer | What it does | +| ------------ | -------------------------------------------------------- | +| Body limit | 11 MB cap on multipart uploads (Hono middleware) | +| MIME check | Magic-number detection via `file-type`; reject mismatches | +| Bucket limit | 10 MB per file enforced by Supabase Storage config | +| Private bucket | No public URLs; signed URLs only | +| Signed URL expiry | Capped to page TTL (min 5 min) | +| Content-Disposition | `attachment` — prevents in-browser rendering | +| TTL sweep | Files auto-deleted with their page (default 30 min) | +| Path sanitization | UUID-based storage paths; original name is metadata only | +| Filename sanitization | Strip path separators, null bytes, cap at 255 chars | diff --git a/docs/superpowers/specs/2026-05-17-public-forms-design.md b/docs/superpowers/specs/2026-05-17-public-forms-design.md new file mode 100644 index 0000000..3e435c4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-public-forms-design.md @@ -0,0 +1,1042 @@ +# Public Forms — Design + +Status: draft, awaiting user review (2026-05-17). + +## Goal + +Add a **public** page mode alongside the existing single-shot page mode. +Public pages accept multiple submissions from multiple users — surveys, +intake forms, bug-report collectors, polls — rather than the current +one-spec-one-result model. + +The agent gets a new `mode` parameter on `show_ui`: + +- `show_ui({ spec, mode: "single" })` — current behavior. One + submission, state machine walks `open → submitted → received`. Default + when `mode` is omitted. +- `show_ui({ spec, mode: "public" })` — multi-submission. The page + stays `open` until the owner explicitly closes it. Each browser + submission creates a row in a new `submissions` table. The agent reads + all submissions via `check_result`. + +## Why now + +The product roadmap (v2) lists public forms as the first major feature +after auth. Surveys, intake forms, and multi-user polls are the +most-requested capability gap: the current single-shot model forces the +agent to emit N separate pages to collect N responses, with no shared +URL. Public forms close that gap with a single shareable URL. + +## Non-goals + +- **Branching / conditional logic in forms.** The spec is static; the + renderer does not evaluate visibility rules. Agents that need + conditional follow-ups should emit a second page. +- **Editing a submitted response.** Once a submission row is written, it + is immutable. The user can submit again (producing a new row). +- **Anonymous submissions for authenticated pages.** If `access_emails` + is set, every submitter must authenticate. There is no "allow some + anonymous" escape hatch. +- **Real-time submission streaming to the agent.** The agent polls + `check_result`; there is no WebSocket/SSE push channel in V1. +- **Custom close messages.** The "this form is no longer accepting + responses" copy is hard-coded. Custom close-page content is deferred. +- **Scheduled auto-close.** The agent can set a shorter TTL, but there + is no `close_at` timestamp that fires automatically. The agent (or a + cron) must call `POST /:id/close`. + +--- + +## Database schema + +### New table: `submissions` + +```sql +create table submissions ( + id uuid primary key default gen_random_uuid(), + page_id text not null references pages(id) on delete cascade, + submitted_by uuid references users(id), -- nullable for unauthenticated pages + result jsonb not null, + submitted_at timestamptz not null default now() +); + +create index submissions_page_id_idx on submissions (page_id, submitted_at); +``` + +**Design notes:** + +- `id` is a UUID (not the 32-hex-char scheme pages use) because + submissions are not URL-addressable — they only appear inside + `check_result` response arrays. +- `on delete cascade` ensures TTL sweeps on `pages` automatically clean + up child submissions without a second query. +- `submitted_by` is nullable: unauthenticated public pages (no + `access_emails` set) allow anonymous submissions. When auth is + present, this column stores the authenticated user's UUID. +- The composite index `(page_id, submitted_at)` supports the primary + query pattern: "all submissions for page X, ordered by time." + +### Alter table: `pages` + +```sql +-- 1. Add mode column +alter table pages + add column if not exists mode text + not null default 'single' + check (mode in ('single', 'public')); + +-- 2. Expand state CHECK to include 'closed' +-- Postgres doesn't support ALTER CHECK directly; drop + re-add. +alter table pages drop constraint if exists pages_state_check; +alter table pages + add constraint pages_state_check + check (state in ('open', 'submitted', 'received', 'closed')); + +-- 3. Add access control column +alter table pages + add column if not exists access_emails text[]; + +-- 4. Add closed_at timestamp +alter table pages + add column if not exists closed_at timestamptz; + +-- 5. Add owner_id for close authorization +alter table pages + add column if not exists owner_id uuid references users(id) on delete set null; + +-- 6. Add max_submissions cap (default 10 000) +alter table pages + add column if not exists max_submissions integer not null default 10000; +``` + +**Column semantics:** + +| Column | Type | Default | Description | +|-----------------|------------|------------|-------------| +| `mode` | `text` | `'single'` | `'single'` = current one-shot behavior. `'public'` = multi-submission. | +| `state` | `text` | — | Now allows `'closed'` in addition to `'open'`, `'submitted'`, `'received'`. | +| `access_emails` | `text[]` | `NULL` | Email allowlist. `NULL` = open to anyone. Non-null = only listed emails may view and submit. | +| `closed_at` | `timestamptz` | `NULL` | Set when owner calls `POST /:id/close`. | +| `owner_id` | `uuid` | `NULL` | The user who created the page. `REFERENCES users(id) ON DELETE SET NULL`. Required for close authorization. NULL for legacy pages. | +| `max_submissions` | `integer` | `10000` | Maximum number of submissions allowed. `POST /:id/result` returns 409 when this cap is reached. Configurable via `show_ui`. | + +--- + +## State machines + +### Single mode (unchanged) + +``` + browser submits agent reads result + ┌───────┐ POST /:id/result ┌───────────┐ GET /:id/result ┌──────────┐ + │ open │ ──────────────────▶│ submitted │ ────────────────▶│ received │ + └───────┘ └───────────┘ └──────────┘ + │ │ + │ TTL expires TTL expires │ + ▼ ▼ + [deleted] [deleted] +``` + +When `mode = 'single'`, the existing logic applies without change. +Additionally, for forward-compatibility, a submission row is written to +`submissions` alongside setting `pages.result` so that both the inline +result and the normalized table agree. + +### Public mode (new) + +``` + browser submits (repeatable) + ┌──────┐ POST /:id/result ┌──────┐ + │ open │ ────────────────────▶│ open │ (state does NOT change) + └──────┘ INSERT submissions └──────┘ + │ │ + │ owner calls │ TTL expires + │ POST /:id/close │ + ▼ ▼ + ┌────────┐ [deleted] + │ closed │ + └────────┘ + │ + │ TTL expires + ▼ + [deleted] +``` + +Key differences from single mode: + +1. **State stays `open`.** `POST /:id/result` does NOT transition the + page to `submitted`. Each submission is an INSERT into `submissions`; + the page remains accepting new entries. +2. **No `submitted` or `received` states.** Public pages transition only + from `open` to `closed` (or expire). +3. **`check_result` does not advance state.** For public pages the agent + reads all submissions without side effects. The `received` state is + meaningless for public pages. +4. **Owner-initiated close.** `POST /:id/close` is the only way to stop + accepting submissions (besides TTL expiry). + +--- + +## API endpoints + +### `POST /new` — create page (modified) + +**Request body changes:** + +```jsonc +{ + "format": "a2ui", // unchanged + "spec": [ ... ], // unchanged + "mode": "single", // NEW — "single" (default) | "public" + "access_emails": [ // NEW — optional email allowlist + "alice@example.com", + "bob@example.com" + ] +} +``` + +**Zod schema update** (`schemas.ts`): + +```typescript +export const newPageBodySchema = z.union([ + z.object({ + format: z.literal('a2ui').optional().default('a2ui'), + spec: z.unknown(), + mode: z.enum(['single', 'public']).optional().default('single'), + access_emails: z.array(z.string().email()).optional(), + }).refine((b) => 'spec' in b, { message: "missing 'spec'" }), + z.object({ + format: z.literal('html'), + spec: z.string().min(1).max(HTML_MAX_BYTES), + // HTML pages are always single/view-only; mode and access_emails + // are not accepted. Passing them is a 400. + }), +]); +``` + +**Validation rules:** + +- `mode: "public"` is only valid with `format: "a2ui"`. HTML pages are + view-only and cannot accept submissions. Reject with 400 + `invalid_for_format` if `mode: "public"` + `format: "html"`. +- `access_emails` is accepted on both modes but is more useful on public + pages. On single pages it restricts who can view and submit. + +**Response body:** unchanged (`{ id, url, expires_at }`). + +**Side effects:** + +- `pages.mode` is set to the requested value. +- `pages.access_emails` is set if provided. +- `pages.owner_id` is set to the authenticated user's ID (from the + auth header). NULL if unauthenticated. + +--- + +### `POST /:id/result` — submit (modified) + +**Single mode:** unchanged. Atomic `open → submitted` transition, +writes `pages.result`, writes a `submissions` row (new, for +consistency), returns `{ ok: true }`. + +**Public mode:** + +1. Verify page exists, is not expired, and `state = 'open'`. + - If `state = 'closed'`: return 409 + `{ error: "closed", message: "This form is no longer accepting responses" }`. +2. If `access_emails` is non-null, verify the submitter's email is in + the list. Return 403 if not. +3. INSERT into `submissions` (page_id, submitted_by, result). +4. Do NOT update `pages.state` or `pages.result`. +5. Fire webhook (see [Webhook interaction](#webhook-interaction)). +6. Return `{ ok: true, submission_id: "" }`. + +**Request body:** unchanged (`ResultRequest` schema). + +**New response field for public mode:** + +```jsonc +{ + "ok": true, + "submission_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" +} +``` + +The `submission_id` is returned only for public pages. Single-mode +pages continue returning `{ ok: true }` for backward compatibility. + +**New error responses:** + +| Status | Error code | When | +|--------|-----------|------| +| 403 | `access_denied` | Submitter's email not in `access_emails` allowlist | +| 409 | `closed` | Page is in state `closed` | + +--- + +### `GET /:id/result` — poll for result (modified) + +**Single mode:** unchanged. Returns `{ state, result, format }`. +First read after submission atomically flips `submitted → received`. + +**Public mode:** returns all submissions as a paginated array. + +**Response shape (public mode):** + +```jsonc +{ + "state": "open", // or "closed" + "mode": "public", + "format": "a2ui", + "submissions": [ + { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "submitted_by": "user-uuid-or-null", + "result": { "name": "submitted", "surfaceId": "main", "context": { ... } }, + "submitted_at": "2026-05-17T12:34:56.789Z" + } + // ... + ], + "total": 42, + "cursor": "2026-05-17T12:34:56.789Z" // null if no more pages +} +``` + +**Query parameters (public mode only):** + +| Param | Type | Default | Description | +|----------|--------|---------|-------------| +| `limit` | int | 50 | Max submissions per response. Capped at 200. | +| `cursor` | string | — | ISO 8601 timestamp. Returns submissions with `submitted_at > cursor`. | +| `after` | string | — | Alias for `cursor` (convenience for agents). | + +**Pagination strategy:** cursor-based on `submitted_at`. The response +includes a `cursor` field set to the `submitted_at` of the last +submission in the current page. The agent passes this as `?cursor=` on +the next request to get the next page. `cursor` is `null` when there +are no more submissions. + +**Why cursor, not offset:** offset pagination breaks when new +submissions arrive between polls (rows shift). Cursor pagination is +stable under concurrent inserts. + +**State behavior:** `GET /:id/result` does NOT advance state for +public pages. There is no `submitted → received` transition. The agent +can read submissions as many times as needed. + +**`check_result` MCP tool behavior:** for public pages, the tool +returns the full paginated response. The model sees an array of +submissions rather than a single result object. + +--- + +### `POST /:id/close` — close page (new) + +Stops a public page from accepting further submissions. Sets +`state = 'closed'` and `closed_at = now()`. + +**Authorization:** only the page owner may close a page. The request +must include a valid auth token whose user ID matches +`pages.owner_id`. Returns 403 if the caller is not the owner. + +**Request:** + +```http +POST /:id/close +Authorization: Bearer +``` + +No request body required. + +**Response (success):** + +```jsonc +// 200 OK +{ + "ok": true, + "state": "closed", + "closed_at": "2026-05-17T15:00:00.000Z" +} +``` + +**Error responses:** + +| Status | Error code | When | +|--------|-----------|------| +| 400 | `invalid_mode` | Page is `mode: 'single'` (single-mode pages use the existing state machine; close is meaningless) | +| 403 | `forbidden` | Caller is not the page owner | +| 404 | `not_found` | Page not found or expired | +| 409 | `already_closed` | Page is already in state `closed` | + +**Idempotency:** calling close on an already-closed page returns 409 +rather than silently succeeding. The agent can check the error code and +treat it as a no-op. + +**Effect on submissions:** existing submissions are preserved. The page +remains readable via `GET /:id` and `GET /:id/result`. Only new +submissions are rejected. + +--- + +### `GET /:id` — get page (modified) + +**Response body gains new fields:** + +```jsonc +{ + "spec": [ ... ], + "format": "a2ui", + "state": "open", // now includes "closed" as a possible value + "mode": "single", // NEW — "single" | "public" + "result": null, // null for public pages (use GET /:id/result) + "expires_at": 1709122000000, + "access_emails": null // NEW — null or string[] +} +``` + +The frontend uses `mode` to decide whether to show the "already +submitted" lock-out (single) or the "submit another" flow (public), +and whether to show the "closed" message. + +--- + +## DB layer changes (`db.ts`) + +### New types + +```typescript +export type PageMode = 'single' | 'public'; + +// Extend PageState to include 'closed' +export type PageState = 'open' | 'submitted' | 'received' | 'closed'; + +// Extend Page to include new columns +export type Page = { + id: string; + spec: unknown; + format: PageFormat; + mode: PageMode; + state: PageState; + result: unknown; + createdAt: number; + expiresAt: number; + accessEmails: string[] | null; + ownerId: string | null; +}; + +export type Submission = { + id: string; + pageId: string; + submittedBy: string | null; + result: unknown; + submittedAt: Date; +}; +``` + +### New functions + +```typescript +/** Insert a submission for a public page. Returns the submission ID. */ +export async function insertSubmission( + pageId: string, + result: unknown, + submittedBy: string | null, +): Promise<{ id: string; submittedAt: Date }>; + +/** Fetch paginated submissions for a page. */ +export async function getSubmissions( + pageId: string, + opts: { limit: number; cursor?: string }, +): Promise<{ submissions: Submission[]; total: number }>; + +/** Close a public page. Returns 'ok' | 'not_found' | 'not_owner' | 'already_closed' | 'wrong_mode'. */ +export async function closePage( + pageId: string, + callerId: string, +): Promise<{ kind: 'ok'; closedAt: Date } | { kind: 'not_found' | 'not_owner' | 'already_closed' | 'wrong_mode' }>; +``` + +### Modified functions + +**`submitPage`** — branching on mode: + +```typescript +export async function submitPage( + id: string, + action: unknown, + submittedBy?: string | null, +): Promise { + const page = await getActivePage(id); + if (!page) return { kind: 'not_found' }; + + if (page.mode === 'public') { + if (page.state === 'closed') return { kind: 'closed' }; + // Insert submission row, do NOT change page state + const sub = await insertSubmission(id, action, submittedBy ?? null); + return { kind: 'ok', createdAt: page.createdAt, submissionId: sub.id }; + } + + // Single mode: existing atomic open→submitted transition + // Also insert a submissions row for consistency + // ... existing logic ... +} +``` + +**`SubmitOutcome`** — extended: + +```typescript +export type SubmitOutcome = + | { kind: 'ok'; createdAt: Date; submissionId?: string } + | { kind: 'conflict' } + | { kind: 'closed' } + | { kind: 'not_found' }; +``` + +**`fetchAndAdvanceResult`** — branching on mode: + +For public pages, this function does NOT advance state. It returns the +page state and defers to `getSubmissions` for the actual data. + +**`insertPage`** — accepts `mode`, `accessEmails`, `ownerId`: + +```typescript +export async function insertPage(p: Page): Promise { + await withRetry(async () => { + const c = client(); + await c`insert into pages (id, spec, format, mode, state, expires_at, access_emails, owner_id) + values ( + ${p.id}, + ${c.json(p.spec)}, + ${p.format}, + ${p.mode}, + 'open', + to_timestamp(${p.expiresAt} / 1000.0), + ${p.accessEmails}, + ${p.ownerId} + )`; + }); +} +``` + +**`deleteExpiredPages`** — unchanged. The `on delete cascade` on +`submissions.page_id` ensures child rows are cleaned up automatically. +Public pages in state `open` count as abandoned, same as single-mode. + +--- + +## MCP tool changes + +### `show_ui` — new parameters + +```typescript +server.registerTool('show_ui', { + title: 'Show UI to the user', + description: SHOW_UI_DESCRIPTION, // updated, see below + inputSchema: { + spec: z.array(z.record(z.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION), + mode: z.enum(['single', 'public']).optional().default('single') + .describe( + 'Page mode. "single" (default): one submission, one result — the page locks after the first submit. ' + + '"public": multiple submissions from multiple users — the page stays open until you close it. ' + + 'Use "public" for surveys, polls, intake forms, or any form where you need responses from many people.' + ), + access_emails: z.array(z.string().email()).optional() + .describe( + 'Optional email allowlist. When set, only users with these email addresses can view and submit the form. ' + + 'Requires authentication. Omit to allow anyone with the link.' + ), + }, +}, async ({ spec, mode, access_emails }) => { + const created = await ops.showUi(spec, { mode, accessEmails: access_emails }); + // ... return content ... +}); +``` + +**Updated `SHOW_UI_DESCRIPTION`** (additions in bold): + +> Ask the user a question that needs a structured answer back. Forms, +> pickers, confirmations, multi-step wizards, surveys, dashboards-as-input. +> +> Returns { page_id, url, expires_at }. PRINT the URL so the user can +> open it. The agent never sees the user typing — only the final +> submitted result. +> +> **The `mode` parameter controls how many submissions the page accepts. +> Default "single": one spec, one result — the page locks after the +> first submit. Use "public" for surveys, polls, or intake forms that +> need responses from multiple people. Public pages stay open until you +> close them with `POST /:id/close` or they expire.** +> +> After this call, poll check_result on your own cadence to read the +> user response (start at 2-3s, back off exponentially up to ~30s; do +> other useful work between polls rather than blocking). **For public +> pages, check_result returns an array of all submissions so far.** + +### `check_result` — response shape change + +**Single mode:** unchanged. + +```jsonc +{ + "state": "submitted", + "result": { "name": "submitted", ... }, + "format": "a2ui", + "page_id": "abc123..." +} +``` + +**Public mode:** + +```jsonc +{ + "state": "open", + "mode": "public", + "format": "a2ui", + "page_id": "abc123...", + "submissions": [ + { + "id": "uuid", + "result": { ... }, + "submitted_by": "user-uuid-or-null", + "submitted_at": "2026-05-17T12:34:56.789Z" + } + ], + "total": 42, + "cursor": "2026-05-17T12:34:56.789Z" +} +``` + +**Updated tool handler:** + +```typescript +async ({ page_id }) => { + const outcome = await ops.checkResult(page_id); + if (outcome.kind === 'not_found') { + throw new Error(`Page ${page_id} not found ...`); + } + + if (outcome.mode === 'public') { + const subText = outcome.submissions.length === 0 + ? `No submissions yet (state: ${outcome.state}). Call check_result again in a few seconds.` + : `${outcome.total} total submission(s). Latest batch:\n${JSON.stringify(outcome.submissions)}`; + + return { + content: [{ type: 'text', text: subText }], + structuredContent: { + state: outcome.state, + mode: 'public', + format: outcome.format, + page_id, + submissions: outcome.submissions, + total: outcome.total, + cursor: outcome.cursor, + }, + }; + } + + // Single mode: existing logic unchanged + // ... +} +``` + +**Updated `CHECK_RESULT_DESCRIPTION`:** + +> Fetch the current state of a page created by show_ui. Fire-and-return — does NOT block or wait. +> +> **For single-mode pages:** Returns { state, result, format, page_id } where state is "open" | "submitted" | "received". When state is "open", the user has not responded yet — wait a few seconds and call again. When "submitted", result is the user input. When "received", you already read the result on a prior poll. +> +> **For public-mode pages:** Returns { state, mode, format, page_id, submissions, total, cursor }. Submissions is an array of all responses received so far. State stays "open" until the owner closes the page (state becomes "closed"). Use cursor-based pagination for pages with many submissions. + +### `PageOps` interface — extended + +```typescript +export interface PageOps { + showUi(spec: unknown, opts?: { mode?: PageMode; accessEmails?: string[] }): Promise; + showHtml(html: string): Promise; + checkResult(page_id: string, opts?: { limit?: number; cursor?: string }): Promise; +} +``` + +### `CheckResultOutcome` — extended + +```typescript +export type CheckResultOutcome = + | { kind: 'not_found' } + | { kind: 'state'; state: PageState; result: unknown; format: PageFormat; mode: 'single' } + | { + kind: 'state'; + state: PageState; + format: PageFormat; + mode: 'public'; + submissions: Submission[]; + total: number; + cursor: string | null; + }; +``` + +--- + +## Frontend changes (`apps/web/main.ts`) + +### Page loading (`loadPage`) + +The `GET /:id` response now includes `mode`. The frontend stores it: + +```typescript +declare mode: PageMode; // 'single' | 'public' +``` + +### Submit handler (public mode) + +For public pages, the action handler in the `MessageProcessor` changes: + +1. POST the result to `/:id/result` as before. +2. On success: show a "submitted successfully" confirmation toast/banner. +3. **Do NOT set `this.awaiting = true`** — the form stays interactive. +4. Reset form fields to their default values so the user (or another + user) can submit again. +5. Do NOT start polling for `received` — public pages have no + `received` state. + +```typescript +// Pseudocode for the public-mode submit handler +if (this.mode === 'public') { + const res = await fetch(`${API_BASE}/${pageId}/result`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ... }), + }); + if (!res.ok) { + if (res.status === 409) { + // Page is closed + this.status = 'closed'; + return; + } + this.submitError = body.message ?? 'Submit failed'; + return; + } + this.showConfirmation('Response submitted successfully'); + this.resetForm(); + return; +} +``` + +### Closed state rendering + +When `page.state === 'closed'`, the frontend renders a tombstone: + +```html +
+ block + This form is no longer accepting responses. +
+``` + +The closed state is visually distinct from "Session ended" (which +covers expired/deleted pages). The form spec is still visible behind +a dimmed overlay so late-arriving users can see what was asked. + +### Confirmation toast + +After a successful submission on a public page, a transient toast +appears for 4 seconds: + +```html +
+ ✓ Response submitted +
+``` + +CSS: absolute-positioned at the top center, fades in/out, does not +block interaction. + +### Form reset + +After successful submission on a public page, form fields reset to +their initial values. This is achieved by re-processing the original +spec through the `MessageProcessor`: + +```typescript +private resetForm() { + // Clear all surfaces and re-apply the original spec + for (const id of Array.from(this.processor.model.surfacesMap.keys())) { + this.processor.model.deleteSurface(id); + } + this.processor.processMessages(this.originalSpec as v0_9.A2uiMessage[]); +} +``` + +The original spec is stored during `loadPage` as `this.originalSpec`. + +### Access-restricted pages + +When a page has `access_emails` set and the user is not authenticated +(or not in the allowlist), the frontend shows: + +```html +
+ lock +

This form is restricted. Sign in with an authorized email to continue.

+ +
+``` + +The auth flow is handled by Pagent's custom auth (magic link or OAuth) +and is out of scope for this spec — it is covered by the auth feature spec. + +--- + +## Access control + +### Email allowlist (`access_emails`) + +When `access_emails` is non-null on a page: + +1. **Viewing** (`GET /:id`): the API returns the spec only if the + request includes a valid auth token whose email is in the allowlist. + Unauthenticated requests get 403. +2. **Submitting** (`POST /:id/result`): same check. The `submitted_by` + column is set to the authenticated user's ID. +3. **Reading results** (`GET /:id/result`): only the page owner can + read submissions. Checked via `owner_id`. + +When `access_emails` is null (default): no restrictions. Anyone with +the URL can view and submit. `submitted_by` is null in submission rows. + +### Close authorization + +`POST /:id/close` requires: + +1. A valid auth token (Bearer JWT in Authorization header). +2. The token's user ID must match `pages.owner_id`. + +If either check fails, return 403 `forbidden`. + +### Owner identification + +`pages.owner_id` is set at page creation time: + +- If the `POST /new` request includes a valid auth token, the user ID + is extracted and stored as `owner_id`. +- If no auth token is present, `owner_id` is null. This means the page + cannot be closed via `POST /:id/close` — it will only expire via TTL. +- **Implication:** agents that want to close public pages must + authenticate when creating them. The MCP stdio server includes the + auth token if the user configured it. + +--- + +## Webhook interaction + +### Existing behavior (single mode) + +Currently there are no webhooks. This spec defines the webhook contract +for both modes so the upcoming webhook feature has a clear integration +point. + +### Public mode webhooks + +When webhooks are implemented: + +1. **Each submission fires a webhook.** The webhook payload includes: + ```jsonc + { + "event": "submission.created", + "page_id": "abc123...", + "submission_id": "uuid", + "mode": "public", + "result": { ... }, + "submitted_by": "user-uuid-or-null", + "submitted_at": "2026-05-17T12:34:56.789Z" + } + ``` +2. **Close fires a webhook:** + ```jsonc + { + "event": "page.closed", + "page_id": "abc123...", + "mode": "public", + "closed_at": "2026-05-17T15:00:00.000Z", + "total_submissions": 42 + } + ``` +3. **Webhook delivery is best-effort.** Failed deliveries are retried + with jitter (3 attempts at 0s/1s/5s). After 3 failures, the webhook + is dropped and logged. +4. **Webhook URL is set at page creation** via a future `webhook_url` + parameter on `POST /new`. Not part of this spec. + +### Single mode webhooks + +For forward-compatibility, single-mode submissions also fire +`submission.created` webhooks (with `mode: "single"`). The state +transitions (`submitted`, `received`) fire `page.state_changed`. + +--- + +## Backward compatibility + +### Existing single-mode pages are unaffected + +- `mode` defaults to `'single'` in the DB and in the API. +- Omitting `mode` from `POST /new` creates a single-mode page. +- All existing endpoints behave identically for single-mode pages. +- `pages.result` continues to be set for single-mode pages. +- `GET /:id/result` for single-mode pages returns the same shape. +- The `submitted → received` atomic flip is preserved for single mode. + +### `check_result` shape discrimination + +Agents can distinguish single vs public responses by checking for the +`mode` field: + +- Single: `{ state, result, format, page_id }` (no `mode` field, or + `mode: "single"`) +- Public: `{ state, mode: "public", submissions, total, cursor, ... }` + +For maximum compatibility, the `mode` field is included in single-mode +responses too (set to `"single"`), but older agents that don't check it +will see the same shape they always did. + +### MCP tool backward compatibility + +- `show_ui` with no `mode` parameter behaves identically to today. +- `check_result` for single-mode pages returns the same text and + structured content shape. +- The `mode` and `access_emails` parameters are optional with defaults + that preserve current behavior. + +--- + +## Pagination + +### When pagination matters + +A public page collecting survey responses might accumulate thousands of +submissions. The agent's `check_result` poll must not load all of them +in a single response. + +### Strategy: cursor-based on `submitted_at` + +- Default page size: 50 submissions. +- Maximum page size: 200 submissions (capped server-side). +- Cursor: the `submitted_at` ISO timestamp of the last submission in + the current batch. +- Direction: always forward (oldest to newest). +- The response includes `total` (count of all submissions for the page) + so the agent knows how many remain. +- When `cursor` in the response is `null`, there are no more + submissions. + +### SQL query + +```sql +select id, page_id, submitted_by, result, submitted_at +from submissions +where page_id = $1 + and ($2::timestamptz is null or submitted_at > $2) +order by submitted_at asc +limit $3 +``` + +The `total` is a separate count query (or a window function if +performance allows): + +```sql +select count(*) from submissions where page_id = $1 +``` + +### Agent polling pattern + +For public pages, the agent can use cursor-based polling to get only +new submissions since the last poll: + +1. First poll: `check_result(page_id)` — returns first 50 submissions. +2. Note the `cursor` from the response. +3. Next poll: `check_result(page_id, cursor)` — returns only + submissions after the cursor. +4. Repeat until `cursor` is null (caught up) or page is `closed`. + +The MCP `check_result` tool accepts an optional `cursor` parameter to +support this pattern: + +```typescript +inputSchema: { + page_id: z.string().regex(/^[a-f0-9]{32}$/), + cursor: z.string().datetime().optional() + .describe('Pagination cursor from a previous check_result call. Returns only submissions after this timestamp.'), + limit: z.number().int().min(1).max(200).optional().default(50) + .describe('Max submissions to return per call. Default 50, max 200.'), +} +``` + +--- + +## Metrics + +New counters and histograms: + +| Instrument | Type | Labels | Description | +|-----------|------|--------|-------------| +| `pagent.pages.created` | counter | `format`, `mode` | Existing counter, gains `mode` label | +| `pagent.submissions.created` | counter | `mode` | New: incremented on each submission insert | +| `pagent.pages.closed` | counter | — | New: incremented when owner closes a page | +| `pagent.public_page.submissions` | histogram | — | New: number of submissions per public page at close/expiry time | + +--- + +## Migration strategy + +### Phase 1: schema migration (zero downtime) + +1. Run the `ALTER TABLE` statements to add `mode`, `access_emails`, + `owner_id`, `closed_at` columns with defaults. +2. Run `CREATE TABLE submissions` and its index. +3. Update the `state` CHECK constraint. + +All changes are additive (new columns with defaults, new table). No +existing queries break. + +### Phase 2: API deployment + +Deploy the updated API. The `mode` default ensures existing +`POST /new` calls without `mode` create single-mode pages. + +### Phase 3: frontend deployment + +Deploy the updated web app. The `mode` field in `GET /:id` responses +gates the new UI paths. Old pages (no `mode` in response) are treated +as single-mode. + +### Phase 4: MCP server update + +Update the MCP npm package. The new `mode` and `access_emails` +parameters are optional, so existing MCP clients continue working. + +--- + +## Resolved questions + +1. **TTL for public pages.** Public-mode pages default to 7 days + (`PUBLIC_PAGE_TTL_MS` env var, default `604800000`). Single-mode + pages keep the existing 30-minute default. The `POST /new` handler + selects the TTL based on `mode`: `mode === 'public'` uses + `PUBLIC_PAGE_TTL_MS`, `mode === 'single'` uses the existing + `PAGE_TTL_MS`. + +2. **Submission rate limiting.** 5 submissions per minute per IP per + page, plus a 100 submissions per minute global cap per page. + Implemented as Hono middleware on `POST /:id/result` for public + pages. Single-mode pages are unaffected (they already accept at + most one submission). Both limits return 429 with a `Retry-After` + header when exceeded. + +3. **Submission count cap.** 10,000 max submissions per page. + `POST /:id/result` checks `SELECT count(*) FROM submissions WHERE + page_id = ?` and returns 409 `{ error: "submission_cap_reached" }` + when the cap is met. The cap is stored in `pages.max_submissions` + (default 10000, configurable via `show_ui`). + +4. **Auth readiness.** Auth is custom (not Supabase Auth) and ships in + the same v2 batch as public forms, so `owner_id` and `access_emails` + enforcement will be available at launch. No deferral needed. diff --git a/docs/superpowers/specs/2026-05-17-v2-roadmap-overview.md b/docs/superpowers/specs/2026-05-17-v2-roadmap-overview.md new file mode 100644 index 0000000..5702993 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-v2-roadmap-overview.md @@ -0,0 +1,227 @@ +# Pagent v2 Roadmap — Overview & Dependencies + +> Master reference for all v2 feature specs. Each feature has its own detailed spec in this directory. + +## Features + +| # | Feature | Spec file | Lines | Status | +|---|---------|-----------|-------|--------| +| 1 | Auth (Google + Magic Link + MCP OAuth) | `2026-05-17-auth-design.md` | ~1330 | Ready | +| 2 | File Uploads (Supabase Storage) | `2026-05-17-file-uploads-design.md` | ~1050 | Ready | +| 3 | Webhooks on Submit | `2026-05-17-webhooks-design.md` | ~870 | Ready | +| 4 | Public Forms (multi-submission) | `2026-05-17-public-forms-design.md` | ~1035 | Ready | +| 5 | Audit Log | `2026-05-17-audit-log-design.md` | ~980 | Ready | +| 6 | Custom URLs (handle/slug) | `2026-05-17-custom-urls-design.md` | ~980 | Ready | +| 7 | Agent Form Submission (submit_form) | `2026-05-17-agent-submit-design.md` | ~1400 | Ready | + +## Dependency Graph + +``` + ┌──────────┐ + │ AUTH │ + │ (Google │ + │ Magic │ + │ Link │ + │ OAuth) │ + └────┬─────┘ + ┌───────┬───────┼───────┬───────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌────────┐ ┌──────┐ ┌─────┐ ┌────────┐ ┌──────────┐ + │CUSTOM │ │PUBLIC│ │AUDIT│ │WEBHOOKS│ │ FILE │ + │ URLs │ │FORMS │ │ LOG │ │ │ │ UPLOADS │ + └────────┘ └──────┘ └─────┘ └────────┘ └────┬─────┘ + │ + ┌────▼─────┐ + │ AGENT │ + │ SUBMIT │ + └──────────┘ +``` + +### Hard blockers + +| Feature | Blocked by | Why | +|---------|-----------|-----| +| Custom URLs | Auth | Pages need an `owner_id` to have a handle namespace | +| Public Forms | Auth | Need identity for submitters + owner-only close | +| Agent Submit | Auth | Agent identity comes from OAuth tokens | +| Agent Submit | File Uploads | File support in `submit_form` depends on `POST /:id/files` | + +### Soft dependencies (enriched by, but works without) + +| Feature | Enriched by | What it adds | +|---------|------------|-------------| +| Audit Log | Auth | `user_id` on entries (without auth: null) | +| Webhooks | Auth | `submitted_by` in payload (without auth: null) | +| Webhooks | File Uploads | `files` array in payload (without files: empty) | +| Webhooks | Public Forms | `submission_id` + `mode` in payload | + +### Independent pairs (can build in parallel) + +- Webhooks ∥ Custom URLs ∥ File Uploads ∥ Audit Log +- Public Forms ∥ Audit Log ∥ Custom URLs + +## Recommended Build Order + +### Phase 1: Foundation +**Auth** — everything else depends on user identity. + +### Phase 2: Independent features (parallel) +Build these simultaneously after auth lands: +- **File Uploads** — new endpoint, Supabase Storage integration +- **Webhooks** — delivery logic, HMAC signing +- **Audit Log** — append-only event logging + +### Phase 3: Identity-dependent features (parallel) +These need auth to be functional: +- **Custom URLs** — handle registration, slug routing +- **Public Forms** — multi-submission mode, access control + +### Phase 4: Capstone +**Agent Submit** — combines auth + file uploads, adds `submit_form` MCP tool. + +## Unified Schema Changes + +All features touch the database. Here is the consolidated migration order: + +### Migration 1: Auth tables (Phase 1) +```sql +CREATE TABLE users (...) -- auth spec +CREATE TABLE sessions (...) -- auth spec +CREATE TABLE oauth_clients (...) -- auth spec +CREATE TABLE auth_codes (...) -- auth spec +CREATE TABLE refresh_tokens (...) -- auth spec +CREATE TABLE magic_links (...) -- auth spec +ALTER TABLE pages ADD COLUMN owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +``` + +### Migration 2: Feature tables (Phase 2-3) +```sql +CREATE TABLE files (...) -- file uploads spec +CREATE TABLE submissions (...) -- public forms spec +CREATE TABLE audit_log (...) -- audit log spec +ALTER TABLE pages ADD COLUMN webhook_url text; +ALTER TABLE pages ADD COLUMN webhook_secret text; +ALTER TABLE pages ADD COLUMN mode text NOT NULL DEFAULT 'single' CHECK (mode IN ('single', 'public')); +ALTER TABLE pages ADD COLUMN slug text; +ALTER TABLE pages ADD COLUMN access_emails text[]; +ALTER TABLE pages ADD COLUMN closed_at timestamptz; +-- Update state CHECK to include 'closed' +``` + +### Indexes +```sql +CREATE INDEX pages_owner_id_idx ON pages (owner_id); +CREATE UNIQUE INDEX pages_owner_slug_idx ON pages (owner_id, slug) WHERE slug IS NOT NULL; +CREATE INDEX files_page_id_idx ON files (page_id); +CREATE INDEX submissions_page_id_idx ON submissions (page_id); +CREATE INDEX audit_log_resource_idx ON audit_log (resource_type, resource_id, created_at DESC); +CREATE INDEX audit_log_user_idx ON audit_log (user_id, created_at DESC); +``` + +## Unified MCP Tool Interface + +All features extend the MCP tools. Here is the final combined interface: + +### `show_ui` (extended) +```typescript +{ + spec: unknown[], // A2UI components (existing) + slug?: string, // Custom URLs + mode?: 'single' | 'public', // Public Forms (default: 'single') + access_emails?: string[], // Public Forms (email allowlist) + max_submissions?: number, // Public Forms (default: 10000, only for public mode) + webhook_url?: string, // Webhooks + webhook_secret?: string, // Webhooks +} +``` + +### `show_html` (extended) +```typescript +{ + html: string, // HTML content (existing) + slug?: string, // Custom URLs + webhook_url?: string, // Webhooks + webhook_secret?: string, // Webhooks +} +``` + +### `check_result` (extended) +```typescript +// Input +{ page_id: string, cursor?: string, limit?: number } + +// Output (single mode — unchanged) +{ kind: 'state', state: string, result?: unknown, format?: string } + +// Output (public mode — new) +{ kind: 'submissions', mode: 'public', page_id: string, + submissions: Array<{ id, result, submitted_at, submitted_by? }>, + total: number, cursor?: string } +``` + +### `submit_form` (new) +```typescript +// Input +{ page_id: string, data: Record, files?: Record } + +// Output +{ success: true, submission_id: string } +| { success: false, errors: Array<{ field, message }> } +``` + +### `get_audit_log` (new) +```typescript +// Input +{ page_id: string, limit?: number } + +// Output +{ events: Array<{ action, resource_type, resource_id, metadata, created_at }> } +``` + +## New Environment Variables + +| Variable | Feature | Required | Default | +|----------|---------|----------|---------| +| `GOOGLE_CLIENT_ID` | Auth | Yes (when auth enabled) | — | +| `GOOGLE_CLIENT_SECRET` | Auth | Yes (when auth enabled) | — | +| `JWT_PRIVATE_KEY` | Auth | Yes (when auth enabled) | — | +| `JWT_PUBLIC_KEY` | Auth | Yes (when auth enabled) | — | +| `SMTP_HOST` | Auth (magic link) | Yes (when auth enabled) | — | +| `SMTP_PORT` | Auth (magic link) | No | 587 | +| `SMTP_USER` | Auth (magic link) | Yes (when auth enabled) | — | +| `SMTP_PASS` | Auth (magic link) | Yes (when auth enabled) | — | +| `SMTP_FROM` | Auth (magic link) | No | `noreply@pagent.io` | +| `REQUIRE_AUTH` | Auth | No | `false` | +| `SUPABASE_URL` | File Uploads | Yes (when files enabled) | — | +| `SUPABASE_SERVICE_ROLE_KEY` | File Uploads | Yes (when files enabled) | — | +| `FILE_MAX_SIZE_MB` | File Uploads | No | `10` | +| `WEBHOOK_ALLOW_PRIVATE_IPS` | Webhooks | No | `false` | +| `PUBLIC_PAGE_TTL_MS` | Public Forms | No | `604800000` (7 days) | + +## New Dependencies + +| Package | Feature | Why | +|---------|---------|-----| +| `jose` | Auth | JWT signing/verification (Ed25519, zero deps) | +| `nodemailer` | Auth | SMTP email for magic links | +| `@supabase/supabase-js` | File Uploads | Supabase Storage client | +| `file-type` | File Uploads | MIME type detection via magic bytes | + +## Cross-Spec Consistency (post-review) + +The following inconsistencies were found during cross-spec review and have been resolved: + +1. ~~`users.handle` nullability~~ — Fixed: nullable in auth, set during onboarding via Custom URLs +2. ~~`pages.owner_id` FK target~~ — Fixed: standardized to `REFERENCES users(id) ON DELETE SET NULL` +3. ~~`auth.users` references in Public Forms~~ — Fixed: changed to `users` +4. ~~Handle regex (underscores)~~ — Fixed: hyphens only, `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` +5. ~~`field_id` vs `field_name`~~ — Fixed: standardized to `field_name` +6. ~~Audit log events marked "reserved"~~ — Fixed: all events active since all features ship in v2 +7. ~~Webhook payload missing `submitted_by`/`files`~~ — Fixed: both included +8. ~~Webhook retry timing conflict~~ — Fixed: canonical 0s/1s/5s with jitter + +## Resolved Questions + +1. **Public form TTL** — Public-mode pages default to 7 days (`PUBLIC_PAGE_TTL_MS` env var, default `604800000`). Single-mode pages keep the existing 30-minute default. +2. **Submission rate limiting** — 5 submissions/min per IP per page + 100 submissions/min global cap per page. Implemented as Hono middleware on `POST /:id/result` for public pages. +3. **Submission count cap** — 10,000 max submissions per page. `POST /:id/result` returns 409 after cap reached. Stored in `pages.max_submissions` (default 10000, configurable via `show_ui`). diff --git a/docs/superpowers/specs/2026-05-17-webhooks-design.md b/docs/superpowers/specs/2026-05-17-webhooks-design.md new file mode 100644 index 0000000..73516fb --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-webhooks-design.md @@ -0,0 +1,923 @@ +# Webhooks on Submit -- Design + +Status: draft, awaiting user review (2026-05-17). + +## 1. Overview and motivation + +Today, agents discover that a user submitted a Pagent form by polling +`check_result` (MCP tool) or `GET /:id/result` (REST). Polling works +but wastes round-trips, adds latency proportional to the poll interval, +and scales poorly when an agent manages many concurrent pages. + +Webhooks flip the model: the agent registers a callback URL at page +creation time, and Pagent pushes the submission payload to that URL the +moment the user submits. Polling via `check_result` continues to work +unchanged -- webhooks are strictly additive. + +### Design principles + +- **Per-page, not per-account.** Each `show_ui` call can specify its + own `webhook_url` and `webhook_secret`. No global webhook + configuration, no registration flow, no management UI. The agent + already holds the page_id; the webhook config travels with it. +- **Fire-and-forget with best-effort retry.** Pagent retries on + transient failures but does not guarantee delivery. The agent can + always fall back to `check_result` for reliability. +- **HMAC signing.** When a `webhook_secret` is provided, every delivery + carries an `X-Pagent-Signature` header so the receiver can verify + authenticity. When no secret is set, the header is omitted. + +### Non-goals + +- **Dead-letter queue.** Failed deliveries after all retries are logged + and discarded. A DLQ is a v3 concern. +- **Webhook management API.** No `GET /webhooks`, no `DELETE /webhooks`. + The webhook is a property of the page, not a standalone resource. +- **Fanout / multiple URLs per page.** One URL per page. Multi-endpoint + routing is the receiver's responsibility. +- **Webhook for page expiry.** Only `page.submitted` fires. Expiry + events are a future consideration. +- **Mutual TLS or OAuth for delivery authentication.** HMAC is the + sole verification mechanism in v2. + +## 2. Database schema changes + +The `pages` table gains two nullable columns. Both are added in +`apps/api/db.ts` `init()` using the same idempotent pattern as the +`format` column migration. + +### New columns + +```sql +ALTER TABLE pages + ADD COLUMN IF NOT EXISTS webhook_url text, + ADD COLUMN IF NOT EXISTS webhook_secret text; +``` + +### Updated CREATE TABLE (for fresh deployments) + +```sql +CREATE TABLE IF NOT EXISTS pages ( + id text primary key, + spec jsonb not null, + format text not null default 'a2ui' + check (format in ('a2ui','html')), + state text not null + check (state in ('open','submitted','received')), + result jsonb, + webhook_url text, + webhook_secret text, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + submitted_at timestamptz, + received_at timestamptz +); +``` + +### TypeScript type changes + +`apps/api/db.ts` -- the `Page` type grows two optional fields: + +```ts +export type Page = { + id: string; + spec: unknown; + format: PageFormat; + state: PageState; + result: unknown; + createdAt: number; + expiresAt: number; + webhookUrl?: string | null; + webhookSecret?: string | null; +}; +``` + +`PageRow` (internal to db.ts) gains matching snake_case fields. + +### Data access changes + +- `insertPage`: writes `webhook_url` and `webhook_secret` when + present. Both are nullable; omitting them stores NULL. +- `getActivePage`: selects the two new columns so the submit handler + can read them. +- `submitPage`: return type `SubmitOutcome` for the `'ok'` variant + grows `webhookUrl` and `webhookSecret` fields so the caller can + fire the webhook without a follow-up query: + +```ts +export type SubmitOutcome = + | { kind: 'ok'; createdAt: Date; webhookUrl?: string | null; + webhookSecret?: string | null; submissionId: string; + mode: 'single' | 'public'; submittedBy?: string | null; + files?: WebhookFileRef[] } + | { kind: 'conflict' } + | { kind: 'not_found' }; +``` + +The `submitPage` UPDATE ... RETURNING clause adds `webhook_url, +webhook_secret` to the projection. + +### Security: secret storage + +`webhook_secret` is stored as plaintext in Postgres. This is acceptable +for v2 because: + +1. The secret is agent-generated and per-page, not a long-lived + credential. Single-mode pages expire in 30 minutes; public-mode + pages default to 7 days. +2. The secret never leaves the API server -- it is used server-side to + compute the HMAC and is never included in any response body or log. +3. Database access already requires the `DATABASE_URL` credential. + +Future consideration: if page TTLs grow significantly, encrypt at rest +with a KMS-derived key. + +## 3. MCP tool parameter changes + +### `show_ui` + +The input schema gains two optional string parameters: + +```ts +server.registerTool('show_ui', { + inputSchema: { + spec: z.array(z.record(z.unknown())).describe(SHOW_UI_INPUT_DESCRIPTION), + webhook_url: z.string().url().optional().describe( + 'Optional HTTPS callback URL. When set, Pagent POSTs the submission ' + + 'payload to this URL as soon as the user submits. The agent can ' + + 'still poll check_result -- webhooks are additive, not a replacement.' + ), + webhook_secret: z.string().min(16).max(256).optional().describe( + 'Optional shared secret for HMAC-SHA256 webhook signing. When set, ' + + 'every delivery carries an X-Pagent-Signature header: ' + + '"sha256=". The receiver MUST verify this signature to ' + + 'authenticate the payload. Minimum 16 characters.' + ), + }, + // ... +}); +``` + +### `show_html` + +`show_html` also gains the same two optional parameters for +completeness. Although HTML pages are view-only and never transition +to `submitted`, the webhook fires if a future event type (e.g. +`page.viewed`, `page.expired`) is added. For v2, the webhook is stored +but never fired for HTML pages. + +### `check_result` + +No changes. `check_result` continues to work regardless of whether a +webhook is configured. + +### PageOps interface changes + +```ts +export interface PageOps { + showUi(spec: unknown, opts?: { + webhook_url?: string; + webhook_secret?: string; + }): Promise; + showHtml(html: string, opts?: { + webhook_url?: string; + webhook_secret?: string; + }): Promise; + checkResult(page_id: string): Promise; +} +``` + +Both the in-process adapter (`apps/api/mcp/http.ts`) and the stdio +adapter (`apps/mcp/server.ts`) pass the webhook options through to +`store.createPage` / `store.createHtmlPage`. + +## 4. API changes + +### POST /new -- accepting webhook config + +The request body schema grows two optional fields at the top level. +Both variants of the discriminated union accept them: + +```ts +const webhookFields = { + webhook_url: z.string().url().optional(), + webhook_secret: z.string().min(16).max(256).optional(), +}; + +export const newPageBodySchema = z.union([ + z.object({ + format: z.literal('a2ui').optional().default('a2ui'), + spec: z.unknown(), + ...webhookFields, + }).refine((b) => 'spec' in b, { message: "missing 'spec'" }), + z.object({ + format: z.literal('html'), + spec: z.string().min(1).max(HTML_MAX_BYTES), + ...webhookFields, + }), +]); +``` + +Validation rules on `webhook_url`: + +1. Must be a valid URL (Zod `.url()` check). +2. Must use the `https:` scheme in production. In development + (`NODE_ENV !== 'production'`), `http:` is allowed for local + testing (e.g. `http://localhost:9999/hook`). Enforced by a Zod + `.refine()` on the schema, not by runtime env checks in the + handler. +3. Must not resolve to a private/internal IP (SSRF prevention -- + see section 7). + +Validation rules on `webhook_secret`: + +1. Minimum 16 characters to discourage weak secrets. +2. Maximum 256 characters. +3. Optional. When absent, deliveries are unsigned. + +### POST /new handler changes (`newPageHandler`) + +After Zod parsing, extract `webhook_url` and `webhook_secret` from +`result.data` and pass them to `store.createPage` / +`store.createHtmlPage`. The new fields are stored on the page row. + +The response body is unchanged: `{ id, url, expires_at }`. The +`webhook_url` is intentionally NOT echoed back -- the agent supplied it +and already knows it. + +### POST /:id/result handler changes (`submitResultHandler`) + +After the successful `db.submitPage()` call (outcome `'ok'`), the +handler fires the webhook asynchronously. The webhook delivery MUST +NOT block the HTTP response to the submitting user. + +```ts +// In submitResultHandler, after db.submitPage returns 'ok': +if (outcome.webhookUrl) { + // Fire-and-forget: do not await. The webhook module handles + // retries internally and logs outcomes. + void deliverWebhook({ + pageId: idResult.data, + submissionId: outcome.submissionId, + mode: outcome.mode, + webhookUrl: outcome.webhookUrl, + webhookSecret: outcome.webhookSecret ?? undefined, + result: bodyResult.data, + submittedAt: new Date(), + submittedBy: outcome.submittedBy ?? null, + files: outcome.files ?? [], + log: getLog(c), + }); +} +metrics.pagesSubmitted.add(1); +return c.json({ ok: true }); +``` + +### GET /:id response + +No changes. `webhook_url` and `webhook_secret` are NOT exposed in the +GET response. The webhook config is an implementation detail between +the agent and the API; the browser client should never see it. + +### GET /:id/result response + +No changes. + +## 5. Webhook delivery logic + +New module: `apps/api/webhook.ts`. + +### Trigger point + +The webhook fires immediately after `db.submitPage()` returns +`{ kind: 'ok' }` in `submitResultHandler`. The delivery is started +asynchronously (fire-and-forget from the HTTP handler's perspective). + +### Payload construction + +```ts +type WebhookFileRef = { + file_id: string; + field_name: string; + original_name: string; + mime_type: string; + size_bytes: number; + download_url: string; // signed URL, valid for 1 hour +}; + +type WebhookPayload = { + event: 'page.submitted'; + page_id: string; + submission_id: string; // unique per submission + mode: 'single' | 'public'; + result: unknown; + submitted_at: string; // ISO 8601 + submitted_by: string | null; // email of authenticated user, null if unauthenticated + files: WebhookFileRef[]; // empty array when no files attached +}; +``` + +Auth and file uploads ship in the same v2 batch, so both fields are +available from launch. `submitted_by` is the email of the authenticated +user who submitted, or `null` for unauthenticated submissions (e.g. +public-mode pages without auth). `files` is an array of file references +with signed download URLs (valid for 1 hour); it is an empty array when +the submission has no file attachments. The payload shape is +forward-compatible: adding new top-level keys is a non-breaking change +for receivers. + +### Public-form submissions + +The webhook fires once per submission, regardless of page mode. For +public-mode pages (reusable forms that accept multiple submissions), +the same `page.submitted` event is used -- no separate event name. The +`mode` field (`"single"` or `"public"`) lets consumers distinguish +single-use pages from public forms, and the `submission_id` field +uniquely identifies each individual submission within a public page. +Receivers that handle public forms should expect multiple webhook +deliveries for the same `page_id`, each with a distinct +`submission_id`. + +The payload is serialized to JSON with `JSON.stringify` once, and the +same string is used for both the HMAC computation and the HTTP body. +This ensures the signature matches the exact bytes sent over the wire. + +### HMAC signing + +When `webhook_secret` is set: + +```ts +import { createHmac } from 'node:crypto'; + +function signPayload(secret: string, body: string): string { + return 'sha256=' + createHmac('sha256', secret).update(body, 'utf8').digest('hex'); +} +``` + +The signature is sent as the `X-Pagent-Signature` header. The receiver +verifies it with constant-time comparison (`crypto.timingSafeEqual`). + +### Delivery request + +```ts +const res = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Pagent-Webhook/1.0', + 'X-Pagent-Event': 'page.submitted', + 'X-Pagent-Delivery': deliveryId, // UUID for idempotency + ...(signature ? { 'X-Pagent-Signature': signature } : {}), + }, + body: rawBody, + signal: AbortSignal.timeout(10_000), // 10s timeout per attempt +}); +``` + +Headers: + +| Header | Value | Purpose | +| -------------------- | ----------------------------- | ------------------------------ | +| Content-Type | application/json | Standard | +| User-Agent | Pagent-Webhook/1.0 | Identifies Pagent deliveries | +| X-Pagent-Event | page.submitted | Event type discriminator | +| X-Pagent-Delivery | UUID v4 | Unique per delivery attempt | +| X-Pagent-Signature | sha256= (when secret set)| HMAC verification | + +### Success criteria + +A delivery is considered successful when the receiver responds with +any 2xx status code. The response body is ignored. + +## 6. Retry strategy + +Three attempts with exponential backoff. Timing: + +| Attempt | Delay before | Cumulative wall time | +| ------- | -------------- | -------------------- | +| 1 | 0 (immediate) | 0s | +| 2 | 1s +/- jitter | ~1s | +| 3 | 5s +/- jitter | ~6s | + +If all three attempts fail, the delivery is abandoned and logged as +a permanent failure. No dead-letter queue. + +### Retry eligibility + +Retried on: + +- Network errors (DNS failure, connection refused, timeout). +- HTTP 5xx responses (server-side failures). +- HTTP 429 (rate limited) -- respects `Retry-After` header if present, + capped at 30s. + +NOT retried on: + +- HTTP 4xx (except 429) -- client errors indicate the receiver + rejected the payload permanently. +- HTTP 3xx -- redirects are not followed by default; the agent should + provide the final URL. + +### Jitter + +Each delay is jittered by +/- 25% to prevent thundering herd on +retries: `delay * (0.75 + Math.random() * 0.5)`. This matches the +existing `withRetry` pattern in `apps/api/db.ts`. + +### Implementation sketch + +```ts +const RETRY_DELAYS_MS = [0, 1_000, 5_000]; + +async function deliverWebhook(opts: { + pageId: string; + submissionId: string; + mode: 'single' | 'public'; + webhookUrl: string; + webhookSecret?: string; + result: unknown; + submittedAt: Date; + submittedBy?: string | null; + files?: WebhookFileRef[]; + log: Pick; +}): Promise { + const deliveryId = randomUUID(); + const payload: WebhookPayload = { + event: 'page.submitted', + page_id: opts.pageId, + submission_id: opts.submissionId, + mode: opts.mode, + result: opts.result, + submitted_at: opts.submittedAt.toISOString(), + submitted_by: opts.submittedBy ?? null, + files: opts.files ?? [], + }; + const rawBody = JSON.stringify(payload); + const signature = opts.webhookSecret + ? signPayload(opts.webhookSecret, rawBody) + : undefined; + + for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) { + const delay = RETRY_DELAYS_MS[attempt]!; + if (delay > 0) { + const jittered = delay * (0.75 + Math.random() * 0.5); + await new Promise((r) => setTimeout(r, jittered)); + } + + try { + const res = await fetch(opts.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Pagent-Webhook/1.0', + 'X-Pagent-Event': 'page.submitted', + 'X-Pagent-Delivery': deliveryId, + ...(signature ? { 'X-Pagent-Signature': signature } : {}), + }, + body: rawBody, + signal: AbortSignal.timeout(10_000), + }); + + if (res.ok) { + opts.log.info( + { page_id: opts.pageId, attempt: attempt + 1, + status: res.status, delivery_id: deliveryId }, + 'webhook delivered', + ); + metrics.webhookDeliveries.add(1, { status: 'success' }); + return; + } + + // Non-retryable client error (except 429) + if (res.status >= 400 && res.status < 500 && res.status !== 429) { + opts.log.warn( + { page_id: opts.pageId, attempt: attempt + 1, + status: res.status, delivery_id: deliveryId }, + 'webhook rejected (non-retryable)', + ); + metrics.webhookDeliveries.add(1, { status: 'rejected' }); + return; + } + + // Retryable: 5xx or 429 + opts.log.warn( + { page_id: opts.pageId, attempt: attempt + 1, + status: res.status, delivery_id: deliveryId }, + 'webhook attempt failed (retrying)', + ); + } catch (err) { + opts.log.warn( + { page_id: opts.pageId, attempt: attempt + 1, + err, delivery_id: deliveryId }, + 'webhook attempt error (retrying)', + ); + } + } + + // All attempts exhausted + opts.log.error( + { page_id: opts.pageId, delivery_id: deliveryId, + webhook_url: opts.webhookUrl }, + 'webhook delivery failed after all retries', + ); + metrics.webhookDeliveries.add(1, { status: 'failed' }); +} +``` + +## 7. Security considerations + +### SSRF prevention + +The `webhook_url` is agent-supplied and could point to internal +services (metadata endpoints, internal APIs, localhost services). This +is the primary security concern. + +**Mitigation layers:** + +1. **URL scheme enforcement.** Only `https:` URLs are accepted in + production. `http:` is allowed only when `NODE_ENV !== 'production'` + for local development. Enforced at the Zod schema level. + +2. **DNS resolution check before delivery.** Before the first delivery + attempt, resolve the hostname and reject if it resolves to: + - `127.0.0.0/8` (loopback) + - `10.0.0.0/8` (RFC 1918) + - `172.16.0.0/12` (RFC 1918) + - `192.168.0.0/16` (RFC 1918) + - `169.254.0.0/16` (link-local, includes cloud metadata endpoints) + - `::1` (IPv6 loopback) + - `fc00::/7` (IPv6 unique local) + - `fe80::/10` (IPv6 link-local) + - `0.0.0.0` + + Implementation: use `dns.promises.lookup()` to resolve the hostname, + check the resolved IP against the blocklist, and reject before + calling `fetch()`. + + ```ts + import { promises as dns } from 'node:dns'; + import { isIP } from 'node:net'; + + const BLOCKED_CIDRS = [ + { prefix: '127.', bits: 8 }, + { prefix: '10.', bits: 8 }, + // ... full list + ]; + + async function isPrivateHost(hostname: string): Promise { + // If the hostname is already an IP literal, check directly + if (isIP(hostname)) return isPrivateIp(hostname); + const { address } = await dns.lookup(hostname); + return isPrivateIp(address); + } + ``` + +3. **No redirect following.** `fetch()` in Node 22 follows redirects + by default. Set `redirect: 'error'` to prevent an attacker from + redirecting from a public URL to an internal one. + + ```ts + const res = await fetch(opts.webhookUrl, { + // ... + redirect: 'error', + }); + ``` + +4. **Response body discarded.** The webhook delivery reads only the + status code; the response body is not consumed or logged. This + prevents data exfiltration through the response channel. + +5. **Development bypass.** In non-production environments, the private + IP check can be disabled via `WEBHOOK_ALLOW_PRIVATE_IPS=true` to + support local testing with `localhost` callback URLs. + +### Secret handling + +- `webhook_secret` is never logged (not even at debug level). +- `webhook_secret` is never included in any API response body. +- `webhook_secret` is never included in error messages. +- Log messages about webhook delivery include `webhook_url` only on + permanent failure (for debugging); routine success/retry logs use + `page_id` and `delivery_id` only. + +### Payload size + +The webhook payload is bounded by the result body size, which is +already capped by the `bodyLimit` middleware (1 MB). The webhook +payload adds a small constant overhead (~200 bytes for the envelope +fields). No additional size cap is needed. + +### Rate limiting on webhook delivery + +The webhook delivery rate is implicitly limited by the page creation +rate limit (30/min/IP). Each page can fire at most one webhook. No +additional rate limiting on the delivery side is needed for v2. + +### Timeout + +Each delivery attempt has a 10-second timeout (`AbortSignal.timeout`). +This prevents a slow receiver from holding resources indefinitely. + +## 8. Observability + +### Structured logging + +All webhook events are logged via Pino with structured fields. + +| Log level | Event | Fields | +| --------- | ---------------------------- | ---------------------------------------------------------- | +| info | webhook delivered | `page_id`, `attempt`, `status`, `delivery_id` | +| warn | webhook attempt failed | `page_id`, `attempt`, `status` or `err`, `delivery_id` | +| warn | webhook rejected (4xx) | `page_id`, `attempt`, `status`, `delivery_id` | +| error | webhook delivery failed | `page_id`, `delivery_id`, `webhook_url` | +| warn | webhook SSRF blocked | `page_id`, `webhook_url`, `resolved_ip` | + +`webhook_secret` is NEVER logged at any level. + +### OTel metrics + +New counter and histogram on the existing `pagent-api` meter: + +```ts +// apps/api/metrics.ts additions +webhookDeliveries: meter.createCounter('pagent.webhook.deliveries', { + description: 'Webhook delivery outcomes by status (success/rejected/failed)', +}), +webhookDeliveryDuration: meter.createHistogram('pagent.webhook.delivery.duration', { + description: 'Time from submission to final webhook delivery outcome', + unit: 's', +}), +webhookDeliveryAttempts: meter.createHistogram('pagent.webhook.delivery.attempts', { + description: 'Number of attempts per webhook delivery (1-3)', +}), +``` + +Labels on `webhookDeliveries`: +- `status`: `success` | `rejected` | `failed` | `ssrf_blocked` + +### Audit log (future) + +The v2 roadmap mentions an audit log. Webhook delivery events are +structured for future ingestion into an audit trail: + +```ts +{ + action: 'webhook.delivered' | 'webhook.failed', + page_id: string, + delivery_id: string, + attempts: number, + final_status: number | 'error', + timestamp: string, +} +``` + +This structure is emitted as structured log fields today and can be +routed to a dedicated audit table when that feature lands. + +## 9. Testing strategy + +### Unit tests (`apps/api/webhook.test.ts`) + +Tests for the `deliverWebhook` function with mocked `fetch`: + +1. **Successful delivery on first attempt.** Mock fetch to return 200. + Assert: `webhookDeliveries` counter incremented with `success`, + log.info called once, no retries. + +2. **Retry on 500 then succeed.** Mock fetch to return 500, then 200. + Assert: two attempts, final status `success`, log.warn on first + attempt, log.info on second. + +3. **Retry on network error then succeed.** Mock fetch to throw + `TypeError('fetch failed')`, then return 200. + Assert: two attempts, final status `success`. + +4. **Permanent failure after 3 retries.** Mock fetch to always return + 502. Assert: three attempts, `webhookDeliveries` counter with + `failed`, log.error on final failure. + +5. **No retry on 4xx (except 429).** Mock fetch to return 400. + Assert: one attempt, `webhookDeliveries` counter with `rejected`, + no retry delay. + +6. **Retry on 429.** Mock fetch to return 429, then 200. + Assert: two attempts. + +7. **HMAC signing.** Call with a known secret and body. Assert the + `X-Pagent-Signature` header matches the expected HMAC value. + +8. **No signature header when secret is absent.** Assert + `X-Pagent-Signature` is not set in the fetch headers. + +9. **Timeout.** Mock fetch to hang. Assert the delivery times out + and retries. + +10. **Payload shape.** Assert the POST body matches the + `WebhookPayload` schema, including `submitted_by`, `files`, + `submission_id`, and `mode` fields. + +11. **Payload includes submitted_by when authenticated.** Call with + a `submittedBy` value. Assert the payload `submitted_by` matches. + +12. **Payload submitted_by is null when unauthenticated.** Call + without `submittedBy`. Assert `submitted_by` is `null`. + +13. **Payload includes files array.** Call with a `files` array. + Assert the payload `files` matches. + +14. **Public-mode submission_id is unique per delivery.** Fire two + webhooks for the same `pageId` with `mode: 'public'`. Assert + each has a distinct `submission_id`. + +### Unit tests (`apps/api/webhook-ssrf.test.ts`) + +1. **Block private IPs.** For each blocked CIDR, mock DNS to resolve + to an IP in that range. Assert: delivery is rejected before fetch + is called, `ssrf_blocked` metric incremented. + +2. **Allow public IPs.** Mock DNS to resolve to `93.184.216.34`. + Assert: fetch is called. + +3. **Block localhost in production.** URL `https://localhost/hook`. + Assert: blocked. + +4. **Allow localhost in development.** With `WEBHOOK_ALLOW_PRIVATE_IPS`, + mock DNS to resolve to `127.0.0.1`. Assert: fetch is called. + +5. **Block IP literal in URL.** URL `https://169.254.169.254/metadata`. + Assert: blocked without DNS resolution. + +### Integration tests (`apps/api/app.test.ts` additions) + +1. **POST /new with webhook_url stores it.** Assert `db.insertPage` is + called with a page object containing the webhook URL. + +2. **POST /new rejects invalid webhook_url.** Body with + `webhook_url: 'not-a-url'`. Assert 400. + +3. **POST /new rejects short webhook_secret.** Body with + `webhook_secret: 'abc'` (< 16 chars). Assert 400. + +4. **POST /new without webhook fields works.** Existing tests remain + green -- webhook fields are optional. + +5. **POST /:id/result fires webhook on success.** Mock + `db.submitPage` to return `{ kind: 'ok', webhookUrl: '...' }`. + Assert `deliverWebhook` is called (mock the module). + +6. **POST /:id/result does not fire webhook when webhookUrl is null.** + Mock `db.submitPage` to return `{ kind: 'ok', webhookUrl: null }`. + Assert `deliverWebhook` is not called. + +### MCP tool tests (`apps/api/mcp/tools.test.ts` additions) + +1. **show_ui passes webhook_url and webhook_secret to ops.showUi.** + Assert the ops mock receives both fields. + +2. **show_ui without webhook fields still works.** Assert opts are + undefined or empty. + +### Local testing + +For local development, agents can point `webhook_url` at a local HTTP +server. Two recommended approaches: + +1. **`npx @anthropic-ai/webhook-server`** (hypothetical) or any local + HTTP echo server: + + ```bash + # Terminal 1: simple echo server + npx http-echo-server --port 9999 + + # Terminal 2: agent sets webhook_url + # show_ui({ spec: [...], webhook_url: 'http://localhost:9999/hook' }) + ``` + +2. **ngrok / cloudflared tunnel** for testing with the deployed API: + + ```bash + ngrok http 9999 + # Use the ngrok HTTPS URL as webhook_url + ``` + +The SSRF check is relaxed in development (`NODE_ENV !== 'production'`) +or via `WEBHOOK_ALLOW_PRIVATE_IPS=true` to allow `localhost` URLs. + +## 10. Environment variables + +| Variable | Default | Required | Description | +| -------------------------- | --------- | -------- | ---------------------------------------------------- | +| `WEBHOOK_ALLOW_PRIVATE_IPS`| `false` | No | Set to `true` to skip SSRF IP checks (dev only) | + +No other new environment variables. The webhook feature uses existing +config (`NODE_ENV`, `DATABASE_URL`, etc.). + +Add to `apps/api/schemas.ts` `envSchema`: + +```ts +WEBHOOK_ALLOW_PRIVATE_IPS: z + .enum(['true', 'false']) + .optional() + .default('false') + .transform((v) => v === 'true'), +``` + +## 11. Dependencies + +### No new npm packages + +The webhook implementation uses only Node.js built-ins: + +- `node:crypto` -- `createHmac`, `randomUUID`, `timingSafeEqual` + (already used in `store.ts` for `randomBytes`) +- `node:dns` -- `promises.lookup` for SSRF hostname resolution +- `node:net` -- `isIP` for IP literal detection +- Global `fetch` -- available in Node 22+ (already used in + `apps/mcp/server.ts`) +- `AbortSignal.timeout` -- available in Node 22+ + +No external HTTP client library is needed. Node's native `fetch` is +sufficient for the webhook delivery use case. + +### Existing dependencies leveraged + +- `pino` -- structured logging (already a dependency) +- `@opentelemetry/api` -- metrics (already a dependency) +- `zod` -- schema validation for webhook fields (already a dependency) + +## 12. File change summary + +| File | Change type | Description | +| ----------------------------- | ----------- | ---------------------------------------------- | +| `apps/api/db.ts` | modify | Add columns, update Page type, update queries | +| `apps/api/schemas.ts` | modify | Add webhook fields to newPageBodySchema, env | +| `apps/api/app.ts` | modify | Pass webhook config, fire webhook on submit | +| `apps/api/store.ts` | modify | Accept and pass through webhook options | +| `apps/api/webhook.ts` | **new** | Delivery logic, HMAC signing, retry, SSRF | +| `apps/api/metrics.ts` | modify | Add webhook counters and histograms | +| `apps/api/mcp/tools.ts` | modify | Add webhook params to show_ui, show_html | +| `apps/api/mcp/http.ts` | modify | Pass webhook opts through in-process adapter | +| `apps/mcp/server.ts` | modify | Pass webhook opts through REST adapter | +| `apps/api/webhook.test.ts` | **new** | Unit tests for delivery logic | +| `apps/api/webhook-ssrf.test.ts`| **new** | SSRF prevention tests | +| `apps/api/app.test.ts` | modify | Integration tests for webhook flow | +| `apps/api/mcp/tools.test.ts` | modify | MCP tool tests for webhook params | +| `docs/openapi.yaml` | modify | Document webhook fields on POST /new | + +## 13. Rollout + +### Commit order + +Land in one PR with two logical commits: + +1. **API + webhook delivery engine.** Schema changes, webhook.ts + module, SSRF guard, metrics, handler integration. All unit tests. + Tree is green; webhooks are accepted and stored but no MCP tool + exposes them yet. + +2. **MCP tool integration.** `show_ui` and `show_html` gain + `webhook_url` and `webhook_secret` parameters. Stdio adapter + updated. MCP tests. OpenAPI doc updated. + +Both commits are individually green. Commit 1 can deploy safely +because no MCP client sends webhook fields yet. Commit 2 enables +the feature for agents. + +### Backwards compatibility + +- Existing `show_ui` calls without webhook fields continue to work. + Both new parameters are optional with no default. +- `check_result` is unaffected. +- `POST /new` without webhook fields returns the same response shape. +- The `pages` table migration is additive (new nullable columns). +- No existing API contract is broken. + +## 14. Open questions + +None blocking implementation. Future considerations: + +- **Webhook for page expiry.** Fire `page.expired` when TTL kills an + open page. Useful for cleanup on the agent side. Spec separately. +- **Webhook for page viewed.** Fire `page.viewed` on first + `GET /:id`. Useful for "the user saw the form" confirmation. +- **Dead-letter queue.** Persist failed deliveries for manual replay. + v3 concern per roadmap. +- **Webhook signature rotation.** Per-delivery unique nonce in the + HMAC input to prevent replay. Low priority given short page TTLs + (30 min for single mode, 7 days for public mode). + +## 15. Decisions summary + +| Decision | Chosen | Rejected | +| ------------------------------- | ----------------------------------- | ------------------------------------- | +| Webhook scope | Per-page, at creation time | Per-account global config | +| Delivery model | Fire-and-forget, best-effort retry | Queue-based guaranteed delivery | +| Retry count | 3 attempts (0s, 1s, 5s) | 5 attempts / unlimited / no retry | +| Signing | HMAC-SHA256 with shared secret | JWT / mTLS / unsigned | +| SSRF mitigation | DNS resolve + IP blocklist | No mitigation / egress proxy | +| Secret storage | Plaintext (short-lived pages) | Encrypted at rest | +| Webhook URL scheme (production) | HTTPS only | Allow HTTP | +| Redirect following | Disabled (`redirect: 'error'`) | Follow redirects | +| Response body handling | Discard | Log / store | +| Dead-letter queue | None (v2) | Persist failed deliveries | +| New dependencies | None (Node built-ins only) | axios / got / bull / pg-boss | diff --git a/docs/superpowers/tasks/01-auth/01-env-and-database-schema.md b/docs/superpowers/tasks/01-auth/01-env-and-database-schema.md new file mode 100644 index 0000000..1cc5047 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/01-env-and-database-schema.md @@ -0,0 +1,32 @@ +# 01 — Environment variables & database schema + +## Description + +Extend the env schema with all auth-related variables and add the six new auth tables (`users`, `sessions`, `oauth_clients`, `auth_codes`, `refresh_tokens`, `magic_links`) plus the `owner_id` column on `pages` to the database bootstrap in `db.ts`. + +## Files to create/modify + +- `apps/api/schemas.ts` — add `REQUIRE_AUTH`, `JWT_SIGNING_KEY`, `JWT_PUBLIC_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, `MAGIC_LINK_SECRET`, `AUTH_STATE_SECRET`, `SESSION_MAX_AGE_DAYS`, `REFRESH_TOKEN_MAX_DAYS`, `ACCESS_TOKEN_TTL_SECONDS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` to `envSchema`. Add `superRefine` requiring crypto/SMTP vars when `REQUIRE_AUTH=true`. +- `apps/api/schemas.test.ts` — tests for the new env vars and `superRefine` logic. +- `apps/api/db.ts` — add `CREATE TABLE IF NOT EXISTS` for `users`, `sessions`, `oauth_clients`, `auth_codes`, `refresh_tokens`, `magic_links` in the `init()` function. Add `ALTER TABLE pages ADD COLUMN IF NOT EXISTS owner_id`. Add indexes from spec section 2. +- `apps/api/db.test.ts` — tests verifying tables are created and `owner_id` column exists on `pages`. +- `apps/api/.env.example` — document the new env vars. + +## Acceptance criteria + +- `envSchema` parses successfully with `REQUIRE_AUTH=false` and no auth vars present. +- `envSchema` fails with a clear error when `REQUIRE_AUTH=true` but `JWT_SIGNING_KEY` is missing. +- All six tables are created idempotently on `db.init()` — running init twice does not error. +- `pages` table has a nullable `owner_id` FK referencing `users(id)` with `ON DELETE SET NULL`. +- `users` table has unique indexes on `lower(email)` and `lower(handle)`. +- `sessions`, `auth_codes`, `refresh_tokens`, `magic_links` have `expires_at` indexes. +- Existing tests continue to pass (no regressions). + +## Dependencies + +None — this is the foundation task. + +## Relevant spec sections + +- Section 2 (Database schema) — all subsections 2.1 through 2.7 +- Section 9 (Environment variables) — full table and schema validation diff --git a/docs/superpowers/tasks/01-auth/02-jwt-signing-and-verification.md b/docs/superpowers/tasks/01-auth/02-jwt-signing-and-verification.md new file mode 100644 index 0000000..49ba616 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/02-jwt-signing-and-verification.md @@ -0,0 +1,39 @@ +# 02 — JWT signing and verification + +## Description + +Implement the `apps/api/auth/jwt.ts` module that handles Ed25519 JWT signing, verification, and JWKS serialization. This is the core cryptographic primitive used by the token endpoint (task 06) and auth middleware (task 07). + +## Files to create/modify + +- `apps/api/auth/jwt.ts` (new) — export functions: + - `signAccessToken(payload: { sub, email, handle, clientId, scope }): Promise` — signs a JWT with `alg: EdDSA`, `typ: at+jwt`, `kid: pagent-2026-05`. Claims: `iss`, `sub`, `aud`, `exp` (1h from `iat`), `iat`, `jti` (random UUID), `client_id`, `scope`, `email`, `handle`. + - `verifyAccessToken(token: string): Promise` — verifies signature, checks `exp > now`, `iss === issuer`, `aud === audience`. Returns decoded payload or throws. + - `getJwks(): { keys: JWK[] }` — returns the public key as a JWK with `kty: OKP`, `crv: Ed25519`, `use: sig`, `kid`. + - `initKeys(signingKey: string, publicKey: string): void` — imports base64url-encoded DER keys from env vars. +- `apps/api/auth/jwt.test.ts` (new) — tests: + - Round-trip: sign then verify returns original claims. + - Expired token is rejected. + - Tampered token (modified payload) is rejected. + - Wrong issuer/audience is rejected. + - `getJwks()` returns valid JWK structure. + +## Acceptance criteria + +- Uses the `jose` library (already listed in spec section 10 as a new dependency). +- JWT header has `alg: EdDSA`, `typ: at+jwt`, `kid: pagent-2026-05`. +- All claims from spec section 5.1 are present in signed tokens. +- `ACCESS_TOKEN_TTL_SECONDS` from env is respected (defaults to 3600). +- `verifyAccessToken` does NOT hit the database — purely cryptographic. +- JWKS output matches the format in spec section 5.3. + +## Dependencies + +- **01** — env vars (`JWT_SIGNING_KEY`, `JWT_PUBLIC_KEY`, `ACCESS_TOKEN_TTL_SECONDS`) must be in the schema. + +## Relevant spec sections + +- Section 5.1 (Access tokens — JWT header and payload) +- Section 5.3 (Signing key management — key format, JWKS endpoint structure) +- Section 5.4 (Token validation — `PagentTokenVerifier` interface) +- Section 10 (Dependencies — `jose` package) diff --git a/docs/superpowers/tasks/01-auth/03-oauth-metadata-endpoints.md b/docs/superpowers/tasks/01-auth/03-oauth-metadata-endpoints.md new file mode 100644 index 0000000..8e45d84 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/03-oauth-metadata-endpoints.md @@ -0,0 +1,37 @@ +# 03 — OAuth metadata endpoints (well-known) + +## Description + +Add the three discovery endpoints that MCP clients and OAuth tools use to find the authorization server configuration: the AS metadata, the protected resource metadata, and the JWKS endpoint. These are static JSON responses served by Hono routes. + +## Files to create/modify + +- `apps/api/auth/routes.ts` (new) — create a Hono sub-app exporting auth routes. Add: + - `GET /.well-known/oauth-authorization-server` — returns AS metadata JSON (issuer, authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, response_types_supported, grant_types_supported, token_endpoint_auth_methods_supported, code_challenge_methods_supported, scopes_supported). + - `GET /.well-known/oauth-protected-resource` — returns protected resource metadata JSON (resource, authorization_servers, scopes_supported, bearer_methods_supported, resource_name). + - `GET /.well-known/jwks.json` — returns the JWKS from `jwt.getJwks()`. +- `apps/api/app.ts` — mount the auth routes sub-app. +- `apps/api/auth/routes.test.ts` (new) — tests: + - Each endpoint returns 200 with correct `Content-Type: application/json`. + - AS metadata `issuer` matches `PUBLIC_URL`. + - All required fields are present per RFC 8414 and RFC 9728. + - JWKS contains one key with `kty: OKP`, `crv: Ed25519`. + +## Acceptance criteria + +- All three endpoints are publicly accessible (no auth required). +- `issuer` and `resource` values are dynamically derived from `PUBLIC_URL` env var. +- Endpoint URLs in the AS metadata use `PUBLIC_URL` as the base (not hardcoded `api.pagent.link`). +- `scopes_supported` returns `["page:create", "page:read", "page:write"]`. +- `code_challenge_methods_supported` returns `["S256"]` (no `plain`). +- No Express dependency — all routes are native Hono. + +## Dependencies + +- **02** — `getJwks()` from `jwt.ts` is needed for the JWKS endpoint. + +## Relevant spec sections + +- Section 3.1 (Authorization Server metadata) +- Section 3.2 (Protected Resource metadata — RFC 9728) +- Section 5.3 (JWKS endpoint format) diff --git a/docs/superpowers/tasks/01-auth/04-dynamic-client-registration.md b/docs/superpowers/tasks/01-auth/04-dynamic-client-registration.md new file mode 100644 index 0000000..daafe39 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/04-dynamic-client-registration.md @@ -0,0 +1,42 @@ +# 04 — Dynamic client registration + +## Description + +Implement `POST /oauth/register` per RFC 7591. MCP clients self-register before starting the authorization code flow. This endpoint creates rows in the `oauth_clients` table and also implements the `OAuthRegisteredClientsStore` interface from the MCP SDK. + +## Files to create/modify + +- `apps/api/auth/clients-store.ts` (new) — implements `OAuthRegisteredClientsStore` interface from `@modelcontextprotocol/sdk/server/auth/clients`. Methods: + - `registerClient(metadata)` — validates `redirect_uris` (required, each must be a valid URI), generates `client_id` via `randomUUID()`, inserts into `oauth_clients`. Returns `OAuthClientInformationFull`. + - `getClient(clientId)` — looks up by `client_id` PK. Returns client info or undefined. +- `apps/api/auth/routes.ts` — add route: + - `POST /oauth/register` — validates request body, calls `registerClient()`, returns 201 with client info. Rate-limited to 10/IP/hour. +- `apps/api/auth/clients-store.test.ts` (new) — tests: + - Successful registration returns `client_id` and echoes back metadata. + - Missing `redirect_uris` returns 400 `invalid_client_metadata`. + - Invalid URI in `redirect_uris` returns 400. + - `getClient` returns the registered client. + - `getClient` returns undefined for unknown `client_id`. + - Rate limit (10/IP/hour) rejects the 11th request with 429. + +## Acceptance criteria + +- `POST /oauth/register` returns 201 with a body matching `OAuthClientInformationFull` from the MCP SDK. +- No `client_secret` is issued (public clients, `token_endpoint_auth_method: "none"`). +- `client_id` is a UUID. +- `client_id_issued_at` is a Unix timestamp (seconds). +- `redirect_uris` is validated: must be a non-empty array of valid URIs. +- `grant_types` defaults to `["authorization_code", "refresh_token"]`. +- `response_types` defaults to `["code"]`. +- Rate limit: 10 registrations per IP per hour. + +## Dependencies + +- **01** — `oauth_clients` table must exist. + +## Relevant spec sections + +- Section 2.3 (oauth_clients table schema) +- Section 3.3 (Dynamic client registration endpoint, request/response format, error cases) +- Section 7.3 (Rate limiting — 10/IP/hour for register) +- Section 10 (MCP SDK usage — `OAuthRegisteredClientsStore` interface) diff --git a/docs/superpowers/tasks/01-auth/05-google-oauth-flow.md b/docs/superpowers/tasks/01-auth/05-google-oauth-flow.md new file mode 100644 index 0000000..08834a4 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/05-google-oauth-flow.md @@ -0,0 +1,46 @@ +# 05 — Google OAuth flow + +## Description + +Implement the Google OAuth identity provider leg: the authorize endpoint's login page, the redirect to Google's consent screen, and the callback that exchanges Google's authorization code for user info, upserts the user, and issues a Pagent authorization code. + +## Files to create/modify + +- `apps/api/auth/google.ts` (new) — Google OAuth helpers: + - `buildGoogleAuthUrl(state: string): string` — constructs the Google OAuth URL with `GOOGLE_CLIENT_ID`, `GOOGLE_REDIRECT_URI`, `scope=openid email profile`, `response_type=code`, encoded `state`. + - `exchangeGoogleCode(code: string): Promise<{ sub, email, name, picture }>` — exchanges the Google authorization code for an ID token via `googleapis.com/token`, decodes the ID token claims. +- `apps/api/auth/login-page.ts` (new) — exports a function `renderLoginPage(params: { clientId, redirectUri, codeChallenge, scope, state, error? }): string` that returns server-rendered HTML with "Continue with Google" button and an email input for Magic Link (Magic Link submit is wired in task 06). +- `apps/api/auth/routes.ts` — add routes: + - `GET /oauth/authorize` — validates `client_id`, `redirect_uri` (exact match against registered URIs), `code_challenge`, `code_challenge_method=S256`. Renders the login page. Also supports `browser_session=1` mode (no client_id required). Rate-limited to 30/IP/min. + - `GET /oauth/callback/google` — receives Google's `code` and `state`, calls `exchangeGoogleCode()`, upserts user (by email), generates a Pagent auth code (inserts into `auth_codes`), redirects to the MCP client's `redirect_uri?code=...&state=...`. +- `apps/api/auth/provider.ts` (new, partial) — begin the `OAuthServerProvider` implementation with the user upsert logic: + - `upsertUser(profile: { email, name?, avatarUrl? }): Promise` — INSERT ON CONFLICT(email) UPDATE name, avatar_url, updated_at. Auto-generates `handle` from email local part (with numeric suffix if taken). + - `createAuthCode(userId, clientId, redirectUri, codeChallenge, codeChallengeMethod, scope): Promise` — generates random code, inserts into `auth_codes` with 10-minute expiry. + +## Acceptance criteria + +- Login page renders valid HTML with a "Continue with Google" button. +- Google button redirects to `accounts.google.com/o/oauth2/v2/auth` with correct params. +- `state` parameter sent to Google is a signed JWT (HMAC-SHA256 with `AUTH_STATE_SECRET`) encoding the original authorize params (client_id, redirect_uri, code_challenge, scope, original state). +- State JWT is validated and verified on callback — tampered state is rejected. +- Google callback successfully upserts user with `email`, `name`, `avatar_url` from Google's ID token. +- `handle` is auto-generated from email local part, lowercased, validated against `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`, with numeric suffix if taken. +- Auth code is inserted with 10-minute expiry and PKCE challenge. +- `redirect_uri` is validated via exact string match against client's registered URIs. +- Invalid `client_id` returns error on the login page (not redirected). +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are read from env. + +## Dependencies + +- **01** — `users`, `auth_codes` tables must exist. +- **04** — `clients-store.ts` needed to look up `client_id` and validate `redirect_uri`. + +## Relevant spec sections + +- Section 3.4 (Authorization endpoint — login page, parameters, error cases) +- Section 3.7 (Google OAuth callback) +- Section 4.1 (MCP OAuth flow — full sequence diagram) +- Section 4.2 (Google OAuth flow — sequence diagram, state parameter encoding) +- Section 7.3 (Rate limiting — 30/IP/min for authorize) +- Section 7.5 (Open redirect prevention — exact match redirect_uri) +- Section 7.7 (Google OAuth state parameter — signed JWT) diff --git a/docs/superpowers/tasks/01-auth/06-magic-link-flow.md b/docs/superpowers/tasks/01-auth/06-magic-link-flow.md new file mode 100644 index 0000000..916adf6 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/06-magic-link-flow.md @@ -0,0 +1,49 @@ +# 06 — Magic Link flow + +## Description + +Implement passwordless email login via Magic Links: sending the email with a one-time token and verifying it on click. Reuses the user upsert and auth code generation from task 05. + +## Files to create/modify + +- `apps/api/auth/magic-link.ts` (new) — Magic link generation, validation, and email sending: + - `sendMagicLink(email: string, authorizeContext: AuthorizeContext): Promise` — generates a 32-byte random token, stores `SHA-256(token)` in `magic_links` with 15-minute expiry. Stores the authorize context (client_id, redirect_uri, code_challenge, scope, state) server-side keyed by the magic link token. Sends the email via `nodemailer`. + - `verifyMagicLink(token: string): Promise<{ email, authorizeContext }>` — looks up by `SHA-256(token)`, checks `expires_at > now()`, checks `consumed_at IS NULL`, marks `consumed_at = now()`. Returns the email and stored authorize context. + - `createTransport(): Transporter` — creates a nodemailer SMTP transport from `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` env vars. +- `apps/api/auth/routes.ts` — add routes: + - `POST /oauth/magic/send` — accepts `{ email }`, validates email format, calls `sendMagicLink()`. Always returns the same response ("check your email") regardless of whether the email exists. Rate-limited to 5/email/15min. + - `GET /oauth/magic` — accepts `?token=...`, calls `verifyMagicLink()`, upserts user by email, generates auth code, redirects to `redirect_uri?code=...&state=...`. +- `apps/api/auth/login-page.ts` — wire the email form to POST to `/oauth/magic/send`. Show "Check your email" message on success. +- `apps/api/auth/magic-link.test.ts` (new) — tests: + - Token generation and verification round-trip. + - Expired token is rejected. + - Already-consumed token is rejected. + - Rate limit (5/email/15min) is enforced. + - Email enumeration: response is identical for existing and non-existing emails. + +## Acceptance criteria + +- Magic link tokens are 32 bytes, stored as SHA-256 hashes (raw token never persisted). +- Token expires after 15 minutes. +- Token is single-use (`consumed_at` prevents replay). +- Authorize context is stored server-side (not in the email URL) to keep links short and avoid leaking OAuth params. +- Email is sent via `nodemailer` with `SMTP_*` env vars. +- `SMTP_FROM` defaults to `noreply@pagent.link`. +- Response to `/oauth/magic/send` does not reveal whether the email is registered (anti-enumeration, spec section 7.6). +- Rate limit: 5 per email per 15 minutes. +- If `SMTP_HOST` is not configured, `/oauth/magic/send` returns 503. + +## Dependencies + +- **01** — `magic_links` table must exist. +- **05** — `upsertUser()` and `createAuthCode()` from `provider.ts`, login page from `login-page.ts`. + +## Relevant spec sections + +- Section 2.6 (magic_links table schema) +- Section 3.8 (Magic Link verification endpoint) +- Section 4.3 (Magic Link flow — full sequence diagram) +- Section 7.3 (Rate limiting — 5/email/15min for magic/send) +- Section 7.6 (Email enumeration prevention) +- Section 9 (SMTP env vars) +- Section 10 (Dependencies — `nodemailer`) diff --git a/docs/superpowers/tasks/01-auth/07-token-exchange-and-refresh.md b/docs/superpowers/tasks/01-auth/07-token-exchange-and-refresh.md new file mode 100644 index 0000000..0f57633 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/07-token-exchange-and-refresh.md @@ -0,0 +1,55 @@ +# 07 — Token exchange and refresh + +## Description + +Implement the OAuth token endpoint (`POST /oauth/token`) supporting both the `authorization_code` and `refresh_token` grant types, plus the revocation endpoint (`POST /oauth/revoke`). This is where PKCE verification happens and JWT access tokens are minted. + +## Files to create/modify + +- `apps/api/auth/provider.ts` — extend the `OAuthServerProvider` with token operations: + - `exchangeAuthCode(code, clientId, redirectUri, codeVerifier): Promise` — looks up auth code, validates `consumed_at IS NULL`, verifies PKCE (`code_challenge === BASE64URL(SHA256(code_verifier))`), validates `client_id` and `redirect_uri` match, marks code as consumed. Calls `signAccessToken()` from `jwt.ts`, generates opaque refresh token (`rt_` + 32 random bytes), stores `SHA-256(refresh_token)` in `refresh_tokens` with 90-day expiry. Returns `{ access_token, token_type, expires_in, refresh_token, scope }`. + - `refreshToken(refreshToken, clientId): Promise` — looks up by `SHA-256(token)`, checks not expired, checks not revoked. If revoked: revoke ALL tokens for `(user_id, client_id)` pair (token family revocation). Rotates: inserts new refresh token, revokes old one (`revoked_at = now()`). Mints new access token. Returns same `TokenResponse` shape. + - `revokeToken(token, tokenTypeHint, clientId): Promise` — revokes the specified token. Always returns success per RFC 7009. +- `apps/api/auth/routes.ts` — add routes: + - `POST /oauth/token` — parses `application/x-www-form-urlencoded` body, dispatches on `grant_type` to `exchangeAuthCode()` or `refreshToken()`. Rate-limited to 20/IP/min. + - `POST /oauth/revoke` — parses body, calls `revokeToken()`. Returns 200 always. +- `apps/api/auth/provider.test.ts` (new) — tests: + - Authorization code exchange: valid code + verifier returns JWT + refresh token. + - PKCE failure: wrong `code_verifier` returns `invalid_grant`. + - Expired code returns `invalid_grant`. + - Consumed code returns `invalid_grant`. + - Refresh token exchange: returns new access token + new refresh token. + - Old refresh token is revoked after rotation. + - Presenting a revoked refresh token revokes the entire token family. + - Unsupported `grant_type` returns `unsupported_grant_type`. + - Token revocation always returns 200. + +## Acceptance criteria + +- Token endpoint accepts `application/x-www-form-urlencoded` (not JSON). +- PKCE verification uses S256 only: `BASE64URL(SHA256(code_verifier)) === code_challenge`. +- Access token is a JWT signed with Ed25519 (via `signAccessToken()`). +- Refresh token format: `rt_` prefix + 32 random bytes hex-encoded. +- Refresh tokens are stored as SHA-256 hashes. +- Refresh token rotation: every use issues a new refresh token and revokes the old one. +- Token family revocation: presenting a revoked refresh token revokes all tokens for that `(user_id, client_id)` pair. +- Auth code is single-use: `consumed_at` is set on first exchange. +- Rate limit: 20/IP/min on token endpoint. +- Error responses use OAuth 2.1 error format: `{ error, error_description }`. + +## Dependencies + +- **01** — `auth_codes`, `refresh_tokens` tables must exist. +- **02** — `signAccessToken()` from `jwt.ts`. +- **04** — `getClient()` from `clients-store.ts` for client_id validation. +- **05** — `createAuthCode()` must be working so codes exist to exchange. + +## Relevant spec sections + +- Section 3.5 (Token endpoint — request/response format, error cases) +- Section 3.6 (Token revocation — RFC 7009) +- Section 5.1 (Access token JWT format) +- Section 5.2 (Refresh tokens — opaque format, rotation, family revocation) +- Section 5.5 (Scopes) +- Section 7.1 (PKCE — S256 mandatory, plain forbidden) +- Section 7.3 (Rate limiting — 20/IP/min for token) diff --git a/docs/superpowers/tasks/01-auth/08-auth-middleware.md b/docs/superpowers/tasks/01-auth/08-auth-middleware.md new file mode 100644 index 0000000..d436b0d --- /dev/null +++ b/docs/superpowers/tasks/01-auth/08-auth-middleware.md @@ -0,0 +1,54 @@ +# 08 — Auth middleware (Hono + MCP) + +## Description + +Implement the two auth middleware layers: the Hono middleware for REST routes (`resolveAuth` + `requireAuth`) and the MCP handler middleware for Bearer token validation on `/mcp`. Wire them into `app.ts` and `server.ts`. + +## Files to create/modify + +- `apps/api/auth/middleware.ts` (new) — Hono middleware: + - `resolveAuth(): MiddlewareHandler` — checks for `pagent_session` cookie (resolves via session lookup) or `Authorization: Bearer` header (resolves via `verifyAccessToken()`). Sets `c.var.user` to `AuthUser | null`. Does NOT reject unauthenticated requests. + - `requireAuth(): MiddlewareHandler` — rejects with 401 JSON if `c.var.user` is null. + - Type exports: `AuthUser = { id, email, handle, authMethod: 'cookie' | 'bearer' }`, `AuthVariables = { user: AuthUser | null }`. +- `apps/api/auth/session.ts` (new) — session helpers: + - `lookupSession(token: string): Promise` — computes `SHA-256(token)`, queries `sessions JOIN users WHERE token_hash = hash AND expires_at > now()`. Extends `expires_at` by `SESSION_MAX_AGE_DAYS` (sliding expiry). + - `createSession(userId: string, ip?: string, userAgent?: string): Promise` — generates 128-bit random hex, stores `SHA-256(token)` in `sessions` with expiry. Returns the raw token for the cookie. + - `deleteSession(token: string): Promise` — deletes by `token_hash`. +- `apps/api/mcp/http.ts` — add Bearer token check before `StreamableHTTPServerTransport.handleRequest()`. On missing/invalid Bearer when `REQUIRE_AUTH=true`: return 401 with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource"`. +- `apps/api/app.ts` — mount `resolveAuth()` on `*`. Conditionally mount `requireAuth()` on `POST /new` when `REQUIRE_AUTH=true`. +- `apps/api/auth/middleware.test.ts` (new) — tests: + - Request with valid session cookie sets `c.var.user`. + - Request with valid Bearer JWT sets `c.var.user`. + - Request with no auth sets `c.var.user = null`. + - `requireAuth()` returns 401 when user is null. + - Expired session cookie is rejected. + - Invalid JWT is rejected. +- `apps/api/auth/session.test.ts` (new) — tests: + - `createSession` + `lookupSession` round-trip. + - Expired session returns null. + - `deleteSession` prevents future lookup. + - Sliding expiry extends `expires_at` on lookup. + +## Acceptance criteria + +- `resolveAuth()` is applied to ALL routes via `app.use('*', ...)`. +- Cookie-based auth uses `pagent_session` cookie name. +- Session tokens are stored as SHA-256 hashes in the DB (raw token only in cookie). +- Session lifetime: 30 days, sliding (each request extends by `SESSION_MAX_AGE_DAYS`). +- Bearer auth uses `verifyAccessToken()` from `jwt.ts` (no DB lookup). +- MCP handler returns 401 with `WWW-Authenticate` header pointing to resource metadata when auth is required but missing. +- `requireAuth()` is only applied when `REQUIRE_AUTH=true`. +- Read endpoints (`GET /:id`, `GET /:id/result`) remain public regardless of `REQUIRE_AUTH`. +- Existing unauthenticated behavior is preserved when `REQUIRE_AUTH=false`. + +## Dependencies + +- **01** — `sessions` table must exist. +- **02** — `verifyAccessToken()` from `jwt.ts`. + +## Relevant spec sections + +- Section 6 (Middleware design — all subsections 6.1 through 6.4) +- Section 4.1 (MCP OAuth flow — 401 discovery with WWW-Authenticate) +- Section 7.2 (Token storage — httpOnly cookie properties) +- Section 7.4 (CSRF protection — SameSite=Lax) diff --git a/docs/superpowers/tasks/01-auth/09-browser-session-support.md b/docs/superpowers/tasks/01-auth/09-browser-session-support.md new file mode 100644 index 0000000..2fe792b --- /dev/null +++ b/docs/superpowers/tasks/01-auth/09-browser-session-support.md @@ -0,0 +1,42 @@ +# 09 — Browser session support + +## Description + +Add browser-specific session endpoints (`POST /auth/logout`, `GET /auth/me`) and the browser session flow where the login page sets an httpOnly cookie after authentication. Support the `browser_session=1` query parameter for direct browser login without an MCP client. + +## Files to create/modify + +- `apps/api/auth/routes.ts` — add routes: + - `GET /auth/me` — requires session cookie (via `resolveAuth()`), returns current user profile `{ id, handle, email, name, avatar_url }`. Returns 401 if no valid session. + - `POST /auth/logout` — requires session cookie, calls `deleteSession()`, clears cookie with `Set-Cookie: pagent_session=; Max-Age=0; ...`, returns 200. + - Modify `GET /oauth/authorize` — when `browser_session=1` is present (and no `client_id`): after successful authentication, set session cookie and redirect to `/` instead of issuing an auth code. +- `apps/api/auth/routes.ts` — in the Google callback and Magic Link verification handlers: when the authorize context has `browser_session=1`, call `createSession()` and set the cookie: + - Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000` (30 days). +- `apps/api/auth/routes.test.ts` — add tests: + - `GET /auth/me` with valid session returns user profile. + - `GET /auth/me` without session returns 401. + - `POST /auth/logout` clears the session and cookie. + - Browser session flow (`browser_session=1`) sets cookie and redirects to `/`. + +## Acceptance criteria + +- `pagent_session` cookie is set only for browser-initiated auth flows. +- Cookie attributes: `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`, `Max-Age=2592000`. +- `GET /auth/me` returns the user profile from the session. +- `POST /auth/logout` deletes the DB session row and clears the cookie. +- `browser_session=1` authorize flow works without `client_id`, `redirect_uri`, or `code_challenge`. +- After browser login, redirect goes to `/` (not to a client redirect_uri). +- Session creation stores IP address and User-Agent from the request. + +## Dependencies + +- **05** — Google OAuth callback must be working. +- **06** — Magic Link verification must be working. +- **08** — `resolveAuth()` middleware and session helpers must be working. + +## Relevant spec sections + +- Section 3.9 (Browser session endpoints — /auth/me, /auth/logout) +- Section 4.4 (Browser session flow — cookie setting, browser_session=1 parameter) +- Section 7.2 (Token storage — cookie attributes) +- Section 7.4 (CSRF protection — SameSite=Lax, POST /auth/logout) diff --git a/docs/superpowers/tasks/01-auth/10-owner-id-integration.md b/docs/superpowers/tasks/01-auth/10-owner-id-integration.md new file mode 100644 index 0000000..b1fc621 --- /dev/null +++ b/docs/superpowers/tasks/01-auth/10-owner-id-integration.md @@ -0,0 +1,38 @@ +# 10 — Page owner_id integration and MCP auth wiring + +## Description + +Wire authenticated user identity into page creation so pages get an `owner_id` when created by an authenticated user. Update the MCP tools to pass auth info through, and update the stdio MCP server to forward `PAGENT_TOKEN` as a Bearer token. + +## Files to create/modify + +- `apps/api/store.ts` — modify `createPage()` and `createHtmlPage()` to accept an optional `ownerId?: string` parameter. Pass it through to `db.insertPage()`. +- `apps/api/db.ts` — modify `insertPage()` to include `owner_id` in the INSERT when provided. +- `apps/api/app.ts` — in the `POST /new` handler, extract `c.var.user?.id` and pass it as `ownerId` to `createPage()` / `createHtmlPage()`. +- `apps/api/mcp/tools.ts` — modify the `show_ui` and `show_html` tool handlers to extract `auth.extra.sub` (user ID from the JWT) and pass it as `ownerId` to the store functions. +- `apps/api/mcp/http.ts` — ensure `(req as any).auth` from the Bearer middleware (task 08) is forwarded to the `StreamableHTTPServerTransport` so tool handlers receive it. +- `apps/mcp/src/index.ts` (or equivalent stdio MCP entry) — if `PAGENT_TOKEN` env var is set, include `Authorization: Bearer ${PAGENT_TOKEN}` in HTTP requests to `SERVICE_URL`. +- `apps/api/db.test.ts` — add test: page created with `ownerId` has the correct FK. Page created without `ownerId` has `owner_id = NULL`. +- `apps/api/app.test.ts` — add test: authenticated `POST /new` produces a page with `owner_id`. + +## Acceptance criteria + +- Pages created by authenticated users (cookie or Bearer) have `owner_id` set to the user's UUID. +- Pages created by unauthenticated users (grace period) have `owner_id = NULL`. +- MCP tool handlers receive auth info and pass `ownerId` through to page creation. +- The stdio MCP server (`apps/mcp`) includes `Authorization: Bearer` header when `PAGENT_TOKEN` is set. +- Existing unauthenticated page creation still works when `REQUIRE_AUTH=false`. +- No behavioral changes to read endpoints (`GET /:id`, `GET /:id/result`). + +## Dependencies + +- **01** — `owner_id` column on `pages` must exist. +- **08** — auth middleware must be working (both Hono and MCP). + +## Relevant spec sections + +- Section 2.7 (Changes to pages — owner_id FK) +- Section 6.5 (owner_id injection — store.ts and db changes) +- Section 8 (Migration plan — grace period behavior) +- Section 8.4 (Backward compatibility guarantees) +- Section 9 (Environment variables — PAGENT_TOKEN for stdio MCP) diff --git a/docs/superpowers/tasks/02-file-uploads/01-env-schema-db-migration.md b/docs/superpowers/tasks/02-file-uploads/01-env-schema-db-migration.md new file mode 100644 index 0000000..ffe1f74 --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/01-env-schema-db-migration.md @@ -0,0 +1,37 @@ +# 01 — Environment variables, dependencies & database migration + +## Description + +Add Supabase env vars to the env schema, install `@supabase/supabase-js` and `file-type` npm dependencies, and add the `files` table and index to the database bootstrap in `db.ts`. + +## Files to create/modify + +- `apps/api/schemas.ts` — add `SUPABASE_URL` (z.string().url(), required), `SUPABASE_SERVICE_ROLE_KEY` (z.string().min(1), required), and `FILE_MAX_SIZE_MB` (z.coerce.number().int().positive().max(50).default(10)) to `envSchema`. +- `apps/api/schemas.test.ts` — tests for new env vars: parsing succeeds with valid values, fails when `SUPABASE_URL` or `SUPABASE_SERVICE_ROLE_KEY` are missing, `FILE_MAX_SIZE_MB` defaults to 10. +- `apps/api/db.ts` — add `FileRow` type export. Add `CREATE TABLE IF NOT EXISTS files` with all columns from the spec (id, page_id, field_name, storage_path, original_name, mime_type, size_bytes, uploaded_by, created_at) and `CREATE INDEX IF NOT EXISTS files_page_id_idx` to the `init()` function, after the `pages` table migration. Add `insertFile()`, `getFilesByPageId()`, `getFileById()`, `getExpiredFilesPaths()` query functions. +- `apps/api/db.test.ts` — tests verifying the `files` table is created idempotently, `insertFile` round-trips correctly, CASCADE delete from `pages` removes `files` rows, `getExpiredFilesPaths` returns paths for expired pages only. +- `package.json` (root or `apps/api`) — install `@supabase/supabase-js@^2.49` and `file-type@^19.6`. +- `apps/api/.env.example` (if it exists) — document `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `FILE_MAX_SIZE_MB`. + +## Acceptance criteria + +- `envSchema` parses successfully with valid `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` values. +- `envSchema` fails with a clear error when `SUPABASE_URL` is missing. +- `FILE_MAX_SIZE_MB` defaults to 10 when omitted, accepts values 1-50, rejects 0 and negatives. +- `files` table is created idempotently on `db.init()` — running init twice does not error. +- `files.page_id` has `ON DELETE CASCADE` referencing `pages(id)`. +- `files_page_id_idx` index exists on `files(page_id)`. +- `FileRow` type is exported from `db.ts`. +- `insertFile`, `getFilesByPageId`, `getFileById`, `getExpiredFilesPaths` are exported and tested. +- `@supabase/supabase-js` and `file-type` are in `dependencies` (not devDependencies). +- Existing tests continue to pass (no regressions). + +## Dependencies + +None — this is the foundation task. + +## Relevant spec sections + +- Section 2 (Database schema) — full section including design notes and `db.ts` additions +- Section 10 (Environment variables) — full table and schema additions +- Section 11 (Dependencies) — 11.1 `apps/api` new dependencies diff --git a/docs/superpowers/tasks/02-file-uploads/02-storage-module.md b/docs/superpowers/tasks/02-file-uploads/02-storage-module.md new file mode 100644 index 0000000..5cb97ae --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/02-storage-module.md @@ -0,0 +1,34 @@ +# 02 — Supabase Storage module + +## Description + +Create `apps/api/storage.ts` — the Supabase Storage client wrapper providing `uploadFile`, `createSignedUrl`, and `deleteFiles` functions. This module centralizes all interactions with the `page-files` bucket. + +## Files to create/modify + +- `apps/api/storage.ts` — new file. Initialize a Supabase client using `env.SUPABASE_URL` and `env.SUPABASE_SERVICE_ROLE_KEY`. Export three functions: + - `uploadFile(storagePath: string, fileBuffer: Buffer, mimeType: string): Promise` — uploads to `page-files` bucket with `upsert: false`. + - `createSignedUrl(storagePath: string, expirySeconds: number): Promise` — returns a signed URL from `page-files` bucket. + - `deleteFiles(paths: string[]): Promise` — batch-deletes from `page-files` bucket in chunks of 1000. Logs but does not throw on batch failure (orphan tolerance). +- `apps/api/storage.test.ts` — unit tests that mock `@supabase/supabase-js` and verify: upload passes correct params, signed URL returns the URL, deleteFiles batches correctly for >1000 paths, deleteFiles logs errors without throwing. + +## Acceptance criteria + +- `storage.ts` exports `uploadFile`, `createSignedUrl`, `deleteFiles`. +- `uploadFile` calls `supabase.storage.from('page-files').upload()` with `contentType` and `upsert: false`. +- `uploadFile` throws on storage errors. +- `createSignedUrl` calls `supabase.storage.from('page-files').createSignedUrl()` and returns `data.signedUrl`. +- `createSignedUrl` throws on storage errors. +- `deleteFiles` processes paths in batches of 1000. +- `deleteFiles` logs errors via `logger` or `console.error` but does not throw, to avoid breaking the TTL sweep. +- The Supabase client is initialized lazily or at import time from env vars. +- All tests pass. + +## Dependencies + +- Task 01 (env vars for `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`; `@supabase/supabase-js` installed) + +## Relevant spec sections + +- Section 4 (Supabase Storage integration) — 4.1 bucket config, 4.3 signed URLs, 4.4 upload, 4.5 bulk deletion +- Section 9.2 (Storage deletion helper) diff --git a/docs/superpowers/tasks/02-file-uploads/03-file-upload-endpoint.md b/docs/superpowers/tasks/02-file-uploads/03-file-upload-endpoint.md new file mode 100644 index 0000000..9bdb4cb --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/03-file-upload-endpoint.md @@ -0,0 +1,50 @@ +# 03 — POST /:id/files upload endpoint & validation + +## Description + +Add the `POST /:id/files` multipart endpoint that accepts a file upload for a specific page and field. This includes the spec-walker (`findFileComponent`), MIME type detection via `file-type`, filename sanitization, and all validation logic from the spec. + +## Files to create/modify + +- `apps/api/app.ts` — register `POST /:id/files` route with `bodyLimit({ maxSize: 11 * 1024 * 1024 })`. Handler (`uploadFileHandler`) implements the full validation sequence: parse page ID, verify format is `a2ui`, verify state is `open`, parse multipart body (`c.req.parseBody()`), look up `field_name` in spec, verify component is `FileInput`, check no existing file for `(page_id, field_name)`, validate file size against `maxSizeMB`, detect and validate MIME type, sanitize filename, upload to storage, insert `files` row, return 201 with file metadata. +- `apps/api/file-validation.ts` — new file. Export: + - `findFileComponent(spec: unknown, fieldName: string): FileInputComponent | null` — walks A2UI spec `updateComponents` messages to find a `FileInput` component by id. + - `detectMimeType(buffer: Buffer): Promise` — uses `fileTypeFromBuffer` from `file-type` package, falls back to `application/octet-stream`. + - `matchesAcceptFilter(detectedMime: string, accept: string): boolean` — checks against comma-separated accept list (exact MIME, wildcard MIME like `image/*`, extensions like `.pdf`). + - `sanitizeFilename(name: string): string` — strips `/`, `\`, null bytes, caps at 255 chars. + - `FileInputComponent` type export. +- `apps/api/file-validation.test.ts` — tests for all exported functions: `findFileComponent` finds the right component and returns null for missing/wrong type; `detectMimeType` detects PDF, PNG, falls back to octet-stream; `matchesAcceptFilter` handles exact, wildcard, and extension matching; `sanitizeFilename` strips dangerous chars and truncates. +- `apps/api/app.test.ts` — integration tests for `POST /:id/files`: successful upload returns 201 with correct shape, 400 for missing field_name, 400 for non-FileInput field, 400 for html-format pages, 400 for file too large, 400 for invalid MIME type, 400 for duplicate upload, 404 for missing page, 409 for already-submitted page, 413 for oversized body. +- `apps/api/metrics.ts` — add `filesUploaded` counter (`pagent.files.uploaded`) and `fileUploadSize` histogram (`pagent.files.upload.size`, unit: bytes). + +## Acceptance criteria + +- `POST /:id/files` accepts `multipart/form-data` with `field_name` (text) and `file` (binary) parts. +- Returns 201 with `{ file_id, field_name, original_name, mime_type, size_bytes }` on success. +- Returns 400 `bad_request` when `field_name` or `file` part is missing. +- Returns 400 `invalid_field_type` when the field exists but is not `type: "FileInput"`. +- Returns 400 `invalid_for_format` when page format is `html`. +- Returns 400 `file_too_large` when file exceeds field's `maxSizeMB` (or global default 10 MB). +- Returns 400 `invalid_mime_type` when detected MIME does not match field's `accept` filter. +- Returns 400 `file_already_uploaded` when a file already exists for `(page_id, field_name)`. +- Returns 404 `not_found` for missing or expired pages. +- Returns 409 `conflict` when page state is not `open`. +- Storage path follows `{page_id}/{field_name}/{uuid}.{ext}` convention. +- MIME type is detected from file content (magic numbers), not trusted from the upload header. +- `filesUploaded` counter is incremented on successful upload. +- `fileUploadSize` histogram records the file size in bytes. +- Original filename is sanitized before storage in the `files` table. +- All tests pass. + +## Dependencies + +- Task 01 (database `files` table, `insertFile`, `getFilesByPageId`, env vars) +- Task 02 (`storage.uploadFile`) + +## Relevant spec sections + +- Section 3.1 (POST /:id/files) — full endpoint spec including validation sequence +- Section 5 (A2UI spec extension) — 5.1 FileInputComponent type, 5.3 validation/spec walker +- Section 8 (File validation) — 8.1 MIME checking, 8.2 size limits, 8.4 filename sanitization +- Section 11.4 (Hono multipart parsing) +- Appendix B (Metrics additions) diff --git a/docs/superpowers/tasks/02-file-uploads/04-result-endpoints-update.md b/docs/superpowers/tasks/02-file-uploads/04-result-endpoints-update.md new file mode 100644 index 0000000..1aa0ece --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/04-result-endpoints-update.md @@ -0,0 +1,51 @@ +# 04 — Update POST /:id/result and GET /:id/result for file references + +## Description + +Extend the result submission endpoint to validate `__file_id` references and handle inline multipart file uploads. Extend the result retrieval endpoint to hydrate file references with signed download URLs via a new `hydrateFileUrls` function in `store.ts`. + +## Files to create/modify + +- `apps/api/app.ts` — update `POST /:id/result` handler: + - Accept `multipart/form-data` in addition to existing `application/json`. When multipart: extract the `action` text part (JSON-encoded A2UI action) and file binary parts (keyed by field name). For each file part, upload to storage, insert into `files`, then rewrite the action's context to include `{ "__file_id": fileId }`. + - For JSON submissions: walk `context` and validate each `__file_id` reference — verify the file exists in the `files` table and belongs to this page. Return 400 `invalid_file_reference` if not. + - For each `FileInput` field with `required: true` in the page spec, verify either an inline file or a valid `__file_id` is present. Return 400 `missing_required_file` if not. +- `apps/api/store.ts` — add `hydrateFileUrls(result: unknown, pageId: string, expiresAt: number): Promise` function: + - Walks the result's `context` object. + - For each value that is an object with a `__file_id` key, looks up the file via `db.getFileById()`. + - Generates a signed URL via `storage.createSignedUrl()` with expiry = `max(remainingTtlSeconds, 300)`. + - Replaces the `__file_id` object with `{ file_id, original_name, mime_type, size_bytes, download_url }`. + - Call `hydrateFileUrls` in `advanceResult()` (or the GET handler) before returning the result. +- `apps/api/app.ts` — update `GET /:id/result` handler to call `hydrateFileUrls` on the result before responding. +- `apps/api/app.test.ts` — tests for: + - JSON submission with valid `__file_id` succeeds. + - JSON submission with invalid/foreign `__file_id` returns 400. + - Multipart submission with inline file upload succeeds. + - Submission missing a required file field returns 400 `missing_required_file`. + - GET result returns hydrated file metadata with `download_url` instead of raw `__file_id`. + - Signed URL expiry is clamped to floor of 300 seconds. +- `apps/api/store.test.ts` — unit tests for `hydrateFileUrls`: replaces `__file_id` objects, passes through non-file values, handles missing files gracefully. + +## Acceptance criteria + +- `POST /:id/result` with `Content-Type: application/json` validates `__file_id` references against the `files` table. +- Returns 400 `invalid_file_reference` for `__file_id` values that don't exist or belong to a different page. +- Returns 400 `missing_required_file` when a `required: true` FileInput field has no file. +- `POST /:id/result` with `Content-Type: multipart/form-data` uploads inline files, inserts DB rows, and rewrites context with `__file_id`. +- `GET /:id/result` replaces `__file_id` objects with full file metadata including `download_url`. +- Signed URL expiry = `max(floor((expiresAt - now) / 1000), 300)`. +- Non-file context values pass through unchanged. +- Existing non-file result submissions continue to work without regression. +- All tests pass. + +## Dependencies + +- Task 01 (database queries: `getFileById`, `getFilesByPageId`, `insertFile`) +- Task 02 (`storage.createSignedUrl`, `storage.uploadFile`) +- Task 03 (`findFileComponent` from `file-validation.ts` — needed to identify required file fields) + +## Relevant spec sections + +- Section 3.2 (POST /:id/result — updated) — multipart handling, `__file_id` validation, required file checks +- Section 3.3 (GET /:id/result — updated) — `hydrateFileUrls`, signed URL expiry calculation +- Section 4.3 (Signed URLs) — expiry calculation formula diff --git a/docs/superpowers/tasks/02-file-uploads/05-ttl-sweep-update.md b/docs/superpowers/tasks/02-file-uploads/05-ttl-sweep-update.md new file mode 100644 index 0000000..2e9e512 --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/05-ttl-sweep-update.md @@ -0,0 +1,37 @@ +# 05 — TTL sweep: delete storage blobs before expired pages + +## Description + +Update the TTL sweep in `server.ts` to delete file blobs from Supabase Storage before the DB CASCADE removes `files` rows. Add the `filesOrphaned` metric for failed storage deletes. + +## Files to create/modify + +- `apps/api/server.ts` — update the `sweepTimer` setInterval callback: + 1. Call `db.getExpiredFilesPaths()` to collect storage paths for files belonging to expired pages. + 2. If paths exist, call `storage.deleteFiles(expiredPaths)` to remove blobs from Supabase Storage. + 3. Log the count of deleted file blobs at `debug` level. + 4. Then call `db.deleteExpiredPages()` as before (CASCADE removes `files` rows). + - Import `storage` from `./storage.ts`. +- `apps/api/metrics.ts` — add `filesOrphaned` counter (`pagent.files.orphaned`, description: "File blobs that failed to delete from storage during sweep"). +- `apps/api/storage.ts` — update `deleteFiles` to increment `metrics.filesOrphaned` counter on batch failure (in addition to logging the error). +- `apps/api/server.test.ts` or `apps/api/app.test.ts` — test that the sweep ordering is correct: `getExpiredFilesPaths` is called before `deleteExpiredPages`, and `storage.deleteFiles` is called with the returned paths. + +## Acceptance criteria + +- Sweep calls `db.getExpiredFilesPaths()` before `db.deleteExpiredPages()`. +- Sweep calls `storage.deleteFiles()` with the collected paths when there are expired files. +- If `storage.deleteFiles()` fails (logged, not thrown), the sweep still proceeds to delete expired pages. +- `metrics.filesOrphaned` counter is incremented when a storage batch delete fails. +- Sweep logs the count of deleted file blobs at `debug` level. +- Existing sweep behavior (deleting expired pages, counting abandoned pages) is preserved. +- All tests pass. + +## Dependencies + +- Task 01 (`db.getExpiredFilesPaths`) +- Task 02 (`storage.deleteFiles`) + +## Relevant spec sections + +- Section 9 (Cleanup logic) — 9.1 updated sweep, 9.3 orphan protection, 9.4 ordering guarantee +- Appendix B (Metrics additions) — `pagent.files.orphaned` diff --git a/docs/superpowers/tasks/02-file-uploads/06-frontend-file-input.md b/docs/superpowers/tasks/02-file-uploads/06-frontend-file-input.md new file mode 100644 index 0000000..98fe607 --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/06-frontend-file-input.md @@ -0,0 +1,45 @@ +# 06 — Frontend FileInput component & form submission integration + +## Description + +Create the `PagentFileInput` Lit web component for the browser renderer and integrate it into the form submission flow. The component renders a file input with drag-and-drop, client-side validation, immediate upload to `POST /:id/files`, and wires the resulting `file_id` into the action context on submit. + +## Files to create/modify + +- `apps/web/file-input.ts` — new file. `PagentFileInput` Lit component (``) with: + - Properties: `accept`, `maxSizeMB` (number, default 10), `required` (boolean), `label`, `description`, `fieldName`. + - State: `selectedFile`, `uploading`, `uploadProgress`, `uploadedFileId`, `error`. + - Renders: label (with required indicator), dropzone with ``, file preview (name, size, uploaded badge, remove button), progress bar during upload, error message, description text. + - Event handlers: `onFileSelected` (from input change), `onDrop`/`onDragOver` (drag-and-drop), `removeFile` (clear selection). + - Client-side validation: check file size against `maxSizeMB`, check extension/type against `accept`. Show inline error without uploading if invalid. + - Upload: on valid file selection, POST to `/${pageId}/files` as `multipart/form-data` with `field_name` and `file` parts. Store returned `file_id` in `uploadedFileId`. + - Styles: shadcn-aligned, consistent with existing Pagent component styling. +- `apps/web/main.ts` — update the A2UI component rendering logic: + - Import `./file-input.ts`. + - When encountering a component with `component: "FileInput"` in the surface tree, render `` with the component's props mapped to element attributes. + - Update the action/submit handler (the `MessageProcessor` callback): before POSTing to `/:id/result`, scan the action's `context` for keys whose corresponding component is a `FileInput`. Replace each value with `{ "__file_id": fileId }` from the component's `uploadedFileId`. If a required `FileInput` has no `uploadedFileId`, block submission and show a validation error. + +## Acceptance criteria + +- `` is registered as a custom element. +- File picker opens on click and accepts files matching `accept` attribute. +- Drag-and-drop onto the dropzone selects the file. +- Client-side validation rejects files exceeding `maxSizeMB` with an inline error. +- Client-side validation rejects files not matching `accept` with an inline error. +- Valid file selection triggers immediate upload to `POST /${pageId}/files`. +- Upload progress is visible during upload. +- After successful upload, an "Uploaded" badge and remove button are shown. +- Remove button clears the selected file and `uploadedFileId`. +- On form submission, `__file_id` references are injected into the action context. +- Submission is blocked if a required file field has no uploaded file. +- Component styling is consistent with existing Pagent/shadcn patterns. +- No new npm dependencies added to `apps/web`. + +## Dependencies + +- Task 03 (`POST /:id/files` endpoint must exist for the upload to succeed) + +## Relevant spec sections + +- Section 5 (A2UI spec extension) — 5.1 FileInputComponent schema, 5.2 spec example +- Section 6 (Frontend renderer changes) — 6.1 FileInput component, 6.2 upload flow, 6.3 form submission integration, 6.4 alternative discussion diff --git a/docs/superpowers/tasks/02-file-uploads/07-mcp-tool-descriptions-and-docs.md b/docs/superpowers/tasks/02-file-uploads/07-mcp-tool-descriptions-and-docs.md new file mode 100644 index 0000000..74bf80b --- /dev/null +++ b/docs/superpowers/tasks/02-file-uploads/07-mcp-tool-descriptions-and-docs.md @@ -0,0 +1,36 @@ +# 07 — MCP tool descriptions & OpenAPI docs + +## Description + +Update the MCP tool description strings so agents know `FileInput` exists, and update the OpenAPI spec to document the new `POST /:id/files` endpoint and the updated schemas for `POST /:id/result` and `GET /:id/result`. + +## Files to create/modify + +- `apps/api/mcp/tools.ts` — update `SHOW_UI_DESCRIPTION` and/or `SHOW_UI_INPUT_DESCRIPTION` strings to mention the `FileInput` component type with an inline example: `{ id: "upload", component: "FileInput", accept: ".pdf,.png", maxSizeMB: 5, required: true, label: "Upload file" }`. +- `apps/api/mcp/tools.test.ts` — verify the updated description strings contain "FileInput". +- `docs/openapi.yaml` — add: + - `POST /{id}/files` endpoint with multipart request body schema, 201 response schema, and all error responses (400, 404, 409, 413, 429, 500). + - Updated `POST /{id}/result` request body to document multipart alternative and `__file_id` context references. + - Updated `GET /{id}/result` 200 response to show hydrated file metadata shape (`file_id`, `original_name`, `mime_type`, `size_bytes`, `download_url`). + - `FileUploadResponse` schema component. + - `FileMetadata` schema component (for hydrated result). + +## Acceptance criteria + +- `SHOW_UI_DESCRIPTION` or `SHOW_UI_INPUT_DESCRIPTION` mentions `FileInput` with props: `accept`, `maxSizeMB`, `required`, `label`. +- An agent reading the tool description can understand how to add a file upload field to a spec. +- `docs/openapi.yaml` documents `POST /{id}/files` with correct request/response shapes. +- `docs/openapi.yaml` documents the updated result endpoints with file metadata. +- The OpenAPI spec validates (no YAML/schema errors). +- MCP tool description tests pass. + +## Dependencies + +- Task 03 (upload endpoint exists — descriptions should match its actual behavior) +- Task 04 (result endpoint updates exist — OpenAPI should match) + +## Relevant spec sections + +- Section 7 (MCP tool changes) — 7.1 show_ui (no changes), 7.3 tool description updates, 7.4 check_result (no changes) +- Section 3 (API endpoints) — all subsections for OpenAPI documentation +- Appendix A (Migration checklist) — items 12, 15 diff --git a/docs/superpowers/tasks/03-webhooks/01-db-schema-and-types.md b/docs/superpowers/tasks/03-webhooks/01-db-schema-and-types.md new file mode 100644 index 0000000..32697cc --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/01-db-schema-and-types.md @@ -0,0 +1,79 @@ +# 01 -- Database schema and type changes + +## Description + +Add `webhook_url` and `webhook_secret` columns to the `pages` table and +update all TypeScript types, queries, and data-access functions that +touch page rows. This is the foundation every other webhook task builds on. + +## Files to create/modify + +- `apps/api/db.ts` -- modify +- `apps/api/db.test.ts` -- modify + +## Changes + +### `apps/api/db.ts` + +1. **`Page` type** -- add `webhookUrl?: string | null` and + `webhookSecret?: string | null`. + +2. **`PageRow` (internal)** -- add matching `webhook_url` and + `webhook_secret` snake_case fields. + +3. **`init()`** -- add two idempotent `ALTER TABLE` migrations after the + existing `format` migration: + + ```sql + ALTER TABLE pages ADD COLUMN IF NOT EXISTS webhook_url text; + ALTER TABLE pages ADD COLUMN IF NOT EXISTS webhook_secret text; + ``` + + Also add both columns to the `CREATE TABLE IF NOT EXISTS` statement + for fresh deployments. + +4. **`insertPage()`** -- write `webhook_url` and `webhook_secret` when + present; store `NULL` when absent. + +5. **`getActivePage()`** -- select `webhook_url` and `webhook_secret` so + downstream handlers can read them without a follow-up query. + +6. **`submitPage()`** -- the `SubmitOutcome` `'ok'` variant gains + `webhookUrl?: string | null` and `webhookSecret?: string | null`. + The `UPDATE ... RETURNING` clause adds `webhook_url, webhook_secret` + to the projection. + +### `apps/api/db.test.ts` + +- Add a test that inserts a page with both webhook fields, then reads it + back via `getActivePage` and asserts the fields round-trip correctly. +- Add a test that inserts a page without webhook fields and asserts both + fields are `null`. +- Add a test that `submitPage` returns the webhook fields in the `'ok'` + outcome. + +## Acceptance criteria + +- [ ] `CREATE TABLE` includes `webhook_url text` and `webhook_secret text` + as nullable columns. +- [ ] `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` runs on boot for both + columns (idempotent migration pattern matches the `format` column). +- [ ] `Page` type has `webhookUrl?: string | null` and + `webhookSecret?: string | null`. +- [ ] `insertPage` persists both fields (or NULL when omitted). +- [ ] `getActivePage` returns both fields. +- [ ] `submitPage` `'ok'` outcome includes `webhookUrl` and + `webhookSecret`. +- [ ] Existing db tests remain green (no regressions). +- [ ] New db tests pass. + +## Dependencies + +None -- this is the first task. + +## Relevant spec sections + +- Section 2: Database schema changes +- Section 2: TypeScript type changes +- Section 2: Data access changes +- Section 2: Security: secret storage diff --git a/docs/superpowers/tasks/03-webhooks/02-webhook-delivery-engine.md b/docs/superpowers/tasks/03-webhooks/02-webhook-delivery-engine.md new file mode 100644 index 0000000..1f704aa --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/02-webhook-delivery-engine.md @@ -0,0 +1,126 @@ +# 02 -- Webhook delivery engine + +## Description + +Create the core `apps/api/webhook.ts` module that handles payload +construction, HMAC signing, delivery with retry, and SSRF prevention. +This is a self-contained module with no coupling to HTTP handlers or +MCP tools -- it exports `deliverWebhook()` and the supporting types. + +## Files to create/modify + +- `apps/api/webhook.ts` -- **new** +- `apps/api/webhook.test.ts` -- **new** +- `apps/api/webhook-ssrf.test.ts` -- **new** + +## Changes + +### `apps/api/webhook.ts` + +1. **Types** -- export `WebhookPayload` and `WebhookFileRef`: + + ```ts + type WebhookFileRef = { + file_id: string; + field_name: string; + original_name: string; + mime_type: string; + size_bytes: number; + download_url: string; + }; + + type WebhookPayload = { + event: 'page.submitted'; + page_id: string; + submission_id: string; + mode: 'single' | 'public'; + result: unknown; + submitted_at: string; + submitted_by: string | null; + files: WebhookFileRef[]; + }; + ``` + +2. **`signPayload(secret, body)`** -- HMAC-SHA256 signature function + returning `"sha256="`. Uses `node:crypto` `createHmac`. + +3. **`isPrivateHost(hostname)`** -- DNS-based SSRF check. Resolves + hostname via `dns.promises.lookup()`, checks resolved IP against + blocked CIDRs (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, + 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10, 0.0.0.0). + IP literals checked directly via `net.isIP()`. Bypassed when + `WEBHOOK_ALLOW_PRIVATE_IPS` env var is `true`. + +4. **`deliverWebhook(opts)`** -- main entry point. Parameters: + `pageId`, `submissionId`, `mode`, `webhookUrl`, `webhookSecret?`, + `result`, `submittedAt`, `submittedBy?`, `files?`, `log`. + - Constructs `WebhookPayload`, serializes to JSON once. + - Computes HMAC signature if secret is provided. + - Runs SSRF check on the URL hostname before first attempt. + - Retries up to 3 times with delays [0ms, 1000ms, 5000ms] and + +/-25% jitter. + - Sets `redirect: 'error'` on fetch to prevent redirect-based SSRF. + - Sets `AbortSignal.timeout(10_000)` per attempt. + - Sends headers: `Content-Type`, `User-Agent: Pagent-Webhook/1.0`, + `X-Pagent-Event`, `X-Pagent-Delivery` (UUID), and + `X-Pagent-Signature` (when secret set). + - Success: any 2xx. No retry on 4xx (except 429). Retry on 5xx, + 429, network errors. + - Logs via provided `log` object (info on success, warn on retry, + error on final failure). Never logs `webhookSecret`. + - Records metrics (see task 03). + +### `apps/api/webhook.test.ts` + +Unit tests with mocked `fetch` (14 tests per spec section 9): + +1. Successful delivery on first attempt (200). +2. Retry on 500 then succeed (200). +3. Retry on network error then succeed. +4. Permanent failure after 3 retries (always 502). +5. No retry on 4xx (except 429) -- single attempt, 400. +6. Retry on 429 then succeed. +7. HMAC signing with known secret matches expected value. +8. No `X-Pagent-Signature` header when secret absent. +9. Timeout handling (mock fetch to hang). +10. Payload shape matches `WebhookPayload` schema. +11. Payload includes `submitted_by` when authenticated. +12. Payload `submitted_by` is `null` when unauthenticated. +13. Payload includes `files` array. +14. Public-mode `submission_id` is unique per delivery. + +### `apps/api/webhook-ssrf.test.ts` + +SSRF prevention tests (5 tests per spec section 9): + +1. Block each private IP CIDR (mock DNS). +2. Allow public IPs. +3. Block `localhost` in production. +4. Allow `localhost` when `WEBHOOK_ALLOW_PRIVATE_IPS=true`. +5. Block IP literal in URL (e.g., `https://169.254.169.254/metadata`). + +## Acceptance criteria + +- [ ] `deliverWebhook` is exported from `apps/api/webhook.ts`. +- [ ] `signPayload` produces correct HMAC-SHA256 signatures. +- [ ] SSRF check blocks all listed private CIDRs before `fetch` is called. +- [ ] SSRF check is bypassed when `WEBHOOK_ALLOW_PRIVATE_IPS=true`. +- [ ] Retry logic follows [0ms, 1s, 5s] schedule with jitter. +- [ ] 4xx (except 429) terminates immediately without retry. +- [ ] 5xx and 429 are retried. +- [ ] `redirect: 'error'` is set on all fetch calls. +- [ ] 10s timeout per attempt via `AbortSignal.timeout`. +- [ ] `webhookSecret` never appears in any log message. +- [ ] All 14 unit tests and 5 SSRF tests pass. + +## Dependencies + +- **01** (db schema) -- `WebhookPayload` type references fields that + flow from the `SubmitOutcome` added in task 01. + +## Relevant spec sections + +- Section 5: Webhook delivery logic (payload, HMAC, delivery request) +- Section 6: Retry strategy +- Section 7: Security considerations (SSRF, secret handling, redirects) +- Section 9: Testing strategy (unit tests, SSRF tests) diff --git a/docs/superpowers/tasks/03-webhooks/03-metrics-and-env.md b/docs/superpowers/tasks/03-webhooks/03-metrics-and-env.md new file mode 100644 index 0000000..e4016f2 --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/03-metrics-and-env.md @@ -0,0 +1,73 @@ +# 03 -- Metrics and environment config + +## Description + +Add webhook-specific OTel metrics to the metrics module and the +`WEBHOOK_ALLOW_PRIVATE_IPS` env var to the env schema. These are +imported by the webhook delivery engine (task 02) and the submit +handler (task 04). + +## Files to create/modify + +- `apps/api/metrics.ts` -- modify +- `apps/api/schemas.ts` -- modify +- `apps/api/metrics.test.ts` -- modify (if it asserts metric names) + +## Changes + +### `apps/api/metrics.ts` + +Add three new instruments to the `metrics` object on the existing +`pagent-api` meter: + +```ts +webhookDeliveries: meter.createCounter('pagent.webhook.deliveries', { + description: 'Webhook delivery outcomes by status (success/rejected/failed/ssrf_blocked)', +}), +webhookDeliveryDuration: meter.createHistogram('pagent.webhook.delivery.duration', { + description: 'Time from submission to final webhook delivery outcome', + unit: 's', +}), +webhookDeliveryAttempts: meter.createHistogram('pagent.webhook.delivery.attempts', { + description: 'Number of attempts per webhook delivery (1-3)', +}), +``` + +Labels on `webhookDeliveries`: `status` = +`success` | `rejected` | `failed` | `ssrf_blocked`. + +### `apps/api/schemas.ts` + +Add `WEBHOOK_ALLOW_PRIVATE_IPS` to `envSchema`: + +```ts +WEBHOOK_ALLOW_PRIVATE_IPS: z + .enum(['true', 'false']) + .optional() + .default('false') + .transform((v) => v === 'true'), +``` + +### `apps/api/metrics.test.ts` + +If the test file asserts the set of metric names, add the three new +instruments. + +## Acceptance criteria + +- [ ] `metrics.webhookDeliveries` is a counter on the `pagent-api` meter. +- [ ] `metrics.webhookDeliveryDuration` is a histogram (unit: `s`). +- [ ] `metrics.webhookDeliveryAttempts` is a histogram. +- [ ] `env.WEBHOOK_ALLOW_PRIVATE_IPS` is parsed as a boolean, defaults + to `false`. +- [ ] Existing metrics tests remain green. + +## Dependencies + +- **01** (db schema) -- no direct code dependency, but should land in + the same PR so the delivery engine can import both. + +## Relevant spec sections + +- Section 8: Observability (OTel metrics) +- Section 10: Environment variables diff --git a/docs/superpowers/tasks/03-webhooks/04-api-handler-integration.md b/docs/superpowers/tasks/03-webhooks/04-api-handler-integration.md new file mode 100644 index 0000000..5b041ef --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/04-api-handler-integration.md @@ -0,0 +1,103 @@ +# 04 -- API handler integration (POST /new and POST /:id/result) + +## Description + +Wire webhook config into the REST API handlers: accept `webhook_url` +and `webhook_secret` on page creation, and fire the webhook on +submission. After this task, webhooks work end-to-end for REST API +callers (MCP integration is task 05). + +## Files to create/modify + +- `apps/api/schemas.ts` -- modify +- `apps/api/app.ts` -- modify +- `apps/api/store.ts` -- modify +- `apps/api/app.test.ts` -- modify + +## Changes + +### `apps/api/schemas.ts` + +Add webhook fields to `newPageBodySchema`. Extract a shared +`webhookFields` object and spread it into both union variants: + +```ts +const webhookFields = { + webhook_url: z.string().url().optional(), + webhook_secret: z.string().min(16).max(256).optional(), +}; +``` + +Add a `.refine()` on `webhook_url` to enforce HTTPS-only in production: +when `NODE_ENV === 'production'`, reject `http:` URLs. + +### `apps/api/store.ts` + +1. Update `createPage()` signature to accept optional `webhookUrl` and + `webhookSecret` parameters (add an opts object or extend existing + params). +2. Pass `webhookUrl` and `webhookSecret` to `db.insertPage()` on the + `Page` object. +3. Similarly update `createHtmlPage()` to accept and pass through the + webhook fields. + +### `apps/api/app.ts` + +1. **`newPageHandler`** -- after Zod parsing, extract `webhook_url` and + `webhook_secret` from `result.data` and pass them to + `store.createPage()` / `store.createHtmlPage()`. The response body + remains `{ id, url, expires_at }` -- do NOT echo `webhook_url` back. + +2. **`submitResultHandler`** -- after `db.submitPage()` returns + `{ kind: 'ok' }`, check `outcome.webhookUrl`. If set, fire + `deliverWebhook()` from `./webhook.ts` asynchronously + (`void deliverWebhook(...)` -- fire-and-forget). Pass `pageId`, + `submissionId`, `mode`, `webhookUrl`, `webhookSecret`, `result`, + `submittedAt`, `submittedBy`, `files`, and `log`. + +3. The webhook delivery MUST NOT block the HTTP response to the + submitting user. + +### `apps/api/app.test.ts` + +Add integration tests (6 tests per spec section 9): + +1. `POST /new` with `webhook_url` stores it -- assert `db.insertPage` + receives the webhook URL. +2. `POST /new` rejects invalid `webhook_url` (`'not-a-url'`) -- 400. +3. `POST /new` rejects short `webhook_secret` (< 16 chars) -- 400. +4. `POST /new` without webhook fields works -- existing tests stay green. +5. `POST /:id/result` fires webhook when `webhookUrl` is set -- mock + `deliverWebhook` module and assert it is called. +6. `POST /:id/result` does NOT fire webhook when `webhookUrl` is null -- + assert `deliverWebhook` is not called. + +## Acceptance criteria + +- [ ] `POST /new` accepts optional `webhook_url` (valid URL) and + `webhook_secret` (16-256 chars) in the request body. +- [ ] `POST /new` rejects `http:` webhook URLs in production mode. +- [ ] `POST /new` stores both fields on the page row. +- [ ] `POST /new` response body is unchanged (`{ id, url, expires_at }`). +- [ ] `POST /:id/result` fires `deliverWebhook` asynchronously when + `webhookUrl` is set on the page. +- [ ] `POST /:id/result` does not call `deliverWebhook` when + `webhookUrl` is null. +- [ ] The webhook call does not block the 200 response to the user. +- [ ] All 6 new integration tests pass. +- [ ] All existing app.test.ts tests remain green. + +## Dependencies + +- **01** (db schema) -- `insertPage`, `submitPage`, `Page` type with + webhook fields. +- **02** (delivery engine) -- `deliverWebhook` function imported by + `app.ts`. +- **03** (metrics/env) -- `metrics.webhookDeliveries`, env schema for + HTTPS enforcement. + +## Relevant spec sections + +- Section 4: API changes (POST /new, POST /:id/result, GET responses) +- Section 5: Webhook delivery logic (trigger point) +- Section 9: Testing strategy (integration tests) diff --git a/docs/superpowers/tasks/03-webhooks/05-mcp-tool-integration.md b/docs/superpowers/tasks/03-webhooks/05-mcp-tool-integration.md new file mode 100644 index 0000000..16cbc3d --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/05-mcp-tool-integration.md @@ -0,0 +1,104 @@ +# 05 -- MCP tool integration (show_ui and show_html) + +## Description + +Add `webhook_url` and `webhook_secret` parameters to the `show_ui` and +`show_html` MCP tools, and update both transport adapters (in-process +HTTP and stdio REST) to pass them through. After this task, agents can +set webhooks via MCP tool calls. + +## Files to create/modify + +- `apps/api/mcp/tools.ts` -- modify +- `apps/api/mcp/http.ts` -- modify +- `apps/mcp/server.ts` -- modify +- `apps/api/mcp/tools.test.ts` -- modify +- `apps/api/mcp/http.test.ts` -- modify (if it tests tool params) +- `apps/mcp/server.test.ts` -- modify (if it tests tool params) + +## Changes + +### `apps/api/mcp/tools.ts` + +1. **`PageOps` interface** -- update `showUi` and `showHtml` signatures + to accept an optional opts object: + + ```ts + showUi(spec: unknown, opts?: { + webhook_url?: string; + webhook_secret?: string; + }): Promise; + showHtml(html: string, opts?: { + webhook_url?: string; + webhook_secret?: string; + }): Promise; + ``` + +2. **`registerPagentTools`** -- add `webhook_url` and `webhook_secret` + to the `inputSchema` for both `show_ui` and `show_html`: + + ```ts + webhook_url: z.string().url().optional().describe( + 'Optional HTTPS callback URL. When set, Pagent POSTs the submission ' + + 'payload to this URL as soon as the user submits. The agent can ' + + 'still poll check_result -- webhooks are additive, not a replacement.' + ), + webhook_secret: z.string().min(16).max(256).optional().describe( + 'Optional shared secret for HMAC-SHA256 webhook signing. When set, ' + + 'every delivery carries an X-Pagent-Signature header: ' + + '"sha256=". The receiver MUST verify this signature to ' + + 'authenticate the payload. Minimum 16 characters.' + ), + ``` + +3. **Tool handlers** -- extract `webhook_url` and `webhook_secret` from + the parsed input and pass to `ops.showUi(spec, { webhook_url, webhook_secret })` + and `ops.showHtml(html, { webhook_url, webhook_secret })`. + +### `apps/api/mcp/http.ts` + +Update the in-process `PageOps` implementation to pass webhook opts +through to `store.createPage()` / `store.createHtmlPage()`. + +### `apps/mcp/server.ts` + +Update the stdio `restOps` `PageOps` implementation to include +`webhook_url` and `webhook_secret` in the `POST /new` request body. + +### `apps/api/mcp/tools.test.ts` + +Add MCP tool tests (2 tests per spec section 9): + +1. `show_ui` passes `webhook_url` and `webhook_secret` to `ops.showUi` -- + assert the ops mock receives both fields. +2. `show_ui` without webhook fields still works -- assert opts are + undefined or empty. + +### `apps/mcp/server.test.ts` + +If test coverage exists for the stdio adapter, add a test that the +`POST /new` fetch body includes webhook fields when provided. + +## Acceptance criteria + +- [ ] `show_ui` MCP tool accepts optional `webhook_url` (URL) and + `webhook_secret` (16-256 chars). +- [ ] `show_html` MCP tool accepts the same two optional parameters. +- [ ] `PageOps.showUi` and `PageOps.showHtml` signatures accept an + opts object with `webhook_url` and `webhook_secret`. +- [ ] In-process HTTP adapter passes webhook opts to `store.createPage`. +- [ ] Stdio REST adapter includes webhook fields in `POST /new` body. +- [ ] `check_result` tool is unchanged. +- [ ] Existing MCP tool calls without webhook fields remain green. +- [ ] New MCP tool tests pass. + +## Dependencies + +- **04** (API handler integration) -- `store.createPage` must accept + webhook opts before this task can pass them through. + +## Relevant spec sections + +- Section 3: MCP tool parameter changes (show_ui, show_html, PageOps) +- Section 9: Testing strategy (MCP tool tests) +- Section 13: Rollout (commit 2 -- MCP tool integration) diff --git a/docs/superpowers/tasks/03-webhooks/06-openapi-docs.md b/docs/superpowers/tasks/03-webhooks/06-openapi-docs.md new file mode 100644 index 0000000..8614cc6 --- /dev/null +++ b/docs/superpowers/tasks/03-webhooks/06-openapi-docs.md @@ -0,0 +1,71 @@ +# 06 -- OpenAPI documentation + +## Description + +Update the OpenAPI spec to document the `webhook_url` and +`webhook_secret` fields on `POST /new`, the webhook delivery payload +shape, and the delivery headers. This is the final task -- it documents +the feature after all code is in place. + +## Files to create/modify + +- `docs/openapi.yaml` -- modify + +## Changes + +### `docs/openapi.yaml` + +1. **`POST /new` request body** -- add `webhook_url` and + `webhook_secret` as optional properties to both union variants + (a2ui and html): + - `webhook_url`: type `string`, format `uri`, description from spec + section 3. + - `webhook_secret`: type `string`, minLength 16, maxLength 256, + description from spec section 3. + +2. **Webhook payload schema** -- add a `WebhookPayload` schema in + `components/schemas` documenting the POST body Pagent sends to the + callback URL: + - `event`: `page.submitted` + - `page_id`: string + - `submission_id`: string + - `mode`: enum `single` | `public` + - `result`: object (opaque) + - `submitted_at`: string (ISO 8601) + - `submitted_by`: string or null + - `files`: array of `WebhookFileRef` + +3. **`WebhookFileRef` schema** -- add under `components/schemas`: + - `file_id`, `field_name`, `original_name`, `mime_type` (string) + - `size_bytes` (integer) + - `download_url` (string, format uri) + +4. **Webhook delivery headers** -- add a description block under + `POST /new` or a new `x-webhooks` section documenting the headers: + `Content-Type`, `User-Agent`, `X-Pagent-Event`, `X-Pagent-Delivery`, + `X-Pagent-Signature`. + +5. **Note** that `GET /:id` and `GET /:id/result` responses are + unchanged -- webhook config is not exposed. + +## Acceptance criteria + +- [ ] `POST /new` schema includes `webhook_url` and `webhook_secret` as + optional properties. +- [ ] `WebhookPayload` and `WebhookFileRef` schemas are defined in + `components/schemas`. +- [ ] Webhook delivery headers are documented. +- [ ] OpenAPI YAML validates (no syntax errors). +- [ ] The Scalar API reference page renders the new fields. + +## Dependencies + +- **05** (MCP tool integration) -- all code is complete; this documents + the final API surface. + +## Relevant spec sections + +- Section 3: MCP tool parameter changes (parameter descriptions) +- Section 4: API changes (POST /new schema) +- Section 5: Webhook delivery logic (payload, headers) +- Section 12: File change summary (docs/openapi.yaml) diff --git a/docs/superpowers/tasks/04-public-forms/01-schema-migration.md b/docs/superpowers/tasks/04-public-forms/01-schema-migration.md new file mode 100644 index 0000000..e86f11e --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/01-schema-migration.md @@ -0,0 +1,32 @@ +# 01 — Schema migration: submissions table and pages columns + +## Description + +Add the `submissions` table and extend the `pages` table with `mode`, `access_emails`, `owner_id`, `closed_at`, and `max_submissions` columns. Update the `state` CHECK constraint to include `'closed'`. All changes are additive with defaults, so existing queries continue to work. + +## Files to create/modify + +- `apps/api/db.ts` — add `CREATE TABLE submissions` and all `ALTER TABLE pages` statements inside `init()`. Update the `PageState` type to include `'closed'`. Add the `PageMode` type. Extend the `Page` type with `mode`, `accessEmails`, `ownerId`, `closedAt`, `maxSubmissions`. Add the `Submission` type. + +## Acceptance criteria + +- Running `init()` on a fresh database creates the `submissions` table with columns `id` (uuid PK), `page_id` (text FK → pages), `submitted_by` (uuid nullable FK → users), `result` (jsonb), `submitted_at` (timestamptz). +- The composite index `submissions_page_id_idx` on `(page_id, submitted_at)` exists. +- `submissions.page_id` has `ON DELETE CASCADE`. +- Running `init()` on an existing database adds `mode` (text, default `'single'`, CHECK `('single','public')`), `access_emails` (text[]), `owner_id` (uuid), `closed_at` (timestamptz), and `max_submissions` (integer, default 10000) to `pages` idempotently. +- The `pages` state CHECK constraint allows `'open'`, `'submitted'`, `'received'`, `'closed'`. +- `PageState` type is `'open' | 'submitted' | 'received' | 'closed'`. +- `PageMode` type is `'single' | 'public'`. +- `Page` type includes `mode: PageMode`, `accessEmails: string[] | null`, `ownerId: string | null`, `closedAt: number | null`, `maxSubmissions: number`. +- `Submission` type is exported with fields `id`, `pageId`, `submittedBy`, `result`, `submittedAt`. +- Existing `db.test.ts` tests pass without modification. + +## Dependencies + +None — this is the foundation for all other tasks. + +## Relevant spec sections + +- Database schema > New table: `submissions` +- Database schema > Alter table: `pages` +- DB layer changes > New types diff --git a/docs/superpowers/tasks/04-public-forms/02-db-functions.md b/docs/superpowers/tasks/04-public-forms/02-db-functions.md new file mode 100644 index 0000000..b5c21c9 --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/02-db-functions.md @@ -0,0 +1,33 @@ +# 02 — DB functions: insertSubmission, getSubmissions, closePage, and submitPage branching + +## Description + +Implement the new database functions (`insertSubmission`, `getSubmissions`, `closePage`) and modify `submitPage`, `insertPage`, and `fetchAndAdvanceResult` to branch on page mode. This is the core data layer that all API and MCP changes build on. + +## Files to create/modify + +- `apps/api/db.ts` — add `insertSubmission()`, `getSubmissions()`, `closePage()`. Modify `insertPage()` to accept and persist `mode`, `accessEmails`, `ownerId`, `maxSubmissions`. Modify `submitPage()` to branch on `mode`: public mode inserts a submission row without changing page state; single mode also inserts a submission row for consistency. Extend `SubmitOutcome` with `'closed'` kind and optional `submissionId`. Modify `fetchAndAdvanceResult()` to skip state advancement for public pages. +- `apps/api/db.test.ts` — add tests for `insertSubmission`, `getSubmissions` (including cursor pagination), `closePage` (success, not_found, not_owner, already_closed, wrong_mode), and the public-mode branch of `submitPage`. + +## Acceptance criteria + +- `insertSubmission(pageId, result, submittedBy)` inserts a row and returns `{ id, submittedAt }`. +- `getSubmissions(pageId, { limit, cursor? })` returns `{ submissions: Submission[], total: number }` with cursor-based pagination on `submitted_at`. Default limit 50, max 200. Returns submissions ordered oldest-to-newest. +- `closePage(pageId, callerId)` returns `{ kind: 'ok', closedAt }` on success, or `{ kind: 'not_found' | 'not_owner' | 'already_closed' | 'wrong_mode' }` on failure. Sets `pages.state = 'closed'` and `pages.closed_at = now()`. +- `submitPage()` for `mode = 'public'`: inserts a submission, does NOT change `pages.state`, does NOT set `pages.result`, returns `{ kind: 'ok', createdAt, submissionId }`. Returns `{ kind: 'closed' }` if page state is `'closed'`. +- `submitPage()` for `mode = 'single'`: existing behavior preserved, plus a `submissions` row is inserted alongside `pages.result`. +- `insertPage()` persists `mode`, `access_emails`, `owner_id`, `max_submissions`. +- `fetchAndAdvanceResult()` for public pages does NOT flip state from `submitted` to `received`. +- `SubmitOutcome` type includes `| { kind: 'closed' }` variant. + +## Dependencies + +- 01-schema-migration + +## Relevant spec sections + +- DB layer changes > New functions (`insertSubmission`, `getSubmissions`, `closePage`) +- DB layer changes > Modified functions (`submitPage`, `insertPage`, `fetchAndAdvanceResult`) +- DB layer changes > `SubmitOutcome` — extended +- State machines > Public mode +- Pagination > Strategy and SQL query diff --git a/docs/superpowers/tasks/04-public-forms/03-api-endpoints.md b/docs/superpowers/tasks/04-public-forms/03-api-endpoints.md new file mode 100644 index 0000000..48201e8 --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/03-api-endpoints.md @@ -0,0 +1,47 @@ +# 03 — API endpoints: POST /new, POST /:id/result, GET /:id/result, POST /:id/close, GET /:id + +## Description + +Update the REST API handlers to support public mode: extend `POST /new` with `mode` and `access_emails`, branch `POST /:id/result` and `GET /:id/result` by mode, add the new `POST /:id/close` endpoint, and return new fields from `GET /:id`. Add submission rate limiting for public pages. + +## Files to create/modify + +- `apps/api/schemas.ts` — update `newPageBodySchema` to accept `mode` (enum `['single','public']`, default `'single'`) and `access_emails` (array of emails, optional) on the a2ui branch. Reject `mode: 'public'` on the html branch with a refine. Add `closePageParamsSchema` if needed. +- `apps/api/app.ts` — modify `newPageHandler` to pass `mode`, `accessEmails`, `ownerId` to `store.createPage()`. Select TTL based on mode (`PUBLIC_PAGE_TTL_MS` for public, `PAGE_TTL_MS` for single). Modify `submitResultHandler` to handle the public-mode branch: check for `closed` state (409), check `access_emails` allowlist (403), return `{ ok: true, submission_id }`. Modify `getResultHandler` to return paginated submissions for public pages (accept `limit`, `cursor`, `after` query params). Add `closeHandler` for `POST /:id/close`. Modify `getPageHandler` to return `mode` and `access_emails`. Register the new route. +- `apps/api/store.ts` — update `createPage()` to accept `mode`, `accessEmails`, `ownerId`, `maxSubmissions`. Add `PUBLIC_PAGE_TTL_MS` config. Update `advanceResult()` to return public-mode response shape with submissions array. +- `apps/api/limits.ts` — add submission rate-limit constants: 5 submissions/min/IP/page, 100 submissions/min/page global. +- `apps/api/app.test.ts` — add tests for: creating a public page, submitting to a public page multiple times, submitting to a closed page (409), closing a page, closing by non-owner (403), closing a single-mode page (400), GET /:id/result pagination for public pages, submission rate limiting (429). +- `apps/api/schemas.test.ts` — add tests for the updated `newPageBodySchema` (mode + access_emails validation, html+public rejection). + +## Acceptance criteria + +- `POST /new` with `{ spec, mode: "public" }` creates a public page with 7-day TTL. +- `POST /new` with `{ format: "html", spec: "...", mode: "public" }` returns 400 `invalid_for_format`. +- `POST /new` stores `owner_id` from the auth header (NULL if unauthenticated). +- `POST /:id/result` on a public page inserts a submission, returns `{ ok: true, submission_id: "" }`, and does NOT change page state. +- `POST /:id/result` on a closed public page returns 409 `{ error: "closed" }`. +- `POST /:id/result` on a page with `access_emails` and an unauthorized submitter returns 403 `{ error: "access_denied" }`. +- `POST /:id/result` on public pages is rate-limited at 5/min/IP/page and 100/min/page. +- `GET /:id/result` on a public page returns `{ state, mode: "public", format, submissions: [...], total, cursor }`. +- `GET /:id/result` accepts `limit` (default 50, max 200) and `cursor`/`after` query params. +- `POST /:id/close` transitions a public page to `closed`, returns `{ ok: true, state: "closed", closed_at }`. +- `POST /:id/close` returns 400 for single-mode, 403 for non-owner, 404 for missing, 409 for already closed. +- `GET /:id` returns `mode` and `access_emails` in the response. +- All existing single-mode tests continue to pass. + +## Dependencies + +- 01-schema-migration +- 02-db-functions + +## Relevant spec sections + +- API endpoints > `POST /new` — create page (modified) +- API endpoints > `POST /:id/result` — submit (modified) +- API endpoints > `GET /:id/result` — poll for result (modified) +- API endpoints > `POST /:id/close` — close page (new) +- API endpoints > `GET /:id` — get page (modified) +- Access control (email allowlist, close authorization, owner identification) +- Resolved questions > TTL for public pages +- Resolved questions > Submission rate limiting +- Resolved questions > Submission count cap diff --git a/docs/superpowers/tasks/04-public-forms/04-mcp-tools.md b/docs/superpowers/tasks/04-public-forms/04-mcp-tools.md new file mode 100644 index 0000000..897185c --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/04-mcp-tools.md @@ -0,0 +1,40 @@ +# 04 — MCP tools: show_ui mode/access_emails params and check_result public response + +## Description + +Extend the MCP `show_ui` tool with `mode` and `access_emails` parameters. Update `check_result` to return paginated submissions for public pages and accept `cursor`/`limit` params. Update the `PageOps` interface and `CheckResultOutcome` type. Update both the in-process HTTP MCP adapter and the stdio MCP server. + +## Files to create/modify + +- `apps/api/mcp/tools.ts` — add `mode` and `access_emails` to the `show_ui` input schema with `.describe()` strings from the spec. Add `cursor` and `limit` to the `check_result` input schema. Update `SHOW_UI_DESCRIPTION` and `CHECK_RESULT_DESCRIPTION` with public-mode guidance. Extend `CheckResultOutcome` to include the public-mode variant with `submissions`, `total`, `cursor`. Extend `PageOps.showUi()` signature to accept `{ mode?, accessEmails? }`. Extend `PageOps.checkResult()` to accept `{ limit?, cursor? }`. Update the `check_result` handler to format public-mode responses (submissions array as text + structuredContent). +- `apps/api/mcp/http.ts` — update the in-process `PageOps` implementation to pass `mode`, `accessEmails`, `ownerId` through to `store.createPage()` and to pass `limit`, `cursor` through `store.advanceResult()`. +- `apps/mcp/server.ts` — update the stdio `restOps` implementation: pass `mode` and `access_emails` in the `POST /new` body, pass `cursor` and `limit` as query params on `GET /:id/result`. +- `apps/api/mcp/tools.test.ts` — add tests for: `show_ui` with `mode: "public"`, `check_result` returning submissions array, `check_result` with cursor pagination. +- `apps/mcp/server.test.ts` — add tests for the stdio adapter passing mode and pagination params. + +## Acceptance criteria + +- `show_ui({ spec, mode: "public" })` creates a public page. +- `show_ui({ spec, mode: "public", access_emails: ["a@b.com"] })` creates a restricted public page. +- `show_ui({ spec })` (no mode) defaults to `"single"` — backward compatible. +- `check_result(page_id)` for a public page returns `{ state, mode: "public", format, page_id, submissions: [...], total, cursor }` as structuredContent, with a human-readable text summary. +- `check_result(page_id, { cursor, limit })` paginates submissions. +- `check_result(page_id)` for a single page returns the existing shape (backward compatible). +- `SHOW_UI_DESCRIPTION` mentions public mode, closing, and polling behavior. +- `CHECK_RESULT_DESCRIPTION` documents both single and public response shapes. +- `PageOps` interface signature updated: `showUi(spec, opts?)`, `checkResult(page_id, opts?)`. +- Stdio adapter sends `mode`/`access_emails` in POST body and `cursor`/`limit` as query params. + +## Dependencies + +- 01-schema-migration +- 02-db-functions +- 03-api-endpoints + +## Relevant spec sections + +- MCP tool changes > `show_ui` — new parameters +- MCP tool changes > `check_result` — response shape change +- MCP tool changes > `PageOps` interface — extended +- MCP tool changes > `CheckResultOutcome` — extended +- Pagination > Agent polling pattern diff --git a/docs/superpowers/tasks/04-public-forms/05-frontend-public-mode.md b/docs/superpowers/tasks/04-public-forms/05-frontend-public-mode.md new file mode 100644 index 0000000..f260533 --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/05-frontend-public-mode.md @@ -0,0 +1,35 @@ +# 05 — Frontend: public-mode submit, form reset, confirmation toast, and closed state + +## Description + +Update the web frontend to handle public-mode pages: submit without locking, reset the form after each submission, show a confirmation toast, render the closed-state tombstone, and handle the 409/403 error responses. Store `mode` from the API response and branch behavior accordingly. + +## Files to create/modify + +- `apps/web/main.ts` — add `mode` property to `AgentUIApp` (type `'single' | 'public'`, populated from `GET /:id` response). In the action handler: if `mode === 'public'`, POST result, show confirmation toast, call `resetForm()`, do NOT set `this.awaiting = true`, do NOT poll for `received`. Handle 409 (closed) by setting `status = 'closed'`. Handle 403 (access_denied) by showing an error. Add `resetForm()` method that clears all surfaces and re-processes `this.originalSpec`. Store `originalSpec` during `loadPage`. Add `showConfirmation()` method for the transient toast (4s, top-center, fades). Add closed-state rendering: when `state === 'closed'`, show the tombstone banner with the form dimmed behind it. Add `PageResponse.mode` and `PageResponse.access_emails` to the type. +- `apps/web/main.ts` (CSS section) — add styles for `.submit-confirmation` (toast), `.closed-banner` (tombstone), `.form-dimmed` (overlay for closed state). + +## Acceptance criteria + +- Public page: submitting shows a "Response submitted" toast for 4 seconds, then the form resets to its initial state. The user can submit again. +- Public page: the page does NOT show the "awaiting" spinner or poll for `received` after submit. +- Public page: submitting to a closed page (409 response) transitions the UI to the closed state. +- Public page: submitting with an unauthorized email (403 response) shows an access-denied error. +- Closed state: a banner reading "This form is no longer accepting responses." is displayed with a block icon. The form spec is visible but dimmed behind an overlay. +- Single-mode pages: behavior is completely unchanged. +- The confirmation toast is accessible (`role="status"`, `aria-live="polite"`). +- `originalSpec` is preserved during `loadPage` for reset. +- `resetForm()` clears surfaces and re-processes the spec through `MessageProcessor`. + +## Dependencies + +- 03-api-endpoints (for the new response fields and error codes) + +## Relevant spec sections + +- Frontend changes > Page loading (`loadPage`) +- Frontend changes > Submit handler (public mode) +- Frontend changes > Closed state rendering +- Frontend changes > Confirmation toast +- Frontend changes > Form reset +- Frontend changes > Access-restricted pages diff --git a/docs/superpowers/tasks/04-public-forms/06-metrics-and-ttl.md b/docs/superpowers/tasks/04-public-forms/06-metrics-and-ttl.md new file mode 100644 index 0000000..80f9a93 --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/06-metrics-and-ttl.md @@ -0,0 +1,40 @@ +# 06 — Metrics, TTL configuration, and submission cap enforcement + +## Description + +Add observability counters/histograms for public-form events, configure the separate TTL for public pages, and enforce the max-submissions cap on submit. These are cross-cutting concerns that wire into the API and DB layers built in tasks 02-03. + +## Files to create/modify + +- `apps/api/metrics.ts` — add `pagent.submissions.created` counter (labels: `mode`), `pagent.pages.closed` counter, `pagent.public_page.submissions` histogram. Add `mode` label to existing `pagent.pages.created` counter. +- `apps/api/metrics.test.ts` — add tests verifying the new instruments are registered. +- `apps/api/app.ts` — emit `submissions.created` in `submitResultHandler` for both modes. Emit `pages.closed` in `closeHandler`. Pass `mode` label to `pagesCreated` counter in `newPageHandler`. +- `apps/api/schemas.ts` — add `PUBLIC_PAGE_TTL_MS` to the `envSchema` (default `604800000`, 7 days). Export it. +- `apps/api/store.ts` — update `createPage()` to select TTL based on mode. Accept `mode` in `CreatePageConfig` or as a parameter. Record `public_page.submissions` histogram at close time (or let the TTL sweeper do it at expiry). +- `apps/api/db.ts` — in `submitPage()` for public mode, check `SELECT count(*) FROM submissions WHERE page_id = $1` against `pages.max_submissions`. Return a new `{ kind: 'submission_cap_reached' }` outcome when the cap is hit. +- `apps/api/app.ts` — map `submission_cap_reached` to 409 `{ error: "submission_cap_reached" }` in `submitResultHandler`. + +## Acceptance criteria + +- `pagent.pages.created` counter includes a `mode` label (`"single"` or `"public"`). +- `pagent.submissions.created` counter increments on every submission insert (both modes). +- `pagent.pages.closed` counter increments when `POST /:id/close` succeeds. +- `pagent.public_page.submissions` histogram records the submission count at page close/expiry. +- Public pages default to a 7-day TTL (`PUBLIC_PAGE_TTL_MS` env var, default `604800000`). +- Single pages keep their existing 30-minute TTL. +- `POST /:id/result` returns 409 `{ error: "submission_cap_reached" }` when submission count reaches `pages.max_submissions` (default 10,000). +- `SubmitOutcome` type includes `| { kind: 'submission_cap_reached' }`. +- TTL sweeps on `pages` cascade-delete child `submissions` rows automatically (verified by existing `deleteExpiredPages` behavior + `ON DELETE CASCADE`). + +## Dependencies + +- 01-schema-migration +- 02-db-functions +- 03-api-endpoints + +## Relevant spec sections + +- Metrics (all instruments) +- Resolved questions > TTL for public pages +- Resolved questions > Submission count cap +- DB layer changes > `deleteExpiredPages` diff --git a/docs/superpowers/tasks/04-public-forms/07-backward-compat-and-openapi.md b/docs/superpowers/tasks/04-public-forms/07-backward-compat-and-openapi.md new file mode 100644 index 0000000..49dda0c --- /dev/null +++ b/docs/superpowers/tasks/04-public-forms/07-backward-compat-and-openapi.md @@ -0,0 +1,38 @@ +# 07 — Backward compatibility verification and OpenAPI spec update + +## Description + +Verify that all existing single-mode flows remain unchanged end-to-end, add the `mode` field to single-mode responses for forward compatibility, and update the OpenAPI document to cover the new endpoints, parameters, and response shapes. + +## Files to create/modify + +- `docs/openapi.yaml` — add `mode` and `access_emails` parameters to `POST /new` request schema. Add `POST /:id/close` endpoint. Update `POST /:id/result` response to include `submission_id` for public mode and new error codes (409 closed, 403 access_denied). Update `GET /:id/result` response with the public-mode shape (submissions array, total, cursor). Update `GET /:id` response with `mode` and `access_emails`. Add the `closed` value to state enums. +- `apps/api/app.ts` — ensure `GET /:id/result` for single-mode includes `mode: "single"` in the response for forward compatibility. +- `apps/api/mcp/tools.ts` — ensure `CheckResultOutcome` single-mode variant includes `mode: 'single'` so agents can discriminate. +- `apps/api/app.test.ts` — add an explicit backward-compatibility test suite: create a single-mode page with no `mode` param, submit, read result, verify exact response shapes match pre-public-forms behavior. Verify `mode: "single"` is present in responses. +- `apps/mcp/server.test.ts` — verify stdio MCP `show_ui` with no `mode` param works identically to current behavior. + +## Acceptance criteria + +- `POST /new` with no `mode` parameter creates a single-mode page (default `"single"`). +- `POST /:id/result` on a single-mode page returns `{ ok: true }` (no `submission_id`). +- `GET /:id/result` on a single-mode page returns `{ state, result, format, mode: "single" }`. +- The `submitted -> received` atomic flip is preserved for single-mode pages. +- `GET /:id` returns `mode: "single"` for pages created without a mode parameter. +- `check_result` MCP tool for single-mode pages returns the same text and structuredContent shape as before, plus `mode: "single"`. +- `docs/openapi.yaml` documents all new and modified endpoints, parameters, request/response schemas, and error codes. +- The OpenAPI spec renders correctly at `/docs` (Scalar API reference). + +## Dependencies + +- 03-api-endpoints +- 04-mcp-tools +- 05-frontend-public-mode +- 06-metrics-and-ttl + +## Relevant spec sections + +- Backward compatibility (entire section) +- API endpoints (all modified response shapes) +- MCP tool changes > `check_result` shape discrimination +- Migration strategy diff --git a/docs/superpowers/tasks/05-audit-log/01-schema-and-db-layer.md b/docs/superpowers/tasks/05-audit-log/01-schema-and-db-layer.md new file mode 100644 index 0000000..35a1dd8 --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/01-schema-and-db-layer.md @@ -0,0 +1,49 @@ +# 01 -- Schema and DB layer + +## Description + +Create the `audit_log` table DDL in `db.init()` and implement the four +low-level database functions: single insert, batch insert, paginated +query, and retention purge. + +## Files to create/modify + +- `apps/api/db.ts` -- add `CREATE TABLE IF NOT EXISTS audit_log` with + indexes inside `init()`. Add `insertAuditEvent()`, + `insertAuditEvents()`, `queryAuditLog()`, `purgeOldAuditEvents()`. +- `apps/api/db.test.ts` -- unit tests for all four functions (mocked SQL + client). + +## Acceptance criteria + +- `db.init()` creates the `audit_log` table with columns: `id`, `user_id`, + `action`, `resource_type`, `resource_id`, `metadata`, `ip_address`, + `user_agent`, `created_at`. +- `resource_type` has a CHECK constraint limiting to `('page', 'file', 'webhook')`. +- Four indexes are created: `audit_log_resource_idx`, + `audit_log_user_idx` (partial, `WHERE user_id IS NOT NULL`), + `audit_log_created_at_idx`, `audit_log_action_idx`. +- `insertAuditEvent(event)` inserts a single row; `user_agent` is + truncated to 512 chars at write time. +- `insertAuditEvents(events)` performs a multi-row INSERT, chunked at + 500 rows per statement. +- `queryAuditLog(params)` returns `{ events, cursor, has_more }` with + cursor-based pagination using `(created_at, id)` keyset. Cursor is + base64-encoded JSON. +- `purgeOldAuditEvents(retentionDays)` deletes rows older than the + given number of days and returns the count deleted. +- `AuditEventRow` type is exported. +- Tests cover: single insert, batch insert (>500 rows chunking), query + with each filter (`resource_id`, `user_id`, `action`), cursor + round-trip, purge. + +## Dependencies + +None -- this is the foundation task. + +## Relevant spec sections + +- Section 2 (Database schema) -- table DDL, column notes, indexes +- Section 8 (Performance considerations) -- batch insert chunking at 500 +- Section 9 (Retention policy) -- `purgeOldAuditEvents` implementation +- Appendix A -- DB function signatures diff --git a/docs/superpowers/tasks/05-audit-log/02-emitter-module.md b/docs/superpowers/tasks/05-audit-log/02-emitter-module.md new file mode 100644 index 0000000..3354dfe --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/02-emitter-module.md @@ -0,0 +1,41 @@ +# 02 -- Audit emitter module + +## Description + +Create the fire-and-forget audit emitter (`apps/api/audit.ts`) that +wraps the DB insert functions, and add the two OTel counters for +success/failure tracking. + +## Files to create/modify + +- `apps/api/audit.ts` -- **new file**. Exports `emitAuditEvent()`, + `emitAuditEvents()`, and the `AuditEvent` input type. +- `apps/api/metrics.ts` -- add `pagent.audit.events.emitted` counter and + `pagent.audit.events.failed` counter. + +## Acceptance criteria + +- `emitAuditEvent(event)` calls `db.insertAuditEvent()` without + awaiting in the caller. On DB error, logs via `logger.error` and + swallows the exception (never throws). +- `emitAuditEvents(events)` calls `db.insertAuditEvents()` with the + same fire-and-forget/swallow contract. No-ops on empty array. +- On successful insert, `pagent.audit.events.emitted` counter is + incremented (by 1 for single, by `events.length` for batch). +- On failed insert, `pagent.audit.events.failed` counter is incremented. +- `AuditEvent` type matches the spec: `user_id?`, `action`, `resource_type`, + `resource_id`, `metadata?`, `ip_address?`, `user_agent?`. +- `RequestContext` type (`{ ipAddress?: string | null; userAgent?: string | null }`) + is exported from `audit.ts` (or `store.ts`) for use by call sites. + +## Dependencies + +- **01** (schema and DB layer) -- `insertAuditEvent`, `insertAuditEvents` + must exist. + +## Relevant spec sections + +- Section 6.1 (Audit emitter module) -- full `emitAuditEvent` / + `emitAuditEvents` implementation +- Section 6.2 preamble -- `RequestContext` type definition +- Appendix C (Metrics) -- counter names and descriptions diff --git a/docs/superpowers/tasks/05-audit-log/03-emit-at-call-sites.md b/docs/superpowers/tasks/05-audit-log/03-emit-at-call-sites.md new file mode 100644 index 0000000..519929a --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/03-emit-at-call-sites.md @@ -0,0 +1,58 @@ +# 03 -- Emit audit events at call sites + +## Description + +Wire `emitAuditEvent()` / `emitAuditEvents()` into the existing page +lifecycle code so that `page.created`, `page.submitted`, +`page.received`, and `page.expired` events are recorded. + +## Files to create/modify + +- `apps/api/store.ts` -- add `RequestContext` parameter to + `createPage()` and `createHtmlPage()`. Emit `page.created` after + `db.insertPage()`. Emit `page.received` in `advanceResult()` (or + equivalent) when state flips from `submitted` to `received`. +- `apps/api/app.ts` -- extract `ip_address` (via `clientKey()`) and + `user_agent` from the Hono context in `newPageHandler`, + `submitResultHandler`, and `getResultHandler`. Pass `RequestContext` to + store functions. Emit `page.submitted` after `db.submitPage()` returns + `{ kind: 'ok' }`. +- `apps/api/server.ts` -- extend the TTL sweep callback. Change + `db.deleteExpiredPages()` (or its caller) to return expired row + metadata (`id`, `state`, `format`, `created_at`). Call + `emitAuditEvents()` with `page.expired` for each deleted row. +- `apps/api/db.ts` -- extend `deleteExpiredPages()` return type to + include the `expired` array with `{ id, state, format, created_at }`. +- `apps/api/mcp/http.ts` -- extract IP/UA from the raw + `IncomingMessage` and pass `RequestContext` to store calls. + +## Acceptance criteria + +- `POST /new` emits `page.created` with metadata: `format`, + `spec_bytes`, `expires_at`, `url`. +- `POST /:id/result` emits `page.submitted` with metadata: `format`, + `action_name`, `action_surface_id`, `latency_ms`. +- `GET /:id/result` emits `page.received` (only on the first read that + transitions state from `submitted` to `received`) with metadata: + `format`, `read_latency_ms`. +- The TTL sweep emits one `page.expired` event per deleted row with + metadata: `state_at_expiry`, `format`, `age_ms`. +- All emits include `ip_address` and `user_agent` when available (null + for system-initiated events like expiry). +- Existing tests in `app.test.ts` still pass (no regressions from + signature changes). +- New tests verify that each handler calls `emitAuditEvent` with the + correct action and metadata shape. + +## Dependencies + +- **01** (schema and DB layer) -- `deleteExpiredPages` return type change. +- **02** (emitter module) -- `emitAuditEvent`, `emitAuditEvents`, + `RequestContext`. + +## Relevant spec sections + +- Section 3 (Event catalog) -- 3.1 through 3.4 for metadata shapes +- Section 6.2 (Call sites) -- exact code locations and context threading +- Section 8 (Performance) -- fire-and-forget contract, batch insert for + sweep diff --git a/docs/superpowers/tasks/05-audit-log/04-rest-endpoint.md b/docs/superpowers/tasks/05-audit-log/04-rest-endpoint.md new file mode 100644 index 0000000..8f8076d --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/04-rest-endpoint.md @@ -0,0 +1,45 @@ +# 04 -- REST API endpoint (GET /audit) + +## Description + +Add the `GET /audit` route with query parameter validation, cursor-based +pagination, and access-control enforcement. + +## Files to create/modify + +- `apps/api/app.ts` -- register `app.get('/audit', auditHandler)` after + existing routes. Implement `auditHandler`: parse and validate query + params with the Zod schema, enforce the mandatory filter requirement + (`resource_id` or `user_id`), call `db.queryAuditLog()`, and return + the paginated JSON response. +- `apps/api/app.test.ts` -- tests for the `/audit` endpoint. + +## Acceptance criteria + +- `GET /audit?resource_id=X&resource_type=page` returns matching events + sorted by `created_at DESC`. +- `GET /audit?user_id=X` returns matching events for that user. +- `GET /audit` with no `resource_id` or `user_id` returns 400 with + `error: "bad_request"`. +- `limit` query param is validated: integer 1..200, defaults to 50. +- `cursor` is validated: base64 JSON with `created_at` + `id`. Malformed + cursor returns 400. +- Response shape: `{ events: [...], cursor: string | null, has_more: boolean }`. +- Access control (when auth middleware is present): + - `user_id` filter only returns results when `user_id === authedUser`. + - `resource_id` filter checks resource ownership via + `db.getResourceOwner()`. Returns 403 on mismatch. + - Unauthenticated requests return 401. +- Tests cover: valid query with each filter, pagination round-trip, + missing filter 400, invalid cursor 400, limit boundary values. + +## Dependencies + +- **01** (schema and DB layer) -- `queryAuditLog()` must exist. + +## Relevant spec sections + +- Section 4 (REST API endpoint) -- query params, response shape, + pagination, Zod schema, error responses +- Section 7 (Access control) -- ownership checks, 401/403 rules +- Appendix B (OpenAPI addition) -- optional: add to `docs/openapi.yaml` diff --git a/docs/superpowers/tasks/05-audit-log/05-mcp-tool.md b/docs/superpowers/tasks/05-audit-log/05-mcp-tool.md new file mode 100644 index 0000000..db226d5 --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/05-mcp-tool.md @@ -0,0 +1,46 @@ +# 05 -- MCP tool (get_audit_log) + +## Description + +Register a `get_audit_log` MCP tool that lets agents query audit events +for a page they created, returning both human-readable text and +structured JSON. + +## Files to create/modify + +- `apps/api/mcp/tools.ts` -- register `get_audit_log` tool in + `registerPagentTools()` after `check_result`. Input: + `page_id` (32-char hex, required) and `limit` (1..100, default 20). + Output: `content` with formatted text summary, `structuredContent` + with full event array. +- `apps/api/mcp/http.ts` -- implement `getAuditLog()` on the in-process + `PageOps` adapter by calling `db.queryAuditLog()` directly. +- `apps/mcp/server.ts` -- implement `getAuditLog()` on the stdio + adapter by calling `GET /audit?resource_id=&resource_type=page&limit=`. +- `apps/api/mcp/tools.test.ts` -- tests for the new tool. + +## Acceptance criteria + +- Tool name is `get_audit_log` with title "Get audit log for a page". +- `page_id` input is validated as a 32-char hex string; invalid values + return an MCP error. +- `limit` defaults to 20, capped at 100. +- Text output is a human-readable summary: one line per event with + timestamp, action, and key metadata. +- `structuredContent` contains `{ page_id, events: [...] }` with full + event objects (action, created_at, metadata). +- `PageOps` interface gains `getAuditLog(page_id: string, limit: number): Promise`. +- In-process adapter queries DB directly; stdio adapter calls the REST + endpoint. +- Tests cover: valid page_id returns events, invalid page_id returns + error, empty result set, text formatting. + +## Dependencies + +- **01** (schema and DB layer) -- `queryAuditLog()`. +- **04** (REST endpoint) -- stdio adapter calls `GET /audit`. + +## Relevant spec sections + +- Section 5 (MCP tool) -- parameters, response format, registration + code, PageOps extension diff --git a/docs/superpowers/tasks/05-audit-log/06-retention-purge.md b/docs/superpowers/tasks/05-audit-log/06-retention-purge.md new file mode 100644 index 0000000..fc4b06c --- /dev/null +++ b/docs/superpowers/tasks/05-audit-log/06-retention-purge.md @@ -0,0 +1,31 @@ +# 06 -- Retention purge timer + +## Description + +Add the background timer that deletes audit log rows older than 90 days, +running every 6 hours alongside the existing page TTL sweep. + +## Files to create/modify + +- `apps/api/server.ts` -- add a `setInterval` (6-hour period) that calls + `db.purgeOldAuditEvents(90)`. Log the count of deleted rows. Call + `.unref()` on the timer so it does not prevent process exit. + +## Acceptance criteria + +- A `setInterval` runs every 6 hours (21,600,000 ms). +- It calls `db.purgeOldAuditEvents(90)` and logs the result via + `logger.info` when `deleted > 0`. +- On error, it logs via `logger.error` and does not crash the process. +- The timer handle has `.unref()` so it does not keep the process alive + during shutdown. +- The 90-day retention period is hardcoded (no env-var config in V1). + +## Dependencies + +- **01** (schema and DB layer) -- `purgeOldAuditEvents()` must exist. + +## Relevant spec sections + +- Section 9 (Retention policy) -- timer setup, purge implementation, + batched delete strategy for large tables diff --git a/docs/superpowers/tasks/06-custom-urls/01-schemas-and-migration.md b/docs/superpowers/tasks/06-custom-urls/01-schemas-and-migration.md new file mode 100644 index 0000000..6ead8ea --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/01-schemas-and-migration.md @@ -0,0 +1,37 @@ +# 01 — Validation schemas, reserved handles, and DB migration + +## Description + +Add `handleSchema`, `slugSchema`, and `RESERVED_HANDLES` to the API schemas module, then create the database migration that extends the `pages` table with `slug` and `owner_id` columns plus the compound unique index. + +## Files to create/modify + +- `apps/api/schemas.ts` — add `handleSchema`, `slugSchema`, `RESERVED_HANDLES` set; update `newPageBodySchema` to accept optional `slug` on both union branches +- `apps/api/db.ts` — extend `Page` type with optional `slug: string` and `owner_id: string` fields; add boot migration `ALTER TABLE pages ADD COLUMN IF NOT EXISTS slug text` + `ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL`; add `CREATE UNIQUE INDEX IF NOT EXISTS pages_owner_slug_unique ON pages (owner_id, slug) WHERE slug IS NOT NULL` +- `apps/api/schemas.test.ts` — add tests for `handleSchema` (valid, too short, leading/trailing hyphen, consecutive hyphens, 32-char hex rejection, reserved handles) and `slugSchema` (valid, bounds, format); add tests for updated `newPageBodySchema` with and without `slug` + +## Acceptance criteria + +- `handleSchema` validates: 3-40 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphen, `.refine()` rejecting consecutive hyphens +- `slugSchema` validates: 3-64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphen +- `RESERVED_HANDLES` contains at least: `new`, `health`, `docs`, `mcp`, `resolve`, `_components`, `api`, `oauth`, `admin`, `settings`, `login`, `signup`, `logout` +- `newPageBodySchema` accepts optional `slug` on both `a2ui` and `html` branches (existing payloads without `slug` continue to parse) +- `Page` type includes optional `slug` and `owner_id` +- Boot migration in `db.init()` adds both columns and the partial unique index idempotently +- `insertPage()` writes `slug` and `owner_id` when provided +- All new/changed schemas have unit tests +- Existing tests still pass + +## Dependencies + +None (first task) + +## Relevant spec sections + +- 2.2 `pages` table (existing -- extended) +- 2.3 Validation constraints (application-level) +- 2.4 Full migration script +- 3.3 Handle validation rules +- 7.8 REST `POST /new` body schema update +- 8.1 Reserved handles +- 8.2 Hex ID disambiguation diff --git a/docs/superpowers/tasks/06-custom-urls/02-handle-registration.md b/docs/superpowers/tasks/06-custom-urls/02-handle-registration.md new file mode 100644 index 0000000..8924f6b --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/02-handle-registration.md @@ -0,0 +1,37 @@ +# 02 — Handle registration endpoint + +## Description + +Implement `PUT /me/handle` so authenticated users can claim a handle, and extend `GET /me` to include the handle in its response. Handles are immutable after creation. + +## Files to create/modify + +- `apps/api/app.ts` — register `PUT /me/handle` route with handler (`putHandleHandler`); ensure `GET /me` response shape includes `handle` +- `apps/api/db.ts` — add `setUserHandle(userId: string, handle: string): Promise` that runs `UPDATE users SET handle = $handle WHERE id = $user_id AND handle IS NULL` and returns false on 0-row-update; add `getUserHandle(userId: string): Promise` +- `apps/api/app.test.ts` — add tests for `PUT /me/handle`: success (200), validation failure (400), handle taken (409), already-has-handle (422), 32-char hex rejection (400), reserved handle rejection (400) + +## Acceptance criteria + +- `PUT /me/handle` with valid handle returns `200 { "handle": "alex" }` +- `PUT /me/handle` with invalid format returns `400 { "error": "bad_request", ... }` +- `PUT /me/handle` when handle is taken returns `409 { "error": "handle_taken", ... }` +- `PUT /me/handle` when user already has a handle returns `422 { "error": "handle_immutable", ... }` +- 32-char hex strings rejected with 400 (collision avoidance) +- Reserved handles (from `RESERVED_HANDLES`) rejected with 400 +- `GET /me` includes `handle` field (null when not yet set) +- Route registered before `/:id` catch-all in app.ts +- All new handler logic has integration tests + +## Dependencies + +- 01 (schemas and migration -- needs `handleSchema`, `RESERVED_HANDLES`) + +## Relevant spec sections + +- 3.1 Onboarding flow +- 3.2 Settings read endpoint +- 3.3 Handle validation rules +- 3.4 Immutability +- 8.1 Reserved handles +- 8.2 Hex ID disambiguation +- 8.4 Route matching order (API) diff --git a/docs/superpowers/tasks/06-custom-urls/03-store-slug-support.md b/docs/superpowers/tasks/06-custom-urls/03-store-slug-support.md new file mode 100644 index 0000000..4b2e43c --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/03-store-slug-support.md @@ -0,0 +1,35 @@ +# 03 — Store layer slug support and custom URL building + +## Description + +Update `store.createPage()` and `store.createHtmlPage()` to accept an optional slug and owner ID, enforce slug-requires-auth, catch unique-constraint violations as `SlugConflictError`, and build the custom URL (`/:handle/:slug`) when both handle and slug are present. + +## Files to create/modify + +- `apps/api/store.ts` — add `SlugConflictError` class; update `createPage(spec, format, cfg)` signature to accept optional `{ slug?: string; ownerId?: string; ownerHandle?: string }` options; reject slug without ownerId; pass slug + owner_id to `db.insertPage()`; catch Postgres error code `23505` on the `pages_owner_slug_unique` index and throw `SlugConflictError`; build URL as `${publicUrl}/${ownerHandle}/${slug}` when both present, else fall back to `${publicUrl}/${id}` +- `apps/api/app.ts` — update `newPageHandler` to extract `slug` from the parsed body and forward it to `store.createPage()` along with the authenticated user's `ownerId` and `ownerHandle`; map `SlugConflictError` to `409 { "error": "slug_conflict", "slug": "...", "message": "..." }` +- `apps/api/db.ts` — update `insertPage()` to write `slug` and `owner_id` columns when provided +- `apps/api/app.test.ts` — add tests: page created with slug returns custom URL; page created without slug returns hex URL; slug without auth rejected; duplicate slug returns 409 + +## Acceptance criteria + +- `POST /new { spec, slug: "my-form" }` from authenticated user with handle `alex` returns `url: "https://pagent.link/alex/my-form"` +- `POST /new { spec }` (no slug) returns hex-ID URL as before +- `POST /new { spec, slug: "x" }` without authentication returns an error (slug requires auth) +- Duplicate slug for same owner returns `409 { "error": "slug_conflict", ... }` +- `SlugConflictError` is exported for use by MCP layer +- `createHtmlPage()` also accepts and forwards slug +- Existing tests for `POST /new` without slug still pass + +## Dependencies + +- 01 (schemas, migration, `slugSchema` in `newPageBodySchema`) +- 02 (handle registration -- need user with handle for integration tests) + +## Relevant spec sections + +- 4.1 How slugs flow through the system +- 4.3 Uniqueness enforcement +- 4.4 Slug lifecycle and expired pages +- 4.5 Slugs without Auth +- 7.5 Response URL format diff --git a/docs/superpowers/tasks/06-custom-urls/04-resolve-endpoint.md b/docs/superpowers/tasks/06-custom-urls/04-resolve-endpoint.md new file mode 100644 index 0000000..7e5aed3 --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/04-resolve-endpoint.md @@ -0,0 +1,35 @@ +# 04 — Resolution endpoint (`GET /resolve/:handle/:slug`) + +## Description + +Add the API endpoint that resolves a `(handle, slug)` pair to a page ID, enabling the web renderer to look up pages by their custom URL. + +## Files to create/modify + +- `apps/api/db.ts` — add `resolveHandleSlug(handle: string, slug: string): Promise<{ id: string; format: PageFormat; state: PageState; expiresAt: number } | null>` that joins `pages` on `users` (`u.handle = $handle AND p.slug = $slug AND p.expires_at > now()`) +- `apps/api/app.ts` — add `GET /resolve/:handle/:slug` route and `resolveHandleSlugHandler`; register it BEFORE `/:id` catch-all; handler validates params against `handleSchema`/`slugSchema`, calls `db.resolveHandleSlug()`, returns `200 { id, format, state, expires_at }` or `404 { error: "not_found" }` +- `apps/api/app.test.ts` — add tests: resolve existing active page (200), resolve non-existent handle (404), resolve non-existent slug (404), resolve expired page (404), invalid handle format (400) + +## Acceptance criteria + +- `GET /resolve/alex/quarterly-review` returns `200 { "id": "", ... }` when page exists and is not expired +- `GET /resolve/alex/no-such-slug` returns `404` +- `GET /resolve/nobody/anything` returns `404` (unknown handle) +- Expired pages return `404` (query filters on `expires_at > now()`) +- Route is registered before `/:id` in app.ts -- no ambiguity with hex page IDs +- Shares the same rate limiter as `GET /:id` (read-only, generous) +- Handler validates handle/slug format and returns `400` for obviously invalid params +- Integration tests cover all response codes + +## Dependencies + +- 01 (schemas -- `handleSchema`, `slugSchema`) +- 03 (store -- pages must be insertable with slug + owner_id for test setup) + +## Relevant spec sections + +- 5.1 Routing logic and precedence +- 5.2 API resolution endpoint +- 5.3 Redirect behavior +- 5.4 API route registration +- 8.4 Route matching order (API) diff --git a/docs/superpowers/tasks/06-custom-urls/05-mcp-slug-parameter.md b/docs/superpowers/tasks/06-custom-urls/05-mcp-slug-parameter.md new file mode 100644 index 0000000..60634b6 --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/05-mcp-slug-parameter.md @@ -0,0 +1,41 @@ +# 05 — MCP tool slug parameter (show_ui, show_html, stdio adapter) + +## Description + +Add the optional `slug` parameter to the `show_ui` and `show_html` MCP tool input schemas, update the `PageOps` interface, forward slug through both the in-process HTTP MCP adapter and the stdio REST adapter, and clarify the `check_result` description. + +## Files to create/modify + +- `apps/api/mcp/tools.ts` — add `slug: slugSchema.optional()` to both `show_ui` and `show_html` `inputSchema`; update `PageOps` interface signatures to `showUi(spec: unknown, slug?: string)` and `showHtml(html: string, slug?: string)`; update `SHOW_UI_DESCRIPTION` with slug guidance; update `check_result` description to clarify page_id must be hex +- `apps/api/mcp/http.ts` — update `buildInProcessOps()` to forward the `slug` argument from tool handlers through to `store.createPage()` / `store.createHtmlPage()` +- `apps/mcp/server.ts` — update `restOps.showUi()` and `restOps.showHtml()` to accept `slug` parameter and include `...(slug && { slug })` in the `POST /new` request body +- `apps/api/mcp/tools.test.ts` — add tests: show_ui with slug forwards it; show_ui without slug omits it; show_html with slug forwards it +- `apps/mcp/server.test.ts` — add tests: restOps.showUi sends slug in body; restOps.showHtml sends slug in body + +## Acceptance criteria + +- `show_ui({ spec: [...], slug: "my-form" })` creates a page with the slug and returns custom URL +- `show_ui({ spec: [...] })` (no slug) works exactly as before +- `show_html` has the same optional slug behavior +- `PageOps` interface updated with `slug?: string` on both methods +- Stdio adapter forwards slug to `POST /new` when present +- In-process HTTP adapter forwards slug to store when present +- `check_result` tool description notes that `page_id` must be the hex ID, not a slug +- `SHOW_UI_DESCRIPTION` mentions slugs and custom URLs +- All new behavior has unit tests + +## Dependencies + +- 01 (schemas -- `slugSchema`) +- 03 (store -- `createPage` accepts slug) + +## Relevant spec sections + +- 4.2 MCP tool changes (show_ui) +- 7.1 `show_ui` -- slug parameter +- 7.2 `show_html` -- same slug parameter +- 7.3 Updated tool description +- 7.4 `check_result` -- page_id format update +- 7.5 Response URL format +- 7.6 PageOps interface update +- 7.7 Stdio MCP adapter diff --git a/docs/superpowers/tasks/06-custom-urls/06-frontend-routing.md b/docs/superpowers/tasks/06-custom-urls/06-frontend-routing.md new file mode 100644 index 0000000..19db36e --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/06-frontend-routing.md @@ -0,0 +1,37 @@ +# 06 — Frontend routing and Vite dev-proxy for custom URLs + +## Description + +Update the web renderer to parse `/:handle/:slug` paths, resolve them to page IDs via the new API endpoint, and add Vite dev-server proxy rules so custom URL paths work in local development. + +## Files to create/modify + +- `apps/web/main.ts` — replace the single-segment `pageId` extraction with `parseRoute(pathname)` returning a `PageRef` discriminated union (`{ kind: 'id'; id } | { kind: 'handle-slug'; handle; slug } | { kind: 'home' } | { kind: 'showcase' }`); add resolution fetch in `AgentUIApp` that calls `GET ${API_BASE}/resolve/${handle}/${slug}` for `handle-slug` routes, extracts the `id`, then continues with existing page-load logic; show "Page not found" on 404 from resolve +- `apps/web/vite.config.ts` — add proxy rule for `/resolve/:handle/:slug` (always API); add content-negotiated proxy rule for `/:handle/:slug` patterns (HTML requests get SPA shell, fetch requests proxied to API) + +## Acceptance criteria + +- Navigating to `http://localhost:8788/alex/quarterly-review` renders the page (after resolution) +- Navigating to `http://localhost:8788/<32-char-hex>` works exactly as before +- Navigating to `http://localhost:8788/` shows the home page +- Navigating to `http://localhost:8788/_components` shows the component showcase +- Resolution failure (404 from API) shows a "Page not found or expired" message +- `parseRoute()` prioritizes hex IDs over handle/slug (a 32-char hex string is always treated as an ID) +- Vite dev proxy correctly routes `/resolve/...` requests to the API server +- Vite dev proxy correctly content-negotiates `/:handle/:slug` requests (HTML -> SPA, fetch -> API) +- No changes needed to Vercel rewrite config (existing catch-all handles it) +- Browser URL bar keeps the custom URL (no rewrite to hex ID) + +## Dependencies + +- 04 (resolve endpoint must exist for the frontend to call) + +## Relevant spec sections + +- 5.1 Routing logic and precedence +- 5.3 Redirect behavior +- 6.1 Current routing (main.ts) +- 6.2 Updated routing +- 6.3 Resolution in AgentUIApp +- 6.4 Vite dev-server proxy changes +- 6.5 Vercel rewrite (no change needed) diff --git a/docs/superpowers/tasks/06-custom-urls/07-openapi-docs.md b/docs/superpowers/tasks/06-custom-urls/07-openapi-docs.md new file mode 100644 index 0000000..b2927cd --- /dev/null +++ b/docs/superpowers/tasks/06-custom-urls/07-openapi-docs.md @@ -0,0 +1,30 @@ +# 07 — OpenAPI spec updates + +## Description + +Document the new `GET /resolve/:handle/:slug` and `PUT /me/handle` endpoints in the OpenAPI spec, and update the `POST /new` schema to include the optional `slug` field. + +## Files to create/modify + +- `docs/openapi.yaml` — add path `/resolve/{handle}/{slug}` with GET operation (200 with `{ id, format, state, expires_at }`, 404); add path `/me/handle` with PUT operation (200, 400, 409, 422); update `POST /new` request body to include optional `slug` field on both `a2ui` and `html` branches; add `HandleSchema` and `SlugSchema` component schemas + +## Acceptance criteria + +- `GET /resolve/{handle}/{slug}` fully documented with path parameters, response schemas for 200 and 404 +- `PUT /me/handle` documented with request body schema, response schemas for 200, 400, 409, 422 +- `POST /new` request body schemas updated with optional `slug` on both format branches +- `HandleSchema` and `SlugSchema` defined as reusable components with pattern and length constraints +- API docs page (`/docs`) renders the new endpoints correctly +- YAML is valid and parseable (no syntax errors) + +## Dependencies + +- 02 (handle registration endpoint exists) +- 04 (resolve endpoint exists) + +## Relevant spec sections + +- 3.1 Onboarding flow (PUT /me/handle response table) +- 5.2 API resolution endpoint (GET /resolve/:handle/:slug response table) +- 7.8 REST `POST /new` body schema update +- Appendix A: Full file change inventory (docs/openapi.yaml row) diff --git a/docs/superpowers/tasks/07-agent-submit/01-validation-module.md b/docs/superpowers/tasks/07-agent-submit/01-validation-module.md new file mode 100644 index 0000000..bba5fea --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/01-validation-module.md @@ -0,0 +1,33 @@ +# 01 -- Shared Validation Module + +## Description + +Create the validation module that extracts a component map from an A2UI spec and validates agent-submitted data against it. This is the foundation every other task depends on. + +## Files to create/modify + +- **Create** `apps/api/validate-agent-data.ts` -- exports `INPUT_COMPONENTS`, `extractComponentMap`, `validateAgentData`, `validateFieldValue`, and the `FieldError` type +- **Create** `apps/api/validate-agent-data.test.ts` -- unit tests + +## Acceptance criteria + +- `extractComponentMap(spec)` walks `spec[*].updateComponents.components` and returns a `Map`. +- `INPUT_COMPONENTS` set contains exactly `TextField`, `CheckBox`, `Slider`, `ChoicePicker`, `DateTimeInput`. +- `validateAgentData(data, componentMap)` returns `FieldError[]` (empty array if all valid). +- Per-component validation rules match the spec's component type table (section 4): + - `TextField`: string for text/obscured/longText (max 10k / 50k chars); number for variant=number with string-to-number coercion. + - `CheckBox`: boolean only. + - `Slider`: finite number within optional `[min, max]`. + - `ChoicePicker`: `string[]`; single-selection requires exactly 1 element; values must be in `options[].value`. + - `DateTimeInput`: ISO 8601 string; respects `enableDate`/`enableTime` for date-only or time-only formats. +- Keys in `data` that target non-input or unknown components are silently ignored (no error). +- Unit tests cover: every component type happy path, type mismatches, boundary values (slider min/max), ChoicePicker invalid option, DateTimeInput format variants, unknown field IDs, empty data object. + +## Dependencies + +None -- this is the first task. + +## Relevant spec sections + +- Section 4: A2UI Spec-to-Validation Mapping (full section) +- Section 7: Error taxonomy (`FieldError` type definition) diff --git a/docs/superpowers/tasks/07-agent-submit/02-schema-update.md b/docs/superpowers/tasks/07-agent-submit/02-schema-update.md new file mode 100644 index 0000000..5096fd0 --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/02-schema-update.md @@ -0,0 +1,30 @@ +# 02 -- Result Body Schema Update + +## Description + +Update the `resultBodySchema` in `schemas.ts` from a single object schema to a discriminated union that accepts both browser submissions (existing) and agent submissions (new `source: "agent"` shape). + +## Files to create/modify + +- **Modify** `apps/api/schemas.ts` -- add `agentResultBodySchema`, `browserResultBodySchema`, redefine `resultBodySchema` as a `z.union`, export `AgentResult`, `BrowserResult`, `ResultBody` types +- **Modify** `apps/api/schemas.test.ts` -- add test cases for the new union schema + +## Acceptance criteria + +- `agentResultBodySchema` validates `{ source: "agent", data: Record, file_refs?: Record }`. +- `browserResultBodySchema` is the existing schema extracted into its own export, with `.passthrough()` preserved. +- `resultBodySchema` is `z.union([agentResultBodySchema, browserResultBodySchema])`. +- Parsing a body with `source: "agent"` matches the agent branch. +- Parsing a body without `source` (e.g., `{ name: "submitted", surfaceId: "main", context: {} }`) matches the browser branch -- backward compatible. +- Parsing a body that matches neither branch fails validation. +- Types `AgentResult`, `BrowserResult`, `ResultBody` are exported. +- Existing tests in `schemas.test.ts` continue to pass unchanged. + +## Dependencies + +None -- independent of task 01. + +## Relevant spec sections + +- Section 3: Result body schema update in `schemas.ts` +- Section 2: Agent submission payload (Zod schemas) diff --git a/docs/superpowers/tasks/07-agent-submit/03-api-handler.md b/docs/superpowers/tasks/07-agent-submit/03-api-handler.md new file mode 100644 index 0000000..def02c2 --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/03-api-handler.md @@ -0,0 +1,36 @@ +# 03 -- API Handler: Agent Submission Branch + +## Description + +Update the `submitResultHandler` in `apps/api/app.ts` to branch on the parsed body shape. When `source === "agent"`, run server-side validation against the page spec and store the agent result. Browser submissions remain unchanged. + +## Files to create/modify + +- **Modify** `apps/api/app.ts` -- update `submitResultHandler` to detect agent submissions, import validation module, add server-side validation, add `validation_failed` error response +- **Modify** `apps/api/app.test.ts` -- add integration tests for agent submission path + +## Acceptance criteria + +- After Zod parsing with the updated `resultBodySchema`, the handler checks `parsed.source === "agent"` to branch. +- Agent branch: + 1. Fetches the page and verifies `format === "a2ui"` (returns 400 `invalid_for_format` for HTML pages). + 2. Verifies `state === "open"` (returns 409 `conflict` if already submitted). + 3. Calls `extractComponentMap` + `validateAgentData` from the validation module. + 4. On validation failure: returns 400 with `{ error: "validation_failed", message, fields: FieldError[] }`. + 5. On success: stores result as `{ source: "agent", data, file_refs?, submitted_at }` and transitions state to `submitted`. +- Browser branch: existing behavior, no changes. +- Response codes match the spec table: 200 success, 400 bad_request / validation_failed / invalid_for_format, 404 not_found, 409 conflict. +- Integration tests cover: successful agent submit, validation failure response, HTML page rejection, already-submitted conflict, browser submit still works. + +## Dependencies + +- 01 (validation module -- imports `extractComponentMap`, `validateAgentData`) +- 02 (schema update -- uses the union `resultBodySchema`) + +## Relevant spec sections + +- Section 2: API Endpoint Changes (`POST /:id/result` -- dual-format) +- Section 2: Server-side validation (agent submissions) +- Section 2: Response codes table +- Section 2: Validation error response shape +- Section 10: Backward Compatibility (browser submissions unchanged) diff --git a/docs/superpowers/tasks/07-agent-submit/04-pageops-and-mcp-tool.md b/docs/superpowers/tasks/07-agent-submit/04-pageops-and-mcp-tool.md new file mode 100644 index 0000000..8b67126 --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/04-pageops-and-mcp-tool.md @@ -0,0 +1,38 @@ +# 04 -- PageOps Extension and MCP Tool Registration + +## Description + +Extend the `PageOps` interface with `getPage` and `submitForm` methods. Register the `submit_form` MCP tool in `tools.ts` with the full fetch-validate-submit orchestration logic. + +## Files to create/modify + +- **Modify** `apps/api/mcp/tools.ts` -- extend `PageOps` with `getPage(page_id)` and `submitForm(page_id, body)`, add `GetPageResult` / `SubmitFormResult` types, register `submit_form` tool via `registerPagentTools` +- **Modify** `apps/api/mcp/tools.test.ts` -- add tests for the `submit_form` tool using a mock `PageOps` + +## Acceptance criteria + +- `PageOps` interface adds: + - `getPage(page_id: string): Promise` where `GetPageResult` is `{ kind: 'not_found' } | { kind: 'ok', spec, format, state }`. + - `submitForm(page_id: string, body: AgentResultBody): Promise` where `SubmitFormResult` covers ok / validation_failed / not_found / conflict / invalid_format / access_denied. +- `submit_form` tool is registered with: + - Input schema: `{ page_id: z.string().regex(/^[a-f0-9]{32}$/), data: z.record(z.unknown()), files: z.record(z.string()).optional() }`. + - Model-facing description from spec section 1. + - Title: "Submit a form on behalf of the agent". +- Tool handler orchestration: + 1. Calls `ops.getPage(page_id)` -- throws MCP error for not_found, HTML format, non-open state. + 2. Extracts component map from spec, validates data locally. + 3. On validation failure: returns structured `{ valid: false, errors }` (not a throw). + 4. On success: calls `ops.submitForm` and returns `{ submission_id, page_id, submitted_at }`. +- File handling (`files` param) is accepted in schema but deferred -- if `files` is provided, throw a clear error: "File uploads not yet supported" (placeholder for task 06). +- Tests verify: successful submit flow, not_found error, HTML rejection, conflict error, local validation failure returns structured errors. + +## Dependencies + +- 01 (validation module -- imports `extractComponentMap`, `validateAgentData`) +- 02 (schema update -- imports `AgentResult` type) + +## Relevant spec sections + +- Section 1: MCP Tool Definition (full section) +- Section 9: MCP Stdio Server Implementation (PageOps extension, tool handler orchestration) +- Section 7: Error Responses (error taxonomy, MCP error vs structured return) diff --git a/docs/superpowers/tasks/07-agent-submit/05-adapters.md b/docs/superpowers/tasks/07-agent-submit/05-adapters.md new file mode 100644 index 0000000..aede235 --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/05-adapters.md @@ -0,0 +1,33 @@ +# 05 -- Stdio and In-Process Adapter Implementations + +## Description + +Implement the concrete `getPage` and `submitForm` methods in both the stdio REST adapter (`apps/mcp/server.ts`) and the in-process adapter (`apps/api/mcp/http.ts`), wiring the new `PageOps` methods to actual HTTP calls or direct handler invocation. + +## Files to create/modify + +- **Modify** `apps/mcp/server.ts` -- add `restOps.getPage` and `restOps.submitForm` to the stdio adapter +- **Modify** `apps/api/mcp/http.ts` -- add `getPage` and `submitForm` to `buildInProcessOps` +- **Modify** `apps/mcp/server.test.ts` -- add tests for stdio adapter's new methods +- **Modify** `apps/api/mcp/http.test.ts` -- add tests for in-process adapter's new methods + +## Acceptance criteria + +- **Stdio adapter** (`restOps` in `apps/mcp/server.ts`): + - `getPage(page_id)`: `GET ${SERVICE_URL}/${page_id}` with `accept: application/json`. Returns `{ kind: 'not_found' }` on 404, `{ kind: 'ok', spec, format, state }` on 200. + - `submitForm(page_id, body)`: `POST ${SERVICE_URL}/${page_id}/result` with JSON body. Maps status codes to `SubmitFormResult` kinds (404->not_found, 409->conflict, 403->access_denied, 400->validation_failed or invalid_format, 200->ok). +- **In-process adapter** (`buildInProcessOps` in `apps/api/mcp/http.ts`): + - `getPage(page_id)`: reads from the page store directly (same store used by `checkResult`). Returns format, spec, and state. + - `submitForm(page_id, body)`: calls the API handler logic directly (in-process), returning the appropriate result kind. +- Both adapters handle error responses gracefully and map them to `SubmitFormResult` discriminated union. +- Integration tests verify: end-to-end `submit_form` tool call through in-process transport creates a page with `show_ui`, submits with `submit_form`, and reads result with `check_result`. The result contains `source: "agent"` and the submitted data. + +## Dependencies + +- 03 (API handler -- the endpoint must exist for the stdio adapter to call, and the in-process adapter reuses handler logic) +- 04 (PageOps interface -- defines the methods to implement) + +## Relevant spec sections + +- Section 9: Stdio adapter code (`restOps.getPage`, `restOps.submitForm`) +- Section 11: Implementation Plan, Phase 1, items 5-6 diff --git a/docs/superpowers/tasks/07-agent-submit/06-file-handling.md b/docs/superpowers/tasks/07-agent-submit/06-file-handling.md new file mode 100644 index 0000000..85a49ba --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/06-file-handling.md @@ -0,0 +1,41 @@ +# 06 -- File Upload Support in submit_form + +## Description + +Wire the `files` parameter of `submit_form` to the file upload endpoint. The MCP tool reads files from local disk, uploads them via `POST /:id/files`, and includes the returned file IDs as `file_refs` in the submission payload. The API handler validates that referenced file IDs belong to the page. + +## Files to create/modify + +- **Modify** `apps/api/mcp/tools.ts` -- remove the "file uploads not yet supported" placeholder; implement file read/upload loop in the `submit_form` handler +- **Modify** `apps/mcp/server.ts` -- add `restOps.uploadFile(page_id, fieldId, file)` to the stdio adapter +- **Modify** `apps/api/mcp/http.ts` -- add `uploadFile` to `buildInProcessOps` +- **Modify** `apps/api/app.ts` -- in the agent submission branch, validate that each `file_refs` value is a known file ID belonging to the page +- **Modify** `apps/api/mcp/tools.test.ts` -- add file handling tests +- **Modify** `apps/api/app.test.ts` -- add file_refs validation tests + +## Acceptance criteria + +- `PageOps` interface includes `uploadFile(page_id: string, fieldId: string, file: FilePayload): Promise` where `FilePayload = { buffer: Buffer, fileName: string, mimeType: string }`. +- MCP tool handler, when `files` is provided: + 1. For each entry, verifies the local path exists and is a file (throws `"File not found: /path"` if not). + 2. Reads the file into a buffer. + 3. Guesses MIME type from file extension. + 4. Calls `ops.uploadFile` with multipart form data to `POST /:id/files`. + 5. Collects returned `file_id` values into a `file_refs` map. + 6. Includes `file_refs` in the `POST /:id/result` payload. +- Stdio adapter: `uploadFile` sends `POST ${SERVICE_URL}/${page_id}/files` with `multipart/form-data` containing the file blob and `field_name`. +- API handler (server-side): validates each `file_refs[field]` value exists in storage and belongs to the target page. Returns 400 if a file_id is invalid. +- Errors: `"File not found: {path}"` for missing local files, `"File upload failed for field {field}: {reason}"` for server rejection. + +## Dependencies + +- 04 (PageOps interface -- `uploadFile` extends it) +- 05 (adapters -- adds `uploadFile` to existing adapter implementations) +- External: `POST /:id/files` endpoint must exist (file uploads feature from the v2 batch) + +## Relevant spec sections + +- Section 5: File Handling (full section) +- Section 1: Input schema (`files` parameter) +- Section 7: Error catalog (`file_not_found`, `file_upload_failed`) +- Section 11: Phase 2 (File handling) diff --git a/docs/superpowers/tasks/07-agent-submit/07-auth-access-control.md b/docs/superpowers/tasks/07-agent-submit/07-auth-access-control.md new file mode 100644 index 0000000..1be66a7 --- /dev/null +++ b/docs/superpowers/tasks/07-agent-submit/07-auth-access-control.md @@ -0,0 +1,39 @@ +# 07 -- Auth and Access Control Integration + +## Description + +Integrate OAuth token-based identity into agent submissions. The server extracts the submitter's email from the Bearer token, populates `submitted_by` on the stored result, and enforces access control on private pages. + +## Files to create/modify + +- **Modify** `apps/api/app.ts` -- in the agent submission branch: extract `email` from the Bearer token, set `submitted_by` on stored result, enforce `access_emails` allowlist for private pages, return 401 for anonymous submissions to private pages, return 403 for email not in allowlist +- **Modify** `apps/mcp/server.ts` -- include OAuth Bearer token in `Authorization` header on `POST /:id/result` and `POST /:id/files` requests +- **Modify** `apps/api/app.test.ts` -- add auth and access control test cases + +## Acceptance criteria + +- Server extracts `email` claim from the `Authorization: Bearer ` header on agent submissions. +- Stored agent result includes `submitted_by: ""` (or `null` if no token). +- Private page access control: + - If `page.access_emails` is non-null and non-empty: + - Token email in list: allow submission. + - Token email not in list: return 403 `access_denied` with message including the email. + - No token: return 401 `unauthorized`. + - If `page.access_emails` is null (public page): allow without access check. +- Public mode pages (`mode: "public"`) skip email allowlist checks entirely. +- Stdio adapter sends OAuth token on both `POST /:id/files` and `POST /:id/result`. +- `check_result` returns `submitted_by` in the result for agent submissions. +- Tests cover: successful submit with token, `submitted_by` populated in stored result, private page allowed email, private page denied email (403), private page no token (401), public page no token (allowed). + +## Dependencies + +- 03 (API handler -- agent submission branch must exist) +- 05 (adapters -- stdio adapter must exist to add auth headers) +- External: OAuth token infrastructure must exist (auth feature from the v2 batch) + +## Relevant spec sections + +- Section 6: Auth and Access Control (full section) +- Section 2: `submitted_by` field in agent submission payload +- Section 7: Error catalog (`access_denied`, `unauthorized`) +- Section 11: Phase 3 (Auth integration) diff --git a/package-lock.json b/package-lock.json index b7707d6..02f38fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,9 +47,12 @@ "@opentelemetry/sdk-metrics": "^2.7.1", "@opentelemetry/sdk-node": "^0.217.0", "@scalar/hono-api-reference": "^0.10.14", + "@types/nodemailer": "^8.0.0", "hono": "^4.6.14", "hono-rate-limiter": "^0.5.3", "isomorphic-dompurify": "^2.16.0", + "jose": "^6.2.3", + "nodemailer": "^8.0.7", "pino": "^10.3.1", "pino-opentelemetry-transport": "^3.0.0", "postgres": "^3.4.9", @@ -3611,6 +3614,15 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -8373,6 +8385,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz",