From 8c5467aa69e2bd4330125a507db9ea3a07625a43 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 09:20:31 +0200 Subject: [PATCH 01/26] feat(cli): add encrypted token store to integration templates Generated integration apps previously had two options for durable OAuth token storage: implement the full RefreshCapableTokenStore contract by hand or fall back to the development-only memory store. This adds a drop-in lib/encrypted-token-store.ts to the integration _base template that builds a RefreshCapableTokenStore over any durable key-value service (get/set/delete plus atomic compareAndSwap and withLock), with AES-256-GCM encryption at rest via the Web Crypto API: - fresh random 96-bit IV per encryption - the storage key bound as additional authenticated data, so ciphertext moved between slots fails authentication - key imported from TOKEN_ENCRYPTION_KEY (64 hex chars); creation fails closed with guidance when the key is missing or malformed, and reads refuse anything not in the versioned encrypted envelope - there is no plaintext path in either direction - one-shot OAuth state via atomic compare-and-swap plus a 10-minute freshness window enforced independently of backend TTL support lib/token-store-examples.ts ships a development-only in-memory backend (refused in production) and a Redis-shaped wiring sketch. The design is reworked from the codex/module-reconcile-20260723 branch, replacing its plaintext fallback with fail-closed behavior; the branch's revokeToken auth changes are deliberately not ported. --- cli/encrypted-token-store-template.test.ts | 336 +++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 451 ++++++++++++++++++ .../_base/files/lib/token-store-examples.ts | 126 +++++ .../_base/files/lib/token-store.ts | 4 + cli/templates/manifest.json | 4 +- 5 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 cli/encrypted-token-store-template.test.ts create mode 100644 cli/templates/integrations/_base/files/lib/encrypted-token-store.ts create mode 100644 cli/templates/integrations/_base/files/lib/token-store-examples.ts diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts new file mode 100644 index 0000000000..4e95a03f9a --- /dev/null +++ b/cli/encrypted-token-store-template.test.ts @@ -0,0 +1,336 @@ +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 { + 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.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(), + }; +} + +/** 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); + }, + }; +} + +describe("generated encrypted OAuth token store", () => { + const originalKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); + const originalNodeEnv = Deno.env.get("NODE_ENV"); + + beforeEach(() => { + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + 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 (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("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 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); + assertEquals(stored.includes("super-secret-access"), false); + assertEquals(stored.includes("super-secret-refresh"), false); + } + assertEquals(await store.getTokens("github", "alice"), tokens); + }); + + 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]; + + assertNotEquals(first, second); + }); + + it("refuses plaintext rows found in the backend", async () => { + const backend = createMemoryKvBackend(); + const store = createEncryptedTokenStore(backend); + await backend.set( + 'veryfront:oauth:v1:tokens:["github","alice"]', + JSON.stringify({ revision: "r1", tokens: { accessToken: "plaintext" } }), + ); + + await assertRejects( + () => store.getTokens("github", "alice"), + Error, + "never reads plaintext", + ); + }); + + it("rejects tampered ciphertext", 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 body = stored.slice(ENVELOPE_PREFIX.length); + const index = 20; + const replacement = body[index] === "A" ? "B" : "A"; + await backend.set( + key, + ENVELOPE_PREFIX + body.slice(0, index) + replacement + body.slice(index + 1), + ); + + await assertRejects( + () => store.getTokens("github", "alice"), + Error, + "failed authentication", + ); + }); + + 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); + + await assertRejects( + () => store.getTokens("github", "mallory"), + Error, + "failed authentication", + ); + }); + + it("cannot read values written under a different key", async () => { + const backend = createMemoryKvBackend(); + const store = createEncryptedTokenStore(backend); + await store.setTokens("github", "alice", { accessToken: "secret" }); + + Deno.env.set("TOKEN_ENCRYPTION_KEY", generateEncryptionKey()); + const rotated = createEncryptedTokenStore(backend); + + await assertRejects( + () => rotated.getTokens("github", "alice"), + Error, + "failed authentication", + ); + }); + + 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 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("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", + ); + }); +}); 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..08e7ce21da --- /dev/null +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -0,0 +1,451 @@ +/** + * 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. + * + * 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; +} + +const ENCRYPTION_KEY_ENV_VAR = "TOKEN_ENCRYPTION_KEY"; +const ENVELOPE_PREFIX = "vf-aes-gcm.v1:"; +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; +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 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 { + if (typeof process !== "undefined" && process.env) return process.env[name]; + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.(name); +} + +/** 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): Uint8Array { + if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) { + throw new TypeError( + `${ENCRYPTION_KEY_ENV_VAR} 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); +} + +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 + ) { + throw new TypeError( + `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`, + ); + } + return value; +} + +function tokensStorageKey(serviceId: string, userId: string): string { + return TOKENS_KEY_PREFIX + JSON.stringify([ + requireKeyComponent(serviceId, "serviceId"), + requireKeyComponent(userId, "userId"), + ]); +} + +function refreshLockKey(serviceId: string, userId: string): string { + return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([ + requireKeyComponent(serviceId, "serviceId"), + requireKeyComponent(userId, "userId"), + ]); +} + +function stateStorageKey(state: string): string { + if (typeof state !== "string" || state.length === 0 || state.length > MAX_STATE_KEY_LENGTH) { + throw new RangeError( + `state must contain between 1 and ${MAX_STATE_KEY_LENGTH} characters`, + ); + } + return STATE_KEY_PREFIX + state; +} + +interface StoredTokenEntry { + revision: string; + tokens: OAuthTokens; +} + +function requireTokenRow(value: unknown): OAuthTokens { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("OAuth token row must be an object"); + } + const tokens = value as OAuthTokens; + if (typeof tokens.accessToken !== "string" || tokens.accessToken.length === 0) { + throw new TypeError("OAuth token row must contain a non-empty accessToken"); + } + if ( + tokens.expiresAt !== undefined && + (typeof tokens.expiresAt !== "number" || !Number.isSafeInteger(tokens.expiresAt) || + tokens.expiresAt < 0) + ) { + throw new TypeError("OAuth token expiresAt must be a non-negative safe integer"); + } + return tokens; +} + +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 entry = value as StoredTokenEntry; + if (typeof entry.revision !== "string" || entry.revision.length === 0) { + throw new TypeError("Stored OAuth token entry must contain a revision"); + } + return { revision: entry.revision, tokens: requireTokenRow(entry.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 meta = value as StoredOAuthState; + if (typeof meta.userId !== "string" || meta.userId.length === 0) { + throw new TypeError("Stored OAuth state row must contain a userId"); + } + if (typeof meta.serviceId !== "string" || meta.serviceId.length === 0) { + throw new TypeError("Stored OAuth state row must contain a serviceId"); + } + if (typeof meta.createdAt !== "number" || !Number.isSafeInteger(meta.createdAt)) { + throw new TypeError("Stored OAuth state row must contain a createdAt timestamp"); + } + return meta; +} + +function isFreshState(createdAt: number, now: number): boolean { + return createdAt <= now + STATE_CLOCK_SKEW_MS && + now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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}()`); + } + } +} + +class EnvelopeCipher { + readonly #key: Promise; + + constructor(keyBytes: Uint8Array) { + this.#key = crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); + keyBytes.fill(0); + } + + async seal(storageKey: string, value: unknown): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) { + throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`); + } + 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) }, + await this.#key, + plaintext, + ), + ); + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv); + combined.set(ciphertext, iv.byteLength); + return ENVELOPE_PREFIX + 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"); + } + if (!stored.startsWith(ENVELOPE_PREFIX)) { + throw new Error( + `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` + + "format. This store never reads plaintext credentials; re-authenticate " + + "affected users to replace legacy rows.", + ); + } + const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length)); + 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) }, + await this.#key, + ciphertext, + ); + } catch (cause) { + throw new Error( + "Encrypted OAuth value failed authentication (wrong key, corrupted " + + "data, or a value moved between storage slots)", + { cause }, + ); + } + return JSON.parse(new TextDecoder().decode(plaintext)); + } +} + +/** + * 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: + * + * ```ts + * import { configureTokenStore } from "./token-store.ts"; + * import { createEncryptedTokenStore } from "./encrypted-token-store.ts"; + * + * configureTokenStore(createEncryptedTokenStore(myKvBackend)); + * ``` + */ +export function createEncryptedTokenStore( + backend: EncryptedKvBackend, +): RefreshCapableTokenStore { + assertBackend(backend); + const cipher = new EnvelopeCipher(requireEncryptionKeyBytes()); + + 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; + return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) }; + } + + 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; + const row = requireStateRow(await cipher.open(key, raw)); + return isFreshState(row.createdAt, Date.now()) ? row : null; + }, + }; +} 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..62603552b5 --- /dev/null +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -0,0 +1,126 @@ +/** + * 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 wire it once during startup: + * + * ```ts + * import { configureTokenStore } from "./token-store.ts"; + * import { createEncryptedTokenStore } from "./encrypted-token-store.ts"; + * + * configureTokenStore(createEncryptedTokenStore(myDurableBackend)); + * ``` + * + * A Redis-shaped backend maps naturally onto the contract: + * + * ```ts + * const redisBackend: EncryptedKvBackend = { + * get: (key) => redis.get(key), + * set: async (key, value, options) => { + * await (options?.expiresInMs + * ? redis.set(key, value, { px: options.expiresInMs }) + * : redis.set(key, value)); + * }, + * delete: async (key) => { + * await redis.del(key); + * }, + * // Run WATCH/MULTI or a small Lua script so the comparison and the write + * // are one atomic step on the server. + * compareAndSwap: (key, expected, next, options) => + * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs), + * // Use a bounded lease (for example SET NX PX plus a fenced release) so + * // a crashed holder cannot block token refresh forever. + * withLock: (key, operation) => redisWithLease(key, operation), + * }; + * ``` + */ + +import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; + +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"; +} + +interface MemoryRow { + value: string; + expiresAt: number | null; +} + +/** + * Development-only 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 { + if (isProductionRuntime()) { + throw new Error( + "The in-memory example backend is not allowed in production. 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..6699a4a593 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -5,6 +5,10 @@ * 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. + * + * 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 { diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index ae868ae738..287d3aeacf 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/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 * 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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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;\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\" || 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 return STATE_KEY_PREFIX + state;\n}\n\ninterface StoredTokenEntry {\n revision: string;\n tokens: OAuthTokens;\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 tokens = value as OAuthTokens;\n if (typeof tokens.accessToken !== \"string\" || tokens.accessToken.length === 0) {\n throw new TypeError(\"OAuth token row must contain a non-empty accessToken\");\n }\n if (\n tokens.expiresAt !== undefined &&\n (typeof tokens.expiresAt !== \"number\" || !Number.isSafeInteger(tokens.expiresAt) ||\n tokens.expiresAt < 0)\n ) {\n throw new TypeError(\"OAuth token expiresAt must be a non-negative safe integer\");\n }\n return tokens;\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 entry = value as StoredTokenEntry;\n if (typeof entry.revision !== \"string\" || entry.revision.length === 0) {\n throw new TypeError(\"Stored OAuth token entry must contain a revision\");\n }\n return { revision: entry.revision, tokens: requireTokenRow(entry.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 meta = value as StoredOAuthState;\n if (typeof meta.userId !== \"string\" || meta.userId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (typeof meta.serviceId !== \"string\" || meta.serviceId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n if (typeof meta.createdAt !== \"number\" || !Number.isSafeInteger(meta.createdAt)) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n return meta;\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 const row = requireStateRow(await cipher.open(key, raw));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\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/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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\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\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 if (isProductionRuntime()) {\n throw new Error(\n \"The in-memory example backend is not allowed in production. Implement \" +\n \"EncryptedKvBackend over 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 development-only.\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;\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/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" } From bbdba82c0b40c020a992e1dacd3225fc8f6883de Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 09:21:26 +0200 Subject: [PATCH 02/26] chore: drop unused stringifyJsonValue imports to unblock pre-push lint --- extensions/ext-llm-anthropic/src/anthropic-request-builder.ts | 1 - .../ext-llm-openai/src/openai-responses-request-builder.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts index 4d9d75f6f4..6158b89e21 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts @@ -2,7 +2,6 @@ import { jsonValuesEqual, readProviderOptions, readRecord, - stringifyJsonValue, stringifyToolResultValue, unwrapToolInputSchema, } from "veryfront/provider/shared"; diff --git a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts index e4d358c5bb..093fa001f2 100644 --- a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts +++ b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts @@ -1,7 +1,6 @@ import { jsonValuesEqual, readProviderOptions, - stringifyJsonValue, stringifyToolArguments, stringifyToolResultValue, unwrapToolInputSchema, From 5bd3b15725b0e3bdd2bc69726833578c10055cc9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 09:56:25 +0200 Subject: [PATCH 03/26] Harden generated token row normalization The encrypted integration token store accepted arbitrary object shapes at the persistence boundary, so generated integrations could persist values through getters or keep non-string optional token fields. Normalize rows through own data descriptors and return detached snapshots before sealing or returning tokens. Constraint: Generated templates must remain self-contained and not depend on non-exported internal OAuth helpers. Rejected: Export normalizeStoredOAuthTokens for templates | would broaden the public OAuth surface to fix one generated file. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts Tested: npx --yes deno@2.7.7 lint cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: npx --yes deno@2.7.7 check --config=deno.json cli/encrypted-token-store-template.test.ts --- cli/encrypted-token-store-template.test.ts | 57 +++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 61 ++++++++++++++++--- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 4e95a03f9a..a374134a57 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -123,6 +123,63 @@ describe("generated encrypted OAuth token store", () => { 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("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); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 08e7ce21da..9f85557201 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -81,6 +81,9 @@ 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_TOKEN_VALUE_LENGTH = 65_536; +const MAX_TOKEN_TYPE_LENGTH = 256; +const MAX_SCOPE_WIRE_LENGTH = 4_096; const TOKENS_KEY_PREFIX = "veryfront:oauth:v1:tokens:"; const STATE_KEY_PREFIX = "veryfront:oauth:v1:state:"; @@ -209,33 +212,73 @@ interface StoredTokenEntry { 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; +} + +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 tokens = value as OAuthTokens; - if (typeof tokens.accessToken !== "string" || tokens.accessToken.length === 0) { + 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 scope = requireOptionalTokenString(value, "scope", MAX_SCOPE_WIRE_LENGTH); + const idToken = requireOptionalTokenString(value, "idToken", MAX_TOKEN_VALUE_LENGTH); + const expiresAt = ownDataValue(value, "expiresAt"); if ( - tokens.expiresAt !== undefined && - (typeof tokens.expiresAt !== "number" || !Number.isSafeInteger(tokens.expiresAt) || - tokens.expiresAt < 0) + expiresAt !== undefined && + (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < 0) ) { throw new TypeError("OAuth token expiresAt must be a non-negative safe integer"); } - return tokens; + 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 entry = value as StoredTokenEntry; - if (typeof entry.revision !== "string" || entry.revision.length === 0) { + 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: entry.revision, tokens: requireTokenRow(entry.tokens) }; + return { revision, tokens: requireTokenRow(ownDataValue(value, "tokens")) }; } function requireStateRow(value: unknown): StoredOAuthState { From 211d76dd6e7315e43540db5ebac51e3438c17861 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:01:15 +0200 Subject: [PATCH 04/26] Keep generated integration manifest current The token-store template hardening changed the embedded integration template, so the committed manifest must carry the same generated source for scaffolded projects and manifest checks. Constraint: Template manifest checks compare the embedded source against cli/templates/integrations. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 task generate:manifests:check Tested: git diff --check --- cli/templates/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 287d3aeacf..a346f07090 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 * 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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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;\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\" || 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 return STATE_KEY_PREFIX + state;\n}\n\ninterface StoredTokenEntry {\n revision: string;\n tokens: OAuthTokens;\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 tokens = value as OAuthTokens;\n if (typeof tokens.accessToken !== \"string\" || tokens.accessToken.length === 0) {\n throw new TypeError(\"OAuth token row must contain a non-empty accessToken\");\n }\n if (\n tokens.expiresAt !== undefined &&\n (typeof tokens.expiresAt !== \"number\" || !Number.isSafeInteger(tokens.expiresAt) ||\n tokens.expiresAt < 0)\n ) {\n throw new TypeError(\"OAuth token expiresAt must be a non-negative safe integer\");\n }\n return tokens;\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 entry = value as StoredTokenEntry;\n if (typeof entry.revision !== \"string\" || entry.revision.length === 0) {\n throw new TypeError(\"Stored OAuth token entry must contain a revision\");\n }\n return { revision: entry.revision, tokens: requireTokenRow(entry.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 meta = value as StoredOAuthState;\n if (typeof meta.userId !== \"string\" || meta.userId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (typeof meta.serviceId !== \"string\" || meta.serviceId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n if (typeof meta.createdAt !== \"number\" || !Number.isSafeInteger(meta.createdAt)) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n return meta;\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 const row = requireStateRow(await cipher.open(key, raw));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n },\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 * 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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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_TOKEN_VALUE_LENGTH = 65_536;\nconst MAX_TOKEN_TYPE_LENGTH = 256;\nconst MAX_SCOPE_WIRE_LENGTH = 4_096;\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\" || 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 return STATE_KEY_PREFIX + 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\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 scope = requireOptionalTokenString(value, \"scope\", MAX_SCOPE_WIRE_LENGTH);\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 meta = value as StoredOAuthState;\n if (typeof meta.userId !== \"string\" || meta.userId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (typeof meta.serviceId !== \"string\" || meta.serviceId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n if (typeof meta.createdAt !== \"number\" || !Number.isSafeInteger(meta.createdAt)) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n return meta;\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 const row = requireStateRow(await cipher.open(key, raw));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\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\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 if (isProductionRuntime()) {\n throw new Error(\n \"The in-memory example backend is not allowed in production. Implement \" +\n \"EncryptedKvBackend over 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 development-only.\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;\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", From a58a5732cb9e751185ac0d2683eaaa3c67c14326 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 12:44:10 +0200 Subject: [PATCH 05/26] Keep generated OAuth storage fail closed under bad state Tighten the template boundary that persists OAuth credentials and state after review exposed fail-open runtime selection, under-validated callback state, and a test that did not isolate IV reuse. Constraint: Generated code must remain dependency-free and work in both Deno and Node runtimes. Rejected: Add multi-key decryption to the v1 envelope | a rotation protocol needs an explicit key identifier and migration design beyond this review fix. Confidence: high Scope-risk: narrow Directive: Keep the manifest synchronized with every template source change. Tested: Focused encrypted token-store tests, template tests, deno check, manifest check, and diff check. --- cli/encrypted-token-store-template.test.ts | 84 +++++++++++- .../_base/files/lib/encrypted-token-store.ts | 121 ++++++++++++++++-- .../_base/files/lib/token-store-examples.ts | 17 ++- cli/templates/manifest.json | 4 +- 4 files changed, 207 insertions(+), 19 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index a374134a57..96aaf8bc3c 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -28,6 +28,12 @@ function oauthState(userId: string): StoredOAuthState { }; } +function envelopeIv(stored: string): number[] { + const encoded = stored.slice(ENVELOPE_PREFIX.length); + const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + return [...bytes.subarray(0, 12)]; +} + /** Expose the raw rows so tests can assert what actually hits storage. */ function inspectableBackend(): EncryptedKvBackend & { rows: Map } { const backend = createMemoryKvBackend(); @@ -163,6 +169,17 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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("stores detached token snapshots", async () => { const store = createEncryptedTokenStore(createMemoryKvBackend()); const tokens = { accessToken: "original", refreshToken: "refresh" }; @@ -189,8 +206,9 @@ describe("generated encrypted OAuth token store", () => { 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(first, second); + assertNotEquals(envelopeIv(first), envelopeIv(second)); }); it("refuses plaintext rows found in the backend", async () => { @@ -321,6 +339,60 @@ describe("generated encrypted OAuth token store", () => { 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", + ); + }); + + it("treats corrupted one-shot OAuth state as invalid", async () => { + const backend = inspectableBackend(); + const store = createEncryptedTokenStore(backend); + await store.setState("corrupted-state", oauthState("alice")); + + const entry = [...backend.rows.entries()][0]; + if (!entry) throw new Error("Expected a stored state row"); + const [key, stored] = entry; + const body = stored.slice(ENVELOPE_PREFIX.length); + const index = 20; + const replacement = body[index] === "A" ? "B" : "A"; + await backend.set( + key, + ENVELOPE_PREFIX + body.slice(0, index) + replacement + body.slice(index + 1), + ); + + assertEquals(await store.consumeState("corrupted-state"), null); + assertEquals(await store.consumeState("corrupted-state"), null); + }); + it("rejects state rows outside the acceptance window", async () => { const store = createEncryptedTokenStore(createMemoryKvBackend()); const expired = { ...oauthState("alice"), createdAt: Date.now() - 12 * 60_000 }; @@ -390,4 +462,14 @@ describe("generated encrypted OAuth token store", () => { "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", + ); + }); }); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 9f85557201..10a14007c6 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -14,6 +14,11 @@ * the store without a valid key throws, and values that are not in the * expected encrypted envelope are refused on read. * + * Keep the configured key while encrypted rows still use it. Replacing the + * key makes existing rows unreadable. Delete affected rows and have users + * reconnect before removing the old key; this v1 envelope does not support + * decrypting with multiple keys during rotation. + * * Generate a key once per deployment and set it before startup: * * ```sh @@ -81,10 +86,17 @@ 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; +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:"; @@ -199,12 +211,18 @@ function refreshLockKey(serviceId: string, userId: string): string { } function stateStorageKey(state: string): string { - if (typeof state !== "string" || state.length === 0 || state.length > MAX_STATE_KEY_LENGTH) { + 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`, ); } - return STATE_KEY_PREFIX + state; + if (state.trim() !== state || hasAsciiControlCharacter(state)) { + throw new TypeError("state must not contain surrounding whitespace or control characters"); + } + return STATE_KEY_PREFIX + JSON.stringify([state]); } interface StoredTokenEntry { @@ -251,7 +269,20 @@ function requireTokenRow(value: unknown): OAuthTokens { } const refreshToken = requireOptionalTokenString(value, "refreshToken", MAX_TOKEN_VALUE_LENGTH); const tokenType = requireOptionalTokenString(value, "tokenType", MAX_TOKEN_TYPE_LENGTH); - const scope = requireOptionalTokenString(value, "scope", MAX_SCOPE_WIRE_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 ( @@ -285,17 +316,83 @@ 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 meta = value as StoredOAuthState; - if (typeof meta.userId !== "string" || meta.userId.length === 0) { + 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 = ownDataValue(value, "metadata"); + if ( + typeof userId !== "string" || userId.length === 0 || + userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId + ) { throw new TypeError("Stored OAuth state row must contain a userId"); } - if (typeof meta.serviceId !== "string" || meta.serviceId.length === 0) { + 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"); } - if (typeof meta.createdAt !== "number" || !Number.isSafeInteger(meta.createdAt)) { + 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"); } - return meta; + 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: metadata as Record }), + }; } function isFreshState(createdAt: number, now: number): boolean { @@ -487,8 +584,12 @@ export function createEncryptedTokenStore( // may redeem it, so a replayed callback cannot reuse the state. const consumed = await backend.compareAndSwap(key, raw, null); if (!consumed) return null; - const row = requireStateRow(await cipher.open(key, raw)); - return isFreshState(row.createdAt, Date.now()) ? row : null; + try { + const row = requireStateRow(await cipher.open(key, raw)); + return isFreshState(row.createdAt, Date.now()) ? row : null; + } catch { + return null; + } }, }; } diff --git a/cli/templates/integrations/_base/files/lib/token-store-examples.ts b/cli/templates/integrations/_base/files/lib/token-store-examples.ts index 62603552b5..6216992e98 100644 --- a/cli/templates/integrations/_base/files/lib/token-store-examples.ts +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -40,10 +40,10 @@ import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; -function isProductionRuntime(): boolean { - if (typeof process !== "undefined") return process.env?.NODE_ENV === "production"; +function runtimeMode(): string | undefined { + if (typeof process !== "undefined") return process.env?.NODE_ENV; return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno - ?.env?.get?.("NODE_ENV") === "production"; + ?.env?.get?.("NODE_ENV"); } interface MemoryRow { @@ -57,10 +57,15 @@ interface MemoryRow { * nothing is shared across workers, so creation is refused in production. */ export function createMemoryKvBackend(): EncryptedKvBackend { - if (isProductionRuntime()) { + const mode = runtimeMode(); + if (mode !== "development" && mode !== "test") { throw new Error( - "The in-memory example backend is not allowed in production. Implement " + - "EncryptedKvBackend over a durable service (Redis, Postgres, Deno KV).", + 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).", ); } diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index a346f07090..bf2a049793 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,9 +115,9 @@ "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/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 * 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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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_TOKEN_VALUE_LENGTH = 65_536;\nconst MAX_TOKEN_TYPE_LENGTH = 256;\nconst MAX_SCOPE_WIRE_LENGTH = 4_096;\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\" || 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 return STATE_KEY_PREFIX + 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\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 scope = requireOptionalTokenString(value, \"scope\", MAX_SCOPE_WIRE_LENGTH);\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 meta = value as StoredOAuthState;\n if (typeof meta.userId !== \"string\" || meta.userId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (typeof meta.serviceId !== \"string\" || meta.serviceId.length === 0) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n if (typeof meta.createdAt !== \"number\" || !Number.isSafeInteger(meta.createdAt)) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n return meta;\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 const row = requireStateRow(await cipher.open(key, raw));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n },\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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\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 = ownDataValue(value, \"metadata\");\n if (\n typeof userId !== \"string\" || userId.length === 0 ||\n userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== 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: metadata as Record }),\n };\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\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\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 if (isProductionRuntime()) {\n throw new Error(\n \"The in-memory example backend is not allowed in production. Implement \" +\n \"EncryptedKvBackend over 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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n if (typeof process !== \"undefined\") return process.env?.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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;\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/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" From c0e3ad02d671c0cf3ed8a654d841a0d247b2064e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:02:49 +0200 Subject: [PATCH 06/26] Harden generated OAuth token metadata validation The encrypted integration token-store template already avoids token-row accessors, but state metadata and storage key components still left two suppressed review gaps. This keeps validation data-only before sealing encrypted rows and rejects control characters in generated backend keys. Constraint: Generated integration code must fail closed before persistence and must not invoke user-provided getters during serialization. Rejected: Allow arbitrary metadata and rely on JSON.stringify | it invokes accessors and toJSON hooks outside the store's data-only validation model. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts Tested: npx --yes deno@2.7.7 fmt --check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: npx --yes deno@2.7.7 check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/integrations/_base/files/lib/token-store-examples.ts cli/templates/integrations/_base/files/lib/token-store.ts --- cli/encrypted-token-store-template.test.ts | 32 +++++++++++ .../_base/files/lib/encrypted-token-store.ts | 54 +++++++++++++++++-- cli/templates/manifest.json | 2 +- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 96aaf8bc3c..975e5306a2 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -180,6 +180,21 @@ describe("generated encrypted OAuth token store", () => { 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("stores detached token snapshots", async () => { const store = createEncryptedTokenStore(createMemoryKvBackend()); const tokens = { accessToken: "original", refreshToken: "refresh" }; @@ -373,6 +388,23 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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("treats corrupted one-shot OAuth state as invalid", async () => { const backend = inspectableBackend(); const store = createEncryptedTokenStore(backend); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 10a14007c6..488504d9cc 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -187,10 +187,11 @@ function base64ToBytes(encoded: string): Uint8Array { function requireKeyComponent(value: string, label: string): string { if ( typeof value !== "string" || value.length === 0 || - value.length > MAX_KEY_COMPONENT_LENGTH || value.trim() !== value + 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`, + `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters without control characters`, ); } return value; @@ -243,6 +244,51 @@ function hasAsciiControlCharacter(value: string): boolean { return false; } +function requireJsonDataValue(value: unknown, label: string): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError(`${label} must contain only finite JSON numbers`); + } + return value; + } + if (Array.isArray(value)) { + return Array.from({ length: 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`); + } + return requireJsonDataValue(descriptor.value, label); + }); + } + if (!value || typeof value !== "object") { + throw new TypeError(`${label} must contain only JSON data values`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must contain only plain JSON data objects`); + } + const snapshot: Record = {}; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`${label} must contain only own data values`); + } + snapshot[key] = requireJsonDataValue(descriptor.value, label); + } + return snapshot; +} + +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 requireJsonDataValue(value, "Stored OAuth state metadata") as Record; +} + function requireOptionalTokenString( record: object, key: string, @@ -322,7 +368,7 @@ function requireStateRow(value: unknown): StoredOAuthState { const scopes = ownDataValue(value, "scopes"); const createdAt = ownDataValue(value, "createdAt"); const codeVerifier = ownDataValue(value, "codeVerifier"); - const metadata = ownDataValue(value, "metadata"); + const metadata = requireMetadata(ownDataValue(value, "metadata")); if ( typeof userId !== "string" || userId.length === 0 || userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId @@ -391,7 +437,7 @@ function requireStateRow(value: unknown): StoredOAuthState { scopes: scopeSnapshot, createdAt, ...(codeVerifier === undefined ? {} : { codeVerifier }), - ...(metadata === undefined ? {} : { metadata: metadata as Record }), + ...(metadata === undefined ? {} : { metadata }), }; } diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 77690cf50c..5d5097b1fc 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters`,\n );\n }\n return value;\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\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 = ownDataValue(value, \"metadata\");\n if (\n typeof userId !== \"string\" || userId.length === 0 ||\n userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== 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: metadata as Record }),\n };\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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 ) {\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 return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n if (typeof process !== \"undefined\") return process.env?.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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;\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", From 4e81376a80941a7a4017057349df6070689c9dee Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:43:38 +0200 Subject: [PATCH 07/26] Constrain OAuth state skew to future timestamps The generated encrypted token store used clock skew as extra age for old OAuth state rows, so a backend that retained rows past the 10-minute state TTL could still redeem a callback inside the extra skew minute. Split freshness into a future-skew branch and a strict past-TTL branch, and keep the generated template manifest in sync. Constraint: OAuth state TTL is 10 minutes; clock skew tolerance is only for producer clocks ahead of the consumer. Rejected: Shorten the backend TTL hint | durable backends may ignore TTL hints, so consume-time freshness must still enforce the contract. Confidence: high Scope-risk: narrow Tested: Red run: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts failed on the new 10m30s past-state assertion. Tested: Green run: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts passed 29 steps. Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts cli/templates/index.test.ts passed 55 steps. Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check passed. Tested: npx --yes deno@2.7.7 fmt --check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/manifest.json passed. Tested: npx --yes deno@2.7.7 lint cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts passed. Tested: npx --yes deno@2.7.7 check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts passed. Tested: git diff --check passed. --- cli/encrypted-token-store-template.test.ts | 20 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 6 ++++-- cli/templates/manifest.json | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 975e5306a2..a0e63cfda2 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -433,6 +433,26 @@ describe("generated encrypted OAuth token store", () => { 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. diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 488504d9cc..e3584bd15f 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -442,8 +442,10 @@ function requireStateRow(value: unknown): StoredOAuthState { } function isFreshState(createdAt: number, now: number): boolean { - return createdAt <= now + STATE_CLOCK_SKEW_MS && - now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_MS; + if (createdAt > now) { + return createdAt - now <= STATE_CLOCK_SKEW_MS; + } + return now - createdAt <= STATE_TTL_MS; } function assertBackend(backend: EncryptedKvBackend): void { diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 5d5097b1fc..4590546169 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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 ) {\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 return createdAt <= now + STATE_CLOCK_SKEW_MS &&\n now - createdAt <= STATE_TTL_MS + STATE_CLOCK_SKEW_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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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 ) {\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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n if (typeof process !== \"undefined\") return process.env?.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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;\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", From b3c9fbfdef16a2dccd8c604127912f8d32d6ef9d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 14:03:49 +0200 Subject: [PATCH 08/26] fix(cli): harden generated OAuth token storage --- cli/encrypted-token-store-template.test.ts | 21 +++ cli/templates/index.test.ts | 33 +++- .../_base/files/lib/encrypted-token-store.ts | 14 +- .../integrations/_base/files/lib/oauth.ts | 29 ++-- .../_base/files/lib/token-store-examples.ts | 39 ++--- .../_base/files/lib/token-store.ts | 89 ++++++++++- .../files/lib/docs-google-oauth.ts | 29 ++-- .../drive/files/lib/drive-oauth.ts | 30 ++-- cli/templates/manifest.json | 12 +- cli/token-store-template.test.ts | 142 +++++++++++++++--- 10 files changed, 325 insertions(+), 113 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index a0e63cfda2..149183e7b6 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -386,6 +386,11 @@ describe("generated encrypted OAuth token store", () => { TypeError, "scopes", ); + await assertRejects( + () => store.setState("unsafe-user", oauthState("ali\u0000ce")), + TypeError, + "userId", + ); }); it("rejects OAuth state metadata that requires invoking getters", async () => { @@ -524,4 +529,20 @@ describe("generated encrypted OAuth token store", () => { "explicit development or test", ); }); + + 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..ab992c702e 100644 --- a/cli/templates/index.test.ts +++ b/cli/templates/index.test.ts @@ -420,9 +420,9 @@ 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 for production memory storage", + "token-store.ts should fail closed outside explicit development and test modes", ); assertEquals( tokenStore.includes("getDefaultTokenStore"), @@ -456,6 +456,35 @@ 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("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 index e3584bd15f..019939f056 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -371,7 +371,8 @@ function requireStateRow(value: unknown): StoredOAuthState { const metadata = requireMetadata(ownDataValue(value, "metadata")); if ( typeof userId !== "string" || userId.length === 0 || - userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId + userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId || + hasAsciiControlCharacter(userId) ) { throw new TypeError("Stored OAuth state row must contain a userId"); } @@ -529,13 +530,18 @@ class EnvelopeCipher { * malformed, and when the backend does not provide the atomic operations * that safe multi-worker refresh requires. * - * Wire it once during startup: + * Wire it once during startup through an explicit configuration boundary: * * ```ts * import { configureTokenStore } from "./token-store.ts"; - * import { createEncryptedTokenStore } from "./encrypted-token-store.ts"; + * import { + * createEncryptedTokenStore, + * type EncryptedKvBackend, + * } from "./encrypted-token-store.ts"; * - * configureTokenStore(createEncryptedTokenStore(myKvBackend)); + * export function configureOAuthStorage(backend: EncryptedKvBackend): void { + * configureTokenStore(createEncryptedTokenStore(backend)); + * } * ``` */ export function createEncryptedTokenStore( 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 index 6216992e98..e4913795eb 100644 --- a/cli/templates/integrations/_base/files/lib/token-store-examples.ts +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -5,35 +5,36 @@ * 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 wire it once during startup: + * 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 } from "./encrypted-token-store.ts"; + * import { + * createEncryptedTokenStore, + * type EncryptedKvBackend, + * } from "./encrypted-token-store.ts"; * - * configureTokenStore(createEncryptedTokenStore(myDurableBackend)); + * export function configureOAuthStorage(backend: EncryptedKvBackend): void { + * configureTokenStore(createEncryptedTokenStore(backend)); + * } * ``` * - * A Redis-shaped backend maps naturally onto the contract: + * Redis adapter sketch (pseudocode, not a paste-ready client): replace every + * angle-bracketed operation with the equivalent atomic operation from your + * initialized Redis client. * - * ```ts + * ```text * const redisBackend: EncryptedKvBackend = { - * get: (key) => redis.get(key), + * get: (key) => (key), * set: async (key, value, options) => { - * await (options?.expiresInMs - * ? redis.set(key, value, { px: options.expiresInMs }) - * : redis.set(key, value)); - * }, - * delete: async (key) => { - * await redis.del(key); + * await (key, value, options?.expiresInMs); * }, - * // Run WATCH/MULTI or a small Lua script so the comparison and the write - * // are one atomic step on the server. + * delete: (key) => (key), * compareAndSwap: (key, expected, next, options) => - * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs), - * // Use a bounded lease (for example SET NX PX plus a fenced release) so - * // a crashed holder cannot block token refresh forever. - * withLock: (key, operation) => redisWithLease(key, operation), + * (key, expected, next, options?.expiresInMs), + * withLock: (key, operation) => + * (key, operation), * }; * ``` */ @@ -41,7 +42,7 @@ import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; function runtimeMode(): string | undefined { - if (typeof process !== "undefined") return process.env?.NODE_ENV; + if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno ?.env?.get?.("NODE_ENV"); } diff --git a/cli/templates/integrations/_base/files/lib/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index 6699a4a593..a3e00da287 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -42,11 +42,17 @@ 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"; +function runtimeMode(): string | undefined { + if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno - ?.env?.get?.("NODE_ENV") === "production"; + ?.env?.get?.("NODE_ENV"); +} + +function allowsProcessLocalStorage(): boolean { + const mode = runtimeMode(); + return mode === "development" || mode === "test"; } function assertRefreshCapableStore( @@ -135,6 +141,72 @@ 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; + } + + 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; @@ -150,17 +222,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/manifest.json b/cli/templates/manifest.json index 4590546169..823f13c003 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,10 +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/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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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 ) {\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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myKvBackend));\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\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-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 wire it once during startup:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import { createEncryptedTokenStore } from \"./encrypted-token-store.ts\";\n *\n * configureTokenStore(createEncryptedTokenStore(myDurableBackend));\n * ```\n *\n * A Redis-shaped backend maps naturally onto the contract:\n *\n * ```ts\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => redis.get(key),\n * set: async (key, value, options) => {\n * await (options?.expiresInMs\n * ? redis.set(key, value, { px: options.expiresInMs })\n * : redis.set(key, value));\n * },\n * delete: async (key) => {\n * await redis.del(key);\n * },\n * // Run WATCH/MULTI or a small Lua script so the comparison and the write\n * // are one atomic step on the server.\n * compareAndSwap: (key, expected, next, options) =>\n * redisCompareAndSwapScript(key, expected, next, options?.expiresInMs),\n * // Use a bounded lease (for example SET NX PX plus a fenced release) so\n * // a crashed holder cannot block token refresh forever.\n * withLock: (key, operation) => redisWithLease(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n if (typeof process !== \"undefined\") return process.env?.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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;\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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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" } @@ -225,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", @@ -239,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", diff --git a/cli/token-store-template.test.ts b/cli/token-store-template.test.ts index 9d7d2fc77f..2a84271642 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,143 @@ 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 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 a refresh-capable memory store only in development", async () => { + 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("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("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")); From 6e69f2018581c518031b2be566e86b9516464fd5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:59:26 +0200 Subject: [PATCH 09/26] Prevent inherited serializers from shaping encrypted rows Generated OAuth storage accepted already-snapshotted rows, but the final JSON storage boundary still used raw JSON.stringify on objects and key-frame arrays. A poisoned inherited toJSON hook could therefore alter keys or payloads after validation. The template now builds storage key frames explicitly and snapshots encrypted payloads onto serializer-safe data before encoding. Constraint: PR #3310 requires patch-only hardening of the generated template without changing the encrypted envelope version or storage key semantics. Rejected: Replace JSON storage with a new binary codec | unnecessary format churn for scaffolded projects and harder to inspect. Confidence: high Scope-risk: narrow Directive: Do not reintroduce raw JSON.stringify at the storage boundary unless inherited toJSON hooks are shadowed or impossible. Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/encrypted-token-store-template.test.ts cli/templates/index.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check Tested: npx --yes deno@2.7.7 fmt --check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/manifest.json Tested: npx --yes deno@2.7.7 lint cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: npx --yes deno@2.7.7 check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: git diff --check --- cli/encrypted-token-store-template.test.ts | 59 ++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 92 ++++++++++++++++++- cli/templates/manifest.json | 2 +- 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 149183e7b6..5ec3dbfb32 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -195,6 +195,35 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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" }; @@ -410,6 +439,36 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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("treats corrupted one-shot OAuth state as invalid", async () => { const backend = inspectableBackend(); const store = createEncryptedTokenStore(backend); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 019939f056..258686c7de 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -197,15 +197,56 @@ function requireKeyComponent(value: string, label: string): string { 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 { + 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 + JSON.stringify([ + return TOKENS_KEY_PREFIX + jsonArrayFrame([ requireKeyComponent(serviceId, "serviceId"), requireKeyComponent(userId, "userId"), ]); } function refreshLockKey(serviceId: string, userId: string): string { - return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([ + return REFRESH_LOCK_KEY_PREFIX + jsonArrayFrame([ requireKeyComponent(serviceId, "serviceId"), requireKeyComponent(userId, "userId"), ]); @@ -223,7 +264,7 @@ function stateStorageKey(state: string): string { if (state.trim() !== state || hasAsciiControlCharacter(state)) { throw new TypeError("state must not contain surrounding whitespace or control characters"); } - return STATE_KEY_PREFIX + JSON.stringify([state]); + return STATE_KEY_PREFIX + jsonArrayFrame([state]); } interface StoredTokenEntry { @@ -281,6 +322,49 @@ function requireJsonDataValue(value: unknown, label: string): unknown { return snapshot; } +function snapshotForJsonStringify(value: unknown, label: string): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError(`${label} must contain only finite JSON numbers`); + } + return value; + } + if (Array.isArray(value)) { + const snapshot = Array.from({ length: 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`); + } + return snapshotForJsonStringify(descriptor.value, label); + }); + Object.defineProperty(snapshot, "toJSON", { + configurable: true, + enumerable: false, + value: undefined, + }); + return snapshot; + } + if (!value || typeof value !== "object") { + throw new TypeError(`${label} must contain only JSON data values`); + } + const snapshot: Record = Object.create(null); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`${label} must contain only own data values`); + } + snapshot[key] = snapshotForJsonStringify(descriptor.value, label); + } + return snapshot; +} + +function stringifyJsonData(value: unknown): string { + return JSON.stringify(snapshotForJsonStringify(value, "Stored OAuth value")); +} + function requireMetadata(value: unknown): Record | undefined { if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -472,7 +556,7 @@ class EnvelopeCipher { } async seal(storageKey: string, value: unknown): Promise { - const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const plaintext = new TextEncoder().encode(stringifyJsonData(value)); if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) { throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`); } diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 823f13c003..76988b8686 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + JSON.stringify([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + JSON.stringify([\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 + JSON.stringify([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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\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 requireJsonDataValue(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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n }\n\n async seal(storageKey: string, value: unknown): Promise {\n const plaintext = new TextEncoder().encode(JSON.stringify(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\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 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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From 55292cf7aa91d00efcd7102f6b60f1a34603530c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 3 Aug 2026 14:59:31 +0200 Subject: [PATCH 10/26] feat(cli): support key rotation and degraded reads in encrypted token store Address the two remaining review findings on the generated encrypted token store template: - Key rotation: seal new writes in a v2 envelope (vf-aes-gcm.v2::) that records an id derived from the sealing key, and accept an optional TOKEN_ENCRYPTION_KEY_PREVIOUS so rows sealed with the retiring key stay readable during rotation. Legacy v1 envelopes remain readable by trying every configured key; rows are re-sealed with the current key on write. - Undecryptable-row policy: token read paths (getTokens, getTokenSnapshot, and the compareAndSetTokens pre-read) now treat unreadable rows (unknown key, tampering, legacy plaintext) as absent and log a warning without token material, instead of throwing and failing the whole request. The integration shows as disconnected and reconnecting overwrites the row. Regenerate cli/templates/manifest.json and cover the new behavior with tests: previous-key decryption, v1 envelope compatibility, re-seal on write, malformed previous-key rejection, and null-plus-warning reads with reconnect recovery. Co-Authored-By: Claude Fable 5 --- cli/encrypted-token-store-template.test.ts | 191 +++++++++++++++--- .../_base/files/lib/encrypted-token-store.ts | 152 +++++++++++--- cli/templates/manifest.json | 2 +- 3 files changed, 287 insertions(+), 58 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 5ec3dbfb32..868657c43e 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -16,7 +16,8 @@ import { 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.v1:"; +const ENVELOPE_PREFIX = "vf-aes-gcm.v2:"; +const LEGACY_ENVELOPE_PREFIX = "vf-aes-gcm.v1:"; function oauthState(userId: string): StoredOAuthState { return { @@ -28,12 +29,61 @@ function oauthState(userId: string): StoredOAuthState { }; } +/** 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 encoded = stored.slice(ENVELOPE_PREFIX.length); - const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + 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 a value in the legacy v1 envelope (no key id) for compatibility tests. */ +async function sealLegacyV1( + 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 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(JSON.stringify(value)), + ), + ); + 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); +} + /** Expose the raw rows so tests can assert what actually hits storage. */ function inspectableBackend(): EncryptedKvBackend & { rows: Map } { const backend = createMemoryKvBackend(); @@ -67,16 +117,20 @@ function inspectableBackend(): EncryptedKvBackend & { rows: Map 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); }); @@ -102,6 +156,16 @@ describe("generated encrypted OAuth token store", () => { } }); + 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; @@ -123,6 +187,11 @@ describe("generated encrypted OAuth token store", () => { 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); } @@ -255,22 +324,23 @@ describe("generated encrypted OAuth token store", () => { assertNotEquals(envelopeIv(first), envelopeIv(second)); }); - it("refuses plaintext rows found in the backend", async () => { + 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: "plaintext" } }), + JSON.stringify({ revision: "r1", tokens: { accessToken: "legacy-raw-secret" } }), ); - await assertRejects( - () => store.getTokens("github", "alice"), - Error, - "never reads plaintext", - ); + 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); }); - it("rejects tampered ciphertext", async () => { + 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" }); @@ -278,19 +348,18 @@ describe("generated encrypted OAuth token store", () => { 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 { header, body } = splitEnvelope(stored); const index = 20; const replacement = body[index] === "A" ? "B" : "A"; await backend.set( key, - ENVELOPE_PREFIX + body.slice(0, index) + replacement + body.slice(index + 1), + header + body.slice(0, index) + replacement + body.slice(index + 1), ); - await assertRejects( - () => store.getTokens("github", "alice"), - Error, - "failed authentication", - ); + 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 () => { @@ -302,26 +371,88 @@ describe("generated encrypted OAuth token store", () => { if (!stored) throw new Error("Expected a stored row"); await backend.set('veryfront:oauth:v1:tokens:["github","mallory"]', stored); - await assertRejects( - () => store.getTokens("github", "mallory"), - Error, - "failed authentication", - ); + 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("cannot read values written under a different key", async () => { + 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); - await assertRejects( - () => rotated.getTokens("github", "alice"), - Error, - "failed authentication", + 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("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); + const rotated = createEncryptedTokenStore(backend); + assertEquals(await rotated.getTokens("github", "alice"), { accessToken: "legacy-token" }); }); it("supports revisioned compare-and-set for token refresh", async () => { @@ -477,12 +608,12 @@ describe("generated encrypted OAuth token store", () => { const entry = [...backend.rows.entries()][0]; if (!entry) throw new Error("Expected a stored state row"); const [key, stored] = entry; - const body = stored.slice(ENVELOPE_PREFIX.length); + const { header, body } = splitEnvelope(stored); const index = 20; const replacement = body[index] === "A" ? "B" : "A"; await backend.set( key, - ENVELOPE_PREFIX + body.slice(0, index) + replacement + body.slice(index + 1), + header + body.slice(0, index) + replacement + body.slice(index + 1), ); assertEquals(await store.consumeState("corrupted-state"), null); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 258686c7de..ecb8bde5f3 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -14,10 +14,18 @@ * the store without a valid key throws, and values that are not in the * expected encrypted envelope are refused on read. * - * Keep the configured key while encrypted rows still use it. Replacing the - * key makes existing rows unreadable. Delete affected rows and have users - * reconnect before removing the old key; this v1 envelope does not support - * decrypting with multiple keys during rotation. + * 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 and are re-sealed with the new + * key the next time they are written. Legacy v1 envelopes (no key id) are + * decrypted by trying every configured key. Remove + * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it. + * + * 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: * @@ -74,13 +82,18 @@ export interface EncryptedKvBackend { } const ENCRYPTION_KEY_ENV_VAR = "TOKEN_ENCRYPTION_KEY"; -const ENVELOPE_PREFIX = "vf-aes-gcm.v1:"; +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; +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; @@ -122,10 +135,10 @@ export function generateEncryptionKey(): string { .join(""); } -function parseEncryptionKeyHex(keyHex: string): Uint8Array { +function parseEncryptionKeyHex(keyHex: string, envVar: string): Uint8Array { if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) { throw new TypeError( - `${ENCRYPTION_KEY_ENV_VAR} must be exactly 64 hexadecimal characters ` + + `${envVar} must be exactly 64 hexadecimal characters ` + "(a 256-bit AES key). Generate one with `openssl rand -hex 32`.", ); } @@ -151,7 +164,20 @@ function requireEncryptionKeyBytes(): Uint8Array { `${ENCRYPTION_KEY_ENV_VAR} before starting the app.`, ); } - return parseEncryptionKeyHex(configured); + 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 { @@ -544,15 +570,31 @@ function assertBackend(backend: EncryptedKvBackend): void { } } +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 }; +} + class EnvelopeCipher { - readonly #key: Promise; + /** The first entry is the current key; every entry may decrypt. */ + readonly #keys: Promise; - constructor(keyBytes: Uint8Array) { - this.#key = crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ - "encrypt", - "decrypt", - ]); - keyBytes.fill(0); + constructor(keyRing: readonly Uint8Array[]) { + this.#keys = Promise.all(keyRing.map(importEnvelopeKey)); } async seal(storageKey: string, value: unknown): Promise { @@ -560,39 +602,81 @@ class EnvelopeCipher { 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) }, - await this.#key, + current.key, plaintext, ), ); const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); combined.set(iv); combined.set(ciphertext, iv.byteLength); - return ENVELOPE_PREFIX + bytesToBase64(combined); + 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"); } - if (!stored.startsWith(ENVELOPE_PREFIX)) { - throw new Error( - `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` + - "format. This store never reads plaintext credentials; re-authenticate " + - "affected users to replace legacy rows.", + 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 await this.#decrypt( + storageKey, + base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)), + match.key, ); } - const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length)); + if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) { + // v1 envelopes carry no key id, so try every configured key. + const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length)); + let lastFailure: unknown; + for (const entry of keys) { + try { + return await this.#decrypt(storageKey, combined, entry.key); + } 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) }, - await this.#key, + key, ciphertext, ); } catch (cause) { @@ -632,8 +716,13 @@ export function createEncryptedTokenStore( backend: EncryptedKvBackend, ): RefreshCapableTokenStore { assertBackend(backend); - const cipher = new EnvelopeCipher(requireEncryptionKeyBytes()); + 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, @@ -641,7 +730,16 @@ export function createEncryptedTokenStore( const key = tokensStorageKey(serviceId, userId); const raw = await backend.get(key); if (raw === null) return null; - return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) }; + try { + return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) }; + } catch (failure) { + console.warn( + `[Encrypted Token Store] Ignoring unreadable OAuth token row for ${key}: ` + + `${failure instanceof Error ? failure.message : String(failure)} ` + + "The integration is reported as disconnected; reconnecting overwrites the row.", + ); + return null; + } } return { diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 76988b8686..49ad903b25 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 * Keep the configured key while encrypted rows still use it. Replacing the\n * key makes existing rows unreadable. Delete affected rows and have users\n * reconnect before removing the old key; this v1 envelope does not support\n * decrypting with multiple keys during rotation.\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\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\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;\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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): Uint8Array {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${ENCRYPTION_KEY_ENV_VAR} 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);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\nclass EnvelopeCipher {\n readonly #key: Promise;\n\n constructor(keyBytes: Uint8Array) {\n this.#key = crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\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 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 await this.#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 + 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 if (!stored.startsWith(ENVELOPE_PREFIX)) {\n throw new Error(\n `Stored OAuth value is not in the ${ENVELOPE_PREFIX.slice(0, -1)} envelope ` +\n \"format. This store never reads plaintext credentials; re-authenticate \" +\n \"affected users to replace legacy rows.\",\n );\n }\n const combined = base64ToBytes(stored.slice(ENVELOPE_PREFIX.length));\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 await this.#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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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(requireEncryptionKeyBytes());\n\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\n } catch (failure) {\n console.warn(\n `[Encrypted Token Store] Ignoring unreadable OAuth token row for ${key}: ` +\n `${failure instanceof Error ? failure.message : String(failure)} ` +\n \"The integration is reported as disconnected; reconnecting overwrites the row.\",\n );\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From 19aef365ab049a37d1fb0f3d700cdfce76347a09 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:11:00 +0200 Subject: [PATCH 11/26] fix(cli): redact encrypted token row failures --- cli/encrypted-token-store-template.test.ts | 80 ++++++++++++++++++- .../_base/files/lib/encrypted-token-store.ts | 19 ++++- cli/templates/manifest.json | 2 +- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 868657c43e..3df798489b 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -57,11 +57,11 @@ function captureWarnings(): { warnings: string[]; [Symbol.dispose](): void } { }; } -/** Seal a value in the legacy v1 envelope (no key id) for compatibility tests. */ -async function sealLegacyV1( +/** Seal plaintext in the legacy v1 envelope (no key id) for compatibility tests. */ +async function sealLegacyV1Plaintext( keyHex: string, storageKey: string, - value: unknown, + plaintext: string, ): Promise { const keyBytes = new Uint8Array(32); for (let index = 0; index < keyBytes.length; index++) { @@ -73,7 +73,7 @@ async function sealLegacyV1( await crypto.subtle.encrypt( { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(storageKey) }, key, - new TextEncoder().encode(JSON.stringify(value)), + new TextEncoder().encode(plaintext), ), ); const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); @@ -84,6 +84,17 @@ async function sealLegacyV1( 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); +} + /** Expose the raw rows so tests can assert what actually hits storage. */ function inspectableBackend(): EncryptedKvBackend & { rows: Map } { const backend = createMemoryKvBackend(); @@ -338,6 +349,28 @@ describe("generated encrypted OAuth token store", () => { 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 () => { @@ -570,6 +603,45 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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 undefined; + }, + 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"); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index ecb8bde5f3..5afa9529cb 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -337,7 +337,7 @@ function requireJsonDataValue(value: unknown, label: string): unknown { if (prototype !== Object.prototype && prototype !== null) { throw new TypeError(`${label} must contain only plain JSON data objects`); } - const snapshot: Record = {}; + const snapshot: Record = Object.create(null); for (const key of Object.keys(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) { @@ -686,10 +686,21 @@ class EnvelopeCipher { { cause }, ); } - return JSON.parse(new TextDecoder().decode(plaintext)); + 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"; +} + /** * Build a `RefreshCapableTokenStore` over a durable key-value backend with * AES-256-GCM encryption at rest. @@ -734,8 +745,8 @@ export function createEncryptedTokenStore( return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) }; } catch (failure) { console.warn( - `[Encrypted Token Store] Ignoring unreadable OAuth token row for ${key}: ` + - `${failure instanceof Error ? failure.message : String(failure)} ` + + "[Encrypted Token Store] Ignoring unreadable OAuth token row " + + `(${unreadableTokenRowReason(failure)}). ` + "The integration is reported as disconnected; reconnecting overwrites the row.", ); return null; diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 49ad903b25..6a3a3ea0df 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = {};\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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 return JSON.parse(new TextDecoder().decode(plaintext));\n }\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\n } catch (failure) {\n console.warn(\n `[Encrypted Token Store] Ignoring unreadable OAuth token row for ${key}: ` +\n `${failure instanceof Error ? failure.message : String(failure)} ` +\n \"The integration is reported as disconnected; reconnecting overwrites the row.\",\n );\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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From 88f9bf10a39a0a81ba057903b3c2001e2865e7b7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:26:58 +0200 Subject: [PATCH 12/26] fix(cli): bound encrypted token JSON snapshots --- cli/encrypted-token-store-template.test.ts | 67 ++++++++ .../_base/files/lib/encrypted-token-store.ts | 157 +++++++++++------- cli/templates/manifest.json | 2 +- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 3df798489b..702b9cb2dd 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -603,6 +603,73 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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}'); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 5afa9529cb..e300e475e9 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -105,6 +105,12 @@ 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]+$/; @@ -311,8 +317,48 @@ function hasAsciiControlCharacter(value: string): boolean { return false; } -function requireJsonDataValue(value: unknown, label: string): unknown { - if (value === null || typeof value === "string" || typeof value === "boolean") { +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") { @@ -321,74 +367,73 @@ function requireJsonDataValue(value: unknown, label: string): unknown { } return value; } - if (Array.isArray(value)) { - return Array.from({ length: 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`); - } - return requireJsonDataValue(descriptor.value, label); - }); - } if (!value || typeof value !== "object") { throw new TypeError(`${label} must contain only JSON data values`); } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError(`${label} must contain only plain JSON data objects`); + if (state.depth >= MAX_JSON_NESTING_DEPTH) { + throw new RangeError( + `${label} exceeds the maximum JSON nesting depth of ${MAX_JSON_NESTING_DEPTH}`, + ); } - const snapshot: Record = Object.create(null); - for (const key of Object.keys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) { - throw new TypeError(`${label} must contain only own data values`); - } - snapshot[key] = requireJsonDataValue(descriptor.value, label); + if (state.ancestors.has(value)) { + throw new TypeError(`${label} must not contain cyclic JSON data`); } - return snapshot; -} + state.ancestors.add(value); + state.depth++; -function snapshotForJsonStringify(value: unknown, label: string): unknown { - if (value === null || typeof value === "string" || typeof value === "boolean") { - return value; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new TypeError(`${label} must contain only finite JSON numbers`); + 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; } - return value; - } - if (Array.isArray(value)) { - const snapshot = Array.from({ length: value.length }, (_, index) => { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + + 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`); } - return snapshotForJsonStringify(descriptor.value, label); - }); - Object.defineProperty(snapshot, "toJSON", { - configurable: true, - enumerable: false, - value: undefined, - }); - return snapshot; - } - if (!value || typeof value !== "object") { - throw new TypeError(`${label} must contain only JSON data values`); - } - const snapshot: Record = Object.create(null); - for (const key of Object.keys(value)) { - 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); } - snapshot[key] = snapshotForJsonStringify(descriptor.value, label); + return snapshot; + } finally { + state.depth--; + state.ancestors.delete(value); } - return snapshot; } function stringifyJsonData(value: unknown): string { - return JSON.stringify(snapshotForJsonStringify(value, "Stored OAuth value")); + return JSON.stringify(snapshotJsonData(value, "Stored OAuth value")); } function requireMetadata(value: unknown): Record | undefined { @@ -396,7 +441,7 @@ function requireMetadata(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError("Stored OAuth state metadata must be a plain object"); } - return requireJsonDataValue(value, "Stored OAuth state metadata") as Record; + return snapshotJsonData(value, "Stored OAuth state metadata") as Record; } function requireOptionalTokenString( diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 6a3a3ea0df..79df5a8d37 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\nfunction requireJsonDataValue(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n return Array.from({ length: 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 return requireJsonDataValue(descriptor.value, label);\n });\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\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 snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = requireJsonDataValue(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction snapshotForJsonStringify(value: unknown, label: string): unknown {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") {\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 (Array.isArray(value)) {\n const snapshot = Array.from({ length: 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 return snapshotForJsonStringify(descriptor.value, label);\n });\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n const snapshot: Record = Object.create(null);\n for (const key of Object.keys(value)) {\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] = snapshotForJsonStringify(descriptor.value, label);\n }\n return snapshot;\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotForJsonStringify(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 requireJsonDataValue(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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From fcaefb03d488e3bb6db920d012c336c4cfc984ff Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:28:53 +0200 Subject: [PATCH 13/26] test(cli): use valid null prototype sentinel --- cli/encrypted-token-store-template.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 702b9cb2dd..2bec68e14d 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -678,7 +678,7 @@ describe("generated encrypted OAuth token store", () => { Object.defineProperty(Object.prototype, "__proto__", { configurable: true, get() { - return undefined; + return null; }, set() { legacySetterCalls++; From aa4dea72e460e51011a1a9a55db1f9de6371ac8f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:45:45 +0200 Subject: [PATCH 14/26] Make encrypted OAuth rotation and state failures observable Generated integration stores can keep idle rows sealed with the previous key after a key rotation, and consumed OAuth state rows previously collapsed malformed, tampered, or wrong-key rows into silent nulls. Add a scan-based rotation report and sanitized state-row warning categories so operators have completion and failure signals without exposing OAuth state or token material. Constraint: Generated integration stores must not rewrite rows during reads. Constraint: Warnings must not include state, user, redirect, or metadata values. Rejected: Re-seal previous-key rows during getTokens/consumeState | surprising write-on-read and not guaranteed safe for all backends. Confidence: high Scope-risk: narrow Tested: RED import failure for missing checkEncryptedTokenStoreRotation; focused token/template tests; manifest generation checks; deno check; lint Not-tested: Full CLI suite green; broad parallel CLI run hit unrelated SOCKS network failure and cwd-sensitive start test. --- cli/encrypted-token-store-template.test.ts | 102 +++++++++++++- .../_base/files/lib/encrypted-token-store.ts | 124 +++++++++++++++++- cli/templates/manifest.json | 2 +- 3 files changed, 219 insertions(+), 9 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 2bec68e14d..092df3de32 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -9,6 +9,7 @@ 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, @@ -126,6 +127,20 @@ function inspectableBackend(): EncryptedKvBackend & { rows: Map }; } +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"); @@ -464,6 +479,38 @@ describe("generated encrypted OAuth token store", () => { }); }); + 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); + + assertEquals(await rotated.getTokens("github", "alice"), { + accessToken: "sealed-with-old-key", + }); + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 1, + currentKeyRows: 0, + previousKeyRows: 1, + unreadableRows: 0, + complete: false, + }); + + await rotated.setTokens("github", "alice", { accessToken: "resealed" }); + assertEquals(await checkEncryptedTokenStoreRotation(backend), { + scannedRows: 1, + currentKeyRows: 1, + 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"); @@ -739,24 +786,65 @@ describe("generated encrypted OAuth token store", () => { assertEquals(await store.consumeState("state-without-hooks"), state); }); - it("treats corrupted one-shot OAuth state as invalid", async () => { + 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); - await store.setState("corrupted-state", oauthState("alice")); - const entry = [...backend.rows.entries()][0]; - if (!entry) throw new Error("Expected a stored state row"); - const [key, stored] = entry; + 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( - key, + 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(await store.consumeState("corrupted-state"), null); + assertEquals(captured.warnings.length, 2); + assertEquals(captured.warnings[0]?.includes("failed authentication"), true); + assertEquals(captured.warnings[1]?.includes("unknown encryption key"), true); + for (const warning of captured.warnings) { + assertEquals(warning.includes("corrupted-state"), false); + assertEquals(warning.includes("wrong-key-state"), false); + assertEquals(warning.includes("alice"), false); + assertEquals(warning.includes("bob"), false); + assertEquals(warning.includes("sensitive-tenant"), false); + assertEquals(warning.includes("https://app.example.com"), false); + } }); it("rejects state rows outside the acceptance window", async () => { diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index e300e475e9..de0137c6fe 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -81,6 +81,23 @@ export interface EncryptedKvBackend { 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:"; @@ -710,6 +727,44 @@ class EnvelopeCipher { ); } + async classifyKeyUse(storageKey: string, stored: string): Promise<"current" | "previous"> { + 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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId); + if (keyIndex < 0) { + throw new Error("Encrypted OAuth value was sealed with an unknown encryption key"); + } + await this.#decrypt( + storageKey, + base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)), + keys[keyIndex]!.key, + ); + return keyIndex === 0 ? "current" : "previous"; + } + if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) { + const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length)); + let lastFailure: unknown; + for (let index = 0; index < keys.length; index++) { + try { + await this.#decrypt(storageKey, combined, keys[index]!.key); + return index === 0 ? "current" : "previous"; + } catch (failure) { + lastFailure = failure; + } + } + throw lastFailure; + } + throw new Error("Stored OAuth value is not in a vf-aes-gcm envelope format"); + } + async #decrypt( storageKey: string, combined: Uint8Array, @@ -746,6 +801,68 @@ function unreadableTokenRowReason(failure: unknown): string { 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. + */ +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, + }; + + 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 keyUse = await cipher.classifyKeyUse(row.key, row.value); + if (keyUse === "current") 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. @@ -879,7 +996,12 @@ export function createEncryptedTokenStore( try { const row = requireStateRow(await cipher.open(key, raw)); return isFreshState(row.createdAt, Date.now()) ? row : null; - } catch { + } 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/manifest.json b/cli/templates/manifest.json index 79df5a8d37..c80c2a606d 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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\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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch {\n return null;\n }\n },\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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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 classifyKeyUse(storageKey: string, stored: string): Promise<\"current\" | \"previous\"> {\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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId);\n if (keyIndex < 0) {\n throw new Error(\"Encrypted OAuth value was sealed with an unknown encryption key\");\n }\n await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n keys[keyIndex]!.key,\n );\n return keyIndex === 0 ? \"current\" : \"previous\";\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (let index = 0; index < keys.length; index++) {\n try {\n await this.#decrypt(storageKey, combined, keys[index]!.key);\n return index === 0 ? \"current\" : \"previous\";\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\"Stored OAuth value is not in a vf-aes-gcm envelope format\");\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 */\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\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 keyUse = await cipher.classifyKeyUse(row.key, row.value);\n if (keyUse === \"current\") 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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From 3c848a8335185dbe52e99b0671efa86a2228521a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 3 Aug 2026 15:54:38 +0200 Subject: [PATCH 15/26] feat(cli): re-seal rotated encrypted token rows on read - Opportunistically re-seal token rows decrypted with a non-current key (previous ring key or legacy v1 envelope) during reads, best-effort via compareAndSwap and preserving the revision, so key rotation converges for rows that are read but never rewritten. Complements the rotation scan report, which now flips to complete after a plain read; rotation docs in the module header updated accordingly. - Keep the legacy v1 envelope path: existing compat tests and the documented template behavior depend on it, and re-seal-on-read now upgrades v1 rows to v2 so the branch retires itself. - Add missing parse-branch tests (malformed v2 key id segment, v1 envelope undecryptable by every ring key) plus coverage for re-seal on read, best-effort re-seal failure, and compare-and-set through a re-sealed pre-read. Co-Authored-By: Claude Fable 5 --- cli/encrypted-token-store-template.test.ts | 165 +++++++++++++++++- .../_base/files/lib/encrypted-token-store.ts | 75 ++++++-- cli/templates/manifest.json | 2 +- 3 files changed, 223 insertions(+), 19 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 092df3de32..7c398e64f8 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -490,9 +490,8 @@ describe("generated encrypted OAuth token store", () => { Deno.env.set("TOKEN_ENCRYPTION_KEY_PREVIOUS", retiringKey); const rotated = createEncryptedTokenStore(backend); - assertEquals(await rotated.getTokens("github", "alice"), { - accessToken: "sealed-with-old-key", - }); + // 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, @@ -501,7 +500,11 @@ describe("generated encrypted OAuth token store", () => { complete: false, }); - await rotated.setTokens("github", "alice", { accessToken: "resealed" }); + // 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, @@ -535,6 +538,160 @@ describe("generated encrypted OAuth token store", () => { 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" }); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index de0137c6fe..2973204ac6 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -17,10 +17,18 @@ * 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 and are re-sealed with the new - * key the next time they are written. Legacy v1 envelopes (no key id) are - * decrypted by trying every configured key. Remove - * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it. + * 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 @@ -651,6 +659,16 @@ async function importEnvelopeKey(keyBytes: Uint8Array): Promise; @@ -682,7 +700,7 @@ class EnvelopeCipher { return ENVELOPE_PREFIX + current.keyId + ":" + bytesToBase64(combined); } - async open(storageKey: string, stored: string): Promise { + 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"); } @@ -701,19 +719,26 @@ class EnvelopeCipher { "or re-authenticate affected users.", ); } - return await this.#decrypt( - storageKey, - base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)), - match.key, - ); + 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. + // 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 await this.#decrypt(storageKey, combined, entry.key); + return { + value: await this.#decrypt(storageKey, combined, entry.key), + sealedWithCurrentKey: false, + }; } catch (failure) { lastFailure = failure; } @@ -904,7 +929,10 @@ export function createEncryptedTokenStore( const raw = await backend.get(key); if (raw === null) return null; try { - return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) }; + 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 " + @@ -915,6 +943,25 @@ export function createEncryptedTokenStore( } } + // 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; @@ -994,7 +1041,7 @@ export function createEncryptedTokenStore( const consumed = await backend.compareAndSwap(key, raw, null); if (!consumed) return null; try { - const row = requireStateRow(await cipher.open(key, raw)); + const row = requireStateRow((await cipher.open(key, raw)).value); return isFreshState(row.createdAt, Date.now()) ? row : null; } catch (failure) { console.warn( diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index c80c2a606d..2f761f688c 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 and are re-sealed with the new\n * key the next time they are written. Legacy v1 envelopes (no key id) are\n * decrypted by trying every configured key. Remove\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` once no rows still use it.\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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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\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 await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n );\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return await this.#decrypt(storageKey, combined, entry.key);\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 classifyKeyUse(storageKey: string, stored: string): Promise<\"current\" | \"previous\"> {\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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId);\n if (keyIndex < 0) {\n throw new Error(\"Encrypted OAuth value was sealed with an unknown encryption key\");\n }\n await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n keys[keyIndex]!.key,\n );\n return keyIndex === 0 ? \"current\" : \"previous\";\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (let index = 0; index < keys.length; index++) {\n try {\n await this.#decrypt(storageKey, combined, keys[index]!.key);\n return index === 0 ? \"current\" : \"previous\";\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\"Stored OAuth value is not in a vf-aes-gcm envelope format\");\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 */\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\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 keyUse = await cipher.classifyKeyUse(row.key, row.value);\n if (keyUse === \"current\") 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 return { key, raw, entry: requireTokenEntry(await cipher.open(key, raw)) };\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 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));\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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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 classifyKeyUse(storageKey: string, stored: string): Promise<\"current\" | \"previous\"> {\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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId);\n if (keyIndex < 0) {\n throw new Error(\"Encrypted OAuth value was sealed with an unknown encryption key\");\n }\n await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n keys[keyIndex]!.key,\n );\n return keyIndex === 0 ? \"current\" : \"previous\";\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (let index = 0; index < keys.length; index++) {\n try {\n await this.#decrypt(storageKey, combined, keys[index]!.key);\n return index === 0 ? \"current\" : \"previous\";\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\"Stored OAuth value is not in a vf-aes-gcm envelope format\");\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 */\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\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 keyUse = await cipher.classifyKeyUse(row.key, row.value);\n if (keyUse === \"current\") 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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From cec87137a06b7162283921eae741d30900cf03a7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:57:14 +0200 Subject: [PATCH 16/26] Keep rotation reports from trusting malformed encrypted rows Rotation scans previously classified a row after successful decryption alone. That let authenticated but schema-invalid token or state payloads count as current-key rows and report complete even though the store would reject them at read time. Reuse the normal token and state row validators during scan classification so malformed authenticated rows remain visible as unreadable rotation debt without logging their contents. Constraint: Rotation checks must not expose token, state, redirect, or metadata values. Constraint: Generated template changes require manifest regeneration. Rejected: Keep a key-only classifier and add ad hoc shape checks | duplicates the storage boundary and can drift from runtime validation. Confidence: high Scope-risk: narrow Tested: RED focused encrypted-store test with authenticated invalid token/state payloads; GREEN focused token/template suites; deno check; manifest checks; lint; git diff check Not-tested: Hosted CI completion after push. --- cli/encrypted-token-store-template.test.ts | 48 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 47 +++--------------- cli/templates/manifest.json | 2 +- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 7c398e64f8..04f4bb7856 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -96,6 +96,36 @@ async function sealLegacyV1( 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(); @@ -514,6 +544,24 @@ describe("generated encrypted OAuth token store", () => { }); }); + 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("keeps reading legacy v1 envelopes with any configured key", async () => { const backend = createMemoryKvBackend(); const legacyKey = Deno.env.get("TOKEN_ENCRYPTION_KEY"); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 2973204ac6..90e4c4c084 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -752,44 +752,6 @@ class EnvelopeCipher { ); } - async classifyKeyUse(storageKey: string, stored: string): Promise<"current" | "previous"> { - 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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId); - if (keyIndex < 0) { - throw new Error("Encrypted OAuth value was sealed with an unknown encryption key"); - } - await this.#decrypt( - storageKey, - base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)), - keys[keyIndex]!.key, - ); - return keyIndex === 0 ? "current" : "previous"; - } - if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) { - const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length)); - let lastFailure: unknown; - for (let index = 0; index < keys.length; index++) { - try { - await this.#decrypt(storageKey, combined, keys[index]!.key); - return index === 0 ? "current" : "previous"; - } catch (failure) { - lastFailure = failure; - } - } - throw lastFailure; - } - throw new Error("Stored OAuth value is not in a vf-aes-gcm envelope format"); - } - async #decrypt( storageKey: string, combined: Uint8Array, @@ -875,8 +837,13 @@ export async function checkEncryptedTokenStoreRotation( continue; } try { - const keyUse = await cipher.classifyKeyUse(row.key, row.value); - if (keyUse === "current") report.currentKeyRows++; + const opened = await cipher.open(row.key, row.value); + if (row.key.startsWith(TOKENS_KEY_PREFIX)) { + requireTokenEntry(opened.value); + } else { + requireStateRow(opened.value); + } + if (opened.sealedWithCurrentKey) report.currentKeyRows++; else report.previousKeyRows++; } catch { report.unreadableRows++; diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 2f761f688c..846ee47d83 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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 classifyKeyUse(storageKey: string, stored: string): Promise<\"current\" | \"previous\"> {\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 keyIndex = keys.findIndex((entry) => entry.keyId === keyId);\n if (keyIndex < 0) {\n throw new Error(\"Encrypted OAuth value was sealed with an unknown encryption key\");\n }\n await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n keys[keyIndex]!.key,\n );\n return keyIndex === 0 ? \"current\" : \"previous\";\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (let index = 0; index < keys.length; index++) {\n try {\n await this.#decrypt(storageKey, combined, keys[index]!.key);\n return index === 0 ? \"current\" : \"previous\";\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\"Stored OAuth value is not in a vf-aes-gcm envelope format\");\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 */\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\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 keyUse = await cipher.classifyKeyUse(row.key, row.value);\n if (keyUse === \"current\") 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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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 */\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\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 requireStateRow(opened.value);\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From c3a3ba6faaf583f8c3eac33cd44e1f1071b408f7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:08:27 +0200 Subject: [PATCH 17/26] fix(cli): keep encrypted token storage keys distinct --- cli/encrypted-token-store-template.test.ts | 42 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 10 +++++ cli/templates/manifest.json | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 04f4bb7856..8055127964 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -157,6 +157,36 @@ function inspectableBackend(): EncryptedKvBackend & { rows: Map }; } +/** 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 }>; } { @@ -320,6 +350,18 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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; diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 90e4c4c084..0844e9183c 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -283,6 +283,16 @@ function quoteJsonString(value: string): string { 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]; } diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 846ee47d83..f0d1a3021e 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 {\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 */\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\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 requireStateRow(opened.value);\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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 */\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\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 requireStateRow(opened.value);\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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", From dabe464e3aa2d1f96ed3d460232bd02f42c3cc18 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:11:36 +0200 Subject: [PATCH 18/26] fix(cli): preserve OAuth refresh capabilities --- cli/templates/index.test.ts | 17 ++++++++++ .../_base/files/lib/token-store.ts | 8 +++-- .../gmail/files/lib/gmail-client.ts | 28 ++-------------- cli/templates/manifest.json | 4 +-- cli/token-store-template.test.ts | 32 +++++++++++++++++++ 5 files changed, 60 insertions(+), 29 deletions(-) diff --git a/cli/templates/index.test.ts b/cli/templates/index.test.ts index ab992c702e..9e2016e6c6 100644 --- a/cli/templates/index.test.ts +++ b/cli/templates/index.test.ts @@ -485,6 +485,23 @@ describe("cli/templates", () => { ); }); + 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/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index a3e00da287..c69579408c 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -46,8 +46,12 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1_000; function runtimeMode(): string | undefined { if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; - return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno - ?.env?.get?.("NODE_ENV"); + try { + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.("NODE_ENV"); + } catch { + return undefined; + } } function allowsProcessLocalStorage(): boolean { 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 f0d1a3021e..95e3ab272a 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -118,7 +118,7 @@ "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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 */\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\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 requireStateRow(opened.value);\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\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 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/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 * 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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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" } @@ -305,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 2a84271642..1e82719f05 100644 --- a/cli/token-store-template.test.ts +++ b/cli/token-store-template.test.ts @@ -139,6 +139,38 @@ describe("generated OAuth token store", () => { } }); + 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("serializes concurrent refresh and persists it with compare-and-set", async () => { const store = createTokenStore(new MemoryTokenStore("refresh-concurrency")); await store.setTokens("github", "alice", { From f968a1e22e8616d81a8b8419f6d8083894826507 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:13:22 +0200 Subject: [PATCH 19/26] test(cli): tighten encryption rotation checks --- cli/encrypted-token-store-template.test.ts | 7 +++++++ .../integrations/_base/files/lib/encrypted-token-store.ts | 3 +++ cli/templates/manifest.json | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 8055127964..f9bbeb836f 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -624,6 +624,13 @@ describe("generated encrypted OAuth token store", () => { // 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" }); }); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 0844e9183c..5997375c67 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -822,6 +822,9 @@ function assertRotationScanBackend( * 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. + * `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, diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 95e3ab272a..4f4ce5716f 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 */\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\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 requireStateRow(opened.value);\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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 * `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\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 requireStateRow(opened.value);\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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", From 8fa1d4f5c4a498477ab106a83be0ffc8e75840fb Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:28:43 +0200 Subject: [PATCH 20/26] test(cli): assert exact sanitized token-store warnings --- cli/encrypted-token-store-template.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index f9bbeb836f..05e864586d 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -1088,17 +1088,12 @@ describe("generated encrypted OAuth token store", () => { 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.length, 2); - assertEquals(captured.warnings[0]?.includes("failed authentication"), true); - assertEquals(captured.warnings[1]?.includes("unknown encryption key"), true); - for (const warning of captured.warnings) { - assertEquals(warning.includes("corrupted-state"), false); - assertEquals(warning.includes("wrong-key-state"), false); - assertEquals(warning.includes("alice"), false); - assertEquals(warning.includes("bob"), false); - assertEquals(warning.includes("sensitive-tenant"), false); - assertEquals(warning.includes("https://app.example.com"), false); - } + 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 () => { From 5a0c75039e1edb5fa86364d50d7cc353a3518e62 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:21:39 +0200 Subject: [PATCH 21/26] Keep generated token stores fail-closed without env access Deno can throw when generated templates read environment variables without permission. The token store should treat that as an unset value so callers get the same fail-closed configuration errors as other missing-env cases. Constraint: Review comments called out denied Deno.env access in generated encrypted token store templates. Rejected: Propagate the permission error | it bypasses the generated store's deterministic configuration guidance. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts Tested: npx --yes deno@2.7.7 check cli/encrypted-token-store-template.test.ts Tested: npx --yes deno@2.7.7 fmt --check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/integrations/_base/files/lib/token-store-examples.ts Tested: npx --yes deno@2.7.7 lint cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/integrations/_base/files/lib/token-store-examples.ts Tested: git diff --check --- cli/encrypted-token-store-template.test.ts | 72 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 8 ++- .../_base/files/lib/token-store-examples.ts | 8 ++- cli/templates/manifest.json | 4 +- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 05e864586d..89a090cf3f 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -231,6 +231,46 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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("rejects malformed encryption keys instead of downgrading", () => { for (const bad of ["", "not-hex", "abcd", "zz".repeat(32)]) { Deno.env.set("TOKEN_ENCRYPTION_KEY", bad); @@ -1196,6 +1236,38 @@ describe("generated encrypted OAuth token store", () => { ); }); + 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("uses Deno runtime mode when process exists without env", () => { Deno.env.set("NODE_ENV", "development"); const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 5997375c67..f4d12a289f 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -155,8 +155,12 @@ const REQUIRED_BACKEND_METHODS = [ function readEnvironmentVariable(name: string): string | undefined { if (typeof process !== "undefined" && process.env) return process.env[name]; - return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) - .Deno?.env?.get?.(name); + 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. */ diff --git a/cli/templates/integrations/_base/files/lib/token-store-examples.ts b/cli/templates/integrations/_base/files/lib/token-store-examples.ts index e4913795eb..d5b3e602b3 100644 --- a/cli/templates/integrations/_base/files/lib/token-store-examples.ts +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -43,8 +43,12 @@ import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; function runtimeMode(): string | undefined { if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; - return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno - ?.env?.get?.("NODE_ENV"); + try { + return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }) + .Deno?.env?.get?.("NODE_ENV"); + } catch { + return undefined; + } } interface MemoryRow { diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 4f4ce5716f..406fc5412b 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,9 +115,9 @@ "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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\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 * `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\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 requireStateRow(opened.value);\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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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 * `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\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 requireStateRow(opened.value);\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } }).Deno\n ?.env?.get?.(\"NODE_ENV\");\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development-only 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-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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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" From d6f9b5e13c0caf4b23df851a484be00ace246de5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:37:52 +0200 Subject: [PATCH 22/26] Keep stale OAuth state out of rotation gates Rotation reports are used to decide when TOKEN_ENCRYPTION_KEY_PREVIOUS can be removed. Backends may ignore the OAuth state TTL hint, so expired one-shot state rows should not keep a rotation permanently incomplete after they are no longer consumable. Constraint: OAuth state freshness is already enforced by consumeState and setState; the scanner must match that boundary without deleting rows. Rejected: Delete expired rows during scan | a rotation report should not mutate backend state. Confidence: high Scope-risk: narrow Directive: Count malformed or undecryptable state rows as unreadable, but skip schema-valid expired state rows after authenticated decrypt. Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts cli/token-store-template.test.ts cli/templates/index.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check Tested: npx --yes deno@2.7.7 fmt --check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts cli/templates/manifest.json Tested: npx --yes deno@2.7.7 lint cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: npx --yes deno@2.7.7 check cli/encrypted-token-store-template.test.ts cli/templates/integrations/_base/files/lib/encrypted-token-store.ts Tested: git diff --check --- cli/encrypted-token-store-template.test.ts | 25 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 6 ++++- cli/templates/manifest.json | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index 89a090cf3f..cb3343f6c9 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -644,6 +644,31 @@ describe("generated encrypted OAuth token store", () => { }); }); + 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"); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index f4d12a289f..8427c5624b 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -826,6 +826,8 @@ function assertRotationScanBackend( * 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. @@ -842,6 +844,7 @@ export async function checkEncryptedTokenStoreRotation( 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)) { @@ -858,7 +861,8 @@ export async function checkEncryptedTokenStoreRotation( if (row.key.startsWith(TOKENS_KEY_PREFIX)) { requireTokenEntry(opened.value); } else { - requireStateRow(opened.value); + const state = requireStateRow(opened.value); + if (!isFreshState(state.createdAt, scanNow)) continue; } if (opened.sealedWithCurrentKey) report.currentKeyRows++; else report.previousKeyRows++; diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 406fc5412b..15f16392de 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,7 +115,7 @@ "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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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 * `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\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 requireStateRow(opened.value);\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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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", From 24ade6426adf6852d33f7edb16170dd7dd1a9be3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:54:48 +0200 Subject: [PATCH 23/26] Align token-store template guidance with test mode The generated token-store header still described the process-local memory store as development-only after the runtime guard intentionally allowed both development and test. Update the template copy and add a template-contract assertion so future guard wording changes stay synchronized with generated guidance. Constraint: PR #3310 allows process-local OAuth storage only in explicit development or test modes. Rejected: Runtime behavior change | the implementation already enforces the intended development/test guard. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts cli/token-store-template.test.ts cli/templates/index.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check Tested: npx --yes deno@2.7.7 fmt --check cli/templates/integrations/_base/files/lib/token-store.ts cli/templates/index.test.ts cli/templates/manifest.json && npx --yes deno@2.7.7 lint cli/templates/integrations/_base/files/lib/token-store.ts cli/templates/index.test.ts && npx --yes deno@2.7.7 check cli/templates/integrations/_base/files/lib/token-store.ts cli/templates/index.test.ts && git diff --check --- cli/templates/index.test.ts | 5 +++++ cli/templates/integrations/_base/files/lib/token-store.ts | 2 +- cli/templates/manifest.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/templates/index.test.ts b/cli/templates/index.test.ts index 9e2016e6c6..bbffc825fb 100644 --- a/cli/templates/index.test.ts +++ b/cli/templates/index.test.ts @@ -424,6 +424,11 @@ describe("cli/templates", () => { 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 header should match the development/test memory-store guard", + ); assertEquals( tokenStore.includes("getDefaultTokenStore"), true, diff --git a/cli/templates/integrations/_base/files/lib/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index c69579408c..ce087721c9 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -4,7 +4,7 @@ * 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 diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 15f16392de..019223c363 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -118,7 +118,7 @@ "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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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-only 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 development-only.\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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/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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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" } From 6c974b2c9fb7d9b599b228d813b13aecbe6215fd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 18:07:46 +0200 Subject: [PATCH 24/26] Preserve refresh tokens omitted by OAuth providers Some OAuth refresh endpoints return a fresh access token without echoing the durable refresh token. The generated lock-aware helper now carries the current refresh token forward before its revisioned compare-and-set write, so the next refresh attempt remains possible without weakening CAS protection. Constraint: Generated templates must remain durable across provider refresh responses that omit refresh_token. Rejected: Require every generated provider refresh helper to backfill refresh_token | duplicates a central store invariant across clients. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts cli/token-store-template.test.ts cli/templates/index.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check Tested: npx --yes deno@2.7.7 fmt --check cli/templates/integrations/_base/files/lib/token-store.ts cli/token-store-template.test.ts cli/templates/manifest.json Tested: npx --yes deno@2.7.7 lint cli/templates/integrations/_base/files/lib/token-store.ts cli/token-store-template.test.ts Tested: npx --yes deno@2.7.7 check cli/templates/integrations/_base/files/lib/token-store.ts cli/token-store-template.test.ts Tested: git diff --check --- .../_base/files/lib/token-store.ts | 4 ++++ cli/templates/manifest.json | 2 +- cli/token-store-template.test.ts | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cli/templates/integrations/_base/files/lib/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index ce087721c9..6ae97a9f50 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -198,6 +198,10 @@ export async function getRefreshableAccessToken( return latest ? unexpiredAccessToken(latest) : null; } + if (refreshed.refreshToken === undefined) { + refreshed = { ...refreshed, refreshToken: token.refreshToken }; + } + const replaced = await store.compareAndSetTokens( serviceId, userId, diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 019223c363..22fbf0265a 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -118,7 +118,7 @@ "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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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-only 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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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 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/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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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" } diff --git a/cli/token-store-template.test.ts b/cli/token-store-template.test.ts index 1e82719f05..955011b852 100644 --- a/cli/token-store-template.test.ts +++ b/cli/token-store-template.test.ts @@ -199,6 +199,29 @@ describe("generated OAuth token store", () => { 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", { From 67dc85d18ee7b0edd5dd78229956fefa75d48c17 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 18:08:47 +0200 Subject: [PATCH 25/26] Keep generated token stores fail-closed under process env denial --- cli/encrypted-token-store-template.test.ts | 58 +++++++++++++++++++ .../_base/files/lib/encrypted-token-store.ts | 9 ++- .../_base/files/lib/token-store-examples.ts | 8 ++- .../_base/files/lib/token-store.ts | 8 ++- cli/templates/manifest.json | 6 +- cli/token-store-template.test.ts | 25 ++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/cli/encrypted-token-store-template.test.ts b/cli/encrypted-token-store-template.test.ts index cb3343f6c9..4d2e45a819 100644 --- a/cli/encrypted-token-store-template.test.ts +++ b/cli/encrypted-token-store-template.test.ts @@ -271,6 +271,39 @@ describe("generated encrypted OAuth token store", () => { } }); + 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); @@ -1293,6 +1326,31 @@ describe("generated encrypted OAuth token store", () => { } }); + 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"); diff --git a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts index 8427c5624b..acf2e1bc93 100644 --- a/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts +++ b/cli/templates/integrations/_base/files/lib/encrypted-token-store.ts @@ -154,7 +154,14 @@ const REQUIRED_BACKEND_METHODS = [ ] as const; function readEnvironmentVariable(name: string): string | undefined { - if (typeof process !== "undefined" && process.env) return process.env[name]; + 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); diff --git a/cli/templates/integrations/_base/files/lib/token-store-examples.ts b/cli/templates/integrations/_base/files/lib/token-store-examples.ts index d5b3e602b3..3a5ffb4e58 100644 --- a/cli/templates/integrations/_base/files/lib/token-store-examples.ts +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -42,7 +42,13 @@ import type { EncryptedKvBackend } from "./encrypted-token-store.ts"; function runtimeMode(): string | undefined { - if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; + 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"); diff --git a/cli/templates/integrations/_base/files/lib/token-store.ts b/cli/templates/integrations/_base/files/lib/token-store.ts index 6ae97a9f50..3a379fac0a 100644 --- a/cli/templates/integrations/_base/files/lib/token-store.ts +++ b/cli/templates/integrations/_base/files/lib/token-store.ts @@ -45,7 +45,13 @@ const REQUIRED_STORE_METHODS = [ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1_000; function runtimeMode(): string | undefined { - if (typeof process !== "undefined" && process.env) return process.env.NODE_ENV; + 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"); diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 22fbf0265a..131a6c89d1 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -115,10 +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/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 if (typeof process !== \"undefined\" && process.env) return process.env[name];\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/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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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-only 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 if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\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/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-only 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" } diff --git a/cli/token-store-template.test.ts b/cli/token-store-template.test.ts index 955011b852..f0993c4eee 100644 --- a/cli/token-store-template.test.ts +++ b/cli/token-store-template.test.ts @@ -171,6 +171,31 @@ describe("generated OAuth token store", () => { } }); + 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", { From aeafe6692e2e592a85a0a2dbab0e4a2dee38044f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 18:28:39 +0200 Subject: [PATCH 26/26] Align generated memory-backend guidance with test mode The generated encrypted-token-store example allows its in-memory backend in explicit development and test modes, but one public docstring still described it as development-only. Align the generated guidance with the runtime guard and pin the wording in the template inventory test. Constraint: The runtime behavior already allows both development and test; this change must not alter storage selection. Rejected: Change the guard to development-only | existing tests intentionally allow generated integration tests to use the backend under NODE_ENV=test. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net cli/encrypted-token-store-template.test.ts cli/token-store-template.test.ts cli/templates/index.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/generate-templates-manifest.ts --check Tested: npx --yes deno@2.7.7 fmt --check cli/templates/integrations/_base/files/lib/token-store-examples.ts cli/templates/index.test.ts cli/templates/manifest.json Tested: npx --yes deno@2.7.7 check --allow-import cli/templates/index.test.ts cli/token-store-template.test.ts cli/encrypted-token-store-template.test.ts Tested: git diff --check --- cli/templates/index.test.ts | 10 ++++++++++ .../_base/files/lib/token-store-examples.ts | 2 +- cli/templates/manifest.json | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/templates/index.test.ts b/cli/templates/index.test.ts index bbffc825fb..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"), @@ -429,6 +434,11 @@ describe("cli/templates", () => { true, "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"), true, diff --git a/cli/templates/integrations/_base/files/lib/token-store-examples.ts b/cli/templates/integrations/_base/files/lib/token-store-examples.ts index 3a5ffb4e58..6252a6bd28 100644 --- a/cli/templates/integrations/_base/files/lib/token-store-examples.ts +++ b/cli/templates/integrations/_base/files/lib/token-store-examples.ts @@ -63,7 +63,7 @@ interface MemoryRow { } /** - * Development-only in-memory backend. Values are still encrypted (the store + * 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. */ diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 131a6c89d1..60c1d6ee96 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -117,7 +117,7 @@ "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/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-only 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-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"