diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts new file mode 100644 index 0000000000..4d2e45a819 --- /dev/null +++ b/cli/encrypted-token-store-template.test.ts @@ -0,0 +1,1369 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + assertEquals, + assertNotEquals, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { FakeTime } from "#std/testing/time"; +import type { StoredOAuthState } from "veryfront/oauth"; +import { + checkEncryptedTokenStoreRotation, + createEncryptedTokenStore, + type EncryptedKvBackend, + generateEncryptionKey, +} from "./templates/integrations/_base/files/lib/encrypted-token-store.ts"; +import { createMemoryKvBackend } from "./templates/integrations/_base/files/lib/token-store-examples.ts"; +import { createTokenStore } from "./templates/integrations/_base/files/lib/token-store.ts"; + +const ENVELOPE_PREFIX = "vf-aes-gcm.v2:"; +const LEGACY_ENVELOPE_PREFIX = "vf-aes-gcm.v1:"; + +function oauthState(userId: string): StoredOAuthState { + return { + userId, + serviceId: "github", + redirectUri: "https://app.example.com/api/auth/github/callback", + scopes: ["read:user"], + createdAt: Date.now(), + }; +} + +/** Split an envelope into its header (prefix + key id) and base64 body. */ +function splitEnvelope(stored: string): { header: string; body: string } { + const separator = stored.lastIndexOf(":"); + return { header: stored.slice(0, separator + 1), body: stored.slice(separator + 1) }; +} + +function envelopeIv(stored: string): number[] { + const bytes = Uint8Array.from( + atob(splitEnvelope(stored).body), + (character) => character.charCodeAt(0), + ); + return [...bytes.subarray(0, 12)]; +} + +function captureWarnings(): { warnings: string[]; [Symbol.dispose](): void } { + const original = console.warn; + const warnings: string[] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + return { + warnings, + [Symbol.dispose]() { + console.warn = original; + }, + }; +} + +/** Seal plaintext in the legacy v1 envelope (no key id) for compatibility tests. */ +async function sealLegacyV1Plaintext( + keyHex: string, + storageKey: string, + plaintext: string, +): Promise { + const keyBytes = new Uint8Array(32); + for (let index = 0; index < keyBytes.length; index++) { + keyBytes[index] = Number.parseInt(keyHex.slice(index * 2, index * 2 + 2), 16); + } + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(storageKey) }, + key, + new TextEncoder().encode(plaintext), + ), + ); + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv); + combined.set(ciphertext, iv.byteLength); + let binary = ""; + for (const byte of combined) binary += String.fromCharCode(byte); + return LEGACY_ENVELOPE_PREFIX + btoa(binary); +} + +/** Seal a JSON value in the legacy v1 envelope for compatibility tests. */ +async function sealLegacyV1( + keyHex: string, + storageKey: string, + value: unknown, +): Promise { + const plaintext = JSON.stringify(value); + if (plaintext === undefined) throw new TypeError("Legacy test value must be JSON serializable"); + return await sealLegacyV1Plaintext(keyHex, storageKey, plaintext); +} + +/** Seal a JSON value in the current v2 envelope for boundary tests. */ +async function sealV2(keyHex: string, storageKey: string, value: unknown): Promise { + const keyBytes = new Uint8Array(32); + for (let index = 0; index < keyBytes.length; index++) { + keyBytes[index] = Number.parseInt(keyHex.slice(index * 2, index * 2 + 2), 16); + } + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", keyBytes)); + const keyId = Array.from(digest.subarray(0, 8)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); + keyBytes.fill(0); + const plaintext = JSON.stringify(value); + if (plaintext === undefined) throw new TypeError("V2 test value must be JSON serializable"); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(storageKey) }, + key, + new TextEncoder().encode(plaintext), + ), + ); + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv); + combined.set(ciphertext, iv.byteLength); + let binary = ""; + for (const byte of combined) binary += String.fromCharCode(byte); + return ENVELOPE_PREFIX + keyId + ":" + btoa(binary); +} + +/** Expose the raw rows so tests can assert what actually hits storage. */ +function inspectableBackend(): EncryptedKvBackend & { rows: Map } { + const backend = createMemoryKvBackend(); + const rows = new Map(); + return { + rows, + async get(key) { + return await backend.get(key); + }, + async set(key, value, options) { + rows.set(key, value); + await backend.set(key, value, options); + }, + async delete(key) { + rows.delete(key); + await backend.delete(key); + }, + async compareAndSwap(key, expected, next, options) { + const swapped = await backend.compareAndSwap(key, expected, next, options); + if (swapped) { + if (next === null) rows.delete(key); + else rows.set(key, next); + } + return swapped; + }, + withLock(key, operation) { + return backend.withLock(key, operation); + }, + }; +} + +/** Model a durable backend that transports keys as UTF-8 bytes. */ +function utf8KeyBackend(): EncryptedKvBackend & { rows: Map } { + const rows = new Map(); + const wireKey = (key: string) => new TextDecoder().decode(new TextEncoder().encode(key)); + return { + rows, + get(key) { + return Promise.resolve(rows.get(wireKey(key)) ?? null); + }, + set(key, value) { + rows.set(wireKey(key), value); + return Promise.resolve(); + }, + delete(key) { + rows.delete(wireKey(key)); + return Promise.resolve(); + }, + compareAndSwap(key, expected, next) { + const normalized = wireKey(key); + if ((rows.get(normalized) ?? null) !== expected) return Promise.resolve(false); + if (next === null) rows.delete(normalized); + else rows.set(normalized, next); + return Promise.resolve(true); + }, + withLock(_key, operation) { + return operation(); + }, + }; +} + +function scanCapableBackend(): ReturnType & { + scan(prefix: string): AsyncIterable<{ key: string; value: string }>; +} { + const backend = inspectableBackend(); + return { + ...backend, + async *scan(prefix: string) { + for (const [key, value] of backend.rows) { + if (key.startsWith(prefix)) yield { key, value }; + } + }, + }; +} + +describe("generated encrypted OAuth token store", () => { + const originalKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + const originalPreviousKey = Deno.env.get("TOKEN_ENCRYPTION_KEY_PREVIOUS"); + const originalNodeEnv = Deno.env.get("NODE_ENV"); + + beforeEach(() => { + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.delete("TOKEN_ENCRYPTION_KEY_PREVIOUS"); + Deno.env.set("NODE_ENV", "development"); + }); + + afterEach(() => { + if (originalKey === undefined) Deno.env.delete("TOKEN_ENCRYPTION_KEY"); + else Deno.env.set("TOKEN_ENCRYPTION_KEY", originalKey); + if (originalPreviousKey === undefined) Deno.env.delete("TOKEN_ENCRYPTION_KEY_PREVIOUS"); + else Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", originalPreviousKey); + if (originalNodeEnv === undefined) Deno.env.delete("NODE_ENV"); + else Deno.env.set("NODE_ENV", originalNodeEnv); + }); + + it("fails closed when TOKEN_ENCRYPTION_KEY is not configured", () => { + Deno.env.delete("TOKEN_ENCRYPTION_KEY"); + + assertThrows( + () => createEncryptedTokenStore(createMemoryKvBackend()), + Error, + "TOKEN_ENCRYPTION_KEY is not set", + ); + }); + + it("treats denied Deno key access as an unset encryption key", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + const denoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "Deno", { + configurable: true, + value: { + env: { + get() { + throw new Error("PermissionDenied"); + }, + }, + }, + }); + + const backend: EncryptedKvBackend = { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + compareAndSwap: () => Promise.resolve(false), + withLock: (_key, operation) => operation(), + }; + + try { + assertThrows( + () => createEncryptedTokenStore(backend), + Error, + "TOKEN_ENCRYPTION_KEY is not set", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + if (denoDescriptor) Object.defineProperty(globalThis, "Deno", denoDescriptor); + else Reflect.deleteProperty(globalThis, "Deno"); + } + }); + + it("treats denied process key access as an unset encryption key", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: { + env: new Proxy({}, { + get() { + throw new Error("PermissionDenied"); + }, + }), + }, + }); + + const backend: EncryptedKvBackend = { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + compareAndSwap: () => Promise.resolve(false), + withLock: (_key, operation) => operation(), + }; + + try { + assertThrows( + () => createEncryptedTokenStore(backend), + Error, + "TOKEN_ENCRYPTION_KEY is not set", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + } + }); + + it("rejects malformed encryption keys instead of downgrading", () => { + for (const bad of ["", "not-hex", "abcd", "zz".repeat(32)]) { + Deno.env.set("TOKEN_ENCRYPTION_KEY", bad); + assertThrows( + () => createEncryptedTokenStore(createMemoryKvBackend()), + Error, + "TOKEN_ENCRYPTION_KEY", + ); + } + }); + + it("rejects a malformed TOKEN_ENCRYPTION_KEY_PREVIOUS instead of ignoring it", () => { + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", "not-hex"); + + assertThrows( + () => createEncryptedTokenStore(createMemoryKvBackend()), + TypeError, + "TOKEN_ENCRYPTION_KEY_PREVIOUS", + ); + }); + + it("rejects backends that cannot provide atomic operations", () => { + const incomplete = createMemoryKvBackend() as unknown as Record; + incomplete.compareAndSwap = undefined; + + assertThrows( + () => createEncryptedTokenStore(incomplete as never), + TypeError, + "compareAndSwap", + ); + }); + + it("never writes token plaintext to the backend", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + const tokens = { accessToken: "super-secret-access", refreshToken: "super-secret-refresh" }; + + await store.setTokens("github", "alice", tokens); + + assertEquals(backend.rows.size, 1); + for (const stored of backend.rows.values()) { + assertEquals(stored.startsWith(ENVELOPE_PREFIX), true); + // The v2 envelope records which key sealed the row. + assertEquals( + /^[0-9a-f]{16}:/.test(stored.slice(ENVELOPE_PREFIX.length)), + true, + ); + assertEquals(stored.includes("super-secret-access"), false); + assertEquals(stored.includes("super-secret-refresh"), false); + } + assertEquals(await store.getTokens("github", "alice"), tokens); + }); + + it("rejects token rows that require invoking getters", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const tokens = {}; + Object.defineProperty(tokens, "accessToken", { + enumerable: true, + get() { + throw new Error("getter must not run"); + }, + }); + + await assertRejects( + () => store.setTokens("github", "alice", tokens as never), + TypeError, + "accessToken", + ); + }); + + it("rejects malformed optional token string fields", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await assertRejects( + () => + store.setTokens("github", "alice", { + accessToken: "access-token", + refreshToken: 42, + } as never), + TypeError, + "refreshToken", + ); + await assertRejects( + () => + store.setTokens("github", "alice", { + accessToken: "access-token", + scope: "read\nwrite", + }), + TypeError, + "scope", + ); + }); + + it("normalizes surrounding whitespace in provider scope strings", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await store.setTokens("github", "alice", { + accessToken: "access-token", + scope: " read:user user:email ", + }); + + assertEquals((await store.getTokens("github", "alice"))?.scope, "read:user user:email"); + }); + + it("rejects control characters in token storage keys", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await assertRejects( + () => store.setTokens("git\nhub", "alice", { accessToken: "access-token" }), + TypeError, + "serviceId", + ); + await assertRejects( + () => store.setTokens("github", "ali\u0000ce", { accessToken: "access-token" }), + TypeError, + "userId", + ); + }); + + it("keeps ill-formed Unicode key components distinct across UTF-8 storage", async () => { + const backend = utf8KeyBackend(); + const store = createEncryptedTokenStore(backend); + + await store.setTokens("github", "\ud800", { accessToken: "lone-surrogate-token" }); + await store.setTokens("github", "\ufffd", { accessToken: "replacement-token" }); + + assertEquals(backend.rows.size, 2); + assertEquals((await store.getTokens("github", "\ud800"))?.accessToken, "lone-surrogate-token"); + assertEquals((await store.getTokens("github", "\ufffd"))?.accessToken, "replacement-token"); + }); + + it("stores tokens without invoking inherited JSON serializers", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + let inheritedSerializerCalls = 0; + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value() { + inheritedSerializerCalls++; + throw new Error("Object.prototype.toJSON must not run"); + }, + }); + Object.defineProperty(Array.prototype, "toJSON", { + configurable: true, + value() { + inheritedSerializerCalls++; + throw new Error("Array.prototype.toJSON must not run"); + }, + }); + + try { + await store.setTokens("github", "alice", { accessToken: "access-token" }); + } finally { + delete (Object.prototype as { toJSON?: unknown }).toJSON; + delete (Array.prototype as { toJSON?: unknown }).toJSON; + } + + assertEquals(inheritedSerializerCalls, 0); + assertEquals((await store.getTokens("github", "alice"))?.accessToken, "access-token"); + }); + + it("stores detached token snapshots", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const tokens = { accessToken: "original", refreshToken: "refresh" }; + + await store.setTokens("github", "alice", tokens); + tokens.accessToken = "mutated"; + + const stored = await store.getTokens("github", "alice"); + assertEquals(stored, { accessToken: "original", refreshToken: "refresh" }); + if (!stored) throw new Error("Expected stored tokens"); + stored.accessToken = "caller-mutated"; + assertEquals(await store.getTokens("github", "alice"), { + accessToken: "original", + refreshToken: "refresh", + }); + }); + + it("uses a fresh IV for every encryption", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + const tokens = { accessToken: "same-token" }; + + await store.setTokens("github", "alice", tokens); + const first = [...backend.rows.values()][0]; + await store.setTokens("github", "alice", tokens); + const second = [...backend.rows.values()][0]; + if (!first || !second) throw new Error("Expected encrypted token rows"); + + assertNotEquals(envelopeIv(first), envelopeIv(second)); + }); + + it("treats plaintext rows found in the backend as absent without reading them", async () => { + const backend = createMemoryKvBackend(); + const store = createEncryptedTokenStore(backend); + await backend.set( + 'veryfront:oauth:v1:tokens:["github","alice"]', + JSON.stringify({ revision: "r1", tokens: { accessToken: "legacy-raw-secret" } }), + ); + + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("unreadable OAuth token row"), true); + // The warning must never surface token material. + assertEquals(captured.warnings[0]?.includes("legacy-raw-secret"), false); + assertEquals(captured.warnings[0]?.includes("alice"), false); + }); + + it("does not log decrypted plaintext when authenticated payload JSON is malformed", async () => { + const backend = createMemoryKvBackend(); + const keyHex = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!keyHex) throw new Error("Expected a configured encryption key"); + const storageKey = 'veryfront:oauth:v1:tokens:["github","alice"]'; + const secret = "SENSITIVE_OAUTH_TOKEN_MUST_NOT_BE_LOGGED"; + await backend.set( + storageKey, + await sealLegacyV1Plaintext(keyHex, storageKey, secret), + ); + const store = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("malformed encrypted row"), true); + assertEquals(captured.warnings[0]?.includes(secret), false); + assertEquals(captured.warnings[0]?.includes(secret.slice(0, 10)), false); + assertEquals(captured.warnings[0]?.includes("alice"), false); + }); + + it("treats tampered ciphertext as absent instead of failing the read", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "secret" }); + + const entry = [...backend.rows.entries()][0]; + if (!entry) throw new Error("Expected a stored row"); + const [key, stored] = entry; + const { header, body } = splitEnvelope(stored); + const index = 20; + const replacement = body[index] === "A" ? "B" : "A"; + await backend.set( + key, + header + body.slice(0, index) + replacement + body.slice(index + 1), + ); + + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("failed authentication"), true); + }); + + it("binds ciphertext to its storage slot", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "alices-token" }); + + const stored = [...backend.rows.values()][0]; + if (!stored) throw new Error("Expected a stored row"); + await backend.set('veryfront:oauth:v1:tokens:["github","mallory"]', stored); + + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "mallory"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(await store.getTokens("github", "alice"), { accessToken: "alices-token" }); + }); + + it("treats values written under an unknown key as absent and recovers on reconnect", async () => { + const backend = createMemoryKvBackend(); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "secret" }); + const snapshot = await store.getTokenSnapshot("github", "alice"); + if (!snapshot) throw new Error("Expected a revisioned token snapshot"); + + // Rotation without TOKEN_ENCRYPTION_KEY_PREVIOUS: rows sealed with the + // retired key are unreadable, so they degrade to "disconnected" rather + // than failing every read. + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + const rotated = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await rotated.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("unknown encryption key"), true); + assertEquals( + await rotated.compareAndSetTokens("github", "alice", snapshot.revision, { + accessToken: "stale-refresh", + }), + false, + ); + + // The documented recovery: reconnecting overwrites the unreadable row. + await rotated.setTokens("github", "alice", { accessToken: "reconnected" }); + assertEquals(await rotated.getTokens("github", "alice"), { accessToken: "reconnected" }); + }); + + it("decrypts rows sealed with the previous key during rotation", async () => { + const backend = createMemoryKvBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "sealed-with-old-key" }); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + const rotated = createEncryptedTokenStore(backend); + + assertEquals(await rotated.getTokens("github", "alice"), { + accessToken: "sealed-with-old-key", + }); + + // Writing re-seals the row with the current key, so the previous key can + // be dropped afterwards. + await rotated.setTokens("github", "alice", { accessToken: "resealed" }); + Deno.env.delete("TOKEN_ENCRYPTION_KEY_PREVIOUS"); + const afterRotation = createEncryptedTokenStore(backend); + assertEquals(await afterRotation.getTokens("github", "alice"), { + accessToken: "resealed", + }); + }); + + it("reports idle rows that still require the previous key during rotation", async () => { + const backend = scanCapableBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "sealed-with-old-key" }); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + const rotated = createEncryptedTokenStore(backend); + + // The row has not been read or written under the new keyring yet, which + // is exactly what the scan is for. + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 1, + currentKeyRows: 0, + previousKeyRows: 1, + unreadableRows: 0, + complete: false, + }); + + // A single read re-seals the row with the current key, so the report + // converges without an explicit write. + assertEquals(await rotated.getTokens("github", "alice"), { + accessToken: "sealed-with-old-key", + }); + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 1, + currentKeyRows: 1, + previousKeyRows: 0, + unreadableRows: 0, + complete: true, + }); + }); + + it("reports authenticated rows with invalid payload schemas as unreadable", async () => { + const backend = scanCapableBackend(); + const keyHex = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!keyHex) throw new Error("Expected a configured encryption key"); + const tokenKey = 'veryfront:oauth:v1:tokens:["github","malformed-token-user"]'; + const stateKey = 'veryfront:oauth:v1:state:["malformed-state"]'; + await backend.set(tokenKey, await sealV2(keyHex, tokenKey, {})); + await backend.set(stateKey, await sealV2(keyHex, stateKey, {})); + + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 2, + currentKeyRows: 0, + previousKeyRows: 0, + unreadableRows: 2, + complete: false, + }); + }); + + it("ignores expired OAuth state rows during rotation scans", async () => { + const backend = scanCapableBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const stateKey = 'veryfront:oauth:v1:state:["expired-state"]'; + await backend.set( + stateKey, + await sealV2(retiringKey, stateKey, { + ...oauthState("alice"), + createdAt: Date.now() - 12 * 60_000, + }), + ); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 1, + currentKeyRows: 0, + previousKeyRows: 0, + unreadableRows: 0, + complete: true, + }); + }); + + it("keeps reading legacy v1 envelopes with any configured key", async () => { + const backend = createMemoryKvBackend(); + const legacyKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!legacyKey) throw new Error("Expected a configured encryption key"); + const storageKey = 'veryfront:oauth:v1:tokens:["github","alice"]'; + await backend.set( + storageKey, + await sealLegacyV1(legacyKey, storageKey, { + revision: "legacy-revision", + tokens: { accessToken: "legacy-token" }, + }), + ); + + // Read with the sealing key as the current key. + const store = createEncryptedTokenStore(backend); + assertEquals(await store.getTokens("github", "alice"), { accessToken: "legacy-token" }); + + // Read with the sealing key demoted to the previous key. + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", legacyKey); + await backend.set( + storageKey, + await sealLegacyV1(legacyKey, storageKey, { + revision: "legacy-revision", + tokens: { accessToken: "legacy-token" }, + }), + ); + const rotated = createEncryptedTokenStore(backend); + assertEquals(await rotated.getTokens("github", "alice"), { accessToken: "legacy-token" }); + }); + + it("re-seals rows decrypted with the previous key on read", async () => { + const backend = inspectableBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "sealed-with-old-key" }); + const snapshot = await store.getTokenSnapshot("github", "alice"); + if (!snapshot) throw new Error("Expected a revisioned token snapshot"); + const sealedWithRetiringKey = [...backend.rows.values()][0]; + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + const rotated = createEncryptedTokenStore(backend); + assertEquals(await rotated.getTokens("github", "alice"), { + accessToken: "sealed-with-old-key", + }); + + // The read itself re-sealed the row with the current key... + const resealed = [...backend.rows.values()][0]; + assertNotEquals(resealed, sealedWithRetiringKey); + assertEquals(resealed?.startsWith(ENVELOPE_PREFIX), true); + // ...preserving the logical revision: a re-seal is not a token update. + assertEquals( + (await rotated.getTokenSnapshot("github", "alice"))?.revision, + snapshot.revision, + ); + + // The previous key can now be dropped without any explicit write. + Deno.env.delete("TOKEN_ENCRYPTION_KEY_PREVIOUS"); + const afterRotation = createEncryptedTokenStore(backend); + assertEquals(await afterRotation.getTokens("github", "alice"), { + accessToken: "sealed-with-old-key", + }); + }); + + it("upgrades legacy v1 envelopes to v2 on read", async () => { + const backend = inspectableBackend(); + const keyHex = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!keyHex) throw new Error("Expected a configured encryption key"); + const storageKey = 'veryfront:oauth:v1:tokens:["github","alice"]'; + await backend.set( + storageKey, + await sealLegacyV1(keyHex, storageKey, { + revision: "legacy-revision", + tokens: { accessToken: "legacy-token" }, + }), + ); + const store = createEncryptedTokenStore(backend); + + assertEquals(await store.getTokens("github", "alice"), { accessToken: "legacy-token" }); + + const upgraded = backend.rows.get(storageKey); + assertEquals(upgraded?.startsWith(ENVELOPE_PREFIX), true); + assertEquals( + (await store.getTokenSnapshot("github", "alice"))?.revision, + "legacy-revision", + ); + }); + + it("keeps reads working when the best-effort re-seal cannot be persisted", async () => { + const inner = createMemoryKvBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const backend: EncryptedKvBackend = { + get: (key) => inner.get(key), + set: (key, value, options) => inner.set(key, value, options), + delete: (key) => inner.delete(key), + compareAndSwap: () => Promise.reject(new Error("backend rejects swaps")), + withLock: (key, operation) => inner.withLock(key, operation), + }; + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "still-readable" }); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + const rotated = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await rotated.getTokens("github", "alice"), { accessToken: "still-readable" }); + assertEquals(captured.warnings.length, 0); + // The row is unchanged; the next read simply tries the re-seal again. + assertEquals(await rotated.getTokens("github", "alice"), { accessToken: "still-readable" }); + }); + + it("supports compare-and-set through a row re-sealed during its own pre-read", async () => { + const backend = createMemoryKvBackend(); + const retiringKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!retiringKey) throw new Error("Expected a configured encryption key"); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "access-1" }); + const snapshot = await store.getTokenSnapshot("github", "alice"); + if (!snapshot) throw new Error("Expected a revisioned token snapshot"); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); + const rotated = createEncryptedTokenStore(backend); + + // The pre-read re-seals the row, so the swap must target the re-sealed + // ciphertext rather than the one fetched at the start of the call. + assertEquals( + await rotated.compareAndSetTokens("github", "alice", snapshot.revision, { + accessToken: "access-2", + }), + true, + ); + assertEquals((await rotated.getTokens("github", "alice"))?.accessToken, "access-2"); + }); + + it("treats rows with a malformed v2 key id segment as absent", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "secret-token" }); + const entry = [...backend.rows.entries()][0]; + if (!entry) throw new Error("Expected a stored row"); + const [key, stored] = entry; + const body = stored.slice(ENVELOPE_PREFIX.length); + + const malformedRows = [ + // Key ids are lowercase hex by construction, so uppercase is rejected. + ENVELOPE_PREFIX + "A" + body.slice(1), + // Missing ":" separator after the key id. + ENVELOPE_PREFIX + body.slice(0, 16) + "." + body.slice(17), + ]; + for (const malformed of malformedRows) { + await backend.set(key, malformed); + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("malformed encrypted row"), true); + assertEquals(captured.warnings[0]?.includes("secret-token"), false); + } + }); + + it("degrades legacy v1 envelopes that no configured key can decrypt", async () => { + const backend = createMemoryKvBackend(); + Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", generateEncryptionKey()); + const storageKey = 'veryfront:oauth:v1:tokens:["github","alice"]'; + await backend.set( + storageKey, + // Sealed with a key that is in neither ring slot. + await sealLegacyV1(generateEncryptionKey(), storageKey, { + revision: "r1", + tokens: { accessToken: "unreachable-secret" }, + }), + ); + const store = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await store.getTokens("github", "alice"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("failed authentication"), true); + assertEquals(captured.warnings[0]?.includes("unreachable-secret"), false); + }); + + it("supports revisioned compare-and-set for token refresh", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + await store.setTokens("github", "alice", { accessToken: "access-1" }); + + const snapshot = await store.getTokenSnapshot("github", "alice"); + if (!snapshot) throw new Error("Expected a revisioned token snapshot"); + + assertEquals( + await store.compareAndSetTokens("github", "alice", "stale-revision", { + accessToken: "loser", + }), + false, + ); + assertEquals( + await store.compareAndSetTokens("github", "alice", snapshot.revision, { + accessToken: "access-2", + }), + true, + ); + assertEquals((await store.getTokens("github", "alice"))?.accessToken, "access-2"); + // The consumed revision can no longer win a second swap. + assertEquals( + await store.compareAndSetTokens("github", "alice", snapshot.revision, { + accessToken: "replayed", + }), + false, + ); + }); + + it("serializes refresh operations through the backend lock", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const order: string[] = []; + + await Promise.all([ + store.withTokenRefreshLock("github", "alice", async () => { + order.push("first-start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push("first-end"); + }), + store.withTokenRefreshLock("github", "alice", async () => { + order.push("second-start"); + }), + ]); + + assertEquals(order, ["first-start", "first-end", "second-start"]); + }); + + it("stores OAuth state one-shot and rejects duplicates", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const state = oauthState("alice"); + + await store.setState("state-1", state); + await assertRejects(() => store.setState("state-1", state), Error, "already exists"); + + assertEquals(await store.consumeState("state-1"), state); + assertEquals(await store.consumeState("state-1"), null); + assertEquals(await store.consumeState("never-set"), null); + }); + + it("rejects unsafe state storage keys", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await assertRejects( + () => store.setState("state\nheader", oauthState("alice")), + TypeError, + "state", + ); + }); + + it("rejects incomplete or malformed OAuth state rows", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await assertRejects( + () => store.setState("missing-redirect", { ...oauthState("alice"), redirectUri: undefined }), + TypeError, + "redirectUri", + ); + await assertRejects( + () => + store.setState("unsafe-redirect", { + ...oauthState("alice"), + redirectUri: "javascript:alert(1)", + }), + TypeError, + "redirectUri", + ); + await assertRejects( + () => store.setState("invalid-scopes", { ...oauthState("alice"), scopes: ["read user"] }), + TypeError, + "scopes", + ); + await assertRejects( + () => store.setState("unsafe-user", oauthState("ali\u0000ce")), + TypeError, + "userId", + ); + }); + + it("rejects OAuth state metadata that requires invoking getters", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const metadata = {}; + Object.defineProperty(metadata, "tenantId", { + enumerable: true, + get() { + throw new Error("getter must not run"); + }, + }); + + await assertRejects( + () => store.setState("state-with-metadata", { ...oauthState("alice"), metadata }), + TypeError, + "metadata", + ); + }); + + it("rejects sparse OAuth state metadata arrays before allocating a snapshot", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const sparseValues: unknown[] = []; + sparseValues.length = 0xffff_ffff; + + await assertRejects( + () => + store.setState("state-with-sparse-metadata", { + ...oauthState("alice"), + metadata: { values: sparseValues }, + }), + RangeError, + "too many JSON values", + ); + }); + + it("rejects oversized metadata strings before JSON serialization", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + + await assertRejects( + () => + store.setState("state-with-oversized-metadata", { + ...oauthState("alice"), + metadata: { value: "x".repeat(65_537) }, + }), + RangeError, + "too much JSON string data", + ); + }); + + it("rejects cyclic OAuth state metadata deterministically", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const metadata: Record = {}; + metadata.self = metadata; + + await assertRejects( + () => + store.setState("state-with-cyclic-metadata", { + ...oauthState("alice"), + metadata, + }), + TypeError, + "cyclic JSON data", + ); + }); + + it("rejects excessively nested OAuth state metadata", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const metadata: Record = {}; + let cursor = metadata; + for (let depth = 0; depth < 64; depth++) { + const child: Record = {}; + cursor.child = child; + cursor = child; + } + + await assertRejects( + () => + store.setState("state-with-deep-metadata", { + ...oauthState("alice"), + metadata, + }), + RangeError, + "maximum JSON nesting depth", + ); + }); + + it("preserves own metadata keys named __proto__ across runtimes", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const metadata = JSON.parse('{"__proto__":{"tenant":"north"},"ok":true}'); + const legacyDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__"); + let legacySetterCalls = 0; + Object.defineProperty(Object.prototype, "__proto__", { + configurable: true, + get() { + return null; + }, + set() { + legacySetterCalls++; + }, + }); + + try { + await store.setState("state-with-proto-metadata", { + ...oauthState("alice"), + metadata, + }); + } finally { + if (legacyDescriptor) { + Object.defineProperty(Object.prototype, "__proto__", legacyDescriptor); + } else { + Reflect.deleteProperty(Object.prototype, "__proto__"); + } + } + + assertEquals(legacySetterCalls, 0); + const consumed = await store.consumeState("state-with-proto-metadata"); + const storedMetadata = consumed?.metadata; + if (!storedMetadata) throw new Error("Expected stored OAuth state metadata"); + assertEquals(Object.keys(storedMetadata), ["__proto__", "ok"]); + assertEquals(Object.getOwnPropertyDescriptor(storedMetadata, "__proto__")?.value, { + tenant: "north", + }); + assertEquals(storedMetadata.ok, true); + }); + + it("stores OAuth state without invoking inherited JSON serializers", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const state = oauthState("alice"); + let inheritedSerializerCalls = 0; + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value() { + inheritedSerializerCalls++; + throw new Error("Object.prototype.toJSON must not run"); + }, + }); + Object.defineProperty(Array.prototype, "toJSON", { + configurable: true, + value() { + inheritedSerializerCalls++; + throw new Error("Array.prototype.toJSON must not run"); + }, + }); + + try { + await store.setState("state-without-hooks", state); + } finally { + delete (Object.prototype as { toJSON?: unknown }).toJSON; + delete (Array.prototype as { toJSON?: unknown }).toJSON; + } + + assertEquals(inheritedSerializerCalls, 0); + assertEquals(await store.consumeState("state-without-hooks"), state); + }); + + it("warns with a sanitized category for malformed consumed OAuth state rows", async () => { + const backend = inspectableBackend(); + const keyHex = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + if (!keyHex) throw new Error("Expected a configured encryption key"); + const storageKey = 'veryfront:oauth:v1:state:["malformed-state"]'; + const secret = "SENSITIVE_STATE_PAYLOAD_MUST_NOT_BE_LOGGED"; + await backend.set( + storageKey, + await sealLegacyV1Plaintext(keyHex, storageKey, secret), + ); + const store = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await store.consumeState("malformed-state"), null); + assertEquals(captured.warnings.length, 1); + assertEquals(captured.warnings[0]?.includes("malformed encrypted state row"), true); + assertEquals(captured.warnings[0]?.includes(secret), false); + assertEquals(captured.warnings[0]?.includes("malformed-state"), false); + }); + + it("warns with sanitized categories for tampered or wrong-key consumed OAuth state rows", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + await store.setState("corrupted-state", { + ...oauthState("alice"), + metadata: { tenantId: "sensitive-tenant" }, + }); + await store.setState("wrong-key-state", oauthState("bob")); + + const corruptedKey = 'veryfront:oauth:v1:state:["corrupted-state"]'; + const stored = backend.rows.get(corruptedKey); + if (!stored) throw new Error("Expected a stored state row"); + const { header, body } = splitEnvelope(stored); + const index = 20; + const replacement = body[index] === "A" ? "B" : "A"; + await backend.set( + corruptedKey, + header + body.slice(0, index) + replacement + body.slice(index + 1), + ); + + const wrongKeyStore = createEncryptedTokenStore(backend); + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + const rotatedWithoutPrevious = createEncryptedTokenStore(backend); + + using captured = captureWarnings(); + assertEquals(await wrongKeyStore.consumeState("corrupted-state"), null); + assertEquals(await rotatedWithoutPrevious.consumeState("wrong-key-state"), null); + assertEquals(await store.consumeState("corrupted-state"), null); + assertEquals(captured.warnings, [ + "[Encrypted Token Store] Ignoring unreadable OAuth state row " + + "(failed authentication). The OAuth callback state is rejected.", + "[Encrypted Token Store] Ignoring unreadable OAuth state row " + + "(unknown encryption key). The OAuth callback state is rejected.", + ]); + }); + + it("rejects state rows outside the acceptance window", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const expired = { ...oauthState("alice"), createdAt: Date.now() - 12 * 60_000 }; + + // Insertion refuses rows that are already outside the window. + await assertRejects(() => store.setState("late", expired), RangeError, "acceptance window"); + }); + + it("rejects OAuth state older than the TTL without adding clock skew", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const expired = { ...oauthState("alice"), createdAt: Date.now() - 10.5 * 60_000 }; + + await assertRejects( + () => store.setState("past-skew", expired), + RangeError, + "acceptance window", + ); + }); + + it("accepts OAuth state created within the allowed future clock skew", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + const future = { ...oauthState("alice"), createdAt: Date.now() + 30_000 }; + + await store.setState("future-skew", future); + + assertEquals(await store.consumeState("future-skew"), future); + }); + + it("drops expired state rows even when the backend ignores TTL hints", async () => { + // A backend that never expires rows, so only the store's own freshness + // check stands between an old state and a replayed callback. + const rows = new Map(); + const backend: EncryptedKvBackend = { + get: (key) => Promise.resolve(rows.get(key) ?? null), + set: (key, value) => { + rows.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + rows.delete(key); + return Promise.resolve(); + }, + compareAndSwap: (key, expected, next) => { + if ((rows.get(key) ?? null) !== expected) return Promise.resolve(false); + if (next === null) rows.delete(key); + else rows.set(key, next); + return Promise.resolve(true); + }, + withLock: (_key, operation) => operation(), + }; + + using time = new FakeTime(); + const store = createEncryptedTokenStore(backend); + await store.setState("old", oauthState("alice")); + + // Past the 10-minute window (plus skew) the CSRF token is no longer + // redeemable, and the atomic consume means a retry cannot resurrect it. + time.tick(12 * 60_000); + assertEquals(await store.consumeState("old"), null); + assertEquals(await store.consumeState("old"), null); + }); + + it("clears tokens on revocation", async () => { + const store = createEncryptedTokenStore(createMemoryKvBackend()); + await store.setTokens("github", "alice", { accessToken: "secret" }); + await store.clearTokens("github", "alice"); + assertEquals(await store.getTokens("github", "alice"), null); + }); + + it("plugs into the generated token-store adapter", async () => { + const adapter = createTokenStore(createEncryptedTokenStore(createMemoryKvBackend())); + const tokens = { accessToken: "adapted", expiresAt: Date.now() + 60_000 }; + + await adapter.setToken("alice", "github", tokens); + assertEquals(await adapter.getToken("alice", "github"), tokens); + assertEquals(await adapter.isConnected("alice", "github"), true); + await adapter.revokeToken("alice", "github"); + assertEquals(await adapter.getToken("alice", "github"), null); + }); + + it("refuses the in-memory example backend in production", () => { + Deno.env.set("NODE_ENV", "production"); + + assertThrows( + () => createMemoryKvBackend(), + Error, + "not allowed in production", + ); + }); + + it("refuses the in-memory example backend when runtime mode is unset", () => { + Deno.env.delete("NODE_ENV"); + + assertThrows( + () => createMemoryKvBackend(), + Error, + "explicit development or test", + ); + }); + + it("treats denied Deno runtime mode access as unset for the example backend", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + const denoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "Deno", { + configurable: true, + value: { + env: { + get() { + throw new Error("PermissionDenied"); + }, + }, + }, + }); + + try { + assertThrows( + () => createMemoryKvBackend(), + Error, + "explicit development or test", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + if (denoDescriptor) Object.defineProperty(globalThis, "Deno", denoDescriptor); + else Reflect.deleteProperty(globalThis, "Deno"); + } + }); + + it("treats denied process runtime mode access as unset for the example backend", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: { + env: new Proxy({}, { + get() { + throw new Error("PermissionDenied"); + }, + }), + }, + }); + + try { + assertThrows( + () => createMemoryKvBackend(), + Error, + "explicit development or test", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + } + }); + + it("uses Deno runtime mode when process exists without env", () => { + Deno.env.set("NODE_ENV", "development"); + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: {}, + }); + + try { + assertEquals(typeof createMemoryKvBackend(), "object"); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + } + }); +}); diff --git a/cli/templates/index.test.ts b/cli/templates/index.test.ts index de58dc9891..163285047a 100644 --- a/cli/templates/index.test.ts +++ b/cli/templates/index.test.ts @@ -412,7 +412,12 @@ describe("cli/templates", () => { "./integrations/_base/files/lib/token-store.ts", import.meta.url, ); + const tokenStoreExamplesPath = new URL( + "./integrations/_base/files/lib/token-store-examples.ts", + import.meta.url, + ); const tokenStore = await Deno.readTextFile(tokenStorePath); + const tokenStoreExamples = await Deno.readTextFile(tokenStoreExamplesPath); assertEquals( tokenStore.includes("createDefaultTokenStore"), @@ -420,9 +425,19 @@ describe("cli/templates", () => { "token-store.ts should centralize default store selection", ); assertEquals( - tokenStore.includes("OAuth token storage is not configured for production"), + tokenStore.includes("only when NODE_ENV is explicitly development or test"), + true, + "token-store.ts should fail closed outside explicit development and test modes", + ); + assertEquals( + tokenStore.includes("The built-in memory store is for development and test."), true, - "token-store.ts should fail closed for production memory storage", + "token-store.ts header should match the development/test memory-store guard", + ); + assertEquals( + tokenStoreExamples.includes("Development/test in-memory backend."), + true, + "token-store-examples.ts should match the development/test memory-store guard", ); assertEquals( tokenStore.includes("getDefaultTokenStore"), @@ -456,6 +471,52 @@ describe("cli/templates", () => { ); }); + it("generated OAuth refresh helpers use the shared lock and CAS protocol", async () => { + const integrationTemplates = new URL("./integrations/", import.meta.url); + const offenders: string[] = []; + let helperCount = 0; + + for (const file of await collectTemplateTsFiles(integrationTemplates)) { + const source = await Deno.readTextFile(file); + if (!source.includes("export async function getValidToken(")) continue; + + helperCount++; + if ( + !source.includes("getRefreshableAccessToken(") || + source.includes("tokenStore.setToken(") || + source.includes("tokenStore.revokeToken(") + ) { + offenders.push(file.pathname.replace(integrationTemplates.pathname, "")); + } + } + + assertEquals(helperCount, 3, "Expected every generated getValidToken implementation"); + assertEquals( + offenders, + [], + `OAuth refresh helpers must use the shared lock/CAS protocol. Offenders: ${ + offenders.join(", ") + }`, + ); + }); + + it("keeps Gmail on the shared refresh-capable token store", async () => { + const gmailClient = await Deno.readTextFile( + new URL("./integrations/gmail/files/lib/gmail-client.ts", import.meta.url), + ); + + assertEquals( + gmailClient.includes("new OAuthService(gmailConfig, tokenStore)"), + true, + "Gmail must preserve the shared store's refresh lock and revisioned CAS methods", + ); + assertEquals( + gmailClient.includes("tokenStoreAdapter"), + false, + "Gmail must not narrow the refresh-capable token store contract", + ); + }); + it("OAuth route templates use the central shared token store", async () => { const integrationTemplates = new URL("./integrations/", import.meta.url); const offenders: string[] = []; diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts new file mode 100644 index 0000000000..acf2e1bc93 --- /dev/null +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -0,0 +1,1051 @@ +/** + * Encrypted key-value OAuth token store for generated integrations. + * + * Wraps any durable key-value service (Redis, Postgres, Deno KV, a cloud KV + * API) in the `RefreshCapableTokenStore` contract that `configureTokenStore` + * in `token-store.ts` expects. Every value is encrypted at rest with + * AES-256-GCM via the Web Crypto API before it reaches the backend: + * + * - A fresh random 96-bit IV is generated for every encryption. + * - The storage key is bound as AES-GCM additional authenticated data, so a + * ciphertext copied between storage slots fails authentication. + * - The key comes from the `TOKEN_ENCRYPTION_KEY` environment variable + * (64 hex characters = 256 bits). There is NO plaintext fallback: creating + * the store without a valid key throws, and values that are not in the + * expected encrypted envelope are refused on read. + * + * Key rotation: set `TOKEN_ENCRYPTION_KEY` to the new key and move the old + * key to `TOKEN_ENCRYPTION_KEY_PREVIOUS`. New writes are sealed with the new + * key (the v2 envelope records a key id derived from the key), while rows + * sealed with the previous key stay readable. Every token row decrypted with + * a non-current key is transparently re-sealed with the current key: always + * on the next write, and best-effort on every read, so rotation converges + * even for rows that are read but never rewritten. Use + * `checkEncryptedTokenStoreRotation` to confirm no rows still need the + * previous key, then remove `TOKEN_ENCRYPTION_KEY_PREVIOUS`; stragglers + * degrade to "disconnected" and recover on reconnect. + * + * Legacy v1 envelopes (no key id) were written only by earlier revisions of + * this template; they are decrypted by trying every configured key and are + * upgraded to v2 by the same re-seal-on-read path, so the compatibility + * branch retires itself as rows are read. + * + * Undecryptable token rows (unknown key, tampering, legacy plaintext) never + * fail a whole request: the token read paths log a warning and report the + * integration as disconnected, so the recovery is simply reconnecting (a + * fresh `setTokens` overwrites the row; `clearTokens` removes it). + * + * Generate a key once per deployment and set it before startup: + * + * ```sh + * openssl rand -hex 32 + * ``` + * + * Concurrency (compare-and-swap, refresh locking) is delegated to the + * backend so the guarantees hold across workers; this module never emulates + * distributed behavior in process memory. See `token-store-examples.ts` for + * reference backends and wiring. + */ + +import type { + OAuthTokens, + OAuthTokenSnapshot, + RefreshCapableTokenStore, + StoredOAuthState, +} from "veryfront/oauth"; + +/** + * Minimal durable backend contract. All five operations are required; the + * atomic ones are what make token refresh and one-shot OAuth state safe + * across workers. + */ +export interface EncryptedKvBackend { + /** Read the raw stored value for a key, or null when absent. */ + get(key: string): Promise; + /** + * Durably write a value, replacing any existing one. `expiresInMs`, when + * provided, is a TTL after which the backend may drop the row. + */ + set(key: string, value: string, options?: { expiresInMs?: number }): Promise; + /** Remove a key. Deleting an absent key is not an error. */ + delete(key: string): Promise; + /** + * Atomically replace `expected` with `next`. `expected === null` requires + * the key to be absent; `next === null` deletes the key. Returns false + * (without writing) when the current value does not match `expected`. + */ + compareAndSwap( + key: string, + expected: string | null, + next: string | null, + options?: { expiresInMs?: number }, + ): Promise; + /** + * Run `operation` while holding a mutual-exclusion lease for `key` that is + * visible to every worker (for example a Redis lock or an advisory lock). + * The lease must be bounded so a crashed holder cannot block refresh + * forever. + */ + withLock(key: string, operation: () => Promise): Promise; +} + +export interface EncryptedKvRotationScanBackend extends EncryptedKvBackend { + /** + * Iterate stored rows whose key starts with `prefix`. Use a backend-native + * bounded cursor or paginated scan; do not load an unbounded keyspace into + * memory before yielding. + */ + scan(prefix: string): AsyncIterable<{ key: string; value: string }>; +} + +export interface EncryptedTokenStoreRotationReport { + scannedRows: number; + currentKeyRows: number; + previousKeyRows: number; + unreadableRows: number; + complete: boolean; +} + +const ENCRYPTION_KEY_ENV_VAR = "TOKEN_ENCRYPTION_KEY"; +const PREVIOUS_ENCRYPTION_KEY_ENV_VAR = "TOKEN_ENCRYPTION_KEY_PREVIOUS"; +const ENVELOPE_PREFIX = "vf-aes-gcm.v2:"; +const LEGACY_ENVELOPE_PREFIX = "vf-aes-gcm.v1:"; +const KEY_ID_HEX_LENGTH = 16; +const KEY_ID_PATTERN = /^[0-9a-f]{16}$/; +const AES_GCM_IV_BYTES = 12; +const AES_GCM_TAG_BYTES = 16; +const AES_KEY_BYTES = 32; +const MAX_PLAINTEXT_BYTES = 64 * 1024; +const MAX_ENCRYPTED_BYTES = MAX_PLAINTEXT_BYTES + AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES; +const MAX_ENCODED_LENGTH = Math.ceil(MAX_ENCRYPTED_BYTES / 3) * 4 + ENVELOPE_PREFIX.length + + KEY_ID_HEX_LENGTH + 1; +const BASE64_CHUNK_BYTES = 0x8000; +const MAX_KEY_COMPONENT_LENGTH = 1_024; +const MAX_STATE_KEY_LENGTH = 1_024; +const STATE_TTL_MS = 10 * 60 * 1_000; +const STATE_CLOCK_SKEW_MS = 60 * 1_000; +const MAX_SERVICE_ID_LENGTH = 128; +const MAX_SCOPE_COUNT = 100; +const MAX_REDIRECT_URI_LENGTH = 8_192; +const MAX_TOKEN_VALUE_LENGTH = 65_536; +const MAX_TOKEN_TYPE_LENGTH = 256; +const MAX_SCOPE_WIRE_LENGTH = 4_096; +// A JSON array containing one-character values needs two bytes per value once +// separators are included. Bounding the traversal before cloning therefore +// prevents sparse arrays or deeply nested metadata from consuming memory +// before the final plaintext-size check can run. +const MAX_JSON_VALUE_COUNT = Math.floor((MAX_PLAINTEXT_BYTES + 1) / 2); +const MAX_JSON_NESTING_DEPTH = 64; + +const SERVICE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/; +const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/; + +const TOKENS_KEY_PREFIX = "veryfront:oauth:v1:tokens:"; +const STATE_KEY_PREFIX = "veryfront:oauth:v1:state:"; +const REFRESH_LOCK_KEY_PREFIX = "veryfront:oauth:v1:refresh-lock:"; + +const REQUIRED_BACKEND_METHODS = [ + "get", + "set", + "delete", + "compareAndSwap", + "withLock", +] as const; + +function readEnvironmentVariable(name: string): string | undefined { + try { + if (typeof process !== "undefined" && process.env) return process.env[name]; + } catch { + // Deno exposes the Node-compatible `process` global even when env access + // is denied. Treat that denial as an unavailable value; never bypass it + // through a second environment API. + return undefined; + } + try { + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.(name); + } catch { + return undefined; + } +} + +/** Generate a fresh 256-bit key encoded as 64 hex characters. */ +export function generateEncryptionKey(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(AES_KEY_BYTES))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function parseEncryptionKeyHex(keyHex: string, envVar: string): Uint8Array { + if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) { + throw new TypeError( + `${envVar} must be exactly 64 hexadecimal characters ` + + "(a 256-bit AES key). Generate one with `openssl rand -hex 32`.", + ); + } + const key = new Uint8Array(AES_KEY_BYTES); + for (let index = 0; index < key.length; index++) { + key[index] = Number.parseInt(keyHex.slice(index * 2, index * 2 + 2), 16); + } + return key; +} + +/** + * Resolve the configured encryption key or fail closed. This store never + * writes plaintext credentials, so a missing key is a hard error rather than + * a downgrade. + */ +function requireEncryptionKeyBytes(): Uint8Array { + const configured = readEnvironmentVariable(ENCRYPTION_KEY_ENV_VAR); + if (configured === undefined || configured === "") { + throw new Error( + `${ENCRYPTION_KEY_ENV_VAR} is not set. The encrypted token store refuses ` + + "to persist plaintext OAuth credentials. Generate a key with " + + "`openssl rand -hex 32` (or generateEncryptionKey()) and set " + + `${ENCRYPTION_KEY_ENV_VAR} before starting the app.`, + ); + } + return parseEncryptionKeyHex(configured, ENCRYPTION_KEY_ENV_VAR); +} + +/** + * Resolve the decryption key ring: the current key first (used for every + * new write), then the optional previous key kept readable during rotation. + */ +function resolveEncryptionKeyRing(): Uint8Array[] { + const ring = [requireEncryptionKeyBytes()]; + const previous = readEnvironmentVariable(PREVIOUS_ENCRYPTION_KEY_ENV_VAR); + if (previous !== undefined && previous !== "") { + ring.push(parseEncryptionKeyHex(previous, PREVIOUS_ENCRYPTION_KEY_ENV_VAR)); + } + return ring; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.byteLength; offset += BASE64_CHUNK_BYTES) { + const chunk = bytes.subarray(offset, offset + BASE64_CHUNK_BYTES); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +} + +function base64ToBytes(encoded: string): Uint8Array { + if ( + encoded.length === 0 || encoded.length % 4 !== 0 || + !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) + ) { + throw new TypeError("Encrypted OAuth value has invalid base64 encoding"); + } + const binary = atob(encoded); + if ( + binary.length < AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES || + binary.length > MAX_ENCRYPTED_BYTES + ) { + throw new RangeError("Encrypted OAuth value has an invalid size"); + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function requireKeyComponent(value: string, label: string): string { + if ( + typeof value !== "string" || value.length === 0 || + value.length > MAX_KEY_COMPONENT_LENGTH || value.trim() !== value || + hasAsciiControlCharacter(value) + ) { + throw new TypeError( + `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters without control characters`, + ); + } + return value; +} + +function quoteJsonString(value: string): string { + let quoted = '"'; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + switch (code) { + case 0x08: + quoted += "\\b"; + break; + case 0x09: + quoted += "\\t"; + break; + case 0x0a: + quoted += "\\n"; + break; + case 0x0c: + quoted += "\\f"; + break; + case 0x0d: + quoted += "\\r"; + break; + case 0x22: + quoted += '\\"'; + break; + case 0x5c: + quoted += "\\\\"; + break; + default: + if (code <= 0x1f) { + quoted += "\\u" + code.toString(16).padStart(4, "0"); + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + quoted += value[index]! + value[index + 1]!; + index++; + } else { + quoted += "\\u" + code.toString(16).padStart(4, "0"); + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + quoted += "\\u" + code.toString(16).padStart(4, "0"); + } else { + quoted += value[index]; + } + } + } + return quoted + '"'; +} + +function jsonArrayFrame(values: readonly string[]): string { + return "[" + values.map(quoteJsonString).join(",") + "]"; +} + +function tokensStorageKey(serviceId: string, userId: string): string { + return TOKENS_KEY_PREFIX + jsonArrayFrame([ + requireKeyComponent(serviceId, "serviceId"), + requireKeyComponent(userId, "userId"), + ]); +} + +function refreshLockKey(serviceId: string, userId: string): string { + return REFRESH_LOCK_KEY_PREFIX + jsonArrayFrame([ + requireKeyComponent(serviceId, "serviceId"), + requireKeyComponent(userId, "userId"), + ]); +} + +function stateStorageKey(state: string): string { + if (typeof state !== "string") { + throw new TypeError("state must be a string"); + } + if (state.length === 0 || state.length > MAX_STATE_KEY_LENGTH) { + throw new RangeError( + `state must contain between 1 and ${MAX_STATE_KEY_LENGTH} characters`, + ); + } + if (state.trim() !== state || hasAsciiControlCharacter(state)) { + throw new TypeError("state must not contain surrounding whitespace or control characters"); + } + return STATE_KEY_PREFIX + jsonArrayFrame([state]); +} + +interface StoredTokenEntry { + revision: string; + tokens: OAuthTokens; +} + +function ownDataValue(record: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +function hasAsciiControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +interface JsonTraversalState { + ancestors: WeakSet; + depth: number; + remainingStringCodeUnits: number; + remainingValues: number; +} + +function createJsonTraversalState(): JsonTraversalState { + return { + ancestors: new WeakSet(), + depth: 0, + remainingStringCodeUnits: MAX_PLAINTEXT_BYTES, + remainingValues: MAX_JSON_VALUE_COUNT, + }; +} + +function consumeJsonStringBudget( + state: JsonTraversalState, + value: string, + label: string, +): void { + if (value.length > state.remainingStringCodeUnits) { + throw new RangeError(`${label} contains too much JSON string data`); + } + state.remainingStringCodeUnits -= value.length; +} + +function snapshotJsonData( + value: unknown, + label: string, + state = createJsonTraversalState(), +): unknown { + if (state.remainingValues === 0) { + throw new RangeError(`${label} contains too many JSON values`); + } + state.remainingValues--; + + if (value === null || typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + consumeJsonStringBudget(state, value, label); + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError(`${label} must contain only finite JSON numbers`); + } + return value; + } + if (!value || typeof value !== "object") { + throw new TypeError(`${label} must contain only JSON data values`); + } + if (state.depth >= MAX_JSON_NESTING_DEPTH) { + throw new RangeError( + `${label} exceeds the maximum JSON nesting depth of ${MAX_JSON_NESTING_DEPTH}`, + ); + } + if (state.ancestors.has(value)) { + throw new TypeError(`${label} must not contain cyclic JSON data`); + } + state.ancestors.add(value); + state.depth++; + + try { + if (Array.isArray(value)) { + if (value.length > state.remainingValues) { + throw new RangeError(`${label} contains too many JSON values`); + } + const snapshot: unknown[] = []; + snapshot.length = value.length; + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`${label} must contain only own data values`); + } + Object.defineProperty(snapshot, String(index), { + configurable: true, + enumerable: true, + value: snapshotJsonData(descriptor.value, label, state), + writable: true, + }); + } + Object.defineProperty(snapshot, "toJSON", { + configurable: true, + enumerable: false, + value: undefined, + }); + return snapshot; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must contain only plain JSON data objects`); + } + const keys = Object.keys(value); + if (keys.length > state.remainingValues) { + throw new RangeError(`${label} contains too many JSON values`); + } + const snapshot: Record = Object.create(null); + for (const key of keys) { + consumeJsonStringBudget(state, key, label); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`${label} must contain only own data values`); + } + snapshot[key] = snapshotJsonData(descriptor.value, label, state); + } + return snapshot; + } finally { + state.depth--; + state.ancestors.delete(value); + } +} + +function stringifyJsonData(value: unknown): string { + return JSON.stringify(snapshotJsonData(value, "Stored OAuth value")); +} + +function requireMetadata(value: unknown): Record | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Stored OAuth state metadata must be a plain object"); + } + return snapshotJsonData(value, "Stored OAuth state metadata") as Record; +} + +function requireOptionalTokenString( + record: object, + key: string, + maxLength: number, +): string | undefined { + const value = ownDataValue(record, key); + if (value === undefined) return undefined; + if ( + typeof value !== "string" || value.length === 0 || value.length > maxLength || + value.trim() !== value || hasAsciiControlCharacter(value) + ) { + throw new TypeError(`OAuth token row ${key} must be a safe bounded string`); + } + return value; +} + +function requireTokenRow(value: unknown): OAuthTokens { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("OAuth token row must be an object"); + } + const accessToken = requireOptionalTokenString(value, "accessToken", MAX_TOKEN_VALUE_LENGTH); + if (accessToken === undefined) { + throw new TypeError("OAuth token row must contain a non-empty accessToken"); + } + const refreshToken = requireOptionalTokenString(value, "refreshToken", MAX_TOKEN_VALUE_LENGTH); + const tokenType = requireOptionalTokenString(value, "tokenType", MAX_TOKEN_TYPE_LENGTH); + const scopeValue = ownDataValue(value, "scope"); + let scope: string | undefined; + if (scopeValue !== undefined) { + if ( + typeof scopeValue !== "string" || hasAsciiControlCharacter(scopeValue) || + scopeValue.length > MAX_SCOPE_WIRE_LENGTH + ) { + throw new TypeError("OAuth token row scope must be a safe bounded string"); + } + scope = scopeValue.trim(); + if (scope.length === 0) { + throw new TypeError("OAuth token row scope must be a safe bounded string"); + } + } + const idToken = requireOptionalTokenString(value, "idToken", MAX_TOKEN_VALUE_LENGTH); + const expiresAt = ownDataValue(value, "expiresAt"); + if ( + expiresAt !== undefined && + (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < 0) + ) { + throw new TypeError("OAuth token expiresAt must be a non-negative safe integer"); + } + return { + accessToken, + ...(refreshToken === undefined ? {} : { refreshToken }), + ...(expiresAt === undefined ? {} : { expiresAt }), + ...(tokenType === undefined ? {} : { tokenType }), + ...(scope === undefined ? {} : { scope }), + ...(idToken === undefined ? {} : { idToken }), + }; +} + +function requireTokenEntry(value: unknown): StoredTokenEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Stored OAuth token entry must be an object"); + } + const revision = ownDataValue(value, "revision"); + if (typeof revision !== "string" || revision.length === 0) { + throw new TypeError("Stored OAuth token entry must contain a revision"); + } + return { revision, tokens: requireTokenRow(ownDataValue(value, "tokens")) }; +} + +function requireStateRow(value: unknown): StoredOAuthState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Stored OAuth state row must be an object"); + } + const userId = ownDataValue(value, "userId"); + const serviceId = ownDataValue(value, "serviceId"); + const redirectUri = ownDataValue(value, "redirectUri"); + const scopes = ownDataValue(value, "scopes"); + const createdAt = ownDataValue(value, "createdAt"); + const codeVerifier = ownDataValue(value, "codeVerifier"); + const metadata = requireMetadata(ownDataValue(value, "metadata")); + if ( + typeof userId !== "string" || userId.length === 0 || + userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId || + hasAsciiControlCharacter(userId) + ) { + throw new TypeError("Stored OAuth state row must contain a userId"); + } + if ( + typeof serviceId !== "string" || serviceId.length > MAX_SERVICE_ID_LENGTH || + !SERVICE_ID_PATTERN.test(serviceId) + ) { + throw new TypeError("Stored OAuth state row must contain a serviceId"); + } + let parsedRedirectUri: URL; + try { + if ( + typeof redirectUri !== "string" || redirectUri.length > MAX_REDIRECT_URI_LENGTH || + redirectUri.trim() !== redirectUri || hasAsciiControlCharacter(redirectUri) || + redirectUri.includes("\\") + ) { + throw new TypeError(); + } + parsedRedirectUri = new URL(redirectUri); + } catch { + throw new TypeError("Stored OAuth state row must contain a valid redirectUri"); + } + const isLoopback = parsedRedirectUri.hostname === "localhost" || + parsedRedirectUri.hostname === "127.0.0.1" || + parsedRedirectUri.hostname === "[::1]" || parsedRedirectUri.hostname === "::1"; + if ( + parsedRedirectUri.username || parsedRedirectUri.password || parsedRedirectUri.hash || + (parsedRedirectUri.protocol !== "https:" && + !(parsedRedirectUri.protocol === "http:" && isLoopback)) + ) { + throw new TypeError("Stored OAuth state row must contain a valid redirectUri"); + } + if (!Array.isArray(scopes) || scopes.length > MAX_SCOPE_COUNT) { + throw new TypeError("Stored OAuth state row must contain valid scopes"); + } + const scopeSnapshot: string[] = []; + for (let index = 0; index < scopes.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(scopes, String(index)); + if ( + !descriptor || !("value" in descriptor) || typeof descriptor.value !== "string" || + !SCOPE_TOKEN_PATTERN.test(descriptor.value) + ) { + throw new TypeError("Stored OAuth state row must contain valid scopes"); + } + scopeSnapshot.push(descriptor.value); + } + if (scopeSnapshot.join(" ").length > MAX_SCOPE_WIRE_LENGTH) { + throw new TypeError("Stored OAuth state row must contain valid scopes"); + } + if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt <= 0) { + throw new TypeError("Stored OAuth state row must contain a createdAt timestamp"); + } + if ( + codeVerifier !== undefined && + (typeof codeVerifier !== "string" || !PKCE_VERIFIER_PATTERN.test(codeVerifier)) + ) { + throw new TypeError("Stored OAuth state row has an invalid codeVerifier"); + } + return { + userId, + serviceId, + redirectUri, + scopes: scopeSnapshot, + createdAt, + ...(codeVerifier === undefined ? {} : { codeVerifier }), + ...(metadata === undefined ? {} : { metadata }), + }; +} + +function isFreshState(createdAt: number, now: number): boolean { + if (createdAt > now) { + return createdAt - now <= STATE_CLOCK_SKEW_MS; + } + return now - createdAt <= STATE_TTL_MS; +} + +function assertBackend(backend: EncryptedKvBackend): void { + if (!backend || typeof backend !== "object") { + throw new TypeError("Encrypted token store backend must be an object"); + } + for (const method of REQUIRED_BACKEND_METHODS) { + if (typeof backend[method] !== "function") { + throw new TypeError(`Encrypted token store backend must implement ${method}()`); + } + } +} + +interface EnvelopeKey { + /** First 8 bytes of SHA-256 over the raw key, hex-encoded. */ + keyId: string; + key: CryptoKey; +} + +async function importEnvelopeKey(keyBytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", keyBytes)); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); + keyBytes.fill(0); + const keyId = Array.from(digest.subarray(0, KEY_ID_HEX_LENGTH / 2)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return { keyId, key }; +} + +interface OpenedEnvelope { + value: unknown; + /** + * False when the row was decrypted with a retiring key or arrived in a + * legacy v1 envelope, i.e. re-sealing it with the current key lets + * `TOKEN_ENCRYPTION_KEY_PREVIOUS` be dropped sooner. + */ + sealedWithCurrentKey: boolean; +} + +class EnvelopeCipher { + /** The first entry is the current key; every entry may decrypt. */ + readonly #keys: Promise; + + constructor(keyRing: readonly Uint8Array[]) { + this.#keys = Promise.all(keyRing.map(importEnvelopeKey)); + } + + async seal(storageKey: string, value: unknown): Promise { + const plaintext = new TextEncoder().encode(stringifyJsonData(value)); + if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) { + throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`); + } + const [current] = await this.#keys; + if (!current) { + throw new Error("Encrypted token store has no encryption key configured"); + } + const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(storageKey) }, + current.key, + plaintext, + ), + ); + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv); + combined.set(ciphertext, iv.byteLength); + return ENVELOPE_PREFIX + current.keyId + ":" + bytesToBase64(combined); + } + + async open(storageKey: string, stored: string): Promise { + if (typeof stored !== "string" || stored.length > MAX_ENCODED_LENGTH) { + throw new TypeError("Stored OAuth value must be a bounded string"); + } + const keys = await this.#keys; + if (stored.startsWith(ENVELOPE_PREFIX)) { + const body = stored.slice(ENVELOPE_PREFIX.length); + const keyId = body.slice(0, KEY_ID_HEX_LENGTH); + if (!KEY_ID_PATTERN.test(keyId) || body[KEY_ID_HEX_LENGTH] !== ":") { + throw new TypeError("Encrypted OAuth value has a malformed key id"); + } + const match = keys.find((entry) => entry.keyId === keyId); + if (!match) { + throw new Error( + `Encrypted OAuth value was sealed with an unknown encryption key (id ${keyId}). ` + + `Set ${PREVIOUS_ENCRYPTION_KEY_ENV_VAR} to the retiring key during rotation, ` + + "or re-authenticate affected users.", + ); + } + return { + value: await this.#decrypt( + storageKey, + base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)), + match.key, + ), + sealedWithCurrentKey: match === keys[0], + }; + } + if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) { + // v1 envelopes carry no key id, so try every configured key. They are + // never reported as current: re-sealing upgrades them to v2. + const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length)); + let lastFailure: unknown; + for (const entry of keys) { + try { + return { + value: await this.#decrypt(storageKey, combined, entry.key), + sealedWithCurrentKey: false, + }; + } catch (failure) { + lastFailure = failure; + } + } + throw lastFailure; + } + throw new Error( + "Stored OAuth value is not in a vf-aes-gcm envelope format. This store " + + "never reads plaintext credentials; re-authenticate affected users to " + + "replace legacy rows.", + ); + } + + async #decrypt( + storageKey: string, + combined: Uint8Array, + key: CryptoKey, + ): Promise { + const iv = combined.subarray(0, AES_GCM_IV_BYTES); + const ciphertext = combined.subarray(AES_GCM_IV_BYTES); + let plaintext: ArrayBuffer; + try { + plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(storageKey) }, + key, + ciphertext, + ); + } catch (cause) { + throw new Error( + "Encrypted OAuth value failed authentication (wrong key, corrupted " + + "data, or a value moved between storage slots)", + { cause }, + ); + } + try { + return JSON.parse(new TextDecoder().decode(plaintext)); + } catch (cause) { + throw new Error("Encrypted OAuth value contains invalid JSON", { cause }); + } + } +} + +function unreadableTokenRowReason(failure: unknown): string { + if (!(failure instanceof Error)) return "malformed encrypted row"; + if (failure.message.includes("unknown encryption key")) return "unknown encryption key"; + if (failure.message.includes("failed authentication")) return "failed authentication"; + return "malformed encrypted row"; +} + +function unreadableStateRowReason(failure: unknown): string { + if (!(failure instanceof Error)) return "malformed encrypted state row"; + if (failure.message.includes("unknown encryption key")) return "unknown encryption key"; + if (failure.message.includes("failed authentication")) return "failed authentication"; + return "malformed encrypted state row"; +} + +function assertRotationScanBackend( + backend: EncryptedKvRotationScanBackend, +): asserts backend is EncryptedKvRotationScanBackend { + assertBackend(backend); + if (typeof backend.scan !== "function") { + throw new TypeError("Encrypted token store rotation checks require backend.scan()"); + } +} + +/** + * Count token and OAuth state rows that still require + * `TOKEN_ENCRYPTION_KEY_PREVIOUS`. + * + * Run this after rotating keys and after normal reconnect/refresh traffic has + * had a chance to rewrite rows. When `complete` is true, the scanned rows no + * longer require the previous key. Unreadable rows are counted separately and + * should be cleared or replaced before removing the previous key. + * Expired OAuth state rows are ignored after authenticated decrypt and schema + * validation because they can no longer be consumed. + * `complete` describes only rows yielded by the backend; an empty scan reports + * `complete: true` with `scannedRows: 0`. Confirm the scan covered the expected + * rows before removing the previous key. + */ +export async function checkEncryptedTokenStoreRotation( + backend: EncryptedKvRotationScanBackend, +): Promise { + assertRotationScanBackend(backend); + const cipher = new EnvelopeCipher(resolveEncryptionKeyRing()); + const report: EncryptedTokenStoreRotationReport = { + scannedRows: 0, + currentKeyRows: 0, + previousKeyRows: 0, + unreadableRows: 0, + complete: false, + }; + const scanNow = Date.now(); + + for (const prefix of [TOKENS_KEY_PREFIX, STATE_KEY_PREFIX]) { + for await (const row of backend.scan(prefix)) { + report.scannedRows++; + if ( + !row || typeof row !== "object" || typeof row.key !== "string" || + typeof row.value !== "string" || !row.key.startsWith(prefix) + ) { + report.unreadableRows++; + continue; + } + try { + const opened = await cipher.open(row.key, row.value); + if (row.key.startsWith(TOKENS_KEY_PREFIX)) { + requireTokenEntry(opened.value); + } else { + const state = requireStateRow(opened.value); + if (!isFreshState(state.createdAt, scanNow)) continue; + } + if (opened.sealedWithCurrentKey) report.currentKeyRows++; + else report.previousKeyRows++; + } catch { + report.unreadableRows++; + } + } + } + + report.complete = report.previousKeyRows === 0 && report.unreadableRows === 0; + return report; +} + +/** + * Build a `RefreshCapableTokenStore` over a durable key-value backend with + * AES-256-GCM encryption at rest. + * + * Fails closed at creation time when `TOKEN_ENCRYPTION_KEY` is missing or + * malformed, and when the backend does not provide the atomic operations + * that safe multi-worker refresh requires. + * + * Wire it once during startup through an explicit configuration boundary: + * + * ```ts + * import { configureTokenStore } from "./token-store.ts"; + * import { + * createEncryptedTokenStore, + * type EncryptedKvBackend, + * } from "./encrypted-token-store.ts"; + * + * export function configureOAuthStorage(backend: EncryptedKvBackend): void { + * configureTokenStore(createEncryptedTokenStore(backend)); + * } + * ``` + */ +export function createEncryptedTokenStore( + backend: EncryptedKvBackend, +): RefreshCapableTokenStore { + assertBackend(backend); + const cipher = new EnvelopeCipher(resolveEncryptionKeyRing()); + + // Undecryptable or malformed rows degrade to "absent" instead of failing + // the caller: a single bad row must not take down an integrations page. + // The integration shows as disconnected and reconnecting (setTokens) + // overwrites the row; clearTokens removes it explicitly. The warning never + // includes token material. + async function readTokenEntry( + serviceId: string, + userId: string, + ): Promise<{ key: string; raw: string; entry: StoredTokenEntry } | null> { + const key = tokensStorageKey(serviceId, userId); + const raw = await backend.get(key); + if (raw === null) return null; + try { + const opened = await cipher.open(key, raw); + const entry = requireTokenEntry(opened.value); + if (opened.sealedWithCurrentKey) return { key, raw, entry }; + return { key, raw: (await resealTokenRow(key, raw, entry)) ?? raw, entry }; + } catch (failure) { + console.warn( + "[Encrypted Token Store] Ignoring unreadable OAuth token row " + + `(${unreadableTokenRowReason(failure)}). ` + + "The integration is reported as disconnected; reconnecting overwrites the row.", + ); + return null; + } + } + + // Best-effort transparent re-seal so rotation also converges for rows that + // are read but never rewritten. The revision is preserved (this is a + // re-encryption, not a logical write), the swap is ABA-safe because every + // seal uses a fresh IV, and any failure is ignored: the next read simply + // tries again, and losing the swap to a concurrent writer is fine because + // that writer already sealed with the current key. + async function resealTokenRow( + key: string, + raw: string, + entry: StoredTokenEntry, + ): Promise { + try { + const resealed = await cipher.seal(key, entry); + return (await backend.compareAndSwap(key, raw, resealed)) ? resealed : null; + } catch { + return null; + } + } + + return { + async getTokens(serviceId: string, userId: string): Promise { + return (await readTokenEntry(serviceId, userId))?.entry.tokens ?? null; + }, + + async getTokenSnapshot( + serviceId: string, + userId: string, + ): Promise { + return (await readTokenEntry(serviceId, userId))?.entry ?? null; + }, + + async setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise { + const key = tokensStorageKey(serviceId, userId); + const entry: StoredTokenEntry = { + revision: crypto.randomUUID(), + tokens: requireTokenRow(tokens), + }; + await backend.set(key, await cipher.seal(key, entry)); + }, + + async compareAndSetTokens( + serviceId: string, + userId: string, + expectedRevision: string, + tokens: OAuthTokens, + ): Promise { + if (typeof expectedRevision !== "string" || expectedRevision.length === 0) { + throw new TypeError("Expected OAuth token revision must be a non-empty string"); + } + const current = await readTokenEntry(serviceId, userId); + if (!current || current.entry.revision !== expectedRevision) return false; + const next: StoredTokenEntry = { + revision: crypto.randomUUID(), + tokens: requireTokenRow(tokens), + }; + return backend.compareAndSwap( + current.key, + current.raw, + await cipher.seal(current.key, next), + ); + }, + + withTokenRefreshLock( + serviceId: string, + userId: string, + operation: () => Promise, + ): Promise { + return backend.withLock(refreshLockKey(serviceId, userId), operation); + }, + + async clearTokens(serviceId: string, userId: string): Promise { + await backend.delete(tokensStorageKey(serviceId, userId)); + }, + + async setState(state: string, metadata: StoredOAuthState): Promise { + const key = stateStorageKey(state); + const row = requireStateRow(metadata); + if (!isFreshState(row.createdAt, Date.now())) { + throw new RangeError("OAuth state createdAt is outside the acceptance window"); + } + const inserted = await backend.compareAndSwap( + key, + null, + await cipher.seal(key, row), + { expiresInMs: STATE_TTL_MS + STATE_CLOCK_SKEW_MS }, + ); + if (!inserted) throw new Error("OAuth state already exists"); + }, + + async consumeState(state: string): Promise { + const key = stateStorageKey(state); + const raw = await backend.get(key); + if (raw === null) return null; + // One-shot semantics: only the caller that atomically removes the row + // may redeem it, so a replayed callback cannot reuse the state. + const consumed = await backend.compareAndSwap(key, raw, null); + if (!consumed) return null; + try { + const row = requireStateRow((await cipher.open(key, raw)).value); + return isFreshState(row.createdAt, Date.now()) ? row : null; + } catch (failure) { + console.warn( + "[Encrypted Token Store] Ignoring unreadable OAuth state row " + + `(${unreadableStateRowReason(failure)}). ` + + "The OAuth callback state is rejected.", + ); + return null; + } + }, + }; +} diff --git a/cli/templates/integrations/_base/files/lib/oauth.ts b/cli/templates/integrations/_base/files/lib/oauth.ts index 90a6d20805..26a9ac9dac 100644 --- a/cli/templates/integrations/_base/files/lib/oauth.ts +++ b/cli/templates/integrations/_base/files/lib/oauth.ts @@ -1,4 +1,8 @@ -import { type OAuthToken, tokenStore } from "./token-store.ts"; +import { + getRefreshableAccessToken, + type OAuthToken, + tokenStore, +} from "./token-store.ts"; export interface OAuthProvider { name: string; @@ -105,21 +109,10 @@ export async function getValidToken( userId: string, service: string, ): Promise { - const token = await tokenStore.getToken(userId, service); - if (!token) return null; - - const isExpired = token.expiresAt - ? token.expiresAt < Date.now() + 5 * 60 * 1000 - : false; - - if (!isExpired || !token.refreshToken) return token.accessToken; - - try { - const newToken = await refreshAccessToken(provider, token.refreshToken); - await tokenStore.setToken(userId, service, newToken); - return newToken.accessToken; - } catch { - await tokenStore.revokeToken(userId, service); - return null; - } + return await getRefreshableAccessToken( + tokenStore, + service, + userId, + (refreshToken) => refreshAccessToken(provider, refreshToken), + ); } diff --git a/cli/templates/integrations/_base/files/lib/token-store-examples.ts b/cli/templates/integrations/_base/files/lib/token-store-examples.ts new file mode 100644 index 0000000000..6252a6bd28 --- /dev/null +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -0,0 +1,142 @@ +/** + * Reference backends for `createEncryptedTokenStore` in + * `encrypted-token-store.ts`. + * + * The in-memory backend below is for local development and tests only: it is + * process-local, so tokens vanish on restart and are not shared across + * workers. For production, implement `EncryptedKvBackend` over a durable + * service and pass it into startup through an explicit configuration + * boundary. This example is complete and does not rely on module globals: + * + * ```ts + * import { configureTokenStore } from "./token-store.ts"; + * import { + * createEncryptedTokenStore, + * type EncryptedKvBackend, + * } from "./encrypted-token-store.ts"; + * + * export function configureOAuthStorage(backend: EncryptedKvBackend): void { + * configureTokenStore(createEncryptedTokenStore(backend)); + * } + * ``` + * + * Redis adapter sketch (pseudocode, not a paste-ready client): replace every + * angle-bracketed operation with the equivalent atomic operation from your + * initialized Redis client. + * + * ```text + * const redisBackend: EncryptedKvBackend = { + * get: (key) => (key), + * set: async (key, value, options) => { + * await (key, value, options?.expiresInMs); + * }, + * delete: (key) => (key), + * compareAndSwap: (key, expected, next, options) => + * (key, expected, next, options?.expiresInMs), + * withLock: (key, operation) => + * (key, operation), + * }; + * ``` + */ + +import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; + +function runtimeMode(): string | undefined { + try { + if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; + } catch { + // Deno exposes the Node-compatible `process` global even when env access + // is denied. Preserve the fail-closed mode decision in that runtime. + return undefined; + } + try { + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.("NODE_ENV"); + } catch { + return undefined; + } +} + +interface MemoryRow { + value: string; + expiresAt: number | null; +} + +/** + * Development/test in-memory backend. Values are still encrypted (the store + * requires `TOKEN_ENCRYPTION_KEY` in every mode) but nothing is durable and + * nothing is shared across workers, so creation is refused in production. + */ +export function createMemoryKvBackend(): EncryptedKvBackend { + const mode = runtimeMode(); + if (mode !== "development" && mode !== "test") { + throw new Error( + mode === "production" + ? "The in-memory example backend is not allowed in production. Implement " + + "EncryptedKvBackend over a durable service (Redis, Postgres, Deno KV)." + : "The in-memory example backend requires an explicit development or test " + + "runtime. Set NODE_ENV accordingly, or implement EncryptedKvBackend over " + + "a durable service (Redis, Postgres, Deno KV).", + ); + } + + const rows = new Map(); + const lockTails = new Map>(); + + function readRow(key: string): string | null { + const row = rows.get(key); + if (!row) return null; + if (row.expiresAt !== null && Date.now() >= row.expiresAt) { + rows.delete(key); + return null; + } + return row.value; + } + + function writeRow(key: string, value: string, expiresInMs?: number): void { + rows.set(key, { + value, + expiresAt: expiresInMs === undefined ? null : Date.now() + expiresInMs, + }); + } + + return { + get(key) { + return Promise.resolve(readRow(key)); + }, + set(key, value, options) { + writeRow(key, value, options?.expiresInMs); + return Promise.resolve(); + }, + delete(key) { + rows.delete(key); + return Promise.resolve(); + }, + compareAndSwap(key, expected, next, options) { + // No await between comparison and write: within one process this block + // is indivisible, which is exactly the guarantee the contract asks a + // distributed backend to provide server-side. + if (readRow(key) !== expected) return Promise.resolve(false); + if (next === null) rows.delete(key); + else writeRow(key, next, options?.expiresInMs); + return Promise.resolve(true); + }, + async withLock(key, operation) { + const prior = lockTails.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = prior.catch(() => undefined).then(() => current); + lockTails.set(key, tail); + + await prior.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (lockTails.get(key) === tail) lockTails.delete(key); + } + }, + }; +} diff --git a/cli/templates/integrations/_base/files/lib/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index 4b72c42e5c..3a379fac0a 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -4,7 +4,11 @@ * The same store owns authorization state and tokens. This is required for * callbacks and token refresh to work across production workers. Configure a * durable, extension-owned RefreshCapableTokenStore before the first OAuth - * request in production. The built-in memory store is development-only. + * request in production. The built-in memory store is for development and test. + * + * To build that durable store on top of a plain key-value service with + * AES-256-GCM encryption at rest, see `encrypted-token-store.ts` (reference + * backends live in `token-store-examples.ts`). */ import { @@ -38,11 +42,27 @@ const REQUIRED_STORE_METHODS = [ "setState", "consumeState", ] as const; +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1_000; -function isProductionRuntime(): boolean { - if (typeof process !== "undefined") return process.env?.NODE_ENV === "production"; - return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno - ?.env?.get?.("NODE_ENV") === "production"; +function runtimeMode(): string | undefined { + try { + if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; + } catch { + // Deno exposes the Node-compatible `process` global even when env access + // is denied. Preserve the fail-closed mode decision in that runtime. + return undefined; + } + try { + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.("NODE_ENV"); + } catch { + return undefined; + } +} + +function allowsProcessLocalStorage(): boolean { + const mode = runtimeMode(); + return mode === "development" || mode === "test"; } function assertRefreshCapableStore( @@ -131,6 +151,76 @@ export function createTokenStore(store: RefreshCapableTokenStore): TokenStore { }; } +function unexpiredAccessToken(token: OAuthToken, now = Date.now()): string | null { + return token.expiresAt === undefined || now < token.expiresAt ? token.accessToken : null; +} + +/** + * Resolve an access token, refreshing it under the store's distributed lock. + * Revisioned compare-and-set prevents a refresh from overwriting a concurrent + * reconnect or revocation that did not participate in the refresh lock. + */ +export async function getRefreshableAccessToken( + store: TokenStore, + serviceId: string, + userId: string, + refresh: (refreshToken: string) => Promise, +): Promise { + const initial = await store.getTokenSnapshot(serviceId, userId); + if (!initial) return null; + + const initialToken = initial.tokens; + const now = Date.now(); + if ( + initialToken.expiresAt === undefined || + now < initialToken.expiresAt - TOKEN_REFRESH_BUFFER_MS + ) { + return initialToken.accessToken; + } + if (!initialToken.refreshToken) return unexpiredAccessToken(initialToken, now); + + return await store.withTokenRefreshLock(serviceId, userId, async () => { + // Another worker may have refreshed this slot while this caller waited. + const current = await store.getTokenSnapshot(serviceId, userId); + if (!current) return null; + + const token = current.tokens; + const lockedNow = Date.now(); + if ( + token.expiresAt === undefined || + lockedNow < token.expiresAt - TOKEN_REFRESH_BUFFER_MS + ) { + return token.accessToken; + } + if (!token.refreshToken) return unexpiredAccessToken(token, lockedNow); + + let refreshed: OAuthToken; + try { + refreshed = await refresh(token.refreshToken); + } catch { + // A provider failure must not unconditionally delete a row that may + // have been replaced by a concurrent reconnect outside the lock. + const latest = await store.getTokens(serviceId, userId); + return latest ? unexpiredAccessToken(latest) : null; + } + + if (refreshed.refreshToken === undefined) { + refreshed = { ...refreshed, refreshToken: token.refreshToken }; + } + + const replaced = await store.compareAndSetTokens( + serviceId, + userId, + current.revision, + refreshed, + ); + if (replaced) return refreshed.accessToken; + + const latest = await store.getTokens(serviceId, userId); + return latest ? unexpiredAccessToken(latest) : null; + }); +} + let configuredTokenStore: TokenStore | null = null; let defaultTokenStore: TokenStore | null = null; @@ -146,17 +236,20 @@ export function configureTokenStore(store: RefreshCapableTokenStore): void { if (configuredTokenStore || defaultTokenStore) { throw new Error("OAuth token store must be configured exactly once before first use"); } - if (isProductionRuntime() && store instanceof MemoryTokenStore) { - throw new Error("MemoryTokenStore is not allowed for production OAuth storage"); + if (!allowsProcessLocalStorage() && store instanceof MemoryTokenStore) { + throw new Error( + "MemoryTokenStore is allowed only when NODE_ENV is explicitly development or test", + ); } configuredTokenStore = createTokenStore(store); } /** Resolve the development default without doing work during module import. */ export function createDefaultTokenStore(): TokenStore { - if (isProductionRuntime()) { + if (!allowsProcessLocalStorage()) { throw new Error( - "OAuth token storage is not configured for production. " + + "OAuth token storage is not configured. The in-memory default is allowed " + + "only when NODE_ENV is explicitly development or test. " + "Configure an extension-owned RefreshCapableTokenStore with " + "configureTokenStore() before the first OAuth request.", ); diff --git a/cli/templates/integrations/docs-google/files/lib/docs-google-oauth.ts b/cli/templates/integrations/docs-google/files/lib/docs-google-oauth.ts index 11499a5ff9..c6245505aa 100644 --- a/cli/templates/integrations/docs-google/files/lib/docs-google-oauth.ts +++ b/cli/templates/integrations/docs-google/files/lib/docs-google-oauth.ts @@ -1,4 +1,8 @@ -import { type OAuthToken, tokenStore } from "./token-store.ts"; +import { + getRefreshableAccessToken, + type OAuthToken, + tokenStore, +} from "./token-store.ts"; export interface OAuthProvider { name: string; @@ -94,21 +98,10 @@ export async function getValidToken( userId: string, service: string, ): Promise { - const token = await tokenStore.getToken(userId, service); - if (!token) return null; - - const isExpired = token.expiresAt - ? token.expiresAt < Date.now() + 5 * 60 * 1000 - : false; - - if (!isExpired || !token.refreshToken) return token.accessToken; - - try { - const newToken = await refreshAccessToken(provider, token.refreshToken); - await tokenStore.setToken(userId, service, newToken); - return newToken.accessToken; - } catch { - await tokenStore.revokeToken(userId, service); - return null; - } + return await getRefreshableAccessToken( + tokenStore, + service, + userId, + (refreshToken) => refreshAccessToken(provider, refreshToken), + ); } diff --git a/cli/templates/integrations/drive/files/lib/drive-oauth.ts b/cli/templates/integrations/drive/files/lib/drive-oauth.ts index 42ea7d8d87..f27d1b9c11 100644 --- a/cli/templates/integrations/drive/files/lib/drive-oauth.ts +++ b/cli/templates/integrations/drive/files/lib/drive-oauth.ts @@ -1,4 +1,8 @@ -import { type OAuthToken, tokenStore } from "./token-store.ts"; +import { + getRefreshableAccessToken, + type OAuthToken, + tokenStore, +} from "./token-store.ts"; export interface OAuthProvider { name: string; @@ -110,22 +114,10 @@ export async function getValidToken( userId: string, service: string, ): Promise { - const token = await tokenStore.getToken(userId, service); - if (!token) return null; - - const isExpired = token.expiresAt - ? token.expiresAt < Date.now() + 5 * 60 * 1000 - : false; - - if (!isExpired) return token.accessToken; - if (!token.refreshToken) return token.accessToken; - - try { - const newToken = await refreshAccessToken(provider, token.refreshToken); - await tokenStore.setToken(userId, service, newToken); - return newToken.accessToken; - } catch { - await tokenStore.revokeToken(userId, service); - return null; - } + return await getRefreshableAccessToken( + tokenStore, + service, + userId, + (refreshToken) => refreshAccessToken(provider, refreshToken), + ); } diff --git a/cli/templates/integrations/gmail/files/lib/gmail-client.ts b/cli/templates/integrations/gmail/files/lib/gmail-client.ts index 4623666cfe..bb605e8040 100644 --- a/cli/templates/integrations/gmail/files/lib/gmail-client.ts +++ b/cli/templates/integrations/gmail/files/lib/gmail-client.ts @@ -7,7 +7,6 @@ import { gmailConfig, OAuthService } from "veryfront/oauth"; import { tokenStore } from "./token-store.ts"; -import type { OAuthToken } from "./token-store.ts"; export type GmailMessageFormat = "full" | "metadata" | "minimal" | "raw"; export type GmailThreadFormat = Exclude; @@ -203,30 +202,9 @@ export interface GmailClient { stopMailboxWatch(): Promise; } -// TokenStore adapter keyed by (serviceId, userId). All API calls must pass -// the authenticated user's id. Never use a shared development user id -// in production; that re-introduces VULN-AUTH-2. -const tokenStoreAdapter = { - async getTokens(serviceId: string, userId: string): Promise { - return tokenStore.getToken(userId, serviceId); - }, - async setTokens( - serviceId: string, - userId: string, - tokens: { accessToken: string; refreshToken?: string; expiresAt?: number }, - ): Promise { - await tokenStore.setToken(userId, serviceId, tokens); - }, - async clearTokens(serviceId: string, userId: string): Promise { - await tokenStore.revokeToken(userId, serviceId); - }, - async setState(): Promise {}, - async consumeState(): Promise { - return null; - }, -}; - -const gmailService = new OAuthService(gmailConfig, tokenStoreAdapter); +// Keep the full refresh-capable contract: OAuthService uses the store's +// distributed lock and revisioned compare-and-set when access tokens expire. +const gmailService = new OAuthService(gmailConfig, tokenStore); function formatAddresses(addresses: string | string[] | undefined): string { if (!addresses) return ""; diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index f1bdc7c006..60c1d6ee96 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,8 +115,10 @@ "app/page.tsx": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { Chat, useChat } from 'veryfront/chat'\n\ninterface Integration {\n id: string\n name: string\n connected: boolean\n connectUrl: string\n}\n\nexport default function ChatPage(): React.ReactElement {\n const chat = useChat({ api: '/api/ag-ui' })\n\n return (\n
\n
\n
\n

