From 3ef3046ce1829508af3e26d6efedb5578aabffcf Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:23:13 -0500 Subject: [PATCH 01/21] feat: add personal access token schema --- migrations/0015_personal_access_tokens.sql | 23 +++ scripts/hqbase/reset-d1.sql | 1 + test/integration/worker/local-reset.test.ts | 44 +++++- .../personal-access-token-migration.test.ts | 136 ++++++++++++++++++ .../personal-access-token-schema.test.ts | 129 +++++++++++++++++ test/unit/scripts/reset-d1.test.mjs | 4 + 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 migrations/0015_personal_access_tokens.sql create mode 100644 test/integration/worker/personal-access-token-migration.test.ts create mode 100644 test/integration/worker/personal-access-token-schema.test.ts diff --git a/migrations/0015_personal_access_tokens.sql b/migrations/0015_personal_access_tokens.sql new file mode 100644 index 00000000..e94923c6 --- /dev/null +++ b/migrations/0015_personal_access_tokens.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE personal_access_tokens ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + token_hash TEXT NOT NULL UNIQUE + CHECK (length(token_hash) = 43 AND token_hash NOT GLOB '*[^A-Za-z0-9_-]*'), + token_suffix TEXT NOT NULL + CHECK (length(token_suffix) = 4 AND token_suffix NOT GLOB '*[^A-Za-z0-9_-]*'), + created_at TEXT NOT NULL + CHECK (length(created_at) = 24 AND substr(created_at, 24, 1) = 'Z'), + expires_at TEXT + CHECK (expires_at IS NULL OR (length(expires_at) = 24 AND substr(expires_at, 24, 1) = 'Z')), + revoked_at TEXT + CHECK (revoked_at IS NULL OR (length(revoked_at) = 24 AND substr(revoked_at, 24, 1) = 'Z')) +); + +CREATE INDEX personal_access_tokens_user_idx +ON personal_access_tokens(user_id, created_at DESC); + +CREATE INDEX personal_access_tokens_list_idx +ON personal_access_tokens(created_at DESC, id DESC); diff --git a/scripts/hqbase/reset-d1.sql b/scripts/hqbase/reset-d1.sql index f642c84d..cc2505e4 100644 --- a/scripts/hqbase/reset-d1.sql +++ b/scripts/hqbase/reset-d1.sql @@ -25,6 +25,7 @@ DROP TABLE IF EXISTS deployment_state; DROP TABLE IF EXISTS operation_runs; DROP TABLE IF EXISTS retention_policies; DROP TABLE IF EXISTS rate_limits; +DROP TABLE IF EXISTS personal_access_tokens; DROP TABLE IF EXISTS audit_events; DROP TABLE IF EXISTS mailbox_grants; DROP TABLE IF EXISTS hqbase_schema_state; diff --git a/test/integration/worker/local-reset.test.ts b/test/integration/worker/local-reset.test.ts index 50b88d58..d2559d49 100644 --- a/test/integration/worker/local-reset.test.ts +++ b/test/integration/worker/local-reset.test.ts @@ -16,6 +16,7 @@ import latestPasswordResetTokenMigration from "../../../migrations/0011_latest_p import messageActivityIndexMigration from "../../../migrations/0012_message_activity_index.sql?raw"; import messageChangesMigration from "../../../migrations/0013_message_changes.sql?raw"; import unassignedMessagesMigration from "../../../migrations/0014_unassigned_messages.sql?raw"; +import personalAccessTokensMigration from "../../../migrations/0015_personal_access_tokens.sql?raw"; import resetSql from "../../../scripts/hqbase/reset-d1.sql?raw"; import { buildSeedSql } from "../../../scripts/local-seed-fixture.mjs"; import { migrationStatements } from "./migration-statements"; @@ -35,7 +36,8 @@ const migrations = [ latestPasswordResetTokenMigration, messageActivityIndexMigration, messageChangesMigration, - unassignedMessagesMigration + unassignedMessagesMigration, + personalAccessTokensMigration ]; describe("local database reset", () => { @@ -47,6 +49,17 @@ describe("local database reset", () => { }, 60_000); it("removes current data and supports a fresh migration", async () => { + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES ( + 'pat_before_local_reset', 'usr_local_owner', 'Reset fixture', ?, 'a1B2', + '2026-08-14T18:00:00.000Z', NULL, NULL + )` + ) + .bind("R".repeat(43)) + .run(); + await applyStatements(resetSql); await applyMigrations(); @@ -104,6 +117,35 @@ describe("local database reset", () => { name: string; }>(); expect(messageColumns.results.map((column) => column.name)).toContain("is_unassigned"); + + const personalAccessTokens = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM personal_access_tokens" + ).first<{ count: number }>(); + expect(personalAccessTokens?.count).toBe(0); + + const stamp = "2026-08-20T18:00:00.000Z"; + await env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned) + VALUES ('usr_after_local_reset', 'Reset Owner', 'reset@example.com', 1, ?, ?, 'owner', 0)` + ) + .bind(stamp, stamp) + .run(); + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES ( + 'pat_after_local_reset', 'usr_after_local_reset', 'Fresh reset token', ?, 'c3D4', + ?, NULL, NULL + )` + ) + .bind("S".repeat(43), stamp) + .run(); + + const freshPat = await env.DB.prepare( + "SELECT id FROM personal_access_tokens WHERE id = 'pat_after_local_reset'" + ).first<{ id: string }>(); + expect(freshPat?.id).toBe("pat_after_local_reset"); }); }); diff --git a/test/integration/worker/personal-access-token-migration.test.ts b/test/integration/worker/personal-access-token-migration.test.ts new file mode 100644 index 00000000..79ae91d0 --- /dev/null +++ b/test/integration/worker/personal-access-token-migration.test.ts @@ -0,0 +1,136 @@ +import { env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import initialMigration from "../../../migrations/0001_initial.sql?raw"; +import workspaceMigration from "../../../migrations/0002_workspace.sql?raw"; +import oauthResourcesMigration from "../../../migrations/0003_oauth_resources.sql?raw"; +import conversationMigration from "../../../migrations/0004_conversations.sql?raw"; +import threadRebuildMigration from "../../../migrations/0005_rebuild_threads.sql?raw"; +import pushMigration from "../../../migrations/0006_push_notifications.sql?raw"; +import userMailPreferencesMigration from "../../../migrations/0007_user_mail_preferences.sql?raw"; +import userOnboardingMigration from "../../../migrations/0008_user_onboarding.sql?raw"; +import loginEmailDomainMigration from "../../../migrations/0009_login_email_domain_isolation.sql?raw"; +import deviceAuthorizationMigration from "../../../migrations/0010_oauth_device_authorization.sql?raw"; +import latestPasswordResetTokenMigration from "../../../migrations/0011_latest_password_reset_token.sql?raw"; +import messageActivityIndexMigration from "../../../migrations/0012_message_activity_index.sql?raw"; +import messageChangesMigration from "../../../migrations/0013_message_changes.sql?raw"; +import unassignedMessagesMigration from "../../../migrations/0014_unassigned_messages.sql?raw"; +import personalAccessTokensMigration from "../../../migrations/0015_personal_access_tokens.sql?raw"; +import { migrationStatements } from "./migration-statements"; + +const priorMigrations = [ + initialMigration, + workspaceMigration, + oauthResourcesMigration, + conversationMigration, + threadRebuildMigration, + pushMigration, + userMailPreferencesMigration, + userOnboardingMigration, + loginEmailDomainMigration, + deviceAuthorizationMigration, + latestPasswordResetTokenMigration, + messageActivityIndexMigration, + messageChangesMigration, + unassignedMessagesMigration +]; + +describe("personal access token migration", () => { + let existingRows: Record[]>; + + beforeAll(async () => { + for (const migration of priorMigrations) await applyMigration(migration); + await seedExistingRows(); + existingRows = await loadExistingRows(); + await applyMigration(personalAccessTokensMigration); + }); + + it("adds the PAT table and indexes without changing existing records", async () => { + expect(await loadExistingRows()).toEqual(existingRows); + + const columns = await env.DB.prepare("PRAGMA table_info(personal_access_tokens)").all<{ + name: string; + }>(); + expect(columns.results.map(({ name }) => name)).toEqual([ + "id", + "user_id", + "name", + "token_hash", + "token_suffix", + "created_at", + "expires_at", + "revoked_at" + ]); + + const indexes = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'personal_access_tokens_%'" + ).all<{ name: string }>(); + expect(indexes.results.map(({ name }) => name).sort()).toEqual([ + "personal_access_tokens_list_idx", + "personal_access_tokens_user_idx" + ]); + }); +}); + +async function seedExistingRows(): Promise { + const stamp = "2026-08-19T18:00:00.000Z"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned) + VALUES ('usr_pat_migration', 'Migration Owner', 'migration@example.com', 1, ?, ?, 'owner', 0)` + ).bind(stamp, stamp), + env.DB.prepare( + `INSERT INTO oauthClient + (id, clientId, userId, redirectUris, createdAt, updatedAt) + VALUES ('oauth_client_row', 'oauth-client-migration', 'usr_pat_migration', '[]', ?, ?)` + ).bind(stamp, stamp), + env.DB.prepare( + `INSERT INTO mailboxes (id, address, display_name, is_active, created_at, updated_at) + VALUES ('mbx_pat_migration', 'migration@example.com', 'Migration', 1, ?, ?)` + ).bind(stamp, stamp), + env.DB.prepare( + `INSERT INTO threads (id, subject_normalized, last_message_at, created_at, updated_at) + VALUES ('thr_pat_migration', 'migration', ?, ?, ?)` + ).bind(stamp, stamp, stamp), + env.DB.prepare( + `INSERT INTO messages ( + id, thread_id, mailbox_id, direction, folder, from_address, to_json, cc_json, bcc_json, + subject, snippet, text_body, references_json, received_at, has_attachments, + created_at, updated_at + ) VALUES ( + 'msg_pat_migration', 'thr_pat_migration', 'mbx_pat_migration', 'inbound', 'inbox', + 'sender@example.net', '[]', '[]', '[]', 'Migration', '', '', '[]', ?, 0, ?, ? + )` + ).bind(stamp, stamp, stamp), + env.DB.prepare( + `INSERT INTO audit_events + (id, occurred_at, correlation_id, actor_type, actor_id, action, resource_type, + resource_id, outcome, metadata_json) + VALUES ( + 'audit_pat_migration', ?, 'correlation-migration', 'user', 'usr_pat_migration', + 'migration.fixture', 'message', 'msg_pat_migration', 'success', '{}' + )` + ).bind(stamp) + ]); +} + +async function loadExistingRows(): Promise[]>> { + const tables = ["user", "oauthClient", "mailboxes", "threads", "messages", "audit_events"]; + return Object.fromEntries( + await Promise.all( + tables.map(async (table) => { + const rows = await env.DB.prepare(`SELECT * FROM "${table}" ORDER BY rowid`).all< + Record + >(); + return [table, rows.results] as const; + }) + ) + ); +} + +async function applyMigration(source: string): Promise { + for (const statement of migrationStatements(source)) { + await env.DB.prepare(statement).run(); + } +} diff --git a/test/integration/worker/personal-access-token-schema.test.ts b/test/integration/worker/personal-access-token-schema.test.ts new file mode 100644 index 00000000..f18284a8 --- /dev/null +++ b/test/integration/worker/personal-access-token-schema.test.ts @@ -0,0 +1,129 @@ +import { env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { applyCurrentMigrations } from "./current-migrations"; + +const stamp = "2026-08-19T18:00:00.000Z"; + +describe("personal access token schema", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + }); + + it("deletes a user's PAT rows through the foreign-key cascade", async () => { + await insertUser("usr_pat_cascade"); + await insertPat({ id: "pat_cascade", userId: "usr_pat_cascade", tokenHash: "A".repeat(43) }); + + await env.DB.prepare('DELETE FROM "user" WHERE id = ?').bind("usr_pat_cascade").run(); + + const row = await env.DB.prepare( + "SELECT id FROM personal_access_tokens WHERE id = 'pat_cascade'" + ).first<{ id: string }>(); + expect(row).toBeNull(); + }); + + it("rejects duplicate token hashes", async () => { + await insertUser("usr_pat_unique"); + const tokenHash = "B".repeat(43); + await insertPat({ id: "pat_unique_first", userId: "usr_pat_unique", tokenHash }); + + await expect( + insertPat({ id: "pat_unique_second", userId: "usr_pat_unique", tokenHash }) + ).rejects.toThrow(); + }); + + it.each([ + { label: "empty name", values: { name: "" } }, + { label: "blank name", values: { name: " " } }, + { label: "name longer than 80 characters", values: { name: "n".repeat(81) } }, + { label: "42-character hash", values: { tokenHash: "C".repeat(42) } }, + { label: "non-Base64url hash", values: { tokenHash: `${"C".repeat(42)}+` } }, + { label: "three-character suffix", values: { tokenSuffix: "abc" } }, + { label: "non-Base64url suffix", values: { tokenSuffix: "abc+" } } + ])("rejects invalid PAT shape: $label", async ({ label, values }) => { + const userId = `usr_pat_invalid_${label.replaceAll(/[^a-z0-9]/gu, "_")}`; + await insertUser(userId); + await expect( + insertPat({ + id: `pat_invalid_${label.replaceAll(/[^a-z0-9]/gu, "_")}`, + userId, + tokenHash: `${label.length.toString(36).padStart(2, "0")}${"D".repeat(41)}`, + ...values + }) + ).rejects.toThrow(); + }); + + it("rejects a malformed creation timestamp", async () => { + await insertUser("usr_pat_bad_created"); + await expect( + insertPat({ + id: "pat_bad_created", + userId: "usr_pat_bad_created", + tokenHash: `01${"E".repeat(41)}`, + createdAt: "2026-08-19T18:00:00Z" + }) + ).rejects.toThrow(); + }); + + it("rejects a malformed expiry timestamp", async () => { + await insertUser("usr_pat_bad_expiry"); + await expect( + insertPat({ + id: "pat_bad_expiry", + userId: "usr_pat_bad_expiry", + tokenHash: `02${"E".repeat(41)}`, + expiresAt: "2026-08-20T18:00:00Z" + }) + ).rejects.toThrow(); + }); + + it("rejects a malformed revocation timestamp", async () => { + await insertUser("usr_pat_bad_revoked"); + await expect( + insertPat({ + id: "pat_bad_revoked", + userId: "usr_pat_bad_revoked", + tokenHash: `03${"E".repeat(41)}`, + revokedAt: "2026-08-20T18:00:00Z" + }) + ).rejects.toThrow(); + }); +}); + +async function insertUser(id: string): Promise { + await env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned) + VALUES (?, 'PAT schema user', ?, 1, ?, ?, 'member', 0)` + ) + .bind(id, `${id}@example.com`, stamp, stamp) + .run(); +} + +async function insertPat(input: { + id: string; + userId: string; + tokenHash: string; + name?: string; + tokenSuffix?: string; + createdAt?: string; + expiresAt?: string | null; + revokedAt?: string | null; +}): Promise { + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + input.id, + input.userId, + input.name ?? "Schema test token", + input.tokenHash, + input.tokenSuffix ?? "a1B2", + input.createdAt ?? stamp, + input.expiresAt ?? null, + input.revokedAt ?? null + ) + .run(); +} diff --git a/test/unit/scripts/reset-d1.test.mjs b/test/unit/scripts/reset-d1.test.mjs index cc2325a4..4ed0eea8 100644 --- a/test/unit/scripts/reset-d1.test.mjs +++ b/test/unit/scripts/reset-d1.test.mjs @@ -20,6 +20,10 @@ describe("local D1 reset", () => { expect(resetTables).toContain("d1_migrations"); }); + it("drops personal access tokens explicitly", () => { + expect(resetSql).toMatch(/DROP TABLE IF EXISTS personal_access_tokens;/u); + }); + it("keeps the destructive workflow local for both reset and migration", () => { const command = packageJson.scripts["db:reset:local"]; expect(command.match(/--local/g)).toHaveLength(2); From 2c0a7897eceac8d8e56211da503fef9ee47672cd Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:25:21 -0500 Subject: [PATCH 02/21] feat: add personal access token secrets --- test/helpers/secret-safe-assertions.ts | 15 ++++++ .../auth/personal-access-token-secret.test.ts | 45 ++++++++++++++++++ worker/auth/personal-access-token-secret.ts | 46 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 test/helpers/secret-safe-assertions.ts create mode 100644 test/unit/worker/auth/personal-access-token-secret.test.ts create mode 100644 worker/auth/personal-access-token-secret.ts diff --git a/test/helpers/secret-safe-assertions.ts b/test/helpers/secret-safe-assertions.ts new file mode 100644 index 00000000..999e0c83 --- /dev/null +++ b/test/helpers/secret-safe-assertions.ts @@ -0,0 +1,15 @@ +import { inspect } from "node:util"; + +export function assertSecretSafeEqual(actual: string, expected: string): void { + if (actual !== expected) throw new Error("Secret-safe equality assertion failed."); +} + +export function assertSecretSafeAbsent(value: unknown, forbidden: readonly string[]): void { + const inspected = + typeof value === "string" + ? value + : inspect(value, { customInspect: false, depth: null, getters: false }); + if (forbidden.some((secret) => secret.length > 0 && inspected.includes(secret))) { + throw new Error("Secret-safe exclusion assertion failed."); + } +} diff --git a/test/unit/worker/auth/personal-access-token-secret.test.ts b/test/unit/worker/auth/personal-access-token-secret.test.ts new file mode 100644 index 00000000..3009bfe4 --- /dev/null +++ b/test/unit/worker/auth/personal-access-token-secret.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + generatePersonalAccessToken, + hashPersonalAccessToken, + parsePersonalAccessToken +} from "../../../../worker/auth/personal-access-token-secret"; +import { assertSecretSafeEqual } from "../../../helpers/secret-safe-assertions"; + +describe("personal access token secrets", () => { + it("generates a canonical 256-bit PAT and a stable hash", async () => { + const first = await generatePersonalAccessToken(); + const second = await generatePersonalAccessToken(); + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(first.token)).toBe(true); + expect(first.token !== second.token).toBe(true); + expect(/^[A-Za-z0-9_-]{43}$/u.test(first.tokenHash)).toBe(true); + assertSecretSafeEqual(first.tokenHash, await hashPersonalAccessToken(first.token)); + assertSecretSafeEqual(first.tokenSuffix, first.token.slice(-4)); + assertSecretSafeEqual(parsePersonalAccessToken(first.token), first.token); + }); + + it("hashes the complete prefixed plaintext", async () => { + const fixedNonSecretVector = `hqb_pat_${"A".repeat(43)}`; + assertSecretSafeEqual( + await hashPersonalAccessToken(fixedNonSecretVector), + "NL3kqdezjVTNN-Swiu5QQhalwm-OrMKYkuMww0solic" + ); + }); + + it.each([ + { label: "wrong prefix", value: "hqb_access_value" }, + { label: "empty secret", value: "hqb_pat_" }, + { label: "short secret", value: `hqb_pat_${"a".repeat(42)}` }, + { label: "long secret", value: `hqb_pat_${"a".repeat(44)}` }, + { label: "invalid alphabet", value: `hqb_pat_${"a".repeat(42)}+` } + ])("rejects malformed PAT text: $label", ({ value }) => { + expect(() => parsePersonalAccessToken(value)).toThrow("Personal access token is malformed."); + }); + + it("rejects noncanonical Base64url trailing bits", () => { + const noncanonical = `hqb_pat_${"A".repeat(42)}B`; + expect(() => parsePersonalAccessToken(noncanonical)).toThrow( + "Personal access token is malformed." + ); + }); +}); diff --git a/worker/auth/personal-access-token-secret.ts b/worker/auth/personal-access-token-secret.ts new file mode 100644 index 00000000..9cf7faf8 --- /dev/null +++ b/worker/auth/personal-access-token-secret.ts @@ -0,0 +1,46 @@ +const prefix = "hqb_pat_"; +const tokenPattern = /^hqb_pat_[A-Za-z0-9_-]{43}$/u; +const encoder = new TextEncoder(); + +export async function generatePersonalAccessToken(): Promise<{ + token: string; + tokenHash: string; + tokenSuffix: string; +}> { + const secret = new Uint8Array(32); + crypto.getRandomValues(secret); + const token = `${prefix}${base64Url(secret)}`; + return { + token, + tokenHash: await hashPersonalAccessToken(token), + tokenSuffix: token.slice(-4) + }; +} + +export function parsePersonalAccessToken(value: string): string { + if (!tokenPattern.test(value)) throw new Error("Personal access token is malformed."); + const encoded = value.slice(prefix.length); + const decoded = fromBase64Url(encoded); + if (decoded.length !== 32 || base64Url(decoded) !== encoded) { + throw new Error("Personal access token is malformed."); + } + return value; +} + +export async function hashPersonalAccessToken(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return base64Url(new Uint8Array(digest)); +} + +function base64Url(value: Uint8Array): string { + return btoa(String.fromCharCode(...value)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +function fromBase64Url(value: string): Uint8Array { + const padding = "=".repeat((4 - (value.length % 4)) % 4); + const decoded = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); +} From 106df05293e67f0df34e409f0536cc3a9d95fbaf Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:29:15 -0500 Subject: [PATCH 03/21] refactor: prepare guarded audit statements --- test/unit/worker/features/audit/audit.test.ts | 198 +++++++++++++++++- worker/features/audit/service.ts | 71 ++++++- 2 files changed, 257 insertions(+), 12 deletions(-) diff --git a/test/unit/worker/features/audit/audit.test.ts b/test/unit/worker/features/audit/audit.test.ts index 95ce1a84..365e67fe 100644 --- a/test/unit/worker/features/audit/audit.test.ts +++ b/test/unit/worker/features/audit/audit.test.ts @@ -1,6 +1,84 @@ -import { recordAudit } from "@worker/features/audit/service"; +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; + +import { prepareAuditInsert, recordAudit } from "@worker/features/audit/service"; import { describe, expect, it } from "vitest"; +describe("prepared audit inserts", () => { + it("inserts an unconditional audit event", async () => { + const fixture = auditDatabase(); + try { + await prepareAuditInsert(fixture.db, auditInput()).run(); + expect(auditCount(fixture.sqlite)).toBe(1); + } finally { + fixture.sqlite.close(); + } + }); + + it("inserts only when the personal access token exists", async () => { + const fixture = auditDatabase(); + try { + insertToken(fixture.sqlite, "pat_exists", "usr_owner", "2026-08-20T00:00:00.000Z"); + await prepareAuditInsert(fixture.db, auditInput(), { + kind: "personal-access-token-exists", + id: "pat_exists" + }).run(); + await prepareAuditInsert(fixture.db, auditInput(), { + kind: "personal-access-token-exists", + id: "pat_missing" + }).run(); + expect(auditCount(fixture.sqlite)).toBe(1); + } finally { + fixture.sqlite.close(); + } + }); + + it("inserts for an active token without an owner filter", async () => { + const fixture = auditDatabase(); + try { + insertToken(fixture.sqlite, "pat_active_owner", "usr_owner", null); + await prepareAuditInsert(fixture.db, auditInput(), { + kind: "active-personal-access-token", + id: "pat_active_owner" + }).run(); + expect(auditCount(fixture.sqlite)).toBe(1); + } finally { + fixture.sqlite.close(); + } + }); + + it.each([ + { label: "matching user", userId: "usr_owner", expectedCount: 1 }, + { label: "nonmatching user", userId: "usr_other", expectedCount: 0 } + ])("applies the active-token user filter: $label", async ({ expectedCount, userId }) => { + const fixture = auditDatabase(); + try { + insertToken(fixture.sqlite, "pat_active_user", "usr_owner", null); + await prepareAuditInsert(fixture.db, auditInput(), { + kind: "active-personal-access-token", + id: "pat_active_user", + userId + }).run(); + expect(auditCount(fixture.sqlite)).toBe(expectedCount); + } finally { + fixture.sqlite.close(); + } + }); + + it("does not insert for a revoked token", async () => { + const fixture = auditDatabase(); + try { + insertToken(fixture.sqlite, "pat_revoked", "usr_owner", "2026-08-20T00:00:00.000Z"); + await prepareAuditInsert(fixture.db, auditInput(), { + kind: "active-personal-access-token", + id: "pat_revoked" + }).run(); + expect(auditCount(fixture.sqlite)).toBe(0); + } finally { + fixture.sqlite.close(); + } + }); +}); + describe("audit redaction guard", () => { it.each([ "subject", @@ -8,17 +86,123 @@ describe("audit redaction guard", () => { "email", "password", "token", - "filename" + "filename", + "tokenHash", + "token_hash", + "TOKEN-HASH", + "accessToken", + "access_token", + "refreshToken", + "refresh-token", + "authorization", + "Authorization-Header", + "requestBody", + "request_body", + "responseBody", + "response-body" ])("rejects %s metadata before touching storage", async (key) => { await expect( recordAudit(null as unknown as D1Database, { - correlationId: "request_123", - actorType: "system", - action: "test", - resourceType: "test", - outcome: "success", + ...auditInput(), metadata: { [key]: "sensitive" } }) ).rejects.toThrow("Sensitive audit metadata"); }); + + it("allows the safe personal access token record ID", async () => { + const fixture = auditDatabase(); + try { + await recordAudit(fixture.db, { + ...auditInput(), + metadata: { personalAccessTokenId: "pat_safe_id" } + }); + const metadata = fixture.sqlite.prepare("SELECT metadata_json FROM audit_events").get() as { + metadata_json: string; + }; + expect(JSON.parse(metadata.metadata_json)).toEqual({ + personalAccessTokenId: "pat_safe_id" + }); + } finally { + fixture.sqlite.close(); + } + }); }); + +function auditInput() { + return { + correlationId: "request_123", + actorType: "system" as const, + action: "test", + resourceType: "test", + outcome: "success" as const + }; +} + +function auditDatabase(): { db: D1Database; sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(` + CREATE TABLE audit_events ( + id TEXT PRIMARY KEY NOT NULL, + occurred_at TEXT NOT NULL, + correlation_id TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + outcome TEXT NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE personal_access_tokens ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + revoked_at TEXT + ); + `); + return { + db: { + prepare(query: string) { + return d1Statement(sqlite, query); + } + } as D1Database, + sqlite + }; +} + +function d1Statement( + sqlite: DatabaseSync, + query: string, + values: SQLInputValue[] = [] +): D1PreparedStatement { + return { + bind(...boundValues: unknown[]) { + return d1Statement(sqlite, query, boundValues as SQLInputValue[]); + }, + async run() { + const result = sqlite.prepare(query).run(...values); + return { + success: true, + meta: { changes: Number(result.changes) }, + results: [] + } as unknown as D1Result; + } + } as D1PreparedStatement; +} + +function insertToken( + sqlite: DatabaseSync, + id: string, + userId: string, + revokedAt: string | null +): void { + sqlite + .prepare("INSERT INTO personal_access_tokens (id, user_id, revoked_at) VALUES (?, ?, ?)") + .run(id, userId, revokedAt); +} + +function auditCount(sqlite: DatabaseSync): number { + const row = sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events").get() as { + count: number; + }; + return row.count; +} diff --git a/worker/features/audit/service.ts b/worker/features/audit/service.ts index d75cf997..eae955bb 100644 --- a/worker/features/audit/service.ts +++ b/worker/features/audit/service.ts @@ -25,15 +25,67 @@ const forbiddenMetadata = new Set([ "recipient", "secret", "subject", - "token" + "token", + "tokenhash", + "accesstoken", + "refreshtoken", + "authorization", + "authorizationheader", + "requestbody", + "responsebody" ]); -export async function recordAudit(db: D1Database, input: AuditInput): Promise { - for (const key of Object.keys(input.metadata ?? {})) { - if (forbiddenMetadata.has(key.toLowerCase())) { - throw new Error(`Sensitive audit metadata rejected: ${key}`); +export type AuditInsertGuard = + | { kind: "personal-access-token-exists"; id: string } + | { kind: "active-personal-access-token"; id: string; userId?: string }; + +export function prepareAuditInsert( + db: D1Database, + input: AuditInput, + guard?: AuditInsertGuard +): D1PreparedStatement { + assertSafeAuditMetadata(input); + + const guardValues: string[] = []; + let guardSql = ""; + if (guard?.kind === "personal-access-token-exists") { + guardSql = " WHERE EXISTS (SELECT 1 FROM personal_access_tokens WHERE id = ?)"; + guardValues.push(guard.id); + } else if (guard?.kind === "active-personal-access-token") { + guardSql = + " WHERE EXISTS (SELECT 1 FROM personal_access_tokens WHERE id = ? AND revoked_at IS NULL"; + guardValues.push(guard.id); + if (guard.userId !== undefined) { + guardSql += " AND user_id = ?"; + guardValues.push(guard.userId); } + guardSql += ")"; } + + return db + .prepare( + `INSERT INTO audit_events + (id, occurred_at, correlation_id, actor_type, actor_id, action, resource_type, + resource_id, outcome, metadata_json) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${guardSql}` + ) + .bind( + newId("aud"), + nowIso(), + input.correlationId, + input.actorType, + input.actorId ?? null, + input.action, + input.resourceType, + input.resourceId ?? null, + input.outcome, + JSON.stringify(input.metadata ?? {}), + ...guardValues + ); +} + +export async function recordAudit(db: D1Database, input: AuditInput): Promise { + assertSafeAuditMetadata(input); const database = createDatabase(db); await database .insert(auditEvents) @@ -51,3 +103,12 @@ export async function recordAudit(db: D1Database, input: AuditInput): Promise Date: Thu, 20 Aug 2026 02:38:06 -0500 Subject: [PATCH 04/21] feat: add personal access token lifecycle --- .../personal-access-token-service.test.ts | 362 ++++++++++++++++++ .../personal-access-tokens/service.test.ts | 224 +++++++++++ .../personal-access-tokens/validation.test.ts | 141 +++++++ .../personal-access-tokens/service.ts | 173 +++++++++ .../features/personal-access-tokens/types.ts | 28 ++ .../personal-access-tokens/validation.ts | 68 ++++ 6 files changed, 996 insertions(+) create mode 100644 test/integration/worker/personal-access-token-service.test.ts create mode 100644 test/unit/worker/features/personal-access-tokens/service.test.ts create mode 100644 test/unit/worker/features/personal-access-tokens/validation.test.ts create mode 100644 worker/features/personal-access-tokens/service.ts create mode 100644 worker/features/personal-access-tokens/types.ts create mode 100644 worker/features/personal-access-tokens/validation.ts diff --git a/test/integration/worker/personal-access-token-service.test.ts b/test/integration/worker/personal-access-token-service.test.ts new file mode 100644 index 00000000..773b80e7 --- /dev/null +++ b/test/integration/worker/personal-access-token-service.test.ts @@ -0,0 +1,362 @@ +import { env } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + createPersonalAccessToken, + listPersonalAccessTokens, + revokePersonalAccessToken +} from "../../../worker/features/personal-access-tokens/service"; +import { AppError } from "../../../worker/lib/errors"; +import { assertSecretSafeAbsent } from "../../helpers/secret-safe-assertions"; +import { applyCurrentMigrations } from "./current-migrations"; + +const now = Date.parse("2026-08-20T18:00:00.000Z"); +const nowIso = new Date(now).toISOString(); + +describe("personal access token service", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + }); + + beforeEach(async () => { + await env.DB.prepare( + "DELETE FROM audit_events WHERE action LIKE 'personal_access_token.%'" + ).run(); + await env.DB.prepare("DELETE FROM personal_access_tokens").run(); + await env.DB.prepare(`DELETE FROM "user" WHERE id LIKE 'usr_pat_service_%'`).run(); + await Promise.all([ + insertUser("usr_pat_service_owner", "Workspace Owner", "owner"), + insertUser("usr_pat_service_admin", "Workspace Admin", "admin"), + insertUser("usr_pat_service_member", "Workspace Member", "member"), + insertUser("usr_pat_service_other", "Other Member", "member") + ]); + }); + + it("lists only active visible PATs in stable order", async () => { + await insertPat("pat_owner_z", "usr_pat_service_owner", nowIso, null, null); + await insertPat("pat_owner_a", "usr_pat_service_owner", nowIso, null, null); + await insertPat( + "pat_admin_active", + "usr_pat_service_admin", + "2026-08-20T17:00:00.000Z", + "2026-08-21T18:00:00.000Z", + null + ); + await insertPat( + "pat_member_active", + "usr_pat_service_member", + "2026-08-20T16:00:00.000Z", + null, + null + ); + await insertPat( + "pat_expiry_equal", + "usr_pat_service_admin", + "2026-08-20T15:00:00.000Z", + nowIso, + null + ); + await insertPat( + "pat_expired", + "usr_pat_service_member", + "2026-08-20T14:00:00.000Z", + "2026-08-20T17:59:59.999Z", + null + ); + await insertPat( + "pat_revoked", + "usr_pat_service_owner", + "2026-08-20T13:00:00.000Z", + null, + "2026-08-20T17:00:00.000Z" + ); + + const owner = await listPersonalAccessTokens(env.DB, { + userId: "usr_pat_service_owner", + role: "owner", + now + }); + expect(owner.personalAccessTokens.map(({ id }) => id)).toEqual([ + "pat_owner_z", + "pat_owner_a", + "pat_admin_active", + "pat_member_active" + ]); + + const admin = await listPersonalAccessTokens(env.DB, { + userId: "usr_pat_service_admin", + role: "admin", + now + }); + expect(admin.personalAccessTokens.map(({ id }) => id)).toEqual(["pat_admin_active"]); + + const member = await listPersonalAccessTokens(env.DB, { + userId: "usr_pat_service_member", + role: "member", + now + }); + expect(member.personalAccessTokens.map(({ id }) => id)).toEqual(["pat_member_active"]); + expect(Object.keys(member.personalAccessTokens[0] ?? {})).toEqual([ + "id", + "userId", + "ownerName", + "name", + "tokenSuffix", + "createdAt", + "expiresAt" + ]); + }); + + it("enforces the active-token limit per user", async () => { + for (let index = 0; index < 10; index += 1) { + await insertPat( + `pat_limit_${index}`, + "usr_pat_service_owner", + `2026-08-20T17:${String(index).padStart(2, "0")}:00.000Z`, + null, + null + ); + } + + const limitError = await captureAppError( + createPersonalAccessToken(env.DB, { + userId: "usr_pat_service_owner", + correlationId: "request_owner_limit", + name: "Owner limit", + expiresAt: null, + now + }) + ); + expect(limitError.code).toBe("PERSONAL_ACCESS_TOKEN_LIMIT_REACHED"); + expect(limitError.status).toBe(409); + + const other = await createPersonalAccessToken(env.DB, { + userId: "usr_pat_service_admin", + correlationId: "request_other_create", + name: "Admin token", + expiresAt: null, + now + }); + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(other.token)).toBe(true); + + const counts = await env.DB.prepare( + `SELECT user_id AS userId, COUNT(*) AS count FROM personal_access_tokens + WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) + GROUP BY user_id ORDER BY user_id` + ) + .bind(nowIso) + .all<{ userId: string; count: number }>(); + expect(counts.results).toEqual([ + { userId: "usr_pat_service_admin", count: 1 }, + { userId: "usr_pat_service_owner", count: 10 } + ]); + }); + + it("commits creation with one secret-free audit event", async () => { + const created = await createPersonalAccessToken(env.DB, { + userId: "usr_pat_service_member", + correlationId: "request_create_audit", + name: "Audited token", + expiresAt: null, + now + }); + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(created.token)).toBe(true); + + const stored = await env.DB.prepare( + "SELECT token_hash AS tokenHash FROM personal_access_tokens WHERE id = ?" + ) + .bind(created.personalAccessToken.id) + .first<{ tokenHash: string }>(); + if (!stored) throw new Error("Expected the created PAT row."); + const audit = await env.DB.prepare( + `SELECT action, actor_type AS actorType, actor_id AS actorId, + resource_type AS resourceType, resource_id AS resourceId, outcome, metadata_json + FROM audit_events WHERE correlation_id = 'request_create_audit'` + ).first<{ + action: string; + actorType: string; + actorId: string; + resourceType: string; + resourceId: string; + outcome: string; + metadata_json: string; + }>(); + expect(audit).toEqual({ + action: "personal_access_token.create", + actorType: "user", + actorId: "usr_pat_service_member", + resourceType: "personal_access_token", + resourceId: created.personalAccessToken.id, + outcome: "success", + metadata_json: "{}" + }); + assertSecretSafeAbsent(JSON.stringify(audit), [created.token, stored.tokenHash]); + }); + + it("rolls back creation when its audit statement fails", async () => { + await env.DB.prepare( + `CREATE TRIGGER fail_pat_create_audit + BEFORE INSERT ON audit_events + WHEN NEW.action = 'personal_access_token.create' + BEGIN + SELECT RAISE(ABORT, 'injected create audit failure'); + END` + ).run(); + try { + await expect( + createPersonalAccessToken(env.DB, { + userId: "usr_pat_service_member", + correlationId: "request_create_rollback", + name: "Rollback token", + expiresAt: null, + now + }) + ).rejects.toThrow("injected create audit failure"); + } finally { + await env.DB.prepare("DROP TRIGGER fail_pat_create_audit").run(); + } + + const tokenCount = await countRows( + "SELECT COUNT(*) AS count FROM personal_access_tokens WHERE user_id = 'usr_pat_service_member'" + ); + const auditCount = await countRows( + "SELECT COUNT(*) AS count FROM audit_events WHERE correlation_id = 'request_create_rollback'" + ); + expect(tokenCount).toBe(0); + expect(auditCount).toBe(0); + }); + + it.each([ + { label: "admin", actorId: "usr_pat_service_admin", actorRole: "admin" as const }, + { label: "member", actorId: "usr_pat_service_member", actorRole: "member" as const } + ])("does not disclose or revoke a foreign PAT to an $label", async ({ actorId, actorRole }) => { + await insertPat("pat_foreign", "usr_pat_service_owner", nowIso, null, null); + await expect( + revokePersonalAccessToken(env.DB, { + id: "pat_foreign", + actorId, + actorRole, + correlationId: `request_foreign_${actorRole}`, + now + }) + ).resolves.toBe("not-found"); + + const row = await env.DB.prepare( + "SELECT revoked_at AS revokedAt FROM personal_access_tokens WHERE id = 'pat_foreign'" + ).first<{ revokedAt: string | null }>(); + expect(row?.revokedAt).toBeNull(); + expect( + await countRows( + `SELECT COUNT(*) AS count FROM audit_events WHERE correlation_id = 'request_foreign_${actorRole}'` + ) + ).toBe(0); + }); + + it("rolls back the revoke audit when the state update fails", async () => { + await insertPat("pat_revoke_rollback", "usr_pat_service_member", nowIso, null, null); + await env.DB.prepare( + `CREATE TRIGGER fail_pat_revoke_update + BEFORE UPDATE OF revoked_at ON personal_access_tokens + WHEN NEW.id = 'pat_revoke_rollback' AND NEW.revoked_at IS NOT NULL + BEGIN + SELECT RAISE(ABORT, 'injected revoke update failure'); + END` + ).run(); + try { + await expect( + revokePersonalAccessToken(env.DB, { + id: "pat_revoke_rollback", + actorId: "usr_pat_service_member", + actorRole: "member", + correlationId: "request_revoke_rollback", + now + }) + ).rejects.toThrow("injected revoke update failure"); + } finally { + await env.DB.prepare("DROP TRIGGER fail_pat_revoke_update").run(); + } + + const row = await env.DB.prepare( + "SELECT revoked_at AS revokedAt FROM personal_access_tokens WHERE id = 'pat_revoke_rollback'" + ).first<{ revokedAt: string | null }>(); + expect(row?.revokedAt).toBeNull(); + expect( + await countRows( + "SELECT COUNT(*) AS count FROM audit_events WHERE correlation_id = 'request_revoke_rollback'" + ) + ).toBe(0); + }); + + it("records one state change and one audit under concurrent revocation", async () => { + await insertPat("pat_concurrent_revoke", "usr_pat_service_member", nowIso, null, null); + const results = await Promise.all([ + revokePersonalAccessToken(env.DB, { + id: "pat_concurrent_revoke", + actorId: "usr_pat_service_member", + actorRole: "member", + correlationId: "request_revoke_first", + now + }), + revokePersonalAccessToken(env.DB, { + id: "pat_concurrent_revoke", + actorId: "usr_pat_service_member", + actorRole: "member", + correlationId: "request_revoke_second", + now: now + 1 + }) + ]); + expect(results.sort()).toEqual(["already-revoked", "revoked"]); + expect( + await countRows( + `SELECT COUNT(*) AS count FROM audit_events + WHERE action = 'personal_access_token.revoke' AND resource_id = 'pat_concurrent_revoke'` + ) + ).toBe(1); + }); +}); + +async function insertUser(id: string, name: string, role: "owner" | "admin" | "member") { + await env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned) + VALUES (?, ?, ?, 1, ?, ?, ?, 0)` + ) + .bind(id, name, `${id}@example.com`, nowIso, nowIso, role) + .run(); +} + +async function insertPat( + id: string, + userId: string, + createdAt: string, + expiresAt: string | null, + revokedAt: string | null +): Promise { + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES (?, ?, ?, ?, 'a1B2', ?, ?, ?)` + ) + .bind(id, userId, `Token ${id}`, fixtureHash(id), createdAt, expiresAt, revokedAt) + .run(); +} + +function fixtureHash(id: string): string { + return id + .replaceAll(/[^A-Za-z0-9_-]/gu, "_") + .padEnd(43, "A") + .slice(0, 43); +} + +async function countRows(query: string): Promise { + const row = await env.DB.prepare(query).first<{ count: number }>(); + return row?.count ?? -1; +} + +async function captureAppError(operation: Promise): Promise { + try { + await operation; + } catch (error) { + if (error instanceof AppError) return error; + } + throw new Error("Expected an AppError."); +} diff --git a/test/unit/worker/features/personal-access-tokens/service.test.ts b/test/unit/worker/features/personal-access-tokens/service.test.ts new file mode 100644 index 00000000..778e11ea --- /dev/null +++ b/test/unit/worker/features/personal-access-tokens/service.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import { + createPersonalAccessToken, + revokePersonalAccessToken +} from "../../../../../worker/features/personal-access-tokens/service"; +import { AppError } from "../../../../../worker/lib/errors"; +import { assertSecretSafeAbsent } from "../../../../helpers/secret-safe-assertions"; + +describe("personal access token creation", () => { + it("returns plaintext only after the PAT and audit both commit", async () => { + const fake = new FakeD1([batchResult(1), batchResult(1)], validMetadataRow()); + + const result = await createPersonalAccessToken(fake.db, { + userId: "usr_creator", + correlationId: "request_create", + name: "Nightly archive", + expiresAt: "2026-11-17T18:00:00.000Z", + now: Date.parse("2026-08-20T18:00:00.000Z") + }); + + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(result.token)).toBe(true); + expect(/^pat_[A-Za-z0-9_-]+$/u.test(result.personalAccessToken.id)).toBe(true); + const { id: _expectedId, ...expectedMetadata } = validMetadataRow(); + const { id: _actualId, ...actualMetadata } = result.personalAccessToken; + expect(actualMetadata).toEqual(expectedMetadata); + + const insert = requiredCall(fake, "INSERT INTO personal_access_tokens"); + const tokenHash = String(insert.values[3]); + const audit = requiredCall(fake, "INSERT INTO audit_events"); + expect(audit.values[2]).toBe("request_create"); + expect(audit.values[3]).toBe("user"); + expect(audit.values[4]).toBe("usr_creator"); + expect(audit.values[5]).toBe("personal_access_token.create"); + expect(audit.values[6]).toBe("personal_access_token"); + expect(audit.values[7]).toBe(result.personalAccessToken.id); + expect(audit.values[8]).toBe("success"); + assertSecretSafeAbsent({ query: audit.query, values: audit.values }, [result.token, tokenHash]); + }); + + it("maps the supported zero-change result to the active-token limit", async () => { + const fake = new FakeD1([batchResult(0), batchResult(0)], validMetadataRow()); + const error = await captureAppError( + createPersonalAccessToken(fake.db, createInput("request_limit")) + ); + expect(error.code).toBe("PERSONAL_ACCESS_TOKEN_LIMIT_REACHED"); + expect(error.status).toBe(409); + }); + + it("fails closed on mismatched create batch metadata", async () => { + const fake = new FakeD1([batchResult(1), batchResult(0)], validMetadataRow()); + const error = await captureAppError( + createPersonalAccessToken(fake.db, createInput("request_mismatch")) + ); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.status).toBe(500); + }); + + it("fails closed when committed metadata is missing", async () => { + const fake = new FakeD1([batchResult(1), batchResult(1)], null); + const error = await captureAppError( + createPersonalAccessToken(fake.db, createInput("request_missing_metadata")) + ); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.status).toBe(500); + }); + + it("propagates a rejected create batch", async () => { + const fake = new FakeD1([], validMetadataRow(), new Error("batch failed")); + await expect( + createPersonalAccessToken(fake.db, createInput("request_rejected")) + ).rejects.toThrow("batch failed"); + }); +}); + +describe("personal access token revocation", () => { + it.each([ + { + label: "first revoke", + results: [batchResult(1), batchResult(1), batchResult(0, [{ id: "pat_target" }])], + expected: "revoked" + }, + { + label: "repeat revoke", + results: [batchResult(0), batchResult(0), batchResult(0, [{ id: "pat_target" }])], + expected: "already-revoked" + }, + { + label: "missing target", + results: [batchResult(0), batchResult(0), batchResult(0, [])], + expected: "not-found" + } + ] as const)("maps the supported result for $label", async ({ expected, results }) => { + const fake = new FakeD1([...results], null); + await expect( + revokePersonalAccessToken(fake.db, { + id: "pat_target", + actorId: "usr_revoker", + actorRole: "member", + correlationId: "request_revoke", + now: Date.parse("2026-08-20T18:00:00.000Z") + }) + ).resolves.toBe(expected); + + const audit = requiredCall(fake, "INSERT INTO audit_events"); + expect(audit.values[2]).toBe("request_revoke"); + expect(audit.values[3]).toBe("user"); + expect(audit.values[4]).toBe("usr_revoker"); + expect(audit.values[5]).toBe("personal_access_token.revoke"); + expect(audit.values[6]).toBe("personal_access_token"); + expect(audit.values[7]).toBe("pat_target"); + expect(audit.values[8]).toBe("success"); + }); + + it("fails closed on mismatched revoke batch metadata", async () => { + const fake = new FakeD1( + [batchResult(1), batchResult(0), batchResult(0, [{ id: "pat_target" }])], + null + ); + const error = await captureAppError( + revokePersonalAccessToken(fake.db, { + id: "pat_target", + actorId: "usr_owner", + actorRole: "owner", + correlationId: "request_revoke_mismatch" + }) + ); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.status).toBe(500); + }); + + it("propagates a rejected revoke batch", async () => { + const fake = new FakeD1([], null, new Error("batch failed")); + await expect( + revokePersonalAccessToken(fake.db, { + id: "pat_target", + actorId: "usr_owner", + actorRole: "owner", + correlationId: "request_revoke_rejected" + }) + ).rejects.toThrow("batch failed"); + }); +}); + +type PreparedCall = { query: string; values: unknown[] }; + +class FakeD1 { + readonly calls: PreparedCall[] = []; + readonly db: D1Database; + + constructor( + private readonly results: D1Result[], + private readonly metadataRow: Record | null, + private readonly batchError?: Error + ) { + this.db = { + prepare: (query: string) => this.statement(query), + batch: async () => { + if (this.batchError) throw this.batchError; + return this.results; + } + } as unknown as D1Database; + } + + private statement(query: string): D1PreparedStatement { + const call: PreparedCall = { query, values: [] }; + this.calls.push(call); + const statement = { + bind: (...values: unknown[]) => { + call.values = values; + return statement; + }, + first: async () => + this.metadataRow && query.includes("FROM personal_access_tokens pat") + ? { ...this.metadataRow, id: call.values[0] } + : this.metadataRow + }; + return statement as unknown as D1PreparedStatement; + } +} + +function batchResult(changes: number, results: unknown[] = []): D1Result { + return { + success: true, + meta: { changes }, + results + } as unknown as D1Result; +} + +function validMetadataRow() { + return { + id: "pat_created", + userId: "usr_creator", + ownerName: "Workspace Owner", + name: "Nightly archive", + tokenSuffix: "a1B2", + createdAt: "2026-08-20T18:00:00.000Z", + expiresAt: "2026-11-17T18:00:00.000Z" + }; +} + +function createInput(correlationId: string) { + return { + userId: "usr_creator", + correlationId, + name: "Nightly archive", + expiresAt: null, + now: Date.parse("2026-08-20T18:00:00.000Z") + }; +} + +function requiredCall(fake: FakeD1, fragment: string): PreparedCall { + const call = fake.calls.find(({ query }) => query.includes(fragment)); + if (!call) throw new Error("Expected prepared statement was not created."); + return call; +} + +async function captureAppError(operation: Promise): Promise { + try { + await operation; + } catch (error) { + if (error instanceof AppError) return error; + } + throw new Error("Expected an AppError."); +} diff --git a/test/unit/worker/features/personal-access-tokens/validation.test.ts b/test/unit/worker/features/personal-access-tokens/validation.test.ts new file mode 100644 index 00000000..271f49aa --- /dev/null +++ b/test/unit/worker/features/personal-access-tokens/validation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + readCreatePersonalAccessTokenInput, + readPersonalAccessTokenMetadata +} from "../../../../../worker/features/personal-access-tokens/validation"; +import { AppError } from "../../../../../worker/lib/errors"; + +const now = Date.parse("2026-08-20T18:00:00.000Z"); + +describe("personal access token create input", () => { + it.each([ + { name: "a", expected: "a" }, + { name: " Nightly archive ", expected: "Nightly archive" }, + { name: "n".repeat(80), expected: "n".repeat(80) } + ])("accepts and trims a valid name", ({ expected, name }) => { + expect(readCreatePersonalAccessTokenInput({ expiresAt: null, name }, now)).toEqual({ + name: expected, + expiresAt: null + }); + }); + + it("accepts a canonical future expiry", () => { + expect( + readCreatePersonalAccessTokenInput( + { name: "Canonical", expiresAt: "2026-11-17T18:00:00.000Z" }, + now + ) + ).toEqual({ + name: "Canonical", + expiresAt: "2026-11-17T18:00:00.000Z" + }); + }); + + it("canonicalizes a parseable future expiry", () => { + expect( + readCreatePersonalAccessTokenInput( + { name: "Local time", expiresAt: "2026-11-17T12:00:00-06:00" }, + now + ) + ).toEqual({ + name: "Local time", + expiresAt: "2026-11-17T18:00:00.000Z" + }); + }); + + it.each([ + { label: "null body", value: null }, + { label: "array body", value: [] }, + { label: "missing name", value: { expiresAt: null } }, + { label: "empty name", value: { name: "", expiresAt: null } }, + { label: "blank name", value: { name: " ", expiresAt: null } }, + { label: "long name", value: { name: "n".repeat(81), expiresAt: null } }, + { label: "wrong expiry type", value: { name: "Token", expiresAt: 1 } }, + { label: "invalid expiry", value: { name: "Token", expiresAt: "not-a-date" } }, + { + label: "expiry equality", + value: { name: "Token", expiresAt: "2026-08-20T18:00:00.000Z" } + }, + { + label: "past expiry", + value: { name: "Token", expiresAt: "2026-08-20T17:59:59.999Z" } + }, + { label: "unknown field", value: { name: "Token", expiresAt: null, scope: "mail:read" } } + ])("rejects invalid create input: $label", ({ value }) => { + expectInvalidCreateInput(() => readCreatePersonalAccessTokenInput(value, now)); + }); +}); + +describe("personal access token metadata", () => { + it("returns only validated management metadata", () => { + const row = { + ...validMetadataRow(), + token: "not-a-secret", + tokenHash: "not-a-hash", + revokedAt: null, + status: "active" + }; + const metadata = readPersonalAccessTokenMetadata(row); + expect(metadata).toEqual(validMetadataRow()); + expect(Object.keys(metadata)).toEqual([ + "id", + "userId", + "ownerName", + "name", + "tokenSuffix", + "createdAt", + "expiresAt" + ]); + }); + + it.each([ + { label: "PAT ID scalar", field: "id", value: 1 }, + { label: "PAT ID format", field: "id", value: "token_example" }, + { label: "empty user ID", field: "userId", value: "" }, + { label: "user ID scalar", field: "userId", value: 1 }, + { label: "owner name scalar", field: "ownerName", value: 1 }, + { label: "blank token name", field: "name", value: "" }, + { label: "untrimmed token name", field: "name", value: " Token " }, + { label: "long token name", field: "name", value: "n".repeat(81) }, + { label: "short suffix", field: "tokenSuffix", value: "abc" }, + { label: "suffix alphabet", field: "tokenSuffix", value: "abc+" }, + { label: "creation scalar", field: "createdAt", value: 1 }, + { label: "creation format", field: "createdAt", value: "2026-08-20T18:00:00Z" }, + { label: "expiry scalar", field: "expiresAt", value: 1 }, + { label: "expiry format", field: "expiresAt", value: "2026-11-17T12:00:00-06:00" } + ])("rejects malformed stored metadata: $label", ({ field, value }) => { + const error = captureError(() => + readPersonalAccessTokenMetadata({ ...validMetadataRow(), [field]: value }) + ); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.status).toBe(500); + }); +}); + +function validMetadataRow() { + return { + id: "pat_example", + userId: "usr_example", + ownerName: "Workspace Owner", + name: "Nightly archive", + tokenSuffix: "a1B2", + createdAt: "2026-08-20T18:00:00.000Z", + expiresAt: "2026-11-17T18:00:00.000Z" + }; +} + +function expectInvalidCreateInput(operation: () => unknown): void { + const error = captureError(operation); + expect(error.code).toBe("INVALID_PERSONAL_ACCESS_TOKEN"); + expect(error.status).toBe(400); + expect(error.message).toBe("Personal access token input is invalid."); +} + +function captureError(operation: () => unknown): AppError { + try { + operation(); + } catch (error) { + if (error instanceof AppError) return error; + } + throw new Error("Expected an AppError."); +} diff --git a/worker/features/personal-access-tokens/service.ts b/worker/features/personal-access-tokens/service.ts new file mode 100644 index 00000000..06935b11 --- /dev/null +++ b/worker/features/personal-access-tokens/service.ts @@ -0,0 +1,173 @@ +import { generatePersonalAccessToken } from "../../auth/personal-access-token-secret"; +import { newId } from "../../db/client"; +import { AppError } from "../../lib/errors"; +import type { WorkspaceRole } from "../../lib/validation"; +import { prepareAuditInsert } from "../audit/service"; + +import type { + CreatePersonalAccessTokenInput, + PersonalAccessTokenList, + PersonalAccessTokenMetadata, + PersonalAccessTokenMetadataRow +} from "./types"; +import { readPersonalAccessTokenMetadata } from "./validation"; + +export async function listPersonalAccessTokens( + db: D1Database, + input: { userId: string; role: WorkspaceRole; now?: number } +): Promise { + const timestamp = new Date(input.now ?? Date.now()).toISOString(); + const userFilter = input.role === "owner" ? "" : " AND pat.user_id = ?"; + const bindings = input.role === "owner" ? [timestamp] : [timestamp, input.userId]; + const rows = await db + .prepare( + `SELECT pat.id AS id, pat.user_id AS userId, owner.name AS ownerName, + pat.name AS name, pat.token_suffix AS tokenSuffix, + pat.created_at AS createdAt, pat.expires_at AS expiresAt + FROM personal_access_tokens pat + JOIN "user" owner ON owner.id = pat.user_id + WHERE pat.revoked_at IS NULL AND (pat.expires_at IS NULL OR pat.expires_at > ?) + ${userFilter} + ORDER BY pat.created_at DESC, pat.id DESC` + ) + .bind(...bindings) + .all(); + + return { personalAccessTokens: rows.results.map(readPersonalAccessTokenMetadata) }; +} + +export async function createPersonalAccessToken( + db: D1Database, + input: CreatePersonalAccessTokenInput & { + userId: string; + correlationId: string; + now?: number; + } +): Promise<{ personalAccessToken: PersonalAccessTokenMetadata; token: string }> { + const timestamp = new Date(input.now ?? Date.now()).toISOString(); + const id = newId("pat"); + const generated = await generatePersonalAccessToken(); + const insert = db + .prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + SELECT ?, ?, ?, ?, ?, ?, ?, NULL + WHERE ( + SELECT COUNT(*) FROM personal_access_tokens + WHERE user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) + ) < 10` + ) + .bind( + id, + input.userId, + input.name, + generated.tokenHash, + generated.tokenSuffix, + timestamp, + input.expiresAt, + input.userId, + timestamp + ); + const audit = prepareAuditInsert( + db, + { + correlationId: input.correlationId, + actorType: "user", + actorId: input.userId, + action: "personal_access_token.create", + resourceType: "personal_access_token", + resourceId: id, + outcome: "success" + }, + { kind: "personal-access-token-exists", id } + ); + + const results = await db.batch([insert, audit]); + const insertChanges = results[0]?.meta.changes; + const auditChanges = results[1]?.meta.changes; + if (insertChanges === 0 && auditChanges === 0) { + throw new AppError( + "PERSONAL_ACCESS_TOKEN_LIMIT_REACHED", + "This user already has ten active personal access tokens.", + 409 + ); + } + if (insertChanges !== 1 || auditChanges !== 1) throw invalidLifecycleResult(); + + const row = await db + .prepare( + `SELECT pat.id AS id, pat.user_id AS userId, owner.name AS ownerName, + pat.name AS name, pat.token_suffix AS tokenSuffix, + pat.created_at AS createdAt, pat.expires_at AS expiresAt + FROM personal_access_tokens pat + JOIN "user" owner ON owner.id = pat.user_id + WHERE pat.id = ?` + ) + .bind(id) + .first(); + if (!row) throw invalidLifecycleResult(); + + return { + personalAccessToken: readPersonalAccessTokenMetadata(row), + token: generated.token + }; +} + +export async function revokePersonalAccessToken( + db: D1Database, + input: { + id: string; + actorId: string; + actorRole: WorkspaceRole; + correlationId: string; + now?: number; + } +): Promise<"revoked" | "already-revoked" | "not-found"> { + const timestamp = new Date(input.now ?? Date.now()).toISOString(); + const ownerWide = input.actorRole === "owner"; + const userSql = ownerWide ? "" : " AND user_id = ?"; + const userBindings = ownerWide ? [] : [input.actorId]; + const audit = prepareAuditInsert( + db, + { + correlationId: input.correlationId, + actorType: "user", + actorId: input.actorId, + action: "personal_access_token.revoke", + resourceType: "personal_access_token", + resourceId: input.id, + outcome: "success" + }, + { + kind: "active-personal-access-token", + id: input.id, + ...(ownerWide ? {} : { userId: input.actorId }) + } + ); + const update = db + .prepare( + `UPDATE personal_access_tokens SET revoked_at = ? + WHERE id = ? AND revoked_at IS NULL${userSql}` + ) + .bind(timestamp, input.id, ...userBindings); + const target = db + .prepare(`SELECT id FROM personal_access_tokens WHERE id = ?${userSql}`) + .bind(input.id, ...userBindings); + + const results = await db.batch([audit, update, target]); + const auditChanges = results[0]?.meta.changes; + const updateChanges = results[1]?.meta.changes; + const targetRows = results[2]?.results; + if (!Array.isArray(targetRows)) throw invalidLifecycleResult(); + const targetExists = targetRows.length > 0; + + if (auditChanges === 1 && updateChanges === 1 && targetExists) return "revoked"; + if (auditChanges === 0 && updateChanges === 0) { + return targetExists ? "already-revoked" : "not-found"; + } + throw invalidLifecycleResult(); +} + +function invalidLifecycleResult(): AppError { + return new AppError("INTERNAL_ERROR", "Personal access token lifecycle result is invalid.", 500); +} diff --git a/worker/features/personal-access-tokens/types.ts b/worker/features/personal-access-tokens/types.ts new file mode 100644 index 00000000..eae92b8d --- /dev/null +++ b/worker/features/personal-access-tokens/types.ts @@ -0,0 +1,28 @@ +export type PersonalAccessTokenMetadata = { + id: string; + userId: string; + ownerName: string; + name: string; + tokenSuffix: string; + createdAt: string; + expiresAt: string | null; +}; + +export type PersonalAccessTokenList = { + personalAccessTokens: PersonalAccessTokenMetadata[]; +}; + +export type CreatePersonalAccessTokenInput = { + name: string; + expiresAt: string | null; +}; + +export type PersonalAccessTokenMetadataRow = { + id: unknown; + userId: unknown; + ownerName: unknown; + name: unknown; + tokenSuffix: unknown; + createdAt: unknown; + expiresAt: unknown; +}; diff --git a/worker/features/personal-access-tokens/validation.ts b/worker/features/personal-access-tokens/validation.ts new file mode 100644 index 00000000..2b9b8cc2 --- /dev/null +++ b/worker/features/personal-access-tokens/validation.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { AppError } from "../../lib/errors"; + +import type { + CreatePersonalAccessTokenInput, + PersonalAccessTokenMetadata, + PersonalAccessTokenMetadataRow +} from "./types"; + +const createInputSchema = z + .object({ + name: z.string().trim().min(1).max(80), + expiresAt: z.string().nullable() + }) + .strict(); + +const canonicalTimestampSchema = z.string().refine((value) => { + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value; +}); + +const metadataSchema = z.object({ + id: z.string().regex(/^pat_[A-Za-z0-9_-]+$/u), + userId: z.string().min(1), + ownerName: z.string(), + name: z + .string() + .min(1) + .max(80) + .refine((value) => value.trim() === value), + tokenSuffix: z.string().regex(/^[A-Za-z0-9_-]{4}$/u), + createdAt: canonicalTimestampSchema, + expiresAt: canonicalTimestampSchema.nullable() +}); + +export function readCreatePersonalAccessTokenInput( + value: unknown, + now = Date.now() +): CreatePersonalAccessTokenInput { + const parsed = createInputSchema.safeParse(value); + if (!parsed.success) throw invalidCreateInput(); + if (parsed.data.expiresAt === null) return parsed.data; + + const expiry = new Date(parsed.data.expiresAt); + if (!Number.isFinite(expiry.getTime()) || expiry.getTime() <= now) { + throw invalidCreateInput(); + } + return { name: parsed.data.name, expiresAt: expiry.toISOString() }; +} + +export function readPersonalAccessTokenMetadata( + row: PersonalAccessTokenMetadataRow +): PersonalAccessTokenMetadata { + const parsed = metadataSchema.safeParse(row); + if (!parsed.success) { + throw new AppError("INTERNAL_ERROR", "Stored personal access token metadata is invalid.", 500); + } + return parsed.data; +} + +function invalidCreateInput(): AppError { + return new AppError( + "INVALID_PERSONAL_ACCESS_TOKEN", + "Personal access token input is invalid.", + 400 + ); +} From 42992357b4007fa41c2e3a46545eed1ba34d8833 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:44:36 -0500 Subject: [PATCH 05/21] feat: expose personal access token management --- .../worker/personal-access-tokens.test.ts | 417 ++++++++++++++++++ .../features/personal-access-tokens/routes.ts | 73 +++ worker/routes/index.ts | 2 + 3 files changed, 492 insertions(+) create mode 100644 test/integration/worker/personal-access-tokens.test.ts create mode 100644 worker/features/personal-access-tokens/routes.ts diff --git a/test/integration/worker/personal-access-tokens.test.ts b/test/integration/worker/personal-access-tokens.test.ts new file mode 100644 index 00000000..1aaf8f33 --- /dev/null +++ b/test/integration/worker/personal-access-tokens.test.ts @@ -0,0 +1,417 @@ +import { env, SELF } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { createAuth } from "../../../worker/auth/auth"; +import { assertSecretSafeAbsent } from "../../helpers/secret-safe-assertions"; +import { applyCurrentMigrations } from "./current-migrations"; + +const origin = "https://hqbase.test"; +const users = { + owner: { id: "", cookie: "", role: "owner" }, + secondOwner: { id: "", cookie: "", role: "owner" }, + admin: { id: "", cookie: "", role: "admin" }, + member: { id: "", cookie: "", role: "member" } +} as const; + +describe("personal access token management API", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + await createSessionUser("owner", "pat-owner@example.com"); + await createSessionUser("secondOwner", "pat-second-owner@example.com"); + await createSessionUser("admin", "pat-admin@example.com"); + await createSessionUser("member", "pat-member@example.com"); + }); + + beforeEach(async () => { + await env.DB.prepare( + "DELETE FROM audit_events WHERE action LIKE 'personal_access_token.%'" + ).run(); + await env.DB.prepare("DELETE FROM personal_access_tokens").run(); + await env.DB.prepare("DELETE FROM rate_limits WHERE scope = 'pat.create'").run(); + const recent = new Date().toISOString(); + for (const user of Object.values(users)) { + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?') + .bind(user.role, user.id) + .run(); + await env.DB.prepare('UPDATE "session" SET createdAt = ? WHERE userId = ?') + .bind(recent, user.id) + .run(); + } + }); + + it("creates, lists, and revokes tokens for each current role", async () => { + const ownerToken = await createThroughApi(users.owner.cookie, "Owner automation"); + const secondOwnerToken = await createThroughApi( + users.secondOwner.cookie, + "Second owner automation" + ); + const adminToken = await createThroughApi(users.admin.cookie, "Admin automation"); + const memberToken = await createThroughApi(users.member.cookie, "Member automation"); + + await setCreatedAt(ownerToken.id, "2026-08-20T18:00:04.000Z"); + await setCreatedAt(secondOwnerToken.id, "2026-08-20T18:00:03.000Z"); + await setCreatedAt(adminToken.id, "2026-08-20T18:00:02.000Z"); + await setCreatedAt(memberToken.id, "2026-08-20T18:00:01.000Z"); + await insertPat("pat_expired_http", users.admin.id, "Expired", { + expiresAt: "2026-08-19T18:00:00.000Z" + }); + await insertPat("pat_revoked_http", users.member.id, "Revoked", { + revokedAt: "2026-08-20T17:00:00.000Z" + }); + + const ownerList = await listThroughApi(users.owner.cookie); + expect(ownerList.response.headers.get("cache-control")).toBe("no-store"); + expect(ownerList.tokens.map(({ id }) => id)).toEqual([ + ownerToken.id, + secondOwnerToken.id, + adminToken.id, + memberToken.id + ]); + expect(Object.keys(ownerList.tokens[0] ?? {})).toEqual([ + "id", + "userId", + "ownerName", + "name", + "tokenSuffix", + "createdAt", + "expiresAt" + ]); + + const adminList = await listThroughApi(users.admin.cookie); + expect(adminList.tokens.map(({ id }) => id)).toEqual([adminToken.id]); + const memberList = await listThroughApi(users.member.cookie); + expect(memberList.tokens.map(({ id }) => id)).toEqual([memberToken.id]); + + const foreignAdmin = await SELF.fetch(`${origin}/api/personal-access-tokens/${ownerToken.id}`, { + headers: { cookie: users.admin.cookie }, + method: "DELETE" + }); + expect(foreignAdmin.status).toBe(404); + expect(foreignAdmin.headers.get("cache-control")).toBe("no-store"); + expect(await auditCount(ownerToken.id, "personal_access_token.revoke")).toBe(0); + + const foreignMember = await SELF.fetch( + `${origin}/api/personal-access-tokens/${adminToken.id}`, + { headers: { cookie: users.member.cookie }, method: "DELETE" } + ); + expect(foreignMember.status).toBe(404); + expect(await auditCount(adminToken.id, "personal_access_token.revoke")).toBe(0); + + const revoked = await SELF.fetch(`${origin}/api/personal-access-tokens/${adminToken.id}`, { + headers: { cookie: users.owner.cookie }, + method: "DELETE" + }); + expect(revoked.status).toBe(204); + expect(revoked.headers.get("cache-control")).toBe("no-store"); + const repeated = await SELF.fetch(`${origin}/api/personal-access-tokens/${adminToken.id}`, { + headers: { cookie: users.owner.cookie }, + method: "DELETE" + }); + expect(repeated.status).toBe(204); + expect(await auditCount(adminToken.id, "personal_access_token.revoke")).toBe(1); + + const unknown = await SELF.fetch(`${origin}/api/personal-access-tokens/pat_unknown`, { + headers: { cookie: users.owner.cookie }, + method: "DELETE" + }); + expect(unknown.status).toBe(404); + await expect(unknown.json()).resolves.toEqual({ + error: { + code: "PERSONAL_ACCESS_TOKEN_NOT_FOUND", + message: "Personal access token not found." + } + }); + }); + + it("uses the user's current role after an owner is demoted", async () => { + const own = await createThroughApi(users.owner.cookie, "Demoted owner token"); + const foreign = await createThroughApi(users.secondOwner.cookie, "Other owner token"); + const demoted = await SELF.fetch(`${origin}/api/users/${users.owner.id}`, { + body: JSON.stringify({ role: "admin" }), + headers: { + "content-type": "application/json", + cookie: users.secondOwner.cookie, + origin + }, + method: "PATCH" + }); + expect(demoted.status, await demoted.clone().text()).toBe(200); + + const list = await listThroughApi(users.owner.cookie); + expect(list.tokens.map(({ id }) => id)).toEqual([own.id]); + const rejected = await SELF.fetch(`${origin}/api/personal-access-tokens/${foreign.id}`, { + headers: { cookie: users.owner.cookie }, + method: "DELETE" + }); + expect(rejected.status).toBe(404); + expect(await auditCount(foreign.id, "personal_access_token.revoke")).toBe(0); + const stored = await env.DB.prepare( + "SELECT revoked_at AS revokedAt FROM personal_access_tokens WHERE id = ?" + ) + .bind(foreign.id) + .first<{ revokedAt: string | null }>(); + expect(stored?.revokedAt).toBeNull(); + }); + + it("maps malformed JSON to the PAT input error", async () => { + const response = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: "{", + headers: { + "content-type": "application/json", + cookie: users.member.cookie, + origin + }, + method: "POST" + }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { + code: "INVALID_PERSONAL_ACCESS_TOKEN", + message: "Personal access token input is invalid." + } + }); + }); + + it("requires a recent web session only for creation", async () => { + await insertPat("pat_stale_session", users.member.id, "Stale session token"); + await env.DB.prepare('UPDATE "session" SET createdAt = ? WHERE userId = ?') + .bind("2026-01-01T00:00:00.000Z", users.member.id) + .run(); + + const list = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + headers: { cookie: users.member.cookie } + }); + expect(list.status).toBe(200); + const revoke = await SELF.fetch(`${origin}/api/personal-access-tokens/pat_stale_session`, { + headers: { cookie: users.member.cookie }, + method: "DELETE" + }); + expect(revoke.status).toBe(204); + const create = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: JSON.stringify({ name: "Stale", expiresAt: null }), + headers: { + "content-type": "application/json", + cookie: users.member.cookie, + origin + }, + method: "POST" + }); + expect(create.status).toBe(403); + await expect(create.json()).resolves.toMatchObject({ + error: { code: "RECENT_AUTH_REQUIRED" } + }); + }); + + it("accepts only web sessions on management routes", async () => { + const created = await createThroughApi(users.owner.cookie, "Bearer boundary"); + const stored = await env.DB.prepare( + "SELECT token_hash AS tokenHash FROM personal_access_tokens WHERE id = ?" + ) + .bind(created.id) + .first<{ tokenHash: string }>(); + if (!stored) throw new Error("Expected the created PAT row."); + const authorization = `Bearer ${created.token}`; + const serializedCreate = JSON.stringify({ name: "Bearer boundary", expiresAt: null }); + + for (const header of [authorization, "Bearer hqb_access_example"]) { + for (const [method, path] of [ + ["GET", "/api/personal-access-tokens"], + ["POST", "/api/personal-access-tokens"], + ["DELETE", `/api/personal-access-tokens/${created.id}`] + ] as const) { + const response = await SELF.fetch(`${origin}${path}`, { + ...(method === "POST" ? { body: serializedCreate } : {}), + headers: { + authorization: header, + ...(method === "POST" ? { "content-type": "application/json", origin } : {}) + }, + method + }); + expect(response.status).toBe(401); + const body = await response.text(); + assertSecretSafeAbsent(body, [ + created.token, + stored.tokenHash, + authorization, + serializedCreate + ]); + } + } + }); + + it("allows only one concurrent create to claim the tenth active slot", async () => { + for (let index = 0; index < 9; index += 1) { + await insertPat(`pat_concurrent_${index}`, users.owner.id, `Existing ${index}`); + } + const request = (name: string) => + SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: JSON.stringify({ name, expiresAt: null }), + headers: { + "content-type": "application/json", + cookie: users.owner.cookie, + origin + }, + method: "POST" + }); + const responses = await Promise.all([request("Concurrent A"), request("Concurrent B")]); + expect(responses.map(({ status }) => status).sort()).toEqual([201, 409]); + for (const response of responses) { + expect(response.headers.get("cache-control")).toBe("no-store"); + if (response.status === 201) { + const body = (await response.json()) as { + personalAccessToken: { id: string; name: string }; + token: string; + }; + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(body.token)).toBe(true); + expect(["Concurrent A", "Concurrent B"]).toContain(body.personalAccessToken.name); + } + } + + expect( + await countRows( + `SELECT COUNT(*) AS count FROM personal_access_tokens + WHERE user_id = '${users.owner.id}' AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > datetime('now'))` + ) + ).toBe(10); + const concurrentRows = await env.DB.prepare( + `SELECT id, name FROM personal_access_tokens + WHERE name IN ('Concurrent A', 'Concurrent B')` + ).all<{ id: string; name: string }>(); + expect(concurrentRows.results).toHaveLength(1); + expect( + await auditCount(concurrentRows.results[0]?.id ?? "", "personal_access_token.create") + ).toBe(1); + }); + + it("limits create attempts independently by signed-in user", async () => { + const invalidAttempt = () => + SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: JSON.stringify({ name: "", expiresAt: null }), + headers: { + "content-type": "application/json", + cookie: users.admin.cookie, + origin + }, + method: "POST" + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await invalidAttempt(); + expect(response.status).toBe(400); + } + const limited = await invalidAttempt(); + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toMatchObject({ error: { code: "RATE_LIMITED" } }); + + const otherUser = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: JSON.stringify({ name: "Independent limit", expiresAt: null }), + headers: { + "content-type": "application/json", + cookie: users.member.cookie, + origin + }, + method: "POST" + }); + expect(otherUser.status).toBe(201); + }); +}); + +async function createSessionUser(key: keyof typeof users, email: string): Promise { + const response = await createAuth(env, new Request(`${origin}/api/auth/sign-up/email`)).handler( + new Request(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ + email, + name: `PAT ${key}`, + password: "test-password-123", + rememberMe: false + }), + headers: { "content-type": "application/json", origin }, + method: "POST" + }) + ); + expect(response.status, await response.clone().text()).toBe(200); + const body = (await response.json()) as { user: { id: string } }; + const cookie = extractSessionCookie(response); + Object.assign(users[key], { id: body.user.id, cookie }); + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?') + .bind(users[key].role, body.user.id) + .run(); +} + +async function createThroughApi(cookie: string, name: string) { + const response = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: JSON.stringify({ name, expiresAt: null }), + headers: { "content-type": "application/json", cookie, origin }, + method: "POST" + }); + expect(response.status).toBe(201); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = (await response.json()) as { + personalAccessToken: Record & { id: string; name: string }; + token: string; + }; + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(body.token)).toBe(true); + expect(body.personalAccessToken.name).toBe(name); + return { id: body.personalAccessToken.id, token: body.token }; +} + +async function listThroughApi(cookie: string) { + const response = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + headers: { cookie } + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + personalAccessTokens: Array & { id: string }>; + }; + return { response, tokens: body.personalAccessTokens }; +} + +async function insertPat( + id: string, + userId: string, + name: string, + options: { expiresAt?: string | null; revokedAt?: string | null } = {} +): Promise { + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES (?, ?, ?, ?, 'a1B2', '2026-08-20T18:00:00.000Z', ?, ?)` + ) + .bind( + id, + userId, + name, + id.padEnd(43, "A").slice(0, 43), + options.expiresAt ?? null, + options.revokedAt ?? null + ) + .run(); +} + +async function setCreatedAt(id: string, createdAt: string): Promise { + await env.DB.prepare("UPDATE personal_access_tokens SET created_at = ? WHERE id = ?") + .bind(createdAt, id) + .run(); +} + +async function auditCount(resourceId: string, action: string): Promise { + const row = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM audit_events WHERE resource_id = ? AND action = ?" + ) + .bind(resourceId, action) + .first<{ count: number }>(); + return row?.count ?? -1; +} + +async function countRows(query: string): Promise { + const row = await env.DB.prepare(query).first<{ count: number }>(); + return row?.count ?? -1; +} + +function extractSessionCookie(response: Response): string { + const cookie = (response.headers.get("set-cookie") ?? "").match( + /(?:^|,\s*)((?:__Secure-)?better-auth\.session_token=[^;,]+)/u + )?.[1]; + if (!cookie) throw new Error("Session cookie was not returned."); + return cookie; +} diff --git a/worker/features/personal-access-tokens/routes.ts b/worker/features/personal-access-tokens/routes.ts new file mode 100644 index 00000000..af8b0a35 --- /dev/null +++ b/worker/features/personal-access-tokens/routes.ts @@ -0,0 +1,73 @@ +import { Hono } from "hono"; + +import { requireAuthContext, requireRecentSession } from "../../auth/session"; +import type { HonoApp } from "../../lib/env"; +import { AppError } from "../../lib/errors"; +import { readJson } from "../../lib/json"; +import { enforceRateLimit } from "../../security/rate-limit"; + +import { + createPersonalAccessToken, + listPersonalAccessTokens, + revokePersonalAccessToken +} from "./service"; +import { readCreatePersonalAccessTokenInput } from "./validation"; + +export const personalAccessTokenRoutes = new Hono(); + +personalAccessTokenRoutes.get("/", async (c) => { + const auth = await requireAuthContext(c.env, c.req.raw); + return c.json( + await listPersonalAccessTokens(c.env.DB, { + userId: auth.user.id, + role: auth.user.role + }) + ); +}); + +personalAccessTokenRoutes.post("/", async (c) => { + const auth = await requireAuthContext(c.env, c.req.raw); + requireRecentSession(auth); + await enforceRateLimit(c.env.DB, c.env.BETTER_AUTH_SECRET, { + scope: "pat.create", + subject: auth.user.id, + limit: 5, + windowSeconds: 60 * 60 + }); + + let rawInput: unknown; + try { + rawInput = await readJson(c.req.raw); + } catch (error) { + if (error instanceof AppError && error.code === "INVALID_JSON") { + throw new AppError( + "INVALID_PERSONAL_ACCESS_TOKEN", + "Personal access token input is invalid.", + 400 + ); + } + throw error; + } + + const input = readCreatePersonalAccessTokenInput(rawInput); + const result = await createPersonalAccessToken(c.env.DB, { + ...input, + userId: auth.user.id, + correlationId: c.get("correlationId") + }); + return c.json(result, 201); +}); + +personalAccessTokenRoutes.delete("/:id", async (c) => { + const auth = await requireAuthContext(c.env, c.req.raw); + const result = await revokePersonalAccessToken(c.env.DB, { + id: c.req.param("id"), + actorId: auth.user.id, + actorRole: auth.user.role, + correlationId: c.get("correlationId") + }); + if (result === "not-found") { + throw new AppError("PERSONAL_ACCESS_TOKEN_NOT_FOUND", "Personal access token not found.", 404); + } + return c.body(null, 204); +}); diff --git a/worker/routes/index.ts b/worker/routes/index.ts index bded6b88..5dbb24a7 100644 --- a/worker/routes/index.ts +++ b/worker/routes/index.ts @@ -17,6 +17,7 @@ import { conversationRoutes } from "../features/messages/conversation-routes"; import { attachmentRoutes, messageRoutes } from "../features/messages/routes"; import { notificationRoutes } from "../features/notifications/routes"; import { operationRoutes } from "../features/operations/routes"; +import { personalAccessTokenRoutes } from "../features/personal-access-tokens/routes"; import { sendRoutes } from "../features/send/routes"; import { sessionControlRoutes } from "../features/sessions/routes"; import { setupRoutes } from "../features/setup/routes"; @@ -67,6 +68,7 @@ apiRoutes.route("/api/drafts", draftRoutes); apiRoutes.route("/api/mailbox-grants", mailboxAccessRoutes); apiRoutes.route("/api/sessions", sessionControlRoutes); apiRoutes.route("/api/operations", operationRoutes); +apiRoutes.route("/api/personal-access-tokens", personalAccessTokenRoutes); apiRoutes.route("/api/mailboxes", mailboxRoutes); apiRoutes.route("/api/conversations", conversationRoutes); apiRoutes.route("/api/messages", messageRoutes); From c4e2e5a7ed8c06ca1d821b48a253437329d3aad6 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:49:27 -0500 Subject: [PATCH 06/21] feat: validate personal access token principals --- .../personal-access-token-principal.test.ts | 116 +++++++++++++ .../personal-access-token-principal.test.ts | 158 ++++++++++++++++++ .../auth/personal-access-token-principal.ts | 116 +++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 test/integration/worker/personal-access-token-principal.test.ts create mode 100644 test/unit/worker/auth/personal-access-token-principal.test.ts create mode 100644 worker/auth/personal-access-token-principal.ts diff --git a/test/integration/worker/personal-access-token-principal.test.ts b/test/integration/worker/personal-access-token-principal.test.ts new file mode 100644 index 00000000..b2770f74 --- /dev/null +++ b/test/integration/worker/personal-access-token-principal.test.ts @@ -0,0 +1,116 @@ +import { env } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { + authenticatePersonalAccessToken, + PersonalAccessTokenError +} from "../../../worker/auth/personal-access-token-principal"; +import { generatePersonalAccessToken } from "../../../worker/auth/personal-access-token-secret"; +import { applyCurrentMigrations } from "./current-migrations"; + +const now = Date.parse("2026-08-20T18:00:00.000Z"); +const userId = "usr_pat_principal"; +let bearer = ""; + +describe("personal access token principal lookup", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + }); + + beforeEach(async () => { + await env.DB.prepare("DELETE FROM personal_access_tokens WHERE user_id = ?").bind(userId).run(); + await env.DB.prepare("DELETE FROM user_onboarding WHERE user_id = ?").bind(userId).run(); + await env.DB.prepare('DELETE FROM "user" WHERE id = ?').bind(userId).run(); + await env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned, banExpires) + VALUES (?, 'PAT Principal', 'pat-principal@example.com', 1, ?, ?, 'admin', NULL, NULL)` + ) + .bind(userId, new Date(now).toISOString(), new Date(now).toISOString()) + .run(); + const generated = await generatePersonalAccessToken(); + bearer = generated.token; + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES ('pat_principal_d1', ?, 'D1 principal', ?, ?, ?, NULL, NULL)` + ) + .bind(userId, generated.tokenHash, generated.tokenSuffix, new Date(now).toISOString()) + .run(); + }); + + it("returns live user metadata for a valid token", async () => { + await expectValidPrincipal(); + }); + + it("rejects expiry equality", async () => { + await updateToken("expires_at", new Date(now).toISOString()); + await expectPrincipalError(); + }); + + it("rejects revocation", async () => { + await updateToken("revoked_at", "2026-08-20T17:00:00.000Z"); + await expectPrincipalError(); + }); + + it("rejects pending password setup", async () => { + await env.DB.prepare( + `INSERT INTO user_onboarding + (user_id, method, status, created_by, invitation_sent_at, completed_at, created_at, updated_at) + VALUES (?, 'temporary_password', 'pending', NULL, NULL, NULL, ?, ?)` + ) + .bind(userId, new Date(now).toISOString(), new Date(now).toISOString()) + .run(); + await expectPrincipalError(); + }); + + it("rejects a permanent ban", async () => { + await env.DB.prepare('UPDATE "user" SET banned = 1, banExpires = NULL WHERE id = ?') + .bind(userId) + .run(); + await expectPrincipalError(); + }); + + it("accepts an expired finite temporary ban", async () => { + await env.DB.prepare('UPDATE "user" SET banned = 1, banExpires = ? WHERE id = ?') + .bind("2026-08-20T17:59:59.999Z", userId) + .run(); + await expectValidPrincipal(); + }); + + it("rejects an unsupported stored authorization value", async () => { + await env.DB.prepare('UPDATE "user" SET banned = 2 WHERE id = ?').bind(userId).run(); + await expectPrincipalError(); + }); +}); + +async function updateToken(column: "expires_at" | "revoked_at", value: string): Promise { + const query = + column === "expires_at" + ? "UPDATE personal_access_tokens SET expires_at = ? WHERE id = 'pat_principal_d1'" + : "UPDATE personal_access_tokens SET revoked_at = ? WHERE id = 'pat_principal_d1'"; + await env.DB.prepare(query).bind(value).run(); +} + +async function expectValidPrincipal(): Promise { + const principal = await authenticatePersonalAccessToken(env.DB, bearer, now); + expect(principal).toEqual({ + tokenId: "pat_principal_d1", + user: { + id: userId, + email: "pat-principal@example.com", + name: "PAT Principal", + role: "admin" + } + }); +} + +async function expectPrincipalError(): Promise { + try { + await authenticatePersonalAccessToken(env.DB, bearer, now); + } catch (error) { + expect(error).toBeInstanceOf(PersonalAccessTokenError); + return; + } + throw new Error("Expected personal access token access to be rejected."); +} diff --git a/test/unit/worker/auth/personal-access-token-principal.test.ts b/test/unit/worker/auth/personal-access-token-principal.test.ts new file mode 100644 index 00000000..088915aa --- /dev/null +++ b/test/unit/worker/auth/personal-access-token-principal.test.ts @@ -0,0 +1,158 @@ +import { + PersonalAccessTokenError, + type PersonalAccessTokenPrincipalRow, + validatePersonalAccessTokenPrincipalRow +} from "@worker/auth/personal-access-token-principal"; +import { describe, expect, it } from "vitest"; + +const now = Date.parse("2026-08-20T18:00:00.000Z"); + +describe("personal access token principal row validation", () => { + it("returns a principal for valid access states", () => { + expect(validatePersonalAccessTokenPrincipalRow(validRow(), now)).toEqual({ + tokenId: "pat_principal_valid", + user: { + id: "better-auth-user-id", + email: "principal@example.com", + name: "", + role: "member" + } + }); + expect( + validatePersonalAccessTokenPrincipalRow( + validRow({ expiresAt: "2026-08-20T18:00:00.001Z" }), + now + ).tokenId + ).toBe("pat_principal_valid"); + }); + + it.each([ + ["expiry equality", "2026-08-20T18:00:00.000Z"], + ["past expiry", "2026-08-20T17:59:59.999Z"], + ["malformed expiry", "not-a-time"], + ["wrong expiry scalar", 1] + ])("rejects %s", (_label, expiresAt) => { + expectPrincipalError(validRow({ expiresAt })); + }); + + it.each([ + ["timestamp revocation", "2026-08-20T17:00:00.000Z"], + ["numeric revocation", 0], + ["object revocation", { stored: true }] + ])("rejects %s without parsing it", (_label, revokedAt) => { + expectPrincipalError(validRow({ revokedAt })); + }); + + it.each([ + ["null ban state", null], + ["unbanned state", 0] + ])("accepts %s", (_label, banned) => { + expect(validatePersonalAccessTokenPrincipalRow(validRow({ banned }), now).tokenId).toBe( + "pat_principal_valid" + ); + }); + + it.each([ + ["string zero", "0"], + ["string one", "1"], + ["other text", "other"], + ["negative integer", -1], + ["integer above one", 2], + ["fraction", 0.5], + ["binary value", new Uint8Array([1])] + ])("rejects unsupported banned value: %s", (_label, banned) => { + expectPrincipalError(validRow({ banned })); + }); + + it("ignores ban expiry when the user is not banned", () => { + for (const banned of [null, 0]) { + expect( + validatePersonalAccessTokenPrincipalRow( + validRow({ banned, banExpires: new Uint8Array([1]) }), + now + ).tokenId + ).toBe("pat_principal_valid"); + } + }); + + it.each([ + ["permanent ban", null], + ["active temporary ban", "2026-08-20T18:00:00.001Z"], + ["malformed temporary ban", "not-a-time"], + ["wrong temporary ban scalar", 1] + ])("rejects %s", (_label, banExpires) => { + expectPrincipalError(validRow({ banned: 1, banExpires })); + }); + + it.each([ + ["expiry equality", "2026-08-20T18:00:00.000Z"], + ["expired temporary ban", "2026-08-20T17:59:59.999Z"] + ])("accepts banned state with %s", (_label, banExpires) => { + expect( + validatePersonalAccessTokenPrincipalRow(validRow({ banned: 1, banExpires }), now).tokenId + ).toBe("pat_principal_valid"); + }); + + it.each([ + ["pending onboarding", "pending"], + ["unsupported onboarding", "other"], + ["wrong onboarding scalar", 1] + ])("rejects %s", (_label, onboardingStatus) => { + expectPrincipalError(validRow({ onboardingStatus })); + }); + + it.each([ + ["no onboarding row", null], + ["complete onboarding", "complete"] + ])("accepts %s", (_label, onboardingStatus) => { + expect( + validatePersonalAccessTokenPrincipalRow(validRow({ onboardingStatus }), now).tokenId + ).toBe("pat_principal_valid"); + }); + + it.each([ + ["token ID without prefix", { tokenId: "principal_valid" }], + ["token ID with unsupported text", { tokenId: "pat_invalid!" }], + ["numeric token ID", { tokenId: 1 }], + ["empty user ID", { userId: "" }], + ["numeric user ID", { userId: 1 }], + ["invalid email", { email: "not-an-email" }], + ["numeric email", { email: 1 }], + ["numeric name", { name: 1 }], + ["missing role", { role: null }], + ["unsupported role", { role: "operator" }], + ["numeric role", { role: 1 }] + ] satisfies ReadonlyArray< + readonly [string, Partial] + >)("rejects invalid identity or role: %s", (_label, values) => { + expectPrincipalError(validRow(values)); + }); +}); + +function validRow( + values: Partial = {} +): PersonalAccessTokenPrincipalRow { + return { + tokenId: "pat_principal_valid", + userId: "better-auth-user-id", + email: "principal@example.com", + name: "", + role: "member", + banned: null, + banExpires: null, + onboardingStatus: null, + expiresAt: null, + revokedAt: null, + ...values + }; +} + +function expectPrincipalError(row: PersonalAccessTokenPrincipalRow): void { + try { + validatePersonalAccessTokenPrincipalRow(row, now); + } catch (error) { + expect(error).toBeInstanceOf(PersonalAccessTokenError); + return; + } + throw new Error("Expected personal access token access to be rejected."); +} diff --git a/worker/auth/personal-access-token-principal.ts b/worker/auth/personal-access-token-principal.ts new file mode 100644 index 00000000..061d231b --- /dev/null +++ b/worker/auth/personal-access-token-principal.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; + +import type { WorkspaceRole } from "../lib/validation"; +import { workspaceRoleSchema } from "../lib/validation"; + +import { hashPersonalAccessToken, parsePersonalAccessToken } from "./personal-access-token-secret"; + +export type PersonalAccessTokenPrincipal = { + tokenId: string; + user: { + id: string; + email: string; + name: string; + role: WorkspaceRole; + }; +}; + +export type PersonalAccessTokenPrincipalRow = { + tokenId: unknown; + userId: unknown; + email: unknown; + name: unknown; + role: unknown; + banned: unknown; + banExpires: unknown; + onboardingStatus: unknown; + expiresAt: unknown; + revokedAt: unknown; +}; + +export class PersonalAccessTokenError extends Error { + constructor() { + super("Personal access token is invalid or inactive."); + this.name = "PersonalAccessTokenError"; + } +} + +const identitySchema = z.object({ + tokenId: z.string().regex(/^pat_[A-Za-z0-9_-]+$/u), + userId: z.string().min(1), + email: z.string().email(), + name: z.string(), + role: workspaceRoleSchema +}); + +export function validatePersonalAccessTokenPrincipalRow( + row: PersonalAccessTokenPrincipalRow, + now = Date.now() +): PersonalAccessTokenPrincipal { + const identity = identitySchema.safeParse(row); + if (!identity.success) throw new PersonalAccessTokenError(); + + if (row.revokedAt !== null) throw new PersonalAccessTokenError(); + if (row.expiresAt !== null) { + if (typeof row.expiresAt !== "string") throw new PersonalAccessTokenError(); + const expiresAt = Date.parse(row.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new PersonalAccessTokenError(); + } + + if (row.banned !== null && row.banned !== 0 && row.banned !== 1) { + throw new PersonalAccessTokenError(); + } + if (row.banned === 1) { + if (typeof row.banExpires !== "string") throw new PersonalAccessTokenError(); + const banExpires = Date.parse(row.banExpires); + if (!Number.isFinite(banExpires) || banExpires > now) { + throw new PersonalAccessTokenError(); + } + } + + if (row.onboardingStatus !== null && row.onboardingStatus !== "complete") { + throw new PersonalAccessTokenError(); + } + + return { + tokenId: identity.data.tokenId, + user: { + id: identity.data.userId, + email: identity.data.email, + name: identity.data.name, + role: identity.data.role + } + }; +} + +export async function authenticatePersonalAccessToken( + db: D1Database, + bearer: string, + now = Date.now() +): Promise { + let parsedBearer: string; + try { + parsedBearer = parsePersonalAccessToken(bearer); + } catch { + throw new PersonalAccessTokenError(); + } + + const row = await db + .prepare( + `SELECT pat.id AS tokenId, pat.user_id AS userId, + owner.email AS email, owner.name AS name, owner.role AS role, + owner.banned AS banned, owner.banExpires AS banExpires, + onboarding.status AS onboardingStatus, + pat.expires_at AS expiresAt, pat.revoked_at AS revokedAt + FROM personal_access_tokens pat + JOIN "user" owner ON owner.id = pat.user_id + LEFT JOIN user_onboarding onboarding ON onboarding.user_id = owner.id + WHERE pat.token_hash = ? + LIMIT 1` + ) + .bind(await hashPersonalAccessToken(parsedBearer)) + .first(); + + if (!row) throw new PersonalAccessTokenError(); + return validatePersonalAccessTokenPrincipalRow(row, now); +} From 360acacd5263971e9ac4e4266f1dd5ef54792d1d Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 02:55:39 -0500 Subject: [PATCH 07/21] feat: authenticate Mail API personal access tokens --- ...rsonal-access-token-authentication.test.ts | 513 ++++++++++++++++++ worker/auth/mail-api.ts | 58 +- 2 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 test/integration/worker/personal-access-token-authentication.test.ts diff --git a/test/integration/worker/personal-access-token-authentication.test.ts b/test/integration/worker/personal-access-token-authentication.test.ts new file mode 100644 index 00000000..fed9cb2e --- /dev/null +++ b/test/integration/worker/personal-access-token-authentication.test.ts @@ -0,0 +1,513 @@ +import { env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { createAuth } from "../../../worker/auth/auth"; +import { + generatePersonalAccessToken, + hashPersonalAccessToken +} from "../../../worker/auth/personal-access-token-secret"; +import type { WorkspaceRole } from "../../../worker/lib/validation"; +import { assertSecretSafeAbsent } from "../../helpers/secret-safe-assertions"; +import { applyCurrentMigrations } from "./current-migrations"; +import { tokenRow } from "./mail-api-token-fixture"; + +const origin = "https://hqbase.test"; +const apiResource = `${origin}/api/v1`; +const oauthBearer = "hqb_access_pat-shared-rate-limit"; +const password = "personal-access-token-password"; + +type Actor = { + id: string; + sessionId: string; + cookie: string; + role: WorkspaceRole; + bearer: string; + tokenId: string; + tokenHash: string; +}; + +const actors = {} as Record<"owner" | "admin" | "member", Actor>; +let revokedBearer = ""; +let revokedHash = ""; + +describe("Mail API personal access token authentication", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + actors.owner = await createActor("owner"); + actors.admin = await createActor("admin"); + actors.member = await createActor("member"); + await createMailFixtures(); + await createOAuthFixture(); + + const revoked = await generatePersonalAccessToken(); + revokedBearer = revoked.token; + revokedHash = revoked.tokenHash; + await insertPat( + "pat_http_revoked", + actors.member.id, + revoked.tokenHash, + revoked.tokenSuffix, + new Date().toISOString() + ); + }); + + it("uses owner, admin, and member PATs for read, write, and send operations", async () => { + for (const role of ["owner", "admin", "member"] as const) { + const actor = actors[role]; + const read = await patFetch(actor.bearer, "/api/v1/messages/msg_pat_allowed"); + expect(read.status, role).toBe(200); + + const write = await patFetch(actor.bearer, "/api/v1/messages/msg_pat_allowed/read", { + method: "POST" + }); + expect(write.status, role).toBe(200); + + const send = await patFetch(actor.bearer, "/api/v1/drafts", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST" + }); + expect(send.status, role).toBe(201); + expect(send.headers.get("www-authenticate")).toBeNull(); + } + }); + + it("applies current mailbox and unassigned-mail access to each role", async () => { + for (const role of ["admin", "member"] as const) { + await expect( + patFetch(actors[role].bearer, "/api/v1/messages/msg_pat_foreign") + ).resolves.toMatchObject({ status: 403 }); + await expect( + patFetch(actors[role].bearer, "/api/v1/messages/msg_pat_unassigned") + ).resolves.toMatchObject({ status: 403 }); + } + + await expect( + patFetch(actors.owner.bearer, "/api/v1/messages/msg_pat_foreign") + ).resolves.toMatchObject({ status: 200 }); + await expect( + patFetch(actors.owner.bearer, "/api/v1/messages/msg_pat_unassigned") + ).resolves.toMatchObject({ status: 200 }); + }); + + it("uses live role and mailbox-grant state", async () => { + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?') + .bind("member", actors.owner.id) + .run(); + try { + await expect( + patFetch(actors.owner.bearer, "/api/v1/messages/msg_pat_foreign") + ).resolves.toMatchObject({ status: 403 }); + await expect( + patFetch(actors.owner.bearer, "/api/v1/messages/msg_pat_unassigned") + ).resolves.toMatchObject({ status: 403 }); + } finally { + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?') + .bind("owner", actors.owner.id) + .run(); + } + + await env.DB.prepare( + "DELETE FROM mailbox_grants WHERE mailbox_id = 'mbx_pat_allowed' AND user_id = ?" + ) + .bind(actors.member.id) + .run(); + try { + await expect( + patFetch(actors.member.bearer, "/api/v1/messages/msg_pat_allowed") + ).resolves.toMatchObject({ status: 403 }); + } finally { + await insertGrant(actors.member.id); + } + }); + + it("does not depend on a live web session", async () => { + await env.DB.prepare('UPDATE "session" SET expiresAt = ? WHERE id = ?') + .bind("2026-01-01T00:00:00.000Z", actors.member.sessionId) + .run(); + + const expiredSession = await SELF.fetch(`${origin}/api/v1/messages/msg_pat_allowed`, { + headers: { cookie: actors.member.cookie } + }); + expect(expiredSession.status).toBe(401); + await expect( + patFetch(actors.member.bearer, "/api/v1/messages/msg_pat_allowed") + ).resolves.toMatchObject({ status: 200 }); + }); + + it("stays active after an ordinary password reset", async () => { + const requested = await SELF.fetch(`${origin}/api/auth/request-password-reset`, { + body: JSON.stringify({ + email: "pat-admin@example.com", + redirectTo: `${origin}/reset-password` + }), + headers: { "content-type": "application/json", origin }, + method: "POST" + }); + expect(requested.status).toBe(200); + const verification = await env.DB.prepare( + `SELECT identifier FROM verification + WHERE value = ? AND identifier LIKE 'reset-password:%' + ORDER BY expiresAt DESC LIMIT 1` + ) + .bind(actors.admin.id) + .first<{ identifier: string }>(); + const resetToken = verification?.identifier.replace("reset-password:", ""); + if (!resetToken) throw new Error("Expected a password-reset verification row."); + + const reset = await SELF.fetch(`${origin}/api/auth/reset-password`, { + body: JSON.stringify({ + newPassword: "personal-access-token-password-next", + token: resetToken + }), + headers: { "content-type": "application/json", origin }, + method: "POST" + }); + expect(reset.status, await reset.clone().text()).toBe(200); + await expect( + patFetch(actors.admin.bearer, "/api/v1/messages/msg_pat_allowed") + ).resolves.toMatchObject({ status: 200 }); + }); + + it("keeps current send validation for PAT callers", async () => { + const response = await patFetch(actors.owner.bearer, "/api/v1/send", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: { code: "VALIDATION_ERROR" } }); + }); + + it("shares send and reply limits by user across PAT, session, and OAuth", async () => { + await env.DB.prepare( + "DELETE FROM rate_limits WHERE scope IN ('mail.send', 'mail.reply')" + ).run(); + for (const path of ["/api/v1/send", "/api/v1/reply"]) { + for (let attempt = 0; attempt < 20; attempt += 1) { + expect( + (await invalidAction(path, { authorization: `Bearer ${actors.owner.bearer}` })).status + ).toBe(400); + expect((await invalidAction(path, { cookie: actors.owner.cookie })).status).toBe(400); + expect((await invalidAction(path, { authorization: `Bearer ${oauthBearer}` })).status).toBe( + 400 + ); + } + const limited = await invalidAction(path, { + authorization: `Bearer ${actors.owner.bearer}` + }); + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toMatchObject({ error: { code: "RATE_LIMITED" } }); + } + }); + + it("does not fall back to a valid cookie when a PAT header is malformed", async () => { + const rejected = await SELF.fetch(`${origin}/api/v1/messages/msg_pat_allowed`, { + headers: { + authorization: "Bearer hqb_pat_", + cookie: actors.owner.cookie + } + }); + expect(rejected.status).toBe(401); + await expect(rejected.json()).resolves.toMatchObject({ + error: { code: "INVALID_PERSONAL_ACCESS_TOKEN" } + }); + + const accepted = await SELF.fetch(`${origin}/api/v1/messages/msg_pat_allowed`, { + headers: { cookie: actors.owner.cookie } + }); + expect(accepted.status).toBe(200); + }); + + it("selects PAT authentication only for bearer values with the PAT prefix", async () => { + const nonCanonical = `hqb_pat_${"_".repeat(43)}`; + for (const authorization of ["Bearer hqb_pat_", `Bearer ${nonCanonical}`]) { + const response = await authorizationFetch(authorization); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "INVALID_PERSONAL_ACCESS_TOKEN" } + }); + } + + const unknown = await generatePersonalAccessToken(); + const unknownResponse = await authorizationFetch(`Bearer ${unknown.token}`); + expect(unknownResponse.status).toBe(401); + await expect(unknownResponse.json()).resolves.toMatchObject({ + error: { code: "INVALID_PERSONAL_ACCESS_TOKEN" } + }); + + for (const authorization of [ + "Bearer hqb_access_unknown", + "Basic hqb_pat_example", + "Bearer", + "Bearer:hqb_pat_example" + ]) { + const response = await authorizationFetch(authorization); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "INVALID_OAUTH_TOKEN" } + }); + } + }); + + it("keeps selected D1 failures as internal errors", async () => { + await env.DB.prepare( + "ALTER TABLE personal_access_tokens RENAME TO personal_access_tokens_unavailable" + ).run(); + try { + const response = await patFetch(actors.owner.bearer, "/api/v1/messages/msg_pat_allowed"); + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ error: { code: "INTERNAL_ERROR" } }); + } finally { + await env.DB.prepare( + "ALTER TABLE personal_access_tokens_unavailable RENAME TO personal_access_tokens" + ).run(); + } + }); + + it("returns one generic challenge for malformed, unknown, and revoked PATs", async () => { + const unknown = await generatePersonalAccessToken(); + const malformed = "hqb_pat_"; + const cases = [ + { bearer: malformed, tokenHash: await hashPersonalAccessToken(malformed) }, + { bearer: unknown.token, tokenHash: unknown.tokenHash }, + { bearer: revokedBearer, tokenHash: revokedHash } + ]; + + for (const value of cases) { + const authorizationHeader = `Bearer ${value.bearer}`; + const rejected = await SELF.fetch(`${origin}/api/v1/send`, { + body: JSON.stringify({}), + headers: { authorization: authorizationHeader, "content-type": "application/json" }, + method: "POST" + }); + expect(rejected.status).toBe(401); + const body = (await rejected.json()) as Record; + const challenge = rejected.headers.get("www-authenticate") ?? ""; + assertSecretSafeAbsent( + [body, challenge], + [value.bearer, value.tokenHash, authorizationHeader] + ); + const error = body.error as Record | undefined; + expect( + Object.keys(body).length === 1 && + error !== undefined && + Object.keys(error).length === 2 && + error.code === "INVALID_PERSONAL_ACCESS_TOKEN" && + error.message === "Bearer token is invalid or inactive." + ).toBe(true); + expect( + challenge.includes( + `resource_metadata="${origin}/.well-known/oauth-protected-resource/api/v1"` + ) && + challenge.includes('scope="mail:send"') && + challenge.includes('error="invalid_token"') + ).toBe(true); + } + }); +}); + +async function createActor(role: WorkspaceRole): Promise { + const email = `pat-${role}@example.com`; + const signUp = await createAuth(env, new Request(`${origin}/api/auth/sign-up/email`)).handler( + new Request(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email, name: `PAT ${role}`, password, rememberMe: false }), + headers: { "content-type": "application/json", origin }, + method: "POST" + }) + ); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const cookie = extractSessionCookie(signUp); + const user = await env.DB.prepare( + `SELECT u.id, s.id AS sessionId FROM "user" u + JOIN "session" s ON s.userId = u.id + WHERE u.email = ? ORDER BY s.createdAt DESC LIMIT 1` + ) + .bind(email) + .first<{ id: string; sessionId: string }>(); + if (!user) throw new Error("Expected a PAT authentication user."); + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?').bind(role, user.id).run(); + + const generated = await generatePersonalAccessToken(); + const tokenId = `pat_http_${role}`; + await insertPat(tokenId, user.id, generated.tokenHash, generated.tokenSuffix, null); + return { + id: user.id, + sessionId: user.sessionId, + cookie, + role, + bearer: generated.token, + tokenId, + tokenHash: generated.tokenHash + }; +} + +async function insertPat( + id: string, + userId: string, + tokenHash: string, + tokenSuffix: string, + revokedAt: string | null +): Promise { + const timestamp = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO personal_access_tokens + (id, user_id, name, token_hash, token_suffix, created_at, expires_at, revoked_at) + VALUES (?, ?, 'Mail API PAT', ?, ?, ?, NULL, ?)` + ) + .bind(id, userId, tokenHash, tokenSuffix, timestamp, revokedAt) + .run(); +} + +async function createMailFixtures(): Promise { + const timestamp = new Date().toISOString(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO mailboxes (id, address, display_name, is_active, created_at, updated_at) + VALUES + ('mbx_pat_allowed', 'allowed@pat.example', 'Allowed', 1, ?, ?), + ('mbx_pat_foreign', 'foreign@pat.example', 'Foreign', 1, ?, ?)` + ).bind(timestamp, timestamp, timestamp, timestamp), + env.DB.prepare( + `INSERT INTO mail_domains + (id, name, receiving_status, sending_status, dns_status, is_enabled, created_at, updated_at) + VALUES ('dom_pat', 'pat.example', 'ready', 'ready', 'ready', 1, ?, ?)` + ).bind(timestamp, timestamp), + env.DB.prepare( + `INSERT INTO mailbox_addresses + (id, mailbox_id, mail_domain_id, local_part, address, display_name, + receive_enabled, send_enabled, is_primary, created_at, updated_at) + VALUES + ('addr_pat_allowed', 'mbx_pat_allowed', 'dom_pat', 'allowed', 'allowed@pat.example', + 'Allowed', 1, 1, 1, ?, ?), + ('addr_pat_foreign', 'mbx_pat_foreign', 'dom_pat', 'foreign', 'foreign@pat.example', + 'Foreign', 1, 1, 1, ?, ?)` + ).bind(timestamp, timestamp, timestamp, timestamp), + env.DB.prepare( + `INSERT INTO threads (id, subject_normalized, last_message_at, created_at, updated_at) + VALUES + ('thr_pat_allowed', 'allowed', ?, ?, ?), + ('thr_pat_foreign', 'foreign', ?, ?, ?), + ('thr_pat_unassigned', 'unassigned', ?, ?, ?)` + ).bind( + timestamp, + timestamp, + timestamp, + timestamp, + timestamp, + timestamp, + timestamp, + timestamp, + timestamp + ), + messageRow("msg_pat_allowed", "thr_pat_allowed", "mbx_pat_allowed", 0, timestamp), + messageRow("msg_pat_foreign", "thr_pat_foreign", "mbx_pat_foreign", 0, timestamp), + messageRow("msg_pat_unassigned", "thr_pat_unassigned", null, 1, timestamp) + ]); + await insertGrant(actors.admin.id); + await insertGrant(actors.member.id); +} + +function messageRow( + id: string, + threadId: string, + mailboxId: string | null, + isUnassigned: 0 | 1, + timestamp: string +): D1PreparedStatement { + return env.DB.prepare( + `INSERT INTO messages + (id, thread_id, mailbox_id, is_unassigned, direction, folder, from_address, + to_json, cc_json, bcc_json, subject, snippet, text_body, message_id, dedupe_key, + in_reply_to, references_json, received_at, sent_at, read_at, has_attachments, + created_at, updated_at) + VALUES (?, ?, ?, ?, 'inbound', 'inbox', 'sender@example.net', '[]', '[]', '[]', + ?, 'Body', 'Body', ?, ?, NULL, '[]', ?, NULL, NULL, 0, ?, ?)` + ).bind( + id, + threadId, + mailboxId, + isUnassigned, + id, + `<${id}@example.net>`, + `dedupe-${id}`, + timestamp, + timestamp, + timestamp + ); +} + +async function insertGrant(userId: string): Promise { + const timestamp = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO mailbox_grants + (mailbox_id, user_id, access_level, created_by, created_at, updated_at) + VALUES ('mbx_pat_allowed', ?, 'agent', ?, ?, ?)` + ) + .bind(userId, actors.owner.id, timestamp, timestamp) + .run(); +} + +async function createOAuthFixture(): Promise { + const timestamp = new Date().toISOString(); + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO oauthClient + (id, clientId, disabled, redirectUris, public, requirePKCE, createdAt, updatedAt) + VALUES ('client_row_pat_http', 'client_pat_http', 0, '[]', 1, 1, ?, ?)` + ).bind(timestamp, timestamp), + env.DB.prepare( + `INSERT INTO oauthConsent + (id, clientId, userId, scopes, resources, createdAt, updatedAt) + VALUES ('consent_pat_http', 'client_pat_http', ?, ?, ?, ?, ?)` + ).bind( + actors.owner.id, + JSON.stringify(["mail:read", "mail:write", "mail:send"]), + JSON.stringify([apiResource]), + timestamp, + timestamp + ), + await tokenRow( + env.DB, + "tok_pat_http", + oauthBearer, + "client_pat_http", + actors.owner.sessionId, + actors.owner.id, + future, + ["mail:read", "mail:write", "mail:send"], + apiResource + ) + ]); +} + +function patFetch(bearer: string, path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${bearer}`); + return SELF.fetch(`${origin}${path}`, { ...init, headers }); +} + +function authorizationFetch(authorization: string): Promise { + return SELF.fetch(`${origin}/api/v1/messages/msg_pat_allowed`, { + headers: { authorization, cookie: actors.owner.cookie } + }); +} + +function invalidAction(path: string, authentication: HeadersInit): Promise { + return SELF.fetch(`${origin}${path}`, { + body: JSON.stringify({}), + headers: { + ...Object.fromEntries(new Headers(authentication)), + "content-type": "application/json" + }, + method: "POST" + }); +} + +function extractSessionCookie(response: Response): string { + const serialized = response.headers.get("set-cookie") ?? ""; + const match = serialized.match(/(?:^|,\s*)((?:__Secure-)?better-auth\.session_token=[^;,]+)/); + if (!match?.[1]) throw new Error("Session cookie was not returned."); + return match[1]; +} diff --git a/worker/auth/mail-api.ts b/worker/auth/mail-api.ts index 99ec1bc8..de825213 100644 --- a/worker/auth/mail-api.ts +++ b/worker/auth/mail-api.ts @@ -1,8 +1,13 @@ import type { WorkerEnv } from "../lib/env"; import { AppError } from "../lib/errors"; +import type { WorkspaceRole } from "../lib/validation"; import { authOrigin, mailApiResource } from "./auth"; import { authenticateOAuthBearer, OAuthBearerError } from "./oauth-principal"; +import { + authenticatePersonalAccessToken, + PersonalAccessTokenError +} from "./personal-access-token-principal"; import { type AuthContext, requireAuthContext } from "./session"; export const mailApiScopes = ["mail:read", "mail:write", "mail:send"] as const; @@ -10,6 +15,14 @@ export type MailApiScope = (typeof mailApiScopes)[number]; export const mailApiMetadataPath = "/.well-known/oauth-protected-resource/api/v1"; const agentSkillPath = "/skills/hqbase-mail/SKILL.md"; +export type MailApiContext = { + authentication: + | { kind: "session"; id: string } + | { kind: "oauth"; clientId: string } + | { kind: "pat"; tokenId: string }; + user: { id: string; email: string; name: string; role: WorkspaceRole }; +}; + export class MailApiAuthError extends AppError { readonly authError: "invalid_token" | "insufficient_scope" | null; readonly requiredScope: MailApiScope; @@ -32,14 +45,14 @@ export async function requireMailApiContext( env: WorkerEnv, request: Request, requiredScope: MailApiScope -): Promise { +): Promise { if (!isVersionedMailApiRequest(request)) { - return requireAuthContext(env, request); + return sessionMailApiContext(await requireAuthContext(env, request)); } if (!request.headers.has("authorization")) { try { - return await requireAuthContext(env, request); + return sessionMailApiContext(await requireAuthContext(env, request)); } catch (error) { if (error instanceof AppError && error.status === 401) { throw new MailApiAuthError( @@ -54,6 +67,28 @@ export async function requireMailApiContext( } } + const bearer = readMailApiBearerValue(request); + if (bearer?.startsWith("hqb_pat_")) { + try { + const principal = await authenticatePersonalAccessToken(env.DB, bearer); + return { + authentication: { kind: "pat", tokenId: principal.tokenId }, + user: principal.user + }; + } catch (error) { + if (error instanceof PersonalAccessTokenError) { + throw new MailApiAuthError( + "INVALID_PERSONAL_ACCESS_TOKEN", + "Bearer token is invalid or inactive.", + 401, + requiredScope, + "invalid_token" + ); + } + throw error; + } + } + try { const principal = await authenticateOAuthBearer(request, env, { allowedScopes: mailApiScopes, @@ -68,7 +103,10 @@ export async function requireMailApiContext( "insufficient_scope" ); } - return { session: principal.session, user: principal.user }; + return { + authentication: { kind: "oauth", clientId: principal.clientId }, + user: principal.user + }; } catch (error) { if (error instanceof MailApiAuthError) throw error; if (error instanceof OAuthBearerError) { @@ -84,6 +122,11 @@ export async function requireMailApiContext( } } +export function readMailApiBearerValue(request: Request): string | null { + const authorization = request.headers.get("authorization"); + return authorization?.match(/^Bearer\s+(.+)$/iu)?.[1]?.trim() ?? null; +} + export function isVersionedMailApiRequest(request: Request): boolean { const pathname = new URL(request.url).pathname; return pathname === "/api/v1" || pathname.startsWith("/api/v1/"); @@ -123,3 +166,10 @@ export function handleMailApiMetadata(request: Request, env: WorkerEnv): Respons } ); } + +function sessionMailApiContext(auth: AuthContext): MailApiContext { + return { + authentication: { kind: "session", id: auth.session.id }, + user: auth.user + }; +} From c84d2d2161b885f2d2a4bbdafe499bfb563945bd Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:01:07 -0500 Subject: [PATCH 08/21] feat: audit personal access token use --- ...rsonal-access-token-authentication.test.ts | 81 +++++++++++-- ...ersonal-access-token-observability.test.ts | 99 +++++++++++++++ test/unit/worker/features/send/routes.test.ts | 114 +++++++++++++++++- test/unit/worker/observability/log.test.ts | 35 ++++++ worker/auth/mail-api.ts | 11 ++ worker/features/send/routes.ts | 10 +- worker/observability/log.ts | 12 +- 7 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 test/integration/worker/personal-access-token-observability.test.ts diff --git a/test/integration/worker/personal-access-token-authentication.test.ts b/test/integration/worker/personal-access-token-authentication.test.ts index fed9cb2e..871635d5 100644 --- a/test/integration/worker/personal-access-token-authentication.test.ts +++ b/test/integration/worker/personal-access-token-authentication.test.ts @@ -179,20 +179,66 @@ describe("Mail API personal access token authentication", () => { await expect(response.json()).resolves.toMatchObject({ error: { code: "VALIDATION_ERROR" } }); }); + it("attributes successful PAT send and reply audits without secrets or mail content", async () => { + const sendBody = JSON.stringify({ + from: "allowed@pat.example", + to: ["reader@example.net"], + subject: "pat-send-content-marker", + text: "pat-send-body-marker" + }); + const authorization = `Bearer ${actors.owner.bearer}`; + const sentResponse = await SELF.fetch(`${origin}/api/v1/send`, { + body: sendBody, + headers: { authorization, "content-type": "application/json" }, + method: "POST" + }); + expect(sentResponse.status, await sentResponse.clone().text()).toBe(201); + const sentBody = JSON.stringify(await sentResponse.json()); + await assertPatAudit("message.send", [ + actors.owner.bearer, + actors.owner.tokenHash, + authorization, + sendBody, + sentBody, + "pat-send-content-marker", + "pat-send-body-marker" + ]); + + const replyBody = JSON.stringify({ + from: "allowed@pat.example", + messageId: "msg_pat_allowed", + text: "pat-reply-content-marker" + }); + const replyResponse = await SELF.fetch(`${origin}/api/v1/reply`, { + body: replyBody, + headers: { authorization, "content-type": "application/json" }, + method: "POST" + }); + expect(replyResponse.status, await replyResponse.clone().text()).toBe(201); + const responseBody = JSON.stringify(await replyResponse.json()); + await assertPatAudit("message.reply", [ + actors.owner.bearer, + actors.owner.tokenHash, + authorization, + replyBody, + responseBody, + "pat-reply-content-marker" + ]); + }); + it("shares send and reply limits by user across PAT, session, and OAuth", async () => { await env.DB.prepare( "DELETE FROM rate_limits WHERE scope IN ('mail.send', 'mail.reply')" ).run(); for (const path of ["/api/v1/send", "/api/v1/reply"]) { - for (let attempt = 0; attempt < 20; attempt += 1) { - expect( - (await invalidAction(path, { authorization: `Bearer ${actors.owner.bearer}` })).status - ).toBe(400); - expect((await invalidAction(path, { cookie: actors.owner.cookie })).status).toBe(400); - expect((await invalidAction(path, { authorization: `Bearer ${oauthBearer}` })).status).toBe( - 400 - ); - } + const responses = await Promise.all( + Array.from({ length: 20 }, () => [ + invalidAction(path, { authorization: `Bearer ${actors.owner.bearer}` }), + invalidAction(path, { cookie: actors.owner.cookie }), + invalidAction(path, { authorization: `Bearer ${oauthBearer}` }) + ]).flat() + ); + expect(responses.map(({ status }) => status)).toEqual(Array(60).fill(400)); const limited = await invalidAction(path, { authorization: `Bearer ${actors.owner.bearer}` }); @@ -511,3 +557,20 @@ function extractSessionCookie(response: Response): string { if (!match?.[1]) throw new Error("Session cookie was not returned."); return match[1]; } + +async function assertPatAudit(action: string, forbidden: readonly string[]): Promise { + const row = await env.DB.prepare( + `SELECT metadata_json AS metadataJson FROM audit_events + WHERE action = ? ORDER BY occurred_at DESC, id DESC LIMIT 1` + ) + .bind(action) + .first<{ metadataJson: string }>(); + if (!row) throw new Error("Expected a PAT-backed audit row."); + const metadata = JSON.parse(row.metadataJson) as Record; + expect(metadata).toEqual({ + authenticationKind: "pat", + personalAccessTokenId: actors.owner.tokenId + }); + expect(Object.keys(metadata)).toEqual(["authenticationKind", "personalAccessTokenId"]); + assertSecretSafeAbsent(row.metadataJson, forbidden); +} diff --git a/test/integration/worker/personal-access-token-observability.test.ts b/test/integration/worker/personal-access-token-observability.test.ts new file mode 100644 index 00000000..a34038da --- /dev/null +++ b/test/integration/worker/personal-access-token-observability.test.ts @@ -0,0 +1,99 @@ +import { env, SELF } from "cloudflare:test"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createAuth } from "../../../worker/auth/auth"; +import { assertSecretSafeAbsent } from "../../helpers/secret-safe-assertions"; +import { applyCurrentMigrations } from "./current-migrations"; + +const origin = "https://hqbase.test"; +let cookie = ""; + +describe("personal access token observability", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + const signUp = await createAuth(env, new Request(`${origin}/api/auth/sign-up/email`)).handler( + new Request(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ + email: "pat-observability@example.com", + name: "PAT Observability", + password: "pat-observability-password", + rememberMe: false + }), + headers: { "content-type": "application/json", origin }, + method: "POST" + }) + ); + expect(signUp.status, await signUp.clone().text()).toBe(200); + cookie = extractSessionCookie(signUp); + const user = await env.DB.prepare('SELECT id FROM "user" WHERE email = ?') + .bind("pat-observability@example.com") + .first<{ id: string }>(); + if (!user) throw new Error("Expected the observability user."); + await env.DB.prepare('UPDATE "user" SET role = ? WHERE id = ?').bind("owner", user.id).run(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps PAT lifecycle and authentication logs free of secrets and bodies", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const createRequestBody = JSON.stringify({ name: "Observability PAT", expiresAt: null }); + + const created = await SELF.fetch(`${origin}/api/personal-access-tokens`, { + body: createRequestBody, + headers: { "content-type": "application/json", cookie, origin }, + method: "POST" + }); + expect(created.status).toBe(201); + const createdBody = (await created.json()) as { + personalAccessToken: { id: string }; + token: string; + }; + expect(/^hqb_pat_[A-Za-z0-9_-]{43}$/u.test(createdBody.token)).toBe(true); + const serializedCreateResponse = JSON.stringify(createdBody); + const stored = await env.DB.prepare( + "SELECT token_hash AS tokenHash FROM personal_access_tokens WHERE id = ?" + ) + .bind(createdBody.personalAccessToken.id) + .first<{ tokenHash: string }>(); + if (!stored) throw new Error("Expected the observability PAT row."); + const authorization = `Bearer ${createdBody.token}`; + + const accepted = await SELF.fetch(`${origin}/api/v1/mailboxes`, { + headers: { authorization } + }); + expect(accepted.status).toBe(200); + const revoked = await SELF.fetch( + `${origin}/api/personal-access-tokens/${createdBody.personalAccessToken.id}`, + { headers: { cookie }, method: "DELETE" } + ); + expect(revoked.status).toBe(204); + const rejected = await SELF.fetch(`${origin}/api/v1/mailboxes`, { + headers: { authorization } + }); + expect(rejected.status).toBe(401); + + assertSecretSafeAbsent( + [log.mock.calls, info.mock.calls, warn.mock.calls, error.mock.calls], + [ + createdBody.token, + stored.tokenHash, + authorization, + createRequestBody, + serializedCreateResponse, + "synthetic-mail-content-marker" + ] + ); + }); +}); + +function extractSessionCookie(response: Response): string { + const serialized = response.headers.get("set-cookie") ?? ""; + const match = serialized.match(/(?:^|,\s*)((?:__Secure-)?better-auth\.session_token=[^;,]+)/); + if (!match?.[1]) throw new Error("Session cookie was not returned."); + return match[1]; +} diff --git a/test/unit/worker/features/send/routes.test.ts b/test/unit/worker/features/send/routes.test.ts index 0a1e5ecf..75eec234 100644 --- a/test/unit/worker/features/send/routes.test.ts +++ b/test/unit/worker/features/send/routes.test.ts @@ -9,10 +9,13 @@ const mocks = vi.hoisted(() => ({ requireDraftIdAccess: vi.fn(), requireMailApiContext: vi.fn(), requireMailboxAccess: vi.fn(), + requireMessageAccess: vi.fn(), + replyToMessage: vi.fn(), sendNewMessage: vi.fn() })); -vi.mock("@worker/auth/mail-api", () => ({ +vi.mock("@worker/auth/mail-api", async (importOriginal) => ({ + ...(await importOriginal()), requireMailApiContext: mocks.requireMailApiContext })); vi.mock("@worker/auth/mailbox-access", () => ({ @@ -31,8 +34,11 @@ vi.mock("@worker/features/drafts/access", () => ({ vi.mock("@worker/features/mailboxes/queries", () => ({ findMailboxForSending: mocks.findMailboxForSending })); +vi.mock("@worker/features/messages/access", () => ({ + requireMessageAccess: mocks.requireMessageAccess +})); vi.mock("@worker/features/send/service", () => ({ - replyToMessage: vi.fn(), + replyToMessage: mocks.replyToMessage, sendNewMessage: mocks.sendNewMessage })); @@ -42,9 +48,12 @@ describe("send routes", () => { beforeEach(() => { vi.clearAllMocks(); mocks.requireMailApiContext.mockResolvedValue({ + authentication: { kind: "session", id: "session-1" }, user: { id: "user-1", role: "member" } }); mocks.findMailboxForSending.mockResolvedValue({ id: "mailbox-1" }); + mocks.requireMessageAccess.mockResolvedValue("agent"); + mocks.replyToMessage.mockResolvedValue({ id: "sent-reply-1" }); mocks.sendNewMessage.mockResolvedValue({ id: "sent-message-1" }); }); @@ -83,4 +92,105 @@ describe("send routes", () => { ); expect(mocks.sendNewMessage).toHaveBeenCalledOnce(); }); + + it("adds PAT attribution only to a PAT-backed send audit", async () => { + mocks.requireMailApiContext.mockResolvedValue({ + authentication: { kind: "pat", tokenId: "pat_send_audit" }, + user: { id: "user-1", role: "member" } + }); + + const response = await requestSend(); + + expect(response.status).toBe(201); + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "message.send", + metadata: { + authenticationKind: "pat", + personalAccessTokenId: "pat_send_audit" + } + }) + ); + }); + + it("adds PAT attribution only to a PAT-backed reply audit", async () => { + mocks.requireMailApiContext.mockResolvedValue({ + authentication: { kind: "pat", tokenId: "pat_reply_audit" }, + user: { id: "user-1", role: "member" } + }); + + const response = await requestReply(); + + expect(response.status).toBe(201); + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "message.reply", + metadata: { + authenticationKind: "pat", + personalAccessTokenId: "pat_reply_audit" + } + }) + ); + }); + + it.each([ + ["session", { kind: "session", id: "session-1" }], + ["OAuth", { kind: "oauth", clientId: "client-1" }] + ] as const)("keeps %s send audit metadata unchanged", async (_label, authentication) => { + mocks.requireMailApiContext.mockResolvedValue({ + authentication, + user: { id: "user-1", role: "member" } + }); + + const response = await requestSend(); + + expect(response.status).toBe(201); + const audit = mocks.recordAudit.mock.calls[0]?.[1]; + expect(audit).not.toHaveProperty("metadata"); + }); }); + +function requestSend(): Promise { + return Promise.resolve( + sendRoutes.request( + "/send", + { + body: JSON.stringify({ + from: "sender@example.com", + to: ["reader@example.com"], + subject: "Audit send", + text: "Audit send body" + }), + headers: { "content-type": "application/json" }, + method: "POST" + }, + { + BETTER_AUTH_SECRET: "test-secret", + DB: {} as D1Database + } as WorkerEnv + ) + ); +} + +function requestReply(): Promise { + return Promise.resolve( + sendRoutes.request( + "/reply", + { + body: JSON.stringify({ + from: "sender@example.com", + messageId: "message-1", + text: "Audit reply body" + }), + headers: { "content-type": "application/json" }, + method: "POST" + }, + { + BETTER_AUTH_SECRET: "test-secret", + DB: {} as D1Database + } as WorkerEnv + ) + ); +} diff --git a/test/unit/worker/observability/log.test.ts b/test/unit/worker/observability/log.test.ts index 3c24f407..a98d6b1e 100644 --- a/test/unit/worker/observability/log.test.ts +++ b/test/unit/worker/observability/log.test.ts @@ -23,4 +23,39 @@ describe("operational logging", () => { ])("rejects the sensitive field %s", (key) => { expect(() => operationalLog("info", "unsafe", { [key]: "value" })).toThrow("Sensitive"); }); + + it.each([ + "tokenHash", + "token_hash", + "ACCESS-TOKEN", + "refresh_token", + "Authorization", + "Authorization-Header", + "request_body", + "responseBody" + ])("rejects the normalized sensitive field %s before logging", (key) => { + const output = vi.spyOn(console, "info").mockImplementation(() => undefined); + try { + expect(() => operationalLog("info", "unsafe", { [key]: "value" })).toThrow("Sensitive"); + expect(output).not.toHaveBeenCalled(); + } finally { + output.mockRestore(); + } + }); + + it("allows safe PAT attribution and count fields", () => { + const output = vi.spyOn(console, "info").mockImplementation(() => undefined); + try { + operationalLog("info", "pat_used", { + personalAccessTokenId: "pat_safe_id", + requestCount: 2 + }); + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ + personalAccessTokenId: "pat_safe_id", + requestCount: 2 + }); + } finally { + output.mockRestore(); + } + }); }); diff --git a/worker/auth/mail-api.ts b/worker/auth/mail-api.ts index de825213..fc870a30 100644 --- a/worker/auth/mail-api.ts +++ b/worker/auth/mail-api.ts @@ -127,6 +127,17 @@ export function readMailApiBearerValue(request: Request): string | null { return authorization?.match(/^Bearer\s+(.+)$/iu)?.[1]?.trim() ?? null; } +export function mailApiAuditMetadata( + context: MailApiContext +): { authenticationKind: "pat"; personalAccessTokenId: string } | undefined { + return context.authentication.kind === "pat" + ? { + authenticationKind: "pat", + personalAccessTokenId: context.authentication.tokenId + } + : undefined; +} + export function isVersionedMailApiRequest(request: Request): boolean { const pathname = new URL(request.url).pathname; return pathname === "/api/v1" || pathname.startsWith("/api/v1/"); diff --git a/worker/features/send/routes.ts b/worker/features/send/routes.ts index 25e9be52..1a6e52d4 100644 --- a/worker/features/send/routes.ts +++ b/worker/features/send/routes.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { requireMailApiContext } from "../../auth/mail-api"; +import { mailApiAuditMetadata, requireMailApiContext } from "../../auth/mail-api"; import { requireMailboxAccess } from "../../auth/mailbox-access"; import type { HonoApp } from "../../lib/env"; import { AppError } from "../../lib/errors"; @@ -39,6 +39,7 @@ sendRoutes.post("/send", async (c) => { await requireDraftIdAccess(c.env, principal, input.draftId); await requireDraftAttachmentIdsAccess(c.env, principal, input.attachmentIds); const sent = await sendNewMessage(c.env, input, authContext.user.id); + const metadata = mailApiAuditMetadata(authContext); await recordAudit(c.env.DB, { correlationId: c.get("correlationId"), actorType: "user", @@ -46,7 +47,8 @@ sendRoutes.post("/send", async (c) => { action: "message.send", resourceType: "mailbox", resourceId: mailbox.id, - outcome: "success" + outcome: "success", + ...(metadata ? { metadata } : {}) }); return c.json(sent, 201); }); @@ -80,6 +82,7 @@ sendRoutes.post("/reply", async (c) => { await requireDraftIdAccess(c.env, principal, input.draftId); await requireDraftAttachmentIdsAccess(c.env, principal, input.attachmentIds); const sent = await replyToMessage(c.env, input, authContext.user.id); + const metadata = mailApiAuditMetadata(authContext); await recordAudit(c.env.DB, { correlationId: c.get("correlationId"), actorType: "user", @@ -87,7 +90,8 @@ sendRoutes.post("/reply", async (c) => { action: "message.reply", resourceType: "mailbox", resourceId: mailbox.id, - outcome: "success" + outcome: "success", + ...(metadata ? { metadata } : {}) }); return c.json(sent, 201); }); diff --git a/worker/observability/log.ts b/worker/observability/log.ts index 2d285964..e7002d62 100644 --- a/worker/observability/log.ts +++ b/worker/observability/log.ts @@ -11,7 +11,14 @@ const forbiddenKeys = new Set([ "recipient", "secret", "subject", - "token" + "token", + "tokenhash", + "accesstoken", + "refreshtoken", + "authorization", + "authorizationheader", + "requestbody", + "responsebody" ]); export type LogFields = Record; @@ -22,7 +29,8 @@ export function operationalLog( fields: LogFields = {} ): void { for (const key of Object.keys(fields)) { - if (forbiddenKeys.has(key.toLowerCase())) { + const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/gu, ""); + if (forbiddenKeys.has(normalizedKey)) { throw new Error(`Sensitive operational log field rejected: ${key}`); } } From dd6b795e6a055de914ca3249b77ef5e046546f99 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:05:46 -0500 Subject: [PATCH 09/21] test: lock personal access tokens to the Mail API --- scripts/check-architecture.mjs | 10 ++ ...rsonal-access-token-route-boundary.test.ts | 110 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 test/integration/worker/personal-access-token-route-boundary.test.ts diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 41766340..34f24484 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -6,6 +6,11 @@ const hardLimit = 400; const reviewLimit = 300; const failures = []; const warnings = []; +const patPrincipalReference = /["'][^"'\n]*personal-access-token-principal(?:\.[^"'\n]*)?["']/u; +const patPrincipalReferenceFiles = new Set([ + path.normalize("worker/auth/mail-api.ts"), + path.join("worker", "auth", ["personal-access-token", "principal.ts"].join("-")) +]); for (const root of roots) { for (const file of await sourceFiles(root)) { @@ -27,6 +32,11 @@ for (const root of roots) { if (file.startsWith(`worker${path.sep}`) && /from\s+["']node:/.test(contents)) { failures.push(`${file}: Worker code must prefer Web Platform APIs over Node built-ins`); } + if (patPrincipalReference.test(contents) && !patPrincipalReferenceFiles.has(file)) { + failures.push( + `${file}: only worker/auth/mail-api.ts may reference personal-access-token-principal` + ); + } } } diff --git a/test/integration/worker/personal-access-token-route-boundary.test.ts b/test/integration/worker/personal-access-token-route-boundary.test.ts new file mode 100644 index 00000000..f4eb59e7 --- /dev/null +++ b/test/integration/worker/personal-access-token-route-boundary.test.ts @@ -0,0 +1,110 @@ +import { env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { createPersonalAccessToken } from "../../../worker/features/personal-access-tokens/service"; +import { applyCurrentMigrations } from "./current-migrations"; + +const origin = "https://hqbase.test"; +let bearer = ""; + +describe("personal access token route boundary", () => { + beforeAll(async () => { + await applyCurrentMigrations(); + const timestamp = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO "user" + (id, name, email, emailVerified, createdAt, updatedAt, role, banned) + VALUES ('usr_pat_boundary', 'PAT Boundary', 'pat-boundary@example.com', 1, ?, ?, 'owner', 0)` + ) + .bind(timestamp, timestamp) + .run(); + const created = await createPersonalAccessToken(env.DB, { + userId: "usr_pat_boundary", + correlationId: "request_pat_boundary", + name: "Boundary probe", + expiresAt: null + }); + bearer = created.token; + }); + + it.each([ + ["users", "/api/users"], + ["PAT management", "/api/personal-access-tokens"] + ])("does not authenticate private %s routes", async (_label, path) => { + const response = await patFetch(path); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: { code: "UNAUTHENTICATED" } }); + }); + + it("does not authenticate the legacy mail alias", async () => { + const response = await patFetch("/api/send", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: { code: "UNAUTHENTICATED" } }); + }); + + it("does not become a Cloudflare setup grant", async () => { + const response = await patFetch("/api/setup/cloudflare/zones", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "CLOUDFLARE_ACCESS_REQUIRED" } + }); + }); + + it("does not create a Better Auth session", async () => { + const response = await patFetch("/api/auth/get-session"); + expect(response.status).toBe(200); + const body = (await response.json()) as unknown; + const authenticated = + typeof body === "object" && + body !== null && + (Object.hasOwn(body, "session") || Object.hasOwn(body, "user")); + expect(authenticated).toBe(false); + }); + + it.each([ + ["read-only", "/mcp", "/.well-known/oauth-protected-resource/mcp", "mail:read"], + [ + "full", + "/mcp/full", + "/.well-known/oauth-protected-resource/mcp/full", + "mail:read mail:write mail:send" + ] + ])("does not authenticate the %s MCP profile", async (_label, path, metadataPath, scope) => { + const response = await patFetch(path, { + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "HQBase PAT boundary", version: "1.0.0" } + } + }), + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2025-11-25", + origin + }, + method: "POST" + }); + expect(response.status).toBe(401); + const challenge = response.headers.get("www-authenticate") ?? ""; + expect(challenge).toContain(`resource_metadata="${origin}${metadataPath}"`); + expect(challenge).toContain(`scope="${scope}"`); + }); +}); + +function patFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${bearer}`); + return SELF.fetch(`${origin}${path}`, { ...init, headers }); +} From 1d26dc706a2316ec903e3c9691913fdfefb0727e Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:14:17 -0500 Subject: [PATCH 10/21] docs: publish personal access token authentication --- api/hqbase-mail-api-v1.openapi.json | 270 +++++++----------- ...hqbase-mail-api-v1.postman_collection.json | 4 +- scripts/generate-mail-api-artifacts.mjs | 4 +- test/helpers/pat-artifact-safety.ts | 56 ++++ test/integration/worker/mail-api.test.ts | 32 +++ test/unit/scripts/mail-api-artifacts.test.mjs | 86 ++++++ worker/features/mail-api/discovery.ts | 14 +- 7 files changed, 291 insertions(+), 175 deletions(-) create mode 100644 test/helpers/pat-artifact-safety.ts diff --git a/api/hqbase-mail-api-v1.openapi.json b/api/hqbase-mail-api-v1.openapi.json index e57bbbb2..1c59fe76 100644 --- a/api/hqbase-mail-api-v1.openapi.json +++ b/api/hqbase-mail-api-v1.openapi.json @@ -46,6 +46,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -73,14 +76,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -127,6 +123,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -151,14 +150,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope", @@ -228,6 +220,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -264,14 +259,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -371,6 +359,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -395,14 +386,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -460,6 +444,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -487,14 +474,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -552,6 +532,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -576,14 +559,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -651,6 +627,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -676,14 +655,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -761,6 +733,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -786,14 +761,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -851,6 +819,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -875,14 +846,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -940,6 +904,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -964,14 +931,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1047,6 +1007,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1071,14 +1034,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1167,6 +1123,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1191,14 +1150,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1288,6 +1240,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1315,14 +1270,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1366,6 +1314,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1390,14 +1341,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1465,6 +1409,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1489,14 +1436,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1552,6 +1492,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1576,14 +1519,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1672,6 +1608,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1689,14 +1628,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1755,6 +1687,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1779,14 +1714,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1877,6 +1805,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1894,14 +1825,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -1969,6 +1893,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -1993,14 +1920,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -2066,6 +1986,9 @@ }, { "cookieSession": [] + }, + { + "personalAccessToken": [] } ], "responses": { @@ -2090,14 +2013,7 @@ } }, "401": { - "description": "Missing or invalid authentication", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "$ref": "#/components/responses/MailApiUnauthorized" }, "403": { "description": "Insufficient OAuth scope or mailbox access", @@ -2276,6 +2192,24 @@ "in": "cookie", "name": "better-auth.session_token", "description": "Same-origin HQBase web session. Secure deployments may prefix the cookie name." + }, + "personalAccessToken": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "HQBase PAT", + "description": "An HQBase personal access token. PATs can call every Mail API operation, subject to the token owner's current role and mailbox grants." + } + }, + "responses": { + "MailApiUnauthorized": { + "description": "Authentication is missing or invalid. Missing credentials return UNAUTHENTICATED with the message A session cookie or bearer token is required. Bearer values that start with hqb_pat_ use personal access token authentication; other bearer values use OAuth. An invalid Authorization header does not fall back to a session cookie. A rejected OAuth token returns INVALID_OAUTH_TOKEN, and a rejected PAT returns INVALID_PERSONAL_ACCESS_TOKEN. Both invalid bearer errors use the message Bearer token is invalid or inactive.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "schemas": { diff --git a/api/hqbase-mail-api-v1.postman_collection.json b/api/hqbase-mail-api-v1.postman_collection.json index 9b394e71..43c69b18 100644 --- a/api/hqbase-mail-api-v1.postman_collection.json +++ b/api/hqbase-mail-api-v1.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "62c6dbf4-835d-4a3f-87df-77b7ddcf2db1", "name": "HQBase Mail API v1", - "description": "Generated from api/hqbase-mail-api-v1.openapi.json. Set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting token only in your local environment as access_token. Sending, replying, and forwarding are not idempotent.", + "description": "Generated from api/hqbase-mail-api-v1.openapi.json. The access_token variable can hold an OAuth access token or an HQBase personal access token (PAT). For OAuth, set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting OAuth token or PAT only in your local environment as access_token. Sending, replying, and forwarding are not idempotent.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "auth": { @@ -34,7 +34,7 @@ { "key": "access_token", "value": "", - "type": "string" + "type": "secret" }, { "key": "id", diff --git a/scripts/generate-mail-api-artifacts.mjs b/scripts/generate-mail-api-artifacts.mjs index b880a538..ecbd8d7b 100644 --- a/scripts/generate-mail-api-artifacts.mjs +++ b/scripts/generate-mail-api-artifacts.mjs @@ -54,7 +54,7 @@ function buildCollection(document) { _postman_id: "62c6dbf4-835d-4a3f-87df-77b7ddcf2db1", name: "HQBase Mail API v1", description: - "Generated from api/hqbase-mail-api-v1.openapi.json. Set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting token only in your local environment as access_token. Sending, replying, and forwarding are not idempotent.", + "Generated from api/hqbase-mail-api-v1.openapi.json. The access_token variable can hold an OAuth access token or an HQBase personal access token (PAT). For OAuth, set base_url, run Register public client, and use Postman's OAuth 2.0 Authorization Code flow with PKCE (S256). Auth URL: {{base_url}}/api/auth/oauth2/authorize. Token URL: {{base_url}}/api/auth/oauth2/token. Client ID: {{client_id}}. Scope: mail:read mail:write mail:send offline_access. Add authorization request parameter resource={{api_resource}}, then store the resulting OAuth token or PAT only in your local environment as access_token. Sending, replying, and forwarding are not idempotent.", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, auth: { @@ -65,7 +65,7 @@ function buildCollection(document) { { key: "base_url", value: "https://mail.example.com", type: "string" }, { key: "api_resource", value: "{{base_url}}/api/v1", type: "string" }, { key: "client_id", value: "", type: "string" }, - { key: "access_token", value: "", type: "string" }, + { key: "access_token", value: "", type: "secret" }, { key: "id", value: "msg_example", type: "string" }, { key: "attachmentId", value: "att_example", type: "string" }, { key: "draftId", value: "drf_example", type: "string" }, diff --git a/test/helpers/pat-artifact-safety.ts b/test/helpers/pat-artifact-safety.ts new file mode 100644 index 00000000..250a707c --- /dev/null +++ b/test/helpers/pat-artifact-safety.ts @@ -0,0 +1,56 @@ +const canonicalPersonalAccessToken = /hqb_pat_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/u; + +export function assertPatArtifactSecretSafe(value: unknown): void { + inspectValue(value); +} + +function inspectValue(value: unknown): void { + if (typeof value === "string") { + rejectCanonicalToken(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) inspectValue(item); + return; + } + if (typeof value !== "object" || value === null) return; + + const record = value as Record; + if ( + normalize(record.key) === "accesstoken" && + typeof record.value === "string" && + record.value.length > 0 + ) { + reject(); + } + for (const [key, fieldValue] of Object.entries(record)) { + rejectCanonicalToken(key); + const normalizedKey = normalize(key); + if ( + (normalizedKey === "tokenhash" || normalizedKey === "accesstoken") && + isPopulated(fieldValue) + ) { + reject(); + } + inspectValue(fieldValue); + } +} + +function rejectCanonicalToken(value: string): void { + if (canonicalPersonalAccessToken.test(value)) reject(); +} + +function normalize(value: unknown): string { + return typeof value === "string" ? value.toLowerCase().replace(/[^a-z0-9]/gu, "") : ""; +} + +function isPopulated(value: unknown): boolean { + if (value === null || value === undefined || value === "") return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +function reject(): never { + throw new Error("PAT artifact contains sensitive credential material."); +} diff --git a/test/integration/worker/mail-api.test.ts b/test/integration/worker/mail-api.test.ts index b46adafa..569cb687 100644 --- a/test/integration/worker/mail-api.test.ts +++ b/test/integration/worker/mail-api.test.ts @@ -275,6 +275,14 @@ describe("HQBase Mail API v1", () => { expect(instructions).toContain("`application_type` set to `native`"); expect(instructions).toContain("RFC 8252"); expect(instructions).toContain("app-claimed HTTPS, loopback HTTP, and private-use schemes"); + expect(instructions).toContain("INVALID_PERSONAL_ACCESS_TOKEN"); + expect(instructions).toContain("Bearer token is invalid or inactive."); + expect(instructions).toContain("start with `hqb_pat_`"); + expect(instructions).toContain("does not fall back to a browser session cookie"); + expect(instructions).toContain("trusted automation"); + expect(instructions).toContain("valid only at the issuing origin"); + expect(instructions).toContain("does not authenticate administration or MCP"); + expect(instructions).toContain("Do not retry the same rejected PAT"); for (const [path, pathItem] of Object.entries(mailApiOpenApi.paths)) { for (const method of ["get", "post", "patch", "delete"] as const) { if (method in pathItem) { @@ -287,11 +295,35 @@ describe("HQBase Mail API v1", () => { expect(openApi.status).toBe(200); expect(openApi.headers.get("content-type")).toContain("application/json"); const document = (await openApi.json()) as { + components: { + responses: { MailApiUnauthorized: { description: string } }; + securitySchemes: { personalAccessToken: Record }; + }; externalDocs: { url: string }; + paths: Record< + string, + Record; security: unknown }> + >; servers: Array<{ url: string }>; }; expect(document.servers).toEqual([{ url: origin, description: "This HQBase installation" }]); expect(document.externalDocs.url).toBe(`${origin}/skills/hqbase-mail/SKILL.md`); + expect(document.components.securitySchemes.personalAccessToken).toMatchObject({ + type: "http", + scheme: "bearer", + bearerFormat: "HQBase PAT" + }); + expect(document.paths["/api/v1/send"]?.post?.security).toEqual([ + { oauth2: ["mail:send"] }, + { cookieSession: [] }, + { personalAccessToken: [] } + ]); + expect(document.paths["/api/v1/send"]?.post?.responses["401"]).toEqual({ + $ref: "#/components/responses/MailApiUnauthorized" + }); + expect(document.components.responses.MailApiUnauthorized.description).toContain( + "INVALID_PERSONAL_ACCESS_TOKEN" + ); const head = await SELF.fetch(`${origin}/skills/hqbase-mail/SKILL.md`, { method: "HEAD" }); expect(head.status).toBe(200); diff --git a/test/unit/scripts/mail-api-artifacts.test.mjs b/test/unit/scripts/mail-api-artifacts.test.mjs index e10f34b0..d4f204c2 100644 --- a/test/unit/scripts/mail-api-artifacts.test.mjs +++ b/test/unit/scripts/mail-api-artifacts.test.mjs @@ -1,10 +1,39 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; +import { assertPatArtifactSecretSafe } from "../../helpers/pat-artifact-safety"; const openApi = JSON.parse(await readFile("api/hqbase-mail-api-v1.openapi.json", "utf8")); const postman = JSON.parse( await readFile("api/hqbase-mail-api-v1.postman_collection.json", "utf8") ); +const postmanEnvironment = JSON.parse( + await readFile("api/hqbase-mail-api-v1.postman_environment.json", "utf8") +); + +const expectedOauthScopes = { + "/api/v1/mailboxes": { get: ["mail:read"] }, + "/api/v1/changes": { get: ["mail:read"] }, + "/api/v1/messages": { get: ["mail:read"] }, + "/api/v1/messages/{id}": { get: ["mail:read"] }, + "/api/v1/messages/{id}/thread": { get: ["mail:read"] }, + "/api/v1/messages/{id}/html": { get: ["mail:read"] }, + "/api/v1/messages/{id}/inline/{attachmentId}": { get: ["mail:read"] }, + "/api/v1/attachments/{id}": { get: ["mail:read"] }, + "/api/v1/messages/{id}/remote-media/trust": { post: ["mail:write"] }, + "/api/v1/messages/{id}/{action}": { post: ["mail:write"] }, + "/api/v1/conversations": { get: ["mail:read"] }, + "/api/v1/conversations/{id}/{action}": { post: ["mail:write"] }, + "/api/v1/drafts": { get: ["mail:send"], post: ["mail:send"] }, + "/api/v1/drafts/{id}": { + get: ["mail:send"], + patch: ["mail:send"], + delete: ["mail:send"] + }, + "/api/v1/drafts/{id}/attachments": { post: ["mail:send"] }, + "/api/v1/drafts/{draftId}/attachments/{id}": { delete: ["mail:send"] }, + "/api/v1/send": { post: ["mail:send"] }, + "/api/v1/reply": { post: ["mail:send"] } +}; describe("Mail API public artifacts", () => { it("publishes the versioned endpoint and scope matrix without internal storage keys", () => { @@ -43,6 +72,42 @@ describe("Mail API public artifacts", () => { expect(JSON.stringify(openApi)).not.toContain("r2Key"); }); + it("adds PAT authentication without changing any OAuth or cookie requirement", () => { + for (const [path, methods] of Object.entries(expectedOauthScopes)) { + for (const [method, scopes] of Object.entries(methods)) { + const operation = openApi.paths[path][method]; + expect(operation.security).toEqual([ + { oauth2: scopes }, + { cookieSession: [] }, + { personalAccessToken: [] } + ]); + expect(operation.responses["401"]).toEqual({ + $ref: "#/components/responses/MailApiUnauthorized" + }); + } + } + expect(openApi.components.securitySchemes.personalAccessToken).toEqual({ + type: "http", + scheme: "bearer", + bearerFormat: "HQBase PAT", + description: + "An HQBase personal access token. PATs can call every Mail API operation, subject to the token owner's current role and mailbox grants." + }); + }); + + it("documents stable Mail API authentication errors and dispatch", () => { + const unauthorized = openApi.components.responses.MailApiUnauthorized; + expect(unauthorized.description).toContain( + "UNAUTHENTICATED with the message A session cookie or bearer token is required." + ); + expect(unauthorized.description).toContain( + "INVALID_OAUTH_TOKEN, and a rejected PAT returns INVALID_PERSONAL_ACCESS_TOKEN" + ); + expect(unauthorized.description).toContain("Bearer token is invalid or inactive."); + expect(unauthorized.description).toContain("start with hqb_pat_"); + expect(unauthorized.description).toContain("does not fall back to a session cookie"); + }); + it("generates human-testable OAuth setup and every OpenAPI operation", () => { const serialized = JSON.stringify(postman); expect(serialized).toContain("/.well-known/oauth-protected-resource/api/v1"); @@ -60,4 +125,25 @@ describe("Mail API public artifacts", () => { } } }); + + it("keeps checked-in API artifacts free of PATs, hashes, and populated access tokens", () => { + assertPatArtifactSecretSafe(openApi); + assertPatArtifactSecretSafe(postman); + assertPatArtifactSecretSafe(postmanEnvironment); + + const accessToken = postmanEnvironment.values.find( + (variable) => variable.key === "access_token" + ); + expect(accessToken).toMatchObject({ key: "access_token", value: "", type: "secret" }); + }); + + it.each([ + ["complete PAT", { example: `hqb_pat_${"A".repeat(43)}` }], + ["token hash", { token_hash: "synthetic-hash" }], + ["populated access token", { key: "access_token", value: "synthetic-access-token" }] + ])("rejects synthetic artifact credential material: %s", (_label, value) => { + expect(() => assertPatArtifactSecretSafe(value)).toThrow( + "PAT artifact contains sensitive credential material." + ); + }); }); diff --git a/worker/features/mail-api/discovery.ts b/worker/features/mail-api/discovery.ts index 9404b6d8..6623465f 100644 --- a/worker/features/mail-api/discovery.ts +++ b/worker/features/mail-api/discovery.ts @@ -13,7 +13,7 @@ const apiMethods = ["get", "post", "patch", "delete"] as const; type ApiMethod = (typeof apiMethods)[number]; type OpenApiOperation = { - security?: Array<{ oauth2?: string[] }>; + security?: Array<{ oauth2?: string[]; personalAccessToken?: never[] }>; summary?: string; tags?: string[]; }; @@ -98,7 +98,9 @@ The OpenAPI document is authoritative for query parameters, request bodies, resp ## Authentication -External agents must use an OAuth bearer token. Do not copy or reuse an HQBase browser session cookie. +External agents can use OAuth Device Authorization for delegated or interactive connections, or a personal access token (PAT) for trusted automation when a user intentionally provides it. Do not copy or reuse an HQBase browser session cookie. + +### OAuth Device Authorization 1. Fetch the OAuth protected-resource metadata. 2. Fetch the advertised authorization-server metadata. @@ -115,6 +117,12 @@ Native desktop and mobile clients that use Authorization Code with PKCE must reg Use this exact OAuth resource and token audience: \`${apiBase}\`. MCP uses separate audiences at \`${origin}/mcp\` and \`${origin}/mcp/full\`; an MCP token cannot be used with the Mail API. +### Personal access token + +Use a PAT only for trusted automation when a user intentionally provides one. Send it as \`Authorization: Bearer \`. Bearer values that start with \`hqb_pat_\` use PAT authentication; other bearer values use OAuth. An invalid Authorization header does not fall back to a browser session cookie. + +A PAT has every Mail API capability, subject to its owner's current role and mailbox grants. It does not use OAuth scopes. A PAT is valid only at the issuing origin, does not authenticate administration or MCP, and must never be logged or sent to another origin, service, chat, or tool. + ## Permissions - \`mail:read\` — List visible mailboxes and conversations, search and open messages, render message HTML, and download attachments. @@ -147,7 +155,7 @@ The method index is an orientation aid. Consult ${openApiUrl} for exact paramete ## Errors -JSON errors contain a stable \`error.code\` and human-readable \`error.message\`. A missing or invalid token returns \`401\`; insufficient OAuth scope or mailbox access returns \`403\`. Responses include \`X-Request-Id\`. Retain that identifier when reporting a failure, but never include credentials or private mail content. +JSON errors contain a stable \`error.code\` and human-readable \`error.message\`. A missing or invalid token returns \`401\`; insufficient OAuth scope or mailbox access returns \`403\`. \`INVALID_PERSONAL_ACCESS_TOKEN\` with the message \`Bearer token is invalid or inactive.\` means the PAT is inactive. Do not retry the same rejected PAT. Obtain a new PAT or ask the user to restore the existing PAT. Responses include \`X-Request-Id\`. Retain that identifier when reporting a failure, but never include credentials or private mail content. ## API boundary and stability From 69a93a59301cc14febe60d82d82b1c2a6aafc7b3 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:16:59 -0500 Subject: [PATCH 11/21] fix: bypass API requests in the service worker --- scripts/build-pwa.mjs | 4 +++- scripts/test-pwa.mjs | 22 +++++++++++++++++++++- test/unit/scripts/pwa-build.test.mjs | 6 +++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs index d670efd8..d4d8615b 100644 --- a/scripts/build-pwa.mjs +++ b/scripts/build-pwa.mjs @@ -128,7 +128,9 @@ self.addEventListener("notificationclick", (event) => { self.addEventListener("fetch", (event) => { const request = event.request; const url = new URL(request.url); - if (request.method !== "GET" || url.origin !== self.location.origin) return; + if (url.origin !== self.location.origin) return; + if (url.pathname === "/api" || url.pathname.startsWith("/api/")) return; + if (request.method !== "GET") return; if (request.mode === "navigate") { event.respondWith( diff --git a/scripts/test-pwa.mjs b/scripts/test-pwa.mjs index 3103453a..1b575a5a 100644 --- a/scripts/test-pwa.mjs +++ b/scripts/test-pwa.mjs @@ -44,6 +44,19 @@ try { const { installabilityErrors } = await client.send("Page.getInstallabilityErrors"); assert.deepEqual(installabilityErrors, []); + const apiHealthUrl = `${baseUrl}/api/health`; + await context.route(apiHealthUrl, (route) => + route.fulfill({ + body: JSON.stringify({ ok: true }), + contentType: "application/json", + status: 200 + }) + ); + const onlineApiResponse = await page.goto(apiHealthUrl, { waitUntil: "domcontentloaded" }); + assert.equal(onlineApiResponse?.fromServiceWorker(), false); + assert.deepEqual(await onlineApiResponse?.json(), { ok: true }); + await context.unroute(apiHealthUrl); + const cacheState = await page.evaluate(async () => { const names = await caches.keys(); const urls = []; @@ -58,10 +71,17 @@ try { assert.ok(cacheState.urls.includes("/sounds/incoming-email.wav")); assert.ok(cacheState.urls.includes("/sounds/unlock.wav")); assert.equal( - cacheState.urls.some((url) => url.startsWith("/api/")), + cacheState.urls.some((url) => url === "/api" || url.startsWith("/api/")), false ); + await context.setOffline(true); + await assert.rejects(page.goto(apiHealthUrl, { waitUntil: "domcontentloaded" })); + assert.equal(await page.getByRole("heading", { name: "You're offline" }).count(), 0); + assert.equal(await page.locator("#root").count(), 0); + + await context.setOffline(false); + await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); await context.setOffline(true); const offlineResponse = await page.reload({ waitUntil: "domcontentloaded" }); assert.equal(offlineResponse?.fromServiceWorker(), true); diff --git a/test/unit/scripts/pwa-build.test.mjs b/test/unit/scripts/pwa-build.test.mjs index 8e6c3f13..c8f06c09 100644 --- a/test/unit/scripts/pwa-build.test.mjs +++ b/test/unit/scripts/pwa-build.test.mjs @@ -84,11 +84,15 @@ describe("PWA build contract", () => { cacheName: "hqbase-pwa-test-1", precacheUrls: ["/assets/app-abc.js", "/offline.html"] }); + const apiBypass = worker.indexOf( + 'if (url.pathname === "/api" || url.pathname.startsWith("/api/")) return;' + ); + expect(apiBypass).toBeGreaterThan(-1); + expect(apiBypass).toBeLessThan(worker.indexOf('request.mode === "navigate"')); expect(worker).toContain('request.mode === "navigate"'); expect(worker).toContain('caches.match("/offline.html")'); expect(worker).toContain('badge: "/icons/notification-badge.png"'); expect(worker).toContain('event.data?.type === "SKIP_WAITING"'); - expect(worker).not.toContain("/api/"); }); it("generates visible push notifications, unread badging, and safe message navigation", () => { From d018302b8f85b095f291e87cb9faa0452f86ac35 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:23:39 -0500 Subject: [PATCH 12/21] refactor: share recent authentication UI --- app/features/auth/api.ts | 2 + .../recent-authentication-api.ts} | 0 app/features/auth/recent-authentication.tsx | 211 ++++++++++++++++++ app/features/auth/sign-out-lifecycle.ts | 5 + .../cloudflare-authorization-dialog.tsx | 206 ++--------------- .../app/auth/recent-authentication.test.tsx | 145 ++++++++++++ test/unit/app/auth/sign-out-lifecycle.test.ts | 51 +++++ .../cloudflare-authorization-dialog.test.tsx | 75 +++++-- 8 files changed, 493 insertions(+), 202 deletions(-) rename app/features/{settings/cloudflare-authorization-api.ts => auth/recent-authentication-api.ts} (100%) create mode 100644 app/features/auth/recent-authentication.tsx create mode 100644 app/features/auth/sign-out-lifecycle.ts create mode 100644 test/unit/app/auth/recent-authentication.test.tsx create mode 100644 test/unit/app/auth/sign-out-lifecycle.test.ts diff --git a/app/features/auth/api.ts b/app/features/auth/api.ts index ea45994c..705998ae 100644 --- a/app/features/auth/api.ts +++ b/app/features/auth/api.ts @@ -1,5 +1,6 @@ import { disableCurrentDeviceNotificationsBeforeSignOut } from "@/features/notifications/sign-out"; import { apiGet, apiPatch, apiPost } from "@/lib/api-client"; +import { notifySignOutStarted } from "./sign-out-lifecycle"; import type { CurrentUser } from "./types"; export async function getCurrentUser(): Promise { @@ -35,6 +36,7 @@ export async function signIn(email: string, password: string): Promise { + notifySignOutStarted(); const cleanup = disableCurrentDeviceNotificationsBeforeSignOut().catch(() => {}); await Promise.race([cleanup, new Promise((resolve) => setTimeout(resolve, 1500))]); try { diff --git a/app/features/settings/cloudflare-authorization-api.ts b/app/features/auth/recent-authentication-api.ts similarity index 100% rename from app/features/settings/cloudflare-authorization-api.ts rename to app/features/auth/recent-authentication-api.ts diff --git a/app/features/auth/recent-authentication.tsx b/app/features/auth/recent-authentication.tsx new file mode 100644 index 00000000..a223ab62 --- /dev/null +++ b/app/features/auth/recent-authentication.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { getRecentAuthentication, reauthenticate } from "./recent-authentication-api"; + +export type RecentAuthenticationGateProps = { + active: boolean; + description: string; + layout: "dialog" | "inline"; + ready: React.ReactNode; + onAuthenticated?: () => void | Promise; +}; + +export function RecentAuthenticationGate({ + active, + description, + layout, + ready, + onAuthenticated +}: RecentAuthenticationGateProps): React.ReactElement { + const [authentication, setAuthentication] = React.useState<"checking" | "recent" | "stale">( + "checking" + ); + const [password, setPassword] = React.useState(""); + const [error, setError] = React.useState(null); + const [pending, setPending] = React.useState(false); + + React.useEffect(() => { + if (!active) return; + let cancelled = false; + setAuthentication("checking"); + setPassword(""); + setError(null); + setPending(false); + void getRecentAuthentication() + .then((recent) => { + if (!cancelled) setAuthentication(recent ? "recent" : "stale"); + }) + .catch((nextError: unknown) => { + if (cancelled) return; + setAuthentication("stale"); + setError( + nextError instanceof Error ? nextError.message : "Your sign-in could not be confirmed." + ); + }); + return () => { + cancelled = true; + }; + }, [active]); + + async function confirmPassword(event: React.FormEvent) { + event.preventDefault(); + setPending(true); + setError(null); + try { + await reauthenticate(password); + setAuthentication("recent"); + await onAuthenticated?.(); + } catch (nextError) { + setAuthentication("stale"); + setError(nextError instanceof Error ? nextError.message : "Sign-in confirmation failed."); + setPending(false); + } + } + + if (authentication === "recent") return <>{ready}; + if (authentication === "checking") { + return ; + } + return ( + void confirmPassword(event)} + /> + ); +} + +function ReauthenticationForm({ + description, + error, + layout, + password, + pending, + onPasswordChange, + onSubmit +}: { + description: string; + error: string | null; + layout: "dialog" | "inline"; + password: string; + pending: boolean; + onPasswordChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; +}): React.ReactElement { + const guidance = "Confirm your HQBase password to continue."; + return ( + <> + {layout === "dialog" ? ( + + Sign in again + + {guidance} {description} + + + ) : ( +
+

{guidance}

+

{description}

+
+ )} +
+ + {error ? ( +

+ {error} +

+ ) : null} + {layout === "dialog" ? ( + + + + + + + ) : ( + + )} + + + ); +} + +function SubmitButton({ + className, + pending +}: { + className?: string; + pending: boolean; +}): React.ReactElement { + return ( + + ); +} + +function AuthenticationChecking({ + description, + layout +}: { + description: string; + layout: "dialog" | "inline"; +}): React.ReactElement { + if (layout === "inline") { + return ( +
+

{description}

+ +
+ ); + } + return ( + <> + + Confirm sign-in + {description} + + + + + + + + + ); +} diff --git a/app/features/auth/sign-out-lifecycle.ts b/app/features/auth/sign-out-lifecycle.ts new file mode 100644 index 00000000..88e5a181 --- /dev/null +++ b/app/features/auth/sign-out-lifecycle.ts @@ -0,0 +1,5 @@ +export const signOutStartedEvent = "hqbase:sign-out-started"; + +export function notifySignOutStarted(): void { + window.dispatchEvent(new Event(signOutStartedEvent)); +} diff --git a/app/features/settings/cloudflare-authorization-dialog.tsx b/app/features/settings/cloudflare-authorization-dialog.tsx index f937dcb2..50efa734 100644 --- a/app/features/settings/cloudflare-authorization-dialog.tsx +++ b/app/features/settings/cloudflare-authorization-dialog.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -9,8 +9,7 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { getRecentAuthentication, reauthenticate } from "./cloudflare-authorization-api"; +import { RecentAuthenticationGate } from "@/features/auth/recent-authentication"; export function CloudflareAuthorizationDialog({ authorizeHref, @@ -53,78 +52,30 @@ export function CloudflareAuthorizationFlow({ layout: "dialog" | "inline"; onAuthorize?: () => void; }): React.ReactElement { - const [authentication, setAuthentication] = React.useState<"checking" | "recent" | "stale">( - "checking" - ); - const [password, setPassword] = React.useState(""); - const [error, setError] = React.useState(null); - const [pending, setPending] = React.useState(false); - - React.useEffect(() => { - if (!active) return; - let cancelled = false; - setAuthentication("checking"); - setPassword(""); - setError(null); - void getRecentAuthentication() - .then((recent) => { - if (!cancelled) setAuthentication(recent ? "recent" : "stale"); - }) - .catch((nextError: unknown) => { - if (cancelled) return; - setAuthentication("stale"); - setError( - nextError instanceof Error ? nextError.message : "Your sign-in could not be confirmed." - ); - }); - return () => { - cancelled = true; - }; - }, [active]); - - async function confirmPassword(event: React.FormEvent) { - event.preventDefault(); - setPending(true); - setError(null); - try { - await reauthenticate(password); - onAuthorize?.(); - window.location.assign(authorizeHref); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : "Sign-in confirmation failed."); - setPending(false); - } - } - - if (authentication === "recent") { - return layout === "dialog" ? ( - - ) : ( - - ); - } - - if (authentication === "checking") { - return ; - } - return ( - void confirmPassword(event)} + ready={ + layout === "dialog" ? ( + + ) : ( + + ) + } + onAuthenticated={() => { + onAuthorize?.(); + window.location.assign(authorizeHref); + }} /> ); } @@ -160,117 +111,6 @@ export function CloudflareAuthorizationDialogBody({ ); } -export function CloudflareReauthenticationForm({ - description, - error, - layout, - password, - pending, - onPasswordChange, - onSubmit -}: { - description: string; - error: string | null; - layout: "dialog" | "inline"; - password: string; - pending: boolean; - onPasswordChange: (value: string) => void; - onSubmit: (event: React.FormEvent) => void; -}): React.ReactElement { - return ( - <> - {layout === "dialog" ? ( - - Sign in again - - Confirm your HQBase password before authorizing Cloudflare. {description} - - - ) : ( -

- Confirm your HQBase password before authorizing Cloudflare. -

- )} -
- - {error ? ( -

- {error} -

- ) : null} - {layout === "dialog" ? ( - - - - - - - ) : ( - - )} -
- - ); -} - -function AuthorizationChecking({ - description, - layout -}: { - description: string; - layout: "dialog" | "inline"; -}): React.ReactElement { - if (layout === "inline") { - return ( -
-

{description}

- -
- ); - } - return ( - <> - - Authorize Cloudflare - {description} - - - - - - - - - ); -} - function InlineAuthorization({ authorizeHref, description, diff --git a/test/unit/app/auth/recent-authentication.test.tsx b/test/unit/app/auth/recent-authentication.test.tsx new file mode 100644 index 00000000..6d6ac75f --- /dev/null +++ b/test/unit/app/auth/recent-authentication.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getRecentAuthentication: vi.fn(), + reauthenticate: vi.fn() +})); + +vi.mock("@/features/auth/recent-authentication-api", () => ({ + getRecentAuthentication: mocks.getRecentAuthentication, + reauthenticate: mocks.reauthenticate +})); + +import { RecentAuthenticationGate } from "@/features/auth/recent-authentication"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("RecentAuthenticationGate", () => { + it("shows checking, recent, and stale states", async () => { + const checking = deferred(); + mocks.getRecentAuthentication.mockReturnValueOnce(checking.promise); + const checkingView = await renderGate(); + expect(checkingView.container.textContent).toContain("Checking sign-in…"); + checking.resolve(true); + await flushHookEffects(); + expect(checkingView.container.querySelector("[data-ready]")).not.toBeNull(); + await checkingView.unmount(); + + mocks.getRecentAuthentication.mockResolvedValueOnce(false); + const staleView = await renderGate(); + await flushHookEffects(); + expect(staleView.container.textContent).toContain("Confirm your HQBase password to continue."); + expect(staleView.container.querySelector('input[type="password"]')).not.toBeNull(); + await staleView.unmount(); + }); + + it("shows a wrong-password error", async () => { + mocks.getRecentAuthentication.mockResolvedValue(false); + mocks.reauthenticate.mockRejectedValue(new Error("Password is incorrect.")); + const view = await renderGate(); + await flushHookEffects(); + + await submitPassword(view.container, "wrong-password"); + + expect(mocks.reauthenticate).toHaveBeenCalledWith("wrong-password"); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + "Password is incorrect." + ); + expect(view.container.querySelector("[data-ready]")).toBeNull(); + await view.unmount(); + }); + + it("marks the gate ready and calls onAuthenticated once after a successful password", async () => { + const onAuthenticated = vi.fn(); + mocks.getRecentAuthentication.mockResolvedValue(false); + mocks.reauthenticate.mockResolvedValue(undefined); + const view = await renderGate(onAuthenticated); + await flushHookEffects(); + + await submitPassword(view.container, "correct-password"); + + expect(mocks.reauthenticate).toHaveBeenCalledWith("correct-password"); + expect(view.container.querySelector("[data-ready]")).not.toBeNull(); + expect(onAuthenticated).toHaveBeenCalledOnce(); + await flushHookEffects(); + expect(onAuthenticated).toHaveBeenCalledOnce(); + await view.unmount(); + }); + + it("cancels an old check and checks again when reactivated", async () => { + const firstCheck = deferred(); + mocks.getRecentAuthentication + .mockReturnValueOnce(firstCheck.promise) + .mockResolvedValueOnce(true); + let setActive: React.Dispatch> = () => undefined; + + function Harness(): React.ReactElement { + const [active, updateActive] = React.useState(true); + setActive = updateActive; + return ( + Ready} + /> + ); + } + + const view = await renderComponent(); + await flushHookEffects(() => setActive(false)); + firstCheck.resolve(true); + await flushHookEffects(); + expect(view.container.querySelector("[data-ready]")).toBeNull(); + + await flushHookEffects(() => setActive(true)); + expect(mocks.getRecentAuthentication).toHaveBeenCalledTimes(2); + expect(view.container.querySelector("[data-ready]")).not.toBeNull(); + await view.unmount(); + }); +}); + +async function renderGate(onAuthenticated?: () => void) { + return renderComponent( + Ready} + {...(onAuthenticated ? { onAuthenticated } : {})} + /> + ); +} + +async function submitPassword(container: HTMLElement, password: string): Promise { + const input = container.querySelector('input[type="password"]'); + const form = container.querySelector("form"); + if (!input || !form) throw new Error("The recent-authentication form is missing."); + await flushHookEffects(() => { + setInputValue(input, password); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushHookEffects(() => form.dispatchEvent(new Event("submit", { bubbles: true }))); +} + +function setInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!setter) throw new Error("The input value setter is unavailable."); + setter.call(input, value); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} diff --git a/test/unit/app/auth/sign-out-lifecycle.test.ts b/test/unit/app/auth/sign-out-lifecycle.test.ts new file mode 100644 index 00000000..661edba6 --- /dev/null +++ b/test/unit/app/auth/sign-out-lifecycle.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + disableCurrentDeviceNotificationsBeforeSignOut: vi.fn() +})); + +vi.mock("@/features/notifications/sign-out", () => ({ + disableCurrentDeviceNotificationsBeforeSignOut: + mocks.disableCurrentDeviceNotificationsBeforeSignOut +})); + +import { signOut } from "@/features/auth/api"; +import { signOutStartedEvent } from "@/features/auth/sign-out-lifecycle"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("sign-out lifecycle", () => { + it("announces sign-out before notification cleanup and the delayed request", async () => { + const order: string[] = []; + const listener = () => order.push("event"); + window.addEventListener(signOutStartedEvent, listener); + mocks.disableCurrentDeviceNotificationsBeforeSignOut.mockImplementation(() => { + order.push("cleanup"); + return new Promise(() => undefined); + }); + const fetchMock = vi.fn(async () => { + order.push("fetch"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const pending = signOut(); + expect(order).toEqual(["event", "cleanup"]); + expect(fetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + await pending; + expect(order).toEqual(["event", "cleanup", "fetch"]); + + window.removeEventListener(signOutStartedEvent, listener); + }); +}); diff --git a/test/unit/app/settings/cloudflare-authorization-dialog.test.tsx b/test/unit/app/settings/cloudflare-authorization-dialog.test.tsx index 9ab988d4..9f26b182 100644 --- a/test/unit/app/settings/cloudflare-authorization-dialog.test.tsx +++ b/test/unit/app/settings/cloudflare-authorization-dialog.test.tsx @@ -1,10 +1,26 @@ +// @vitest-environment happy-dom import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Dialog } from "@/components/ui/dialog"; import { CloudflareAuthorizationDialogBody, - CloudflareReauthenticationForm + CloudflareAuthorizationFlow } from "@/features/settings/cloudflare-authorization-dialog"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + getRecentAuthentication: vi.fn(), + reauthenticate: vi.fn() +})); + +vi.mock("@/features/auth/recent-authentication-api", () => ({ + getRecentAuthentication: mocks.getRecentAuthentication, + reauthenticate: mocks.reauthenticate +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); describe("Cloudflare authorization dialog", () => { it("explains the handoff and keeps authorization inside the modal", () => { @@ -23,24 +39,45 @@ describe("Cloudflare authorization dialog", () => { expect(html).toContain('href="/api/domains/cloudflare/oauth/start"'); }); - it("keeps stale-session confirmation inside the modal", () => { - const html = renderToStaticMarkup( - - undefined} - onSubmit={() => undefined} - /> - + it("continues Cloudflare authorization in the successful stale-session transition", async () => { + const authorizeHref = "/api/domains/cloudflare/oauth/start"; + const onAuthorize = vi.fn(); + const assign = vi.spyOn(window.location, "assign").mockImplementation(() => undefined); + mocks.getRecentAuthentication.mockResolvedValue(false); + mocks.reauthenticate.mockResolvedValue(undefined); + const view = await renderComponent( + ); + await flushHookEffects(); + + const input = view.container.querySelector('input[type="password"]'); + const form = view.container.querySelector("form"); + if (!input || !form) throw new Error("The Cloudflare reauthentication form is missing."); + await flushHookEffects(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!setter) throw new Error("The input value setter is unavailable."); + setter.call(input, "correct-password"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushHookEffects(() => form.dispatchEvent(new Event("submit", { bubbles: true }))); - expect(html).toContain("Sign in again"); - expect(html).toContain("Password"); - expect(html).toContain("Sign in and continue"); - expect(html).not.toContain("/api/updates/cloudflare/oauth/start"); + expect(mocks.reauthenticate).toHaveBeenCalledWith("correct-password"); + expect(onAuthorize).toHaveBeenCalledOnce(); + expect(assign).toHaveBeenCalledOnce(); + expect(assign).toHaveBeenCalledWith(authorizeHref); + const onAuthorizeOrder = onAuthorize.mock.invocationCallOrder[0]; + const assignOrder = assign.mock.invocationCallOrder[0]; + if (onAuthorizeOrder === undefined || assignOrder === undefined) { + throw new Error("The Cloudflare authorization transition did not complete."); + } + expect(onAuthorizeOrder).toBeLessThan(assignOrder); + assign.mockRestore(); + await view.unmount(); }); }); From aefbfe3fa674f82f946ccb16f3456c3890cedffa Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:29:03 -0500 Subject: [PATCH 13/21] feat: list and revoke personal access tokens --- app/app.tsx | 1 + app/components/layout/sidebar/constants.ts | 3 + app/features/personal-access-tokens/api.ts | 20 +++ .../personal-access-token-settings.tsx | 117 ++++++++++++++++++ .../personal-access-token-table.tsx | 78 ++++++++++++ app/features/personal-access-tokens/types.ts | 13 ++ app/features/settings/settings-page.tsx | 6 +- app/lib/routes.ts | 1 + test/unit/app/mail-api-routes.test.ts | 10 ++ .../personal-access-token-settings.test.tsx | 107 ++++++++++++++++ .../settings/settings-presentation.test.tsx | 28 +++++ 11 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 app/features/personal-access-tokens/api.ts create mode 100644 app/features/personal-access-tokens/personal-access-token-settings.tsx create mode 100644 app/features/personal-access-tokens/personal-access-token-table.tsx create mode 100644 app/features/personal-access-tokens/types.ts create mode 100644 test/unit/app/settings/personal-access-token-settings.test.tsx diff --git a/app/app.tsx b/app/app.tsx index 7979b658..516dfe94 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -233,6 +233,7 @@ export function App(): React.ReactElement { defaultFromMailboxId={user.defaultFromMailboxId} mailboxes={mailboxes} notifications={mailSync.notifications} + userRole={user.role} setup={setup} users={users} onDefaultFromMailboxChange={(defaultFromMailboxId) => { diff --git a/app/components/layout/sidebar/constants.ts b/app/components/layout/sidebar/constants.ts index ebb4d093..f5f1b8ce 100644 --- a/app/components/layout/sidebar/constants.ts +++ b/app/components/layout/sidebar/constants.ts @@ -6,6 +6,7 @@ import { PiEnvelopeSimple, PiGear, PiGlobe, + PiKey, PiNotePencil, PiPalette, PiPaperPlaneTilt, @@ -51,6 +52,7 @@ export const settingsTabIcons: Record< domains: PiGlobe, notifications: PiBell, interface: PiPalette, + api: PiKey, mcp: PiPlug, updates: PiArrowsClockwise, debug: PiBug @@ -62,6 +64,7 @@ export const settingsTabLabels: Record = { domains: "Domains", notifications: "Notifications", interface: "Interface", + api: "API", mcp: "MCP", updates: "Updates", debug: "Debug" diff --git a/app/features/personal-access-tokens/api.ts b/app/features/personal-access-tokens/api.ts new file mode 100644 index 00000000..d80c52cf --- /dev/null +++ b/app/features/personal-access-tokens/api.ts @@ -0,0 +1,20 @@ +import type { PersonalAccessTokenList } from "./types"; + +export async function listPersonalAccessTokens(): Promise { + const response = await fetch("/api/personal-access-tokens", { + cache: "no-store", + credentials: "include", + method: "GET" + }); + if (!response.ok) throw new Error("Personal access tokens could not be loaded."); + return response.json(); +} + +export async function revokePersonalAccessToken(id: string): Promise { + const response = await fetch(`/api/personal-access-tokens/${encodeURIComponent(id)}`, { + cache: "no-store", + credentials: "include", + method: "DELETE" + }); + if (response.status !== 204) throw new Error("The personal access token could not be revoked."); +} diff --git a/app/features/personal-access-tokens/personal-access-token-settings.tsx b/app/features/personal-access-tokens/personal-access-token-settings.tsx new file mode 100644 index 00000000..56527905 --- /dev/null +++ b/app/features/personal-access-tokens/personal-access-token-settings.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { SettingsSection } from "@/features/settings/settings-section"; +import type { WorkspaceRole } from "@/features/users/types"; +import { listPersonalAccessTokens, revokePersonalAccessToken } from "./api"; +import { PersonalAccessTokenTable } from "./personal-access-token-table"; +import type { PersonalAccessTokenMetadata } from "./types"; + +export function PersonalAccessTokenSettings({ + userRole +}: { + userRole: WorkspaceRole; +}): React.ReactElement { + const [personalAccessTokens, setPersonalAccessTokens] = React.useState< + PersonalAccessTokenMetadata[] + >([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [revokeTarget, setRevokeTarget] = React.useState(null); + const [pendingId, setPendingId] = React.useState(null); + + const refresh = React.useCallback(async () => { + setLoading(true); + try { + const result = await listPersonalAccessTokens(); + setPersonalAccessTokens(result.personalAccessTokens); + setError(null); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Personal access tokens failed."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + async function revoke(): Promise { + if (!revokeTarget || pendingId !== null) return; + setPendingId(revokeTarget.id); + setError(null); + try { + await revokePersonalAccessToken(revokeTarget.id); + setRevokeTarget(null); + await refresh(); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Personal access token revocation failed." + ); + } finally { + setPendingId(null); + } + } + + return ( + +
+

+ Personal access tokens can call every Mail API operation, subject to the token owner's + current role and mailbox grants. +

+

Personal access tokens cannot access workspace administration or MCP.

+
+ {error ? ( + + {error} + + ) : null} + {loading && personalAccessTokens.length === 0 ? ( +

Loading personal access tokens…

+ ) : ( + + )} + { + if (!open && pendingId === null) setRevokeTarget(null); + }} + > + + + Revoke {revokeTarget?.name}? + + Active clients will fail on their next request. This action cannot be undone. + + + + + + + + + + +
+ ); +} diff --git a/app/features/personal-access-tokens/personal-access-token-table.tsx b/app/features/personal-access-tokens/personal-access-token-table.tsx new file mode 100644 index 00000000..2376e978 --- /dev/null +++ b/app/features/personal-access-tokens/personal-access-token-table.tsx @@ -0,0 +1,78 @@ +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import type { WorkspaceRole } from "@/features/users/types"; +import { formatDateTime } from "@/lib/format"; +import type { PersonalAccessTokenMetadata } from "./types"; + +export function PersonalAccessTokenTable({ + personalAccessTokens, + pendingId, + userRole, + onRevoke +}: { + personalAccessTokens: PersonalAccessTokenMetadata[]; + pendingId: string | null; + userRole: WorkspaceRole; + onRevoke: (token: PersonalAccessTokenMetadata) => void; +}): React.ReactElement { + const showOwner = userRole === "owner"; + return ( + + + + Name + {showOwner ? Owner : null} + Token + Created + Expires + Actions + + + + {personalAccessTokens.length === 0 ? ( + + + No active personal access tokens. + + + ) : null} + {personalAccessTokens.map((token) => ( + + {token.name} + {showOwner ? {token.ownerName} : null} + ••••{token.tokenSuffix} + + {formatDateTime(token.createdAt)} + + + {token.expiresAt ? formatDateTime(token.expiresAt) : "Never"} + + + + + + ))} + +
+ ); +} diff --git a/app/features/personal-access-tokens/types.ts b/app/features/personal-access-tokens/types.ts new file mode 100644 index 00000000..10adb108 --- /dev/null +++ b/app/features/personal-access-tokens/types.ts @@ -0,0 +1,13 @@ +export type PersonalAccessTokenMetadata = { + id: string; + userId: string; + ownerName: string; + name: string; + tokenSuffix: string; + createdAt: string; + expiresAt: string | null; +}; + +export type PersonalAccessTokenList = { + personalAccessTokens: PersonalAccessTokenMetadata[]; +}; diff --git a/app/features/settings/settings-page.tsx b/app/features/settings/settings-page.tsx index 6afd1174..59e63c3f 100644 --- a/app/features/settings/settings-page.tsx +++ b/app/features/settings/settings-page.tsx @@ -6,6 +6,7 @@ import type { Mailbox } from "@/features/mailboxes/types"; import { McpSettings } from "@/features/mcp/mcp-settings"; import { NotificationSettings } from "@/features/notifications/notification-settings"; import type { NotificationController } from "@/features/notifications/types"; +import { PersonalAccessTokenSettings } from "@/features/personal-access-tokens/personal-access-token-settings"; import { DebugSettings } from "@/features/settings/debug-settings"; import { InterfaceSettings } from "@/features/settings/interface-settings"; import { SettingsSection } from "@/features/settings/settings-section"; @@ -13,7 +14,7 @@ import type { SetupStatus } from "@/features/setup/types"; import type { UpdateStatus } from "@/features/updates/types"; import type { UpdateProgress } from "@/features/updates/update-progress"; import { UpdateSettings } from "@/features/updates/update-settings"; -import type { WorkspaceUser } from "@/features/users/types"; +import type { WorkspaceRole, WorkspaceUser } from "@/features/users/types"; import { UserSettings } from "@/features/users/user-settings"; import type { SettingsTabId } from "@/lib/routes"; @@ -24,6 +25,7 @@ type SettingsPageProps = { defaultFromMailboxId: string | null; mailboxes: Mailbox[]; notifications: NotificationController; + userRole: WorkspaceRole; setup: SetupStatus; users: WorkspaceUser[]; onDefaultFromMailboxChange: (mailboxId: string) => void; @@ -41,6 +43,7 @@ export function SettingsPage({ defaultFromMailboxId, mailboxes, notifications, + userRole, setup, users, onDefaultFromMailboxChange, @@ -81,6 +84,7 @@ export function SettingsPage({ ) : null} {activeTab === "interface" ? : null} + {activeTab === "api" ? : null} {activeTab === "mcp" ? : null} {activeTab === "updates" && canManage ? ( readFileSync(path, "utf8"); describe("web Mail API routing", () => { + it("routes every signed-in role to API settings", () => { + expect(settingsTabs).toContain("api"); + expect(readAppRoute("https://mail.example.com/settings/api")).toEqual({ + kind: "settings", + tab: "api" + }); + expect(appRoutePath({ kind: "settings", tab: "api" })).toBe("/settings/api"); + }); + it("uses the stable v1 API for mail while keeping mailbox administration internal", () => { const mailSources = [ "app/features/messages/api.ts", diff --git a/test/unit/app/settings/personal-access-token-settings.test.tsx b/test/unit/app/settings/personal-access-token-settings.test.tsx new file mode 100644 index 00000000..814c8436 --- /dev/null +++ b/test/unit/app/settings/personal-access-token-settings.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PersonalAccessTokenSettings } from "@/features/personal-access-tokens/personal-access-token-settings"; +import type { WorkspaceRole } from "@/features/users/types"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +const token = { + id: "pat_example", + userId: "user_owner", + ownerName: "Avery Stone", + name: "Deployment agent", + tokenSuffix: "Ab_9", + createdAt: "2026-08-20T12:00:00.000Z", + expiresAt: null +}; + +beforeEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +describe("personal access token settings", () => { + it.each([ + ["owner", true], + ["admin", false], + ["member", false] + ] as const)("loads one metadata list for a %s and applies owner-column rules", async (role, ownerColumn) => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ personalAccessTokens: [token] })); + vi.stubGlobal("fetch", fetchMock); + + const view = await renderSettings(role); + await flushHookEffects(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("/api/personal-access-tokens", { + cache: "no-store", + credentials: "include", + method: "GET" + }); + const headings = [...view.container.querySelectorAll("th")].map((cell) => cell.textContent); + expect(headings.includes("Owner")).toBe(ownerColumn); + expect(view.container.textContent).toContain( + "Personal access tokens can call every Mail API operation, subject to the token owner's current role and mailbox grants." + ); + expect(view.container.textContent).toContain( + "Personal access tokens cannot access workspace administration or MCP." + ); + expect(view.container.textContent).toContain("Deployment agent"); + expect(view.container.textContent).toContain("••••Ab_9"); + expect(view.container.textContent).not.toContain("tokenHash"); + expect(view.container.textContent).not.toContain("Revoked"); + expect(view.container.textContent).not.toContain("Next page"); + await view.unmount(); + }); + + it("names the token, revokes it once, and refreshes the active metadata list", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ personalAccessTokens: [token] })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse({ personalAccessTokens: [] })); + vi.stubGlobal("fetch", fetchMock); + const view = await renderSettings("owner"); + document.body.appendChild(view.container); + await flushHookEffects(); + + const revoke = view.container.querySelector( + '[aria-label="Revoke Deployment agent"]' + ); + await flushHookEffects(() => revoke?.click()); + expect(document.body.textContent).toContain("Revoke Deployment agent?"); + expect(document.body.textContent).toContain("Active clients will fail on their next request."); + + const confirm = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent === "Revoke token" + ); + await flushHookEffects(() => confirm?.click()); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/personal-access-tokens/pat_example", { + cache: "no-store", + credentials: "include", + method: "DELETE" + }); + expect(fetchMock.mock.calls.filter(([, options]) => options?.method === "DELETE")).toHaveLength( + 1 + ); + expect(view.container.textContent).not.toContain("Deployment agent"); + await view.unmount(); + }); +}); + +async function renderSettings(role: WorkspaceRole) { + return renderComponent(); +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + headers: { "content-type": "application/json" }, + status: 200 + }); +} diff --git a/test/unit/app/settings/settings-presentation.test.tsx b/test/unit/app/settings/settings-presentation.test.tsx index a7cf97a1..aabd453d 100644 --- a/test/unit/app/settings/settings-presentation.test.tsx +++ b/test/unit/app/settings/settings-presentation.test.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { SettingsNav } from "@/components/layout/sidebar/sidebar-nav"; import { DomainSettings } from "@/features/domains/domain-settings"; import { DomainTable } from "@/features/domains/domain-table"; import type { MailDomain } from "@/features/domains/types"; @@ -81,6 +82,32 @@ const notifications = { }; describe("settings presentation", () => { + it.each(["owner", "admin", "member"] as const)("shows API navigation to a %s", (role) => { + const user = { + id: `user-${role}`, + name: role, + email: `${role}@example.com`, + role, + passwordSetupRequired: false, + defaultFromMailboxId: null + }; + const html = renderToStaticMarkup( + undefined} + onSettingsTabChange={() => undefined} + onSignedOut={() => undefined} + /> + ); + + expect(html).toContain('href="/settings/api"'); + expect(html).toContain(">API<"); + }); + it("offers MCP and the deployment-local Agent Skill on the MCP page", () => { const html = renderToStaticMarkup( { defaultFromMailboxId={null} mailboxes={[]} notifications={notifications} + userRole="owner" setup={setup} updateStatus={null} users={[]} From 20b7ff12c9739d0bb3df1b6b2d2535e4c8515de0 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:39:05 -0500 Subject: [PATCH 14/21] feat: create personal access tokens safely --- app/features/personal-access-tokens/api.ts | 133 +++++++- .../create-personal-access-token-dialog.tsx | 160 +++++++++ app/features/personal-access-tokens/expiry.ts | 15 + .../one-time-token-dialog.tsx | 55 +++ .../personal-access-token-settings.tsx | 80 ++++- app/features/personal-access-tokens/types.ts | 10 + package.json | 2 + pnpm-lock.yaml | 118 +++++++ .../personal-access-token-expiry.test.ts | 35 ++ .../personal-access-token-lifecycle.test.tsx | 316 ++++++++++++++++++ .../personal-access-token-settings.test.tsx | 1 + 11 files changed, 922 insertions(+), 3 deletions(-) create mode 100644 app/features/personal-access-tokens/create-personal-access-token-dialog.tsx create mode 100644 app/features/personal-access-tokens/expiry.ts create mode 100644 app/features/personal-access-tokens/one-time-token-dialog.tsx create mode 100644 test/unit/app/settings/personal-access-token-expiry.test.ts create mode 100644 test/unit/app/settings/personal-access-token-lifecycle.test.tsx diff --git a/app/features/personal-access-tokens/api.ts b/app/features/personal-access-tokens/api.ts index d80c52cf..49bb00b5 100644 --- a/app/features/personal-access-tokens/api.ts +++ b/app/features/personal-access-tokens/api.ts @@ -1,4 +1,47 @@ -import type { PersonalAccessTokenList } from "./types"; +import type { + CreatePersonalAccessTokenInput, + CreatePersonalAccessTokenResponse, + PersonalAccessTokenList, + PersonalAccessTokenMetadata +} from "./types"; + +const ambiguousCreateMessage = + "Token creation might have completed. Refresh the list and revoke any token whose value you did not receive."; + +const createErrors = { + UNAUTHENTICATED: { status: 401, message: "Sign in again." }, + RECENT_AUTH_REQUIRED: { status: 403, message: "Confirm your password and try again." }, + INVALID_PERSONAL_ACCESS_TOKEN: { + status: 400, + message: "Check the token name and expiry." + }, + PERSONAL_ACCESS_TOKEN_LIMIT_REACHED: { + status: 409, + message: "Revoke an active personal access token before creating another." + }, + RATE_LIMITED: { + status: 429, + message: "Too many token creation attempts. Wait and try again." + } +} as const; + +export class AmbiguousPersonalAccessTokenCreateError extends Error { + constructor() { + super(ambiguousCreateMessage); + this.name = "AmbiguousPersonalAccessTokenCreateError"; + } +} + +export class PersonalAccessTokenApiError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string + ) { + super(message); + this.name = "PersonalAccessTokenApiError"; + } +} export async function listPersonalAccessTokens(): Promise { const response = await fetch("/api/personal-access-tokens", { @@ -18,3 +61,91 @@ export async function revokePersonalAccessToken(id: string): Promise { }); if (response.status !== 204) throw new Error("The personal access token could not be revoked."); } + +export async function createPersonalAccessToken( + input: CreatePersonalAccessTokenInput +): Promise { + let response: Response; + try { + response = await fetch("/api/personal-access-tokens", { + body: JSON.stringify(input), + cache: "no-store", + credentials: "include", + headers: { "content-type": "application/json" }, + method: "POST" + }); + } catch { + throw new AmbiguousPersonalAccessTokenCreateError(); + } + + if (response.status >= 500 && response.status <= 599) { + throw new AmbiguousPersonalAccessTokenCreateError(); + } + if (response.status >= 200 && response.status <= 299) { + let value: unknown; + try { + value = await response.json(); + return readCreateResponse(value); + } catch { + throw new AmbiguousPersonalAccessTokenCreateError(); + } + } + if (response.status >= 400 && response.status <= 499) { + let value: unknown; + try { + value = await response.json(); + } catch { + throw new AmbiguousPersonalAccessTokenCreateError(); + } + const code = readErrorCode(value); + if (code && code in createErrors) { + const knownError = createErrors[code as keyof typeof createErrors]; + if (knownError.status === response.status) { + throw new PersonalAccessTokenApiError(code, knownError.status, knownError.message); + } + } + } + throw new AmbiguousPersonalAccessTokenCreateError(); +} + +function readCreateResponse(value: unknown): CreatePersonalAccessTokenResponse { + if (!isRecord(value) || typeof value.token !== "string") throw new Error("Invalid response."); + return { + personalAccessToken: readMetadata(value.personalAccessToken), + token: value.token + }; +} + +function readMetadata(value: unknown): PersonalAccessTokenMetadata { + if ( + !isRecord(value) || + typeof value.id !== "string" || + typeof value.userId !== "string" || + typeof value.ownerName !== "string" || + typeof value.name !== "string" || + typeof value.tokenSuffix !== "string" || + typeof value.createdAt !== "string" || + (typeof value.expiresAt !== "string" && value.expiresAt !== null) + ) { + throw new Error("Invalid response."); + } + return { + id: value.id, + userId: value.userId, + ownerName: value.ownerName, + name: value.name, + tokenSuffix: value.tokenSuffix, + createdAt: value.createdAt, + expiresAt: value.expiresAt + }; +} + +function readErrorCode(value: unknown): string | null { + return isRecord(value) && isRecord(value.error) && typeof value.error.code === "string" + ? value.error.code + : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx b/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx new file mode 100644 index 00000000..16397f11 --- /dev/null +++ b/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx @@ -0,0 +1,160 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { RecentAuthenticationGate } from "@/features/auth/recent-authentication"; +import { + AmbiguousPersonalAccessTokenCreateError, + createPersonalAccessToken, + PersonalAccessTokenApiError +} from "./api"; +import { defaultPersonalAccessTokenExpiry, personalAccessTokenExpiryToIso } from "./expiry"; +import type { CreatePersonalAccessTokenResponse } from "./types"; + +export function CreatePersonalAccessTokenDialog({ + open, + onAmbiguous, + onCreated, + onOpenChange +}: { + open: boolean; + onAmbiguous: (error: AmbiguousPersonalAccessTokenCreateError) => void | Promise; + onCreated: (result: CreatePersonalAccessTokenResponse) => void | Promise; + onOpenChange: (open: boolean) => void; +}): React.ReactElement { + return ( + + + + } + /> + + + ); +} + +function CreatePersonalAccessTokenForm({ + active, + onAmbiguous, + onCreated, + onOpenChange +}: { + active: boolean; + onAmbiguous: (error: AmbiguousPersonalAccessTokenCreateError) => void | Promise; + onCreated: (result: CreatePersonalAccessTokenResponse) => void | Promise; + onOpenChange: (open: boolean) => void; +}): React.ReactElement { + const [name, setName] = React.useState(""); + const [expiresAt, setExpiresAt] = React.useState(defaultPersonalAccessTokenExpiry); + const [pending, setPending] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!active) return; + setName(""); + setExpiresAt(defaultPersonalAccessTokenExpiry()); + setPending(false); + setError(null); + }, [active]); + + async function submit(event: React.FormEvent): Promise { + event.preventDefault(); + if (pending) return; + const trimmedName = name.trim(); + if (!trimmedName) return; + let expiry: string | null; + try { + expiry = personalAccessTokenExpiryToIso(expiresAt); + } catch { + setError("Expiry is invalid."); + return; + } + setPending(true); + setError(null); + try { + const result = await createPersonalAccessToken({ name: trimmedName, expiresAt: expiry }); + onOpenChange(false); + await onCreated(result); + } catch (nextError) { + if (nextError instanceof AmbiguousPersonalAccessTokenCreateError) { + onOpenChange(false); + await onAmbiguous(nextError); + return; + } + setError( + nextError instanceof PersonalAccessTokenApiError + ? nextError.message + : "The personal access token could not be created." + ); + setPending(false); + } + } + + return ( + <> + + Create personal access token + + Use a short name that identifies the client or automation. + + +
void submit(event)}> + + + {error ? ( +

+ {error} +

+ ) : null} + + + + + + +
+ + ); +} diff --git a/app/features/personal-access-tokens/expiry.ts b/app/features/personal-access-tokens/expiry.ts new file mode 100644 index 00000000..9f4a60aa --- /dev/null +++ b/app/features/personal-access-tokens/expiry.ts @@ -0,0 +1,15 @@ +export function formatDateTimeLocal(date: Date): string { + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export function defaultPersonalAccessTokenExpiry(now = Date.now()): string { + return formatDateTimeLocal(new Date(now + 90 * 24 * 60 * 60 * 1000)); +} + +export function personalAccessTokenExpiryToIso(value: string): string | null { + if (value === "") return null; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) throw new Error("Expiry is invalid."); + return parsed.toISOString(); +} diff --git a/app/features/personal-access-tokens/one-time-token-dialog.tsx b/app/features/personal-access-tokens/one-time-token-dialog.tsx new file mode 100644 index 00000000..62999afe --- /dev/null +++ b/app/features/personal-access-tokens/one-time-token-dialog.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; + +export function OneTimeTokenDialog({ + open, + token, + onCopy, + onOpenChange +}: { + open: boolean; + token: string | null; + onCopy: () => Promise; + onOpenChange: (open: boolean) => void; +}): React.ReactElement { + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (open) setCopied(false); + }, [open]); + + return ( + + + + Personal access token created + Copy this token now. HQBase cannot show it again. + + + {token} + + + + + + + + ); +} diff --git a/app/features/personal-access-tokens/personal-access-token-settings.tsx b/app/features/personal-access-tokens/personal-access-token-settings.tsx index 56527905..2aa9bc73 100644 --- a/app/features/personal-access-tokens/personal-access-token-settings.tsx +++ b/app/features/personal-access-tokens/personal-access-token-settings.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { flushSync } from "react-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -10,16 +11,21 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { signOutStartedEvent } from "@/features/auth/sign-out-lifecycle"; import { SettingsSection } from "@/features/settings/settings-section"; import type { WorkspaceRole } from "@/features/users/types"; import { listPersonalAccessTokens, revokePersonalAccessToken } from "./api"; +import { CreatePersonalAccessTokenDialog } from "./create-personal-access-token-dialog"; +import { OneTimeTokenDialog } from "./one-time-token-dialog"; import { PersonalAccessTokenTable } from "./personal-access-token-table"; import type { PersonalAccessTokenMetadata } from "./types"; export function PersonalAccessTokenSettings({ - userRole + userRole, + onCopyReferenceChange }: { userRole: WorkspaceRole; + onCopyReferenceChange?: (hasValue: boolean) => void; }): React.ReactElement { const [personalAccessTokens, setPersonalAccessTokens] = React.useState< PersonalAccessTokenMetadata[] @@ -28,6 +34,10 @@ export function PersonalAccessTokenSettings({ const [error, setError] = React.useState(null); const [revokeTarget, setRevokeTarget] = React.useState(null); const [pendingId, setPendingId] = React.useState(null); + const [createOpen, setCreateOpen] = React.useState(false); + const [oneTimeToken, setOneTimeToken] = React.useState(null); + const [oneTimeOpen, setOneTimeOpen] = React.useState(false); + const copyReference = React.useRef(null); const refresh = React.useCallback(async () => { setLoading(true); @@ -46,6 +56,32 @@ export function PersonalAccessTokenSettings({ void refresh(); }, [refresh]); + const clearPlaintext = React.useCallback(() => { + copyReference.current = null; + onCopyReferenceChange?.(false); + setOneTimeToken(null); + setOneTimeOpen(false); + }, [onCopyReferenceChange]); + + React.useEffect(() => { + const clearSynchronously = () => { + copyReference.current = null; + onCopyReferenceChange?.(false); + flushSync(() => { + setOneTimeToken(null); + setOneTimeOpen(false); + }); + }; + window.addEventListener("pagehide", clearSynchronously); + window.addEventListener(signOutStartedEvent, clearSynchronously); + return () => { + window.removeEventListener("pagehide", clearSynchronously); + window.removeEventListener(signOutStartedEvent, clearSynchronously); + copyReference.current = null; + onCopyReferenceChange?.(false); + }; + }, [onCopyReferenceChange]); + async function revoke(): Promise { if (!revokeTarget || pendingId !== null) return; setPendingId(revokeTarget.id); @@ -64,7 +100,15 @@ export function PersonalAccessTokenSettings({ } return ( - + setCreateOpen(true)}> + Create token + + } + description="Manage credentials for Mail API automation." + title="API" + >

Personal access tokens can call every Mail API operation, subject to the token owner's @@ -112,6 +156,38 @@ export function PersonalAccessTokenSettings({ + { + await refresh(); + setError(nextError.message); + }} + onCreated={(result) => { + copyReference.current = result.token; + onCopyReferenceChange?.(true); + setOneTimeToken(result.token); + setOneTimeOpen(true); + void refresh(); + }} + onOpenChange={setCreateOpen} + /> + { + const token = copyReference.current; + if (!token || !navigator.clipboard) return false; + try { + await navigator.clipboard.writeText(token); + return true; + } catch { + return false; + } + }} + onOpenChange={(open) => { + if (!open) clearPlaintext(); + }} + /> ); } diff --git a/app/features/personal-access-tokens/types.ts b/app/features/personal-access-tokens/types.ts index 10adb108..5aa493fe 100644 --- a/app/features/personal-access-tokens/types.ts +++ b/app/features/personal-access-tokens/types.ts @@ -11,3 +11,13 @@ export type PersonalAccessTokenMetadata = { export type PersonalAccessTokenList = { personalAccessTokens: PersonalAccessTokenMetadata[]; }; + +export type CreatePersonalAccessTokenInput = { + name: string; + expiresAt: string | null; +}; + +export type CreatePersonalAccessTokenResponse = { + personalAccessToken: PersonalAccessTokenMetadata; + token: string; +}; diff --git a/package.json b/package.json index d0d63266..72e48ed5 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,8 @@ "@biomejs/biome": "2.5.3", "@cloudflare/vitest-pool-workers": "0.18.8", "@playwright/test": "1.61.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@types/node": "^26.0.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5ee6b4f..052cbb7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,12 @@ importers: '@playwright/test': specifier: 1.61.1 version: 1.61.1 + '@testing-library/react': + specifier: 16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^26.0.1 version: 26.0.1 @@ -243,6 +249,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -1522,6 +1532,31 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.27.3': resolution: {integrity: sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg==} peerDependencies: @@ -1676,6 +1711,9 @@ packages: '@tiptap/starter-kit@3.27.3': resolution: {integrity: sha512-xX3baFqiC30skntdhxUUvyJo755ON9c1pE83+2Wiq2g+Qnffg9knUuLCZStHoZZ318yB0aBsKAMvHWLtYDo8AA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1789,6 +1827,14 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1803,6 +1849,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} @@ -2035,6 +2084,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2048,6 +2101,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2505,6 +2561,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2713,6 +2773,10 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} @@ -2781,6 +2845,9 @@ packages: peerDependencies: react: '*' + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3333,6 +3400,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4360,6 +4429,31 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.27.3(@tiptap/pm@3.27.3)': dependencies: '@tiptap/pm': 3.27.3 @@ -4536,6 +4630,8 @@ snapshots: '@tiptap/extensions': 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3) '@tiptap/pm': 3.27.3 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -4686,6 +4782,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -4699,6 +4799,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + asn1.js@5.4.1: dependencies: bn.js: 4.12.5 @@ -4884,6 +4988,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -4892,6 +4998,8 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5286,6 +5394,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5445,6 +5555,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -5549,6 +5665,8 @@ snapshots: dependencies: react: 19.2.7 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): diff --git a/test/unit/app/settings/personal-access-token-expiry.test.ts b/test/unit/app/settings/personal-access-token-expiry.test.ts new file mode 100644 index 00000000..b9e507a5 --- /dev/null +++ b/test/unit/app/settings/personal-access-token-expiry.test.ts @@ -0,0 +1,35 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + defaultPersonalAccessTokenExpiry, + formatDateTimeLocal, + personalAccessTokenExpiryToIso +} from "@/features/personal-access-tokens/expiry"; + +const priorTimeZone = process.env.TZ; + +beforeAll(() => { + process.env.TZ = "America/Chicago"; +}); + +afterAll(() => { + if (priorTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = priorTimeZone; +}); + +describe("personal access token expiry", () => { + it("formats and parses datetime-local values in the current time zone", () => { + const instant = new Date("2026-01-15T18:30:00.000Z"); + const local = formatDateTimeLocal(instant); + + expect(local).toBe("2026-01-15T12:30"); + expect(local).not.toBe(instant.toISOString().slice(0, 16)); + expect(personalAccessTokenExpiryToIso(local)).toBe(instant.toISOString()); + expect(personalAccessTokenExpiryToIso("")).toBeNull(); + }); + + it("uses a 90-day default", () => { + const now = new Date("2026-01-15T18:30:00.000Z"); + const expected = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + expect(defaultPersonalAccessTokenExpiry(now.getTime())).toBe(formatDateTimeLocal(expected)); + }); +}); diff --git a/test/unit/app/settings/personal-access-token-lifecycle.test.tsx b/test/unit/app/settings/personal-access-token-lifecycle.test.tsx new file mode 100644 index 00000000..1ef29936 --- /dev/null +++ b/test/unit/app/settings/personal-access-token-lifecycle.test.tsx @@ -0,0 +1,316 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { signOutStartedEvent } from "@/features/auth/sign-out-lifecycle"; +import { PersonalAccessTokenSettings } from "@/features/personal-access-tokens/personal-access-token-settings"; +import { assertSecretSafeEqual } from "../../../helpers/secret-safe-assertions"; + +const apiMocks = vi.hoisted(() => ({ + createPersonalAccessToken: vi.fn(), + listPersonalAccessTokens: vi.fn(), + revokePersonalAccessToken: vi.fn() +})); +const recentAuthenticationMocks = vi.hoisted(() => ({ + getRecentAuthentication: vi.fn(), + reauthenticate: vi.fn() +})); + +vi.mock("@/features/personal-access-tokens/api", async (importOriginal) => ({ + ...(await importOriginal()), + createPersonalAccessToken: apiMocks.createPersonalAccessToken, + listPersonalAccessTokens: apiMocks.listPersonalAccessTokens, + revokePersonalAccessToken: apiMocks.revokePersonalAccessToken +})); +vi.mock("@/features/auth/recent-authentication-api", () => ({ + getRecentAuthentication: recentAuthenticationMocks.getRecentAuthentication, + reauthenticate: recentAuthenticationMocks.reauthenticate +})); + +const actualApi = await vi.importActual( + "@/features/personal-access-tokens/api" +); + +const metadata = { + id: "pat_created", + userId: "user_member", + ownerName: "Member User", + name: "Automation", + tokenSuffix: "oken", + createdAt: "2026-08-20T12:00:00.000Z", + expiresAt: null +}; +const ambiguousMessage = + "Token creation might have completed. Refresh the list and revoke any token whose value you did not receive."; + +beforeEach(() => { + vi.clearAllMocks(); + apiMocks.listPersonalAccessTokens.mockResolvedValue({ personalAccessTokens: [] }); + apiMocks.revokePersonalAccessToken.mockResolvedValue(undefined); + recentAuthenticationMocks.getRecentAuthentication.mockResolvedValue(true); + recentAuthenticationMocks.reauthenticate.mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("createPersonalAccessToken delivery classification", () => { + it("classifies a rejected fetch as ambiguous without retry", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("offline")); + vi.stubGlobal("fetch", fetchMock); + + await expect(actualApi.createPersonalAccessToken(createInput)).rejects.toBeInstanceOf( + actualApi.AmbiguousPersonalAccessTokenCreateError + ); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("/api/personal-access-tokens", { + body: JSON.stringify(createInput), + cache: "no-store", + credentials: "include", + headers: { "content-type": "application/json" }, + method: "POST" + }); + }); + + it("returns one validated successful response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ personalAccessToken: metadata, token: "test-only-token" }), + status: 201 + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await actualApi.createPersonalAccessToken(createInput); + expect(result.personalAccessToken).toEqual(metadata); + assertSecretSafeEqual(result.token, "test-only-token"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + 500, 599 + ])("classifies status %s as ambiguous without reading its body", async (status) => { + const json = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue({ json, status }); + vi.stubGlobal("fetch", fetchMock); + + await expect(actualApi.createPersonalAccessToken(createInput)).rejects.toBeInstanceOf( + actualApi.AmbiguousPersonalAccessTokenCreateError + ); + expect(json).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([200, 299])("classifies a status %s body-read failure as ambiguous", async (status) => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockRejectedValue(new Error("unreadable")), + status + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(actualApi.createPersonalAccessToken(createInput)).rejects.toBeInstanceOf( + actualApi.AmbiguousPersonalAccessTokenCreateError + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("classifies malformed successful JSON as ambiguous", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ token: "test-only-token" }), + status: 201 + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(actualApi.createPersonalAccessToken(createInput)).rejects.toBeInstanceOf( + actualApi.AmbiguousPersonalAccessTokenCreateError + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ["UNAUTHENTICATED", 401, "Sign in again."], + ["RECENT_AUTH_REQUIRED", 403, "Confirm your password and try again."], + ["INVALID_PERSONAL_ACCESS_TOKEN", 400, "Check the token name and expiry."], + [ + "PERSONAL_ACCESS_TOKEN_LIMIT_REACHED", + 409, + "Revoke an active personal access token before creating another." + ], + ["RATE_LIMITED", 429, "Too many token creation attempts. Wait and try again."] + ] as const)("uses the fixed local error for %s", async (code, status, message) => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ + error: { code, message: "unused server message", detail: "unused non-secret detail" } + }), + status + }); + vi.stubGlobal("fetch", fetchMock); + + const error = await actualApi.createPersonalAccessToken(createInput).catch((value) => value); + expect(error).toBeInstanceOf(actualApi.PersonalAccessTokenApiError); + expect(error).toMatchObject({ code, status, message }); + expect(error).not.toHaveProperty("detail"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + [302, { ignored: true }], + [401, { error: { message: "missing code" } }], + [400, { error: { code: "UNAUTHENTICATED" } }], + [400, { error: { code: "UNKNOWN_CODE" } }] + ])("classifies status %s with an untrusted body as ambiguous", async (status, body) => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue(body), + status + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(actualApi.createPersonalAccessToken(createInput)).rejects.toBeInstanceOf( + actualApi.AmbiguousPersonalAccessTokenCreateError + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); + +describe("personal access token creation UI", () => { + it("gates creation, clears expiry, copies once, and clears on modal close", async () => { + let copiedValue: string | null = null; + let hasCopyReference = false; + const recentCheck = deferred(); + recentAuthenticationMocks.getRecentAuthentication.mockReturnValueOnce(recentCheck.promise); + const user = userEvent.setup(); + vi.spyOn(navigator.clipboard, "writeText").mockImplementation(async (value) => { + copiedValue = value; + }); + apiMocks.createPersonalAccessToken.mockResolvedValue({ + personalAccessToken: metadata, + token: "test-only-token" + }); + render( + { + hasCopyReference = hasValue; + }} + /> + ); + await screen.findByText("No active personal access tokens."); + + await user.click(screen.getByRole("button", { name: "Create token" })); + expect(recentAuthenticationMocks.getRecentAuthentication).toHaveBeenCalledOnce(); + expect(screen.getByText("Checking sign-in…")).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: "Name" })).toBeNull(); + recentCheck.resolve(true); + const name = await screen.findByRole("textbox", { name: "Name" }); + const expiry = screen.getByLabelText("Expires"); + expect(expiry.value).not.toBe(""); + await user.type(name, " Automation "); + await user.clear(expiry); + await user.click(screen.getByRole("button", { name: "Create personal access token" })); + + await screen.findByText("Copy this token now. HQBase cannot show it again."); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce(); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledWith({ + expiresAt: null, + name: "Automation" + }); + expect(hasCopyReference).toBe(true); + await user.click(screen.getByRole("button", { name: "Copy token" })); + if (copiedValue === null) throw new Error("The clipboard write did not occur."); + assertSecretSafeEqual(copiedValue, "test-only-token"); + + await user.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByText("Copy this token now. HQBase cannot show it again.")).toBeNull(); + expect(hasCopyReference).toBe(false); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce(); + }); + + it("closes and refreshes after ambiguous delivery without retry", async () => { + apiMocks.createPersonalAccessToken.mockRejectedValue( + new actualApi.AmbiguousPersonalAccessTokenCreateError() + ); + const user = userEvent.setup(); + render(); + await submitCreateForm(user); + + await screen.findByText(ambiguousMessage); + expect(screen.queryByRole("dialog", { name: "Create personal access token" })).toBeNull(); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce(); + expect(apiMocks.listPersonalAccessTokens).toHaveBeenCalledTimes(2); + }); + + it("keeps a definitive fixed error in the form without retry", async () => { + apiMocks.createPersonalAccessToken.mockRejectedValue( + new actualApi.PersonalAccessTokenApiError( + "PERSONAL_ACCESS_TOKEN_LIMIT_REACHED", + 409, + "Revoke an active personal access token before creating another." + ) + ); + const user = userEvent.setup(); + render(); + await submitCreateForm(user); + + await screen.findByText("Revoke an active personal access token before creating another."); + expect(screen.getByRole("dialog", { name: "Create personal access token" })).toBeTruthy(); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce(); + expect(apiMocks.listPersonalAccessTokens).toHaveBeenCalledOnce(); + }); + + it.each([ + "pagehide", + "sign-out", + "unmount" + ] as const)("clears plaintext synchronously on %s", async (lifecycle) => { + let hasCopyReference = false; + let clipboardWriteOccurred = false; + const user = userEvent.setup(); + vi.spyOn(navigator.clipboard, "writeText").mockImplementation(async () => { + clipboardWriteOccurred = true; + }); + apiMocks.createPersonalAccessToken.mockResolvedValue({ + personalAccessToken: metadata, + token: "test-only-token" + }); + const view = render( + { + hasCopyReference = hasValue; + }} + /> + ); + await submitCreateForm(user); + await screen.findByText("Copy this token now. HQBase cannot show it again."); + expect(hasCopyReference).toBe(true); + + if (lifecycle === "pagehide") window.dispatchEvent(new Event("pagehide")); + else if (lifecycle === "sign-out") window.dispatchEvent(new Event(signOutStartedEvent)); + else view.unmount(); + + const tokenIsVisible = document.body.textContent?.includes("test-only-token") ?? false; + expect(tokenIsVisible).toBe(false); + expect(hasCopyReference).toBe(false); + expect(clipboardWriteOccurred).toBe(false); + }); +}); + +const createInput = { name: "Automation", expiresAt: null }; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +async function submitCreateForm(user: ReturnType): Promise { + await screen.findByText("No active personal access tokens."); + await user.click(screen.getByRole("button", { name: "Create token" })); + const name = await screen.findByRole("textbox", { name: "Name" }); + await user.type(name, "Automation"); + await user.click(screen.getByRole("button", { name: "Create personal access token" })); + await waitFor(() => expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce()); +} diff --git a/test/unit/app/settings/personal-access-token-settings.test.tsx b/test/unit/app/settings/personal-access-token-settings.test.tsx index 814c8436..bd041874 100644 --- a/test/unit/app/settings/personal-access-token-settings.test.tsx +++ b/test/unit/app/settings/personal-access-token-settings.test.tsx @@ -51,6 +51,7 @@ describe("personal access token settings", () => { "Personal access tokens cannot access workspace administration or MCP." ); expect(view.container.textContent).toContain("Deployment agent"); + expect(view.container.textContent).toContain("Create token"); expect(view.container.textContent).toContain("••••Ab_9"); expect(view.container.textContent).not.toContain("tokenHash"); expect(view.container.textContent).not.toContain("Revoked"); From cc3d743d1c8dd5b46a046b28b5f47442dbfabb11 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 03:43:17 -0500 Subject: [PATCH 15/21] test: cover personal access tokens in staging --- CHANGELOG.md | 8 + package.json | 2 +- playwright.config.ts | 5 +- .../staging/personal-access-tokens.spec.ts | 148 ++++++++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 test/e2e/staging/personal-access-tokens.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a74b380e..06ca420c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Let every user create and revoke personal access tokens for trusted Mail API automation. Workspace + owners can also inspect and revoke every active token for incident response. These + high-privilege credentials can call every Mail API operation subject to the token owner's current + role and mailbox grants, never access workspace administration or MCP, and appear only once when + created. + ## 1.2.0 ### New diff --git a/package.json b/package.json index 72e48ed5..06cbeb7f 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "api:generate": "node scripts/generate-mail-api-artifacts.mjs --write", "api:check": "node scripts/generate-mail-api-artifacts.mjs --check", "test:pwa": "node scripts/test-pwa.mjs", - "test:e2e:staging": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts lifecycle.spec.ts", + "test:e2e:staging": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts lifecycle.spec.ts personal-access-tokens.spec.ts", "test:e2e:staging:smoke": "playwright test --config playwright.config.ts app-shell-smoke.spec.ts", "test:e2e:staging:lifecycle": "playwright test --config playwright.config.ts lifecycle.spec.ts", "pwa:icons": "node scripts/generate-pwa-icons.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 4cf65334..638cc008 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from "@playwright/test"; +const isDiscovery = process.argv.includes("--list"); const baseURL = process.env.HQBASE_STAGING_URL; const accessClientId = process.env.HQBASE_STAGING_ACCESS_CLIENT_ID; const accessClientSecret = process.env.HQBASE_STAGING_ACCESS_CLIENT_SECRET; -if (!baseURL && process.env.CI) { +if (!isDiscovery && !baseURL && process.env.CI) { throw new Error("HQBASE_STAGING_URL is required. HQBase E2E runs only in staging."); } -if (baseURL && process.env.CI && (!accessClientId || !accessClientSecret)) { +if (!isDiscovery && baseURL && process.env.CI && (!accessClientId || !accessClientSecret)) { throw new Error( "Cloudflare Access service-token credentials are required for HQBase staging E2E." ); diff --git a/test/e2e/staging/personal-access-tokens.spec.ts b/test/e2e/staging/personal-access-tokens.spec.ts new file mode 100644 index 00000000..219ad852 --- /dev/null +++ b/test/e2e/staging/personal-access-tokens.spec.ts @@ -0,0 +1,148 @@ +import { + type APIRequestContext, + expect, + request as playwrightRequest, + test +} from "@playwright/test"; + +test.use({ screenshot: "off", trace: "off", video: "off" }); + +test("personal access token creation, isolation, and revocation", async ({ page }) => { + const stagingUrl = required("HQBASE_STAGING_URL"); + const ownerEmail = required("HQBASE_STAGING_OWNER_EMAIL"); + const ownerPassword = required("HQBASE_STAGING_OWNER_PASSWORD"); + const accessClientId = required("HQBASE_STAGING_ACCESS_CLIENT_ID"); + const accessClientSecret = required("HQBASE_STAGING_ACCESS_CLIENT_SECRET"); + const uniqueName = `PAT staging ${Date.now()} ${process.env.GITHUB_RUN_ID ?? "local"}`; + let plaintext: string | null = null; + let recordId: string | null = null; + let patRequest: APIRequestContext | null = null; + + try { + await page.goto("/settings/api", { waitUntil: "domcontentloaded" }); + const createToken = page.getByRole("button", { name: "Create token" }); + const loginEmail = page.getByLabel("Email"); + await expect(loginEmail.or(createToken)).toBeVisible({ timeout: 60_000 }); + if (await loginEmail.isVisible()) { + await loginEmail.fill(ownerEmail); + await page.getByLabel("Password").fill(ownerPassword); + await page.getByRole("button", { name: "Continue" }).click(); + } + await expect(createToken).toBeVisible({ timeout: 60_000 }); + + await createToken.click(); + const tokenName = page.getByRole("textbox", { name: "Name" }); + const reauthenticationPassword = page.getByLabel("Password"); + await expect(tokenName.or(reauthenticationPassword)).toBeVisible(); + if (await reauthenticationPassword.isVisible()) { + await reauthenticationPassword.fill(ownerPassword); + await page.getByRole("button", { name: "Sign in and continue" }).click(); + } + await tokenName.fill(uniqueName); + await page.getByRole("button", { name: "Create personal access token" }).click(); + + const oneTimeDialog = page.getByRole("dialog", { + name: "Personal access token created" + }); + await expect(oneTimeDialog).toBeVisible(); + plaintext = await oneTimeDialog.locator("code").textContent(); + if (!plaintext) throw new Error("The one-time token value was unavailable."); + expect(plaintext.length > 0).toBe(true); + + const metadataResponse = await page.request.get("/api/personal-access-tokens"); + expect(metadataResponse.status()).toBe(200); + const metadata = (await metadataResponse.json()) as { + personalAccessTokens: Array<{ id: string; name: string }>; + }; + const matchingTokens = metadata.personalAccessTokens.filter( + (token) => token.name === uniqueName + ); + expect(matchingTokens.map((token) => token.name)).toEqual([uniqueName]); + recordId = matchingTokens[0]?.id ?? null; + if (!recordId) throw new Error("The named token metadata was unavailable."); + await expect(page.getByRole("button", { name: `Revoke ${uniqueName}` })).toBeAttached(); + + await page.evaluate(() => { + Object.assign(window, { __hqbasePatPageShowPersisted: false }); + window.addEventListener("pageshow", (event) => { + if (event.persisted) Object.assign(window, { __hqbasePatPageShowPersisted: true }); + }); + }); + await page.goto("/settings/interface", { waitUntil: "domcontentloaded" }); + await page.goBack({ waitUntil: "domcontentloaded" }); + const pageShowPersisted = await page.evaluate( + () => + (window as typeof window & { __hqbasePatPageShowPersisted?: boolean }) + .__hqbasePatPageShowPersisted === true + ); + expect(pageShowPersisted).toBe(true); + const plaintextCleared = await page.evaluate(() => { + const oneTimeModalOpen = [...document.querySelectorAll('[role="dialog"]')].some((dialog) => + dialog.textContent?.includes("Copy this token now. HQBase cannot show it again.") + ); + return !oneTimeModalOpen && document.querySelector("code") === null; + }); + expect(plaintextCleared).toBe(true); + + patRequest = await playwrightRequest.newContext({ + baseURL: stagingUrl, + extraHTTPHeaders: { + "CF-Access-Client-Id": accessClientId, + "CF-Access-Client-Secret": accessClientSecret, + authorization: `Bearer ${plaintext}` + } + }); + const initialPatStorage = await patRequest.storageState(); + expect(initialPatStorage.cookies.length).toBe(0); + + const mailboxes = await patRequest.get("/api/v1/mailboxes"); + expect(mailboxes.status()).toBe(200); + const mailboxMetadata = await mailboxes.json(); + expect(Array.isArray(mailboxMetadata)).toBe(true); + + const privateUsers = await patRequest.get("/api/users"); + expect(privateUsers.status()).toBe(401); + const privateError = (await privateUsers.json()) as { error?: { code?: string } }; + expect(privateError.error?.code === "UNAUTHENTICATED").toBe(true); + + await page.getByRole("button", { name: `Revoke ${uniqueName}` }).click(); + await expect(page.getByRole("dialog", { name: `Revoke ${uniqueName}?` })).toBeVisible(); + await page.getByRole("button", { name: "Revoke token" }).click(); + await expect(page.getByRole("button", { name: `Revoke ${uniqueName}` })).toHaveCount(0); + + const revokedMailboxes = await patRequest.get("/api/v1/mailboxes"); + expect(revokedMailboxes.status()).toBe(401); + const revokedError = (await revokedMailboxes.json()) as { error?: { code?: string } }; + expect(revokedError.error?.code === "INVALID_PERSONAL_ACCESS_TOKEN").toBe(true); + } finally { + await patRequest?.dispose(); + try { + const cleanupList = await page.request.get("/api/personal-access-tokens"); + if (cleanupList.status() === 200) { + const cleanupMetadata = (await cleanupList.json()) as { + personalAccessTokens: Array<{ id: string; name: string }>; + }; + const cleanupRecordId = recordId + ? cleanupMetadata.personalAccessTokens.some((token) => token.id === recordId) + ? recordId + : null + : (cleanupMetadata.personalAccessTokens.find((token) => token.name === uniqueName)?.id ?? + null); + if (cleanupRecordId) { + const cleanupRevoke = await page.request.delete( + `/api/personal-access-tokens/${encodeURIComponent(cleanupRecordId)}` + ); + expect(cleanupRevoke.status()).toBe(204); + } + } + } finally { + plaintext = null; + } + } +}); + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for HQBase staging E2E.`); + return value; +} From 16931156cad245495eaea485d453843cf05ebaf9 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 20 Aug 2026 11:11:43 -0500 Subject: [PATCH 16/21] fix: discard stale personal access token results --- .../create-personal-access-token-dialog.tsx | 16 ++++++ .../personal-access-token-settings.tsx | 18 ++++++- .../personal-access-token-lifecycle.test.tsx | 50 ++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx b/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx index 16397f11..ed297187 100644 --- a/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx +++ b/app/features/personal-access-tokens/create-personal-access-token-dialog.tsx @@ -20,11 +20,15 @@ import { defaultPersonalAccessTokenExpiry, personalAccessTokenExpiryToIso } from import type { CreatePersonalAccessTokenResponse } from "./types"; export function CreatePersonalAccessTokenDialog({ + getCreateResultGeneration, + isCreateResultGenerationCurrent, open, onAmbiguous, onCreated, onOpenChange }: { + getCreateResultGeneration: () => number; + isCreateResultGenerationCurrent: (generation: number) => boolean; open: boolean; onAmbiguous: (error: AmbiguousPersonalAccessTokenCreateError) => void | Promise; onCreated: (result: CreatePersonalAccessTokenResponse) => void | Promise; @@ -40,6 +44,8 @@ export function CreatePersonalAccessTokenDialog({ ready={ number; + isCreateResultGenerationCurrent: (generation: number) => boolean; onAmbiguous: (error: AmbiguousPersonalAccessTokenCreateError) => void | Promise; onCreated: (result: CreatePersonalAccessTokenResponse) => void | Promise; onOpenChange: (open: boolean) => void; @@ -89,8 +99,14 @@ function CreatePersonalAccessTokenForm({ } setPending(true); setError(null); + const resultGeneration = getCreateResultGeneration(); try { const result = await createPersonalAccessToken({ name: trimmedName, expiresAt: expiry }); + if (!isCreateResultGenerationCurrent(resultGeneration)) { + onOpenChange(false); + await onAmbiguous(new AmbiguousPersonalAccessTokenCreateError()); + return; + } onOpenChange(false); await onCreated(result); } catch (nextError) { diff --git a/app/features/personal-access-tokens/personal-access-token-settings.tsx b/app/features/personal-access-tokens/personal-access-token-settings.tsx index 2aa9bc73..1899d947 100644 --- a/app/features/personal-access-tokens/personal-access-token-settings.tsx +++ b/app/features/personal-access-tokens/personal-access-token-settings.tsx @@ -38,6 +38,8 @@ export function PersonalAccessTokenSettings({ const [oneTimeToken, setOneTimeToken] = React.useState(null); const [oneTimeOpen, setOneTimeOpen] = React.useState(false); const copyReference = React.useRef(null); + const createResultGeneration = React.useRef(0); + const mounted = React.useRef(false); const refresh = React.useCallback(async () => { setLoading(true); @@ -56,6 +58,13 @@ export function PersonalAccessTokenSettings({ void refresh(); }, [refresh]); + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const clearPlaintext = React.useCallback(() => { copyReference.current = null; onCopyReferenceChange?.(false); @@ -65,6 +74,7 @@ export function PersonalAccessTokenSettings({ React.useEffect(() => { const clearSynchronously = () => { + createResultGeneration.current += 1; copyReference.current = null; onCopyReferenceChange?.(false); flushSync(() => { @@ -77,6 +87,7 @@ export function PersonalAccessTokenSettings({ return () => { window.removeEventListener("pagehide", clearSynchronously); window.removeEventListener(signOutStartedEvent, clearSynchronously); + createResultGeneration.current += 1; copyReference.current = null; onCopyReferenceChange?.(false); }; @@ -157,10 +168,15 @@ export function PersonalAccessTokenSettings({ createResultGeneration.current} + isCreateResultGenerationCurrent={(generation) => + generation === createResultGeneration.current + } open={createOpen} onAmbiguous={async (nextError) => { + if (!mounted.current) return; await refresh(); - setError(nextError.message); + if (mounted.current) setError(nextError.message); }} onCreated={(result) => { copyReference.current = result.token; diff --git a/test/unit/app/settings/personal-access-token-lifecycle.test.tsx b/test/unit/app/settings/personal-access-token-lifecycle.test.tsx index 1ef29936..bb15a044 100644 --- a/test/unit/app/settings/personal-access-token-lifecycle.test.tsx +++ b/test/unit/app/settings/personal-access-token-lifecycle.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { signOutStartedEvent } from "@/features/auth/sign-out-lifecycle"; @@ -294,6 +294,54 @@ describe("personal access token creation UI", () => { expect(hasCopyReference).toBe(false); expect(clipboardWriteOccurred).toBe(false); }); + + it.each([ + "pagehide", + "sign-out", + "unmount" + ] as const)("discards a create result that arrives after %s", async (lifecycle) => { + let hasCopyReference = false; + const createResult = deferred<{ + personalAccessToken: typeof metadata; + token: string; + }>(); + apiMocks.createPersonalAccessToken.mockReturnValue(createResult.promise); + const user = userEvent.setup(); + const view = render( + { + hasCopyReference = hasValue; + }} + /> + ); + await submitCreateForm(user); + + if (lifecycle === "pagehide") window.dispatchEvent(new Event("pagehide")); + else if (lifecycle === "sign-out") window.dispatchEvent(new Event(signOutStartedEvent)); + else view.unmount(); + + await act(async () => { + createResult.resolve({ personalAccessToken: metadata, token: "test-only-token" }); + await createResult.promise; + }); + + const tokenIsVisible = document.body.textContent?.includes("test-only-token") ?? false; + expect(tokenIsVisible).toBe(false); + expect(hasCopyReference).toBe(false); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledOnce(); + expect(apiMocks.createPersonalAccessToken).toHaveBeenCalledWith({ + expiresAt: expect.any(String), + name: "Automation" + }); + expect(screen.queryByText("Copy this token now. HQBase cannot show it again.")).toBeNull(); + if (lifecycle === "unmount") { + expect(apiMocks.listPersonalAccessTokens).toHaveBeenCalledOnce(); + } else { + await waitFor(() => expect(apiMocks.listPersonalAccessTokens).toHaveBeenCalledTimes(2)); + expect(screen.getByText(ambiguousMessage)).toBeTruthy(); + } + }); }); const createInput = { name: "Automation", expiresAt: null }; From e613fb233b343a259faa472807ca038ceeccdc54 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 21 Aug 2026 18:42:35 -0500 Subject: [PATCH 17/21] fix: align personal access token database schema --- .../personal-access-token-schema.test.ts | 24 +++++++++++ test/unit/scripts/sql-migrations.test.mjs | 17 ++++---- worker/db/schema-auth.ts | 41 +++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/test/integration/worker/personal-access-token-schema.test.ts b/test/integration/worker/personal-access-token-schema.test.ts index f18284a8..17764af5 100644 --- a/test/integration/worker/personal-access-token-schema.test.ts +++ b/test/integration/worker/personal-access-token-schema.test.ts @@ -1,6 +1,9 @@ import { env } from "cloudflare:test"; +import { getTableConfig } from "drizzle-orm/sqlite-core"; import { beforeAll, describe, expect, it } from "vitest"; +import { personalAccessTokens } from "../../../worker/db/schema"; + import { applyCurrentMigrations } from "./current-migrations"; const stamp = "2026-08-19T18:00:00.000Z"; @@ -10,6 +13,27 @@ describe("personal access token schema", () => { await applyCurrentMigrations(); }); + it("keeps the Drizzle PAT table aligned with the SQL migration", () => { + const table = getTableConfig(personalAccessTokens); + + expect(table.name).toBe("personal_access_tokens"); + expect(table.columns.map(({ name }) => name)).toEqual([ + "id", + "user_id", + "name", + "token_hash", + "token_suffix", + "created_at", + "expires_at", + "revoked_at" + ]); + expect(table.indexes.map(({ config }) => config.name).sort()).toEqual([ + "personal_access_tokens_list_idx", + "personal_access_tokens_user_idx" + ]); + expect(table.foreignKeys).toHaveLength(1); + }); + it("deletes a user's PAT rows through the foreign-key cascade", async () => { await insertUser("usr_pat_cascade"); await insertPat({ id: "pat_cascade", userId: "usr_pat_cascade", tokenHash: "A".repeat(43) }); diff --git a/test/unit/scripts/sql-migrations.test.mjs b/test/unit/scripts/sql-migrations.test.mjs index 7f8a828f..7bc577b4 100644 --- a/test/unit/scripts/sql-migrations.test.mjs +++ b/test/unit/scripts/sql-migrations.test.mjs @@ -19,7 +19,8 @@ const expectedMigrationNames = [ "0011_latest_password_reset_token.sql", "0012_message_activity_index.sql", "0013_message_changes.sql", - "0014_unassigned_messages.sql" + "0014_unassigned_messages.sql", + "0015_personal_access_tokens.sql" ]; const databases = []; @@ -88,19 +89,21 @@ function insertRepresentativeData(database) { const insertMessage = database.prepare( `INSERT INTO messages (id, thread_id, mailbox_id, direction, folder, from_address, to_json, cc_json, - bcc_json, subject, snippet, text_body, references_json, created_at, updated_at) + bcc_json, subject, snippet, text_body, references_json, created_at, updated_at, + is_unassigned) VALUES (?, 'thr_upgrade', ?, 'inbound', ?, 'sender@example.com', '["mailbox@example.com"]', '[]', '[]', ?, 'Upgrade message', 'Upgrade message', - '[]', ?, ?)` + '[]', ?, ?, ?)` ); - insertMessage.run("msg_upgrade", "mbx_upgrade", "inbox", "Upgrade", timestamp, timestamp); + insertMessage.run("msg_upgrade", "mbx_upgrade", "inbox", "Upgrade", timestamp, timestamp, 0); insertMessage.run( "msg_unassigned_upgrade", null, "catchall", "Unassigned upgrade", timestamp, - timestamp + timestamp, + 1 ); database .prepare( @@ -132,7 +135,7 @@ describe("SQL migration contract", () => { const tables = database .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") .all(); - expect(tables).toHaveLength(39); + expect(tables).toHaveLength(40); }); it("preserves populated data through the latest upgrade and skips it on retry", async () => { @@ -164,7 +167,7 @@ describe("SQL migration contract", () => { expect(applyMigration(database, migrations.at(-1))).toBe(false); expect(database.prepare("SELECT count(*) AS count FROM d1_migrations").get()).toEqual({ - count: 14 + count: 15 }); }); }); diff --git a/worker/db/schema-auth.ts b/worker/db/schema-auth.ts index 1902e509..61d8fe49 100644 --- a/worker/db/schema-auth.ts +++ b/worker/db/schema-auth.ts @@ -29,6 +29,47 @@ export const users = sqliteTable("user", { banExpires: authDateText("banExpires") }); +export const personalAccessTokens = sqliteTable( + "personal_access_tokens", + { + id: text("id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + tokenHash: text("token_hash").notNull().unique(), + tokenSuffix: text("token_suffix").notNull(), + createdAt: text("created_at").notNull(), + expiresAt: text("expires_at"), + revokedAt: text("revoked_at") + }, + (table) => [ + check("personal_access_tokens_name_check", sql`length(trim(${table.name})) BETWEEN 1 AND 80`), + check( + "personal_access_tokens_token_hash_check", + sql`length(${table.tokenHash}) = 43 AND ${table.tokenHash} NOT GLOB '*[^A-Za-z0-9_-]*'` + ), + check( + "personal_access_tokens_token_suffix_check", + sql`length(${table.tokenSuffix}) = 4 AND ${table.tokenSuffix} NOT GLOB '*[^A-Za-z0-9_-]*'` + ), + check( + "personal_access_tokens_created_at_check", + sql`length(${table.createdAt}) = 24 AND substr(${table.createdAt}, 24, 1) = 'Z'` + ), + check( + "personal_access_tokens_expires_at_check", + sql`${table.expiresAt} IS NULL OR (length(${table.expiresAt}) = 24 AND substr(${table.expiresAt}, 24, 1) = 'Z')` + ), + check( + "personal_access_tokens_revoked_at_check", + sql`${table.revokedAt} IS NULL OR (length(${table.revokedAt}) = 24 AND substr(${table.revokedAt}, 24, 1) = 'Z')` + ), + index("personal_access_tokens_user_idx").on(table.userId, sql`${table.createdAt} DESC`), + index("personal_access_tokens_list_idx").on(sql`${table.createdAt} DESC`, sql`${table.id} DESC`) + ] +); + export const sessions = sqliteTable( "session", { From c31387e0eb255509108a91405a154668f016ccbf Mon Sep 17 00:00:00 2001 From: paul Date: Sat, 22 Aug 2026 00:48:45 -0500 Subject: [PATCH 18/21] fix: address PAT runtime review findings --- app/app.tsx | 1 - .../auth/recent-authentication-state.ts | 70 +++++++++ app/features/auth/recent-authentication.tsx | 87 ++++++----- app/features/personal-access-tokens/api.ts | 19 ++- .../one-time-token-dialog.tsx | 19 ++- .../personal-access-token-settings.tsx | 25 +++- app/features/settings/settings-page.tsx | 6 +- .../personal-access-token-service.test.ts | 5 +- .../auth/recent-authentication-state.test.ts | 26 ++++ .../app/auth/recent-authentication.test.tsx | 62 +++++++- .../personal-access-token-api.test.ts | 58 ++++++++ .../personal-access-token-lifecycle.test.tsx | 136 +++++++++++++++++- .../personal-access-token-settings.test.tsx | 111 ++++++++++++++ .../settings/settings-presentation.test.tsx | 1 - test/unit/worker/features/audit/audit.test.ts | 76 +++++++--- .../personal-access-tokens/service.test.ts | 4 + .../auth/personal-access-token-principal.ts | 2 +- worker/features/audit/service.ts | 34 ++--- .../personal-access-tokens/service.ts | 6 +- 19 files changed, 646 insertions(+), 102 deletions(-) create mode 100644 app/features/auth/recent-authentication-state.ts create mode 100644 test/unit/app/auth/recent-authentication-state.test.ts create mode 100644 test/unit/app/settings/personal-access-token-api.test.ts diff --git a/app/app.tsx b/app/app.tsx index 516dfe94..7979b658 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -233,7 +233,6 @@ export function App(): React.ReactElement { defaultFromMailboxId={user.defaultFromMailboxId} mailboxes={mailboxes} notifications={mailSync.notifications} - userRole={user.role} setup={setup} users={users} onDefaultFromMailboxChange={(defaultFromMailboxId) => { diff --git a/app/features/auth/recent-authentication-state.ts b/app/features/auth/recent-authentication-state.ts new file mode 100644 index 00000000..13c83af2 --- /dev/null +++ b/app/features/auth/recent-authentication-state.ts @@ -0,0 +1,70 @@ +export type RecentAuthenticationState = { + authentication: "checking" | "recent" | "stale"; + password: string; + pending: boolean; + authenticationError: string | null; + continuationError: string | null; +}; + +export const initialRecentAuthenticationState: RecentAuthenticationState = { + authentication: "checking", + password: "", + pending: false, + authenticationError: null, + continuationError: null +}; + +export type RecentAuthenticationAction = + | { type: "check-started" } + | { type: "check-finished"; recent: boolean } + | { type: "check-failed"; message: string } + | { type: "password-changed"; password: string } + | { type: "submit-started" } + | { type: "authentication-failed"; message: string } + | { type: "authenticated" } + | { type: "continuation-failed"; message: string }; + +export function recentAuthenticationReducer( + state: RecentAuthenticationState, + action: RecentAuthenticationAction +): RecentAuthenticationState { + switch (action.type) { + case "check-started": + return initialRecentAuthenticationState; + case "check-finished": + return { ...state, authentication: action.recent ? "recent" : "stale" }; + case "check-failed": + return { ...state, authentication: "stale", authenticationError: action.message }; + case "password-changed": + return { ...state, password: action.password }; + case "submit-started": + return { + ...state, + pending: true, + authenticationError: null, + continuationError: null + }; + case "authentication-failed": + return { + ...state, + authentication: "stale", + pending: false, + authenticationError: action.message + }; + case "authenticated": + return { + authentication: "recent", + password: "", + pending: false, + authenticationError: null, + continuationError: null + }; + case "continuation-failed": + return { + ...state, + authentication: "recent", + pending: false, + continuationError: action.message + }; + } +} diff --git a/app/features/auth/recent-authentication.tsx b/app/features/auth/recent-authentication.tsx index a223ab62..f774dde8 100644 --- a/app/features/auth/recent-authentication.tsx +++ b/app/features/auth/recent-authentication.tsx @@ -8,6 +8,10 @@ import { DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { + initialRecentAuthenticationState, + recentAuthenticationReducer +} from "@/features/auth/recent-authentication-state"; import { getRecentAuthentication, reauthenticate } from "./recent-authentication-api"; export type RecentAuthenticationGateProps = { @@ -25,30 +29,27 @@ export function RecentAuthenticationGate({ ready, onAuthenticated }: RecentAuthenticationGateProps): React.ReactElement { - const [authentication, setAuthentication] = React.useState<"checking" | "recent" | "stale">( - "checking" + const [state, dispatch] = React.useReducer( + recentAuthenticationReducer, + initialRecentAuthenticationState ); - const [password, setPassword] = React.useState(""); - const [error, setError] = React.useState(null); - const [pending, setPending] = React.useState(false); + const passwordId = React.useId(); React.useEffect(() => { if (!active) return; let cancelled = false; - setAuthentication("checking"); - setPassword(""); - setError(null); - setPending(false); + dispatch({ type: "check-started" }); void getRecentAuthentication() .then((recent) => { - if (!cancelled) setAuthentication(recent ? "recent" : "stale"); + if (!cancelled) dispatch({ type: "check-finished", recent }); }) .catch((nextError: unknown) => { if (cancelled) return; - setAuthentication("stale"); - setError( - nextError instanceof Error ? nextError.message : "Your sign-in could not be confirmed." - ); + dispatch({ + type: "check-failed", + message: + nextError instanceof Error ? nextError.message : "Your sign-in could not be confirmed." + }); }); return () => { cancelled = true; @@ -57,31 +58,52 @@ export function RecentAuthenticationGate({ async function confirmPassword(event: React.FormEvent) { event.preventDefault(); - setPending(true); - setError(null); + dispatch({ type: "submit-started" }); try { - await reauthenticate(password); - setAuthentication("recent"); - await onAuthenticated?.(); + await reauthenticate(state.password); } catch (nextError) { - setAuthentication("stale"); - setError(nextError instanceof Error ? nextError.message : "Sign-in confirmation failed."); - setPending(false); + dispatch({ + type: "authentication-failed", + message: nextError instanceof Error ? nextError.message : "Sign-in confirmation failed." + }); + return; + } + + dispatch({ type: "authenticated" }); + try { + await onAuthenticated?.(); + } catch { + dispatch({ + type: "continuation-failed", + message: "Sign-in was confirmed, but the next action could not start. Try again." + }); } } - if (authentication === "recent") return <>{ready}; - if (authentication === "checking") { + if (state.authentication === "recent") { + return ( + <> + {state.continuationError ? ( +

+ {state.continuationError} +

+ ) : null} + {ready} + + ); + } + if (state.authentication === "checking") { return ; } return ( dispatch({ type: "password-changed", password })} onSubmit={(event) => void confirmPassword(event)} /> ); @@ -92,6 +114,7 @@ function ReauthenticationForm({ error, layout, password, + passwordId, pending, onPasswordChange, onSubmit @@ -100,6 +123,7 @@ function ReauthenticationForm({ error: string | null; layout: "dialog" | "inline"; password: string; + passwordId: string; pending: boolean; onPasswordChange: (value: string) => void; onSubmit: (event: React.FormEvent) => void; @@ -121,16 +145,13 @@ function ReauthenticationForm({
)}
-