AI Agent

\n
\n \n \n Setup\n \n
\n
\n
\n\n \n
\n )\n}\n\nfunction ServiceStatusFromAPI(): React.ReactElement | null {\n const [integrations, setIntegrations] = useState([])\n const [loading, setLoading] = useState(true)\n\n useEffect((): void => {\n async function fetchStatus(): Promise {\n try {\n const res = await fetch('/api/integrations/status')\n if (!res.ok) return\n\n const data = await res.json()\n setIntegrations(data.integrations ?? [])\n } catch (error) {\n console.error('Failed to fetch integration status:', error)\n } finally {\n setLoading(false)\n }\n }\n\n void fetchStatus()\n }, [])\n\n if (loading) {\n return (\n
\n
\n
\n )\n }\n\n if (integrations.length === 0) return null\n\n const connected: Integration[] = []\n const disconnected: Integration[] = []\n\n for (const integration of integrations) {\n if (integration.connected) connected.push(integration)\n else disconnected.push(integration)\n }\n\n return (\n
\n {connected.map(service => (\n \n \n {service.name}\n \n ))}\n\n {disconnected.map(service => (\n \n \n {service.name}\n \n ))}\n\n {disconnected.length > 0 && (\n \n {connected.length}/{integrations.length}\n \n )}\n
\n )\n}\n", "app/setup/page-helpers.tsx": "import type { JSX } from \"react\";\n\nexport interface Integration {\n id: string;\n name: string;\n icon: string;\n connected: boolean;\n connectUrl: string;\n}\n\nexport interface SetupStep {\n id: string;\n title: string;\n description: string;\n completed: boolean;\n action?: () => void;\n link?: string;\n}\n\ninterface SetupGuide {\n title: string;\n steps: string[];\n link: string;\n envVars: string[];\n category: string;\n}\n\nexport interface TokenStorageStatus {\n mode: \"memory\" | \"database\" | \"kv\" | \"redis\" | \"custom\";\n encrypted: boolean;\n autoGenerated?: boolean;\n}\n\nexport type TokenStorageStyles = {\n container: string;\n iconWrapper: string;\n title: string;\n text: string;\n isMemory: boolean;\n};\n\nexport const CATEGORIES = [\n { id: \"google\", name: \"Google Services\", icon: \"google\" },\n { id: \"microsoft\", name: \"Microsoft Services\", icon: \"microsoft\" },\n { id: \"atlassian\", name: \"Atlassian\", icon: \"atlassian\" },\n { id: \"communication\", name: \"Communication\", icon: \"chat\" },\n { id: \"development\", name: \"Development\", icon: \"code\" },\n { id: \"productivity\", name: \"Productivity\", icon: \"tasks\" },\n { id: \"storage\", name: \"Storage\", icon: \"folder\" },\n { id: \"infrastructure\", name: \"Infrastructure\", icon: \"server\" },\n { id: \"sales\", name: \"Sales & CRM\", icon: \"users\" },\n { id: \"support\", name: \"Support\", icon: \"headset\" },\n { id: \"finance\", name: \"Finance\", icon: \"dollar\" },\n { id: \"marketing\", name: \"Marketing\", icon: \"megaphone\" },\n { id: \"design\", name: \"Design\", icon: \"palette\" },\n { id: \"ai\", name: \"AI Providers\", icon: \"brain\" },\n] as const;\n\nexport const OAUTH_SETUP_GUIDES: Record = {\n gmail: {\n title: \"Google OAuth Setup (Gmail)\",\n category: \"google\",\n steps: [\n \"Go to Google Cloud Console\",\n \"Create a new project or select existing one\",\n \"Enable Gmail API in APIs & Services > Library\",\n \"Go to APIs & Services > Credentials\",\n \"Create OAuth 2.0 credentials (Web application)\",\n \"Add redirect URI: http://localhost:3000/api/auth/gmail/callback\",\n \"Copy Client ID and Secret to your .env file\",\n ],\n link: \"https://console.cloud.google.com/apis/credentials\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n calendar: {\n title: \"Google Calendar Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials as Gmail\",\n \"Enable Calendar API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/calendar/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n drive: {\n title: \"Google Drive Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Drive API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/drive/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/drive.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n sheets: {\n title: \"Google Sheets Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Sheets API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/sheets/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/sheets.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n \"docs-google\": {\n title: \"Google Docs Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Docs API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/docs-google/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/docs.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n outlook: {\n title: \"Microsoft Outlook Setup\",\n category: \"microsoft\",\n steps: [\n \"Go to Azure Portal > Azure Active Directory\",\n \"Click App registrations > New registration\",\n \"Set redirect URI: http://localhost:3000/api/auth/outlook/callback\",\n \"Go to API permissions > Add Microsoft Graph permissions\",\n \"Add: Mail.Read, Mail.Send, Mail.ReadWrite\",\n \"Go to Certificates & secrets > New client secret\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n teams: {\n title: \"Microsoft Teams Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials as Outlook\",\n \"Add Teams permissions: Chat.Read, Chat.ReadWrite, Channel.ReadBasic.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/teams/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n onedrive: {\n title: \"Microsoft OneDrive Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Files.Read, Files.ReadWrite\",\n \"Add redirect URI: http://localhost:3000/api/auth/onedrive/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n sharepoint: {\n title: \"Microsoft SharePoint Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Sites.Read.All, Sites.ReadWrite.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/sharepoint/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n jira: {\n title: \"Atlassian Jira Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Atlassian Developer Console\",\n \"Click Create > OAuth 2.0 integration\",\n \"Add Jira API scopes: read:jira-work, write:jira-work\",\n \"Set callback URL: http://localhost:3000/api/auth/jira/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n confluence: {\n title: \"Atlassian Confluence Setup\",\n category: \"atlassian\",\n steps: [\n \"Uses same Atlassian OAuth credentials as Jira\",\n \"Add Confluence scopes: read:confluence-content.all, write:confluence-content\",\n \"Add callback URL: http://localhost:3000/api/auth/confluence/callback\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n bitbucket: {\n title: \"Atlassian Bitbucket Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Bitbucket Settings > OAuth consumers\",\n \"Click Add consumer\",\n \"Set callback URL: http://localhost:3000/api/auth/bitbucket/callback\",\n \"Add permissions: repository:read, repository:write\",\n \"Copy Key and Secret to .env\",\n ],\n link: \"https://bitbucket.org/account/settings/app-passwords/\",\n envVars: [\"BITBUCKET_CLIENT_ID\", \"BITBUCKET_CLIENT_SECRET\"],\n },\n slack: {\n title: \"Slack App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Slack API Apps page\",\n \"Click Create New App > From scratch\",\n \"Go to OAuth & Permissions\",\n \"Add scopes: channels:history, channels:read, chat:write, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read\",\n \"Add redirect URL: http://localhost:3000/api/auth/slack/callback\",\n \"Install to Workspace\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://api.slack.com/apps\",\n envVars: [\"SLACK_CLIENT_ID\", \"SLACK_CLIENT_SECRET\"],\n },\n zoom: {\n title: \"Zoom App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Zoom App Marketplace\",\n \"Click Develop > Build App\",\n \"Choose OAuth app type\",\n \"Add redirect URL: http://localhost:3000/api/auth/zoom/callback\",\n \"Add scopes: meeting:read, meeting:write, user:read\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://marketplace.zoom.us/develop/create\",\n envVars: [\"ZOOM_CLIENT_ID\", \"ZOOM_CLIENT_SECRET\"],\n },\n webex: {\n title: \"Webex Integration Setup\",\n category: \"communication\",\n steps: [\n \"Go to Webex Developer Portal\",\n \"Create a new integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/webex/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.webex.com/my-apps\",\n envVars: [\"WEBEX_CLIENT_ID\", \"WEBEX_CLIENT_SECRET\"],\n },\n twilio: {\n title: \"Twilio Setup\",\n category: \"communication\",\n steps: [\n \"Go to Twilio Console\",\n \"Copy Account SID and Auth Token\",\n \"Get a phone number for SMS\",\n \"Add credentials to .env\",\n ],\n link: \"https://console.twilio.com/\",\n envVars: [\"TWILIO_ACCOUNT_SID\", \"TWILIO_AUTH_TOKEN\", \"TWILIO_PHONE_NUMBER\"],\n },\n github: {\n title: \"GitHub OAuth App Setup\",\n category: \"development\",\n steps: [\n \"Go to GitHub Developer Settings\",\n \"Click OAuth Apps > New OAuth App\",\n \"Set Homepage URL: http://localhost:3000\",\n \"Set callback URL: http://localhost:3000/api/auth/github/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://github.com/settings/developers\",\n envVars: [\"GITHUB_CLIENT_ID\", \"GITHUB_CLIENT_SECRET\"],\n },\n gitlab: {\n title: \"GitLab OAuth Setup\",\n category: \"development\",\n steps: [\n \"Go to GitLab User Settings > Applications\",\n \"Create new application\",\n \"Add redirect URI: http://localhost:3000/api/auth/gitlab/callback\",\n \"Select scopes: api, read_user, read_repository\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://gitlab.com/-/profile/applications\",\n envVars: [\"GITLAB_CLIENT_ID\", \"GITLAB_CLIENT_SECRET\"],\n },\n sentry: {\n title: \"Sentry Setup\",\n category: \"development\",\n steps: [\n \"Go to Sentry Settings > Developer Settings\",\n \"Create new integration\",\n \"Add redirect URL: http://localhost:3000/api/auth/sentry/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://sentry.io/settings/developer-settings/\",\n envVars: [\"SENTRY_CLIENT_ID\", \"SENTRY_CLIENT_SECRET\"],\n },\n posthog: {\n title: \"PostHog Setup\",\n category: \"development\",\n steps: [\"Go to PostHog Project Settings\", \"Copy your Project API Key\", \"Add to .env file\"],\n link: \"https://app.posthog.com/project/settings\",\n envVars: [\"POSTHOG_API_KEY\", \"POSTHOG_HOST\"],\n },\n mixpanel: {\n title: \"Mixpanel Setup\",\n category: \"development\",\n steps: [\n \"Go to Mixpanel Project Settings\",\n \"Copy your Project Token\",\n \"For API access, create a Service Account\",\n \"Add credentials to .env\",\n ],\n link: \"https://mixpanel.com/settings/project\",\n envVars: [\"MIXPANEL_TOKEN\", \"MIXPANEL_API_SECRET\"],\n },\n notion: {\n title: \"Notion Integration Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Notion Integrations page\",\n \"Click New integration\",\n \"Name your integration and select workspace\",\n \"Copy the Internal Integration Token\",\n \"Share desired pages/databases with your integration\",\n \"Add token to .env\",\n ],\n link: \"https://www.notion.so/my-integrations\",\n envVars: [\"NOTION_API_KEY\"],\n },\n linear: {\n title: \"Linear OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Linear Settings > API\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/linear/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://linear.app/settings/api\",\n envVars: [\"LINEAR_CLIENT_ID\", \"LINEAR_CLIENT_SECRET\"],\n },\n asana: {\n title: \"Asana OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Asana Developer Console\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/asana/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.asana.com/0/developer-console\",\n envVars: [\"ASANA_CLIENT_ID\", \"ASANA_CLIENT_SECRET\"],\n },\n trello: {\n title: \"Trello Power-Up Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Trello Power-Ups Admin\",\n \"Create new Power-Up\",\n \"Add redirect URI: http://localhost:3000/api/auth/trello/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://trello.com/power-ups/admin\",\n envVars: [\"TRELLO_API_KEY\", \"TRELLO_API_SECRET\"],\n },\n monday: {\n title: \"Monday.com App Setup\",\n category: \"productivity\",\n steps: [\n \"Go to monday.com Developers\",\n \"Create new app\",\n \"Add OAuth redirect: http://localhost:3000/api/auth/monday/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://monday.com/developers/apps\",\n envVars: [\"MONDAY_CLIENT_ID\", \"MONDAY_CLIENT_SECRET\"],\n },\n clickup: {\n title: \"ClickUp OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to ClickUp Settings > Apps\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/clickup/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.clickup.com/settings/apps\",\n envVars: [\"CLICKUP_CLIENT_ID\", \"CLICKUP_CLIENT_SECRET\"],\n },\n box: {\n title: \"Box App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Box Developer Console\",\n \"Create new app with OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/box/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.box.com/developers/console\",\n envVars: [\"BOX_CLIENT_ID\", \"BOX_CLIENT_SECRET\"],\n },\n airtable: {\n title: \"Airtable OAuth Setup\",\n category: \"storage\",\n steps: [\n \"Go to Airtable Developer Hub\",\n \"Create new OAuth integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/airtable/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://airtable.com/create/oauth\",\n envVars: [\"AIRTABLE_CLIENT_ID\", \"AIRTABLE_CLIENT_SECRET\"],\n },\n supabase: {\n title: \"Supabase Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Supabase Dashboard\",\n \"Create new project or select existing\",\n \"Go to Settings > API\",\n \"Copy Project URL and anon/service_role keys\",\n \"Add to .env file\",\n ],\n link: \"https://supabase.com/dashboard\",\n envVars: [\"SUPABASE_URL\", \"SUPABASE_ANON_KEY\", \"SUPABASE_SERVICE_ROLE_KEY\"],\n },\n neon: {\n title: \"Neon Database Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Neon Console\",\n \"Create new project\",\n \"Copy connection string from Dashboard\",\n \"Add to .env file\",\n ],\n link: \"https://console.neon.tech/\",\n envVars: [\"DATABASE_URL\"],\n },\n snowflake: {\n title: \"Snowflake Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Snowflake Console\",\n \"Create a service account or use existing credentials\",\n \"Note your account identifier, warehouse, database\",\n \"Add credentials to .env\",\n ],\n link: \"https://app.snowflake.com/\",\n envVars: [\"SNOWFLAKE_ACCOUNT\", \"SNOWFLAKE_USER\", \"SNOWFLAKE_PASSWORD\", \"SNOWFLAKE_WAREHOUSE\"],\n },\n aws: {\n title: \"AWS Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to AWS IAM Console\",\n \"Create new IAM user with programmatic access\",\n \"Attach required policies (S3, Lambda, DynamoDB)\",\n \"Copy Access Key ID and Secret\",\n \"Add to .env file\",\n ],\n link: \"https://console.aws.amazon.com/iam/\",\n envVars: [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_REGION\"],\n },\n salesforce: {\n title: \"Salesforce Connected App Setup\",\n category: \"sales\",\n steps: [\n \"Go to Salesforce Setup > App Manager\",\n \"Create new Connected App\",\n \"Enable OAuth Settings\",\n \"Add callback URL: http://localhost:3000/api/auth/salesforce/callback\",\n \"Select OAuth scopes: api, refresh_token\",\n \"Copy Consumer Key and Secret to .env\",\n ],\n link: \"https://login.salesforce.com/\",\n envVars: [\"SALESFORCE_CLIENT_ID\", \"SALESFORCE_CLIENT_SECRET\"],\n },\n pipedrive: {\n title: \"Pipedrive OAuth Setup\",\n category: \"sales\",\n steps: [\n \"Go to Pipedrive Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/pipedrive/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.pipedrive.com/\",\n envVars: [\"PIPEDRIVE_CLIENT_ID\", \"PIPEDRIVE_CLIENT_SECRET\"],\n },\n zendesk: {\n title: \"Zendesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Zendesk Admin > API > OAuth Clients\",\n \"Add new OAuth client\",\n \"Set redirect URI: http://localhost:3000/api/auth/zendesk/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add your Zendesk subdomain\",\n ],\n link: \"https://support.zendesk.com/hc/en-us/articles/4408845965210\",\n envVars: [\"ZENDESK_CLIENT_ID\", \"ZENDESK_CLIENT_SECRET\", \"ZENDESK_SUBDOMAIN\"],\n },\n intercom: {\n title: \"Intercom OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Intercom Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/intercom/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.intercom.com/\",\n envVars: [\"INTERCOM_CLIENT_ID\", \"INTERCOM_CLIENT_SECRET\"],\n },\n freshdesk: {\n title: \"Freshdesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Freshdesk Admin > Apps > Custom Apps\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/freshdesk/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.freshdesk.com/\",\n envVars: [\"FRESHDESK_CLIENT_ID\", \"FRESHDESK_CLIENT_SECRET\", \"FRESHDESK_DOMAIN\"],\n },\n servicenow: {\n title: \"ServiceNow OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to ServiceNow System OAuth > Application Registry\",\n \"Create OAuth API endpoint for external clients\",\n \"Add redirect URL: http://localhost:3000/api/auth/servicenow/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://docs.servicenow.com/\",\n envVars: [\"SERVICENOW_CLIENT_ID\", \"SERVICENOW_CLIENT_SECRET\", \"SERVICENOW_INSTANCE\"],\n },\n stripe: {\n title: \"Stripe Setup\",\n category: \"finance\",\n steps: [\n \"Go to Stripe Dashboard\",\n \"Go to Developers > API keys\",\n \"Copy Publishable and Secret keys\",\n \"For Connect, set up OAuth in Connect settings\",\n \"Add to .env file\",\n ],\n link: \"https://dashboard.stripe.com/apikeys\",\n envVars: [\"STRIPE_SECRET_KEY\", \"STRIPE_PUBLISHABLE_KEY\"],\n },\n quickbooks: {\n title: \"QuickBooks OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Intuit Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/quickbooks/callback\",\n \"Select Accounting scope\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.intuit.com/app/developer/dashboard\",\n envVars: [\"QUICKBOOKS_CLIENT_ID\", \"QUICKBOOKS_CLIENT_SECRET\"],\n },\n xero: {\n title: \"Xero OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Xero Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/xero/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.xero.com/app/manage\",\n envVars: [\"XERO_CLIENT_ID\", \"XERO_CLIENT_SECRET\"],\n },\n mailchimp: {\n title: \"Mailchimp OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Mailchimp Developer Portal\",\n \"Register new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/mailchimp/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://admin.mailchimp.com/account/oauth2/\",\n envVars: [\"MAILCHIMP_CLIENT_ID\", \"MAILCHIMP_CLIENT_SECRET\"],\n },\n shopify: {\n title: \"Shopify App Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Shopify Partners Dashboard\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/shopify/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://partners.shopify.com/\",\n envVars: [\"SHOPIFY_API_KEY\", \"SHOPIFY_API_SECRET\"],\n },\n twitter: {\n title: \"Twitter/X OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Twitter Developer Portal\",\n \"Create new project and app\",\n \"Enable OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/twitter/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.twitter.com/en/portal/dashboard\",\n envVars: [\"TWITTER_CLIENT_ID\", \"TWITTER_CLIENT_SECRET\"],\n },\n figma: {\n title: \"Figma OAuth Setup\",\n category: \"design\",\n steps: [\n \"Go to Figma Developer Settings\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/figma/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://www.figma.com/developers/apps\",\n envVars: [\"FIGMA_CLIENT_ID\", \"FIGMA_CLIENT_SECRET\"],\n },\n anthropic: {\n title: \"Anthropic API Setup\",\n category: \"ai\",\n steps: [\"Go to Anthropic Console\", \"Create new API key\", \"Copy API key to .env\"],\n link: \"https://console.anthropic.com/\",\n envVars: [\"ANTHROPIC_API_KEY\"],\n },\n};\n\nexport function filterIntegrations(\n integrations: Integration[],\n searchQuery: string,\n selectedCategory: string | null,\n): Integration[] {\n const query = searchQuery.toLowerCase();\n\n return integrations.filter((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n\n const matchesSearch =\n query === \"\" ||\n integration.name.toLowerCase().includes(query) ||\n integration.id.toLowerCase().includes(query);\n\n const matchesCategory = selectedCategory === null || guide?.category === selectedCategory;\n\n return matchesSearch && matchesCategory;\n });\n}\n\nexport function groupIntegrationsByCategory(\n integrations: Integration[],\n): Record {\n const groups: Record = {};\n\n for (const integration of integrations) {\n const category = OAUTH_SETUP_GUIDES[integration.id]?.category ?? \"other\";\n (groups[category] ??= []).push(integration);\n }\n\n return groups;\n}\n\nexport function buildSetupSteps(\n envChecked: boolean,\n allConnected: boolean,\n markEnvChecked: () => void,\n): SetupStep[] {\n return [\n {\n id: \"env\",\n title: \"Configure Environment Variables\",\n description: \"Add your OAuth credentials to the .env file\",\n completed: envChecked,\n action: markEnvChecked,\n },\n {\n id: \"oauth\",\n title: \"Create OAuth Apps\",\n description: \"Set up OAuth applications for each service\",\n completed: false,\n },\n {\n id: \"connect\",\n title: \"Connect Services\",\n description: \"Authorize your app to access each service\",\n completed: allConnected,\n },\n ];\n}\n\nexport function getTokenStorageStyles(\n tokenStorage: TokenStorageStatus | null,\n): TokenStorageStyles | null {\n if (!tokenStorage) return null;\n\n const isMemory = tokenStorage.mode === \"memory\";\n\n return {\n container: `rounded-2xl p-6 shadow-sm border mb-8 ${\n isMemory\n ? \"bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800\"\n : \"bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800\"\n }`,\n iconWrapper: `w-10 h-10 rounded-full flex items-center justify-center ${\n isMemory ? \"bg-amber-100 dark:bg-amber-900\" : \"bg-green-100 dark:bg-green-900\"\n }`,\n title: `font-semibold ${\n isMemory ? \"text-amber-800 dark:text-amber-200\" : \"text-green-800 dark:text-green-200\"\n }`,\n text: `text-sm mt-1 ${\n isMemory ? \"text-amber-700 dark:text-amber-300\" : \"text-green-700 dark:text-green-300\"\n }`,\n isMemory,\n };\n}\n\nexport function ServiceIcon({ name }: { name: string }): React.JSX.Element {\n const iconMap: Record = {\n mail: (\n \n \n \n ),\n slack: (\n \n \n \n \n \n \n ),\n calendar: (\n \n \n \n ),\n github: (\n \n \n \n ),\n jira: (\n \n \n \n \n \n \n \n \n \n \n \n ),\n notion: (\n \n \n \n ),\n default: (\n \n \n \n ),\n };\n\n return iconMap[name] ?? iconMap.default;\n}\n", "app/setup/page.tsx": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n buildSetupSteps,\n CATEGORIES,\n filterIntegrations,\n getTokenStorageStyles,\n groupIntegrationsByCategory,\n type Integration,\n OAUTH_SETUP_GUIDES,\n ServiceIcon,\n type TokenStorageStatus,\n} from \"./page-helpers\";\n\nexport default function SetupPage(): React.JSX.Element {\n const [integrations, setIntegrations] = useState([]);\n const [loading, setLoading] = useState(true);\n const [expandedGuide, setExpandedGuide] = useState(null);\n const [envChecked, setEnvChecked] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [selectedCategory, setSelectedCategory] = useState(null);\n const [tokenStorage, setTokenStorage] = useState(null);\n\n useEffect(() => {\n void fetchStatus();\n void fetchTokenStorage();\n }, []);\n\n async function fetchStatus(): Promise {\n try {\n const res = await fetch(\"/api/integrations/status\");\n if (!res.ok) {\n console.error(\"Failed to fetch integration status:\", res.status);\n setIntegrations([]);\n return;\n }\n\n const data = await res.json();\n setIntegrations(data.integrations ?? []);\n } catch (error) {\n console.error(\"Failed to fetch integration status:\", error);\n setIntegrations([]);\n } finally {\n setLoading(false);\n }\n }\n\n async function fetchTokenStorage(): Promise {\n const fallback: TokenStorageStatus = { mode: \"memory\", encrypted: false };\n\n try {\n const res = await fetch(\"/api/integrations/token-storage\");\n if (!res.ok) {\n setTokenStorage(fallback);\n return;\n }\n const data = await res.json();\n setTokenStorage(data);\n } catch {\n setTokenStorage(fallback);\n }\n }\n\n const filteredIntegrations = useMemo(\n () => filterIntegrations(integrations, searchQuery, selectedCategory),\n [integrations, searchQuery, selectedCategory],\n );\n\n const groupedIntegrations = useMemo(\n () => groupIntegrationsByCategory(filteredIntegrations),\n [filteredIntegrations],\n );\n\n const connectedCount = integrations.filter((i) => i.connected).length;\n const totalCount = integrations.length;\n const progress = totalCount > 0 ? (connectedCount / totalCount) * 100 : 0;\n\n const allConnected = connectedCount === totalCount && totalCount > 0;\n\n const setupSteps = useMemo(\n () => buildSetupSteps(envChecked, allConnected, () => setEnvChecked(true)),\n [allConnected, envChecked],\n );\n\n const tokenStorageStyles = useMemo(() => getTokenStorageStyles(tokenStorage), [tokenStorage]);\n\n return (\n
\n
\n
\n

\n Setup Your AI Agent\n

\n

\n Connect your services to enable AI-powered automation\n

\n
\n\n
\n
\n \n Setup Progress\n \n \n {connectedCount} / {totalCount} services connected\n \n
\n
\n \n
\n
\n\n {tokenStorage && tokenStorageStyles && (\n
\n
\n
\n {tokenStorageStyles.isMemory ? (\n \n \n \n ) : (\n \n \n \n )}\n
\n\n
\n

\n Token Storage:{\" \"}\n {tokenStorageStyles.isMemory\n ? \"Development Mode\"\n : `${tokenStorage.mode.charAt(0).toUpperCase()}${tokenStorage.mode.slice(\n 1,\n )} Storage`}\n

\n\n

\n {tokenStorageStyles.isMemory ? (\n <>Tokens are stored in memory and will be lost on restart.\n ) : (\n <>Tokens are persisted to {tokenStorage.mode} storage.\n )}\n

\n\n
\n \n \n \n Encryption enabled {tokenStorage.autoGenerated && \"(auto-generated key)\"}\n
\n\n {tokenStorageStyles.isMemory && (\n
\n

\n For production, add one of these to your{\" \"}\n \n .env\n \n :\n

\n
\n \n
\n \n Upstash\n \n \n Recommended\n \n \n Serverless Redis, scales horizontally\n \n
\n \n REDIS_URL\n \n \n\n \n
\n \n Turso / libSQL\n \n \n Edge SQLite, fast reads globally\n \n
\n \n DATABASE_URL\n \n \n\n \n
\n \n Vercel KV\n \n \n Built-in if using Vercel\n \n
\n \n KV_REST_API_URL\n \n \n\n \n
\n Neon\n \n Serverless Postgres\n \n
\n \n DATABASE_URL\n \n \n\n \n
\n \n SQLite\n \n \n Local file, single instance only\n \n
\n \n DATABASE_URL=file:./data.db\n \n \n
\n
\n )}\n
\n
\n
\n )}\n\n
\n
\n

\n Quick Start Guide\n

\n
\n
\n {setupSteps.map((step, index) => (\n
\n \n {step.completed ? (\n \n \n \n ) : (\n {index + 1}\n )}\n
\n
\n

{step.title}

\n

\n {step.description}\n

\n
\n
\n ))}\n
\n
\n\n
\n
\n

\n Service Connections\n

\n

\n Click on a service to see setup instructions or connect\n

\n\n
\n setSearchQuery(e.target.value)}\n className=\"w-full px-4 py-2 bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n />\n
\n\n
\n setSelectedCategory(null)}\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === null\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n All\n \n\n {CATEGORIES.map((category) => (\n \n setSelectedCategory(selectedCategory === category.id ? null : category.id)\n }\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === category.id\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n {category.name}\n \n ))}\n
\n
\n\n {loading ? (\n
Loading...
\n ) : filteredIntegrations.length === 0 ? (\n
\n No services found matching your search\n
\n ) : (\n
\n {CATEGORIES.filter((cat) => groupedIntegrations[cat.id]?.length > 0).map(\n (category) => (\n
\n
\n

\n {category.name}\n

\n
\n\n
\n {groupedIntegrations[category.id]?.map((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n const isExpanded = expandedGuide === integration.id;\n\n return (\n
\n
\n
\n
\n \n
\n
\n

\n {integration.name}\n

\n \n {integration.connected ? \"Connected\" : \"Not connected\"}\n

\n
\n
\n\n
\n {guide && (\n \n setExpandedGuide(isExpanded ? null : integration.id)\n }\n className=\"px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white\"\n >\n {isExpanded ? \"Hide Guide\" : \"Setup Guide\"}\n \n )}\n\n {integration.connected ? (\n \n \n Connected\n \n ) : (\n \n Connect\n \n )}\n
\n
\n\n {isExpanded && guide && (\n
\n
\n

\n {guide.title}\n

\n\n
    \n {guide.steps.map((step, i) => (\n
  1. \n \n {i + 1}\n \n \n {step}\n \n
  2. \n ))}\n
\n\n
\n
\n Required Environment Variables:\n
\n
\n                                      {guide.envVars.map((v) => `${v}=your_value`).join(\"\\n\")}\n                                    
\n
\n\n \n Open Developer Console\n \n \n \n \n
\n
\n )}\n
\n );\n })}\n
\n
\n ),\n )}\n
\n )}\n
\n\n {allConnected && (\n
\n
🎉
\n

\n All Services Connected!\n

\n

\n Your AI agent is ready to use. Start chatting to automate your workflows.\n

\n \n Start Using Your Agent\n \n \n \n \n
\n )}\n
\n
\n );\n}\n", - "lib/oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\" || expiresIn <= 0) return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postTokenRequest(\n provider: OAuthProvider,\n body: Record,\n errorPrefix: string,\n): Promise {\n const response = await fetch(provider.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired || !token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n", - "lib/token-store.ts": "/**\n * Shared OAuth token store for generated integrations.\n *\n * The same store owns authorization state and tokens. This is required for\n * callbacks and token refresh to work across production workers. Configure a\n * durable, extension-owned RefreshCapableTokenStore before the first OAuth\n * request in production. The built-in memory store is development-only.\n */\n\nimport {\n MemoryTokenStore,\n type OAuthTokens,\n type OAuthTokenSnapshot,\n type RefreshCapableTokenStore,\n type StoredOAuthState,\n} from \"veryfront/oauth\";\n\nexport type OAuthToken = OAuthTokens;\n\n/**\n * Application-facing store used by both Veryfront OAuth handlers and the\n * generated integration clients.\n */\nexport interface TokenStore extends RefreshCapableTokenStore {\n getToken(userId: string, serviceId: string): Promise;\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise;\n revokeToken(userId: string, serviceId: string): Promise;\n isConnected(userId: string, serviceId: string): Promise;\n}\n\nconst REQUIRED_STORE_METHODS = [\n \"getTokens\",\n \"getTokenSnapshot\",\n \"setTokens\",\n \"compareAndSetTokens\",\n \"withTokenRefreshLock\",\n \"clearTokens\",\n \"setState\",\n \"consumeState\",\n] as const;\n\nfunction isProductionRuntime(): boolean {\n if (typeof process !== \"undefined\") return process.env?.NODE_ENV === \"production\";\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\") === \"production\";\n}\n\nfunction assertRefreshCapableStore(\n store: RefreshCapableTokenStore,\n): asserts store is RefreshCapableTokenStore {\n if (!store || typeof store !== \"object\") {\n throw new TypeError(\"OAuth token store must be an object\");\n }\n\n for (const method of REQUIRED_STORE_METHODS) {\n if (typeof store[method] !== \"function\") {\n throw new TypeError(`OAuth token store must implement ${method}()`);\n }\n }\n}\n\n/**\n * Add the generated client aliases to a production-grade Veryfront OAuth\n * store. The adapter delegates every concurrency and state operation to the\n * supplied store; it never emulates distributed behavior in process memory.\n */\nexport function createTokenStore(store: RefreshCapableTokenStore): TokenStore {\n assertRefreshCapableStore(store);\n\n return {\n getTokens(serviceId: string, userId: string): Promise {\n return store.getTokens(serviceId, userId);\n },\n\n getTokenSnapshot(\n serviceId: string,\n userId: string,\n ): Promise {\n return store.getTokenSnapshot(serviceId, userId);\n },\n\n setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise {\n return store.setTokens(serviceId, userId, tokens);\n },\n\n compareAndSetTokens(\n serviceId: string,\n userId: string,\n expectedRevision: string,\n tokens: OAuthTokens,\n ): Promise {\n return store.compareAndSetTokens(serviceId, userId, expectedRevision, tokens);\n },\n\n withTokenRefreshLock(\n serviceId: string,\n userId: string,\n operation: () => Promise,\n ): Promise {\n return store.withTokenRefreshLock(serviceId, userId, operation);\n },\n\n clearTokens(serviceId: string, userId: string): Promise {\n return store.clearTokens(serviceId, userId);\n },\n\n setState(state: string, metadata: StoredOAuthState): Promise {\n return store.setState(state, metadata);\n },\n\n consumeState(state: string): Promise {\n return store.consumeState(state);\n },\n\n getToken(userId: string, serviceId: string): Promise {\n return store.getTokens(serviceId, userId);\n },\n\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise {\n return store.setTokens(serviceId, userId, token);\n },\n\n revokeToken(userId: string, serviceId: string): Promise {\n return store.clearTokens(serviceId, userId);\n },\n\n async isConnected(userId: string, serviceId: string): Promise {\n const token = await store.getTokens(serviceId, userId);\n return !!token && (token.expiresAt === undefined || token.expiresAt > Date.now());\n },\n };\n}\n\nlet configuredTokenStore: TokenStore | null = null;\nlet defaultTokenStore: TokenStore | null = null;\n\n/**\n * Configure the shared production store before the first OAuth request.\n *\n * Production stores must persist state and tokens across workers, implement\n * atomic compare-and-set, and use a bounded, crash-recoverable distributed\n * lease for withTokenRefreshLock(). Storage extensions are responsible for\n * encryption and backend-specific concurrency guarantees.\n */\nexport function configureTokenStore(store: RefreshCapableTokenStore): void {\n if (configuredTokenStore || defaultTokenStore) {\n throw new Error(\"OAuth token store must be configured exactly once before first use\");\n }\n if (isProductionRuntime() && store instanceof MemoryTokenStore) {\n throw new Error(\"MemoryTokenStore is not allowed for production OAuth storage\");\n }\n configuredTokenStore = createTokenStore(store);\n}\n\n/** Resolve the development default without doing work during module import. */\nexport function createDefaultTokenStore(): TokenStore {\n if (isProductionRuntime()) {\n throw new Error(\n \"OAuth token storage is not configured for production. \" +\n \"Configure an extension-owned RefreshCapableTokenStore with \" +\n \"configureTokenStore() before the first OAuth request.\",\n );\n }\n\n console.warn(\n \"[Token Store] Using development-only in-memory OAuth storage. \" +\n \"State and tokens will be lost on restart.\",\n );\n return createTokenStore(new MemoryTokenStore());\n}\n\nfunction getDefaultTokenStore(): TokenStore {\n defaultTokenStore ??= configuredTokenStore ?? createDefaultTokenStore();\n return defaultTokenStore;\n}\n\n/**\n * Lazy proxy shared by every generated OAuth route and integration client.\n * Importing a route never initializes storage or throws.\n */\nexport const tokenStore: TokenStore = {\n getTokens(serviceId, userId) {\n return getDefaultTokenStore().getTokens(serviceId, userId);\n },\n getTokenSnapshot(serviceId, userId) {\n return getDefaultTokenStore().getTokenSnapshot(serviceId, userId);\n },\n setTokens(serviceId, userId, tokens) {\n return getDefaultTokenStore().setTokens(serviceId, userId, tokens);\n },\n compareAndSetTokens(serviceId, userId, expectedRevision, tokens) {\n return getDefaultTokenStore().compareAndSetTokens(\n serviceId,\n userId,\n expectedRevision,\n tokens,\n );\n },\n withTokenRefreshLock(serviceId, userId, operation) {\n return getDefaultTokenStore().withTokenRefreshLock(serviceId, userId, operation);\n },\n clearTokens(serviceId, userId) {\n return getDefaultTokenStore().clearTokens(serviceId, userId);\n },\n setState(state, metadata) {\n return getDefaultTokenStore().setState(state, metadata);\n },\n consumeState(state) {\n return getDefaultTokenStore().consumeState(state);\n },\n getToken(userId, serviceId) {\n return getDefaultTokenStore().getToken(userId, serviceId);\n },\n setToken(userId, serviceId, token) {\n return getDefaultTokenStore().setToken(userId, serviceId, token);\n },\n revokeToken(userId, serviceId) {\n return getDefaultTokenStore().revokeToken(userId, serviceId);\n },\n isConnected(userId, serviceId) {\n return getDefaultTokenStore().isConnected(userId, serviceId);\n },\n};\n", + "lib/encrypted-token-store.ts": "/**\n * Encrypted key-value OAuth token store for generated integrations.\n *\n * Wraps any durable key-value service (Redis, Postgres, Deno KV, a cloud KV\n * API) in the `RefreshCapableTokenStore` contract that `configureTokenStore`\n * in `token-store.ts` expects. Every value is encrypted at rest with\n * AES-256-GCM via the Web Crypto API before it reaches the backend:\n *\n * - A fresh random 96-bit IV is generated for every encryption.\n * - The storage key is bound as AES-GCM additional authenticated data, so a\n * ciphertext copied between storage slots fails authentication.\n * - The key comes from the `TOKEN_ENCRYPTION_KEY` environment variable\n * (64 hex characters = 256 bits). There is NO plaintext fallback: creating\n * the store without a valid key throws, and values that are not in the\n * expected encrypted envelope are refused on read.\n *\n * Key rotation: set `TOKEN_ENCRYPTION_KEY` to the new key and move the old\n * key to `TOKEN_ENCRYPTION_KEY_PREVIOUS`. New writes are sealed with the new\n * key (the v2 envelope records a key id derived from the key), while rows\n * sealed with the previous key stay readable. Every token row decrypted with\n * a non-current key is transparently re-sealed with the current key: always\n * on the next write, and best-effort on every read, so rotation converges\n * even for rows that are read but never rewritten. Use\n * `checkEncryptedTokenStoreRotation` to confirm no rows still need the\n * previous key, then remove `TOKEN_ENCRYPTION_KEY_PREVIOUS`; stragglers\n * degrade to \"disconnected\" and recover on reconnect.\n *\n * Legacy v1 envelopes (no key id) were written only by earlier revisions of\n * this template; they are decrypted by trying every configured key and are\n * upgraded to v2 by the same re-seal-on-read path, so the compatibility\n * branch retires itself as rows are read.\n *\n * Undecryptable token rows (unknown key, tampering, legacy plaintext) never\n * fail a whole request: the token read paths log a warning and report the\n * integration as disconnected, so the recovery is simply reconnecting (a\n * fresh `setTokens` overwrites the row; `clearTokens` removes it).\n *\n * Generate a key once per deployment and set it before startup:\n *\n * ```sh\n * openssl rand -hex 32\n * ```\n *\n * Concurrency (compare-and-swap, refresh locking) is delegated to the\n * backend so the guarantees hold across workers; this module never emulates\n * distributed behavior in process memory. See `token-store-examples.ts` for\n * reference backends and wiring.\n */\n\nimport type {\n OAuthTokens,\n OAuthTokenSnapshot,\n RefreshCapableTokenStore,\n StoredOAuthState,\n} from \"veryfront/oauth\";\n\n/**\n * Minimal durable backend contract. All five operations are required; the\n * atomic ones are what make token refresh and one-shot OAuth state safe\n * across workers.\n */\nexport interface EncryptedKvBackend {\n /** Read the raw stored value for a key, or null when absent. */\n get(key: string): Promise;\n /**\n * Durably write a value, replacing any existing one. `expiresInMs`, when\n * provided, is a TTL after which the backend may drop the row.\n */\n set(key: string, value: string, options?: { expiresInMs?: number }): Promise;\n /** Remove a key. Deleting an absent key is not an error. */\n delete(key: string): Promise;\n /**\n * Atomically replace `expected` with `next`. `expected === null` requires\n * the key to be absent; `next === null` deletes the key. Returns false\n * (without writing) when the current value does not match `expected`.\n */\n compareAndSwap(\n key: string,\n expected: string | null,\n next: string | null,\n options?: { expiresInMs?: number },\n ): Promise;\n /**\n * Run `operation` while holding a mutual-exclusion lease for `key` that is\n * visible to every worker (for example a Redis lock or an advisory lock).\n * The lease must be bounded so a crashed holder cannot block refresh\n * forever.\n */\n withLock(key: string, operation: () => Promise): Promise;\n}\n\nexport interface EncryptedKvRotationScanBackend extends EncryptedKvBackend {\n /**\n * Iterate stored rows whose key starts with `prefix`. Use a backend-native\n * bounded cursor or paginated scan; do not load an unbounded keyspace into\n * memory before yielding.\n */\n scan(prefix: string): AsyncIterable<{ key: string; value: string }>;\n}\n\nexport interface EncryptedTokenStoreRotationReport {\n scannedRows: number;\n currentKeyRows: number;\n previousKeyRows: number;\n unreadableRows: number;\n complete: boolean;\n}\n\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst PREVIOUS_ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY_PREVIOUS\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v2:\";\nconst LEGACY_ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\nconst KEY_ID_HEX_LENGTH = 16;\nconst KEY_ID_PATTERN = /^[0-9a-f]{16}$/;\nconst AES_GCM_IV_BYTES = 12;\nconst AES_GCM_TAG_BYTES = 16;\nconst AES_KEY_BYTES = 32;\nconst MAX_PLAINTEXT_BYTES = 64 * 1024;\nconst MAX_ENCRYPTED_BYTES = MAX_PLAINTEXT_BYTES + AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES;\nconst MAX_ENCODED_LENGTH = Math.ceil(MAX_ENCRYPTED_BYTES / 3) * 4 + ENVELOPE_PREFIX.length +\n KEY_ID_HEX_LENGTH + 1;\nconst BASE64_CHUNK_BYTES = 0x8000;\nconst MAX_KEY_COMPONENT_LENGTH = 1_024;\nconst MAX_STATE_KEY_LENGTH = 1_024;\nconst STATE_TTL_MS = 10 * 60 * 1_000;\nconst STATE_CLOCK_SKEW_MS = 60 * 1_000;\nconst MAX_SERVICE_ID_LENGTH = 128;\nconst MAX_SCOPE_COUNT = 100;\nconst MAX_REDIRECT_URI_LENGTH = 8_192;\nconst MAX_TOKEN_VALUE_LENGTH = 65_536;\nconst MAX_TOKEN_TYPE_LENGTH = 256;\nconst MAX_SCOPE_WIRE_LENGTH = 4_096;\n// A JSON array containing one-character values needs two bytes per value once\n// separators are included. Bounding the traversal before cloning therefore\n// prevents sparse arrays or deeply nested metadata from consuming memory\n// before the final plaintext-size check can run.\nconst MAX_JSON_VALUE_COUNT = Math.floor((MAX_PLAINTEXT_BYTES + 1) / 2);\nconst MAX_JSON_NESTING_DEPTH = 64;\n\nconst SERVICE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\nconst SCOPE_TOKEN_PATTERN = /^[\\x21\\x23-\\x5B\\x5D-\\x7E]+$/;\nconst PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;\n\nconst TOKENS_KEY_PREFIX = \"veryfront:oauth:v1:tokens:\";\nconst STATE_KEY_PREFIX = \"veryfront:oauth:v1:state:\";\nconst REFRESH_LOCK_KEY_PREFIX = \"veryfront:oauth:v1:refresh-lock:\";\n\nconst REQUIRED_BACKEND_METHODS = [\n \"get\",\n \"set\",\n \"delete\",\n \"compareAndSwap\",\n \"withLock\",\n] as const;\n\nfunction readEnvironmentVariable(name: string): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env[name];\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Treat that denial as an unavailable value; never bypass it\n // through a second environment API.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\n } catch {\n return undefined;\n }\n}\n\n/** Generate a fresh 256-bit key encoded as 64 hex characters. */\nexport function generateEncryptionKey(): string {\n return Array.from(crypto.getRandomValues(new Uint8Array(AES_KEY_BYTES)))\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nfunction parseEncryptionKeyHex(keyHex: string, envVar: string): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${envVar} must be exactly 64 hexadecimal characters ` +\n \"(a 256-bit AES key). Generate one with `openssl rand -hex 32`.\",\n );\n }\n const key = new Uint8Array(AES_KEY_BYTES);\n for (let index = 0; index < key.length; index++) {\n key[index] = Number.parseInt(keyHex.slice(index * 2, index * 2 + 2), 16);\n }\n return key;\n}\n\n/**\n * Resolve the configured encryption key or fail closed. This store never\n * writes plaintext credentials, so a missing key is a hard error rather than\n * a downgrade.\n */\nfunction requireEncryptionKeyBytes(): Uint8Array {\n const configured = readEnvironmentVariable(ENCRYPTION_KEY_ENV_VAR);\n if (configured === undefined || configured === \"\") {\n throw new Error(\n `${ENCRYPTION_KEY_ENV_VAR} is not set. The encrypted token store refuses ` +\n \"to persist plaintext OAuth credentials. Generate a key with \" +\n \"`openssl rand -hex 32` (or generateEncryptionKey()) and set \" +\n `${ENCRYPTION_KEY_ENV_VAR} before starting the app.`,\n );\n }\n return parseEncryptionKeyHex(configured, ENCRYPTION_KEY_ENV_VAR);\n}\n\n/**\n * Resolve the decryption key ring: the current key first (used for every\n * new write), then the optional previous key kept readable during rotation.\n */\nfunction resolveEncryptionKeyRing(): Uint8Array[] {\n const ring = [requireEncryptionKeyBytes()];\n const previous = readEnvironmentVariable(PREVIOUS_ENCRYPTION_KEY_ENV_VAR);\n if (previous !== undefined && previous !== \"\") {\n ring.push(parseEncryptionKeyHex(previous, PREVIOUS_ENCRYPTION_KEY_ENV_VAR));\n }\n return ring;\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (let offset = 0; offset < bytes.byteLength; offset += BASE64_CHUNK_BYTES) {\n const chunk = bytes.subarray(offset, offset + BASE64_CHUNK_BYTES);\n binary += String.fromCharCode(...chunk);\n }\n return btoa(binary);\n}\n\nfunction base64ToBytes(encoded: string): Uint8Array {\n if (\n encoded.length === 0 || encoded.length % 4 !== 0 ||\n !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)\n ) {\n throw new TypeError(\"Encrypted OAuth value has invalid base64 encoding\");\n }\n const binary = atob(encoded);\n if (\n binary.length < AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES ||\n binary.length > MAX_ENCRYPTED_BYTES\n ) {\n throw new RangeError(\"Encrypted OAuth value has an invalid size\");\n }\n const bytes = new Uint8Array(binary.length);\n for (let index = 0; index < binary.length; index++) {\n bytes[index] = binary.charCodeAt(index);\n }\n return bytes;\n}\n\nfunction requireKeyComponent(value: string, label: string): string {\n if (\n typeof value !== \"string\" || value.length === 0 ||\n value.length > MAX_KEY_COMPONENT_LENGTH || value.trim() !== value ||\n hasAsciiControlCharacter(value)\n ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters without control characters`,\n );\n }\n return value;\n}\n\nfunction quoteJsonString(value: string): string {\n let quoted = '\"';\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index);\n switch (code) {\n case 0x08:\n quoted += \"\\\\b\";\n break;\n case 0x09:\n quoted += \"\\\\t\";\n break;\n case 0x0a:\n quoted += \"\\\\n\";\n break;\n case 0x0c:\n quoted += \"\\\\f\";\n break;\n case 0x0d:\n quoted += \"\\\\r\";\n break;\n case 0x22:\n quoted += '\\\\\"';\n break;\n case 0x5c:\n quoted += \"\\\\\\\\\";\n break;\n default:\n if (code <= 0x1f) {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n } else if (code >= 0xd800 && code <= 0xdbff) {\n const next = value.charCodeAt(index + 1);\n if (next >= 0xdc00 && next <= 0xdfff) {\n quoted += value[index]! + value[index + 1]!;\n index++;\n } else {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n }\n } else if (code >= 0xdc00 && code <= 0xdfff) {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n } else {\n quoted += value[index];\n }\n }\n }\n return quoted + '\"';\n}\n\nfunction jsonArrayFrame(values: readonly string[]): string {\n return \"[\" + values.map(quoteJsonString).join(\",\") + \"]\";\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + jsonArrayFrame([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + jsonArrayFrame([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\") {\n throw new TypeError(\"state must be a string\");\n }\n if (state.length === 0 || state.length > MAX_STATE_KEY_LENGTH) {\n throw new RangeError(\n `state must contain between 1 and ${MAX_STATE_KEY_LENGTH} characters`,\n );\n }\n if (state.trim() !== state || hasAsciiControlCharacter(state)) {\n throw new TypeError(\"state must not contain surrounding whitespace or control characters\");\n }\n return STATE_KEY_PREFIX + jsonArrayFrame([state]);\n}\n\ninterface StoredTokenEntry {\n revision: string;\n tokens: OAuthTokens;\n}\n\nfunction ownDataValue(record: object, key: string): unknown {\n const descriptor = Object.getOwnPropertyDescriptor(record, key);\n return descriptor && \"value\" in descriptor ? descriptor.value : undefined;\n}\n\nfunction hasAsciiControlCharacter(value: string): boolean {\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index);\n if (code <= 0x1f || code === 0x7f) return true;\n }\n return false;\n}\n\ninterface JsonTraversalState {\n ancestors: WeakSet;\n depth: number;\n remainingStringCodeUnits: number;\n remainingValues: number;\n}\n\nfunction createJsonTraversalState(): JsonTraversalState {\n return {\n ancestors: new WeakSet(),\n depth: 0,\n remainingStringCodeUnits: MAX_PLAINTEXT_BYTES,\n remainingValues: MAX_JSON_VALUE_COUNT,\n };\n}\n\nfunction consumeJsonStringBudget(\n state: JsonTraversalState,\n value: string,\n label: string,\n): void {\n if (value.length > state.remainingStringCodeUnits) {\n throw new RangeError(`${label} contains too much JSON string data`);\n }\n state.remainingStringCodeUnits -= value.length;\n}\n\nfunction snapshotJsonData(\n value: unknown,\n label: string,\n state = createJsonTraversalState(),\n): unknown {\n if (state.remainingValues === 0) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n state.remainingValues--;\n\n if (value === null || typeof value === \"boolean\") {\n return value;\n }\n if (typeof value === \"string\") {\n consumeJsonStringBudget(state, value, label);\n return value;\n }\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) {\n throw new TypeError(`${label} must contain only finite JSON numbers`);\n }\n return value;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n if (state.depth >= MAX_JSON_NESTING_DEPTH) {\n throw new RangeError(\n `${label} exceeds the maximum JSON nesting depth of ${MAX_JSON_NESTING_DEPTH}`,\n );\n }\n if (state.ancestors.has(value)) {\n throw new TypeError(`${label} must not contain cyclic JSON data`);\n }\n state.ancestors.add(value);\n state.depth++;\n\n try {\n if (Array.isArray(value)) {\n if (value.length > state.remainingValues) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n const snapshot: unknown[] = [];\n snapshot.length = value.length;\n for (let index = 0; index < value.length; index++) {\n const descriptor = Object.getOwnPropertyDescriptor(value, String(index));\n if (!descriptor || !(\"value\" in descriptor)) {\n throw new TypeError(`${label} must contain only own data values`);\n }\n Object.defineProperty(snapshot, String(index), {\n configurable: true,\n enumerable: true,\n value: snapshotJsonData(descriptor.value, label, state),\n writable: true,\n });\n }\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError(`${label} must contain only plain JSON data objects`);\n }\n const keys = Object.keys(value);\n if (keys.length > state.remainingValues) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of keys) {\n consumeJsonStringBudget(state, key, label);\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (!descriptor || !(\"value\" in descriptor)) {\n throw new TypeError(`${label} must contain only own data values`);\n }\n snapshot[key] = snapshotJsonData(descriptor.value, label, state);\n }\n return snapshot;\n } finally {\n state.depth--;\n state.ancestors.delete(value);\n }\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotJsonData(value, \"Stored OAuth value\"));\n}\n\nfunction requireMetadata(value: unknown): Record | undefined {\n if (value === undefined) return undefined;\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth state metadata must be a plain object\");\n }\n return snapshotJsonData(value, \"Stored OAuth state metadata\") as Record;\n}\n\nfunction requireOptionalTokenString(\n record: object,\n key: string,\n maxLength: number,\n): string | undefined {\n const value = ownDataValue(record, key);\n if (value === undefined) return undefined;\n if (\n typeof value !== \"string\" || value.length === 0 || value.length > maxLength ||\n value.trim() !== value || hasAsciiControlCharacter(value)\n ) {\n throw new TypeError(`OAuth token row ${key} must be a safe bounded string`);\n }\n return value;\n}\n\nfunction requireTokenRow(value: unknown): OAuthTokens {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"OAuth token row must be an object\");\n }\n const accessToken = requireOptionalTokenString(value, \"accessToken\", MAX_TOKEN_VALUE_LENGTH);\n if (accessToken === undefined) {\n throw new TypeError(\"OAuth token row must contain a non-empty accessToken\");\n }\n const refreshToken = requireOptionalTokenString(value, \"refreshToken\", MAX_TOKEN_VALUE_LENGTH);\n const tokenType = requireOptionalTokenString(value, \"tokenType\", MAX_TOKEN_TYPE_LENGTH);\n const scopeValue = ownDataValue(value, \"scope\");\n let scope: string | undefined;\n if (scopeValue !== undefined) {\n if (\n typeof scopeValue !== \"string\" || hasAsciiControlCharacter(scopeValue) ||\n scopeValue.length > MAX_SCOPE_WIRE_LENGTH\n ) {\n throw new TypeError(\"OAuth token row scope must be a safe bounded string\");\n }\n scope = scopeValue.trim();\n if (scope.length === 0) {\n throw new TypeError(\"OAuth token row scope must be a safe bounded string\");\n }\n }\n const idToken = requireOptionalTokenString(value, \"idToken\", MAX_TOKEN_VALUE_LENGTH);\n const expiresAt = ownDataValue(value, \"expiresAt\");\n if (\n expiresAt !== undefined &&\n (typeof expiresAt !== \"number\" || !Number.isSafeInteger(expiresAt) || expiresAt < 0)\n ) {\n throw new TypeError(\"OAuth token expiresAt must be a non-negative safe integer\");\n }\n return {\n accessToken,\n ...(refreshToken === undefined ? {} : { refreshToken }),\n ...(expiresAt === undefined ? {} : { expiresAt }),\n ...(tokenType === undefined ? {} : { tokenType }),\n ...(scope === undefined ? {} : { scope }),\n ...(idToken === undefined ? {} : { idToken }),\n };\n}\n\nfunction requireTokenEntry(value: unknown): StoredTokenEntry {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth token entry must be an object\");\n }\n const revision = ownDataValue(value, \"revision\");\n if (typeof revision !== \"string\" || revision.length === 0) {\n throw new TypeError(\"Stored OAuth token entry must contain a revision\");\n }\n return { revision, tokens: requireTokenRow(ownDataValue(value, \"tokens\")) };\n}\n\nfunction requireStateRow(value: unknown): StoredOAuthState {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth state row must be an object\");\n }\n const userId = ownDataValue(value, \"userId\");\n const serviceId = ownDataValue(value, \"serviceId\");\n const redirectUri = ownDataValue(value, \"redirectUri\");\n const scopes = ownDataValue(value, \"scopes\");\n const createdAt = ownDataValue(value, \"createdAt\");\n const codeVerifier = ownDataValue(value, \"codeVerifier\");\n const metadata = requireMetadata(ownDataValue(value, \"metadata\"));\n if (\n typeof userId !== \"string\" || userId.length === 0 ||\n userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId ||\n hasAsciiControlCharacter(userId)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (\n typeof serviceId !== \"string\" || serviceId.length > MAX_SERVICE_ID_LENGTH ||\n !SERVICE_ID_PATTERN.test(serviceId)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n let parsedRedirectUri: URL;\n try {\n if (\n typeof redirectUri !== \"string\" || redirectUri.length > MAX_REDIRECT_URI_LENGTH ||\n redirectUri.trim() !== redirectUri || hasAsciiControlCharacter(redirectUri) ||\n redirectUri.includes(\"\\\\\")\n ) {\n throw new TypeError();\n }\n parsedRedirectUri = new URL(redirectUri);\n } catch {\n throw new TypeError(\"Stored OAuth state row must contain a valid redirectUri\");\n }\n const isLoopback = parsedRedirectUri.hostname === \"localhost\" ||\n parsedRedirectUri.hostname === \"127.0.0.1\" ||\n parsedRedirectUri.hostname === \"[::1]\" || parsedRedirectUri.hostname === \"::1\";\n if (\n parsedRedirectUri.username || parsedRedirectUri.password || parsedRedirectUri.hash ||\n (parsedRedirectUri.protocol !== \"https:\" &&\n !(parsedRedirectUri.protocol === \"http:\" && isLoopback))\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a valid redirectUri\");\n }\n if (!Array.isArray(scopes) || scopes.length > MAX_SCOPE_COUNT) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n const scopeSnapshot: string[] = [];\n for (let index = 0; index < scopes.length; index++) {\n const descriptor = Object.getOwnPropertyDescriptor(scopes, String(index));\n if (\n !descriptor || !(\"value\" in descriptor) || typeof descriptor.value !== \"string\" ||\n !SCOPE_TOKEN_PATTERN.test(descriptor.value)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n scopeSnapshot.push(descriptor.value);\n }\n if (scopeSnapshot.join(\" \").length > MAX_SCOPE_WIRE_LENGTH) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n if (typeof createdAt !== \"number\" || !Number.isSafeInteger(createdAt) || createdAt <= 0) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n if (\n codeVerifier !== undefined &&\n (typeof codeVerifier !== \"string\" || !PKCE_VERIFIER_PATTERN.test(codeVerifier))\n ) {\n throw new TypeError(\"Stored OAuth state row has an invalid codeVerifier\");\n }\n return {\n userId,\n serviceId,\n redirectUri,\n scopes: scopeSnapshot,\n createdAt,\n ...(codeVerifier === undefined ? {} : { codeVerifier }),\n ...(metadata === undefined ? {} : { metadata }),\n };\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n if (createdAt > now) {\n return createdAt - now <= STATE_CLOCK_SKEW_MS;\n }\n return now - createdAt <= STATE_TTL_MS;\n}\n\nfunction assertBackend(backend: EncryptedKvBackend): void {\n if (!backend || typeof backend !== \"object\") {\n throw new TypeError(\"Encrypted token store backend must be an object\");\n }\n for (const method of REQUIRED_BACKEND_METHODS) {\n if (typeof backend[method] !== \"function\") {\n throw new TypeError(`Encrypted token store backend must implement ${method}()`);\n }\n }\n}\n\ninterface EnvelopeKey {\n /** First 8 bytes of SHA-256 over the raw key, hex-encoded. */\n keyId: string;\n key: CryptoKey;\n}\n\nasync function importEnvelopeKey(keyBytes: Uint8Array): Promise {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", keyBytes));\n const key = await crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n const keyId = Array.from(digest.subarray(0, KEY_ID_HEX_LENGTH / 2))\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n return { keyId, key };\n}\n\ninterface OpenedEnvelope {\n value: unknown;\n /**\n * False when the row was decrypted with a retiring key or arrived in a\n * legacy v1 envelope, i.e. re-sealing it with the current key lets\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` be dropped sooner.\n */\n sealedWithCurrentKey: boolean;\n}\n\nclass EnvelopeCipher {\n /** The first entry is the current key; every entry may decrypt. */\n readonly #keys: Promise;\n\n constructor(keyRing: readonly Uint8Array[]) {\n this.#keys = Promise.all(keyRing.map(importEnvelopeKey));\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(stringifyJsonData(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\n }\n const [current] = await this.#keys;\n if (!current) {\n throw new Error(\"Encrypted token store has no encryption key configured\");\n }\n const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv, additionalData: new TextEncoder().encode(storageKey) },\n current.key,\n plaintext,\n ),\n );\n const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);\n combined.set(iv);\n combined.set(ciphertext, iv.byteLength);\n return ENVELOPE_PREFIX + current.keyId + \":\" + bytesToBase64(combined);\n }\n\n async open(storageKey: string, stored: string): Promise {\n if (typeof stored !== \"string\" || stored.length > MAX_ENCODED_LENGTH) {\n throw new TypeError(\"Stored OAuth value must be a bounded string\");\n }\n const keys = await this.#keys;\n if (stored.startsWith(ENVELOPE_PREFIX)) {\n const body = stored.slice(ENVELOPE_PREFIX.length);\n const keyId = body.slice(0, KEY_ID_HEX_LENGTH);\n if (!KEY_ID_PATTERN.test(keyId) || body[KEY_ID_HEX_LENGTH] !== \":\") {\n throw new TypeError(\"Encrypted OAuth value has a malformed key id\");\n }\n const match = keys.find((entry) => entry.keyId === keyId);\n if (!match) {\n throw new Error(\n `Encrypted OAuth value was sealed with an unknown encryption key (id ${keyId}). ` +\n `Set ${PREVIOUS_ENCRYPTION_KEY_ENV_VAR} to the retiring key during rotation, ` +\n \"or re-authenticate affected users.\",\n );\n }\n return {\n value: await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n ),\n sealedWithCurrentKey: match === keys[0],\n };\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key. They are\n // never reported as current: re-sealing upgrades them to v2.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return {\n value: await this.#decrypt(storageKey, combined, entry.key),\n sealedWithCurrentKey: false,\n };\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\n \"Stored OAuth value is not in a vf-aes-gcm envelope format. This store \" +\n \"never reads plaintext credentials; re-authenticate affected users to \" +\n \"replace legacy rows.\",\n );\n }\n\n async #decrypt(\n storageKey: string,\n combined: Uint8Array,\n key: CryptoKey,\n ): Promise {\n const iv = combined.subarray(0, AES_GCM_IV_BYTES);\n const ciphertext = combined.subarray(AES_GCM_IV_BYTES);\n let plaintext: ArrayBuffer;\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv, additionalData: new TextEncoder().encode(storageKey) },\n key,\n ciphertext,\n );\n } catch (cause) {\n throw new Error(\n \"Encrypted OAuth value failed authentication (wrong key, corrupted \" +\n \"data, or a value moved between storage slots)\",\n { cause },\n );\n }\n try {\n return JSON.parse(new TextDecoder().decode(plaintext));\n } catch (cause) {\n throw new Error(\"Encrypted OAuth value contains invalid JSON\", { cause });\n }\n }\n}\n\nfunction unreadableTokenRowReason(failure: unknown): string {\n if (!(failure instanceof Error)) return \"malformed encrypted row\";\n if (failure.message.includes(\"unknown encryption key\")) return \"unknown encryption key\";\n if (failure.message.includes(\"failed authentication\")) return \"failed authentication\";\n return \"malformed encrypted row\";\n}\n\nfunction unreadableStateRowReason(failure: unknown): string {\n if (!(failure instanceof Error)) return \"malformed encrypted state row\";\n if (failure.message.includes(\"unknown encryption key\")) return \"unknown encryption key\";\n if (failure.message.includes(\"failed authentication\")) return \"failed authentication\";\n return \"malformed encrypted state row\";\n}\n\nfunction assertRotationScanBackend(\n backend: EncryptedKvRotationScanBackend,\n): asserts backend is EncryptedKvRotationScanBackend {\n assertBackend(backend);\n if (typeof backend.scan !== \"function\") {\n throw new TypeError(\"Encrypted token store rotation checks require backend.scan()\");\n }\n}\n\n/**\n * Count token and OAuth state rows that still require\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS`.\n *\n * Run this after rotating keys and after normal reconnect/refresh traffic has\n * had a chance to rewrite rows. When `complete` is true, the scanned rows no\n * longer require the previous key. Unreadable rows are counted separately and\n * should be cleared or replaced before removing the previous key.\n * Expired OAuth state rows are ignored after authenticated decrypt and schema\n * validation because they can no longer be consumed.\n * `complete` describes only rows yielded by the backend; an empty scan reports\n * `complete: true` with `scannedRows: 0`. Confirm the scan covered the expected\n * rows before removing the previous key.\n */\nexport async function checkEncryptedTokenStoreRotation(\n backend: EncryptedKvRotationScanBackend,\n): Promise {\n assertRotationScanBackend(backend);\n const cipher = new EnvelopeCipher(resolveEncryptionKeyRing());\n const report: EncryptedTokenStoreRotationReport = {\n scannedRows: 0,\n currentKeyRows: 0,\n previousKeyRows: 0,\n unreadableRows: 0,\n complete: false,\n };\n const scanNow = Date.now();\n\n for (const prefix of [TOKENS_KEY_PREFIX, STATE_KEY_PREFIX]) {\n for await (const row of backend.scan(prefix)) {\n report.scannedRows++;\n if (\n !row || typeof row !== \"object\" || typeof row.key !== \"string\" ||\n typeof row.value !== \"string\" || !row.key.startsWith(prefix)\n ) {\n report.unreadableRows++;\n continue;\n }\n try {\n const opened = await cipher.open(row.key, row.value);\n if (row.key.startsWith(TOKENS_KEY_PREFIX)) {\n requireTokenEntry(opened.value);\n } else {\n const state = requireStateRow(opened.value);\n if (!isFreshState(state.createdAt, scanNow)) continue;\n }\n if (opened.sealedWithCurrentKey) report.currentKeyRows++;\n else report.previousKeyRows++;\n } catch {\n report.unreadableRows++;\n }\n }\n }\n\n report.complete = report.previousKeyRows === 0 && report.unreadableRows === 0;\n return report;\n}\n\n/**\n * Build a `RefreshCapableTokenStore` over a durable key-value backend with\n * AES-256-GCM encryption at rest.\n *\n * Fails closed at creation time when `TOKEN_ENCRYPTION_KEY` is missing or\n * malformed, and when the backend does not provide the atomic operations\n * that safe multi-worker refresh requires.\n *\n * Wire it once during startup through an explicit configuration boundary:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import {\n * createEncryptedTokenStore,\n * type EncryptedKvBackend,\n * } from \"./encrypted-token-store.ts\";\n *\n * export function configureOAuthStorage(backend: EncryptedKvBackend): void {\n * configureTokenStore(createEncryptedTokenStore(backend));\n * }\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(resolveEncryptionKeyRing());\n\n // Undecryptable or malformed rows degrade to \"absent\" instead of failing\n // the caller: a single bad row must not take down an integrations page.\n // The integration shows as disconnected and reconnecting (setTokens)\n // overwrites the row; clearTokens removes it explicitly. The warning never\n // includes token material.\n async function readTokenEntry(\n serviceId: string,\n userId: string,\n ): Promise<{ key: string; raw: string; entry: StoredTokenEntry } | null> {\n const key = tokensStorageKey(serviceId, userId);\n const raw = await backend.get(key);\n if (raw === null) return null;\n try {\n const opened = await cipher.open(key, raw);\n const entry = requireTokenEntry(opened.value);\n if (opened.sealedWithCurrentKey) return { key, raw, entry };\n return { key, raw: (await resealTokenRow(key, raw, entry)) ?? raw, entry };\n } catch (failure) {\n console.warn(\n \"[Encrypted Token Store] Ignoring unreadable OAuth token row \" +\n `(${unreadableTokenRowReason(failure)}). ` +\n \"The integration is reported as disconnected; reconnecting overwrites the row.\",\n );\n return null;\n }\n }\n\n // Best-effort transparent re-seal so rotation also converges for rows that\n // are read but never rewritten. The revision is preserved (this is a\n // re-encryption, not a logical write), the swap is ABA-safe because every\n // seal uses a fresh IV, and any failure is ignored: the next read simply\n // tries again, and losing the swap to a concurrent writer is fine because\n // that writer already sealed with the current key.\n async function resealTokenRow(\n key: string,\n raw: string,\n entry: StoredTokenEntry,\n ): Promise {\n try {\n const resealed = await cipher.seal(key, entry);\n return (await backend.compareAndSwap(key, raw, resealed)) ? resealed : null;\n } catch {\n return null;\n }\n }\n\n return {\n async getTokens(serviceId: string, userId: string): Promise {\n return (await readTokenEntry(serviceId, userId))?.entry.tokens ?? null;\n },\n\n async getTokenSnapshot(\n serviceId: string,\n userId: string,\n ): Promise {\n return (await readTokenEntry(serviceId, userId))?.entry ?? null;\n },\n\n async setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise {\n const key = tokensStorageKey(serviceId, userId);\n const entry: StoredTokenEntry = {\n revision: crypto.randomUUID(),\n tokens: requireTokenRow(tokens),\n };\n await backend.set(key, await cipher.seal(key, entry));\n },\n\n async compareAndSetTokens(\n serviceId: string,\n userId: string,\n expectedRevision: string,\n tokens: OAuthTokens,\n ): Promise {\n if (typeof expectedRevision !== \"string\" || expectedRevision.length === 0) {\n throw new TypeError(\"Expected OAuth token revision must be a non-empty string\");\n }\n const current = await readTokenEntry(serviceId, userId);\n if (!current || current.entry.revision !== expectedRevision) return false;\n const next: StoredTokenEntry = {\n revision: crypto.randomUUID(),\n tokens: requireTokenRow(tokens),\n };\n return backend.compareAndSwap(\n current.key,\n current.raw,\n await cipher.seal(current.key, next),\n );\n },\n\n withTokenRefreshLock(\n serviceId: string,\n userId: string,\n operation: () => Promise,\n ): Promise {\n return backend.withLock(refreshLockKey(serviceId, userId), operation);\n },\n\n async clearTokens(serviceId: string, userId: string): Promise {\n await backend.delete(tokensStorageKey(serviceId, userId));\n },\n\n async setState(state: string, metadata: StoredOAuthState): Promise {\n const key = stateStorageKey(state);\n const row = requireStateRow(metadata);\n if (!isFreshState(row.createdAt, Date.now())) {\n throw new RangeError(\"OAuth state createdAt is outside the acceptance window\");\n }\n const inserted = await backend.compareAndSwap(\n key,\n null,\n await cipher.seal(key, row),\n { expiresInMs: STATE_TTL_MS + STATE_CLOCK_SKEW_MS },\n );\n if (!inserted) throw new Error(\"OAuth state already exists\");\n },\n\n async consumeState(state: string): Promise {\n const key = stateStorageKey(state);\n const raw = await backend.get(key);\n if (raw === null) return null;\n // One-shot semantics: only the caller that atomically removes the row\n // may redeem it, so a replayed callback cannot reuse the state.\n const consumed = await backend.compareAndSwap(key, raw, null);\n if (!consumed) return null;\n try {\n const row = requireStateRow((await cipher.open(key, raw)).value);\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch (failure) {\n console.warn(\n \"[Encrypted Token Store] Ignoring unreadable OAuth state row \" +\n `(${unreadableStateRowReason(failure)}). ` +\n \"The OAuth callback state is rejected.\",\n );\n return null;\n }\n },\n };\n}\n", + "lib/oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\" || expiresIn <= 0) return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postTokenRequest(\n provider: OAuthProvider,\n body: Record,\n errorPrefix: string,\n): Promise {\n const response = await fetch(provider.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n", + "lib/token-store-examples.ts": "/**\n * Reference backends for `createEncryptedTokenStore` in\n * `encrypted-token-store.ts`.\n *\n * The in-memory backend below is for local development and tests only: it is\n * process-local, so tokens vanish on restart and are not shared across\n * workers. For production, implement `EncryptedKvBackend` over a durable\n * service and pass it into startup through an explicit configuration\n * boundary. This example is complete and does not rely on module globals:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import {\n * createEncryptedTokenStore,\n * type EncryptedKvBackend,\n * } from \"./encrypted-token-store.ts\";\n *\n * export function configureOAuthStorage(backend: EncryptedKvBackend): void {\n * configureTokenStore(createEncryptedTokenStore(backend));\n * }\n * ```\n *\n * Redis adapter sketch (pseudocode, not a paste-ready client): replace every\n * angle-bracketed operation with the equivalent atomic operation from your\n * initialized Redis client.\n *\n * ```text\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => (key),\n * set: async (key, value, options) => {\n * await (key, value, options?.expiresInMs);\n * },\n * delete: (key) => (key),\n * compareAndSwap: (key, expected, next, options) =>\n * (key, expected, next, options?.expiresInMs),\n * withLock: (key, operation) =>\n * (key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Preserve the fail-closed mode decision in that runtime.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(\"NODE_ENV\");\n } catch {\n return undefined;\n }\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development/test in-memory backend. Values are still encrypted (the store\n * requires `TOKEN_ENCRYPTION_KEY` in every mode) but nothing is durable and\n * nothing is shared across workers, so creation is refused in production.\n */\nexport function createMemoryKvBackend(): EncryptedKvBackend {\n const mode = runtimeMode();\n if (mode !== \"development\" && mode !== \"test\") {\n throw new Error(\n mode === \"production\"\n ? \"The in-memory example backend is not allowed in production. Implement \" +\n \"EncryptedKvBackend over a durable service (Redis, Postgres, Deno KV).\"\n : \"The in-memory example backend requires an explicit development or test \" +\n \"runtime. Set NODE_ENV accordingly, or implement EncryptedKvBackend over \" +\n \"a durable service (Redis, Postgres, Deno KV).\",\n );\n }\n\n const rows = new Map();\n const lockTails = new Map>();\n\n function readRow(key: string): string | null {\n const row = rows.get(key);\n if (!row) return null;\n if (row.expiresAt !== null && Date.now() >= row.expiresAt) {\n rows.delete(key);\n return null;\n }\n return row.value;\n }\n\n function writeRow(key: string, value: string, expiresInMs?: number): void {\n rows.set(key, {\n value,\n expiresAt: expiresInMs === undefined ? null : Date.now() + expiresInMs,\n });\n }\n\n return {\n get(key) {\n return Promise.resolve(readRow(key));\n },\n set(key, value, options) {\n writeRow(key, value, options?.expiresInMs);\n return Promise.resolve();\n },\n delete(key) {\n rows.delete(key);\n return Promise.resolve();\n },\n compareAndSwap(key, expected, next, options) {\n // No await between comparison and write: within one process this block\n // is indivisible, which is exactly the guarantee the contract asks a\n // distributed backend to provide server-side.\n if (readRow(key) !== expected) return Promise.resolve(false);\n if (next === null) rows.delete(key);\n else writeRow(key, next, options?.expiresInMs);\n return Promise.resolve(true);\n },\n async withLock(key, operation) {\n const prior = lockTails.get(key) ?? Promise.resolve();\n let release!: () => void;\n const current = new Promise((resolve) => {\n release = resolve;\n });\n const tail = prior.catch(() => undefined).then(() => current);\n lockTails.set(key, tail);\n\n await prior.catch(() => undefined);\n try {\n return await operation();\n } finally {\n release();\n if (lockTails.get(key) === tail) lockTails.delete(key);\n }\n },\n };\n}\n", + "lib/token-store.ts": "/**\n * Shared OAuth token store for generated integrations.\n *\n * The same store owns authorization state and tokens. This is required for\n * callbacks and token refresh to work across production workers. Configure a\n * durable, extension-owned RefreshCapableTokenStore before the first OAuth\n * request in production. The built-in memory store is for development and test.\n *\n * To build that durable store on top of a plain key-value service with\n * AES-256-GCM encryption at rest, see `encrypted-token-store.ts` (reference\n * backends live in `token-store-examples.ts`).\n */\n\nimport {\n MemoryTokenStore,\n type OAuthTokens,\n type OAuthTokenSnapshot,\n type RefreshCapableTokenStore,\n type StoredOAuthState,\n} from \"veryfront/oauth\";\n\nexport type OAuthToken = OAuthTokens;\n\n/**\n * Application-facing store used by both Veryfront OAuth handlers and the\n * generated integration clients.\n */\nexport interface TokenStore extends RefreshCapableTokenStore {\n getToken(userId: string, serviceId: string): Promise;\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise;\n revokeToken(userId: string, serviceId: string): Promise;\n isConnected(userId: string, serviceId: string): Promise;\n}\n\nconst REQUIRED_STORE_METHODS = [\n \"getTokens\",\n \"getTokenSnapshot\",\n \"setTokens\",\n \"compareAndSetTokens\",\n \"withTokenRefreshLock\",\n \"clearTokens\",\n \"setState\",\n \"consumeState\",\n] as const;\nconst TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1_000;\n\nfunction runtimeMode(): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Preserve the fail-closed mode decision in that runtime.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(\"NODE_ENV\");\n } catch {\n return undefined;\n }\n}\n\nfunction allowsProcessLocalStorage(): boolean {\n const mode = runtimeMode();\n return mode === \"development\" || mode === \"test\";\n}\n\nfunction assertRefreshCapableStore(\n store: RefreshCapableTokenStore,\n): asserts store is RefreshCapableTokenStore {\n if (!store || typeof store !== \"object\") {\n throw new TypeError(\"OAuth token store must be an object\");\n }\n\n for (const method of REQUIRED_STORE_METHODS) {\n if (typeof store[method] !== \"function\") {\n throw new TypeError(`OAuth token store must implement ${method}()`);\n }\n }\n}\n\n/**\n * Add the generated client aliases to a production-grade Veryfront OAuth\n * store. The adapter delegates every concurrency and state operation to the\n * supplied store; it never emulates distributed behavior in process memory.\n */\nexport function createTokenStore(store: RefreshCapableTokenStore): TokenStore {\n assertRefreshCapableStore(store);\n\n return {\n getTokens(serviceId: string, userId: string): Promise {\n return store.getTokens(serviceId, userId);\n },\n\n getTokenSnapshot(\n serviceId: string,\n userId: string,\n ): Promise {\n return store.getTokenSnapshot(serviceId, userId);\n },\n\n setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise {\n return store.setTokens(serviceId, userId, tokens);\n },\n\n compareAndSetTokens(\n serviceId: string,\n userId: string,\n expectedRevision: string,\n tokens: OAuthTokens,\n ): Promise {\n return store.compareAndSetTokens(serviceId, userId, expectedRevision, tokens);\n },\n\n withTokenRefreshLock(\n serviceId: string,\n userId: string,\n operation: () => Promise,\n ): Promise {\n return store.withTokenRefreshLock(serviceId, userId, operation);\n },\n\n clearTokens(serviceId: string, userId: string): Promise {\n return store.clearTokens(serviceId, userId);\n },\n\n setState(state: string, metadata: StoredOAuthState): Promise {\n return store.setState(state, metadata);\n },\n\n consumeState(state: string): Promise {\n return store.consumeState(state);\n },\n\n getToken(userId: string, serviceId: string): Promise {\n return store.getTokens(serviceId, userId);\n },\n\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise {\n return store.setTokens(serviceId, userId, token);\n },\n\n revokeToken(userId: string, serviceId: string): Promise {\n return store.clearTokens(serviceId, userId);\n },\n\n async isConnected(userId: string, serviceId: string): Promise {\n const token = await store.getTokens(serviceId, userId);\n return !!token && (token.expiresAt === undefined || token.expiresAt > Date.now());\n },\n };\n}\n\nfunction unexpiredAccessToken(token: OAuthToken, now = Date.now()): string | null {\n return token.expiresAt === undefined || now < token.expiresAt ? token.accessToken : null;\n}\n\n/**\n * Resolve an access token, refreshing it under the store's distributed lock.\n * Revisioned compare-and-set prevents a refresh from overwriting a concurrent\n * reconnect or revocation that did not participate in the refresh lock.\n */\nexport async function getRefreshableAccessToken(\n store: TokenStore,\n serviceId: string,\n userId: string,\n refresh: (refreshToken: string) => Promise,\n): Promise {\n const initial = await store.getTokenSnapshot(serviceId, userId);\n if (!initial) return null;\n\n const initialToken = initial.tokens;\n const now = Date.now();\n if (\n initialToken.expiresAt === undefined ||\n now < initialToken.expiresAt - TOKEN_REFRESH_BUFFER_MS\n ) {\n return initialToken.accessToken;\n }\n if (!initialToken.refreshToken) return unexpiredAccessToken(initialToken, now);\n\n return await store.withTokenRefreshLock(serviceId, userId, async () => {\n // Another worker may have refreshed this slot while this caller waited.\n const current = await store.getTokenSnapshot(serviceId, userId);\n if (!current) return null;\n\n const token = current.tokens;\n const lockedNow = Date.now();\n if (\n token.expiresAt === undefined ||\n lockedNow < token.expiresAt - TOKEN_REFRESH_BUFFER_MS\n ) {\n return token.accessToken;\n }\n if (!token.refreshToken) return unexpiredAccessToken(token, lockedNow);\n\n let refreshed: OAuthToken;\n try {\n refreshed = await refresh(token.refreshToken);\n } catch {\n // A provider failure must not unconditionally delete a row that may\n // have been replaced by a concurrent reconnect outside the lock.\n const latest = await store.getTokens(serviceId, userId);\n return latest ? unexpiredAccessToken(latest) : null;\n }\n\n if (refreshed.refreshToken === undefined) {\n refreshed = { ...refreshed, refreshToken: token.refreshToken };\n }\n\n const replaced = await store.compareAndSetTokens(\n serviceId,\n userId,\n current.revision,\n refreshed,\n );\n if (replaced) return refreshed.accessToken;\n\n const latest = await store.getTokens(serviceId, userId);\n return latest ? unexpiredAccessToken(latest) : null;\n });\n}\n\nlet configuredTokenStore: TokenStore | null = null;\nlet defaultTokenStore: TokenStore | null = null;\n\n/**\n * Configure the shared production store before the first OAuth request.\n *\n * Production stores must persist state and tokens across workers, implement\n * atomic compare-and-set, and use a bounded, crash-recoverable distributed\n * lease for withTokenRefreshLock(). Storage extensions are responsible for\n * encryption and backend-specific concurrency guarantees.\n */\nexport function configureTokenStore(store: RefreshCapableTokenStore): void {\n if (configuredTokenStore || defaultTokenStore) {\n throw new Error(\"OAuth token store must be configured exactly once before first use\");\n }\n if (!allowsProcessLocalStorage() && store instanceof MemoryTokenStore) {\n throw new Error(\n \"MemoryTokenStore is allowed only when NODE_ENV is explicitly development or test\",\n );\n }\n configuredTokenStore = createTokenStore(store);\n}\n\n/** Resolve the development default without doing work during module import. */\nexport function createDefaultTokenStore(): TokenStore {\n if (!allowsProcessLocalStorage()) {\n throw new Error(\n \"OAuth token storage is not configured. The in-memory default is allowed \" +\n \"only when NODE_ENV is explicitly development or test. \" +\n \"Configure an extension-owned RefreshCapableTokenStore with \" +\n \"configureTokenStore() before the first OAuth request.\",\n );\n }\n\n console.warn(\n \"[Token Store] Using development-only in-memory OAuth storage. \" +\n \"State and tokens will be lost on restart.\",\n );\n return createTokenStore(new MemoryTokenStore());\n}\n\nfunction getDefaultTokenStore(): TokenStore {\n defaultTokenStore ??= configuredTokenStore ?? createDefaultTokenStore();\n return defaultTokenStore;\n}\n\n/**\n * Lazy proxy shared by every generated OAuth route and integration client.\n * Importing a route never initializes storage or throws.\n */\nexport const tokenStore: TokenStore = {\n getTokens(serviceId, userId) {\n return getDefaultTokenStore().getTokens(serviceId, userId);\n },\n getTokenSnapshot(serviceId, userId) {\n return getDefaultTokenStore().getTokenSnapshot(serviceId, userId);\n },\n setTokens(serviceId, userId, tokens) {\n return getDefaultTokenStore().setTokens(serviceId, userId, tokens);\n },\n compareAndSetTokens(serviceId, userId, expectedRevision, tokens) {\n return getDefaultTokenStore().compareAndSetTokens(\n serviceId,\n userId,\n expectedRevision,\n tokens,\n );\n },\n withTokenRefreshLock(serviceId, userId, operation) {\n return getDefaultTokenStore().withTokenRefreshLock(serviceId, userId, operation);\n },\n clearTokens(serviceId, userId) {\n return getDefaultTokenStore().clearTokens(serviceId, userId);\n },\n setState(state, metadata) {\n return getDefaultTokenStore().setState(state, metadata);\n },\n consumeState(state) {\n return getDefaultTokenStore().consumeState(state);\n },\n getToken(userId, serviceId) {\n return getDefaultTokenStore().getToken(userId, serviceId);\n },\n setToken(userId, serviceId, token) {\n return getDefaultTokenStore().setToken(userId, serviceId, token);\n },\n revokeToken(userId, serviceId) {\n return getDefaultTokenStore().revokeToken(userId, serviceId);\n },\n isConnected(userId, serviceId) {\n return getDefaultTokenStore().isConnected(userId, serviceId);\n },\n};\n", "lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nfunction normalizeUserId(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length === 0 || value.length > 1_024) {\n return null;\n }\n return value.trim() === value ? value : null;\n}\n\n/**\n * Application-owned session/JWT lookup.\n *\n * Replace this implementation with a server-side session lookup or verified\n * JWT check. There is deliberately no environment-gated default, because an\n * ambient identity collapses every visitor onto a single OAuth token owner.\n */\nexport async function resolveAuthenticatedUserId(\n _request: Request,\n): Promise {\n throw new Error(\n \"Authenticated request identity is not configured. Implement \" +\n \"resolveAuthenticatedUserId in lib/user-id.ts using a verified session or JWT.\",\n );\n}\n\n/** Resolve and validate the authenticated user for an OAuth request. */\nexport async function requireUserIdFromRequest(\n request: Request,\n): Promise {\n return normalizeUserId(await resolveAuthenticatedUserId(request));\n}\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = normalizeUserId(context?.userId);\n if (userId) return userId;\n throw new Error(\"Authenticated tool context userId is required\");\n}\n", "SETUP.md": "# Integration Setup Guide\n\nThis guide helps you set up credentials for all 50+ service integrations available in Veryfront.\n\n## Quick Start\n\n```bash\n# Create a new project with integrations\nveryfront init my-app --with ai --integrations slack,github,notion\n\n# Start development\ncd my-app\nveryfront dev\n```\n\nVisit `http://localhost:3000/api/auth/{service}` to connect each service.\n\n---\n\n## Table of Contents\n\n- [Google Services](#google-services) (Gmail, Calendar, Drive, Docs, Sheets)\n- [Microsoft Services](#microsoft-services) (Outlook, Teams, SharePoint, OneDrive)\n- [Atlassian Services](#atlassian-services) (Jira, Confluence)\n- [Communication](#communication) (Slack, Twilio, Zoom, Webex)\n- [Project Management](#project-management) (Asana, Monday, Trello, ClickUp, Linear, Notion)\n- [Developer Tools](#developer-tools) (GitHub, GitLab, Bitbucket, Figma, Sentry, PostHog)\n- [CRM & Sales](#crm--sales) (Salesforce, Pipedrive, Intercom, Zendesk, Freshdesk)\n- [Databases](#databases) (Supabase, Neon, Airtable, Snowflake)\n- [Cloud & Storage](#cloud--storage) (AWS, Box)\n- [Finance](#finance) (Stripe, QuickBooks, Xero)\n- [Marketing](#marketing) (Mailchimp, Twitter)\n- [E-commerce](#e-commerce) (Shopify)\n- [AI & Analytics](#ai--analytics) (Anthropic, Mixpanel)\n\n---\n\n## Google Services\n\n**Gmail, Calendar, Drive, Docs, Sheets** all use the same Google OAuth credentials.\n\n### Setup Steps\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)\n2. Create a new project or select existing\n3. Enable required APIs:\n - Gmail API\n - Google Calendar API\n - Google Drive API\n - Google Docs API\n - Google Sheets API\n4. Go to **OAuth consent screen**:\n - User Type: External (or Internal for Workspace)\n - Add scopes for each API you need\n5. Go to **Credentials** > **Create Credentials** > **OAuth client ID**:\n - Application type: Web application\n - Authorized redirect URIs:\n ```\n http://localhost:3000/api/auth/gmail/callback\n http://localhost:3000/api/auth/calendar/callback\n http://localhost:3000/api/auth/drive/callback\n http://localhost:3000/api/auth/docs-google/callback\n http://localhost:3000/api/auth/sheets/callback\n ```\n\n### Environment Variables\n\n```env\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| -------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Gmail | `gmail.readonly`, `gmail.send`, `gmail.modify`, `gmail.labels`, `gmail.compose`, `https://mail.google.com/` for permanent delete |\n| Calendar | `calendar.readonly`, `calendar.events` |\n| Drive | `drive.readonly`, `drive.file` |\n| Docs | `documents.readonly`, `documents` |\n| Sheets | `spreadsheets.readonly`, `spreadsheets` |\n\n---\n\n## Microsoft Services\n\n**Outlook, Teams, SharePoint, OneDrive** use Microsoft OAuth (Azure AD).\n\n### Setup Steps\n\n1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)\n2. Click **New registration**:\n - Name: Your app name\n - Supported account types: Accounts in any organizational directory\n - Redirect URI: Web, `http://localhost:3000/api/auth/outlook/callback`\n3. After creation, go to **Certificates & secrets**:\n - Create a new client secret\n4. Go to **API permissions**:\n - Add Microsoft Graph permissions\n\n### Environment Variables\n\n```env\nMICROSOFT_CLIENT_ID=your-application-client-id\nMICROSOFT_CLIENT_SECRET=your-client-secret\nMICROSOFT_TENANT_ID=common\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| ---------- | ------------------------------------------------------------- |\n| Outlook | `Mail.Read`, `Mail.Send`, `Calendars.ReadWrite` |\n| Teams | `Team.ReadBasic.All`, `Chat.ReadWrite`, `ChannelMessage.Send` |\n| SharePoint | `Sites.Read.All`, `Files.ReadWrite.All` |\n| OneDrive | `Files.Read`, `Files.ReadWrite` |\n\n---\n\n## Atlassian Services\n\n**Jira and Confluence** use Atlassian OAuth 2.0 (3LO).\n\n### Setup Steps\n\n1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)\n2. Click **Create** > **OAuth 2.0 integration**\n3. Configure:\n - Name: Your app name\n - Callback URL: `http://localhost:3000/api/auth/jira/callback`\n4. Add required scopes in **Permissions**\n5. Get your Cloud ID: Visit `https://your-domain.atlassian.net/_edge/tenant_info`\n\n### Environment Variables\n\n```env\nATLASSIAN_CLIENT_ID=your-client-id\nATLASSIAN_CLIENT_SECRET=your-client-secret\nATLASSIAN_CLOUD_ID=your-cloud-id\n```\n\n### Required Scopes\n\n| Service | Scopes |\n| ---------- | --------------------------------------------------------- |\n| Jira | `read:jira-work`, `write:jira-work`, `read:jira-user` |\n| Confluence | `read:confluence-content.all`, `write:confluence-content` |\n\n---\n\n## Communication\n\n### Slack\n\n1. Go to [Slack API Apps](https://api.slack.com/apps)\n2. Click **Create New App** > **From scratch**\n3. Go to **OAuth & Permissions**:\n - Add redirect URL: `http://localhost:3000/api/auth/slack/callback`\n - Add scopes: `channels:history`, `channels:read`, `chat:write`, `groups:history`, `groups:read`, `im:history`, `im:read`, `mpim:history`, `mpim:read`, `users:read`\n4. **Install to Workspace**\n\n```env\nSLACK_CLIENT_ID=your-client-id\nSLACK_CLIENT_SECRET=your-client-secret\n```\n\n### Twilio (SMS/WhatsApp)\n\n1. Go to [Twilio Console](https://console.twilio.com/)\n2. Get Account SID and Auth Token from dashboard\n3. Get or buy a phone number for sending\n\n```env\nTWILIO_ACCOUNT_SID=your-account-sid\nTWILIO_AUTH_TOKEN=your-auth-token\nTWILIO_PHONE_NUMBER=+1234567890\n```\n\n### Zoom\n\n1. Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)\n2. Create **OAuth App**\n3. Configure redirect: `http://localhost:3000/api/auth/zoom/callback`\n4. Add scopes: `meeting:read`, `meeting:write`, `user:read`\n\n```env\nZOOM_CLIENT_ID=your-client-id\nZOOM_CLIENT_SECRET=your-client-secret\n```\n\n### Webex\n\n1. Go to [Webex for Developers](https://developer.webex.com/my-apps)\n2. Create new integration\n3. Redirect URI: `http://localhost:3000/api/auth/webex/callback`\n4. Scopes: `spark:messages_read`, `spark:messages_write`, `spark:rooms_read`\n\n```env\nWEBEX_CLIENT_ID=your-client-id\nWEBEX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Project Management\n\n### Asana\n\n1. Go to [Asana Developer Console](https://app.asana.com/0/developer-console)\n2. Create new app\n3. Set redirect URL: `http://localhost:3000/api/auth/asana/callback`\n\n```env\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n```\n\n### Monday.com\n\n1. Go to [Monday Apps](https://auth.monday.com/oauth2/authorize)\n2. Create new app in your account's Developer section\n3. Configure OAuth with redirect: `http://localhost:3000/api/auth/monday/callback`\n\n```env\nMONDAY_CLIENT_ID=your-client-id\nMONDAY_CLIENT_SECRET=your-client-secret\n```\n\n### Trello\n\n1. Go to [Trello Power-Ups Admin](https://trello.com/power-ups/admin)\n2. Create new Power-Up\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/trello/callback`\n\n```env\nTRELLO_API_KEY=your-api-key\nTRELLO_API_SECRET=your-api-secret\n```\n\n### ClickUp\n\n1. Go to [ClickUp API Settings](https://app.clickup.com/settings/apps)\n2. Create new app\n3. Redirect URL: `http://localhost:3000/api/auth/clickup/callback`\n\n```env\nCLICKUP_CLIENT_ID=your-client-id\nCLICKUP_CLIENT_SECRET=your-client-secret\n```\n\n### Linear\n\n1. Go to [Linear Settings > API](https://linear.app/settings/api)\n2. Create OAuth application\n3. Callback URL: `http://localhost:3000/api/auth/linear/callback`\n\n```env\nLINEAR_CLIENT_ID=your-client-id\nLINEAR_CLIENT_SECRET=your-client-secret\n```\n\n### Notion\n\n1. Go to [Notion Integrations](https://www.notion.so/my-integrations)\n2. Create new **public** integration (for OAuth)\n3. Set redirect URI: `http://localhost:3000/api/auth/notion/callback`\n4. **Important**: Share pages with your integration\n\n```env\nNOTION_CLIENT_ID=your-oauth-client-id\nNOTION_CLIENT_SECRET=your-oauth-client-secret\n```\n\n---\n\n## Developer Tools\n\n### GitHub\n\n1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n2. Create **New OAuth App**\n3. Authorization callback: `http://localhost:3000/api/auth/github/callback`\n\n```env\nGITHUB_CLIENT_ID=your-client-id\nGITHUB_CLIENT_SECRET=your-client-secret\n```\n\n### GitLab\n\n1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)\n2. Create new application\n3. Redirect URI: `http://localhost:3000/api/auth/gitlab/callback`\n4. Scopes: `read_user`, `read_api`, `read_repository`\n\n```env\nGITLAB_CLIENT_ID=your-application-id\nGITLAB_CLIENT_SECRET=your-secret\n```\n\n### Bitbucket\n\n1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) or create OAuth consumer\n2. For OAuth: Workspace settings > OAuth consumers\n3. Callback URL: `http://localhost:3000/api/auth/bitbucket/callback`\n\n```env\nBITBUCKET_CLIENT_ID=your-client-id\nBITBUCKET_CLIENT_SECRET=your-client-secret\n```\n\n### Figma\n\n1. Go to [Figma Developers](https://www.figma.com/developers/apps)\n2. Create new app\n3. Callback URL: `http://localhost:3000/api/auth/figma/callback`\n\n```env\nFIGMA_CLIENT_ID=your-client-id\nFIGMA_CLIENT_SECRET=your-client-secret\n```\n\n### Sentry\n\n1. Go to [Sentry Developer Settings](https://sentry.io/settings/developer-settings/)\n2. Create new public integration\n3. Redirect URL: `http://localhost:3000/api/auth/sentry/callback`\n\n```env\nSENTRY_CLIENT_ID=your-client-id\nSENTRY_CLIENT_SECRET=your-client-secret\n```\n\n### PostHog\n\nUses API key authentication (no OAuth).\n\n1. Go to your PostHog project settings\n2. Create a personal API key\n\n```env\nPOSTHOG_API_KEY=phx_your-api-key\nPOSTHOG_HOST=https://app.posthog.com\n```\n\n---\n\n## CRM & Sales\n\n### Salesforce\n\n1. Go to [Salesforce Setup](https://login.salesforce.com/) > App Manager\n2. Create **New Connected App**\n3. Enable OAuth, add callback: `http://localhost:3000/api/auth/salesforce/callback`\n4. Required scopes: `api`, `refresh_token`\n\n```env\nSALESFORCE_CLIENT_ID=your-consumer-key\nSALESFORCE_CLIENT_SECRET=your-consumer-secret\n```\n\n### Pipedrive\n\n1. Go to [Pipedrive Marketplace Manager](https://developers.pipedrive.com/)\n2. Create new app\n3. OAuth redirect: `http://localhost:3000/api/auth/pipedrive/callback`\n\n```env\nPIPEDRIVE_CLIENT_ID=your-client-id\nPIPEDRIVE_CLIENT_SECRET=your-client-secret\n```\n\n### Intercom\n\n1. Go to [Intercom Developer Hub](https://developers.intercom.com/)\n2. Create new app\n3. Configure OAuth: `http://localhost:3000/api/auth/intercom/callback`\n\n```env\nINTERCOM_CLIENT_ID=your-client-id\nINTERCOM_CLIENT_SECRET=your-client-secret\n```\n\n### Zendesk\n\n1. Go to Admin Center > Apps and integrations > APIs > Zendesk API\n2. Create OAuth client\n3. Redirect URL: `http://localhost:3000/api/auth/zendesk/callback`\n\n```env\nZENDESK_CLIENT_ID=your-client-id\nZENDESK_CLIENT_SECRET=your-client-secret\nZENDESK_SUBDOMAIN=your-subdomain\n```\n\n### Freshdesk\n\nUses API key authentication.\n\n1. Go to Profile Settings in Freshdesk\n2. Find your API Key\n\n```env\nFRESHDESK_API_KEY=your-api-key\nFRESHDESK_DOMAIN=your-domain.freshdesk.com\n```\n\n---\n\n## Databases\n\n### Supabase\n\nUses API key (no OAuth needed).\n\n1. Go to your Supabase project dashboard\n2. Go to Settings > API\n3. Copy the `anon` or `service_role` key\n\n```env\nSUPABASE_URL=https://your-project.supabase.co\nSUPABASE_ANON_KEY=your-anon-key\nSUPABASE_SERVICE_ROLE_KEY=your-service-role-key\n```\n\n### Neon\n\nUses API key authentication.\n\n1. Go to [Neon Console](https://console.neon.tech/)\n2. Create API key in Account Settings\n\n```env\nNEON_API_KEY=your-api-key\nNEON_PROJECT_ID=your-project-id\n```\n\n### Airtable\n\n1. Go to [Airtable Account](https://airtable.com/account)\n2. Create personal access token or OAuth app\n3. For OAuth: [Airtable OAuth](https://airtable.com/create/oauth)\n\n```env\nAIRTABLE_API_KEY=your-api-key\n# Or for OAuth:\nAIRTABLE_CLIENT_ID=your-client-id\nAIRTABLE_CLIENT_SECRET=your-client-secret\n```\n\n### Snowflake\n\nUses account credentials (key-pair or password).\n\n1. Get your Snowflake account identifier\n2. Create a user with appropriate permissions\n3. (Optional) Set up key-pair authentication\n\n```env\nSNOWFLAKE_ACCOUNT=your-account-identifier\nSNOWFLAKE_USERNAME=your-username\nSNOWFLAKE_PASSWORD=your-password\nSNOWFLAKE_WAREHOUSE=your-warehouse\nSNOWFLAKE_DATABASE=your-database\n```\n\n---\n\n## Cloud & Storage\n\n### AWS\n\nUses IAM credentials.\n\n1. Go to [AWS IAM Console](https://console.aws.amazon.com/iam/)\n2. Create a new IAM user with programmatic access\n3. Attach policies for services you need (S3, EC2, Lambda, etc.)\n\n```env\nAWS_ACCESS_KEY_ID=your-access-key\nAWS_SECRET_ACCESS_KEY=your-secret-key\nAWS_REGION=us-east-1\n```\n\n### Box\n\n1. Go to [Box Developer Console](https://app.box.com/developers/console)\n2. Create new app with OAuth 2.0\n3. Redirect URI: `http://localhost:3000/api/auth/box/callback`\n\n```env\nBOX_CLIENT_ID=your-client-id\nBOX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Finance\n\n### Stripe\n\nUses API key (no OAuth for basic usage).\n\n1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)\n2. Get your secret key (use test key for development)\n\n```env\nSTRIPE_SECRET_KEY=sk_test_your-secret-key\nSTRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key\n```\n\n### QuickBooks\n\n1. Go to [Intuit Developer](https://developer.intuit.com/)\n2. Create app and get OAuth credentials\n3. Redirect URI: `http://localhost:3000/api/auth/quickbooks/callback`\n\n```env\nQUICKBOOKS_CLIENT_ID=your-client-id\nQUICKBOOKS_CLIENT_SECRET=your-client-secret\n```\n\n### Xero\n\n1. Go to [Xero Developer](https://developer.xero.com/app/manage)\n2. Create app\n3. Redirect URI: `http://localhost:3000/api/auth/xero/callback`\n\n```env\nXERO_CLIENT_ID=your-client-id\nXERO_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Marketing\n\n### Mailchimp\n\n1. Go to [Mailchimp Account API Keys](https://us1.admin.mailchimp.com/account/api/)\n2. For OAuth: Register app at [Mailchimp OAuth](https://admin.mailchimp.com/account/oauth2/)\n3. Redirect: `http://localhost:3000/api/auth/mailchimp/callback`\n\n```env\nMAILCHIMP_CLIENT_ID=your-client-id\nMAILCHIMP_CLIENT_SECRET=your-client-secret\n# Or API key:\nMAILCHIMP_API_KEY=your-api-key-us1\n```\n\n### Twitter/X\n\n1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)\n2. Create project and app\n3. Enable OAuth 2.0\n4. Callback URL: `http://localhost:3000/api/auth/twitter/callback`\n\n```env\nTWITTER_CLIENT_ID=your-client-id\nTWITTER_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## E-commerce\n\n### Shopify\n\n1. Go to [Shopify Partners](https://partners.shopify.com/)\n2. Create new app\n3. App URL and redirect: `http://localhost:3000/api/auth/shopify/callback`\n\n```env\nSHOPIFY_CLIENT_ID=your-api-key\nSHOPIFY_CLIENT_SECRET=your-api-secret\nSHOPIFY_SHOP_NAME=your-store.myshopify.com\n```\n\n---\n\n## AI & Analytics\n\n### Anthropic (Admin API)\n\nFor organization management and usage tracking.\n\n1. Go to [Anthropic Console](https://console.anthropic.com/)\n2. Create Admin API key (requires admin access)\n\n```env\nANTHROPIC_ADMIN_API_KEY=your-admin-api-key\n```\n\n### Mixpanel\n\nUses API key/secret for data export.\n\n1. Go to [Mixpanel Project Settings](https://mixpanel.com/settings/project)\n2. Get Project Token for tracking\n3. Get API Secret for data export\n\n```env\nMIXPANEL_PROJECT_TOKEN=your-project-token\nMIXPANEL_API_SECRET=your-api-secret\n```\n\n---\n\n## Testing Your Setup\n\nAfter configuring credentials:\n\n```bash\n# Start the dev server\nveryfront dev\n\n# Test each integration by visiting:\n# http://localhost:3000/api/auth/{service}\n\n# Check connection status\ncurl http://localhost:3000/api/connections\n```\n\n## Troubleshooting\n\n### Common Issues\n\n| Error | Solution |\n| ---------------------- | -------------------------------------------------------------- |\n| \"Invalid redirect URI\" | Ensure callback URL matches exactly (including trailing slash) |\n| \"Invalid client\" | Check CLIENT_ID is correct and app is published |\n| \"Access denied\" | Verify all required scopes are added |\n| \"Token expired\" | Implement refresh token flow or re-authenticate |\n\n### Debug Mode\n\nEnable debug logging:\n\n```bash\nDEBUG=veryfront:oauth veryfront dev\n```\n\n### Token storage\n\nThe generated token-store proxy uses in-memory storage only in an explicit\ndevelopment or test environment. For production:\n\n1. Select a storage extension that provides a durable\n `RefreshCapableTokenStore`.\n2. Configure it with `configureTokenStore()` before the first OAuth request.\n3. Ensure the extension encrypts tokens, atomically consumes OAuth state,\n implements compare-and-set, and uses a bounded, crash-recoverable\n distributed lease for refresh locking.\n\n```typescript\n// lib/configure-oauth-storage.ts (import once during application startup)\nimport type { RefreshCapableTokenStore } from \"veryfront/oauth\";\nimport { configureTokenStore } from \"./token-store.ts\";\nimport { createApplicationOAuthTokenStore } from \"./storage/oauth.ts\";\n\nconst oauthStore: RefreshCapableTokenStore = createApplicationOAuthTokenStore();\nconfigureTokenStore(oauthStore);\n```\n\n`createApplicationOAuthTokenStore` is the factory exported by your selected\nstorage extension. Veryfront core does not select or import the backend.\n\n### Authenticated OAuth routes\n\nGenerated OAuth routes call `requireUserIdFromRequest` in `lib/user-id.ts`.\nThe generated `resolveAuthenticatedUserId` seam deliberately throws in every\nruntime mode until you replace it with a resolver backed by your server-side\nsession or verified JWT. The surrounding request boundary validates the\nreturned id. There is no development escape hatch because an ambient default\nidentity makes all visitors share one token owner.\n\n```ts\n// lib/user-id.ts\nexport async function resolveAuthenticatedUserId(request: Request) {\n const session = await verifySession(request); // your session/JWT check\n return session?.userId ?? null; // null => anonymous => routes answer 401\n}\n```\n\nRaw request headers are not an authentication boundary. Never return a value\ncopied from an untrusted header such as `x-user-id`.\n\n### Authenticated tool context\n\nGenerated integration tools resolve the token owner from `context.userId`.\nPass the authenticated user through `ToolExecutionContext` whenever an agent or\nworkflow invokes a tool. Execution fails closed when the context has no\nauthenticated user id.\n\n## Production Checklist\n\n- [ ] Update all redirect URIs to production domain\n- [ ] Configure an extension-owned `RefreshCapableTokenStore`\n- [ ] Implement `resolveAuthenticatedUserId` using a verified session or JWT\n- [ ] Verify token encryption and distributed refresh locking\n- [ ] Pass authenticated user ids through every tool execution context\n- [ ] Configure rate limiting\n- [ ] Add error monitoring (Sentry)\n- [ ] Test OAuth flows end-to-end\n- [ ] Review and minimize required scopes\n\n## Need Help?\n\n- Run `veryfront doctor` to diagnose issues\n- Check the [Veryfront Documentation](https://veryfront.com/docs)\n- Join our [Discord community](https://discord.gg/xWuRjafrtV)\n" } @@ -223,7 +225,7 @@ "app/api/auth/docs-google/callback/route.ts": "import { createOAuthCallbackHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(docsGoogleConfig, { tokenStore });\n", "app/api/auth/docs-google/route.ts": "import { createOAuthInitHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(docsGoogleConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n", "lib/docs-client.ts": "/**\n * Google Docs API Client\n *\n * Provides a type-safe interface to Google Docs API operations.\n */\n\nimport { getValidToken } from \"./docs-google-oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DOCS_API_BASE = \"https://docs.googleapis.com/v1\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Document {\n documentId: string;\n title: string;\n body: {\n content: StructuralElement[];\n };\n revisionId: string;\n suggestionsViewMode: string;\n documentStyle: DocumentStyle;\n}\n\nexport interface StructuralElement {\n startIndex: number;\n endIndex: number;\n paragraph?: Paragraph;\n table?: Table;\n sectionBreak?: SectionBreak;\n}\n\nexport interface Paragraph {\n elements: ParagraphElement[];\n paragraphStyle?: ParagraphStyle;\n bullet?: Bullet;\n}\n\nexport interface ParagraphElement {\n startIndex: number;\n endIndex: number;\n textRun?: TextRun;\n inlineObjectElement?: InlineObjectElement;\n}\n\nexport interface TextRun {\n content: string;\n textStyle?: TextStyle;\n}\n\nexport interface TextStyle {\n bold?: boolean;\n italic?: boolean;\n underline?: boolean;\n strikethrough?: boolean;\n fontSize?: Dimension;\n foregroundColor?: Color;\n backgroundColor?: Color;\n fontFamily?: string;\n link?: Link;\n}\n\nexport interface Link {\n url?: string;\n bookmarkId?: string;\n headingId?: string;\n}\n\nexport interface Dimension {\n magnitude: number;\n unit: string;\n}\n\nexport interface Color {\n rgbColor?: RgbColor;\n}\n\nexport interface RgbColor {\n red: number;\n green: number;\n blue: number;\n}\n\nexport interface ParagraphStyle {\n headingId?: string;\n namedStyleType?: string;\n alignment?: string;\n lineSpacing?: number;\n direction?: string;\n spacingMode?: string;\n spaceAbove?: Dimension;\n spaceBelow?: Dimension;\n indentFirstLine?: Dimension;\n indentStart?: Dimension;\n indentEnd?: Dimension;\n}\n\nexport interface Bullet {\n listId: string;\n nestingLevel?: number;\n textStyle?: TextStyle;\n}\n\nexport interface Table {\n rows: number;\n columns: number;\n tableRows: TableRow[];\n tableStyle?: TableStyle;\n}\n\nexport interface TableRow {\n startIndex: number;\n endIndex: number;\n tableCells: TableCell[];\n}\n\nexport interface TableCell {\n startIndex: number;\n endIndex: number;\n content: StructuralElement[];\n tableCellStyle?: TableCellStyle;\n}\n\nexport interface TableCellStyle {\n rowSpan?: number;\n columnSpan?: number;\n backgroundColor?: Color;\n borderLeft?: TableCellBorder;\n borderRight?: TableCellBorder;\n borderTop?: TableCellBorder;\n borderBottom?: TableCellBorder;\n paddingLeft?: Dimension;\n paddingRight?: Dimension;\n paddingTop?: Dimension;\n paddingBottom?: Dimension;\n}\n\nexport interface TableCellBorder {\n color?: Color;\n width?: Dimension;\n dashStyle?: string;\n}\n\nexport interface TableStyle {\n tableColumnProperties?: TableColumnProperties[];\n}\n\nexport interface TableColumnProperties {\n width?: Dimension;\n widthType?: string;\n}\n\nexport interface SectionBreak {\n sectionStyle?: SectionStyle;\n}\n\nexport interface SectionStyle {\n columnSeparatorStyle?: string;\n contentDirection?: string;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageNumberStart?: number;\n}\n\nexport interface DocumentStyle {\n background?: Background;\n pageNumberStart?: number;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageSize?: Size;\n marginHeader?: Dimension;\n marginFooter?: Dimension;\n useFirstPageHeaderFooter?: boolean;\n}\n\nexport interface Background {\n color?: Color;\n}\n\nexport interface Size {\n height?: Dimension;\n width?: Dimension;\n}\n\nexport interface InlineObjectElement {\n inlineObjectId: string;\n textStyle?: TextStyle;\n}\n\nexport interface DocumentFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n iconLink?: string;\n thumbnailLink?: string;\n}\n\nexport interface CreateDocumentOptions {\n title: string;\n}\n\nexport interface BatchUpdateRequest {\n requests: Request[];\n}\n\nexport interface Request {\n insertText?: InsertTextRequest;\n deleteContentRange?: DeleteContentRangeRequest;\n replaceAllText?: ReplaceAllTextRequest;\n updateTextStyle?: UpdateTextStyleRequest;\n updateParagraphStyle?: UpdateParagraphStyleRequest;\n insertPageBreak?: InsertPageBreakRequest;\n insertTable?: InsertTableRequest;\n deleteTableRow?: DeleteTableRowRequest;\n deleteTableColumn?: DeleteTableColumnRequest;\n createParagraphBullets?: CreateParagraphBulletsRequest;\n deleteParagraphBullets?: DeleteParagraphBulletsRequest;\n}\n\nexport interface InsertTextRequest {\n text: string;\n location: Location;\n}\n\nexport interface DeleteContentRangeRequest {\n range: Range;\n}\n\nexport interface ReplaceAllTextRequest {\n containsText: ContainsText;\n replaceText: string;\n}\n\nexport interface UpdateTextStyleRequest {\n range: Range;\n textStyle: TextStyle;\n fields: string;\n}\n\nexport interface UpdateParagraphStyleRequest {\n range: Range;\n paragraphStyle: ParagraphStyle;\n fields: string;\n}\n\nexport interface InsertPageBreakRequest {\n location: Location;\n}\n\nexport interface InsertTableRequest {\n rows: number;\n columns: number;\n location: Location;\n}\n\nexport interface DeleteTableRowRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface DeleteTableColumnRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface CreateParagraphBulletsRequest {\n range: Range;\n bulletPreset: string;\n}\n\nexport interface DeleteParagraphBulletsRequest {\n range: Range;\n}\n\nexport interface Location {\n index: number;\n segmentId?: string;\n}\n\nexport interface Range {\n startIndex: number;\n endIndex: number;\n segmentId?: string;\n}\n\nexport interface ContainsText {\n text: string;\n matchCase: boolean;\n}\n\nexport interface TableCellLocation {\n tableStartLocation: Location;\n rowIndex: number;\n columnIndex: number;\n}\n\nexport interface BatchUpdateResponse {\n documentId: string;\n replies: Reply[];\n writeControl?: WriteControl;\n}\n\nexport interface Reply {\n [key: string]: unknown;\n}\n\nexport interface WriteControl {\n requiredRevisionId: string;\n targetRevisionId: string;\n}\n\n/**\n * Google Docs OAuth provider configuration\n */\nexport const docsOAuthProvider = {\n name: \"docs-google\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/documents.readonly\",\n \"https://www.googleapis.com/auth/documents\",\n \"https://www.googleapis.com/auth/docs\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/docs-google/callback\",\n};\n\nexport function createDocsClient(userId: string): {\n listDocuments(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise;\n getDocument(documentId: string): Promise;\n createDocument(options: CreateDocumentOptions): Promise;\n updateDocument(documentId: string, requests: Request[]): Promise;\n insertText(documentId: string, text: string, index: number): Promise;\n deleteContent(documentId: string, startIndex: number, endIndex: number): Promise;\n replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase?: boolean,\n ): Promise;\n searchDocuments(query: string, maxResults?: number): Promise;\n extractText(document: Document): string;\n createDocumentWithContent(title: string, content: string): Promise;\n} {\n async function getAccessToken(): Promise {\n const token = await getValidToken(docsOAuthProvider, userId, \"docs-google\");\n if (!token) throw new Error(\"Google Docs not connected. Please connect your Google account first.\");\n return token;\n }\n\n async function apiRequest(\n baseUrl: string,\n label: string,\n endpoint: string,\n options: RequestInit = {},\n ): Promise {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${label} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function docsApiRequest(endpoint: string, options: RequestInit = {}): Promise {\n return apiRequest(DOCS_API_BASE, \"Docs\", endpoint, options);\n }\n\n function driveApiRequest(endpoint: string, options: RequestInit = {}): Promise {\n return apiRequest(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n function extractText(document: Document): string {\n const textParts: string[] = [];\n\n function processElement(element: StructuralElement): void {\n if (element.paragraph) {\n for (const el of element.paragraph.elements) {\n if (el.textRun) textParts.push(el.textRun.content);\n }\n return;\n }\n\n if (!element.table) return;\n\n for (const row of element.table.tableRows) {\n for (const cell of row.tableCells) {\n for (const child of cell.content) processElement(child);\n }\n }\n }\n\n for (const element of document.body.content) processElement(element);\n return textParts.join(\"\");\n }\n\n async function listDocuments(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.document' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n async function searchDocuments(query: string, maxResults = 20): Promise {\n const params = new URLSearchParams({\n q: `mimeType='application/vnd.google-apps.document' and trashed=false and fullText contains '${query}'`,\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(maxResults),\n orderBy: \"modifiedTime desc\",\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n function getDocument(documentId: string): Promise {\n return docsApiRequest(`/documents/${documentId}`);\n }\n\n function createDocument(options: CreateDocumentOptions): Promise {\n return docsApiRequest(\"/documents\", {\n method: \"POST\",\n body: JSON.stringify({ title: options.title }),\n });\n }\n\n function updateDocument(documentId: string, requests: Request[]): Promise {\n return docsApiRequest(`/documents/${documentId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({ requests }),\n });\n }\n\n function insertText(documentId: string, text: string, index: number): Promise {\n return updateDocument(documentId, [\n {\n insertText: {\n text,\n location: { index },\n },\n },\n ]);\n }\n\n function deleteContent(documentId: string, startIndex: number, endIndex: number): Promise {\n return updateDocument(documentId, [\n {\n deleteContentRange: {\n range: { startIndex, endIndex },\n },\n },\n ]);\n }\n\n function replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase = false,\n ): Promise {\n return updateDocument(documentId, [\n {\n replaceAllText: {\n containsText: {\n text: searchText,\n matchCase,\n },\n replaceText,\n },\n },\n ]);\n }\n\n async function createDocumentWithContent(title: string, content: string): Promise {\n const doc = await createDocument({ title });\n await insertText(doc.documentId, content, 1);\n return getDocument(doc.documentId);\n }\n\n return {\n listDocuments,\n getDocument,\n createDocument,\n updateDocument,\n insertText,\n deleteContent,\n replaceAllText,\n searchDocuments,\n extractText,\n createDocumentWithContent,\n };\n}\n\nexport type DocsClient = ReturnType;\n", - "lib/docs-google-oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\") return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postForm(url: string, body: Record): Promise {\n const response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n throw new Error(\n `Token request failed: ${response.status} - ${await response.text()}`,\n );\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired || !token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n", + "lib/docs-google-oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\") return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postForm(url: string, body: Record): Promise {\n const response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n throw new Error(\n `Token request failed: ${response.status} - ${await response.text()}`,\n );\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n", "tools/create-document.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-create-document\",\n description:\n \"Create a new Google Docs document with optional initial content. Returns the new document ID and URL.\",\n inputSchema: defineSchema((v) =>\n v.object({\n title: v.string().describe(\"Title of the new document\"),\n content: v\n .string()\n .optional()\n .describe(\"Optional initial text content to insert into the document\"),\n })\n )(),\n async execute({ title, content }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n\n const document = content\n ? await client.createDocumentWithContent(title, content)\n : await client.createDocument({ title });\n\n const [docMeta] = await client.listDocuments({ maxResults: 1 });\n const webViewLink = docMeta?.id === document.documentId ? docMeta.webViewLink : undefined;\n\n return {\n documentId: document.documentId,\n title: document.title,\n url: webViewLink ?? `https://docs.google.com/document/d/${document.documentId}/edit`,\n revisionId: document.revisionId,\n };\n },\n});\n", "tools/get-document.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-get-document\",\n description:\n \"Get a Google Docs document's content and metadata. Returns the full document structure including text, formatting, and styles.\",\n inputSchema: defineSchema((v) =>\n v.object({\n documentId: v.string().describe(\"The ID of the document to retrieve\"),\n extractTextOnly: v\n .boolean()\n .default(false)\n .describe(\"If true, only return plain text content without formatting\"),\n })\n )(),\n async execute({ documentId, extractTextOnly }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n const document = await client.getDocument(documentId);\n\n const { documentId: id, title, revisionId } = document;\n\n if (extractTextOnly) {\n return {\n documentId: id,\n title,\n revisionId,\n text: client.extractText(document),\n };\n }\n\n return {\n documentId: id,\n title,\n revisionId,\n body: document.body,\n documentStyle: document.documentStyle,\n };\n },\n});\n", "tools/list-documents.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-list-documents\",\n description:\n \"List recent Google Docs documents from Google Drive. Returns document names, IDs, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of documents to return\"),\n orderBy: v\n .enum([\"createdTime\", \"modifiedTime\", \"name\"] as const)\n .default(\"modifiedTime\")\n .describe(\"Sort order for results\"),\n })\n )(),\n async execute({ maxResults, orderBy }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n const documents = await client.listDocuments({ maxResults, orderBy });\n\n return documents.map((doc) => ({\n id: doc.id,\n name: doc.name,\n url: doc.webViewLink,\n createdTime: doc.createdTime,\n modifiedTime: doc.modifiedTime,\n thumbnail: doc.thumbnailLink,\n }));\n },\n});\n", @@ -237,7 +239,7 @@ "app/api/auth/drive/callback/route.ts": "import { createOAuthCallbackHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(driveConfig, { tokenStore });\n", "app/api/auth/drive/route.ts": "import { createOAuthInitHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(driveConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n", "lib/drive-client.ts": "import { getValidToken } from \"./drive-oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface DriveFile {\n id: string;\n name: string;\n mimeType: string;\n kind: string;\n createdTime: string;\n modifiedTime: string;\n size?: string;\n webViewLink?: string;\n webContentLink?: string;\n iconLink?: string;\n thumbnailLink?: string;\n parents?: string[];\n starred?: boolean;\n trashed?: boolean;\n shared?: boolean;\n owners?: Array<{\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n }>;\n lastModifyingUser?: {\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n };\n capabilities?: {\n canEdit?: boolean;\n canComment?: boolean;\n canShare?: boolean;\n canDelete?: boolean;\n canDownload?: boolean;\n };\n}\n\nexport interface DriveFileList {\n files: DriveFile[];\n nextPageToken?: string;\n incompleteSearch?: boolean;\n}\n\nexport interface CreateFolderOptions {\n name: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface UploadFileOptions {\n name: string;\n content: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface ListFilesOptions {\n folderId?: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n query?: string;\n}\n\nexport interface SearchFilesOptions {\n query: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n}\n\nexport const driveOAuthProvider = {\n name: \"drive\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/drive\",\n ],\n callbackPath: \"/api/auth/drive/callback\",\n};\n\nexport function createDriveClient(userId: string): {\n listFiles(options?: ListFilesOptions): Promise;\n getFile(fileId: string): Promise;\n searchFiles(options: SearchFilesOptions): Promise;\n createFolder(options: CreateFolderOptions): Promise;\n uploadFile(options: UploadFileOptions): Promise;\n downloadFile(fileId: string): Promise;\n deleteFile(fileId: string): Promise;\n copyFile(fileId: string, name: string, parentId?: string): Promise;\n updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise;\n} {\n async function getAccessToken(): Promise {\n const token = await getValidToken(driveOAuthProvider, userId, \"drive\");\n if (!token) {\n throw new Error(\"Google Drive not connected. Please connect your Google account first.\");\n }\n return token;\n }\n\n async function driveApiRequest(endpoint: string, options: RequestInit = {}): Promise {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive API error: ${response.status} - ${error}`);\n }\n\n if (response.status === 204) return undefined as T;\n return response.json();\n }\n\n function buildMetadata(options: {\n name: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n }): Record {\n const metadata: Record = {\n name: options.name,\n mimeType: options.mimeType,\n };\n\n if (options.parentId) metadata.parents = [options.parentId];\n if (options.description) metadata.description = options.description;\n\n return metadata;\n }\n\n const fileFields =\n \"id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed,shared,owners,lastModifyingUser,capabilities\";\n\n return {\n async listFiles(options: ListFilesOptions = {}): Promise {\n const params = new URLSearchParams({\n fields: `nextPageToken,incompleteSearch,files(${fileFields})`,\n pageSize: String(options.pageSize ?? 100),\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n let query = \"trashed=false\";\n if (options.folderId) query += ` and '${options.folderId}' in parents`;\n if (options.query) query += ` and ${options.query}`;\n\n params.append(\"q\", query);\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest(`/files?${params.toString()}`);\n },\n\n async getFile(fileId: string): Promise {\n const params = new URLSearchParams({ fields: fileFields });\n return driveApiRequest(`/files/${fileId}?${params.toString()}`);\n },\n\n async searchFiles(options: SearchFilesOptions): Promise {\n const params = new URLSearchParams({\n fields:\n \"nextPageToken,incompleteSearch,files(id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed)\",\n pageSize: String(options.pageSize ?? 100),\n q: `${options.query} and trashed=false`,\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest(`/files?${params.toString()}`);\n },\n\n async createFolder(options: CreateFolderOptions): Promise {\n const metadata = buildMetadata({\n name: options.name,\n mimeType: \"application/vnd.google-apps.folder\",\n parentId: options.parentId,\n description: options.description,\n });\n\n return driveApiRequest(\"/files\", {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async uploadFile(options: UploadFileOptions): Promise {\n const accessToken = await getAccessToken();\n\n const boundary = \"-------314159265358979323846\";\n const delimiter = `\\r\\n--${boundary}\\r\\n`;\n const closeDelimiter = `\\r\\n--${boundary}--`;\n\n const metadata = buildMetadata({\n name: options.name,\n mimeType: options.mimeType,\n parentId: options.parentId,\n description: options.description,\n });\n\n const multipartRequestBody =\n delimiter +\n \"Content-Type: application/json\\r\\n\\r\\n\" +\n JSON.stringify(metadata) +\n delimiter +\n `Content-Type: ${options.mimeType}\\r\\n\\r\\n` +\n options.content +\n closeDelimiter;\n\n const response = await fetch(\n \"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": `multipart/related; boundary=${boundary}`,\n },\n body: multipartRequestBody,\n },\n );\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive upload error: ${response.status} - ${error}`);\n }\n\n return response.json();\n },\n\n async downloadFile(fileId: string): Promise {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}/files/${fileId}?alt=media`, {\n headers: { Authorization: `Bearer ${accessToken}` },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive download error: ${response.status} - ${error}`);\n }\n\n return response.text();\n },\n\n async deleteFile(fileId: string): Promise {\n await driveApiRequest(`/files/${fileId}`, { method: \"DELETE\" });\n },\n\n async copyFile(fileId: string, name: string, parentId?: string): Promise {\n const metadata: Record = { name };\n if (parentId) metadata.parents = [parentId];\n\n return driveApiRequest(`/files/${fileId}/copy`, {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise {\n return driveApiRequest(`/files/${fileId}`, {\n method: \"PATCH\",\n body: JSON.stringify(updates),\n });\n },\n };\n}\n\nexport type DriveClient = ReturnType;\n", - "lib/drive-oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction buildTokenRequest(\n provider: OAuthProvider,\n body: Record,\n): RequestInit {\n return {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n ...body,\n }),\n };\n}\n\nasync function fetchToken(\n provider: OAuthProvider,\n body: Record,\n errorPrefix: string,\n): Promise {\n const response = await fetch(\n provider.tokenUrl,\n buildTokenRequest(provider, body),\n );\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nfunction toOAuthToken(data: any, fallbackRefreshToken?: string): OAuthToken {\n const expiresIn = data.expires_in;\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? fallbackRefreshToken,\n expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await fetchToken(\n provider,\n {\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return toOAuthToken(data);\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await fetchToken(\n provider,\n {\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return toOAuthToken(data, refreshToken);\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired) return token.accessToken;\n if (!token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n", + "lib/drive-oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction buildTokenRequest(\n provider: OAuthProvider,\n body: Record,\n): RequestInit {\n return {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n ...body,\n }),\n };\n}\n\nasync function fetchToken(\n provider: OAuthProvider,\n body: Record,\n errorPrefix: string,\n): Promise {\n const response = await fetch(\n provider.tokenUrl,\n buildTokenRequest(provider, body),\n );\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nfunction toOAuthToken(data: any, fallbackRefreshToken?: string): OAuthToken {\n const expiresIn = data.expires_in;\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? fallbackRefreshToken,\n expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise {\n const data = await fetchToken(\n provider,\n {\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return toOAuthToken(data);\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise {\n const data = await fetchToken(\n provider,\n {\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return toOAuthToken(data, refreshToken);\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n", "tools/create-folder.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"drive-create-folder\",\n description:\n \"Create a new folder in Google Drive. Can optionally specify a parent folder. Returns the new folder ID and details.\",\n inputSchema: defineSchema((v) =>\n v.object({\n name: v.string().describe(\"Name of the folder to create\"),\n parentId: v\n .string()\n .optional()\n .describe(\"ID of the parent folder. If not provided, creates in root.\"),\n description: v\n .string()\n .optional()\n .describe(\"Optional description for the folder\"),\n })\n )(),\n async execute({ name, parentId, description }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n const folder = await client.createFolder({ name, parentId, description });\n\n return {\n id: folder.id,\n name: folder.name,\n mimeType: folder.mimeType,\n createdTime: folder.createdTime,\n modifiedTime: folder.modifiedTime,\n webViewLink: folder.webViewLink,\n parents: folder.parents,\n };\n },\n});\n", "tools/get-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\nconst FOLDER_MIME_TYPE = \"application/vnd.google-apps.folder\";\n\nexport default tool({\n id: \"drive-get-file\",\n description:\n \"Get detailed metadata about a specific file or folder in Google Drive. Returns detailed information including sharing settings, owners, and capabilities.\",\n inputSchema: defineSchema((v) =>\n v.object({\n fileId: v.string().describe(\"The ID of the file or folder to retrieve\"),\n })\n )(),\n async execute({ fileId }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n const file = await client.getFile(fileId);\n\n const lastModifyingUser = file.lastModifyingUser\n ? {\n name: file.lastModifyingUser.displayName,\n email: file.lastModifyingUser.emailAddress,\n photoLink: file.lastModifyingUser.photoLink,\n }\n : undefined;\n\n return {\n id: file.id,\n name: file.name,\n mimeType: file.mimeType,\n isFolder: file.mimeType === FOLDER_MIME_TYPE,\n size: file.size,\n createdTime: file.createdTime,\n modifiedTime: file.modifiedTime,\n webViewLink: file.webViewLink,\n webContentLink: file.webContentLink,\n iconLink: file.iconLink,\n thumbnailLink: file.thumbnailLink,\n parents: file.parents,\n starred: file.starred,\n trashed: file.trashed,\n shared: file.shared,\n owners: file.owners?.map((owner) => ({\n name: owner.displayName,\n email: owner.emailAddress,\n photoLink: owner.photoLink,\n })),\n lastModifyingUser,\n capabilities: file.capabilities,\n };\n },\n});\n", "tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\nconst FOLDER_MIME_TYPE = \"application/vnd.google-apps.folder\";\n\nexport default tool({\n id: \"drive-list-files\",\n description:\n \"List files and folders in Google Drive. Can list from a specific folder or root. Returns file names, IDs, types, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n folderId: v\n .string()\n .optional()\n .describe(\n \"ID of the folder to list files from. If not provided, lists from root.\",\n ),\n pageSize: v\n .number()\n .min(1)\n .max(1000)\n .default(100)\n .describe(\"Maximum number of files to return\"),\n pageToken: v\n .string()\n .optional()\n .describe(\"Token for pagination to get next page of results\"),\n orderBy: v\n .enum([\n \"createdTime\",\n \"folder\",\n \"modifiedByMeTime\",\n \"modifiedTime\",\n \"name\",\n \"quotaBytesUsed\",\n \"recency\",\n \"sharedWithMeTime\",\n \"starred\",\n \"viewedByMeTime\",\n ])\n .optional()\n .describe(\"Field to sort results by\"),\n })\n )(),\n async execute({ folderId, pageSize, pageToken, orderBy }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n\n const result = await client.listFiles({\n folderId,\n pageSize,\n pageToken,\n orderBy: orderBy ? `${orderBy} desc` : undefined,\n });\n\n const nextPageToken = result.nextPageToken;\n\n return {\n files: result.files.map((file) => ({\n id: file.id,\n name: file.name,\n mimeType: file.mimeType,\n isFolder: file.mimeType === FOLDER_MIME_TYPE,\n size: file.size,\n createdTime: file.createdTime,\n modifiedTime: file.modifiedTime,\n webViewLink: file.webViewLink,\n iconLink: file.iconLink,\n thumbnailLink: file.thumbnailLink,\n starred: file.starred,\n shared: file.shared,\n })),\n nextPageToken,\n hasMore: Boolean(nextPageToken),\n };\n },\n});\n", @@ -303,7 +305,7 @@ "app/api/auth/gmail/callback/route.ts": "import { createOAuthCallbackHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(gmailConfig, { tokenStore });\n", "app/api/auth/gmail/route.ts": "import { createOAuthInitHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(gmailConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n", "lib/context.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\nimport { requireUserIdFromContext } from \"./user-id.ts\";\n\nexport function resolveUserId(context?: ToolExecutionContext): string {\n return requireUserIdFromContext(context);\n}\n", - "lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\nimport type { OAuthToken } from \"./token-store.ts\";\n\nexport type GmailMessageFormat = \"full\" | \"metadata\" | \"minimal\" | \"raw\";\nexport type GmailThreadFormat = Exclude;\nexport type GmailLabelVisibility = \"labelShow\" | \"labelShowIfUnread\" | \"labelHide\";\nexport type GmailMessageListVisibility = \"show\" | \"hide\";\nexport type GmailHistoryType = \"messageAdded\" | \"messageDeleted\" | \"labelAdded\" | \"labelRemoved\";\n\nexport interface GmailMessagePartBody {\n attachmentId?: string;\n data?: string;\n size: number;\n}\n\nexport interface GmailMessagePart {\n partId?: string;\n mimeType: string;\n filename?: string;\n headers?: Array<{ name: string; value: string }>;\n body?: GmailMessagePartBody;\n parts?: GmailMessagePart[];\n}\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds?: string[];\n snippet?: string;\n payload?: GmailMessagePart;\n internalDate?: string;\n historyId?: string;\n sizeEstimate?: number;\n raw?: string;\n}\n\nexport interface GmailMessageList {\n messages?: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailLabel {\n id: string;\n name: string;\n messageListVisibility?: GmailMessageListVisibility;\n labelListVisibility?: GmailLabelVisibility;\n type?: \"system\" | \"user\";\n messagesTotal?: number;\n messagesUnread?: number;\n threadsTotal?: number;\n threadsUnread?: number;\n color?: {\n textColor: string;\n backgroundColor: string;\n };\n}\n\nexport interface GmailLabelList {\n labels: GmailLabel[];\n}\n\nexport interface GmailThread {\n id: string;\n snippet?: string;\n historyId?: string;\n messages?: GmailMessage[];\n}\n\nexport interface GmailThreadList {\n threads?: Array<{ id: string; historyId?: string; snippet?: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailDraft {\n id: string;\n message: GmailMessage;\n}\n\nexport interface GmailDraftList {\n drafts?: Array<{ id: string; message: { id: string; threadId: string } }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailAttachment {\n attachmentId?: string;\n size: number;\n data: string;\n}\n\nexport interface GmailProfile {\n emailAddress: string;\n messagesTotal: number;\n threadsTotal: number;\n historyId: string;\n}\n\nexport interface GmailHistoryList {\n history?: Array<{\n id: string;\n messages?: GmailMessage[];\n messagesAdded?: Array<{ message: GmailMessage }>;\n messagesDeleted?: Array<{ message: GmailMessage }>;\n labelsAdded?: Array<{ message: GmailMessage; labelIds: string[] }>;\n labelsRemoved?: Array<{ message: GmailMessage; labelIds: string[] }>;\n }>;\n nextPageToken?: string;\n historyId: string;\n}\n\nexport interface GmailWatchResponse {\n historyId: string;\n expiration: string;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n threadId?: string;\n}\n\nexport type DraftEmailOptions = SendEmailOptions;\n\nexport interface ModifyLabelsOptions {\n addLabelIds?: string[];\n removeLabelIds?: string[];\n}\n\nexport interface ListOptions {\n maxResults?: number;\n pageToken?: string;\n}\n\nexport interface ListMessagesOptions extends ListOptions {\n query?: string;\n labelIds?: string[];\n}\n\nexport interface ListHistoryOptions extends ListOptions {\n startHistoryId: string;\n labelId?: string;\n historyTypes?: GmailHistoryType[];\n}\n\nexport interface WatchMailboxOptions {\n topicName: string;\n labelIds?: string[];\n labelFilterBehavior?: \"include\" | \"exclude\";\n}\n\nexport interface GmailClient {\n isConnected(): Promise;\n listMessages(options?: ListMessagesOptions): Promise;\n getMessage(messageId: string, format?: GmailMessageFormat): Promise;\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }>;\n searchEmails(query: string, maxResults?: number): Promise;\n getUnreadEmails(maxResults?: number): Promise;\n markAsRead(messageId: string): Promise;\n archiveEmail(messageId: string): Promise;\n listLabels(): Promise;\n getLabel(labelId: string): Promise;\n createLabel(label: Partial & { name: string }): Promise;\n updateLabel(labelId: string, label: Partial & { name: string }): Promise;\n patchLabel(labelId: string, label: Partial): Promise;\n deleteLabel(labelId: string): Promise;\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise;\n trashMessage(messageId: string): Promise;\n untrashMessage(messageId: string): Promise;\n deleteMessage(messageId: string): Promise;\n batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise;\n batchDeleteMessages(messageIds: string[]): Promise;\n listThreads(options?: ListMessagesOptions): Promise;\n getThread(threadId: string, format?: GmailThreadFormat): Promise;\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise;\n trashThread(threadId: string): Promise;\n untrashThread(threadId: string): Promise;\n deleteThread(threadId: string): Promise;\n createDraft(options: DraftEmailOptions): Promise;\n listDrafts(options?: ListMessagesOptions): Promise;\n getDraft(draftId: string, format?: GmailMessageFormat): Promise;\n updateDraft(draftId: string, options: DraftEmailOptions): Promise;\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }>;\n deleteDraft(draftId: string): Promise;\n getAttachment(messageId: string, attachmentId: string): Promise;\n getProfile(): Promise;\n listHistory(options: ListHistoryOptions): Promise;\n watchMailbox(options: WatchMailboxOptions): Promise;\n stopMailboxWatch(): Promise;\n}\n\n// TokenStore adapter keyed by (serviceId, userId). All API calls must pass\n// the authenticated user's id. Never use a shared development user id\n// in production; that re-introduces VULN-AUTH-2.\nconst tokenStoreAdapter = {\n async getTokens(serviceId: string, userId: string): Promise {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise {\n await tokenStore.revokeToken(userId, serviceId);\n },\n async setState(): Promise {},\n async consumeState(): Promise {\n return null;\n },\n};\n\nconst gmailService = new OAuthService(gmailConfig, tokenStoreAdapter);\n\nfunction formatAddresses(addresses: string | string[] | undefined): string {\n if (!addresses) return \"\";\n return Array.isArray(addresses) ? addresses.join(\", \") : addresses;\n}\n\nfunction encodeEmail(options: SendEmailOptions): string {\n const toAddresses = formatAddresses(options.to);\n const ccAddresses = formatAddresses(options.cc);\n const bccAddresses = formatAddresses(options.bcc);\n\n const headers = [\n `To: ${toAddresses}`,\n `Subject: ${options.subject}`,\n options.isHtml\n ? \"Content-Type: text/html; charset=utf-8\"\n : \"Content-Type: text/plain; charset=utf-8\",\n ];\n\n if (ccAddresses) headers.push(`Cc: ${ccAddresses}`);\n if (bccAddresses) headers.push(`Bcc: ${bccAddresses}`);\n if (options.replyTo) headers.push(`Reply-To: ${options.replyTo}`);\n\n const email = `${headers.join(\"\\r\\n\")}\\r\\n\\r\\n${options.body}`;\n return btoa(email).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction addListParams(params: URLSearchParams, options: ListMessagesOptions = {}): void {\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.query) params.set(\"q\", options.query);\n if (options.labelIds?.length) {\n for (const labelId of options.labelIds) params.append(\"labelIds\", labelId);\n }\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n}\n\nfunction withQuery(path: string, params: URLSearchParams): string {\n const query = params.toString();\n return query ? `${path}?${query}` : path;\n}\n\nfunction encodedMessage(options: SendEmailOptions): { raw: string; threadId?: string } {\n return {\n raw: encodeEmail(options),\n ...(options.threadId ? { threadId: options.threadId } : {}),\n };\n}\n\n/**\n * Create a Gmail client scoped to a specific user. Pass the authenticated\n * user's id (from your session). Tokens are looked up and stored per-user.\n */\nexport function createGmailClient(userId: string): GmailClient {\n async function apiRequest(endpoint: string, options: RequestInit = {}): Promise {\n const token = await gmailService.getAccessToken(userId);\n if (!token) {\n throw new Error(\"Gmail not connected\");\n }\n\n const url = endpoint.startsWith(\"http\") ? endpoint : `${gmailConfig.apiBaseUrl}${endpoint}`;\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const detail = await response.text();\n throw new Error(`Gmail API error: ${response.status} ${detail}`);\n }\n\n const text = await response.text();\n return (text ? JSON.parse(text) : undefined) as T;\n }\n\n return {\n async isConnected(): Promise {\n const token = await gmailService.getAccessToken(userId);\n return token !== null;\n },\n\n listMessages(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/messages\", params));\n },\n\n getMessage(messageId: string, format: GmailMessageFormat = \"full\"): Promise {\n return apiRequest(`/users/me/messages/${messageId}?format=${format}`);\n },\n\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/messages/send\", {\n method: \"POST\",\n body: JSON.stringify(encodedMessage(options)),\n });\n },\n\n async searchEmails(query: string, maxResults = 10): Promise {\n const list = await this.listMessages({ query, maxResults });\n if (!list.messages?.length) return [];\n return Promise.all(list.messages.map((m) => this.getMessage(m.id, \"metadata\")));\n },\n\n getUnreadEmails(maxResults = 10): Promise {\n return this.searchEmails(\"is:unread\", maxResults);\n },\n\n async markAsRead(messageId: string): Promise {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"UNREAD\"] });\n },\n\n async archiveEmail(messageId: string): Promise {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"INBOX\"] });\n },\n\n listLabels(): Promise {\n return apiRequest(\"/users/me/labels\");\n },\n\n getLabel(labelId: string): Promise {\n return apiRequest(`/users/me/labels/${labelId}`);\n },\n\n createLabel(label: Partial & { name: string }): Promise {\n return apiRequest(\"/users/me/labels\", {\n method: \"POST\",\n body: JSON.stringify(label),\n });\n },\n\n updateLabel(\n labelId: string,\n label: Partial & { name: string },\n ): Promise {\n return apiRequest(`/users/me/labels/${labelId}`, {\n method: \"PUT\",\n body: JSON.stringify(label),\n });\n },\n\n patchLabel(labelId: string, label: Partial): Promise {\n return apiRequest(`/users/me/labels/${labelId}`, {\n method: \"PATCH\",\n body: JSON.stringify(label),\n });\n },\n\n async deleteLabel(labelId: string): Promise {\n await apiRequest(`/users/me/labels/${labelId}`, { method: \"DELETE\" });\n },\n\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise {\n return apiRequest(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashMessage(messageId: string): Promise {\n return apiRequest(`/users/me/messages/${messageId}/trash`, { method: \"POST\" });\n },\n\n untrashMessage(messageId: string): Promise {\n return apiRequest(`/users/me/messages/${messageId}/untrash`, {\n method: \"POST\",\n });\n },\n\n async deleteMessage(messageId: string): Promise {\n await apiRequest(`/users/me/messages/${messageId}`, { method: \"DELETE\" });\n },\n\n async batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise {\n await apiRequest(\"/users/me/messages/batchModify\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds, ...labels }),\n });\n },\n\n async batchDeleteMessages(messageIds: string[]): Promise {\n await apiRequest(\"/users/me/messages/batchDelete\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds }),\n });\n },\n\n listThreads(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/threads\", params));\n },\n\n getThread(threadId: string, format: GmailThreadFormat = \"full\"): Promise {\n return apiRequest(`/users/me/threads/${threadId}?format=${format}`);\n },\n\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise {\n return apiRequest(`/users/me/threads/${threadId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashThread(threadId: string): Promise {\n return apiRequest(`/users/me/threads/${threadId}/trash`, { method: \"POST\" });\n },\n\n untrashThread(threadId: string): Promise {\n return apiRequest(`/users/me/threads/${threadId}/untrash`, { method: \"POST\" });\n },\n\n async deleteThread(threadId: string): Promise {\n await apiRequest(`/users/me/threads/${threadId}`, { method: \"DELETE\" });\n },\n\n createDraft(options: DraftEmailOptions): Promise {\n return apiRequest(\"/users/me/drafts\", {\n method: \"POST\",\n body: JSON.stringify({ message: encodedMessage(options) }),\n });\n },\n\n listDrafts(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/drafts\", params));\n },\n\n getDraft(draftId: string, format: GmailMessageFormat = \"full\"): Promise {\n return apiRequest(`/users/me/drafts/${draftId}?format=${format}`);\n },\n\n updateDraft(draftId: string, options: DraftEmailOptions): Promise {\n return apiRequest(`/users/me/drafts/${draftId}`, {\n method: \"PUT\",\n body: JSON.stringify({ id: draftId, message: encodedMessage(options) }),\n });\n },\n\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/drafts/send\", {\n method: \"POST\",\n body: JSON.stringify({ id: draftId }),\n });\n },\n\n async deleteDraft(draftId: string): Promise {\n await apiRequest(`/users/me/drafts/${draftId}`, { method: \"DELETE\" });\n },\n\n getAttachment(messageId: string, attachmentId: string): Promise {\n return apiRequest(\n `/users/me/messages/${messageId}/attachments/${attachmentId}`,\n );\n },\n\n getProfile(): Promise {\n return apiRequest(\"/users/me/profile\");\n },\n\n listHistory(options: ListHistoryOptions): Promise {\n const params = new URLSearchParams();\n params.set(\"startHistoryId\", options.startHistoryId);\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n if (options.labelId) params.set(\"labelId\", options.labelId);\n if (options.historyTypes?.length) {\n for (const historyType of options.historyTypes) params.append(\"historyTypes\", historyType);\n }\n return apiRequest(withQuery(\"/users/me/history\", params));\n },\n\n watchMailbox(options: WatchMailboxOptions): Promise {\n return apiRequest(\"/users/me/watch\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n async stopMailboxWatch(): Promise {\n await apiRequest(\"/users/me/stop\", { method: \"POST\" });\n },\n };\n}\n\nexport function parseEmailHeaders(\n headers: Array<{ name: string; value: string }>,\n): { from: string; to: string; subject: string; date: string } {\n function getHeader(name: string): string {\n return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? \"\";\n }\n\n return {\n from: getHeader(\"From\"),\n to: getHeader(\"To\"),\n subject: getHeader(\"Subject\"),\n date: getHeader(\"Date\"),\n };\n}\n", + "lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\n\nexport type GmailMessageFormat = \"full\" | \"metadata\" | \"minimal\" | \"raw\";\nexport type GmailThreadFormat = Exclude;\nexport type GmailLabelVisibility = \"labelShow\" | \"labelShowIfUnread\" | \"labelHide\";\nexport type GmailMessageListVisibility = \"show\" | \"hide\";\nexport type GmailHistoryType = \"messageAdded\" | \"messageDeleted\" | \"labelAdded\" | \"labelRemoved\";\n\nexport interface GmailMessagePartBody {\n attachmentId?: string;\n data?: string;\n size: number;\n}\n\nexport interface GmailMessagePart {\n partId?: string;\n mimeType: string;\n filename?: string;\n headers?: Array<{ name: string; value: string }>;\n body?: GmailMessagePartBody;\n parts?: GmailMessagePart[];\n}\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds?: string[];\n snippet?: string;\n payload?: GmailMessagePart;\n internalDate?: string;\n historyId?: string;\n sizeEstimate?: number;\n raw?: string;\n}\n\nexport interface GmailMessageList {\n messages?: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailLabel {\n id: string;\n name: string;\n messageListVisibility?: GmailMessageListVisibility;\n labelListVisibility?: GmailLabelVisibility;\n type?: \"system\" | \"user\";\n messagesTotal?: number;\n messagesUnread?: number;\n threadsTotal?: number;\n threadsUnread?: number;\n color?: {\n textColor: string;\n backgroundColor: string;\n };\n}\n\nexport interface GmailLabelList {\n labels: GmailLabel[];\n}\n\nexport interface GmailThread {\n id: string;\n snippet?: string;\n historyId?: string;\n messages?: GmailMessage[];\n}\n\nexport interface GmailThreadList {\n threads?: Array<{ id: string; historyId?: string; snippet?: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailDraft {\n id: string;\n message: GmailMessage;\n}\n\nexport interface GmailDraftList {\n drafts?: Array<{ id: string; message: { id: string; threadId: string } }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailAttachment {\n attachmentId?: string;\n size: number;\n data: string;\n}\n\nexport interface GmailProfile {\n emailAddress: string;\n messagesTotal: number;\n threadsTotal: number;\n historyId: string;\n}\n\nexport interface GmailHistoryList {\n history?: Array<{\n id: string;\n messages?: GmailMessage[];\n messagesAdded?: Array<{ message: GmailMessage }>;\n messagesDeleted?: Array<{ message: GmailMessage }>;\n labelsAdded?: Array<{ message: GmailMessage; labelIds: string[] }>;\n labelsRemoved?: Array<{ message: GmailMessage; labelIds: string[] }>;\n }>;\n nextPageToken?: string;\n historyId: string;\n}\n\nexport interface GmailWatchResponse {\n historyId: string;\n expiration: string;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n threadId?: string;\n}\n\nexport type DraftEmailOptions = SendEmailOptions;\n\nexport interface ModifyLabelsOptions {\n addLabelIds?: string[];\n removeLabelIds?: string[];\n}\n\nexport interface ListOptions {\n maxResults?: number;\n pageToken?: string;\n}\n\nexport interface ListMessagesOptions extends ListOptions {\n query?: string;\n labelIds?: string[];\n}\n\nexport interface ListHistoryOptions extends ListOptions {\n startHistoryId: string;\n labelId?: string;\n historyTypes?: GmailHistoryType[];\n}\n\nexport interface WatchMailboxOptions {\n topicName: string;\n labelIds?: string[];\n labelFilterBehavior?: \"include\" | \"exclude\";\n}\n\nexport interface GmailClient {\n isConnected(): Promise;\n listMessages(options?: ListMessagesOptions): Promise;\n getMessage(messageId: string, format?: GmailMessageFormat): Promise;\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }>;\n searchEmails(query: string, maxResults?: number): Promise;\n getUnreadEmails(maxResults?: number): Promise;\n markAsRead(messageId: string): Promise;\n archiveEmail(messageId: string): Promise;\n listLabels(): Promise;\n getLabel(labelId: string): Promise;\n createLabel(label: Partial & { name: string }): Promise;\n updateLabel(labelId: string, label: Partial & { name: string }): Promise;\n patchLabel(labelId: string, label: Partial): Promise;\n deleteLabel(labelId: string): Promise;\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise;\n trashMessage(messageId: string): Promise;\n untrashMessage(messageId: string): Promise;\n deleteMessage(messageId: string): Promise;\n batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise;\n batchDeleteMessages(messageIds: string[]): Promise;\n listThreads(options?: ListMessagesOptions): Promise;\n getThread(threadId: string, format?: GmailThreadFormat): Promise;\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise;\n trashThread(threadId: string): Promise;\n untrashThread(threadId: string): Promise;\n deleteThread(threadId: string): Promise;\n createDraft(options: DraftEmailOptions): Promise;\n listDrafts(options?: ListMessagesOptions): Promise;\n getDraft(draftId: string, format?: GmailMessageFormat): Promise;\n updateDraft(draftId: string, options: DraftEmailOptions): Promise;\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }>;\n deleteDraft(draftId: string): Promise;\n getAttachment(messageId: string, attachmentId: string): Promise;\n getProfile(): Promise;\n listHistory(options: ListHistoryOptions): Promise;\n watchMailbox(options: WatchMailboxOptions): Promise;\n stopMailboxWatch(): Promise;\n}\n\n// Keep the full refresh-capable contract: OAuthService uses the store's\n// distributed lock and revisioned compare-and-set when access tokens expire.\nconst gmailService = new OAuthService(gmailConfig, tokenStore);\n\nfunction formatAddresses(addresses: string | string[] | undefined): string {\n if (!addresses) return \"\";\n return Array.isArray(addresses) ? addresses.join(\", \") : addresses;\n}\n\nfunction encodeEmail(options: SendEmailOptions): string {\n const toAddresses = formatAddresses(options.to);\n const ccAddresses = formatAddresses(options.cc);\n const bccAddresses = formatAddresses(options.bcc);\n\n const headers = [\n `To: ${toAddresses}`,\n `Subject: ${options.subject}`,\n options.isHtml\n ? \"Content-Type: text/html; charset=utf-8\"\n : \"Content-Type: text/plain; charset=utf-8\",\n ];\n\n if (ccAddresses) headers.push(`Cc: ${ccAddresses}`);\n if (bccAddresses) headers.push(`Bcc: ${bccAddresses}`);\n if (options.replyTo) headers.push(`Reply-To: ${options.replyTo}`);\n\n const email = `${headers.join(\"\\r\\n\")}\\r\\n\\r\\n${options.body}`;\n return btoa(email).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction addListParams(params: URLSearchParams, options: ListMessagesOptions = {}): void {\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.query) params.set(\"q\", options.query);\n if (options.labelIds?.length) {\n for (const labelId of options.labelIds) params.append(\"labelIds\", labelId);\n }\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n}\n\nfunction withQuery(path: string, params: URLSearchParams): string {\n const query = params.toString();\n return query ? `${path}?${query}` : path;\n}\n\nfunction encodedMessage(options: SendEmailOptions): { raw: string; threadId?: string } {\n return {\n raw: encodeEmail(options),\n ...(options.threadId ? { threadId: options.threadId } : {}),\n };\n}\n\n/**\n * Create a Gmail client scoped to a specific user. Pass the authenticated\n * user's id (from your session). Tokens are looked up and stored per-user.\n */\nexport function createGmailClient(userId: string): GmailClient {\n async function apiRequest(endpoint: string, options: RequestInit = {}): Promise {\n const token = await gmailService.getAccessToken(userId);\n if (!token) {\n throw new Error(\"Gmail not connected\");\n }\n\n const url = endpoint.startsWith(\"http\") ? endpoint : `${gmailConfig.apiBaseUrl}${endpoint}`;\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const detail = await response.text();\n throw new Error(`Gmail API error: ${response.status} ${detail}`);\n }\n\n const text = await response.text();\n return (text ? JSON.parse(text) : undefined) as T;\n }\n\n return {\n async isConnected(): Promise {\n const token = await gmailService.getAccessToken(userId);\n return token !== null;\n },\n\n listMessages(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/messages\", params));\n },\n\n getMessage(messageId: string, format: GmailMessageFormat = \"full\"): Promise {\n return apiRequest(`/users/me/messages/${messageId}?format=${format}`);\n },\n\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/messages/send\", {\n method: \"POST\",\n body: JSON.stringify(encodedMessage(options)),\n });\n },\n\n async searchEmails(query: string, maxResults = 10): Promise {\n const list = await this.listMessages({ query, maxResults });\n if (!list.messages?.length) return [];\n return Promise.all(list.messages.map((m) => this.getMessage(m.id, \"metadata\")));\n },\n\n getUnreadEmails(maxResults = 10): Promise {\n return this.searchEmails(\"is:unread\", maxResults);\n },\n\n async markAsRead(messageId: string): Promise {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"UNREAD\"] });\n },\n\n async archiveEmail(messageId: string): Promise {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"INBOX\"] });\n },\n\n listLabels(): Promise {\n return apiRequest(\"/users/me/labels\");\n },\n\n getLabel(labelId: string): Promise {\n return apiRequest(`/users/me/labels/${labelId}`);\n },\n\n createLabel(label: Partial & { name: string }): Promise {\n return apiRequest(\"/users/me/labels\", {\n method: \"POST\",\n body: JSON.stringify(label),\n });\n },\n\n updateLabel(\n labelId: string,\n label: Partial & { name: string },\n ): Promise {\n return apiRequest(`/users/me/labels/${labelId}`, {\n method: \"PUT\",\n body: JSON.stringify(label),\n });\n },\n\n patchLabel(labelId: string, label: Partial): Promise {\n return apiRequest(`/users/me/labels/${labelId}`, {\n method: \"PATCH\",\n body: JSON.stringify(label),\n });\n },\n\n async deleteLabel(labelId: string): Promise {\n await apiRequest(`/users/me/labels/${labelId}`, { method: \"DELETE\" });\n },\n\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise {\n return apiRequest(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashMessage(messageId: string): Promise {\n return apiRequest(`/users/me/messages/${messageId}/trash`, { method: \"POST\" });\n },\n\n untrashMessage(messageId: string): Promise {\n return apiRequest(`/users/me/messages/${messageId}/untrash`, {\n method: \"POST\",\n });\n },\n\n async deleteMessage(messageId: string): Promise {\n await apiRequest(`/users/me/messages/${messageId}`, { method: \"DELETE\" });\n },\n\n async batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise {\n await apiRequest(\"/users/me/messages/batchModify\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds, ...labels }),\n });\n },\n\n async batchDeleteMessages(messageIds: string[]): Promise {\n await apiRequest(\"/users/me/messages/batchDelete\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds }),\n });\n },\n\n listThreads(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/threads\", params));\n },\n\n getThread(threadId: string, format: GmailThreadFormat = \"full\"): Promise {\n return apiRequest(`/users/me/threads/${threadId}?format=${format}`);\n },\n\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise {\n return apiRequest(`/users/me/threads/${threadId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashThread(threadId: string): Promise {\n return apiRequest(`/users/me/threads/${threadId}/trash`, { method: \"POST\" });\n },\n\n untrashThread(threadId: string): Promise {\n return apiRequest(`/users/me/threads/${threadId}/untrash`, { method: \"POST\" });\n },\n\n async deleteThread(threadId: string): Promise {\n await apiRequest(`/users/me/threads/${threadId}`, { method: \"DELETE\" });\n },\n\n createDraft(options: DraftEmailOptions): Promise {\n return apiRequest(\"/users/me/drafts\", {\n method: \"POST\",\n body: JSON.stringify({ message: encodedMessage(options) }),\n });\n },\n\n listDrafts(options: ListMessagesOptions = {}): Promise {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest(withQuery(\"/users/me/drafts\", params));\n },\n\n getDraft(draftId: string, format: GmailMessageFormat = \"full\"): Promise {\n return apiRequest(`/users/me/drafts/${draftId}?format=${format}`);\n },\n\n updateDraft(draftId: string, options: DraftEmailOptions): Promise {\n return apiRequest(`/users/me/drafts/${draftId}`, {\n method: \"PUT\",\n body: JSON.stringify({ id: draftId, message: encodedMessage(options) }),\n });\n },\n\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/drafts/send\", {\n method: \"POST\",\n body: JSON.stringify({ id: draftId }),\n });\n },\n\n async deleteDraft(draftId: string): Promise {\n await apiRequest(`/users/me/drafts/${draftId}`, { method: \"DELETE\" });\n },\n\n getAttachment(messageId: string, attachmentId: string): Promise {\n return apiRequest(\n `/users/me/messages/${messageId}/attachments/${attachmentId}`,\n );\n },\n\n getProfile(): Promise {\n return apiRequest(\"/users/me/profile\");\n },\n\n listHistory(options: ListHistoryOptions): Promise {\n const params = new URLSearchParams();\n params.set(\"startHistoryId\", options.startHistoryId);\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n if (options.labelId) params.set(\"labelId\", options.labelId);\n if (options.historyTypes?.length) {\n for (const historyType of options.historyTypes) params.append(\"historyTypes\", historyType);\n }\n return apiRequest(withQuery(\"/users/me/history\", params));\n },\n\n watchMailbox(options: WatchMailboxOptions): Promise {\n return apiRequest(\"/users/me/watch\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n async stopMailboxWatch(): Promise {\n await apiRequest(\"/users/me/stop\", { method: \"POST\" });\n },\n };\n}\n\nexport function parseEmailHeaders(\n headers: Array<{ name: string; value: string }>,\n): { from: string; to: string; subject: string; date: string } {\n function getHeader(name: string): string {\n return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? \"\";\n }\n\n return {\n from: getHeader(\"From\"),\n to: getHeader(\"To\"),\n subject: getHeader(\"Subject\"),\n date: getHeader(\"Date\"),\n };\n}\n", "tools/apply-labels.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nconst getLabelChangeInput = defineSchema((v) => v\n .object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n addLabelIds: v.array(v.string().min(1)).optional().describe(\"Label IDs to add\"),\n removeLabelIds: v.array(v.string().min(1)).optional().describe(\"Label IDs to remove\"),\n })\n .refine((value) => value.addLabelIds?.length || value.removeLabelIds?.length, {\n message: \"At least one label must be added or removed\",\n }));\n\nexport default tool({\n id: \"gmail-apply-labels\",\n description: \"Apply or remove Gmail labels on a message.\",\n inputSchema: getLabelChangeInput(),\n execute: async ({ messageId, addLabelIds, removeLabelIds }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.modifyMessageLabels(messageId, { addLabelIds, removeLabelIds });\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n", "tools/archive-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"gmail-archive-email\",\n description: \"Archive a Gmail message by removing the INBOX label.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.archiveEmail(messageId);\n\n return {\n success: true,\n messageId,\n message: \"Email archived.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n", "tools/batch-delete-emails.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"gmail-batch-delete-emails\",\n description: \"Permanently delete multiple Gmail messages.\",\n inputSchema: defineSchema((v) => v.object({\n messageIds: v.array(v.string().min(1)).min(1).describe(\"Gmail message IDs\"),\n }))(),\n execute: async ({ messageIds }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.batchDeleteMessages(messageIds);\n\n return {\n success: true,\n count: messageIds.length,\n message: `Permanently deleted ${messageIds.length} email(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n", diff --git a/cli/token-store-template.test.ts b/cli/token-store-template.test.ts index 9d7d2fc77f..f0993c4eee 100644 --- a/cli/token-store-template.test.ts +++ b/cli/token-store-template.test.ts @@ -6,6 +6,7 @@ import { configureTokenStore, createDefaultTokenStore, createTokenStore, + getRefreshableAccessToken, } from "./templates/integrations/_base/files/lib/token-store.ts"; describe("generated OAuth token store", () => { @@ -75,42 +76,223 @@ describe("generated OAuth token store", () => { ); }); - it("fails closed lazily when production storage is not configured", () => { - Deno.env.set("NODE_ENV", "production"); + it("fails closed unless memory storage is explicitly allowed", () => { + for (const mode of [undefined, "production", "staging", "preview"]) { + if (mode === undefined) Deno.env.delete("NODE_ENV"); + else Deno.env.set("NODE_ENV", mode); - assertThrows( - () => createDefaultTokenStore(), - Error, - "OAuth token storage is not configured for production", - ); + assertThrows( + () => createDefaultTokenStore(), + Error, + "NODE_ENV is explicitly development or test", + ); + } }); - it("rejects an explicitly configured memory store in production", () => { - Deno.env.set("NODE_ENV", "production"); + it("rejects an explicitly configured memory store outside development and test", () => { + for (const mode of [undefined, "production", "staging", "preview"]) { + if (mode === undefined) Deno.env.delete("NODE_ENV"); + else Deno.env.set("NODE_ENV", mode); - assertThrows( - () => configureTokenStore(new MemoryTokenStore("production-memory")), - Error, - "MemoryTokenStore is not allowed for production OAuth storage", - ); + assertThrows( + () => configureTokenStore(new MemoryTokenStore(`memory-${mode ?? "unset"}`)), + Error, + "NODE_ENV is explicitly development or test", + ); + } }); - it("uses a refresh-capable memory store only in development", async () => { + it("uses a refresh-capable memory store only in development and test", async () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + for (const mode of ["development", "test"]) { + Deno.env.set("NODE_ENV", mode); + const store = createDefaultTokenStore(); + await store.setTokens("github", "alice", { accessToken: `${mode}-token` }); + const snapshot = await store.getTokenSnapshot("github", "alice"); + assertEquals(snapshot?.tokens.accessToken, `${mode}-token`); + assertEquals(typeof snapshot?.revision, "string"); + } + } finally { + console.warn = originalWarn; + } + }); + + it("uses Deno runtime mode when process exists without env", () => { Deno.env.set("NODE_ENV", "development"); + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); const originalWarn = console.warn; console.warn = () => {}; + Object.defineProperty(globalThis, "process", { + configurable: true, + value: {}, + }); try { - const store = createDefaultTokenStore(); - await store.setTokens("github", "alice", { accessToken: "development-token" }); - const snapshot = await store.getTokenSnapshot("github", "alice"); - assertEquals(snapshot?.tokens.accessToken, "development-token"); - assertEquals(typeof snapshot?.revision, "string"); + assertEquals(typeof createDefaultTokenStore(), "object"); } finally { console.warn = originalWarn; + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); } }); + it("treats denied Deno environment access as an unset runtime mode", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + const denoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "Deno", { + configurable: true, + value: { + env: { + get() { + throw new Error("PermissionDenied"); + }, + }, + }, + }); + + try { + assertThrows( + () => createDefaultTokenStore(), + Error, + "NODE_ENV is explicitly development or test", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + if (denoDescriptor) Object.defineProperty(globalThis, "Deno", denoDescriptor); + else Reflect.deleteProperty(globalThis, "Deno"); + } + }); + + it("treats denied process environment access as an unset runtime mode", () => { + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + Object.defineProperty(globalThis, "process", { + configurable: true, + value: { + env: new Proxy({}, { + get() { + throw new Error("PermissionDenied"); + }, + }), + }, + }); + + try { + assertThrows( + () => createDefaultTokenStore(), + Error, + "NODE_ENV is explicitly development or test", + ); + } finally { + if (processDescriptor) Object.defineProperty(globalThis, "process", processDescriptor); + else Reflect.deleteProperty(globalThis, "process"); + } + }); + + it("serializes concurrent refresh and persists it with compare-and-set", async () => { + const store = createTokenStore(new MemoryTokenStore("refresh-concurrency")); + await store.setTokens("github", "alice", { + accessToken: "expiring", + refreshToken: "refresh-1", + expiresAt: Date.now() + 1_000, + }); + let refreshes = 0; + const refresh = async () => { + refreshes++; + await Promise.resolve(); + return { + accessToken: "refreshed", + refreshToken: "refresh-2", + expiresAt: Date.now() + 10 * 60_000, + }; + }; + + const tokens = await Promise.all([ + getRefreshableAccessToken(store, "github", "alice", refresh), + getRefreshableAccessToken(store, "github", "alice", refresh), + ]); + + assertEquals(tokens, ["refreshed", "refreshed"]); + assertEquals(refreshes, 1); + assertEquals((await store.getTokens("github", "alice"))?.accessToken, "refreshed"); + }); + + it("preserves the existing refresh token when a provider omits one", async () => { + const store = createTokenStore(new MemoryTokenStore("refresh-token-preserve")); + await store.setTokens("github", "alice", { + accessToken: "expiring", + refreshToken: "refresh-1", + expiresAt: Date.now() + 1_000, + }); + + assertEquals( + await getRefreshableAccessToken( + store, + "github", + "alice", + async () => ({ + accessToken: "refreshed", + expiresAt: Date.now() + 10 * 60_000, + }), + ), + "refreshed", + ); + assertEquals((await store.getTokens("github", "alice"))?.refreshToken, "refresh-1"); + }); + + it("does not overwrite a concurrent reconnect when refresh loses CAS", async () => { + const store = createTokenStore(new MemoryTokenStore("refresh-cas")); + await store.setTokens("github", "alice", { + accessToken: "expired", + refreshToken: "refresh-1", + expiresAt: Date.now() - 1, + }); + + const accessToken = await getRefreshableAccessToken( + store, + "github", + "alice", + async () => { + await store.setTokens("github", "alice", { + accessToken: "reauthorized", + refreshToken: "refresh-new", + expiresAt: Date.now() + 60_000, + }); + return { accessToken: "stale-refresh" }; + }, + ); + + assertEquals(accessToken, "reauthorized"); + assertEquals((await store.getTokens("github", "alice"))?.accessToken, "reauthorized"); + }); + + it("retains the token row when the provider refresh fails", async () => { + const store = createTokenStore(new MemoryTokenStore("refresh-failure")); + await store.setTokens("github", "alice", { + accessToken: "expired", + refreshToken: "refresh-1", + expiresAt: Date.now() - 1, + }); + + assertEquals( + await getRefreshableAccessToken( + store, + "github", + "alice", + () => Promise.reject(new Error("provider unavailable")), + ), + null, + ); + assertEquals((await store.getTokens("github", "alice"))?.refreshToken, "refresh-1"); + }); + it("propagates refresh-lock failures from the configured backend", async () => { const store = createTokenStore(new MemoryTokenStore("lock-failure"));