From 08ea52a522edd0ef17d93827601f05e2d1da84c5 Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:59:29 -0400 Subject: [PATCH 1/4] refactor: tighten shared code boundaries --- agent/agent.ts | 4 +- agent/channels/linq.ts | 4 +- agent/lib/google-workspace/client.ts | 6 +- agent/lib/linq-browser-image-delivery.ts | 78 ++++++- agent/lib/linq-browser-image-markdown.ts | 32 +++ agent/subagents/worker/agent.ts | 6 +- .../worker/tools/capture_browser_image.ts | 128 ++++++++++- .../subagents/worker/tools/fill_from_vault.ts | 8 +- agent/subagents/worker/tools/list_vault.ts | 2 +- agent/tools/request_vault_import.ts | 2 +- agent/tools/request_vault_setup.ts | 2 +- db/services/browser-images.ts | 2 +- db/services/settings.ts | 7 +- db/services/vault.ts | 2 +- evals/browser/README.md | 8 +- evals/browser/benchmark-reporter.ts | 10 +- evals/browser/browser.eval.ts | 11 +- .../browser/tasks.ts | 0 package.json | 1 - pnpm-lock.yaml | 8 - .../_components/google-workspace-action.tsx | 2 +- .../(manager)/_components/model-selector.tsx | 4 +- src/app/(authenticated)/(manager)/page.tsx | 4 +- .../vault/_components/address-vault-form.tsx | 4 +- .../_components/chrome-password-import.tsx | 4 +- .../vault/_components/contact-vault-form.tsx | 4 +- .../vault/_components/login-vault-form.tsx | 4 +- .../vault/_components/payment-card-form.tsx | 4 +- .../vault/_components/vault-manager.tsx | 2 +- .../(authenticated)/(manager)/vault/page.tsx | 4 +- .../(tasks)/_lib/task-history.test.ts | 2 +- .../(tasks)/_lib/task-history.ts | 15 +- .../_components/browser-run-detail.tsx | 20 +- .../tasks/_components/browser-batch-form.tsx | 19 +- .../tasks/_components/global-task-history.tsx | 5 +- .../(authenticated)/(tasks)/tasks/page.tsx | 2 +- src/app/artifacts/[artifactId]/route.ts | 37 +++- src/components/ai-elements/message.tsx | 2 +- src/lib/browser-artifact.ts | 83 ++++++++ src/lib/browser-image-path.ts | 11 - src/lib/browser-images.ts | 152 ------------- src/lib/browser-images/server.ts | 201 ------------------ .../config.ts => google-workspace.ts} | 7 +- src/lib/google-workspace/server.ts | 54 ----- src/lib/model-catalog.ts | 16 -- src/lib/model-catalog/server.ts | 29 --- src/lib/model-config.ts | 8 - src/lib/task-history.ts | 24 --- src/lib/task-history/server.ts | 55 ----- ...ask-completion.ts => worker-completion.ts} | 9 +- .../benchmark.ts => worker-events.ts} | 15 +- .../manager/chrome-passwords.ts | 0 src/{lib => modules}/manager/index.ts | 0 src/{lib => modules}/manager/payment-card.ts | 0 .../manager/server/kernel-login-autofill.ts | 0 .../manager/server/kernel-native-autofill.ts | 2 +- .../manager/server/secret-store.ts | 2 +- src/{lib => modules}/manager/server/store.ts | 45 +++- .../manager/server/vault-autofill-provider.ts | 0 .../manager/server/vault-autofill.ts | 2 +- src/{lib => modules}/manager/server/vault.ts | 0 .../manager/vault-autofill-protocol.ts | 0 .../manager/vault-autofill.ts | 0 src/{lib => modules}/manager/vault-payload.ts | 0 src/trpc/router.test.ts | 122 ++++++++--- src/trpc/router.ts | 141 +++++++++++- src/trpc/types.ts | 4 + tests/agent-tool-boundaries.test.ts | 2 +- tests/browser-benchmark.test.ts | 21 +- tests/browser-image-route.test.ts | 46 ++-- tests/browser-image-storage.test.ts | 189 ---------------- tests/browser-images.test.ts | 22 +- tests/google-workspace.test.ts | 61 ++---- tests/linq-browser-image-delivery.test.ts | 25 ++- tests/linq-message-delivery.test.ts | 51 ++++- tests/manager-setup.test.ts | 8 +- tests/payment-card.test.ts | 2 +- tests/services.test.ts | 4 +- tests/source-layout.test.ts | 72 +++++++ tests/vault-autofill.test.ts | 14 +- tests/vault-payload.test.ts | 2 +- tests/worker-browser-images.test.ts | 24 +-- vitest.config.ts | 2 +- 83 files changed, 938 insertions(+), 1048 deletions(-) create mode 100644 agent/lib/linq-browser-image-markdown.ts rename src/lib/browser/benchmark-tasks.ts => evals/browser/tasks.ts (100%) create mode 100644 src/lib/browser-artifact.ts delete mode 100644 src/lib/browser-image-path.ts delete mode 100644 src/lib/browser-images.ts delete mode 100644 src/lib/browser-images/server.ts rename src/lib/{google-workspace/config.ts => google-workspace.ts} (77%) delete mode 100644 src/lib/google-workspace/server.ts delete mode 100644 src/lib/model-catalog.ts delete mode 100644 src/lib/model-catalog/server.ts delete mode 100644 src/lib/model-config.ts delete mode 100644 src/lib/task-history.ts delete mode 100644 src/lib/task-history/server.ts rename src/lib/{task-completion.ts => worker-completion.ts} (82%) rename src/lib/{browser/benchmark.ts => worker-events.ts} (95%) rename src/{lib => modules}/manager/chrome-passwords.ts (100%) rename src/{lib => modules}/manager/index.ts (100%) rename src/{lib => modules}/manager/payment-card.ts (100%) rename src/{lib => modules}/manager/server/kernel-login-autofill.ts (100%) rename src/{lib => modules}/manager/server/kernel-native-autofill.ts (99%) rename src/{lib => modules}/manager/server/secret-store.ts (97%) rename src/{lib => modules}/manager/server/store.ts (69%) rename src/{lib => modules}/manager/server/vault-autofill-provider.ts (100%) rename src/{lib => modules}/manager/server/vault-autofill.ts (95%) rename src/{lib => modules}/manager/server/vault.ts (100%) rename src/{lib => modules}/manager/vault-autofill-protocol.ts (100%) rename src/{lib => modules}/manager/vault-autofill.ts (100%) rename src/{lib => modules}/manager/vault-payload.ts (100%) create mode 100644 src/trpc/types.ts delete mode 100644 tests/browser-image-storage.test.ts create mode 100644 tests/source-layout.test.ts diff --git a/agent/agent.ts b/agent/agent.ts index c2701e28..1e6a4d1e 100644 --- a/agent/agent.ts +++ b/agent/agent.ts @@ -1,6 +1,6 @@ import { defineAgent, defineDynamic } from "eve"; +import { getGatewayModel } from "@/db/services/settings"; import { scopeFromPrincipal } from "@/lib/access-scope"; -import { getModelSettings } from "@/lib/model-config"; export default defineAgent({ experimental: { @@ -11,7 +11,7 @@ export default defineAgent({ "step.started": async (_event, ctx) => { const caller = ctx.session.auth.current ?? ctx.session.auth.initiator; if (!caller) throw new Error("An authenticated user is required."); - return (await getModelSettings(scopeFromPrincipal(caller))).modelId; + return getGatewayModel(scopeFromPrincipal(caller)); }, }, }), diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index 72e73dfa..92403363 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -10,13 +10,13 @@ import { z } from "zod"; import { auth } from "@/auth"; import { normalizeAuthPhoneNumber } from "@/auth/phone-number"; import { accessScopeForUser, scopeFromPrincipal } from "@/lib/access-scope"; +import { prepareLinqBrowserImageDelivery } from "../lib/linq-browser-image-delivery"; import { extractBrowserImageMarkdownReferences, stripBrowserImageMarkdownReferences, -} from "@/lib/browser-images"; +} from "../lib/linq-browser-image-markdown"; import { env } from "@/lib/env"; import { consumeWorkerCancellationTurn } from "../lib/worker-cancellation-delivery"; -import { prepareLinqBrowserImageDelivery } from "../lib/linq-browser-image-delivery"; const verifiedPhoneUserSchema = z.object({ id: z.string().min(1), diff --git a/agent/lib/google-workspace/client.ts b/agent/lib/google-workspace/client.ts index cea6c3ed..a224c99e 100644 --- a/agent/lib/google-workspace/client.ts +++ b/agent/lib/google-workspace/client.ts @@ -4,8 +4,8 @@ import type { ToolContext } from "eve/tools"; import { env } from "@/lib/env"; import { googleWorkspaceSubject, - GOOGLE_WORKSPACE_SCOPES, -} from "@/lib/google-workspace/config"; + googleWorkspaceScopes, +} from "@/lib/google-workspace"; export const googleWorkspaceAuthOptions = { connector: env.GOOGLE_CONNECTOR_UID, @@ -17,7 +17,7 @@ export const googleWorkspaceAuthOptions = { } return googleWorkspaceSubject(principal.id); }, - tokenParams: { scopes: [...GOOGLE_WORKSPACE_SCOPES] }, + tokenParams: { scopes: [...googleWorkspaceScopes] }, validate: true, } satisfies EveAuthorizationOptions; diff --git a/agent/lib/linq-browser-image-delivery.ts b/agent/lib/linq-browser-image-delivery.ts index efec7390..aecf150f 100644 --- a/agent/lib/linq-browser-image-delivery.ts +++ b/agent/lib/linq-browser-image-delivery.ts @@ -1,10 +1,14 @@ +import { createHash } from "node:crypto"; +import { get } from "@vercel/blob"; import type { AccessScope } from "@/lib/access-scope"; +import { readReadyBrowserImageArtifact } from "@/db/services/browser-images"; +import { maximumBrowserImageBytes } from "@/lib/browser-artifact"; +import { env } from "@/lib/env"; +import { maximumWorkerCompletionImages } from "@/lib/worker-completion"; import { extractBrowserImageMarkdownReferences, - maximumBrowserImagesPerCompletion, stripBrowserImageMarkdownReferences, -} from "@/lib/browser-images"; -import { readBrowserImageBytes } from "@/lib/browser-images/server"; +} from "./linq-browser-image-markdown"; interface LinqBrowserImageFile { readonly data: Buffer; @@ -25,10 +29,10 @@ export async function prepareLinqBrowserImageDelivery( return { failedArtifactIds: [], files: [], markdown: message }; } - const selected = references.slice(0, maximumBrowserImagesPerCompletion); + const selected = references.slice(0, maximumWorkerCompletionImages); const loaded = await Promise.all( selected.map(async (reference) => ({ - image: await readBrowserImageBytes(input.scope, reference.id, { + image: await readLinqBrowserImage(input.scope, reference.id, { rootSessionId: input.rootSessionId, signal: input.signal, }).catch(() => undefined), @@ -40,7 +44,7 @@ export async function prepareLinqBrowserImageDelivery( .filter((item) => item.image === undefined) .map((item) => item.reference.id), ...references - .slice(maximumBrowserImagesPerCompletion) + .slice(maximumWorkerCompletionImages) .map((reference) => reference.id), ]; const files = loaded.flatMap(({ image }) => @@ -61,3 +65,65 @@ export async function prepareLinqBrowserImageDelivery( markdown: stripBrowserImageMarkdownReferences(message), }; } + +async function readLinqBrowserImage( + scope: AccessScope, + artifactId: string, + options: { readonly rootSessionId: string; readonly signal?: AbortSignal } +) { + const artifact = await readReadyBrowserImageArtifact(scope, artifactId, { + rootSessionId: options.rootSessionId, + }); + if ( + !artifact?.byteSize || + !artifact.contentHash || + !artifact.filename || + !artifact.mediaType + ) + return; + const blobAuth = env.BLOB_STORE_ID + ? { storeId: env.BLOB_STORE_ID } + : env.BLOB_READ_WRITE_TOKEN + ? { token: env.BLOB_READ_WRITE_TOKEN } + : undefined; + if (!blobAuth) return; + const result = await get(artifact.storagePathname, { + ...blobAuth, + access: "private", + abortSignal: options.signal, + }); + if (result?.statusCode !== 200) return; + if ( + result.blob.size !== artifact.byteSize || + result.blob.contentType !== artifact.mediaType + ) + return; + const reader = result.stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maximumBrowserImageBytes) return; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + if (createHash("sha256").update(bytes).digest("hex") !== artifact.contentHash) + return; + return { + bytes, + filename: artifact.filename, + id: artifact.id, + mediaType: artifact.mediaType, + }; +} diff --git a/agent/lib/linq-browser-image-markdown.ts b/agent/lib/linq-browser-image-markdown.ts new file mode 100644 index 00000000..5526ec75 --- /dev/null +++ b/agent/lib/linq-browser-image-markdown.ts @@ -0,0 +1,32 @@ +import { isBrowserImageArtifactUrl } from "@/lib/browser-artifact"; + +const browserImageMarkdownPattern = + /!\[((?:\\.|[^\]])*)\]\((\/artifacts\/([^\s)]+))\)/giu; + +export function extractBrowserImageMarkdownReferences(message: string) { + const references: { + readonly id: string; + readonly label: string; + readonly markdown: string; + readonly url: string; + }[] = []; + const seen = new Set(); + + for (const match of message.matchAll(browserImageMarkdownPattern)) { + const [markdown, label, url, id] = match; + if (!markdown || !url || !id || seen.has(id)) continue; + if (!isBrowserImageArtifactUrl(url)) continue; + seen.add(id); + references.push({ id, label: label ?? "", markdown, url }); + } + + return references; +} + +export function stripBrowserImageMarkdownReferences(message: string) { + return message + .replace(browserImageMarkdownPattern, "") + .replace(/[ \t]+\n/gu, "\n") + .replace(/\n{3,}/gu, "\n\n") + .trim(); +} diff --git a/agent/subagents/worker/agent.ts b/agent/subagents/worker/agent.ts index ac396dab..f59121db 100644 --- a/agent/subagents/worker/agent.ts +++ b/agent/subagents/worker/agent.ts @@ -1,7 +1,7 @@ import { defineAgent, defineDynamic } from "eve"; +import { getGatewayModel } from "@/db/services/settings"; import { scopeFromPrincipal } from "@/lib/access-scope"; -import { getModelSettings } from "@/lib/model-config"; -import { taskCompletionSchema } from "@/lib/task-completion"; +import { taskCompletionSchema } from "@/lib/worker-completion"; export default defineAgent({ description: @@ -11,7 +11,7 @@ export default defineAgent({ "turn.started": async (_event, ctx) => { const caller = ctx.session.auth.current ?? ctx.session.auth.initiator; if (!caller) throw new Error("An authenticated user is required."); - return (await getModelSettings(scopeFromPrincipal(caller))).modelId; + return getGatewayModel(scopeFromPrincipal(caller)); }, }, }), diff --git a/agent/subagents/worker/tools/capture_browser_image.ts b/agent/subagents/worker/tools/capture_browser_image.ts index 30e684b9..985d2f8c 100644 --- a/agent/subagents/worker/tools/capture_browser_image.ts +++ b/agent/subagents/worker/tools/capture_browser_image.ts @@ -1,19 +1,21 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { del, put } from "@vercel/blob"; import { defineTool, toolOutput } from "eve/tools"; import { z } from "zod"; import { requireWorkerScope } from "@/agent/subagents/worker/lib/access"; import { requireOwnedBrowserSession } from "@/agent/subagents/worker/lib/owned-browser"; import { withVaultScreenshotMask } from "@/agent/subagents/worker/lib/vault-screenshot-mask"; -import { reserveBrowserImageArtifact } from "@/db/services/browser-images"; +import { + finalizeBrowserImageArtifact, + reserveBrowserImageArtifact, + type BrowserImageArtifactReservation, +} from "@/db/services/browser-images"; import { browserImageArtifactReferenceSchema, - safeBrowserImageFilename, + maximumBrowserImageBytes, sniffBrowserImageMediaType, -} from "@/lib/browser-images"; -import { - persistReservedBrowserImage, - readBoundedResponse, -} from "@/lib/browser-images/server"; +} from "@/lib/browser-artifact"; +import { env } from "@/lib/env"; import { kernel } from "@/lib/kernel"; const regionSchema = z.object({ @@ -80,7 +82,7 @@ export default defineTool({ "The captured resource is not a supported browser image." ); } - const image = await persistReservedBrowserImage( + const image = await persistCapturedImage( scope, reserved.reservation, { @@ -257,3 +259,111 @@ await target.screenshot({ animations: "disabled", caret: "hide", path: ${JSON.st } }); } + +function safeBrowserImageFilename( + label: string, + mediaType: NonNullable> +) { + const extension = { + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + }[mediaType]; + const stem = label + .normalize("NFKD") + .replace(/(?:\.\.[/\\])+/gu, "") + .replace(/\p{Cc}+/gu, "") + .replace(/[^\p{L}\p{N}._() -]+/gu, "_") + .replace(/\s+/gu, " ") + .trim() + .replace(/^\.+|\.+$/gu, "") + .slice(0, 160); + return `${stem || "browser-image"}.${extension}`; +} + +async function persistCapturedImage( + scope: Awaited>, + reservation: BrowserImageArtifactReservation, + input: { + readonly bytes: Uint8Array; + readonly filename: string; + readonly sourceKind: string; + }, + signal?: AbortSignal +) { + const mediaType = sniffBrowserImageMediaType(input.bytes); + if (!mediaType) + throw new Error("The captured resource is not a supported browser image."); + const contentHash = createHash("sha256").update(input.bytes).digest("hex"); + const storagePathname = `${reservation.storagePathname}/${contentHash}`; + const blobAuth = env.BLOB_STORE_ID + ? { storeId: env.BLOB_STORE_ID } + : env.BLOB_READ_WRITE_TOKEN + ? { token: env.BLOB_READ_WRITE_TOKEN } + : undefined; + if (!blobAuth) throw new Error("Browser image storage is not configured."); + + await put(storagePathname, Buffer.from(input.bytes), { + ...blobAuth, + access: "private", + abortSignal: signal, + addRandomSuffix: false, + allowOverwrite: true, + cacheControlMaxAge: 30 * 24 * 60 * 60, + contentType: mediaType, + maximumSizeInBytes: maximumBrowserImageBytes, + }); + try { + const finalized = await finalizeBrowserImageArtifact(scope, reservation, { + byteSize: input.bytes.byteLength, + contentHash, + filename: input.filename, + mediaType, + sourceKind: input.sourceKind, + storagePathname, + }); + if (finalized.storagePathname !== storagePathname) { + await del(storagePathname, blobAuth).catch(() => undefined); + } + return finalized.image; + } catch (error) { + await del(storagePathname, blobAuth).catch(() => undefined); + throw error; + } +} + +async function readBoundedResponse(response: Response) { + const contentLength = Number(response.headers.get("content-length")); + if ( + Number.isFinite(contentLength) && + contentLength > maximumBrowserImageBytes + ) { + throw new Error("The browser image exceeds the maximum size."); + } + if (!response.body) throw new Error("The browser image response is empty."); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maximumBrowserImageBytes) { + await reader.cancel(); + throw new Error("The browser image exceeds the maximum size."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} diff --git a/agent/subagents/worker/tools/fill_from_vault.ts b/agent/subagents/worker/tools/fill_from_vault.ts index 1c5b9e68..0a797707 100644 --- a/agent/subagents/worker/tools/fill_from_vault.ts +++ b/agent/subagents/worker/tools/fill_from_vault.ts @@ -4,14 +4,14 @@ import { requireOwnedBrowserSession } from "@/agent/subagents/worker/lib/owned-b import { requireWorkerScope } from "@/agent/subagents/worker/lib/access"; import { readVaultItem } from "@/db/services/vault"; import { kernel } from "@/lib/kernel"; -import { materializeAutofillClaims } from "@/lib/manager/server/vault-autofill"; -import { vaultAutofillProvider } from "@/lib/manager/server/vault-autofill-provider"; +import { materializeAutofillClaims } from "@/modules/manager/server/vault-autofill"; +import { vaultAutofillProvider } from "@/modules/manager/server/vault-autofill-provider"; import { currentKernelPageOrigin, fillWithKernelNativeAutofill, nativeAutofillTokens, -} from "@/lib/manager/server/kernel-native-autofill"; -import { fillFromVaultRequestSchema } from "@/lib/manager/vault-autofill"; +} from "@/modules/manager/server/kernel-native-autofill"; +import { fillFromVaultRequestSchema } from "@/modules/manager/vault-autofill"; const outputSchema = z.object({ filledClaims: z.number().int().nonnegative(), diff --git a/agent/subagents/worker/tools/list_vault.ts b/agent/subagents/worker/tools/list_vault.ts index f9edebf6..ed8899e2 100644 --- a/agent/subagents/worker/tools/list_vault.ts +++ b/agent/subagents/worker/tools/list_vault.ts @@ -1,6 +1,6 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; -import { readManagerVaultItems } from "@/lib/manager/server/vault"; +import { readManagerVaultItems } from "@/modules/manager/server/vault"; import { requireWorkerScope } from "@/agent/subagents/worker/lib/access"; export default defineTool({ diff --git a/agent/tools/request_vault_import.ts b/agent/tools/request_vault_import.ts index 04f8d121..6913bdb6 100644 --- a/agent/tools/request_vault_import.ts +++ b/agent/tools/request_vault_import.ts @@ -1,7 +1,7 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { env } from "@/lib/env"; -import { createManagerImportUrl } from "@/lib/manager"; +import { createManagerImportUrl } from "@/modules/manager"; export default defineTool({ description: diff --git a/agent/tools/request_vault_setup.ts b/agent/tools/request_vault_setup.ts index cdf26ce5..341a177f 100644 --- a/agent/tools/request_vault_setup.ts +++ b/agent/tools/request_vault_setup.ts @@ -2,7 +2,7 @@ import { defineTool } from "eve/tools"; import { createManagerSetupUrl, managerSetupRequestSchema, -} from "@/lib/manager"; +} from "@/modules/manager"; import { env } from "@/lib/env"; export default defineTool({ diff --git a/db/services/browser-images.ts b/db/services/browser-images.ts index 57dbd4ab..34a6c701 100644 --- a/db/services/browser-images.ts +++ b/db/services/browser-images.ts @@ -7,7 +7,7 @@ import { browserImageMediaTypeSchema, browserImageSourceKindSchema, type BrowserImageArtifactReference, -} from "@/lib/browser-images"; +} from "@/lib/browser-artifact"; import { browserImageArtifacts, db } from "@/db"; type ArtifactRow = typeof browserImageArtifacts.$inferSelect; diff --git a/db/services/settings.ts b/db/services/settings.ts index 0020b20d..5dd7c760 100644 --- a/db/services/settings.ts +++ b/db/services/settings.ts @@ -3,8 +3,9 @@ import type { AccessScope } from "@/lib/access-scope"; import { db, settings } from "@/db"; const gatewayModelKey = "gateway_model"; +const defaultGatewayModel = "openai/gpt-5.6-sol-fast"; -export async function readGatewayModel(scope: AccessScope) { +async function readGatewayModel(scope: AccessScope) { const rows = await db .select({ value: settings.value }) .from(settings) @@ -18,6 +19,10 @@ export async function readGatewayModel(scope: AccessScope) { return rows[0]?.value; } +export async function getGatewayModel(scope: AccessScope) { + return (await readGatewayModel(scope)) ?? defaultGatewayModel; +} + export async function selectGatewayModel(scope: AccessScope, modelId: string) { await db .insert(settings) diff --git a/db/services/vault.ts b/db/services/vault.ts index 55e88d21..53961e7c 100644 --- a/db/services/vault.ts +++ b/db/services/vault.ts @@ -1,7 +1,7 @@ import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import type { AccessScope } from "@/lib/access-scope"; -import { vaultItemKindSchema } from "@/lib/manager"; +import { vaultItemKindSchema } from "@/modules/manager"; import { db, vaultItems } from "@/db"; const vaultRecordSchema = z.object({ diff --git a/evals/browser/README.md b/evals/browser/README.md index 35fbbd31..fc0a574e 100644 --- a/evals/browser/README.md +++ b/evals/browser/README.md @@ -32,10 +32,10 @@ BROWSER_BENCH_LABEL=no-fixed-waits pnpm bench:browser pnpm bench:compare .eve/browser-benchmarks/baseline.json .eve/browser-benchmarks/latest.json ``` -Edit `src/lib/browser/benchmark-tasks.ts` to add starter tasks shared by the CLI -and home-page runner. Every eval case should have a stable prompt and one or -more deterministic reply fragments. A task passes only when the expected reply -is present, the agent returns `completed`, and a Kernel Playwright, +Edit `evals/browser/tasks.ts` to add benchmark cases. Every eval case should +have a stable prompt and one or more deterministic reply fragments. A task +passes only when the expected reply is present, the agent returns `completed`, +and a Kernel Playwright, computer-action, or browser-curl call completed, so a plausible answer from another source does not count. Agent time is measured from durable `message.received` to the terminal `message.completed` event. LLM cost sums diff --git a/evals/browser/benchmark-reporter.ts b/evals/browser/benchmark-reporter.ts index b1dc7932..0b005b1a 100644 --- a/evals/browser/benchmark-reporter.ts +++ b/evals/browser/benchmark-reporter.ts @@ -4,10 +4,10 @@ import type { EveEvalResult, EveEvalRunSummary } from "eve/evals"; import type { EvalReporter } from "eve/evals/reporters"; import { browserBenchmarkEnv } from "@/evals/browser/env"; import { - measureBrowserTask, + measureWorkerTask, readTaskCompletion, - terminalBrowserMessage, -} from "@/lib/browser/benchmark"; + terminalWorkerMessage, +} from "@/lib/worker-events"; import type { BrowserBenchmark } from "@/evals/browser/benchmark-schema"; const tableWidths = [34, 8, 10, 12, 64] as const; @@ -63,7 +63,7 @@ export const browserBenchmarkReporter: EvalReporter = { }; function summarizeTaskResult(result: EveEvalResult, name: string) { - const metrics = measureBrowserTask( + const metrics = measureWorkerTask( result.result.events, elapsedMs(result.startedAt, result.completedAt) ); @@ -73,7 +73,7 @@ function summarizeTaskResult(result: EveEvalResult, name: string) { result.skipReason ?? "No reply"; const completion = readTaskCompletion(result.result.events); - const terminalMessage = terminalBrowserMessage( + const terminalMessage = terminalWorkerMessage( fallbackMessage, result.result.events ); diff --git a/evals/browser/browser.eval.ts b/evals/browser/browser.eval.ts index b06c5444..1972b41f 100644 --- a/evals/browser/browser.eval.ts +++ b/evals/browser/browser.eval.ts @@ -1,11 +1,8 @@ import { defineEval, type EveEvalSession, type EveEvalTurn } from "eve/evals"; import { includes, satisfies } from "eve/evals/expect"; -import { - didCompleteBrowserWorker, - didFinishBrowserWorker, -} from "@/lib/browser/benchmark"; -import { browserBenchmarkTasks } from "@/lib/browser/benchmark-tasks"; +import { didCompleteWorker, didFinishWorker } from "@/lib/worker-events"; import { browserBenchmarkEnv } from "@/evals/browser/env"; +import { browserBenchmarkTasks } from "@/evals/browser/tasks"; const repetitions = browserBenchmarkEnv.BROWSER_BENCH_REPETITIONS; @@ -33,7 +30,7 @@ export default browserBenchmarkTasks.flatMap((task) => const turn = await live.result(); turn.expectOk(); workerEvents.push(...turn.events); - if (didFinishBrowserWorker(workerEvents)) completed = turn; + if (didFinishWorker(workerEvents)) completed = turn; session = live.session; } @@ -45,7 +42,7 @@ export default browserBenchmarkTasks.flatMap((task) => ) ); await t.require( - didCompleteBrowserWorker(workerEvents), + didCompleteWorker(workerEvents), satisfies( (workerSucceeded) => workerSucceeded === true, "the worker completed the browser assignment successfully" diff --git a/src/lib/browser/benchmark-tasks.ts b/evals/browser/tasks.ts similarity index 100% rename from src/lib/browser/benchmark-tasks.ts rename to evals/browser/tasks.ts diff --git a/package.json b/package.json index fb0e5256..d4a17846 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "pg": "^8.23.0", "react": "19.2.8", "react-dom": "19.2.8", - "server-only": "0.0.1", "streamdown": "2.6.0", "tailwind-merge": "3.6.0", "tailwindcss": "4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 453e5d8d..3486d484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,9 +110,6 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - server-only: - specifier: 0.0.1 - version: 0.0.1 streamdown: specifier: 2.6.0 version: 2.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -6332,9 +6329,6 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -13077,8 +13071,6 @@ snapshots: transitivePeerDependencies: - supports-color - server-only@0.0.1: {} - set-cookie-parser@3.1.2: {} setprototypeof@1.1.1: {} diff --git a/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx b/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx index 568cfd77..4db9aa0d 100644 --- a/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx +++ b/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import type { ManagerSnapshot } from "@/lib/manager"; +import type { ManagerSnapshot } from "@/modules/manager"; import { api } from "@/trpc/client"; export function GoogleWorkspaceAction({ diff --git a/src/app/(authenticated)/(manager)/_components/model-selector.tsx b/src/app/(authenticated)/(manager)/_components/model-selector.tsx index 0b72091a..e56bbabb 100644 --- a/src/app/(authenticated)/(manager)/_components/model-selector.tsx +++ b/src/app/(authenticated)/(manager)/_components/model-selector.tsx @@ -16,8 +16,10 @@ import { ModelSelectorTrigger, } from "@/components/ai-elements/model-selector"; import { Button } from "@/components/ui/button"; -import type { ModelCatalogItem } from "@/lib/model-catalog"; import { api } from "@/trpc/client"; +import type { RouterOutputs } from "@/trpc/types"; + +type ModelCatalogItem = RouterOutputs["models"]["list"][number]; const priceFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, diff --git a/src/app/(authenticated)/(manager)/page.tsx b/src/app/(authenticated)/(manager)/page.tsx index 879000c9..4b9a0e1a 100644 --- a/src/app/(authenticated)/(manager)/page.tsx +++ b/src/app/(authenticated)/(manager)/page.tsx @@ -11,8 +11,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { env } from "@/lib/env"; import { requireRequestScope } from "@/lib/request-scope"; -import { managerSnapshotSchema, type ManagerSnapshot } from "@/lib/manager"; -import { readManagerSnapshot } from "@/lib/manager/server/store"; +import { managerSnapshotSchema, type ManagerSnapshot } from "@/modules/manager"; +import { readManagerSnapshot } from "@/modules/manager/server/store"; import { GoogleWorkspaceAction } from "./_components/google-workspace-action"; import { ModelSelector } from "./_components/model-selector"; diff --git a/src/app/(authenticated)/(manager)/vault/_components/address-vault-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/address-vault-form.tsx index e726da3c..f717645e 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/address-vault-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/address-vault-form.tsx @@ -4,8 +4,8 @@ import { type FormEvent, useState } from "react"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field"; -import type { ManagerMutation } from "@/lib/manager"; -import { serializeAddressVaultPayload } from "@/lib/manager/vault-payload"; +import type { ManagerMutation } from "@/modules/manager"; +import { serializeAddressVaultPayload } from "@/modules/manager/vault-payload"; import { VaultFormField } from "./vault-form-field"; const addressFormSchema = z.object({ diff --git a/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx b/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx index 508107ca..5e056082 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx @@ -17,8 +17,8 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { ManagerMutation } from "@/lib/manager"; -import { parseChromePasswordsCsv } from "@/lib/manager/chrome-passwords"; +import type { ManagerMutation } from "@/modules/manager"; +import { parseChromePasswordsCsv } from "@/modules/manager/chrome-passwords"; const MAX_FILE_SIZE = 10 * 1024 * 1024; const GOOGLE_PASSWORD_MANAGER_URL = "https://passwords.google.com/options"; diff --git a/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx index 2c3f2bc3..d6f5f3b6 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx @@ -4,8 +4,8 @@ import { type FormEvent, useState } from "react"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field"; -import type { ManagerMutation } from "@/lib/manager"; -import { serializeContactVaultPayload } from "@/lib/manager/vault-payload"; +import type { ManagerMutation } from "@/modules/manager"; +import { serializeContactVaultPayload } from "@/modules/manager/vault-payload"; import { VaultFormField } from "./vault-form-field"; const contactFormSchema = z diff --git a/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx index ef20dd1c..c34a02bc 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx @@ -11,13 +11,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { ManagerMutation } from "@/lib/manager"; +import type { ManagerMutation } from "@/modules/manager"; import { loginIdentifierSchema, loginIdentifierTypeSchema, loginOriginSchema, serializeLoginVaultPayload, -} from "@/lib/manager/vault-payload"; +} from "@/modules/manager/vault-payload"; import { VaultFormField } from "./vault-form-field"; const loginFormSchema = z diff --git a/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx index 493371d4..fea115fb 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx @@ -11,12 +11,12 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import type { ManagerMutation } from "@/lib/manager"; +import type { ManagerMutation } from "@/modules/manager"; import { paymentCardBrand, paymentCardType, serializePaymentCard, -} from "@/lib/manager/payment-card"; +} from "@/modules/manager/payment-card"; const paymentCardFormSchema = z.object({ billingPostalCode: z.string().trim().min(1, "Enter the billing postal code."), diff --git a/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx b/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx index 5e09bb3a..7e728254 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx @@ -27,7 +27,7 @@ import type { ManagerSetupRequest, ManagerSnapshot, VaultCreateItemKind, -} from "@/lib/manager"; +} from "@/modules/manager"; import { api } from "@/trpc/client"; import { AddressVaultForm } from "./address-vault-form"; import { ChromePasswordImportPanel } from "./chrome-password-import"; diff --git a/src/app/(authenticated)/(manager)/vault/page.tsx b/src/app/(authenticated)/(manager)/vault/page.tsx index 15197da1..ef9865d5 100644 --- a/src/app/(authenticated)/(manager)/vault/page.tsx +++ b/src/app/(authenticated)/(manager)/vault/page.tsx @@ -3,8 +3,8 @@ import { requireRequestScope } from "@/lib/request-scope"; import { managerSnapshotSchema, parseManagerSetupSearchParams, -} from "@/lib/manager"; -import { readManagerSnapshot } from "@/lib/manager/server/store"; +} from "@/modules/manager"; +import { readManagerSnapshot } from "@/modules/manager/server/store"; export default async function Page({ searchParams }: PageProps<"/vault">) { const query = await searchParams; diff --git a/src/app/(authenticated)/(tasks)/_lib/task-history.test.ts b/src/app/(authenticated)/(tasks)/_lib/task-history.test.ts index f8026ff7..ee51adb1 100644 --- a/src/app/(authenticated)/(tasks)/_lib/task-history.test.ts +++ b/src/app/(authenticated)/(tasks)/_lib/task-history.test.ts @@ -1,6 +1,6 @@ import type { MessageStreamEvent } from "eve/client"; import { describe, expect, it } from "vitest"; -import type { TaskHistoryRun } from "@/lib/task-history"; +import type { TaskHistoryRun } from "@/app/(authenticated)/(tasks)/_lib/task-history"; import { taskFromHistoryRun } from "./task-history"; const run: TaskHistoryRun = { diff --git a/src/app/(authenticated)/(tasks)/_lib/task-history.ts b/src/app/(authenticated)/(tasks)/_lib/task-history.ts index 71ca789b..5a5f0b80 100644 --- a/src/app/(authenticated)/(tasks)/_lib/task-history.ts +++ b/src/app/(authenticated)/(tasks)/_lib/task-history.ts @@ -1,15 +1,18 @@ import type { MessageStreamEvent } from "eve/client"; -import type { TaskHistoryRun } from "@/lib/task-history"; +import type { RouterOutputs } from "@/trpc/types"; import type { BrowserRunGroup, BrowserRunTask, } from "@/app/(authenticated)/_lib/browser-run-store"; import { - measureBrowserTask, + measureWorkerTask, readBackgroundWorkerTasks, readTaskCompletion, - terminalBrowserMessage, -} from "@/lib/browser/benchmark"; + terminalWorkerMessage, +} from "@/lib/worker-events"; + +export type TaskHistoryPage = RouterOutputs["tasks"]["list"]; +export type TaskHistoryRun = TaskHistoryPage["runs"][number]; export function taskFromHistoryRun( run: TaskHistoryRun, @@ -46,7 +49,7 @@ export function taskFromHistoryRun( waiting?.type === "session.completed" || waiting?.type === "session.waiting")); const updatedAt = new Date(run.updatedAt).getTime(); - const metrics = measureBrowserTask( + const metrics = measureWorkerTask( events, Math.max(0, (settled ? updatedAt : now) - startedAt) ); @@ -84,7 +87,7 @@ export function taskFromHistoryRun( terminalMessage: status === "running" ? undefined - : terminalBrowserMessage( + : terminalWorkerMessage( message?.type === "message.completed" ? (message.data.message ?? undefined) : undefined, diff --git a/src/app/(authenticated)/(tasks)/runs/[groupId]/_components/browser-run-detail.tsx b/src/app/(authenticated)/(tasks)/runs/[groupId]/_components/browser-run-detail.tsx index 80948b18..34e9894b 100644 --- a/src/app/(authenticated)/(tasks)/runs/[groupId]/_components/browser-run-detail.tsx +++ b/src/app/(authenticated)/(tasks)/runs/[groupId]/_components/browser-run-detail.tsx @@ -14,12 +14,12 @@ import { useBrowserRunGroups } from "@/app/(authenticated)/(tasks)/_components/u import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - didFinishBrowserWorker, - measureBrowserTask, + didFinishWorker, + measureWorkerTask, readBackgroundWorkerTasks, readTaskCompletion, - terminalBrowserMessage, -} from "@/lib/browser/benchmark"; + terminalWorkerMessage, +} from "@/lib/worker-events"; import { type BrowserRunGroup, type BrowserRunTask, @@ -227,7 +227,7 @@ async function runPersistedTask( events.push(event); projectTaskEvents(events, requestStartedAt, update); if ( - didFinishBrowserWorker(events) || + didFinishWorker(events) || event.type === "session.failed" || (event.type === "session.waiting" && readBackgroundWorkerTasks(events).length === 0) @@ -266,7 +266,7 @@ async function runPersistedTask( })) { events.push(event); projectTaskEvents(events, requestStartedAt, update); - if (didFinishBrowserWorker(events)) break; + if (didFinishWorker(events)) break; } } } @@ -274,7 +274,7 @@ async function runPersistedTask( completePersistedTask(events, requestStartedAt, update); } catch (error) { const completion = readTaskCompletion(events); - const metrics = measureBrowserTask(events, Date.now() - requestStartedAt); + const metrics = measureWorkerTask(events, Date.now() - requestStartedAt); update({ completedAt: Date.now(), costComplete: metrics.costComplete, @@ -298,7 +298,7 @@ function projectTaskEvents( const event = events.at(-1); if (!event || !shouldProjectEvent(event)) return; - const metrics = measureBrowserTask(events, Date.now() - requestStartedAt); + const metrics = measureWorkerTask(events, Date.now() - requestStartedAt); const completion = readTaskCompletion(events); update({ costComplete: metrics.costComplete, @@ -313,9 +313,9 @@ function completePersistedTask( requestStartedAt: number, update: (taskUpdate: BrowserRunTaskUpdate) => void ) { - const metrics = measureBrowserTask(events, Date.now() - requestStartedAt); + const metrics = measureWorkerTask(events, Date.now() - requestStartedAt); const completion = readTaskCompletion(events); - const fallbackMessage = terminalBrowserMessage(undefined, events); + const fallbackMessage = terminalWorkerMessage(undefined, events); update({ completedAt: Date.now(), costComplete: metrics.costComplete, diff --git a/src/app/(authenticated)/(tasks)/tasks/_components/browser-batch-form.tsx b/src/app/(authenticated)/(tasks)/tasks/_components/browser-batch-form.tsx index 741ddebc..d4cce5f3 100644 --- a/src/app/(authenticated)/(tasks)/tasks/_components/browser-batch-form.tsx +++ b/src/app/(authenticated)/(tasks)/tasks/_components/browser-batch-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { PlayIcon, RotateCcwIcon } from "lucide-react"; +import { PlayIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -18,15 +18,9 @@ import { createBrowserRunGroup, saveBrowserRunGroup, } from "@/app/(authenticated)/_lib/browser-run-store"; -import { browserBenchmarkTasks } from "@/lib/browser/benchmark-tasks"; - -const starterTasks = browserBenchmarkTasks - .map((task) => task.prompt) - .join("\n"); - export function BrowserBatchForm() { const router = useRouter(); - const [input, setInput] = useState(starterTasks); + const [input, setInput] = useState(""); const [groupName, setGroupName] = useState(""); const [concurrency, setConcurrency] = useState(4); const parsedTasks = useMemo(() => parseTasks(input), [input]); @@ -99,15 +93,6 @@ export function BrowserBatchForm() { Create and run group - - ); diff --git a/src/app/(authenticated)/(tasks)/tasks/_components/global-task-history.tsx b/src/app/(authenticated)/(tasks)/tasks/_components/global-task-history.tsx index 5fe82372..537657b0 100644 --- a/src/app/(authenticated)/(tasks)/tasks/_components/global-task-history.tsx +++ b/src/app/(authenticated)/(tasks)/tasks/_components/global-task-history.tsx @@ -14,7 +14,10 @@ import { historyTableGroups, taskFromHistoryRun, } from "@/app/(authenticated)/(tasks)/_lib/task-history"; -import type { TaskHistoryPage, TaskHistoryRun } from "@/lib/task-history"; +import type { + TaskHistoryPage, + TaskHistoryRun, +} from "@/app/(authenticated)/(tasks)/_lib/task-history"; import { api } from "@/trpc/client"; import { useBrowserRunGroups } from "@/app/(authenticated)/(tasks)/_components/use-browser-run-groups"; diff --git a/src/app/(authenticated)/(tasks)/tasks/page.tsx b/src/app/(authenticated)/(tasks)/tasks/page.tsx index 2601ca8f..35c1000c 100644 --- a/src/app/(authenticated)/(tasks)/tasks/page.tsx +++ b/src/app/(authenticated)/(tasks)/tasks/page.tsx @@ -1,7 +1,7 @@ import { MessageSquareIcon } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { readTaskHistoryPage } from "@/lib/task-history/server"; +import { readTaskHistoryPage } from "@/trpc/router"; import { requireRequestScope } from "@/lib/request-scope"; import { BrowserBatchForm } from "./_components/browser-batch-form"; import { GlobalTaskHistory } from "./_components/global-task-history"; diff --git a/src/app/artifacts/[artifactId]/route.ts b/src/app/artifacts/[artifactId]/route.ts index 59898466..489ea6b9 100644 --- a/src/app/artifacts/[artifactId]/route.ts +++ b/src/app/artifacts/[artifactId]/route.ts @@ -1,7 +1,9 @@ +import { get } from "@vercel/blob"; import { z } from "zod"; import { getAuthSession } from "@/auth/session"; +import { readReadyBrowserImageArtifact } from "@/db/services/browser-images"; import { accessScopeForUser } from "@/lib/access-scope"; -import { getBrowserImageBlob } from "@/lib/browser-images/server"; +import { env } from "@/lib/env"; export const runtime = "nodejs"; @@ -14,7 +16,7 @@ export async function GET( if (!session || !parsedId.success) return notFound(); const scope = accessScopeForUser(`better-auth:${session.user.id}`); - const opened = await getBrowserImageBlob(scope, parsedId.data, { + const opened = await openArtifact(scope, parsedId.data, { ifNoneMatch: request.headers.get("if-none-match") ?? undefined, signal: request.signal, }); @@ -35,6 +37,37 @@ export async function GET( return new Response(opened.result.stream, { headers, status: 200 }); } +async function openArtifact( + scope: ReturnType, + artifactId: string, + options: { readonly ifNoneMatch?: string; readonly signal?: AbortSignal } +) { + const artifact = await readReadyBrowserImageArtifact(scope, artifactId); + const byteSize = artifact?.byteSize; + const filename = artifact?.filename; + const mediaType = artifact?.mediaType; + if (!artifact || !byteSize || !filename || !mediaType) return; + const blobAuth = env.BLOB_STORE_ID + ? { storeId: env.BLOB_STORE_ID } + : env.BLOB_READ_WRITE_TOKEN + ? { token: env.BLOB_READ_WRITE_TOKEN } + : undefined; + if (!blobAuth) return; + const result = await get(artifact.storagePathname, { + ...blobAuth, + access: "private", + abortSignal: options.signal, + ifNoneMatch: options.ifNoneMatch, + }); + if (!result) return; + if ( + result.statusCode === 200 && + (result.blob.size !== byteSize || result.blob.contentType !== mediaType) + ) + return; + return { artifact: { ...artifact, byteSize, filename, mediaType }, result }; +} + function notFound() { return new Response("Not found", { headers: privateImageHeaders(), diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 4b4ff84a..96687f2f 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -27,7 +27,7 @@ import { useState, } from "react"; import { Streamdown, type Components } from "streamdown"; -import { isBrowserImageArtifactUrl } from "@/lib/browser-image-path"; +import { isBrowserImageArtifactUrl } from "@/lib/browser-artifact"; export type MessageProps = HTMLAttributes & { from: UIMessage["role"]; diff --git a/src/lib/browser-artifact.ts b/src/lib/browser-artifact.ts new file mode 100644 index 00000000..44052b78 --- /dev/null +++ b/src/lib/browser-artifact.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +export const maximumBrowserImageBytes = 8 * 1024 * 1024; + +export const browserImageMediaTypeSchema = z.enum([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +export const browserImageSourceKindSchema = z.enum([ + "element", + "full_page", + "image_resource", + "viewport", +]); + +export const browserImageArtifactReferenceSchema = z + .object({ + byteSize: z.number().int().positive().max(maximumBrowserImageBytes), + filename: z.string().trim().min(1).max(180), + id: z.uuid(), + label: z.string().trim().min(1).max(200), + mediaType: browserImageMediaTypeSchema, + url: z.string(), + }) + .refine((artifact) => artifact.url === browserImageArtifactUrl(artifact.id), { + message: "Artifact URL must match its id.", + path: ["url"], + }); + +export type BrowserImageArtifactReference = z.infer< + typeof browserImageArtifactReferenceSchema +>; + +export function browserImageArtifactUrl(id: string) { + return `/artifacts/${encodeURIComponent(z.uuid().parse(id))}`; +} + +export function isBrowserImageArtifactUrl(value: string) { + const parsed = /^\/artifacts\/([^/]+)$/u.exec(value); + if (!parsed?.[1]) return false; + return z.uuid().safeParse(decodeURIComponent(parsed[1])).success; +} + +export function sniffBrowserImageMediaType(bytes: Uint8Array) { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "image/png" as const; + } + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return "image/jpeg" as const; + } + if (bytes.length >= 6) { + const signature = new TextDecoder("ascii").decode(bytes.subarray(0, 6)); + if (signature === "GIF87a" || signature === "GIF89a") { + return "image/gif" as const; + } + } + if ( + bytes.length >= 12 && + new TextDecoder("ascii").decode(bytes.subarray(0, 4)) === "RIFF" && + new TextDecoder("ascii").decode(bytes.subarray(8, 12)) === "WEBP" + ) { + return "image/webp" as const; + } + return undefined; +} diff --git a/src/lib/browser-image-path.ts b/src/lib/browser-image-path.ts deleted file mode 100644 index f61ec08e..00000000 --- a/src/lib/browser-image-path.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const browserImageArtifactIdSource = - "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; - -export const browserImageArtifactPathPattern = new RegExp( - `^/artifacts/${browserImageArtifactIdSource}$`, - "iu" -); - -export function isBrowserImageArtifactUrl(value: string) { - return browserImageArtifactPathPattern.test(value); -} diff --git a/src/lib/browser-images.ts b/src/lib/browser-images.ts deleted file mode 100644 index 528dd522..00000000 --- a/src/lib/browser-images.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { z } from "zod"; -import { - browserImageArtifactIdSource, - browserImageArtifactPathPattern, -} from "./browser-image-path"; - -export const maximumBrowserImageBytes = 8 * 1024 * 1024; -export const maximumBrowserImagesPerCompletion = 4; - -export const browserImageMediaTypeSchema = z.enum([ - "image/gif", - "image/jpeg", - "image/png", - "image/webp", -]); - -export const browserImageSourceKindSchema = z.enum([ - "element", - "full_page", - "image_resource", - "viewport", -]); - -const artifactMarkdownPattern = new RegExp( - String.raw`!\[((?:\\.|[^\]])*)\]\((/artifacts/(${browserImageArtifactIdSource}))\)`, - "giu" -); - -export const browserImageArtifactReferenceSchema = z - .object({ - byteSize: z.number().int().positive().max(maximumBrowserImageBytes), - filename: z.string().trim().min(1).max(180), - id: z.uuid(), - label: z.string().trim().min(1).max(200), - mediaType: browserImageMediaTypeSchema, - url: z.string().regex(browserImageArtifactPathPattern), - }) - .refine((artifact) => artifact.url === browserImageArtifactUrl(artifact.id), { - message: "Artifact URL must match its id.", - path: ["url"], - }); - -export type BrowserImageArtifactReference = z.infer< - typeof browserImageArtifactReferenceSchema ->; - -interface BrowserImageMarkdownReference { - readonly id: string; - readonly label: string; - readonly markdown: string; - readonly url: string; -} - -export function browserImageArtifactUrl(id: string) { - return `/artifacts/${encodeURIComponent(z.uuid().parse(id))}`; -} - -export function browserImageMarkdown(artifact: BrowserImageArtifactReference) { - const label = artifact.label - .replaceAll("\\", "\\\\") - .replaceAll("[", "\\[") - .replaceAll("]", "\\]"); - return `![${label}](${artifact.url})`; -} - -export function extractBrowserImageMarkdownReferences(message: string) { - const references: BrowserImageMarkdownReference[] = []; - const seen = new Set(); - - for (const match of message.matchAll(artifactMarkdownPattern)) { - const [markdown, label, url, id] = match; - if (!url || !id || seen.has(id)) continue; - seen.add(id); - references.push({ id, label: label ?? "", markdown, url }); - } - - return references; -} - -export function stripBrowserImageMarkdownReferences(message: string) { - return message - .replace(artifactMarkdownPattern, "") - .replace(/[ \t]+\n/gu, "\n") - .replace(/\n{3,}/gu, "\n\n") - .trim(); -} - -export function safeBrowserImageFilename(label: string, mediaType: string) { - const extension = browserImageExtension(mediaType); - const withoutControls = label - .normalize("NFKD") - .replace(/(?:\.\.[/\\])+/gu, "") - .replace(/\p{Cc}+/gu, ""); - const stem = withoutControls - .replace(/[^\p{L}\p{N}._() -]+/gu, "_") - .replace(/\s+/gu, " ") - .trim() - .replace(/^\.+|\.+$/gu, "") - .slice(0, 160); - return `${stem || "browser-image"}.${extension}`; -} - -function browserImageExtension(mediaType: string) { - switch (browserImageMediaTypeSchema.parse(mediaType)) { - case "image/gif": - return "gif"; - case "image/jpeg": - return "jpg"; - case "image/png": - return "png"; - case "image/webp": - return "webp"; - } -} - -export function sniffBrowserImageMediaType(bytes: Uint8Array) { - if ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a - ) { - return "image/png" as const; - } - if ( - bytes.length >= 3 && - bytes[0] === 0xff && - bytes[1] === 0xd8 && - bytes[2] === 0xff - ) { - return "image/jpeg" as const; - } - if (bytes.length >= 6) { - const signature = new TextDecoder("ascii").decode(bytes.subarray(0, 6)); - if (signature === "GIF87a" || signature === "GIF89a") { - return "image/gif" as const; - } - } - if ( - bytes.length >= 12 && - new TextDecoder("ascii").decode(bytes.subarray(0, 4)) === "RIFF" && - new TextDecoder("ascii").decode(bytes.subarray(8, 12)) === "WEBP" - ) { - return "image/webp" as const; - } - return undefined; -} diff --git a/src/lib/browser-images/server.ts b/src/lib/browser-images/server.ts deleted file mode 100644 index 547da6bd..00000000 --- a/src/lib/browser-images/server.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { createHash } from "node:crypto"; -import { del, get, put } from "@vercel/blob"; -import type { AccessScope } from "@/lib/access-scope"; -import { - finalizeBrowserImageArtifact, - readReadyBrowserImageArtifact, - type BrowserImageArtifactReservation, -} from "@/db/services/browser-images"; -import { - maximumBrowserImageBytes, - sniffBrowserImageMediaType, -} from "@/lib/browser-images"; -import { env } from "@/lib/env"; - -const blobOptions = { access: "private" as const }; - -export function browserImageBlobAuthentication(input: { - readonly readWriteToken?: string; - readonly storeId?: string; -}) { - if (input.storeId) return { storeId: input.storeId }; - if (input.readWriteToken) return { token: input.readWriteToken }; - throw new Error( - "Browser image storage is not configured. Connect a private Vercel Blob store or set BLOB_READ_WRITE_TOKEN." - ); -} - -function blobAuthentication() { - return browserImageBlobAuthentication({ - readWriteToken: env.BLOB_READ_WRITE_TOKEN, - storeId: env.BLOB_STORE_ID, - }); -} - -export async function persistReservedBrowserImage( - scope: AccessScope, - reservation: BrowserImageArtifactReservation, - input: { - readonly bytes: Uint8Array; - readonly filename: string; - readonly sourceKind: string; - }, - signal?: AbortSignal -) { - if (input.bytes.byteLength === 0) - throw new Error("The browser image is empty."); - if (input.bytes.byteLength > maximumBrowserImageBytes) { - throw new Error( - `The browser image is ${String(input.bytes.byteLength)} bytes; the limit is ${String(maximumBrowserImageBytes)}.` - ); - } - const mediaType = sniffBrowserImageMediaType(input.bytes); - if (!mediaType) { - throw new Error("The captured resource is not a supported browser image."); - } - const contentHash = createHash("sha256").update(input.bytes).digest("hex"); - const attemptPathname = `${reservation.storagePathname}/${contentHash}`; - - await put(attemptPathname, Buffer.from(input.bytes), { - ...blobOptions, - ...blobAuthentication(), - abortSignal: signal, - addRandomSuffix: false, - allowOverwrite: true, - cacheControlMaxAge: 30 * 24 * 60 * 60, - contentType: mediaType, - maximumSizeInBytes: maximumBrowserImageBytes, - }); - - try { - const finalized = await finalizeBrowserImageArtifact(scope, reservation, { - byteSize: input.bytes.byteLength, - contentHash, - filename: input.filename, - mediaType, - sourceKind: input.sourceKind, - storagePathname: attemptPathname, - }); - if (finalized.storagePathname !== attemptPathname) { - await deleteBlob(attemptPathname); - } - return finalized.image; - } catch (error) { - await deleteBlob(attemptPathname); - throw error; - } -} - -async function deleteBlob(pathname: string) { - await del(pathname, blobAuthentication()).catch(() => undefined); -} - -export async function getBrowserImageBlob( - scope: AccessScope, - artifactId: string, - options: { - readonly ifNoneMatch?: string; - readonly rootSessionId?: string; - readonly signal?: AbortSignal; - } = {} -) { - const artifact = await readReadyBrowserImageArtifact(scope, artifactId, { - rootSessionId: options.rootSessionId, - }); - if (!artifact) return undefined; - const { byteSize, contentHash, filename, mediaType } = artifact; - if (!byteSize || !contentHash || !filename || !mediaType) return undefined; - const readyArtifact = { - ...artifact, - byteSize, - contentHash, - filename, - mediaType, - }; - const result = await get(artifact.storagePathname, { - ...blobOptions, - ...blobAuthentication(), - abortSignal: options.signal, - ifNoneMatch: options.ifNoneMatch, - }); - if (!result) return undefined; - if ( - result.statusCode === 200 && - (result.blob.size !== readyArtifact.byteSize || - result.blob.contentType !== readyArtifact.mediaType) - ) { - return undefined; - } - return { artifact: readyArtifact, result }; -} - -export async function readBrowserImageBytes( - scope: AccessScope, - artifactId: string, - options: { - readonly rootSessionId?: string; - readonly signal?: AbortSignal; - } = {} -) { - const opened = await getBrowserImageBlob(scope, artifactId, options); - if (opened?.result.statusCode !== 200) return undefined; - const bytes = await readBoundedStream( - opened.result.stream, - maximumBrowserImageBytes - ); - const contentHash = createHash("sha256").update(bytes).digest("hex"); - if (contentHash !== opened.artifact.contentHash) return undefined; - return { - bytes, - filename: opened.artifact.filename, - id: opened.artifact.id, - mediaType: opened.artifact.mediaType, - }; -} - -export async function readBoundedResponse( - response: Response, - maximumBytes = maximumBrowserImageBytes -) { - const contentLength = Number(response.headers.get("content-length")); - if (Number.isFinite(contentLength) && contentLength > maximumBytes) { - throw new Error( - `The browser image exceeds the ${String(maximumBytes)} byte limit.` - ); - } - if (!response.body) throw new Error("The browser image response is empty."); - return readBoundedStream(response.body, maximumBytes); -} - -async function readBoundedStream( - stream: ReadableStream, - maximumBytes: number -) { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maximumBytes) { - await reader.cancel(); - throw new Error( - `The browser image exceeds the ${String(maximumBytes)} byte limit.` - ); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return bytes; -} diff --git a/src/lib/google-workspace/config.ts b/src/lib/google-workspace.ts similarity index 77% rename from src/lib/google-workspace/config.ts rename to src/lib/google-workspace.ts index 72645696..37ce0b59 100644 --- a/src/lib/google-workspace/config.ts +++ b/src/lib/google-workspace.ts @@ -1,7 +1,6 @@ import type { ConnectTokenParams, ConnectTokenSubject } from "@vercel/connect"; -import { z } from "zod"; -export const GOOGLE_WORKSPACE_SCOPES = [ +export const googleWorkspaceScopes = [ "openid", "email", "profile", @@ -11,15 +10,13 @@ export const GOOGLE_WORKSPACE_SCOPES = [ "https://www.googleapis.com/auth/contacts.readonly", ] as const; -export const googleWorkspaceActionSchema = z.enum(["connect", "disconnect"]); - export function googleWorkspaceSubject(userId: string): ConnectTokenSubject { return { id: userId, issuer: "openinstinct", type: "user" }; } export function googleWorkspaceTokenParams(userId: string): ConnectTokenParams { return { - scopes: [...GOOGLE_WORKSPACE_SCOPES], + scopes: [...googleWorkspaceScopes], subject: googleWorkspaceSubject(userId), }; } diff --git a/src/lib/google-workspace/server.ts b/src/lib/google-workspace/server.ts deleted file mode 100644 index 77c4c0f7..00000000 --- a/src/lib/google-workspace/server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - getTokenResponse, - NoValidTokenError, - revokeToken, - startAuthorization, - UserAuthorizationRequiredError, -} from "@vercel/connect"; -import type { AccessScope } from "@/lib/access-scope"; -import { env } from "@/lib/env"; -import { googleWorkspaceSubject, googleWorkspaceTokenParams } from "./config"; - -export async function getGoogleWorkspaceConnection(scope: AccessScope) { - try { - const response = await getTokenResponse( - env.GOOGLE_CONNECTOR_UID, - googleWorkspaceTokenParams(scope.userId), - { forceRefresh: true } - ); - return { - accountLabel: - response.name ?? - (typeof response.claims?.email === "string" - ? response.claims.email - : null), - state: "connected" as const, - }; - } catch (error) { - if ( - error instanceof UserAuthorizationRequiredError || - error instanceof NoValidTokenError - ) { - return { accountLabel: null, state: "disconnected" as const }; - } - return { accountLabel: null, state: "unavailable" as const }; - } -} - -export async function startGoogleWorkspaceAuthorization( - scope: AccessScope, - callbackUrl: string -) { - const authorization = await startAuthorization( - env.GOOGLE_CONNECTOR_UID, - googleWorkspaceTokenParams(scope.userId), - { callbackUrl, expiresInMs: 10 * 60_000 } - ); - return authorization.url; -} - -export async function disconnectGoogleWorkspace(scope: AccessScope) { - await revokeToken(env.GOOGLE_CONNECTOR_UID, { - subject: googleWorkspaceSubject(scope.userId), - }); -} diff --git a/src/lib/model-catalog.ts b/src/lib/model-catalog.ts deleted file mode 100644 index bf502382..00000000 --- a/src/lib/model-catalog.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod"; - -const modelCatalogItemSchema = z.object({ - id: z.string(), - name: z.string(), - ownedBy: z.string(), - pricing: z - .object({ - input: z.number().nonnegative().optional(), - output: z.number().nonnegative().optional(), - }) - .optional(), -}); - -export const modelCatalogSchema = z.array(modelCatalogItemSchema); -export type ModelCatalogItem = z.infer; diff --git a/src/lib/model-catalog/server.ts b/src/lib/model-catalog/server.ts deleted file mode 100644 index aff91ab3..00000000 --- a/src/lib/model-catalog/server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import "server-only"; - -import { gateway } from "ai"; -import { modelCatalogSchema } from "@/lib/model-catalog"; - -export async function readModelCatalog() { - const { models } = await gateway.getAvailableModels(); - - return modelCatalogSchema.parse( - models - .filter((model) => model.modelType === "language") - .map((model) => ({ - id: model.id, - name: model.name, - ownedBy: model.specification.provider, - pricing: model.pricing - ? { - input: perMillion(model.pricing.input), - output: perMillion(model.pricing.output), - } - : undefined, - })) - ); -} - -function perMillion(value: string) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed * 1_000_000 : undefined; -} diff --git a/src/lib/model-config.ts b/src/lib/model-config.ts deleted file mode 100644 index 97ede053..00000000 --- a/src/lib/model-config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { readGatewayModel } from "@/db/services/settings"; -import type { AccessScope } from "./access-scope"; - -export async function getModelSettings(scope: AccessScope) { - return { - modelId: (await readGatewayModel(scope)) ?? "openai/gpt-5.6-sol-fast", - }; -} diff --git a/src/lib/task-history.ts b/src/lib/task-history.ts deleted file mode 100644 index 1d214ca8..00000000 --- a/src/lib/task-history.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -export const taskHistoryPageSchema = z.object({ - cursor: z.string().nullable(), - hasMore: z.boolean(), - runs: z.array( - z.object({ - createdAt: z.string(), - prompt: z.string(), - sessionId: z.string(), - status: z.enum([ - "cancelled", - "completed", - "failed", - "pending", - "running", - ]), - updatedAt: z.string(), - }) - ), -}); - -export type TaskHistoryPage = z.infer; -export type TaskHistoryRun = TaskHistoryPage["runs"][number]; diff --git a/src/lib/task-history/server.ts b/src/lib/task-history/server.ts deleted file mode 100644 index 844e0fb5..00000000 --- a/src/lib/task-history/server.ts +++ /dev/null @@ -1,55 +0,0 @@ -import "server-only"; - -import { createWorld } from "@workflow/world-vercel"; -import { listOwnedSessionIds } from "@/db/services/sessions"; -import type { AccessScope } from "@/lib/access-scope"; -import { taskHistoryPageSchema } from "@/lib/task-history"; - -const pageSize = 25; -const workflowName = "workflow//eve//workflowEntry"; - -export async function readTaskHistoryPage(scope: AccessScope, cursor?: string) { - const ownedSessionIds = await listOwnedSessionIds(scope); - const world = createWorld({ - headers: { "User-Agent": "local-vault-assistant/task-history" }, - }); - const runs: Awaited>["data"][number][] = - []; - let nextCursor = cursor; - let hasMore = true; - let pagesRead = 0; - - while (runs.length < pageSize && hasMore && pagesRead < 10) { - const page = await world.runs.list({ - pagination: { - cursor: nextCursor, - limit: pageSize - runs.length, - sortOrder: "desc", - }, - resolveData: "none", - workflowName, - }); - runs.push( - ...page.data.filter( - (run) => - run.attributes["$eve.type"] === "session" && - ownedSessionIds.has(run.runId) - ) - ); - nextCursor = page.cursor ?? undefined; - hasMore = page.hasMore; - pagesRead += 1; - } - - return taskHistoryPageSchema.parse({ - cursor: nextCursor ?? null, - hasMore, - runs: runs.map((run) => ({ - createdAt: run.createdAt.toISOString(), - prompt: run.attributes["$eve.title"] ?? "Untitled task", - sessionId: run.runId, - status: run.status, - updatedAt: run.updatedAt.toISOString(), - })), - }); -} diff --git a/src/lib/task-completion.ts b/src/lib/worker-completion.ts similarity index 82% rename from src/lib/task-completion.ts rename to src/lib/worker-completion.ts index 0ae0cd02..e16aac41 100644 --- a/src/lib/task-completion.ts +++ b/src/lib/worker-completion.ts @@ -1,13 +1,12 @@ import { z } from "zod"; -import { - browserImageArtifactReferenceSchema, - maximumBrowserImagesPerCompletion, -} from "./browser-images"; +import { browserImageArtifactReferenceSchema } from "@/lib/browser-artifact"; + +export const maximumWorkerCompletionImages = 4; export const taskCompletionSchema = z.object({ images: z .array(browserImageArtifactReferenceSchema) - .max(maximumBrowserImagesPerCompletion), + .max(maximumWorkerCompletionImages), status: z.enum(["success", "failure"]), message: z.string().trim().min(1), }); diff --git a/src/lib/browser/benchmark.ts b/src/lib/worker-events.ts similarity index 95% rename from src/lib/browser/benchmark.ts rename to src/lib/worker-events.ts index 8a0e7cd8..c9987357 100644 --- a/src/lib/browser/benchmark.ts +++ b/src/lib/worker-events.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; import type { MessageStreamEvent } from "eve/client"; -import { parseTaskCompletionOutput } from "../task-completion"; +import { z } from "zod"; +import { parseTaskCompletionOutput } from "@/lib/worker-completion"; const workerTaskNotificationPrefix = /^Background task (\S+) \(worker\) /u; const terminalTaskControlSchema = z.object({ @@ -19,7 +19,7 @@ interface BackgroundWorkerTaskState { terminalAt?: string; } -export function measureBrowserTask( +export function measureWorkerTask( events: readonly MessageStreamEvent[], fallbackDurationMs: number ) { @@ -64,20 +64,19 @@ export function measureBrowserTask( }; } -export function didCompleteBrowserWorker( - events: readonly MessageStreamEvent[] -) { +export function didCompleteWorker(events: readonly MessageStreamEvent[]) { return readTaskCompletion(events)?.status === "success"; } -export function didFinishBrowserWorker(events: readonly MessageStreamEvent[]) { +export function didFinishWorker(events: readonly MessageStreamEvent[]) { const backgroundTasks = readBackgroundWorkerTasks(events); if (backgroundTasks.length > 0) { return backgroundTasks.every((task) => task.status !== undefined); } return readTaskCompletion(events) !== undefined; } -export function terminalBrowserMessage( + +export function terminalWorkerMessage( message: string | undefined, events: readonly MessageStreamEvent[] ) { diff --git a/src/lib/manager/chrome-passwords.ts b/src/modules/manager/chrome-passwords.ts similarity index 100% rename from src/lib/manager/chrome-passwords.ts rename to src/modules/manager/chrome-passwords.ts diff --git a/src/lib/manager/index.ts b/src/modules/manager/index.ts similarity index 100% rename from src/lib/manager/index.ts rename to src/modules/manager/index.ts diff --git a/src/lib/manager/payment-card.ts b/src/modules/manager/payment-card.ts similarity index 100% rename from src/lib/manager/payment-card.ts rename to src/modules/manager/payment-card.ts diff --git a/src/lib/manager/server/kernel-login-autofill.ts b/src/modules/manager/server/kernel-login-autofill.ts similarity index 100% rename from src/lib/manager/server/kernel-login-autofill.ts rename to src/modules/manager/server/kernel-login-autofill.ts diff --git a/src/lib/manager/server/kernel-native-autofill.ts b/src/modules/manager/server/kernel-native-autofill.ts similarity index 99% rename from src/lib/manager/server/kernel-native-autofill.ts rename to src/modules/manager/server/kernel-native-autofill.ts index d1e69388..d0e86dee 100644 --- a/src/lib/manager/server/kernel-native-autofill.ts +++ b/src/modules/manager/server/kernel-native-autofill.ts @@ -1,6 +1,6 @@ import Kernel from "@onkernel/sdk"; import { z } from "zod"; -import { env } from "../../env"; +import { env } from "@/lib/env"; import type { AutofillClaim } from "../vault-autofill-protocol"; import { classifyNativeLoginControl, diff --git a/src/lib/manager/server/secret-store.ts b/src/modules/manager/server/secret-store.ts similarity index 97% rename from src/lib/manager/server/secret-store.ts rename to src/modules/manager/server/secret-store.ts index 341a6e40..37a095c9 100644 --- a/src/lib/manager/server/secret-store.ts +++ b/src/modules/manager/server/secret-store.ts @@ -4,7 +4,7 @@ import { readEncryptedSecret, writeEncryptedSecret, } from "@/db/services/secrets"; -import type { AccessScope } from "../../access-scope"; +import type { AccessScope } from "@/lib/access-scope"; import { env } from "@/lib/env"; export function secretStoreStatus() { diff --git a/src/lib/manager/server/store.ts b/src/modules/manager/server/store.ts similarity index 69% rename from src/lib/manager/server/store.ts rename to src/modules/manager/server/store.ts index 1603c05f..c0151291 100644 --- a/src/lib/manager/server/store.ts +++ b/src/modules/manager/server/store.ts @@ -1,13 +1,18 @@ import { randomUUID } from "node:crypto"; +import { + getTokenResponse, + NoValidTokenError, + UserAuthorizationRequiredError, +} from "@vercel/connect"; import { ensureScope } from "@/db/services/scope"; -import { selectGatewayModel } from "@/db/services/settings"; +import { getGatewayModel, selectGatewayModel } from "@/db/services/settings"; import { createVaultItem as insertVaultItem, deleteVaultItem, } from "@/db/services/vault"; -import type { AccessScope } from "../../access-scope"; -import { getGoogleWorkspaceConnection } from "../../google-workspace/server"; -import { getModelSettings } from "../../model-config"; +import type { AccessScope } from "@/lib/access-scope"; +import { env } from "@/lib/env"; +import { googleWorkspaceTokenParams } from "@/lib/google-workspace"; import type { ManagerMutation } from ".."; import { parsePaymentCardSecret, paymentCardBrand } from "../payment-card"; import { loginAccountHint, parseLoginVaultPayload } from "../vault-payload"; @@ -15,21 +20,47 @@ import { deleteSecret, secretStoreStatus, writeSecret } from "./secret-store"; import { readManagerVaultItems } from "./vault"; export async function readManagerSnapshot(scope: AccessScope) { - const [googleWorkspace, vaultRows, modelSettings] = await Promise.all([ + const [googleWorkspace, vaultRows, gatewayModel] = await Promise.all([ getGoogleWorkspaceConnection(scope), readManagerVaultItems(scope), - getModelSettings(scope), + getGatewayModel(scope), ]); return { browser: { available: true }, googleWorkspace, - runtime: { inference: modelSettings.modelId }, + runtime: { inference: gatewayModel }, secretStore: secretStoreStatus(), vaultItems: vaultRows, }; } +async function getGoogleWorkspaceConnection(scope: AccessScope) { + try { + const response = await getTokenResponse( + env.GOOGLE_CONNECTOR_UID, + googleWorkspaceTokenParams(scope.userId), + { forceRefresh: true } + ); + return { + accountLabel: + response.name ?? + (typeof response.claims?.email === "string" + ? response.claims.email + : null), + state: "connected" as const, + }; + } catch (error) { + if ( + error instanceof UserAuthorizationRequiredError || + error instanceof NoValidTokenError + ) { + return { accountLabel: null, state: "disconnected" as const }; + } + return { accountLabel: null, state: "unavailable" as const }; + } +} + export async function applyManagerMutation( scope: AccessScope, mutation: ManagerMutation diff --git a/src/lib/manager/server/vault-autofill-provider.ts b/src/modules/manager/server/vault-autofill-provider.ts similarity index 100% rename from src/lib/manager/server/vault-autofill-provider.ts rename to src/modules/manager/server/vault-autofill-provider.ts diff --git a/src/lib/manager/server/vault-autofill.ts b/src/modules/manager/server/vault-autofill.ts similarity index 95% rename from src/lib/manager/server/vault-autofill.ts rename to src/modules/manager/server/vault-autofill.ts index 07f568cf..b9ffe30a 100644 --- a/src/lib/manager/server/vault-autofill.ts +++ b/src/modules/manager/server/vault-autofill.ts @@ -1,4 +1,4 @@ -import type { AccessScope } from "../../access-scope"; +import type { AccessScope } from "@/lib/access-scope"; import type { AutofillClaim, AutofillSuggestion, diff --git a/src/lib/manager/server/vault.ts b/src/modules/manager/server/vault.ts similarity index 100% rename from src/lib/manager/server/vault.ts rename to src/modules/manager/server/vault.ts diff --git a/src/lib/manager/vault-autofill-protocol.ts b/src/modules/manager/vault-autofill-protocol.ts similarity index 100% rename from src/lib/manager/vault-autofill-protocol.ts rename to src/modules/manager/vault-autofill-protocol.ts diff --git a/src/lib/manager/vault-autofill.ts b/src/modules/manager/vault-autofill.ts similarity index 100% rename from src/lib/manager/vault-autofill.ts rename to src/modules/manager/vault-autofill.ts diff --git a/src/lib/manager/vault-payload.ts b/src/modules/manager/vault-payload.ts similarity index 100% rename from src/lib/manager/vault-payload.ts rename to src/modules/manager/vault-payload.ts diff --git a/src/trpc/router.test.ts b/src/trpc/router.test.ts index f4f3f2c6..5cad509f 100644 --- a/src/trpc/router.test.ts +++ b/src/trpc/router.test.ts @@ -5,31 +5,48 @@ const mocks = vi.hoisted(() => ({ applyManagerMutation: vi.fn<(scope: AccessScope, input: unknown) => Promise>(), disconnectGoogleWorkspace: vi.fn<(scope: AccessScope) => Promise>(), - readModelCatalog: vi.fn<() => Promise>(), - readTaskHistoryPage: - vi.fn< - ( - scope: AccessScope, - cursor?: string - ) => Promise<{ cursor: string | null; hasMore: boolean; runs: never[] }> - >(), + getAvailableModels: vi.fn<() => Promise<{ models: unknown[] }>>(), + listOwnedSessionIds: vi.fn<(scope: AccessScope) => Promise>>(), + listWorkflowRuns: vi.fn< + (input: unknown) => Promise<{ + cursor: string | null; + data: { + attributes: Record; + createdAt: Date; + runId: string; + status: string; + updatedAt: Date; + }[]; + hasMore: boolean; + }> + >(), saveChat: vi.fn<(scope: AccessScope, input: unknown) => Promise>(), - startGoogleWorkspaceAuthorization: - vi.fn<(scope: AccessScope, callbackUrl: string) => Promise>(), + startGoogleWorkspaceAuthorization: vi.fn< + () => Promise<{ + request: string; + url: string; + verifier: string; + }> + >(), })); -vi.mock("@/lib/model-catalog/server", () => ({ - readModelCatalog: mocks.readModelCatalog, +vi.mock("ai", () => ({ + gateway: { getAvailableModels: mocks.getAvailableModels }, })); -vi.mock("@/lib/task-history/server", () => ({ - readTaskHistoryPage: mocks.readTaskHistoryPage, +vi.mock("@workflow/world-vercel", () => ({ + createWorld: vi.fn<() => { runs: { list: typeof mocks.listWorkflowRuns } }>( + () => ({ runs: { list: mocks.listWorkflowRuns } }) + ), +})); +vi.mock("@/db/services/sessions", () => ({ + listOwnedSessionIds: mocks.listOwnedSessionIds, })); vi.mock("@/db/services/chats", () => ({ saveChat: mocks.saveChat })); -vi.mock("@/lib/google-workspace/server", () => ({ - disconnectGoogleWorkspace: mocks.disconnectGoogleWorkspace, - startGoogleWorkspaceAuthorization: mocks.startGoogleWorkspaceAuthorization, +vi.mock("@vercel/connect", () => ({ + revokeToken: mocks.disconnectGoogleWorkspace, + startAuthorization: mocks.startGoogleWorkspaceAuthorization, })); -vi.mock("@/lib/manager/server/store", () => ({ +vi.mock("@/modules/manager/server/store", () => ({ applyManagerMutation: mocks.applyManagerMutation, })); @@ -44,17 +61,61 @@ describe("appRouter", () => { beforeEach(() => vi.clearAllMocks()); it("passes the authenticated scope and cursor to task history", async () => { - mocks.readTaskHistoryPage.mockResolvedValue({ + mocks.listOwnedSessionIds.mockResolvedValue(new Set(["wrun_owned"])); + mocks.listWorkflowRuns.mockResolvedValue({ cursor: null, + data: [ + { + attributes: { + "$eve.title": "Owned task", + "$eve.type": "session", + }, + createdAt: new Date("2026-08-25T20:00:00.000Z"), + runId: "wrun_owned", + status: "running", + updatedAt: new Date("2026-08-25T20:00:08.000Z"), + }, + { + attributes: { + "$eve.title": "Other task", + "$eve.type": "session", + }, + createdAt: new Date("2026-08-25T20:01:00.000Z"), + runId: "wrun_other", + status: "completed", + updatedAt: new Date("2026-08-25T20:01:08.000Z"), + }, + ], hasMore: false, - runs: [], }); - await appRouter + const result = await appRouter .createCaller({ origin: "https://example.com", scope }) .tasks.list({ cursor: "next-page" }); - expect(mocks.readTaskHistoryPage).toHaveBeenCalledWith(scope, "next-page"); + expect(mocks.listOwnedSessionIds).toHaveBeenCalledWith(scope); + expect(mocks.listWorkflowRuns).toHaveBeenCalledWith({ + pagination: { + cursor: "next-page", + limit: 25, + sortOrder: "desc", + }, + resolveData: "none", + workflowName: "workflow//eve//workflowEntry", + }); + expect(result).toEqual({ + cursor: null, + hasMore: false, + runs: [ + { + createdAt: "2026-08-25T20:00:00.000Z", + prompt: "Owned task", + sessionId: "wrun_owned", + status: "running", + updatedAt: "2026-08-25T20:00:08.000Z", + }, + ], + }); }); it("rejects invalid chat writes before persistence", async () => { @@ -67,17 +128,24 @@ describe("appRouter", () => { }); it("returns a typed Google authorization redirect", async () => { - mocks.startGoogleWorkspaceAuthorization.mockResolvedValue( - "https://accounts.google.com/authorize" - ); + mocks.startGoogleWorkspaceAuthorization.mockResolvedValue({ + request: "request", + url: "https://accounts.google.com/authorize", + verifier: "verifier", + }); const result = await appRouter .createCaller({ origin: "https://example.com", scope }) .googleWorkspace.update("connect"); expect(mocks.startGoogleWorkspaceAuthorization).toHaveBeenCalledWith( - scope, - "https://example.com/?google=connected" + expect.any(String), + expect.objectContaining({ + subject: { id: scope.userId, issuer: "openinstinct", type: "user" }, + }), + expect.objectContaining({ + callbackUrl: "https://example.com/?google=connected", + }) ); expect(result).toEqual({ redirectTo: "https://accounts.google.com/authorize", diff --git a/src/trpc/router.ts b/src/trpc/router.ts index feb4f687..fb430a74 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -1,17 +1,26 @@ +import { gateway } from "ai"; import { z } from "zod"; -import { readModelCatalog } from "@/lib/model-catalog/server"; -import { readTaskHistoryPage } from "@/lib/task-history/server"; +import { createWorld } from "@workflow/world-vercel"; +import { revokeToken, startAuthorization } from "@vercel/connect"; import { saveChat } from "@/db/services/chats"; +import { listOwnedSessionIds } from "@/db/services/sessions"; +import { + managerMutationSchema, + managerSnapshotSchema, +} from "@/modules/manager"; +import { applyManagerMutation } from "@/modules/manager/server/store"; +import type { AccessScope } from "@/lib/access-scope"; import { saveChatSchema } from "@/lib/chat"; -import { googleWorkspaceActionSchema } from "@/lib/google-workspace/config"; +import { env } from "@/lib/env"; import { - disconnectGoogleWorkspace, - startGoogleWorkspaceAuthorization, -} from "@/lib/google-workspace/server"; -import { managerMutationSchema, managerSnapshotSchema } from "@/lib/manager"; -import { applyManagerMutation } from "@/lib/manager/server/store"; + googleWorkspaceSubject, + googleWorkspaceTokenParams, +} from "@/lib/google-workspace"; import { createTRPCRouter, protectedProcedure } from "./init"; +const taskHistoryPageSize = 25; +const taskHistoryWorkflowName = "workflow//eve//workflowEntry"; + export const appRouter = createTRPCRouter({ chats: { save: protectedProcedure @@ -20,10 +29,12 @@ export const appRouter = createTRPCRouter({ }, googleWorkspace: { update: protectedProcedure - .input(googleWorkspaceActionSchema) + .input(z.enum(["connect", "disconnect"])) .mutation(async ({ ctx, input }) => { if (input === "disconnect") { - await disconnectGoogleWorkspace(ctx.scope); + await revokeToken(env.GOOGLE_CONNECTOR_UID, { + subject: googleWorkspaceSubject(ctx.scope.userId), + }); return { redirectTo: "/?google=disconnected" }; } @@ -56,3 +67,113 @@ export const appRouter = createTRPCRouter({ }); export type AppRouter = typeof appRouter; + +async function startGoogleWorkspaceAuthorization( + scope: AccessScope, + callbackUrl: string +) { + const authorization = await startAuthorization( + env.GOOGLE_CONNECTOR_UID, + googleWorkspaceTokenParams(scope.userId), + { callbackUrl, expiresInMs: 10 * 60_000 } + ); + return authorization.url; +} + +export async function readTaskHistoryPage(scope: AccessScope, cursor?: string) { + const ownedSessionIds = await listOwnedSessionIds(scope); + const world = createWorld({ + headers: { "User-Agent": "local-vault-assistant/task-history" }, + }); + const runs: Awaited>["data"][number][] = + []; + let nextCursor = cursor; + let hasMore = true; + let pagesRead = 0; + + while (runs.length < taskHistoryPageSize && hasMore && pagesRead < 10) { + const page = await world.runs.list({ + pagination: { + cursor: nextCursor, + limit: taskHistoryPageSize - runs.length, + sortOrder: "desc", + }, + resolveData: "none", + workflowName: taskHistoryWorkflowName, + }); + runs.push( + ...page.data.filter( + (run) => + run.attributes["$eve.type"] === "session" && + ownedSessionIds.has(run.runId) + ) + ); + nextCursor = page.cursor ?? undefined; + hasMore = page.hasMore; + pagesRead += 1; + } + + return { + cursor: nextCursor ?? null, + hasMore, + runs: runs.map((run) => ({ + createdAt: run.createdAt.toISOString(), + prompt: run.attributes["$eve.title"] ?? "Untitled task", + sessionId: run.runId, + status: taskHistoryStatus(run.status), + updatedAt: run.updatedAt.toISOString(), + })), + }; +} + +function taskHistoryStatus(status: string) { + switch (status) { + case "cancelled": + case "completed": + case "failed": + case "pending": + case "running": + return status; + default: + return "failed"; + } +} + +async function readModelCatalog() { + const { models } = await gateway.getAvailableModels(); + + return z + .array( + z.object({ + id: z.string(), + name: z.string(), + ownedBy: z.string(), + pricing: z + .object({ + input: z.number().nonnegative().optional(), + output: z.number().nonnegative().optional(), + }) + .optional(), + }) + ) + .parse( + models + .filter((model) => model.modelType === "language") + .map((model) => ({ + id: model.id, + name: model.name, + ownedBy: model.specification.provider, + pricing: model.pricing + ? { + input: perMillion(model.pricing.input), + output: perMillion(model.pricing.output), + } + : undefined, + })) + ); +} + +function perMillion(value: string) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed * 1_000_000 : undefined; +} diff --git a/src/trpc/types.ts b/src/trpc/types.ts new file mode 100644 index 00000000..2ecb0d78 --- /dev/null +++ b/src/trpc/types.ts @@ -0,0 +1,4 @@ +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "./router"; + +export type RouterOutputs = inferRouterOutputs; diff --git a/tests/agent-tool-boundaries.test.ts b/tests/agent-tool-boundaries.test.ts index 5608c293..8d1adde5 100644 --- a/tests/agent-tool-boundaries.test.ts +++ b/tests/agent-tool-boundaries.test.ts @@ -113,7 +113,7 @@ describe("root and worker capability boundaries", () => { expect(source).not.toContain("new Kernel("); } expect(readFileSync(`${workerTools}/fill_from_vault.ts`, "utf8")).toContain( - 'from "@/lib/manager/server/kernel-native-autofill"' + 'from "@/modules/manager/server/kernel-native-autofill"' ); }); diff --git a/tests/browser-benchmark.test.ts b/tests/browser-benchmark.test.ts index 43e69e56..4f334e05 100644 --- a/tests/browser-benchmark.test.ts +++ b/tests/browser-benchmark.test.ts @@ -1,9 +1,6 @@ import type { MessageStreamEvent } from "eve/client"; import { describe, expect, it } from "vitest"; -import { - didCompleteBrowserWorker, - didFinishBrowserWorker, -} from "@/lib/browser/benchmark"; +import { didCompleteWorker, didFinishWorker } from "@/lib/worker-events"; type ActionResultEvent = Extract; type SubagentResult = Extract< @@ -75,7 +72,7 @@ function completedWorkerResult( describe("browser benchmark event detection", () => { it("recognizes a successful inline subagent result", () => { expect( - didCompleteBrowserWorker([ + didCompleteWorker([ completedWorkerResult({ message: "Browser assignment completed.", status: "success", @@ -97,10 +94,8 @@ describe("browser benchmark event detection", () => { }), ]; - expect(didCompleteBrowserWorker(initialTurn)).toBe(false); - expect(didCompleteBrowserWorker([...initialTurn, ...terminalTurn])).toBe( - true - ); + expect(didCompleteWorker(initialTurn)).toBe(false); + expect(didCompleteWorker([...initialTurn, ...terminalTurn])).toBe(true); }); it("treats a structured worker failure as terminal but unsuccessful", () => { @@ -116,8 +111,8 @@ describe("browser benchmark event detection", () => { }), ]; - expect(didFinishBrowserWorker(events)).toBe(true); - expect(didCompleteBrowserWorker(events)).toBe(false); + expect(didFinishWorker(events)).toBe(true); + expect(didCompleteWorker(events)).toBe(false); }); it.each(["failed.\n\nError:\nWorker failed.", "is cancelled."])( @@ -131,8 +126,8 @@ describe("browser benchmark event detection", () => { const terminalTurn = [terminalWorkerNotification(notification)]; const events = [...initialTurn, ...terminalTurn]; - expect(didFinishBrowserWorker(events)).toBe(true); - expect(didCompleteBrowserWorker(events)).toBe(false); + expect(didFinishWorker(events)).toBe(true); + expect(didCompleteWorker(events)).toBe(false); } ); }); diff --git a/tests/browser-image-route.test.ts b/tests/browser-image-route.test.ts index 9c757883..6cefe628 100644 --- a/tests/browser-image-route.test.ts +++ b/tests/browser-image-route.test.ts @@ -3,11 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -const mocks = vi.hoisted(() => ({ getAuthSession: vi.fn(), getBlob: vi.fn() })); +const mocks = vi.hoisted(() => ({ + getAuthSession: vi.fn(), + getBlob: vi.fn(), + readArtifact: vi.fn(), +})); vi.mock("@/auth/session", () => ({ getAuthSession: mocks.getAuthSession })); -vi.mock("@/lib/browser-images/server", () => ({ - getBrowserImageBlob: mocks.getBlob, +vi.mock("@/db/services/browser-images", () => ({ + readReadyBrowserImageArtifact: mocks.readArtifact, +})); +vi.mock("@vercel/blob", () => ({ + get: mocks.getBlob, })); import { GET } from "@/app/artifacts/[artifactId]/route"; @@ -15,17 +22,16 @@ import { GET } from "@/app/artifacts/[artifactId]/route"; beforeEach(() => { vi.clearAllMocks(); mocks.getAuthSession.mockResolvedValue({ user: { id: "user-1" } }); + mocks.readArtifact.mockResolvedValue({ + byteSize: png.byteLength, + filename: "Product image.png", + mediaType: "image/png", + storagePathname: "artifacts/product", + }); mocks.getBlob.mockResolvedValue({ - artifact: { - byteSize: png.byteLength, - filename: "Product image.png", - mediaType: "image/png", - }, - result: { - blob: { etag: '"etag"' }, - statusCode: 200, - stream: new Response(png).body, - }, + blob: { contentType: "image/png", etag: '"etag"', size: png.byteLength }, + statusCode: 200, + stream: new Response(png).body, }); }); @@ -48,12 +54,9 @@ describe("browser image route", () => { it("passes conditional ETags through to private Blob", async () => { mocks.getBlob.mockResolvedValue({ - artifact: {}, - result: { - blob: { etag: '"etag"' }, - statusCode: 304, - stream: null, - }, + blob: { contentType: "image/png", etag: '"etag"', size: png.byteLength }, + statusCode: 304, + stream: null, }); const response = await GET( @@ -63,8 +66,7 @@ describe("browser image route", () => { expect(response.status).toBe(304); expect(mocks.getBlob).toHaveBeenCalledWith( - expect.objectContaining({ userId: "better-auth:user-1" }), - artifactId, + "artifacts/product", expect.objectContaining({ ifNoneMatch: '"etag"' }) ); }); @@ -86,7 +88,7 @@ describe("browser image route", () => { ); it("does not reveal an unavailable or cross-workspace artifact", async () => { - mocks.getBlob.mockResolvedValue(undefined); + mocks.readArtifact.mockResolvedValue(undefined); const response = await GET(request(), context()); diff --git a/tests/browser-image-storage.test.ts b/tests/browser-image-storage.test.ts deleted file mode 100644 index af8a969c..00000000 --- a/tests/browser-image-storage.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* oxlint-disable vitest/require-mock-type-parameters -- Hoisted Blob and database fakes are configured per test. */ -import { createHash } from "node:crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { maximumBrowserImageBytes } from "@/lib/browser-images"; - -const mocks = vi.hoisted(() => ({ - del: vi.fn(), - finalize: vi.fn(), - get: vi.fn(), - put: vi.fn(), - readReady: vi.fn(), -})); - -vi.mock("@vercel/blob", () => ({ - del: mocks.del, - get: mocks.get, - put: mocks.put, -})); -vi.mock("@/db/services/browser-images", () => ({ - finalizeBrowserImageArtifact: mocks.finalize, - readReadyBrowserImageArtifact: mocks.readReady, -})); - -import { - browserImageBlobAuthentication, - persistReservedBrowserImage, - readBoundedResponse, - readBrowserImageBytes, -} from "@/lib/browser-images/server"; - -const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -const scope = { userId: "user-1", workspaceId: "workspace-1" }; -const reservation = { - id: "0d01e667-d128-4bb7-a248-1ae21db72f4f", - storagePathname: - "browser-images/workspace/0d01e667-d128-4bb7-a248-1ae21db72f4f", -}; - -beforeEach(() => { - vi.clearAllMocks(); - mocks.del.mockResolvedValue(undefined); - mocks.put.mockResolvedValue({ pathname: reservation.storagePathname }); - mocks.finalize.mockResolvedValue({ - image: { id: reservation.id }, - storagePathname: `${reservation.storagePathname}/${createHash("sha256") - .update(png) - .digest("hex")}`, - }); -}); - -describe("browser image storage", () => { - it("prefers a connected store for OIDC and retains token fallback", () => { - expect( - browserImageBlobAuthentication({ - readWriteToken: "legacy-token", - storeId: "store_openinstinct", - }) - ).toEqual({ storeId: "store_openinstinct" }); - expect( - browserImageBlobAuthentication({ readWriteToken: "legacy-token" }) - ).toEqual({ token: "legacy-token" }); - expect(() => browserImageBlobAuthentication({})).toThrow( - "Browser image storage is not configured" - ); - }); - - it("uploads a private bounded image and finalizes its manifest", async () => { - await persistReservedBrowserImage(scope, reservation, { - bytes: png, - filename: "product.png", - sourceKind: "viewport", - }); - - expect(mocks.put).toHaveBeenCalledWith( - `${reservation.storagePathname}/${createHash("sha256") - .update(png) - .digest("hex")}`, - Buffer.from(png), - expect.objectContaining({ - access: "private", - addRandomSuffix: false, - allowOverwrite: true, - contentType: "image/png", - maximumSizeInBytes: maximumBrowserImageBytes, - token: "vercel_blob_rw_test", - }) - ); - expect(mocks.finalize).toHaveBeenCalledWith( - scope, - reservation, - expect.objectContaining({ - byteSize: png.byteLength, - contentHash: createHash("sha256").update(png).digest("hex"), - filename: "product.png", - mediaType: "image/png", - storagePathname: `${reservation.storagePathname}/${createHash("sha256") - .update(png) - .digest("hex")}`, - }) - ); - }); - - it("rejects oversized responses before buffering them", async () => { - const response = new Response("small", { - headers: { "content-length": String(maximumBrowserImageBytes + 1) }, - }); - - await expect(readBoundedResponse(response)).rejects.toThrow("exceeds the"); - }); - - it("loads only the scoped root-session artifact and verifies its hash", async () => { - mocks.readReady.mockResolvedValue({ - byteSize: png.byteLength, - contentHash: createHash("sha256").update(png).digest("hex"), - filename: "product.png", - id: reservation.id, - mediaType: "image/png", - storagePathname: reservation.storagePathname, - }); - mocks.get.mockResolvedValue({ - blob: { - contentType: "image/png", - etag: '"etag"', - size: png.byteLength, - }, - statusCode: 200, - stream: new Response(png).body, - }); - - const result = await readBrowserImageBytes(scope, reservation.id, { - rootSessionId: "root-session", - }); - - expect(mocks.readReady).toHaveBeenCalledWith(scope, reservation.id, { - rootSessionId: "root-session", - }); - expect(result).toEqual({ - bytes: png, - filename: "product.png", - id: reservation.id, - mediaType: "image/png", - }); - }); - - it("rejects content whose bytes do not match the manifest", async () => { - mocks.readReady.mockResolvedValue({ - byteSize: png.byteLength, - contentHash: "not-the-hash", - filename: "product.png", - id: reservation.id, - mediaType: "image/png", - storagePathname: reservation.storagePathname, - }); - mocks.get.mockResolvedValue({ - blob: { - contentType: "image/png", - etag: '"etag"', - size: png.byteLength, - }, - statusCode: 200, - stream: new Response(png).body, - }); - - expect(await readBrowserImageBytes(scope, reservation.id)).toBeUndefined(); - }); - - it("keeps the finalized winner and deletes a losing concurrent upload", async () => { - const winnerPathname = `${reservation.storagePathname}/winner-hash`; - mocks.finalize.mockResolvedValue({ - image: { id: reservation.id }, - storagePathname: winnerPathname, - }); - - await persistReservedBrowserImage(scope, reservation, { - bytes: png, - filename: "product.png", - sourceKind: "viewport", - }); - - const losingPathname = `${reservation.storagePathname}/${createHash( - "sha256" - ) - .update(png) - .digest("hex")}`; - expect(mocks.del).toHaveBeenCalledWith(losingPathname, { - token: "vercel_blob_rw_test", - }); - }); -}); diff --git a/tests/browser-images.test.ts b/tests/browser-images.test.ts index e2a7181f..ea251983 100644 --- a/tests/browser-images.test.ts +++ b/tests/browser-images.test.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest"; import { browserImageArtifactReferenceSchema, browserImageArtifactUrl, - browserImageMarkdown, - extractBrowserImageMarkdownReferences, - safeBrowserImageFilename, + isBrowserImageArtifactUrl, sniffBrowserImageMediaType, - stripBrowserImageMarkdownReferences, -} from "@/lib/browser-images"; -import { isBrowserImageArtifactUrl } from "@/lib/browser-image-path"; +} from "@/lib/browser-artifact"; import { parseTaskCompletionOutput, taskCompletionSchema, -} from "@/lib/task-completion"; +} from "@/lib/worker-completion"; +import { + extractBrowserImageMarkdownReferences, + stripBrowserImageMarkdownReferences, +} from "@/agent/lib/linq-browser-image-markdown"; const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; @@ -49,7 +49,7 @@ describe("browser image contracts", () => { mediaType: "image/png", url: browserImageArtifactUrl(artifactId), }); - const markdown = browserImageMarkdown(artifact); + const markdown = `![Product](${artifact.url})`; const message = `Here it is.\n\n${markdown}\n\n${markdown}`; expect(extractBrowserImageMarkdownReferences(message)).toEqual([ @@ -76,12 +76,6 @@ describe("browser image contracts", () => { expect(sniffBrowserImageMediaType(bytes)).toBe(expected); }); - it("generates safe filenames without retaining paths", () => { - expect( - safeBrowserImageFilename("../../ Product / front?", "image/jpeg") - ).toBe("Product _ front_.jpg"); - }); - it("defaults historical worker results to no images and caps new results", () => { expect( parseTaskCompletionOutput({ message: "Done", status: "success" }) diff --git a/tests/google-workspace.test.ts b/tests/google-workspace.test.ts index 3ee2e6fc..f0285575 100644 --- a/tests/google-workspace.test.ts +++ b/tests/google-workspace.test.ts @@ -2,7 +2,6 @@ import { getTokenResponse, NoValidTokenError, type ConnectTokenResponse, - startAuthorization, } from "@vercel/connect"; import type * as VercelConnect from "@vercel/connect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -11,19 +10,22 @@ import { googleWorkspaceAuthOptions } from "@/agent/lib/google-workspace/client" import { gmailUpdateLabels } from "@/agent/lib/google-workspace/gmail"; import { googleWorkspaceWriteApproval } from "@/agent/tools/google_workspace_write"; import { - GOOGLE_WORKSPACE_SCOPES, + googleWorkspaceScopes, googleWorkspaceSubject, googleWorkspaceTokenParams, -} from "@/lib/google-workspace/config"; -import { - getGoogleWorkspaceConnection, - startGoogleWorkspaceAuthorization, -} from "@/lib/google-workspace/server"; +} from "@/lib/google-workspace"; +import { readManagerSnapshot } from "@/modules/manager/server/store"; vi.mock("@vercel/connect", async (importOriginal) => ({ ...(await importOriginal()), getTokenResponse: vi.fn(), - startAuthorization: vi.fn(), +})); +vi.mock("@/db/services/settings", () => ({ + getGatewayModel: vi.fn<() => Promise>().mockResolvedValue("test"), + selectGatewayModel: vi.fn<() => Promise>(), +})); +vi.mock("@/modules/manager/server/vault", () => ({ + readManagerVaultItems: vi.fn<() => Promise>().mockResolvedValue([]), })); afterEach(() => vi.clearAllMocks()); @@ -35,14 +37,14 @@ const scope = { describe("Google Workspace connection", () => { it("uses one explicit least-privilege scope set", () => { - expect(GOOGLE_WORKSPACE_SCOPES).not.toContain("*"); - expect(GOOGLE_WORKSPACE_SCOPES).not.toContain("https://mail.google.com/"); + expect(googleWorkspaceScopes).not.toContain("*"); + expect(googleWorkspaceScopes).not.toContain("https://mail.google.com/"); expect(googleWorkspaceTokenParams(scope.userId)).toEqual({ - scopes: [...GOOGLE_WORKSPACE_SCOPES], + scopes: [...googleWorkspaceScopes], subject: googleWorkspaceSubject(scope.userId), }); expect(googleWorkspaceAuthOptions.tokenParams).toEqual({ - scopes: [...GOOGLE_WORKSPACE_SCOPES], + scopes: [...googleWorkspaceScopes], }); expect(googleWorkspaceAuthOptions.validate).toBe(true); }); @@ -64,9 +66,11 @@ describe("Google Workspace connection", () => { }; vi.mocked(getTokenResponse).mockResolvedValue(response); - await expect(getGoogleWorkspaceConnection(scope)).resolves.toEqual({ - accountLabel: "person@example.com", - state: "connected", + await expect(readManagerSnapshot(scope)).resolves.toMatchObject({ + googleWorkspace: { + accountLabel: "person@example.com", + state: "connected", + }, }); expect(getTokenResponse).toHaveBeenCalledWith( expect.any(String), @@ -79,32 +83,9 @@ describe("Google Workspace connection", () => { vi.mocked(getTokenResponse).mockRejectedValue( new NoValidTokenError("No Google grant for this user.") ); - await expect(getGoogleWorkspaceConnection(scope)).resolves.toEqual({ - accountLabel: null, - state: "disconnected", - }); - }); - - it("starts authorization with the canonical subject and scopes", async () => { - vi.mocked(startAuthorization).mockResolvedValue({ - request: "request", - url: "https://connect.vercel.com/request", - verifier: "verifier", + await expect(readManagerSnapshot(scope)).resolves.toMatchObject({ + googleWorkspace: { accountLabel: null, state: "disconnected" }, }); - - await expect( - startGoogleWorkspaceAuthorization( - scope, - "https://openinstinct.example/?google=connected" - ) - ).resolves.toBe("https://connect.vercel.com/request"); - expect(startAuthorization).toHaveBeenCalledWith( - expect.any(String), - googleWorkspaceTokenParams(scope.userId), - expect.objectContaining({ - callbackUrl: "https://openinstinct.example/?google=connected", - }) - ); }); it("maps reversible Gmail actions to system labels", () => { diff --git a/tests/linq-browser-image-delivery.test.ts b/tests/linq-browser-image-delivery.test.ts index 076f76ca..5807c6e4 100644 --- a/tests/linq-browser-image-delivery.test.ts +++ b/tests/linq-browser-image-delivery.test.ts @@ -3,10 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const firstId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; const secondId = "206c3a7e-c0b8-4317-9e34-552cff646673"; -const mocks = vi.hoisted(() => ({ readImage: vi.fn() })); +const mocks = vi.hoisted(() => ({ getBlob: vi.fn(), readArtifact: vi.fn() })); -vi.mock("@/lib/browser-images/server", () => ({ - readBrowserImageBytes: mocks.readImage, +vi.mock("@/db/services/browser-images", () => ({ + readReadyBrowserImageArtifact: mocks.readArtifact, +})); +vi.mock("@vercel/blob", () => ({ + get: mocks.getBlob, })); import { prepareLinqBrowserImageDelivery } from "../agent/lib/linq-browser-image-delivery"; @@ -15,16 +18,24 @@ const scope = { userId: "user-1", workspaceId: "workspace-1" }; beforeEach(() => { vi.clearAllMocks(); - mocks.readImage.mockImplementation(async (_scope: unknown, id: string) => + mocks.readArtifact.mockImplementation(async (_scope: unknown, id: string) => id === firstId ? { - bytes: new Uint8Array([1, 2, 3]), + byteSize: 3, + contentHash: + "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", filename: "product.png", id, mediaType: "image/png", + storagePathname: "artifacts/first", } : undefined ); + mocks.getBlob.mockResolvedValue({ + blob: { contentType: "image/png", size: 3 }, + statusCode: 200, + stream: new Response(new Uint8Array([1, 2, 3])).body, + }); }); describe("Linq browser image delivery", () => { @@ -40,7 +51,7 @@ describe("Linq browser image delivery", () => { scope, }); - expect(mocks.readImage).toHaveBeenCalledExactlyOnceWith(scope, firstId, { + expect(mocks.readArtifact).toHaveBeenCalledExactlyOnceWith(scope, firstId, { rootSessionId: "root-session", signal: undefined, }); @@ -82,6 +93,6 @@ describe("Linq browser image delivery", () => { files: [], markdown, }); - expect(mocks.readImage).not.toHaveBeenCalled(); + expect(mocks.readArtifact).not.toHaveBeenCalled(); }); }); diff --git a/tests/linq-message-delivery.test.ts b/tests/linq-message-delivery.test.ts index e3e4cdb6..5b776e76 100644 --- a/tests/linq-message-delivery.test.ts +++ b/tests/linq-message-delivery.test.ts @@ -3,12 +3,57 @@ import type * as LinqModule from "eve/channels/linq"; import { describe, expect, it, vi } from "vitest"; import workerCancellationHook from "../agent/hooks/worker-cancellation-delivery"; +interface BrowserImage { + bytes: Uint8Array; + filename: string; + id: string; + mediaType: string; +} + const linqChannelCapture = vi.hoisted(() => ({ config: undefined as unknown, - readImage: vi.fn(), + images: new Map(), + readImage: + vi.fn< + ( + scope: unknown, + id: string, + options: unknown + ) => Promise + >(), +})); +vi.mock("@/db/services/browser-images", () => ({ + async readReadyBrowserImageArtifact( + scope: unknown, + id: string, + options: unknown + ) { + const image = await linqChannelCapture.readImage(scope, id, options); + if (!image) return undefined; + linqChannelCapture.images.set(id, image); + return { + byteSize: image.bytes.byteLength, + contentHash: + image.bytes[0] === 1 + ? "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81" + : "787c798e39a5bc1910355bae6d0cd87a36b2e10fd0202a83e3bb6b005da83472", + filename: image.filename, + id, + mediaType: image.mediaType, + storagePathname: id, + }; + }, })); -vi.mock("@/lib/browser-images/server", () => ({ - readBrowserImageBytes: linqChannelCapture.readImage, +vi.mock("@vercel/blob", () => ({ + get: async (pathname: string) => { + const image = linqChannelCapture.images.get(pathname); + if (!image) return null; + return { + blob: { contentType: image.mediaType, size: image.bytes.byteLength }, + statusCode: 200, + stream: new Response(Buffer.from(image.bytes)).body, + }; + }, })); vi.mock("eve/channels/linq", async (importOriginal) => { const original = await importOriginal(); diff --git a/tests/manager-setup.test.ts b/tests/manager-setup.test.ts index 03cd6a02..5acfe76d 100644 --- a/tests/manager-setup.test.ts +++ b/tests/manager-setup.test.ts @@ -5,14 +5,14 @@ import { managerMutationSchema, managerSetupRequestSchema, parseManagerSetupSearchParams, -} from "@/lib/manager"; +} from "@/modules/manager"; import { isSameOrigin } from "@/lib/same-origin"; -import { serializePaymentCard } from "@/lib/manager/payment-card"; -import { parseChromePasswordsCsv } from "@/lib/manager/chrome-passwords"; +import { serializePaymentCard } from "@/modules/manager/payment-card"; +import { parseChromePasswordsCsv } from "@/modules/manager/chrome-passwords"; import { serializeContactVaultPayload, serializeLoginVaultPayload, -} from "@/lib/manager/vault-payload"; +} from "@/modules/manager/vault-payload"; describe("self-hosted manager", () => { it("builds a direct Chrome import URL", () => { diff --git a/tests/payment-card.test.ts b/tests/payment-card.test.ts index 0de85ddf..412b8700 100644 --- a/tests/payment-card.test.ts +++ b/tests/payment-card.test.ts @@ -4,7 +4,7 @@ import { paymentCardSecretStringSchema, paymentCardType, serializePaymentCard, -} from "@/lib/manager/payment-card"; +} from "@/modules/manager/payment-card"; describe("payment card vault values", () => { it("serializes a complete structured card secret", () => { diff --git a/tests/services.test.ts b/tests/services.test.ts index 9d4db7dd..87772e96 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -249,8 +249,8 @@ describe("database services", () => { ); await settings.selectGatewayModel(alice, "openai/test"); - expect(await settings.readGatewayModel(alice)).toBe("openai/test"); - expect(await settings.readGatewayModel(bob)).toBeUndefined(); + expect(await settings.getGatewayModel(alice)).toBe("openai/test"); + expect(await settings.getGatewayModel(bob)).toBe("openai/gpt-5.6-sol-fast"); }, 15_000); }); diff --git a/tests/source-layout.test.ts b/tests/source-layout.test.ts new file mode 100644 index 00000000..df88f86d --- /dev/null +++ b/tests/source-layout.test.ts @@ -0,0 +1,72 @@ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const allowedSrcDirectories = [ + "app", + "auth", + "components", + "hooks", + "lib", + "modules", + "trpc", +]; + +const disallowedLibDirectories = [ + "browser", + "browser-images", + "google-workspace", + "manager", + "model-catalog", + "task-history", +]; + +const expectedLibFiles = [ + "access-scope.ts", + "browser-artifact.ts", + "chat.ts", + "env.ts", + "google-workspace.ts", + "kernel.ts", + "request-scope.ts", + "same-origin.ts", + "utils.ts", + "worker-completion.ts", + "worker-events.ts", +]; + +const expectedModuleDirectories = ["manager"]; + +const expectedModuleFiles: string[] = []; + +function directories(directory: string) { + return readdirSync(directory) + .filter((entry) => statSync(join(directory, entry)).isDirectory()) + .toSorted(); +} + +function files(directory: string) { + return readdirSync(directory) + .filter((entry) => statSync(join(directory, entry)).isFile()) + .toSorted(); +} + +describe("source layout", () => { + it("keeps feature-owned code under modules instead of src root", () => { + expect(directories("src")).toEqual(allowedSrcDirectories); + }); + + it("keeps lib limited to shared infrastructure and contracts", () => { + const libDirectories = directories("src/lib"); + + for (const directory of disallowedLibDirectories) { + expect(libDirectories).not.toContain(directory); + } + expect(files("src/lib")).toEqual(expectedLibFiles); + }); + + it("keeps modules limited to cross-cutting product domains", () => { + expect(directories("src/modules")).toEqual(expectedModuleDirectories); + expect(files("src/modules")).toEqual(expectedModuleFiles); + }); +}); diff --git a/tests/vault-autofill.test.ts b/tests/vault-autofill.test.ts index 6a7270f3..9fd8d4aa 100644 --- a/tests/vault-autofill.test.ts +++ b/tests/vault-autofill.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it } from "vitest"; import type { AccessScope } from "@/lib/access-scope"; -import type { VaultItemKind } from "@/lib/manager"; -import { serializePaymentCard } from "@/lib/manager/payment-card"; +import type { VaultItemKind } from "@/modules/manager"; +import { serializePaymentCard } from "@/modules/manager/payment-card"; import { classifyNativeLoginControl, selectNativeLoginFills, type NativeLoginControlDescriptor, -} from "@/lib/manager/server/kernel-login-autofill"; +} from "@/modules/manager/server/kernel-login-autofill"; import { buildNativeAutofillPayload, nativeAutofillSecretMarkingExpression, nativeAutofillTokens, -} from "@/lib/manager/server/kernel-native-autofill"; +} from "@/modules/manager/server/kernel-native-autofill"; import { listAutofillSuggestions, materializeAutofillClaims, type AutofillVaultAdapter, -} from "@/lib/manager/server/vault-autofill"; -import { createVaultAutofillProvider } from "@/lib/manager/server/vault-autofill-provider"; +} from "@/modules/manager/server/vault-autofill"; +import { createVaultAutofillProvider } from "@/modules/manager/server/vault-autofill-provider"; import { serializeAddressVaultPayload, serializeContactVaultPayload, serializeLoginVaultPayload, -} from "@/lib/manager/vault-payload"; +} from "@/modules/manager/vault-payload"; const scope: AccessScope = { userId: "user-1", diff --git a/tests/vault-payload.test.ts b/tests/vault-payload.test.ts index 98f65cd9..f0fbc824 100644 --- a/tests/vault-payload.test.ts +++ b/tests/vault-payload.test.ts @@ -12,7 +12,7 @@ import { serializeAddressVaultPayload, serializeContactVaultPayload, serializeLoginVaultPayload, -} from "@/lib/manager/vault-payload"; +} from "@/modules/manager/vault-payload"; describe("versioned vault payloads", () => { it("stores password and passwordless login methods", () => { diff --git a/tests/worker-browser-images.test.ts b/tests/worker-browser-images.test.ts index b929b63d..fabe1e4f 100644 --- a/tests/worker-browser-images.test.ts +++ b/tests/worker-browser-images.test.ts @@ -14,15 +14,16 @@ const image = { const mocks = vi.hoisted(() => ({ captureScreenshot: vi.fn(), + del: vi.fn(), deleteFile: vi.fn(), fetch: vi.fn(), mask: vi.fn(), persist: vi.fn(), playwrightExecute: vi.fn(), - readBoundedResponse: vi.fn(), readFile: vi.fn(), reserve: vi.fn(), retrieve: vi.fn(), + put: vi.fn(), requireOwnedBrowserSession: vi.fn(), requireWorkerScope: vi.fn(), })); @@ -37,11 +38,12 @@ vi.mock("@/agent/subagents/worker/lib/vault-screenshot-mask", () => ({ withVaultScreenshotMask: mocks.mask, })); vi.mock("@/db/services/browser-images", () => ({ + finalizeBrowserImageArtifact: mocks.persist, reserveBrowserImageArtifact: mocks.reserve, })); -vi.mock("@/lib/browser-images/server", () => ({ - persistReservedBrowserImage: mocks.persist, - readBoundedResponse: mocks.readBoundedResponse, +vi.mock("@vercel/blob", () => ({ + del: mocks.del, + put: mocks.put, })); vi.mock("@/lib/kernel", () => ({ kernel: { @@ -70,13 +72,14 @@ beforeEach(() => { sessionId: "browser-1", }); mocks.reserve.mockResolvedValue({ reservation, status: "pending" }); - mocks.persist.mockResolvedValue(image); + mocks.persist.mockResolvedValue({ image, storagePathname: "stored/image" }); + mocks.del.mockResolvedValue(undefined); + mocks.put.mockResolvedValue({ pathname: "stored/image" }); mocks.mask.mockImplementation( async (_sessionId: string, _signal: AbortSignal, capture: () => unknown) => capture() ); mocks.captureScreenshot.mockResolvedValue(new Response(png)); - mocks.readBoundedResponse.mockResolvedValue(png); mocks.playwrightExecute.mockResolvedValue({ result: true, success: true }); mocks.readFile.mockResolvedValue(new Response(png)); mocks.deleteFile.mockResolvedValue(undefined); @@ -112,8 +115,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "viewport" }), - undefined + expect.objectContaining({ sourceKind: "viewport" }) ); expect(result).toEqual({ image }); expect(JSON.stringify(result)).not.toContain("base64"); @@ -172,8 +174,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "image_resource" }), - undefined + expect.objectContaining({ sourceKind: "image_resource" }) ); expect(JSON.stringify(mocks.persist.mock.calls)).not.toContain( "private=ignored" @@ -203,8 +204,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "element" }), - undefined + expect.objectContaining({ sourceKind: "element" }) ); }); diff --git a/vitest.config.ts b/vitest.config.ts index 71e0d8eb..988238f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ resolve: { alias: [ { - find: /^@\/(app|auth|components|hooks|lib|trpc)(\/.*)?$/, + find: /^@\/(app|auth|components|hooks|lib|modules|trpc)(\/.*)?$/, replacement: fileURLToPath(new URL("src/$1$2", import.meta.url)), }, { From 6f460751995b7e0b6adac3a23d8afd87df722b86 Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:27:21 -0400 Subject: [PATCH 2/4] refactor: dissolve manager module --- .../subagents/worker/lib/autofill/login.ts | 2 +- .../subagents/worker/lib/autofill/native.ts | 4 +- .../subagents/worker/lib/autofill/protocol.ts | 0 .../subagents/worker/lib/autofill/provider.ts | 156 +++--- .../subagents/worker/lib/autofill/service.ts | 2 +- .../subagents/worker/tools/fill_from_vault.ts | 14 +- agent/subagents/worker/tools/list_vault.ts | 4 +- agent/tools/request_vault_import.ts | 3 +- agent/tools/request_vault_setup.ts | 9 +- db/services/vault.ts | 140 ++++- .../_components/google-workspace-action.tsx | 3 +- .../(manager)/_components/model-selector.tsx | 42 +- src/app/(authenticated)/(manager)/page.tsx | 59 ++- .../form.tsx} | 62 +-- .../vault/_components/addresses/index.tsx | 83 +++ .../{payment-card-form.tsx => cards/form.tsx} | 57 +-- .../vault/_components/cards/index.tsx | 83 +++ .../form.tsx} | 62 +-- .../vault/_components/contacts/index.tsx | 83 +++ .../{vault-form-field.tsx => field.tsx} | 2 +- .../{login-vault-form.tsx => logins/form.tsx} | 64 +-- .../import.tsx} | 213 ++++++-- .../vault/_components/logins/index.tsx | 105 ++++ .../(manager)/vault/_components/other.tsx | 26 + .../(manager)/vault/_components/section.tsx | 280 ++++++++++ .../(manager)/vault/_components/setup.ts | 12 + .../vault/_components/vault-manager.tsx | 483 ------------------ .../(authenticated)/(manager)/vault/page.tsx | 44 +- .../manager/vault-payload.ts => lib/vault.ts} | 163 ++++++ src/modules/manager/chrome-passwords.ts | 164 ------ src/modules/manager/index.ts | 166 ------ src/modules/manager/payment-card.ts | 58 --- src/modules/manager/server/secret-store.ts | 107 ---- src/modules/manager/server/store.ts | 138 ----- src/modules/manager/server/vault.ts | 19 - src/modules/manager/vault-autofill.ts | 6 - src/trpc/router.test.ts | 6 - src/trpc/router.ts | 32 +- tests/agent-tool-boundaries.test.ts | 2 +- tests/google-workspace.test.ts | 107 +--- tests/manager-setup.test.ts | 294 ----------- tests/payment-card.test.ts | 2 +- tests/services.test.ts | 27 +- tests/source-layout.test.ts | 13 +- tests/vault-autofill.test.ts | 90 ++-- tests/vault-payload.test.ts | 2 +- tests/vault-setup.test.ts | 73 +++ 47 files changed, 1621 insertions(+), 1945 deletions(-) rename src/modules/manager/server/kernel-login-autofill.ts => agent/subagents/worker/lib/autofill/login.ts (98%) rename src/modules/manager/server/kernel-native-autofill.ts => agent/subagents/worker/lib/autofill/native.ts (99%) rename src/modules/manager/vault-autofill-protocol.ts => agent/subagents/worker/lib/autofill/protocol.ts (100%) rename src/modules/manager/server/vault-autofill-provider.ts => agent/subagents/worker/lib/autofill/provider.ts (63%) rename src/modules/manager/server/vault-autofill.ts => agent/subagents/worker/lib/autofill/service.ts (97%) rename src/app/(authenticated)/(manager)/vault/_components/{address-vault-form.tsx => addresses/form.tsx} (79%) create mode 100644 src/app/(authenticated)/(manager)/vault/_components/addresses/index.tsx rename src/app/(authenticated)/(manager)/vault/_components/{payment-card-form.tsx => cards/form.tsx} (87%) create mode 100644 src/app/(authenticated)/(manager)/vault/_components/cards/index.tsx rename src/app/(authenticated)/(manager)/vault/_components/{contact-vault-form.tsx => contacts/form.tsx} (70%) create mode 100644 src/app/(authenticated)/(manager)/vault/_components/contacts/index.tsx rename src/app/(authenticated)/(manager)/vault/_components/{vault-form-field.tsx => field.tsx} (95%) rename src/app/(authenticated)/(manager)/vault/_components/{login-vault-form.tsx => logins/form.tsx} (86%) rename src/app/(authenticated)/(manager)/vault/_components/{chrome-password-import.tsx => logins/import.tsx} (53%) create mode 100644 src/app/(authenticated)/(manager)/vault/_components/logins/index.tsx create mode 100644 src/app/(authenticated)/(manager)/vault/_components/other.tsx create mode 100644 src/app/(authenticated)/(manager)/vault/_components/section.tsx create mode 100644 src/app/(authenticated)/(manager)/vault/_components/setup.ts delete mode 100644 src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx rename src/{modules/manager/vault-payload.ts => lib/vault.ts} (56%) delete mode 100644 src/modules/manager/chrome-passwords.ts delete mode 100644 src/modules/manager/index.ts delete mode 100644 src/modules/manager/payment-card.ts delete mode 100644 src/modules/manager/server/secret-store.ts delete mode 100644 src/modules/manager/server/store.ts delete mode 100644 src/modules/manager/server/vault.ts delete mode 100644 src/modules/manager/vault-autofill.ts delete mode 100644 tests/manager-setup.test.ts create mode 100644 tests/vault-setup.test.ts diff --git a/src/modules/manager/server/kernel-login-autofill.ts b/agent/subagents/worker/lib/autofill/login.ts similarity index 98% rename from src/modules/manager/server/kernel-login-autofill.ts rename to agent/subagents/worker/lib/autofill/login.ts index 8b6bd3ce..b2fff796 100644 --- a/src/modules/manager/server/kernel-login-autofill.ts +++ b/agent/subagents/worker/lib/autofill/login.ts @@ -1,4 +1,4 @@ -import type { AutofillClaim } from "../vault-autofill-protocol"; +import type { AutofillClaim } from "./protocol"; export const nativeLoginAutofillTokens = [ "username", diff --git a/src/modules/manager/server/kernel-native-autofill.ts b/agent/subagents/worker/lib/autofill/native.ts similarity index 99% rename from src/modules/manager/server/kernel-native-autofill.ts rename to agent/subagents/worker/lib/autofill/native.ts index d0e86dee..d011f286 100644 --- a/src/modules/manager/server/kernel-native-autofill.ts +++ b/agent/subagents/worker/lib/autofill/native.ts @@ -1,7 +1,7 @@ import Kernel from "@onkernel/sdk"; import { z } from "zod"; import { env } from "@/lib/env"; -import type { AutofillClaim } from "../vault-autofill-protocol"; +import type { AutofillClaim } from "./protocol"; import { classifyNativeLoginControl, nativeLoginAutofillTokens, @@ -9,7 +9,7 @@ import { nativeLoginFillFunctionDeclaration, selectNativeLoginFills, type ClassifiedNativeLoginControl, -} from "./kernel-login-autofill"; +} from "./login"; const targetListSchema = z.object({ targetInfos: z.array( diff --git a/src/modules/manager/vault-autofill-protocol.ts b/agent/subagents/worker/lib/autofill/protocol.ts similarity index 100% rename from src/modules/manager/vault-autofill-protocol.ts rename to agent/subagents/worker/lib/autofill/protocol.ts diff --git a/src/modules/manager/server/vault-autofill-provider.ts b/agent/subagents/worker/lib/autofill/provider.ts similarity index 63% rename from src/modules/manager/server/vault-autofill-provider.ts rename to agent/subagents/worker/lib/autofill/provider.ts index 65183235..a77861e9 100644 --- a/src/modules/manager/server/vault-autofill-provider.ts +++ b/agent/subagents/worker/lib/autofill/provider.ts @@ -1,14 +1,18 @@ -import { listVaultItems, readVaultItem } from "@/db/services/vault"; -import type { VaultItemKind } from ".."; -import { parsePaymentCardSecret } from "../payment-card"; -import type { DetectedAutofillSurface } from "../vault-autofill-protocol"; +import { + hasVaultSecret, + listVaultItems, + readVaultItem, + readVaultSecret, +} from "@/db/services/vault"; import { parseAddressVaultPayload, parseContactVaultPayload, parseLoginVaultPayload, -} from "../vault-payload"; -import { hasSecret, readSecret } from "./secret-store"; -import type { AutofillVaultAdapter } from "./vault-autofill"; + parsePaymentCardSecret, + type VaultItemKind, +} from "@/lib/vault"; +import type { DetectedAutofillSurface } from "./protocol"; +import type { AutofillVaultAdapter } from "./service"; interface VaultAutofillCodec { readonly claims: ( @@ -155,96 +159,70 @@ const codecs: readonly VaultAutofillCodec[] = [ }, ]; -export function createVaultAutofillProvider( - dependencies: { - readonly hasSecret?: typeof hasSecret; - readonly listVaultItems?: typeof listVaultItems; - readonly readSecret?: typeof readSecret; - readonly readVaultItem?: typeof readVaultItem; - } = {} -): AutofillVaultAdapter { - const stores = { - hasSecret: dependencies.hasSecret ?? hasSecret, - listVaultItems: dependencies.listVaultItems ?? listVaultItems, - readSecret: dependencies.readSecret ?? readSecret, - readVaultItem: dependencies.readVaultItem ?? readVaultItem, - }; - - return { - async listSuggestions(scope, origin, surface) { - const compatibleCodecs = codecsForSurface(surface); - if (compatibleCodecs.length === 0) return []; - - const items = await stores.listVaultItems(scope); - const compatibleItems = items.flatMap((item) => { - const codec = compatibleCodecs.find( - (candidate) => - candidate.vaultKind === item.kind && - surface.fields.some(({ token }) => candidate.tokens.includes(token)) - ); - return codec ? [{ codec, item }] : []; - }); - const availability = await Promise.all( - compatibleItems.map(async ({ codec, item }) => { - if (!codec.isAvailableAtOrigin) { - return stores.hasSecret({ id: item.id, namespace: "vault", scope }); - } - const secret = await stores.readSecret({ - id: item.id, - namespace: "vault", - scope, - }); - return ( - secret !== undefined && codec.isAvailableAtOrigin(secret, origin) - ); - }) - ); +export const vaultAutofillProvider: AutofillVaultAdapter = { + async listSuggestions(scope, origin, surface) { + const compatibleCodecs = codecsForSurface(surface); + if (compatibleCodecs.length === 0) return []; - return compatibleItems.flatMap(({ codec, item }, index) => { - if (!availability[index]) return []; - return [ - { - candidateId: item.id, - label: item.label, - matchReason: codec.matchReason, - summary: item.account, - }, - ]; - }); - }, - - async materializeClaims(scope, candidateId, target) { - const item = await stores.readVaultItem(scope, candidateId); - if (!item) throw new Error("The selected vault item was not found."); - - const codec = codecs.find( + const items = await listVaultItems(scope); + const compatibleItems = items.flatMap((item) => { + const codec = compatibleCodecs.find( (candidate) => candidate.vaultKind === item.kind && - candidate.surfaceKinds.includes(target.surface.kind) + surface.fields.some(({ token }) => candidate.tokens.includes(token)) ); - if (!codec) { - throw new Error( - "The selected vault item is not compatible with this form." + return codec ? [{ codec, item }] : []; + }); + const availability = await Promise.all( + compatibleItems.map(async ({ codec, item }) => { + if (!codec.isAvailableAtOrigin) { + return hasVaultSecret(scope, item.id); + } + const secret = await readVaultSecret(scope, item.id); + return ( + secret !== undefined && codec.isAvailableAtOrigin(secret, origin) ); - } + }) + ); - const secret = await stores.readSecret({ - id: item.id, - namespace: "vault", - scope, - }); - if (!secret) throw new Error("The selected vault item has no secret."); + return compatibleItems.flatMap(({ codec, item }, index) => { + if (!availability[index]) return []; + return [ + { + candidateId: item.id, + label: item.label, + matchReason: codec.matchReason, + summary: item.account, + }, + ]; + }); + }, - const values = codec.claims(item, secret, target.origin); - return [...target.availableTokens].flatMap((token) => { - const value = values.get(token); - return value ? [{ id: crypto.randomUUID(), token, value }] : []; - }); - }, - }; -} + async materializeClaims(scope, candidateId, target) { + const item = await readVaultItem(scope, candidateId); + if (!item) throw new Error("The selected vault item was not found."); -export const vaultAutofillProvider = createVaultAutofillProvider(); + const codec = codecs.find( + (candidate) => + candidate.vaultKind === item.kind && + candidate.surfaceKinds.includes(target.surface.kind) + ); + if (!codec) { + throw new Error( + "The selected vault item is not compatible with this form." + ); + } + + const secret = await readVaultSecret(scope, item.id); + if (!secret) throw new Error("The selected vault item has no secret."); + + const values = codec.claims(item, secret, target.origin); + return [...target.availableTokens].flatMap((token) => { + const value = values.get(token); + return value ? [{ id: crypto.randomUUID(), token, value }] : []; + }); + }, +}; function codecsForSurface(surface: DetectedAutofillSurface) { return codecs.filter((codec) => codec.surfaceKinds.includes(surface.kind)); diff --git a/src/modules/manager/server/vault-autofill.ts b/agent/subagents/worker/lib/autofill/service.ts similarity index 97% rename from src/modules/manager/server/vault-autofill.ts rename to agent/subagents/worker/lib/autofill/service.ts index b9ffe30a..8a4b6509 100644 --- a/src/modules/manager/server/vault-autofill.ts +++ b/agent/subagents/worker/lib/autofill/service.ts @@ -3,7 +3,7 @@ import type { AutofillClaim, AutofillSuggestion, DetectedAutofillSurface, -} from "../vault-autofill-protocol"; +} from "./protocol"; export interface AutofillFillTarget { readonly availableTokens: ReadonlySet; diff --git a/agent/subagents/worker/tools/fill_from_vault.ts b/agent/subagents/worker/tools/fill_from_vault.ts index 0a797707..9a47b856 100644 --- a/agent/subagents/worker/tools/fill_from_vault.ts +++ b/agent/subagents/worker/tools/fill_from_vault.ts @@ -4,14 +4,18 @@ import { requireOwnedBrowserSession } from "@/agent/subagents/worker/lib/owned-b import { requireWorkerScope } from "@/agent/subagents/worker/lib/access"; import { readVaultItem } from "@/db/services/vault"; import { kernel } from "@/lib/kernel"; -import { materializeAutofillClaims } from "@/modules/manager/server/vault-autofill"; -import { vaultAutofillProvider } from "@/modules/manager/server/vault-autofill-provider"; import { currentKernelPageOrigin, fillWithKernelNativeAutofill, nativeAutofillTokens, -} from "@/modules/manager/server/kernel-native-autofill"; -import { fillFromVaultRequestSchema } from "@/modules/manager/vault-autofill"; +} from "../lib/autofill/native"; +import { vaultAutofillProvider } from "../lib/autofill/provider"; +import { materializeAutofillClaims } from "../lib/autofill/service"; + +const inputSchema = z.object({ + browserSessionId: z.string().trim().min(1).max(500), + candidateId: z.string().trim().min(1).max(500), +}); const outputSchema = z.object({ filledClaims: z.number().int().nonnegative(), @@ -23,7 +27,7 @@ const outputSchema = z.object({ export default defineTool({ description: "Fill a login, card, or address form with an opaque handle returned by list_vault. Focus one control in the intended form first. Never supply vault fields, selectors, origins, or secret values.", - inputSchema: fillFromVaultRequestSchema, + inputSchema, outputSchema, async execute(input, context) { const scope = await requireWorkerScope(context); diff --git a/agent/subagents/worker/tools/list_vault.ts b/agent/subagents/worker/tools/list_vault.ts index ed8899e2..24a3faba 100644 --- a/agent/subagents/worker/tools/list_vault.ts +++ b/agent/subagents/worker/tools/list_vault.ts @@ -1,14 +1,14 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; -import { readManagerVaultItems } from "@/modules/manager/server/vault"; import { requireWorkerScope } from "@/agent/subagents/worker/lib/access"; +import { readVaultItems } from "@/db/services/vault"; export default defineTool({ description: "List safe metadata and opaque handles for credentials stored in the local vault. Never returns secret values.", inputSchema: z.object({}), async execute(_input, ctx) { - const items = await readManagerVaultItems(await requireWorkerScope(ctx)); + const items = await readVaultItems(await requireWorkerScope(ctx)); return items.map(({ account, hasSecret, id, kind, label }) => ({ account, available: hasSecret, diff --git a/agent/tools/request_vault_import.ts b/agent/tools/request_vault_import.ts index 6913bdb6..2cdd067d 100644 --- a/agent/tools/request_vault_import.ts +++ b/agent/tools/request_vault_import.ts @@ -1,7 +1,6 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { env } from "@/lib/env"; -import { createManagerImportUrl } from "@/modules/manager"; export default defineTool({ description: @@ -11,7 +10,7 @@ export default defineTool({ return { message: "Open this page in your Local Vault Assistant deployment. It explains how to export from Chrome and opens the secure importer directly.", - url: createManagerImportUrl(env.BETTER_AUTH_URL), + url: new URL("/vault?import=chrome", env.BETTER_AUTH_URL).toString(), }; }, }); diff --git a/agent/tools/request_vault_setup.ts b/agent/tools/request_vault_setup.ts index 341a177f..ab5b447a 100644 --- a/agent/tools/request_vault_setup.ts +++ b/agent/tools/request_vault_setup.ts @@ -1,19 +1,16 @@ import { defineTool } from "eve/tools"; -import { - createManagerSetupUrl, - managerSetupRequestSchema, -} from "@/modules/manager"; import { env } from "@/lib/env"; +import { createVaultSetupUrl, vaultSetupRequestSchema } from "@/lib/vault"; export default defineTool({ description: "Create a safe link for adding one supported item to the self-hosted vault. Supported kinds are login (email, phone, or username with a password or one-time-code method), payment (card details), address (structured delivery or billing address), and contact (name, email, and phone). A login setup requires a descriptive label, identifierType, and the exact current website origin; the user enters the actual identifier and secret on the vault page. Other kinds accept only kind and an optional label. Never put an email address, phone number, username, or secret in this setup request. Use ordinary non-secret contact details directly when the user supplied them in chat.", - inputSchema: managerSetupRequestSchema, + inputSchema: vaultSetupRequestSchema, execute(request) { return { message: "Open this page in your Local Vault Assistant deployment and complete the form. Do not send the secret in chat.", - url: createManagerSetupUrl(env.BETTER_AUTH_URL, request), + url: createVaultSetupUrl(env.BETTER_AUTH_URL, request), }; }, }); diff --git a/db/services/vault.ts b/db/services/vault.ts index 53961e7c..f0da5163 100644 --- a/db/services/vault.ts +++ b/db/services/vault.ts @@ -1,8 +1,28 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + randomUUID, +} from "node:crypto"; import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; +import { + loginAccountHint, + parsePaymentCardSecret, + parseLoginVaultPayload, + paymentCardBrand, + vaultItemKindSchema, + type VaultCreateItem, +} from "@/lib/vault"; import type { AccessScope } from "@/lib/access-scope"; -import { vaultItemKindSchema } from "@/modules/manager"; import { db, vaultItems } from "@/db"; +import { + deleteEncryptedSecret, + readEncryptedSecret, + writeEncryptedSecret, +} from "@/db/services/secrets"; +import { ensureScope } from "@/db/services/scope"; +import { env } from "@/lib/env"; const vaultRecordSchema = z.object({ account: z.string(), @@ -24,7 +44,7 @@ const selection = { updatedAt: vaultItems.updatedAt, }; -export async function createVaultItem(scope: AccessScope, record: VaultRecord) { +async function createVaultRecord(scope: AccessScope, record: VaultRecord) { await db.insert(vaultItems).values({ ...record, workspaceId: scope.workspaceId, @@ -43,6 +63,17 @@ export async function listVaultItems(scope: AccessScope) { ); } +export async function readVaultItems(scope: AccessScope) { + await ensureScope(scope); + const records = await listVaultItems(scope); + return Promise.all( + records.map(async (record) => ({ + ...record, + hasSecret: await hasVaultSecret(scope, record.id), + })) + ); +} + export async function readVaultItem(scope: AccessScope, id: string) { const rows = await db .select(selection) @@ -61,5 +92,108 @@ export async function deleteVaultItem(scope: AccessScope, id: string) { and(eq(vaultItems.workspaceId, scope.workspaceId), eq(vaultItems.id, id)) ) .returning({ id: vaultItems.id }); - return rows.length > 0; + if (rows.length === 0) return false; + await deleteEncryptedSecret(scope, id); + return true; +} + +export async function saveVaultItem( + scope: AccessScope, + input: VaultCreateItem +) { + await ensureScope(scope); + const id = randomUUID(); + const now = new Date().toISOString(); + await writeVaultSecret(scope, id, input.secret); + + try { + await createVaultRecord(scope, { + account: vaultAccountHint(input), + createdAt: now, + id, + kind: input.kind, + label: input.label, + updatedAt: now, + }); + } catch (error) { + await deleteEncryptedSecret(scope, id); + throw error; + } +} + +export async function readVaultSecret(scope: AccessScope, id: string) { + const encrypted = await readEncryptedSecret(scope, id); + return encrypted ? decryptVaultSecret(scope, id, encrypted) : undefined; +} + +export async function hasVaultSecret(scope: AccessScope, id: string) { + return (await readEncryptedSecret(scope, id)) !== undefined; +} + +async function writeVaultSecret(scope: AccessScope, id: string, value: string) { + await writeEncryptedSecret(scope, id, encryptVaultSecret(scope, id, value)); +} + +function vaultAccountHint(input: VaultCreateItem) { + switch (input.kind) { + case "login": { + const payload = parseLoginVaultPayload(input.secret); + if (!payload) + throw new Error("The saved login is incomplete or invalid."); + return loginAccountHint( + payload.identifier, + "origin" in payload ? payload.origin : undefined + ); + } + case "payment": { + const card = parsePaymentCardSecret(input.secret); + return `${paymentCardBrand(card.number)} · •••• ${card.number.slice(-4)}`; + } + case "address": + case "contact": + return ""; + } +} + +function encryptVaultSecret(scope: AccessScope, id: string, value: string) { + const iv = randomBytes(12); + const cipher = createCipheriv( + "aes-256-gcm", + Buffer.from(env.SECRET_ENCRYPTION_KEY, "base64"), + iv + ); + cipher.setAAD(vaultSecretAad(scope, id)); + const ciphertext = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); + return [ + "v1", + iv.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + ciphertext.toString("base64url"), + ].join("."); +} + +function decryptVaultSecret(scope: AccessScope, id: string, value: string) { + const [version, encodedIv, encodedTag, encodedCiphertext] = value.split("."); + if (version !== "v1" || !encodedIv || !encodedTag || !encodedCiphertext) { + throw new Error("The stored secret uses an unsupported format."); + } + + const decipher = createDecipheriv( + "aes-256-gcm", + Buffer.from(env.SECRET_ENCRYPTION_KEY, "base64"), + Buffer.from(encodedIv, "base64url") + ); + decipher.setAAD(vaultSecretAad(scope, id)); + decipher.setAuthTag(Buffer.from(encodedTag, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(encodedCiphertext, "base64url")), + decipher.final(), + ]).toString("utf8"); +} + +function vaultSecretAad(scope: AccessScope, id: string) { + return Buffer.from(`${scope.workspaceId}\u0000vault\u0000${id}`); } diff --git a/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx b/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx index 4db9aa0d..3d4e8904 100644 --- a/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx +++ b/src/app/(authenticated)/(manager)/_components/google-workspace-action.tsx @@ -1,13 +1,12 @@ "use client"; import { Button } from "@/components/ui/button"; -import type { ManagerSnapshot } from "@/modules/manager"; import { api } from "@/trpc/client"; export function GoogleWorkspaceAction({ state, }: { - readonly state?: ManagerSnapshot["googleWorkspace"]["state"]; + readonly state?: "connected" | "disconnected" | "unavailable"; }) { const update = api.googleWorkspace.update.useMutation({ onError: () => window.location.assign("/?google=unavailable"), diff --git a/src/app/(authenticated)/(manager)/_components/model-selector.tsx b/src/app/(authenticated)/(manager)/_components/model-selector.tsx index e56bbabb..921b88b9 100644 --- a/src/app/(authenticated)/(manager)/_components/model-selector.tsx +++ b/src/app/(authenticated)/(manager)/_components/model-selector.tsx @@ -31,13 +31,16 @@ const priceFormatter = new Intl.NumberFormat("en-US", { export function ModelSelector({ modelId }: { readonly modelId: string }) { const router = useRouter(); const [open, setOpen] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(); const catalog = api.models.list.useQuery(undefined, { enabled: open, staleTime: 5 * 60 * 1000, }); - const mutateManager = api.manager.mutate.useMutation(); + const selectModel = api.settings.selectModel.useMutation({ + onSuccess: () => { + setOpen(false); + router.refresh(); + }, + }); const groupedModels = useMemo(() => { const groups = new Map(); for (const model of catalog.data ?? []) { @@ -50,31 +53,26 @@ export function ModelSelector({ modelId }: { readonly modelId: string }) { ); }, [catalog.data]); - const select = async (selectedModelId: string) => { - setBusy(true); - setError(undefined); - try { - await mutateManager.mutateAsync({ - action: "model.select", - modelId: selectedModelId, - }); - setOpen(false); - router.refresh(); - } catch { - setError("Unable to update the workspace. Try again."); - } finally { - setBusy(false); - } - }; + const select = (selectedModelId: string) => + selectModel.mutate({ modelId: selectedModelId }); const catalogError = - catalog.error instanceof Error ? catalog.error.message : error; + catalog.error instanceof Error + ? catalog.error.message + : selectModel.error + ? "Unable to update the workspace. Try again." + : undefined; return ( + diff --git a/src/app/(authenticated)/(manager)/vault/_components/addresses/index.tsx b/src/app/(authenticated)/(manager)/vault/_components/addresses/index.tsx new file mode 100644 index 00000000..6cab463e --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/addresses/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import type { VaultItem } from "@/lib/vault"; +import { Button } from "@/components/ui/button"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AddressForm } from "./form"; +import { + useVaultSection, + VaultItemBrowser, + VaultSection, + VaultSectionBackButton, + VaultSectionContent, + VaultSectionTrigger, +} from "../section"; +import { useVaultSetup } from "../setup"; + +export function VaultAddresses({ + items, +}: { + readonly items: readonly VaultItem[]; +}) { + const setup = useVaultSetup(); + const initialAdd = setup?.kind === "address"; + const section = useVaultSection(initialAdd ? "add" : "list"); + + return ( + + + + {section.view === "list" ? ( + <> + + Addresses + + {items.length > 0 + ? `Search and manage ${items.length.toLocaleString()} saved addresses.` + : "Add your first saved address."} + + + +
+ +
+ + ) : ( + <> + section.setView("list")} + title="Addresses" + /> + + Add address + + Sensitive values are encrypted before database storage and are + never returned after saving. + + + section.setView("list")} + /> + + )} +
+
+ ); +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/cards/form.tsx similarity index 87% rename from src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx rename to src/app/(authenticated)/(manager)/vault/_components/cards/form.tsx index fea115fb..9291cc2d 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/payment-card-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/cards/form.tsx @@ -1,6 +1,7 @@ "use client"; import { type FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -11,12 +12,12 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import type { ManagerMutation } from "@/modules/manager"; import { paymentCardBrand, paymentCardType, serializePaymentCard, -} from "@/modules/manager/payment-card"; +} from "@/lib/vault"; +import { api } from "@/trpc/client"; const paymentCardFormSchema = z.object({ billingPostalCode: z.string().trim().min(1, "Enter the billing postal code."), @@ -37,17 +38,20 @@ const paymentCardFormSchema = z.object({ nickname: z.string().trim().max(120), }); -export function PaymentCardForm({ - busy, +export function CardForm({ initialLabel = "", onSaved, - onSubmit, }: { - readonly busy: boolean; readonly initialLabel?: string; readonly onSaved: () => void; - readonly onSubmit: (mutation: ManagerMutation) => Promise; }) { + const router = useRouter(); + const create = api.vault.create.useMutation({ + onSuccess: () => { + router.refresh(); + onSaved(); + }, + }); const [attempted, setAttempted] = useState(false); const [form, setForm] = useState({ billingPostalCode: "", @@ -62,7 +66,7 @@ export function PaymentCardForm({ const errors = attempted && !result.success ? result.error.flatten().fieldErrors : {}; - const submit = async (event: FormEvent) => { + const submit = (event: FormEvent) => { event.preventDefault(); setAttempted(true); if (!result.success) return; @@ -72,30 +76,25 @@ export function PaymentCardForm({ const brand = paymentCardBrand(result.data.cardNumber); const lastFour = result.data.cardNumber.slice(-4); - const saved = await onSubmit({ - action: "vault.create", - input: { - account: `${brand} · •••• ${lastFour}`, - kind: "payment", - label: result.data.nickname || `${brand} ${lastFour}`, - secret: serializePaymentCard({ - billingPostalCode: result.data.billingPostalCode, - cardholderName: result.data.cardholderName, - expirationMonth: Number(month), - expirationYear: 2000 + Number(shortYear), - kind: "payment-card", - number: result.data.cardNumber, - securityCode: result.data.cvc, - version: 1, - }), - }, + create.mutate({ + account: `${brand} · •••• ${lastFour}`, + kind: "payment", + label: result.data.nickname || `${brand} ${lastFour}`, + secret: serializePaymentCard({ + billingPostalCode: result.data.billingPostalCode, + cardholderName: result.data.cardholderName, + expirationMonth: Number(month), + expirationYear: 2000 + Number(shortYear), + kind: "payment-card", + number: result.data.cardNumber, + securityCode: result.data.cvc, + version: 1, + }), }); - - if (saved) onSaved(); }; return ( - void submit(event)}> +
-
diff --git a/src/app/(authenticated)/(manager)/vault/_components/cards/index.tsx b/src/app/(authenticated)/(manager)/vault/_components/cards/index.tsx new file mode 100644 index 00000000..3f5cdf61 --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/cards/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import type { VaultItem } from "@/lib/vault"; +import { Button } from "@/components/ui/button"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CardForm } from "./form"; +import { + useVaultSection, + VaultItemBrowser, + VaultSection, + VaultSectionBackButton, + VaultSectionContent, + VaultSectionTrigger, +} from "../section"; +import { useVaultSetup } from "../setup"; + +export function VaultCards({ + items, +}: { + readonly items: readonly VaultItem[]; +}) { + const setup = useVaultSetup(); + const initialAdd = setup?.kind === "payment"; + const section = useVaultSection(initialAdd ? "add" : "list"); + + return ( + + + + {section.view === "list" ? ( + <> + + Cards + + {items.length > 0 + ? `Search and manage ${items.length.toLocaleString()} saved cards.` + : "Add your first saved card."} + + + +
+ +
+ + ) : ( + <> + section.setView("list")} + title="Cards" + /> + + Add card + + Sensitive values are encrypted before database storage and are + never returned after saving. + + + section.setView("list")} + /> + + )} +
+
+ ); +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/contacts/form.tsx similarity index 70% rename from src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx rename to src/app/(authenticated)/(manager)/vault/_components/contacts/form.tsx index d6f5f3b6..74f7a938 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/contact-vault-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/contacts/form.tsx @@ -1,12 +1,13 @@ "use client"; import { type FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field"; -import type { ManagerMutation } from "@/modules/manager"; -import { serializeContactVaultPayload } from "@/modules/manager/vault-payload"; -import { VaultFormField } from "./vault-form-field"; +import { serializeContactVaultPayload } from "@/lib/vault"; +import { api } from "@/trpc/client"; +import { FormField } from "../field"; const contactFormSchema = z .object({ @@ -33,17 +34,20 @@ const contactFormSchema = z path: ["fullName"], }); -export function ContactVaultForm({ - busy, +export function ContactForm({ initialLabel = "", onSaved, - onSubmit, }: { - readonly busy: boolean; readonly initialLabel?: string; readonly onSaved: () => void; - readonly onSubmit: (mutation: ManagerMutation) => Promise; }) { + const router = useRouter(); + const create = api.vault.create.useMutation({ + onSuccess: () => { + router.refresh(); + onSaved(); + }, + }); const [attempted, setAttempted] = useState(false); const [form, setForm] = useState({ email: "", @@ -55,37 +59,33 @@ export function ContactVaultForm({ const errors = attempted && !result.success ? result.error.flatten().fieldErrors : {}; - const submit = async (event: FormEvent) => { + const submit = (event: FormEvent) => { event.preventDefault(); setAttempted(true); if (!result.success) return; - const saved = await onSubmit({ - action: "vault.create", - input: { - account: "", + create.mutate({ + account: "", + kind: "contact", + label: result.data.nickname, + secret: serializeContactVaultPayload({ + email: result.data.email.length ? result.data.email : undefined, + fullName: result.data.fullName.length + ? result.data.fullName + : undefined, kind: "contact", - label: result.data.nickname, - secret: serializeContactVaultPayload({ - email: result.data.email.length ? result.data.email : undefined, - fullName: result.data.fullName.length - ? result.data.fullName - : undefined, - kind: "contact", - phone: result.data.phone.length ? result.data.phone : undefined, - version: 1, - }), - }, + phone: result.data.phone.length ? result.data.phone : undefined, + version: 1, + }), }); - if (saved) onSaved(); }; const update = (field: keyof typeof form, value: string) => setForm((current) => ({ ...current, [field]: value })); return ( - void submit(event)}> + - - update("fullName", value)} value={form.fullName} /> - -
-
diff --git a/src/app/(authenticated)/(manager)/vault/_components/contacts/index.tsx b/src/app/(authenticated)/(manager)/vault/_components/contacts/index.tsx new file mode 100644 index 00000000..72a581e4 --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/contacts/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import type { VaultItem } from "@/lib/vault"; +import { Button } from "@/components/ui/button"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ContactForm } from "./form"; +import { + useVaultSection, + VaultItemBrowser, + VaultSection, + VaultSectionBackButton, + VaultSectionContent, + VaultSectionTrigger, +} from "../section"; +import { useVaultSetup } from "../setup"; + +export function VaultContacts({ + items, +}: { + readonly items: readonly VaultItem[]; +}) { + const setup = useVaultSetup(); + const initialAdd = setup?.kind === "contact"; + const section = useVaultSection(initialAdd ? "add" : "list"); + + return ( + + + + {section.view === "list" ? ( + <> + + Contact info + + {items.length > 0 + ? `Search and manage ${items.length.toLocaleString()} saved contact info.` + : "Add your first saved contact."} + + + +
+ +
+ + ) : ( + <> + section.setView("list")} + title="Contact info" + /> + + Add contact + + Sensitive values are encrypted before database storage and are + never returned after saving. + + + section.setView("list")} + /> + + )} +
+
+ ); +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/vault-form-field.tsx b/src/app/(authenticated)/(manager)/vault/_components/field.tsx similarity index 95% rename from src/app/(authenticated)/(manager)/vault/_components/vault-form-field.tsx rename to src/app/(authenticated)/(manager)/vault/_components/field.tsx index 240fe689..7fd81ffc 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/vault-form-field.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/field.tsx @@ -3,7 +3,7 @@ import { Field, FieldError, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -export function VaultFormField({ +export function FormField({ error, id, label, diff --git a/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx b/src/app/(authenticated)/(manager)/vault/_components/logins/form.tsx similarity index 86% rename from src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx rename to src/app/(authenticated)/(manager)/vault/_components/logins/form.tsx index c34a02bc..8a34e10b 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/login-vault-form.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/logins/form.tsx @@ -1,6 +1,7 @@ "use client"; import { type FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; @@ -11,14 +12,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { ManagerMutation } from "@/modules/manager"; import { loginIdentifierSchema, loginIdentifierTypeSchema, loginOriginSchema, serializeLoginVaultPayload, -} from "@/modules/manager/vault-payload"; -import { VaultFormField } from "./vault-form-field"; +} from "@/lib/vault"; +import { api } from "@/trpc/client"; +import { FormField } from "../field"; const loginFormSchema = z .object({ @@ -55,21 +56,24 @@ const loginFormSchema = z } }); -export function LoginVaultForm({ - busy, +export function LoginForm({ initialIdentifierType, initialLabel = "", initialOrigin = "", onSaved, - onSubmit, }: { - readonly busy: boolean; readonly initialIdentifierType?: z.infer; readonly initialLabel?: string; readonly initialOrigin?: string; readonly onSaved: () => void; - readonly onSubmit: (mutation: ManagerMutation) => Promise; }) { + const router = useRouter(); + const create = api.vault.create.useMutation({ + onSuccess: () => { + router.refresh(); + onSaved(); + }, + }); const [attempted, setAttempted] = useState(false); const [form, setForm] = useState>({ identifier: "", @@ -82,40 +86,36 @@ export function LoginVaultForm({ const errors = attempted && !result.success ? result.error.flatten().fieldErrors : {}; - const submit = async (event: FormEvent) => { + const submit = (event: FormEvent) => { event.preventDefault(); setAttempted(true); if (!result.success) return; const authentication = loginAuthentication(result.data); - const saved = await onSubmit({ - action: "vault.create", - input: { - account: "", + create.mutate({ + account: "", + kind: "login", + label: result.data.nickname, + secret: serializeLoginVaultPayload({ + authentication, + identifier: { + type: result.data.identifierType, + value: result.data.identifier, + }, kind: "login", - label: result.data.nickname, - secret: serializeLoginVaultPayload({ - authentication, - identifier: { - type: result.data.identifierType, - value: result.data.identifier, - }, - kind: "login", - origin: result.data.origin, - version: 2, - }), - }, + origin: result.data.origin, + version: 2, + }), }); - if (saved) onSaved(); }; const passwordOptional = form.identifierType !== "username"; return ( - void submit(event)}> + {initialLabel ? null : ( - )} - )} -
-
-
diff --git a/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx b/src/app/(authenticated)/(manager)/vault/_components/logins/import.tsx similarity index 53% rename from src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx rename to src/app/(authenticated)/(manager)/vault/_components/logins/import.tsx index 5e056082..1b5c61fa 100644 --- a/src/app/(authenticated)/(manager)/vault/_components/chrome-password-import.tsx +++ b/src/app/(authenticated)/(manager)/vault/_components/logins/import.tsx @@ -7,6 +7,7 @@ import { UploadIcon, } from "lucide-react"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -17,21 +18,19 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { ManagerMutation } from "@/modules/manager"; -import { parseChromePasswordsCsv } from "@/modules/manager/chrome-passwords"; +import { + loginIdentifierSchema, + serializeLoginVaultPayload, + type VaultImportItems, +} from "@/lib/vault"; +import { api } from "@/trpc/client"; const MAX_FILE_SIZE = 10 * 1024 * 1024; const GOOGLE_PASSWORD_MANAGER_URL = "https://passwords.google.com/options"; -export function ChromePasswordImportPanel({ - busy, - onDone, - onImport, -}: { - readonly busy: boolean; - readonly onDone: () => void; - readonly onImport: (mutation: ManagerMutation) => Promise; -}) { +export function ChromeImportPanel({ onDone }: { readonly onDone: () => void }) { + const router = useRouter(); + const importPasswords = api.vault.import.useMutation(); const [selection, setSelection] = useState>(); const [fileName, setFileName] = useState(""); @@ -40,6 +39,7 @@ export function ChromePasswordImportPanel({ const [inputKey, setInputKey] = useState(0); const chooseFile = async (file?: File) => { + importPasswords.reset(); setError(undefined); setImportedCount(undefined); setSelection(undefined); @@ -61,34 +61,34 @@ export function ChromePasswordImportPanel({ } }; - const importPasswords = async () => { + const importSelectedPasswords = () => { if (!selection) return; setError(undefined); const count = selection.items.length; - const saved = await onImport({ - action: "vault.import", - items: selection.items, + importPasswords.mutate(selection.items, { + onSuccess: () => { + router.refresh(); + setSelection(undefined); + setImportedCount(count); + setFileName(""); + setInputKey((key) => key + 1); + }, }); - if (!saved) { - setError( - "The import did not finish. Check the vault error and try again." - ); - return; - } - - setSelection(undefined); - setImportedCount(count); - setFileName(""); - setInputKey((key) => key + 1); }; const reset = () => { + importPasswords.reset(); setSelection(undefined); setFileName(""); setError(undefined); setImportedCount(undefined); setInputKey((key) => key + 1); }; + const importError = + error ?? + (importPasswords.error + ? "The import did not finish. Check the vault error and try again." + : undefined); return ( <> @@ -131,7 +131,7 @@ export function ChromePasswordImportPanel({ @@ -150,11 +150,11 @@ export function ChromePasswordImportPanel({ ) : null} - {error ? ( + {importError ? ( Couldn't import this file - {error} + {importError} ) : null} @@ -185,12 +185,12 @@ export function ChromePasswordImportPanel({ {importedCount === undefined ? ( + + + + ) : ( + <> + section.setView("list")} + title="Logins" + /> + {section.view === "import" ? ( + section.setView("list")} /> + ) : ( + <> + + + {initialSetup ? `Add ${initialSetup.label}` : "Add login"} + + + Enter the credentials you use to sign in. + + + section.setView("list")} + /> + + )} + + )} + + + ); +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/other.tsx b/src/app/(authenticated)/(manager)/vault/_components/other.tsx new file mode 100644 index 00000000..ae572005 --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/other.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { VaultItem } from "@/lib/vault"; +import { VaultItemList } from "./section"; + +export function VaultOtherItems({ + items, +}: { + readonly items: readonly VaultItem[]; +}) { + if (items.length === 0) return null; + + return ( +
+

+ Other +

+
+ +
+
+ ); +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/section.tsx b/src/app/(authenticated)/(manager)/vault/_components/section.tsx new file mode 100644 index 00000000..40a1bd60 --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/section.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { + ArrowLeftIcon, + ChevronRightIcon, + Globe2Icon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { VaultItem } from "@/lib/vault"; +import { api } from "@/trpc/client"; + +const VAULT_DIALOG_PAGE_SIZE = 50; +const dialogContentClass = + "top-auto bottom-0 max-h-[min(32rem,calc(100dvh-0.5rem))] translate-y-0 overscroll-contain rounded-b-none pb-[max(1rem,env(safe-area-inset-bottom))] sm:top-1/2 sm:bottom-auto sm:max-h-[min(32rem,calc(100dvh-2rem))] sm:max-w-2xl sm:-translate-y-1/2 sm:rounded-xl sm:pb-4 [&_[data-slot=dialog-close]]:top-3 [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:size-10 sm:[&_[data-slot=dialog-close]]:top-2 sm:[&_[data-slot=dialog-close]]:right-2 sm:[&_[data-slot=dialog-close]]:size-7 [&_[data-slot=input]]:h-12 sm:[&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-12 sm:[&_[data-slot=select-trigger]]:h-8 [&_button[type=submit]]:h-12 [&_button[type=submit]]:w-full sm:[&_button[type=submit]]:h-8 sm:[&_button[type=submit]]:w-auto"; + +type VaultSectionView = "add" | "import" | "list"; + +export function useVaultSection(initialView: VaultSectionView) { + const [open, setOpen] = useState(initialView !== "list"); + const [view, setView] = useState(initialView); + + const onOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) setView("list"); + }; + + return { onOpenChange, open, setView, view }; +} + +export function VaultSection({ + children, + onOpenChange, + open, + title, +}: { + readonly children: ReactNode; + readonly onOpenChange: (open: boolean) => void; + readonly open: boolean; + readonly title: string; +}) { + return ( +
+ + {children} + +
+ ); +} + +export function VaultSectionTrigger({ + items, + title, +}: { + readonly items: readonly VaultItem[]; + readonly title: string; +}) { + return ( + + } + > + + {title} + + {items.length > 0 + ? `${items.length.toLocaleString()} saved` + : `No saved ${title.toLocaleLowerCase()}`} + + + + + ); +} + +export function VaultSectionContent({ + children, + view, +}: { + readonly children: ReactNode; + readonly view: VaultSectionView; +}) { + return ( + + {children} + + ); +} + +export function VaultSectionBackButton({ + onClick, + title, +}: { + readonly onClick: () => void; + readonly title: string; +}) { + return ( + + ); +} + +export function VaultItemBrowser({ + items, + searchId, + title, +}: { + readonly items: readonly VaultItem[]; + readonly searchId: string; + readonly title: string; +}) { + const [query, setQuery] = useState(""); + const [visibleCount, setVisibleCount] = useState(VAULT_DIALOG_PAGE_SIZE); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredItems = normalizedQuery + ? items.filter((item) => + `${item.label}\n${item.account}` + .toLocaleLowerCase() + .includes(normalizedQuery) + ) + : items; + const visibleItems = filteredItems.slice(0, visibleCount); + + return ( + <> + {items.length > 0 ? ( +
+ + + { + setQuery(event.target.value); + setVisibleCount(VAULT_DIALOG_PAGE_SIZE); + }} + placeholder="Search by name or account" + type="search" + value={query} + /> +
+ ) : ( +
+ )} + +
{ + const list = event.currentTarget; + const nearEnd = + list.scrollHeight - list.scrollTop - list.clientHeight < 96; + if (nearEnd && visibleCount < filteredItems.length) { + setVisibleCount((count) => + Math.min(count + VAULT_DIALOG_PAGE_SIZE, filteredItems.length) + ); + } + }} + > + {visibleItems.length > 0 ? ( + + ) : query.trim() ? ( +

+ No matches for “{query.trim()}” +

+ ) : ( +

+ No saved {title.toLocaleLowerCase()} yet. +

+ )} +
+ + ); +} + +export function VaultItemList({ + items, +}: { + readonly items: readonly VaultItem[]; +}) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function VaultItemRow({ item }: { readonly item: VaultItem }) { + const router = useRouter(); + const remove = api.vault.remove.useMutation({ + onSuccess: () => router.refresh(), + }); + + return ( +
+ +
+

{item.label}

+ {item.account ? ( +

+ {item.account} +

+ ) : null} +
+ +
+ ); +} + +function VaultItemIcon({ item }: { readonly item: VaultItem }) { + const faviconUrl = loginFaviconUrl(item); + return ( + + + {faviconUrl ? ( + // Imported domains cannot be declared in Next Image configuration. + // oxlint-disable-next-line nextjs/no-img-element + event.currentTarget.remove()} + referrerPolicy="no-referrer" + src={faviconUrl} + /> + ) : null} + + ); +} + +function loginFaviconUrl(item: VaultItem): string | undefined { + if (item.kind !== "login") return undefined; + const hostname = item.account.split(" · ", 1)[0]?.trim(); + if (!hostname || !hostname.includes(".") || hostname.includes(" ")) { + return undefined; + } + try { + return new URL("/favicon.ico", `https://${hostname}`).toString(); + } catch { + return undefined; + } +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/setup.ts b/src/app/(authenticated)/(manager)/vault/_components/setup.ts new file mode 100644 index 00000000..0e4fe7db --- /dev/null +++ b/src/app/(authenticated)/(manager)/vault/_components/setup.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { parseVaultSetupSearchParams } from "@/lib/vault"; + +export function useVaultSetup() { + const searchParams = useSearchParams(); + const requestedSetup = parseVaultSetupSearchParams( + Object.fromEntries(searchParams.entries()) + ); + return requestedSetup.success ? requestedSetup.data : undefined; +} diff --git a/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx b/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx deleted file mode 100644 index 7e728254..00000000 --- a/src/app/(authenticated)/(manager)/vault/_components/vault-manager.tsx +++ /dev/null @@ -1,483 +0,0 @@ -"use client"; - -import { - ArrowLeftIcon, - ChevronRightIcon, - Globe2Icon, - KeyRoundIcon, - PlusIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { useState } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import type { - ManagerMutation, - ManagerSetupRequest, - ManagerSnapshot, - VaultCreateItemKind, -} from "@/modules/manager"; -import { api } from "@/trpc/client"; -import { AddressVaultForm } from "./address-vault-form"; -import { ChromePasswordImportPanel } from "./chrome-password-import"; -import { ContactVaultForm } from "./contact-vault-form"; -import { LoginVaultForm } from "./login-vault-form"; -import { PaymentCardForm } from "./payment-card-form"; - -const categories = [ - { - addLabel: "Add login", - kind: "login", - title: "Logins", - }, - { - addLabel: "Add card", - kind: "payment", - title: "Cards", - }, - { - addLabel: "Add address", - kind: "address", - title: "Addresses", - }, - { - addLabel: "Add contact", - kind: "contact", - title: "Contact info", - }, -] as const; - -const VAULT_DIALOG_PAGE_SIZE = 50; -const dialogContentClass = - "top-auto bottom-0 max-h-[min(32rem,calc(100dvh-0.5rem))] translate-y-0 overscroll-contain rounded-b-none pb-[max(1rem,env(safe-area-inset-bottom))] sm:top-1/2 sm:bottom-auto sm:max-h-[min(32rem,calc(100dvh-2rem))] sm:max-w-2xl sm:-translate-y-1/2 sm:rounded-xl sm:pb-4 [&_[data-slot=dialog-close]]:top-3 [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:size-10 sm:[&_[data-slot=dialog-close]]:top-2 sm:[&_[data-slot=dialog-close]]:right-2 sm:[&_[data-slot=dialog-close]]:size-7 [&_[data-slot=input]]:h-12 sm:[&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-12 sm:[&_[data-slot=select-trigger]]:h-8 [&_button[type=submit]]:h-12 [&_button[type=submit]]:w-full sm:[&_button[type=submit]]:h-8 sm:[&_button[type=submit]]:w-auto"; - -export function VaultManager({ - initialChromeImport, - initialSetup, - initialSnapshot, -}: { - readonly initialChromeImport?: boolean; - readonly initialSetup?: Extract; - readonly initialSnapshot: ManagerSnapshot; -}) { - const managerMutation = api.manager.mutate.useMutation(); - const [snapshot, setSnapshot] = useState(initialSnapshot); - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - const mutate = async (mutation: ManagerMutation) => { - setBusy(true); - setError(undefined); - try { - const nextSnapshot = await managerMutation.mutateAsync(mutation); - setSnapshot(nextSnapshot); - return true; - } catch { - setError("Unable to update the workspace. Try again."); - return false; - } finally { - setBusy(false); - } - }; - const legacyItems = snapshot.vaultItems.filter( - (item) => - item.kind === "identity" || item.kind === "token" || item.kind === "phone" - ); - - return ( -
-

Vault

- - {error ? ( - - - Vault unavailable - {error} - - ) : null} - - {categories.map((category) => ( - item.kind === category.kind - )} - key={category.kind} - onDelete={mutate} - onSubmit={mutate} - {...category} - /> - ))} - - {legacyItems.length > 0 ? ( -
-

- Other -

-
- {legacyItems.map((item) => ( - - ))} -
-
- ) : null} -
- ); -} - -function VaultCategory({ - addLabel, - busy, - initialChromeImport, - initialSetup, - items, - kind, - onDelete, - onSubmit, - title, -}: { - readonly addLabel: string; - readonly busy: boolean; - readonly initialChromeImport?: boolean; - readonly initialSetup?: Extract; - readonly items: ManagerSnapshot["vaultItems"]; - readonly kind: VaultCreateItemKind; - readonly onDelete: (mutation: ManagerMutation) => Promise; - readonly onSubmit: (mutation: ManagerMutation) => Promise; - readonly title: string; -}) { - const [open, setOpen] = useState( - Boolean(initialSetup || initialChromeImport) - ); - const [view, setView] = useState<"add" | "import" | "list">( - initialChromeImport ? "import" : initialSetup ? "add" : "list" - ); - const [query, setQuery] = useState(""); - const [visibleCount, setVisibleCount] = useState(VAULT_DIALOG_PAGE_SIZE); - const normalizedQuery = query.trim().toLocaleLowerCase(); - const filteredItems = normalizedQuery - ? items.filter((item) => - `${item.label}\n${item.account}` - .toLocaleLowerCase() - .includes(normalizedQuery) - ) - : items; - const visibleItems = filteredItems.slice(0, visibleCount); - const singularItem = kind === "payment" ? "card" : kind; - - const updateOpen = (nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(""); - setVisibleCount(VAULT_DIALOG_PAGE_SIZE); - setView("list"); - } - }; - - return ( -
- - - } - > - - {title} - - {items.length > 0 - ? `${items.length.toLocaleString()} saved` - : `No saved ${title.toLocaleLowerCase()}`} - - - - - - {view === "list" ? ( - <> - - {title} - - {items.length > 0 - ? `Search and manage ${items.length.toLocaleString()} saved ${title.toLocaleLowerCase()}.` - : `Add your first saved ${singularItem}.`} - - - - {items.length > 0 ? ( -
- - - { - setQuery(event.target.value); - setVisibleCount(VAULT_DIALOG_PAGE_SIZE); - }} - placeholder="Search by name or account" - type="search" - value={query} - /> -
- ) : ( -
- )} - -
{ - const list = event.currentTarget; - const nearEnd = - list.scrollHeight - list.scrollTop - list.clientHeight < 96; - if (nearEnd && visibleCount < filteredItems.length) { - setVisibleCount((count) => - Math.min( - count + VAULT_DIALOG_PAGE_SIZE, - filteredItems.length - ) - ); - } - }} - > - {visibleItems.length > 0 ? ( -
- {visibleItems.map((item) => ( - - ))} -
- ) : query.trim() ? ( -

- No matches for “{query.trim()}” -

- ) : ( -

- No saved {title.toLocaleLowerCase()} yet. -

- )} -
- -
- {kind === "login" ? ( - - ) : null} - -
- - ) : ( - <> - - {view === "import" ? ( - setView("list")} - onImport={onSubmit} - /> - ) : ( - <> - - - {initialSetup?.kind === "login" - ? `Add ${initialSetup.label}` - : addLabel} - - - {kind === "login" - ? "Enter the credentials you use to sign in." - : "Sensitive values are encrypted before database storage and are never returned after saving."} - - - {renderVaultForm({ - busy, - initialIdentifierType: - initialSetup?.kind === "login" - ? initialSetup.identifierType - : undefined, - initialLabel: initialSetup?.label, - initialOrigin: - initialSetup?.kind === "login" - ? initialSetup.origin - : undefined, - kind, - onSaved: () => setView("list"), - onSubmit, - })} - - )} - - )} - -
-
- ); -} - -function VaultItemRow({ - busy, - item, - onDelete, -}: { - readonly busy: boolean; - readonly item: ManagerSnapshot["vaultItems"][number]; - readonly onDelete: (mutation: ManagerMutation) => Promise; -}) { - return ( -
- -
-

{item.label}

- {item.account ? ( -

- {item.account} -

- ) : null} -
- -
- ); -} - -function VaultItemIcon({ - item, -}: { - readonly item: ManagerSnapshot["vaultItems"][number]; -}) { - const faviconUrl = loginFaviconUrl(item); - return ( - - - {faviconUrl ? ( - // Imported domains cannot be declared in Next Image configuration. - // oxlint-disable-next-line nextjs/no-img-element - event.currentTarget.remove()} - referrerPolicy="no-referrer" - src={faviconUrl} - /> - ) : null} - - ); -} - -function loginFaviconUrl( - item: ManagerSnapshot["vaultItems"][number] -): string | undefined { - if (item.kind !== "login") return undefined; - const hostname = item.account.split(" · ", 1)[0]?.trim(); - if (!hostname || !hostname.includes(".") || hostname.includes(" ")) { - return undefined; - } - try { - return new URL("/favicon.ico", `https://${hostname}`).toString(); - } catch { - return undefined; - } -} - -function renderVaultForm({ - busy, - initialIdentifierType, - initialLabel, - initialOrigin, - kind, - onSaved, - onSubmit, -}: { - readonly busy: boolean; - readonly initialIdentifierType?: "email" | "phone" | "username"; - readonly initialLabel?: string; - readonly initialOrigin?: string; - readonly kind: VaultCreateItemKind; - readonly onSaved: () => void; - readonly onSubmit: (mutation: ManagerMutation) => Promise; -}) { - const common = { busy, initialLabel, onSaved, onSubmit }; - switch (kind) { - case "login": - return ( - - ); - case "payment": - return ; - case "address": - return ; - case "contact": - return ; - } -} diff --git a/src/app/(authenticated)/(manager)/vault/page.tsx b/src/app/(authenticated)/(manager)/vault/page.tsx index ef9865d5..0c6cb30f 100644 --- a/src/app/(authenticated)/(manager)/vault/page.tsx +++ b/src/app/(authenticated)/(manager)/vault/page.tsx @@ -1,32 +1,28 @@ -import { VaultManager } from "./_components/vault-manager"; +import { VaultAddresses } from "./_components/addresses"; +import { VaultCards } from "./_components/cards"; +import { VaultContacts } from "./_components/contacts"; +import { VaultLogins } from "./_components/logins"; +import { VaultOtherItems } from "./_components/other"; +import { readVaultItems } from "@/db/services/vault"; import { requireRequestScope } from "@/lib/request-scope"; -import { - managerSnapshotSchema, - parseManagerSetupSearchParams, -} from "@/modules/manager"; -import { readManagerSnapshot } from "@/modules/manager/server/store"; -export default async function Page({ searchParams }: PageProps<"/vault">) { - const query = await searchParams; - const requestedSetup = parseManagerSetupSearchParams(query); +export default async function Page() { const scope = await requireRequestScope(); - const initialSnapshot = managerSnapshotSchema.parse( - await readManagerSnapshot(scope) + const items = await readVaultItems(scope); + const itemsByKind = Object.groupBy(items, (item) => item.kind); + const otherItems = items.filter( + (item) => + item.kind === "identity" || item.kind === "phone" || item.kind === "token" ); return ( - +
+

Vault

+ + + + + +
); } - -function firstQueryValue(value: string | readonly string[] | undefined) { - return typeof value === "string" ? value : value?.[0]; -} diff --git a/src/modules/manager/vault-payload.ts b/src/lib/vault.ts similarity index 56% rename from src/modules/manager/vault-payload.ts rename to src/lib/vault.ts index 0cbae9ad..e727a349 100644 --- a/src/modules/manager/vault-payload.ts +++ b/src/lib/vault.ts @@ -1,5 +1,33 @@ +import creditCardType from "credit-card-type"; import { z } from "zod"; +export const vaultItemKindSchema = z.enum([ + "login", + "payment", + "address", + "contact", + "phone", + "identity", + "token", +]); + +const vaultCreateItemKindSchema = vaultItemKindSchema.extract([ + "login", + "payment", + "address", + "contact", +]); + +const vaultItemSchema = z.object({ + account: z.string(), + createdAt: z.string(), + hasSecret: z.boolean(), + id: z.string(), + kind: vaultItemKindSchema, + label: z.string(), + updatedAt: z.string(), +}); + const boundedValue = z.string().trim().min(1).max(20_000); const optionalBoundedValue = z .string() @@ -151,6 +179,79 @@ export const contactVaultPayloadStringSchema = serializedPayloadSchema( "Enter at least one contact value." ); +export const paymentCardSecretSchema = z.object({ + billingPostalCode: z.string().trim().min(1).max(20), + cardholderName: z.string().trim().min(1).max(200), + expirationMonth: z.number().int().min(1).max(12), + expirationYear: z.number().int().min(2000).max(9999), + kind: z.literal("payment-card"), + number: z.string().regex(/^\d{12,19}$/u), + securityCode: z.string().regex(/^\d{3,4}$/u), + version: z.literal(1), +}); + +export const paymentCardSecretStringSchema = serializedPayloadSchema( + paymentCardSecretSchema, + "Enter complete, valid card details." +); + +export const vaultCreateItemSchema = z + .object({ + account: z.string().trim().max(200).default(""), + kind: vaultCreateItemKindSchema, + label: z.string().trim().min(1).max(120), + secret: z.string().min(1).max(20_000), + }) + .superRefine((input, context) => { + const secretSchema = { + address: addressVaultPayloadStringSchema, + contact: contactVaultPayloadStringSchema, + login: loginVaultPayloadStringSchema, + payment: paymentCardSecretStringSchema, + }[input.kind]; + if (!secretSchema.safeParse(input.secret).success) { + context.addIssue({ + code: "custom", + message: `Complete the ${input.kind} details before saving.`, + path: ["secret"], + }); + } + }); + +export const vaultImportItemsSchema = z + .array( + vaultCreateItemSchema.refine((item) => item.kind === "login", { + message: "Bulk imports support login credentials only.", + }) + ) + .min(1) + .max(3_000); + +export const vaultSetupRequestSchema = z.union([ + z + .object({ + identifierType: loginIdentifierTypeSchema, + kind: z.literal("login"), + label: z.string().trim().min(1).max(120), + origin: loginOriginSchema, + target: z.literal("vault"), + }) + .strict(), + z + .object({ + kind: vaultCreateItemKindSchema.exclude(["login"]), + label: z.string().trim().min(1).max(120).optional(), + target: z.literal("vault"), + }) + .strict(), +]); + +export type VaultCreateItem = z.infer; +export type VaultImportItems = z.infer; +export type VaultItem = z.infer; +export type VaultItemKind = z.infer; +export type VaultSetupRequest = z.infer; + export function serializeLoginVaultPayload( input: z.input ) { @@ -169,6 +270,31 @@ export function serializeContactVaultPayload( return JSON.stringify(contactVaultPayloadSchema.parse(input)); } +export function serializePaymentCard( + input: z.input +) { + return JSON.stringify(paymentCardSecretSchema.parse(input)); +} + +export function parsePaymentCardSecret(value: string) { + const card = parseSerializedPayload(paymentCardSecretSchema, value); + if (!card) + throw new Error("The saved payment card is incomplete or invalid."); + return card; +} + +export function paymentCardBrand(number: string) { + return paymentCardType(number)?.niceType ?? "Card"; +} + +export function paymentCardType(number: string) { + const digits = number.replaceAll(/\D/gu, ""); + if (!digits) return undefined; + + const matches = creditCardType(digits); + return matches.length === 1 ? matches[0] : undefined; +} + export function parseLoginVaultPayload(value: string) { return parseSerializedPayload(readableLoginVaultPayloadSchema, value); } @@ -203,10 +329,47 @@ export function loginAccountHint( : identifierHint; } +export function parseVaultSetupSearchParams( + query: Record +) { + const identifierType = firstQueryValue(query.identifier_type); + const origin = firstQueryValue(query.origin); + const input = { + kind: firstQueryValue(query.kind), + label: firstQueryValue(query.label), + target: firstQueryValue(query.setup), + }; + + return vaultSetupRequestSchema.safeParse( + identifierType === undefined && origin === undefined + ? input + : { ...input, identifierType, origin } + ); +} + +export function createVaultSetupUrl( + baseUrl: string, + request: VaultSetupRequest +) { + const url = new URL("/vault", baseUrl); + url.searchParams.set("setup", request.target); + if (request.label) url.searchParams.set("label", request.label); + url.searchParams.set("kind", request.kind); + if (request.kind === "login") { + url.searchParams.set("identifier_type", request.identifierType); + url.searchParams.set("origin", request.origin); + } + return url.toString(); +} + function lastCharacters(value: string, count: number) { return value.replaceAll(/\D/gu, "").slice(-count); } +function firstQueryValue(value: string | readonly string[] | undefined) { + return typeof value === "string" ? value : value?.[0]; +} + function serializedPayloadSchema(schema: z.ZodType, message: string) { return z.string().superRefine((value, context) => { if (!parseSerializedPayload(schema, value)) { diff --git a/src/modules/manager/chrome-passwords.ts b/src/modules/manager/chrome-passwords.ts deleted file mode 100644 index 387cf06c..00000000 --- a/src/modules/manager/chrome-passwords.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { ManagerMutation } from "."; -import { - loginIdentifierSchema, - serializeLoginVaultPayload, -} from "./vault-payload"; - -const MAX_IMPORT_ITEMS = 3_000; -const MAX_ACCOUNT_LENGTH = 300; -const MAX_LABEL_LENGTH = 120; -const MAX_SECRET_LENGTH = 20_000; - -type VaultImportItem = Extract< - ManagerMutation, - { action: "vault.import" } ->["items"][number]; - -export function parseChromePasswordsCsv(csv: string) { - const rows = parseCsv(csv); - const headers = rows.shift()?.map((header) => - header - .replace(/^\uFEFF/, "") - .trim() - .toLowerCase() - ); - if (!headers) throw new Error("Choose a Chrome passwords CSV file."); - - const indexes = { - name: headers.indexOf("name"), - password: headers.indexOf("password"), - url: headers.indexOf("url"), - username: headers.indexOf("username"), - }; - if (indexes.url < 0 || indexes.username < 0 || indexes.password < 0) { - throw new Error( - "This CSV needs url, username, and password columns. Export it from Google Password Manager and try again." - ); - } - - const items: VaultImportItem[] = []; - let skipped = 0; - - for (const row of rows) { - if (row.every((value) => value.length === 0)) continue; - - const account = row[indexes.username]?.trim() ?? ""; - const password = row[indexes.password] ?? ""; - const url = row[indexes.url]?.trim() ?? ""; - const origin = originFromUrl(url); - const name = indexes.name >= 0 ? row[indexes.name]?.trim() : undefined; - const label = name?.length ? name : labelFromUrl(url); - - if ( - !label || - !origin || - account.length === 0 || - password.length === 0 || - account.length > MAX_ACCOUNT_LENGTH || - label.length > MAX_LABEL_LENGTH || - password.length > MAX_SECRET_LENGTH - ) { - skipped += 1; - continue; - } - - items.push({ - account: "", - kind: "login", - label, - secret: serializeLoginVaultPayload({ - authentication: { password, type: "password" }, - identifier: { - type: loginIdentifierSchema.safeParse({ - type: "email", - value: account, - }).success - ? "email" - : "username", - value: account, - }, - kind: "login", - origin, - version: 2, - }), - }); - } - - if (items.length === 0) { - throw new Error("No valid saved passwords were found in this CSV."); - } - if (items.length > MAX_IMPORT_ITEMS) { - throw new Error( - `This file contains ${items.length.toLocaleString()} passwords. Import up to ${MAX_IMPORT_ITEMS.toLocaleString()} at a time.` - ); - } - - return { items, skipped }; -} - -function labelFromUrl(value: string) { - if (!value) return ""; - try { - const url = new URL(value); - return url.hostname.replace(/^www\./, "") || value; - } catch { - return value.slice(0, MAX_LABEL_LENGTH); - } -} - -function originFromUrl(value: string) { - try { - const url = new URL(value); - return url.protocol === "http:" || url.protocol === "https:" - ? url.origin - : undefined; - } catch { - return undefined; - } -} - -function parseCsv(csv: string) { - const rows: string[][] = []; - let field = ""; - let quoted = false; - let row: string[] = []; - - for (let index = 0; index < csv.length; index += 1) { - const character = csv.charAt(index); - if (quoted) { - if (character === '"') { - if (csv[index + 1] === '"') { - field += '"'; - index += 1; - } else { - quoted = false; - } - } else { - field += character; - } - continue; - } - - if (character === '"' && field.length === 0) { - quoted = true; - } else if (character === ",") { - row.push(field); - field = ""; - } else if (character === "\n" || character === "\r") { - if (character === "\r" && csv[index + 1] === "\n") index += 1; - row.push(field); - rows.push(row); - field = ""; - row = []; - } else { - field += character; - } - } - - if (quoted) throw new Error("This CSV has an unfinished quoted value."); - if (field.length > 0 || row.length > 0) { - row.push(field); - rows.push(row); - } - return rows; -} diff --git a/src/modules/manager/index.ts b/src/modules/manager/index.ts deleted file mode 100644 index 7f0b6fee..00000000 --- a/src/modules/manager/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { z } from "zod"; -import { paymentCardSecretStringSchema } from "./payment-card"; -import { - addressVaultPayloadStringSchema, - contactVaultPayloadStringSchema, - loginIdentifierTypeSchema, - loginOriginSchema, - loginVaultPayloadStringSchema, -} from "./vault-payload"; - -export const vaultItemKindSchema = z.enum([ - "login", - "payment", - "address", - "contact", - "phone", - "identity", - "token", -]); - -const vaultCreateItemKindSchema = vaultItemKindSchema.extract([ - "login", - "payment", - "address", - "contact", -]); - -const managerVaultItemSchema = z.object({ - account: z.string(), - createdAt: z.string(), - hasSecret: z.boolean(), - id: z.string(), - kind: vaultItemKindSchema, - label: z.string(), - updatedAt: z.string(), -}); - -export const managerSnapshotSchema = z.object({ - browser: z.object({ available: z.boolean() }), - googleWorkspace: z.object({ - accountLabel: z.string().nullable(), - state: z.enum(["connected", "disconnected", "unavailable"]), - }), - runtime: z.object({ inference: z.string() }), - secretStore: z.object({ - available: z.boolean(), - description: z.string(), - kind: z.string(), - }), - vaultItems: z.array(managerVaultItemSchema), -}); - -const vaultItemInputSchema = z - .object({ - account: z.string().trim().max(200).default(""), - kind: vaultCreateItemKindSchema, - label: z.string().trim().min(1).max(120), - secret: z.string().min(1).max(20_000), - }) - .superRefine((input, context) => { - const secretSchema = { - address: addressVaultPayloadStringSchema, - contact: contactVaultPayloadStringSchema, - login: loginVaultPayloadStringSchema, - payment: paymentCardSecretStringSchema, - }[input.kind]; - if (!secretSchema.safeParse(input.secret).success) { - context.addIssue({ - code: "custom", - message: `Complete the ${input.kind} details before saving.`, - path: ["secret"], - }); - } - }); - -const loginManagerSetupRequestSchema = z - .object({ - identifierType: loginIdentifierTypeSchema, - kind: z.literal("login"), - label: z.string().trim().min(1).max(120), - origin: loginOriginSchema, - target: z.literal("vault"), - }) - .strict(); - -const nonLoginManagerSetupRequestSchema = z - .object({ - kind: vaultCreateItemKindSchema.exclude(["login"]), - label: z.string().trim().min(1).max(120).optional(), - target: z.literal("vault"), - }) - .strict(); - -export const managerSetupRequestSchema = z.union([ - loginManagerSetupRequestSchema, - nonLoginManagerSetupRequestSchema, -]); - -export const managerMutationSchema = z.discriminatedUnion("action", [ - z.object({ - action: z.literal("model.select"), - modelId: z.string().trim().min(1).max(300), - }), - z.object({ action: z.literal("vault.create"), input: vaultItemInputSchema }), - z.object({ - action: z.literal("vault.import"), - items: z - .array( - vaultItemInputSchema.refine((item) => item.kind === "login", { - message: "Bulk imports support login credentials only.", - }) - ) - .min(1) - .max(3_000), - }), - z.object({ action: z.literal("vault.delete"), id: z.string().min(1) }), -]); - -export type ManagerMutation = z.infer; -export type ManagerSetupRequest = z.infer; -export type ManagerSnapshot = z.infer; -export type VaultItemKind = z.infer; -export type VaultCreateItemKind = z.infer; - -export function parseManagerSetupSearchParams( - query: Record -) { - const identifierType = firstQueryValue(query.identifier_type); - const origin = firstQueryValue(query.origin); - const input = { - kind: firstQueryValue(query.kind), - label: firstQueryValue(query.label), - target: firstQueryValue(query.setup), - }; - - return managerSetupRequestSchema.safeParse( - identifierType === undefined && origin === undefined - ? input - : { ...input, identifierType, origin } - ); -} - -export function createManagerSetupUrl( - baseUrl: string, - request: ManagerSetupRequest -) { - const url = new URL("/vault", baseUrl); - url.searchParams.set("setup", request.target); - if (request.label) url.searchParams.set("label", request.label); - url.searchParams.set("kind", request.kind); - if (request.kind === "login") { - url.searchParams.set("identifier_type", request.identifierType); - url.searchParams.set("origin", request.origin); - } - return url.toString(); -} - -export function createManagerImportUrl(baseUrl: string) { - const url = new URL("/vault", baseUrl); - url.searchParams.set("import", "chrome"); - return url.toString(); -} - -function firstQueryValue(value: string | readonly string[] | undefined) { - return typeof value === "string" ? value : value?.[0]; -} diff --git a/src/modules/manager/payment-card.ts b/src/modules/manager/payment-card.ts deleted file mode 100644 index bc907224..00000000 --- a/src/modules/manager/payment-card.ts +++ /dev/null @@ -1,58 +0,0 @@ -import creditCardType from "credit-card-type"; -import { z } from "zod"; - -export const paymentCardSecretSchema = z.object({ - billingPostalCode: z.string().trim().min(1).max(20), - cardholderName: z.string().trim().min(1).max(200), - expirationMonth: z.number().int().min(1).max(12), - expirationYear: z.number().int().min(2000).max(9999), - kind: z.literal("payment-card"), - number: z.string().regex(/^\d{12,19}$/u), - securityCode: z.string().regex(/^\d{3,4}$/u), - version: z.literal(1), -}); - -export const paymentCardSecretStringSchema = z - .string() - .superRefine((value, context) => { - try { - const result = paymentCardSecretSchema.safeParse(JSON.parse(value)); - if (!result.success) { - context.addIssue({ - code: "custom", - message: "Enter complete, valid card details.", - }); - } - } catch { - context.addIssue({ - code: "custom", - message: "Enter complete, valid card details.", - }); - } - }); - -export function serializePaymentCard( - input: z.input -) { - return JSON.stringify(paymentCardSecretSchema.parse(input)); -} - -export function parsePaymentCardSecret(value: string) { - try { - return paymentCardSecretSchema.parse(JSON.parse(value)); - } catch { - throw new Error("The saved payment card is incomplete or invalid."); - } -} - -export function paymentCardBrand(number: string) { - return paymentCardType(number)?.niceType ?? "Card"; -} - -export function paymentCardType(number: string) { - const digits = number.replaceAll(/\D/gu, ""); - if (!digits) return undefined; - - const matches = creditCardType(digits); - return matches.length === 1 ? matches[0] : undefined; -} diff --git a/src/modules/manager/server/secret-store.ts b/src/modules/manager/server/secret-store.ts deleted file mode 100644 index 37a095c9..00000000 --- a/src/modules/manager/server/secret-store.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { - deleteEncryptedSecret, - readEncryptedSecret, - writeEncryptedSecret, -} from "@/db/services/secrets"; -import type { AccessScope } from "@/lib/access-scope"; -import { env } from "@/lib/env"; - -export function secretStoreStatus() { - return { - available: true, - description: - "Secrets are encrypted for this workspace before database storage.", - kind: "Encrypted vault", - }; -} - -export async function writeSecret({ - id, - scope, - value, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; - readonly value: string; -}) { - await writeEncryptedSecret(scope, id, encryptSecret(scope, id, value)); -} - -export async function readSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - const encrypted = await readEncryptedSecret(scope, id); - return encrypted ? decryptSecret(scope, id, encrypted) : undefined; -} - -export async function hasSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - return (await readEncryptedSecret(scope, id)) !== undefined; -} - -export async function deleteSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - await deleteEncryptedSecret(scope, id); -} - -function encryptSecret(scope: AccessScope, id: string, value: string) { - const iv = randomBytes(12); - const cipher = createCipheriv( - "aes-256-gcm", - Buffer.from(env.SECRET_ENCRYPTION_KEY, "base64"), - iv - ); - cipher.setAAD(secretAad(scope, id)); - const ciphertext = Buffer.concat([ - cipher.update(value, "utf8"), - cipher.final(), - ]); - return [ - "v1", - iv.toString("base64url"), - cipher.getAuthTag().toString("base64url"), - ciphertext.toString("base64url"), - ].join("."); -} - -function decryptSecret(scope: AccessScope, id: string, value: string) { - const [version, encodedIv, encodedTag, encodedCiphertext] = value.split("."); - if (version !== "v1" || !encodedIv || !encodedTag || !encodedCiphertext) { - throw new Error("The stored secret uses an unsupported format."); - } - - const decipher = createDecipheriv( - "aes-256-gcm", - Buffer.from(env.SECRET_ENCRYPTION_KEY, "base64"), - Buffer.from(encodedIv, "base64url") - ); - decipher.setAAD(secretAad(scope, id)); - decipher.setAuthTag(Buffer.from(encodedTag, "base64url")); - return Buffer.concat([ - decipher.update(Buffer.from(encodedCiphertext, "base64url")), - decipher.final(), - ]).toString("utf8"); -} - -function secretAad(scope: AccessScope, id: string) { - return Buffer.from(`${scope.workspaceId}\u0000vault\u0000${id}`); -} diff --git a/src/modules/manager/server/store.ts b/src/modules/manager/server/store.ts deleted file mode 100644 index c0151291..00000000 --- a/src/modules/manager/server/store.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - getTokenResponse, - NoValidTokenError, - UserAuthorizationRequiredError, -} from "@vercel/connect"; -import { ensureScope } from "@/db/services/scope"; -import { getGatewayModel, selectGatewayModel } from "@/db/services/settings"; -import { - createVaultItem as insertVaultItem, - deleteVaultItem, -} from "@/db/services/vault"; -import type { AccessScope } from "@/lib/access-scope"; -import { env } from "@/lib/env"; -import { googleWorkspaceTokenParams } from "@/lib/google-workspace"; -import type { ManagerMutation } from ".."; -import { parsePaymentCardSecret, paymentCardBrand } from "../payment-card"; -import { loginAccountHint, parseLoginVaultPayload } from "../vault-payload"; -import { deleteSecret, secretStoreStatus, writeSecret } from "./secret-store"; -import { readManagerVaultItems } from "./vault"; - -export async function readManagerSnapshot(scope: AccessScope) { - const [googleWorkspace, vaultRows, gatewayModel] = await Promise.all([ - getGoogleWorkspaceConnection(scope), - readManagerVaultItems(scope), - getGatewayModel(scope), - ]); - - return { - browser: { available: true }, - googleWorkspace, - runtime: { inference: gatewayModel }, - secretStore: secretStoreStatus(), - vaultItems: vaultRows, - }; -} - -async function getGoogleWorkspaceConnection(scope: AccessScope) { - try { - const response = await getTokenResponse( - env.GOOGLE_CONNECTOR_UID, - googleWorkspaceTokenParams(scope.userId), - { forceRefresh: true } - ); - return { - accountLabel: - response.name ?? - (typeof response.claims?.email === "string" - ? response.claims.email - : null), - state: "connected" as const, - }; - } catch (error) { - if ( - error instanceof UserAuthorizationRequiredError || - error instanceof NoValidTokenError - ) { - return { accountLabel: null, state: "disconnected" as const }; - } - return { accountLabel: null, state: "unavailable" as const }; - } -} - -export async function applyManagerMutation( - scope: AccessScope, - mutation: ManagerMutation -) { - await ensureScope(scope); - - switch (mutation.action) { - case "model.select": - await selectGatewayModel(scope, mutation.modelId); - break; - case "vault.create": - await createVaultItem(scope, mutation.input); - break; - case "vault.import": - for (const item of mutation.items) await createVaultItem(scope, item); - break; - case "vault.delete": - await removeVaultItem(scope, mutation.id); - break; - } - - return readManagerSnapshot(scope); -} - -async function createVaultItem( - scope: AccessScope, - input: Extract["input"] -) { - const id = randomUUID(); - const now = new Date().toISOString(); - await writeSecret({ id, namespace: "vault", scope, value: input.secret }); - - try { - await insertVaultItem(scope, { - account: vaultAccountHint(input), - createdAt: now, - id, - kind: input.kind, - label: input.label, - updatedAt: now, - }); - } catch (error) { - await deleteSecret({ id, namespace: "vault", scope }); - throw error; - } -} - -function vaultAccountHint( - input: Extract["input"] -) { - switch (input.kind) { - case "login": { - const payload = parseLoginVaultPayload(input.secret); - if (!payload) - throw new Error("The saved login is incomplete or invalid."); - return loginAccountHint( - payload.identifier, - "origin" in payload ? payload.origin : undefined - ); - } - case "payment": { - const card = parsePaymentCardSecret(input.secret); - return `${paymentCardBrand(card.number)} · •••• ${card.number.slice(-4)}`; - } - case "address": - case "contact": - return ""; - } -} - -async function removeVaultItem(scope: AccessScope, id: string) { - const deleted = await deleteVaultItem(scope, id); - if (!deleted) return; - await deleteSecret({ id, namespace: "vault", scope }); -} diff --git a/src/modules/manager/server/vault.ts b/src/modules/manager/server/vault.ts deleted file mode 100644 index fd8c90b6..00000000 --- a/src/modules/manager/server/vault.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ensureScope } from "@/db/services/scope"; -import { listVaultItems } from "@/db/services/vault"; -import type { AccessScope } from "@/lib/access-scope"; -import { hasSecret } from "./secret-store"; - -export async function readManagerVaultItems(scope: AccessScope) { - await ensureScope(scope); - const vaultRows = await listVaultItems(scope); - return Promise.all( - vaultRows.map(async (row) => ({ - ...row, - hasSecret: await hasSecret({ - id: row.id, - namespace: "vault", - scope, - }), - })) - ); -} diff --git a/src/modules/manager/vault-autofill.ts b/src/modules/manager/vault-autofill.ts deleted file mode 100644 index 5939e9f7..00000000 --- a/src/modules/manager/vault-autofill.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; - -export const fillFromVaultRequestSchema = z.object({ - browserSessionId: z.string().trim().min(1).max(500), - candidateId: z.string().trim().min(1).max(500), -}); diff --git a/src/trpc/router.test.ts b/src/trpc/router.test.ts index 5cad509f..294d17ed 100644 --- a/src/trpc/router.test.ts +++ b/src/trpc/router.test.ts @@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AccessScope } from "@/lib/access-scope"; const mocks = vi.hoisted(() => ({ - applyManagerMutation: - vi.fn<(scope: AccessScope, input: unknown) => Promise>(), disconnectGoogleWorkspace: vi.fn<(scope: AccessScope) => Promise>(), getAvailableModels: vi.fn<() => Promise<{ models: unknown[] }>>(), listOwnedSessionIds: vi.fn<(scope: AccessScope) => Promise>>(), @@ -46,10 +44,6 @@ vi.mock("@vercel/connect", () => ({ revokeToken: mocks.disconnectGoogleWorkspace, startAuthorization: mocks.startGoogleWorkspaceAuthorization, })); -vi.mock("@/modules/manager/server/store", () => ({ - applyManagerMutation: mocks.applyManagerMutation, -})); - import { appRouter } from "./router"; const scope = { diff --git a/src/trpc/router.ts b/src/trpc/router.ts index fb430a74..e54b7aa8 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -4,11 +4,8 @@ import { createWorld } from "@workflow/world-vercel"; import { revokeToken, startAuthorization } from "@vercel/connect"; import { saveChat } from "@/db/services/chats"; import { listOwnedSessionIds } from "@/db/services/sessions"; -import { - managerMutationSchema, - managerSnapshotSchema, -} from "@/modules/manager"; -import { applyManagerMutation } from "@/modules/manager/server/store"; +import { selectGatewayModel } from "@/db/services/settings"; +import { deleteVaultItem, saveVaultItem } from "@/db/services/vault"; import type { AccessScope } from "@/lib/access-scope"; import { saveChatSchema } from "@/lib/chat"; import { env } from "@/lib/env"; @@ -16,6 +13,7 @@ import { googleWorkspaceSubject, googleWorkspaceTokenParams, } from "@/lib/google-workspace"; +import { vaultCreateItemSchema, vaultImportItemsSchema } from "@/lib/vault"; import { createTRPCRouter, protectedProcedure } from "./init"; const taskHistoryPageSize = 25; @@ -48,11 +46,25 @@ export const appRouter = createTRPCRouter({ }; }), }, - manager: { - mutate: protectedProcedure - .input(managerMutationSchema) - .output(managerSnapshotSchema) - .mutation(({ ctx, input }) => applyManagerMutation(ctx.scope, input)), + settings: { + selectModel: protectedProcedure + .input(z.object({ modelId: z.string().trim().min(1).max(300) })) + .mutation(({ ctx, input }) => + selectGatewayModel(ctx.scope, input.modelId) + ), + }, + vault: { + create: protectedProcedure + .input(vaultCreateItemSchema) + .mutation(({ ctx, input }) => saveVaultItem(ctx.scope, input)), + import: protectedProcedure + .input(vaultImportItemsSchema) + .mutation(async ({ ctx, input }) => { + for (const item of input) await saveVaultItem(ctx.scope, item); + }), + remove: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(({ ctx, input }) => deleteVaultItem(ctx.scope, input.id)), }, models: { list: protectedProcedure.query(readModelCatalog), diff --git a/tests/agent-tool-boundaries.test.ts b/tests/agent-tool-boundaries.test.ts index 8d1adde5..b3dae9df 100644 --- a/tests/agent-tool-boundaries.test.ts +++ b/tests/agent-tool-boundaries.test.ts @@ -113,7 +113,7 @@ describe("root and worker capability boundaries", () => { expect(source).not.toContain("new Kernel("); } expect(readFileSync(`${workerTools}/fill_from_vault.ts`, "utf8")).toContain( - 'from "@/modules/manager/server/kernel-native-autofill"' + 'from "../lib/autofill/native"' ); }); diff --git a/tests/google-workspace.test.ts b/tests/google-workspace.test.ts index f0285575..77e75d14 100644 --- a/tests/google-workspace.test.ts +++ b/tests/google-workspace.test.ts @@ -1,10 +1,4 @@ -import { - getTokenResponse, - NoValidTokenError, - type ConnectTokenResponse, -} from "@vercel/connect"; -import type * as VercelConnect from "@vercel/connect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { parseCalendarAvailability } from "@/agent/lib/google-workspace/calendar"; import { googleWorkspaceAuthOptions } from "@/agent/lib/google-workspace/client"; import { gmailUpdateLabels } from "@/agent/lib/google-workspace/gmail"; @@ -14,34 +8,16 @@ import { googleWorkspaceSubject, googleWorkspaceTokenParams, } from "@/lib/google-workspace"; -import { readManagerSnapshot } from "@/modules/manager/server/store"; - -vi.mock("@vercel/connect", async (importOriginal) => ({ - ...(await importOriginal()), - getTokenResponse: vi.fn(), -})); -vi.mock("@/db/services/settings", () => ({ - getGatewayModel: vi.fn<() => Promise>().mockResolvedValue("test"), - selectGatewayModel: vi.fn<() => Promise>(), -})); -vi.mock("@/modules/manager/server/vault", () => ({ - readManagerVaultItems: vi.fn<() => Promise>().mockResolvedValue([]), -})); - -afterEach(() => vi.clearAllMocks()); -const scope = { - userId: "better-auth:user-123", - workspaceId: "personal:workspace-123", -}; +const userId = "better-auth:user-123"; -describe("Google Workspace connection", () => { +describe("Google Workspace", () => { it("uses one explicit least-privilege scope set", () => { expect(googleWorkspaceScopes).not.toContain("*"); expect(googleWorkspaceScopes).not.toContain("https://mail.google.com/"); - expect(googleWorkspaceTokenParams(scope.userId)).toEqual({ + expect(googleWorkspaceTokenParams(userId)).toEqual({ scopes: [...googleWorkspaceScopes], - subject: googleWorkspaceSubject(scope.userId), + subject: googleWorkspaceSubject(userId), }); expect(googleWorkspaceAuthOptions.tokenParams).toEqual({ scopes: [...googleWorkspaceScopes], @@ -49,46 +25,15 @@ describe("Google Workspace connection", () => { expect(googleWorkspaceAuthOptions.validate).toBe(true); }); - it("uses the same user subject for manager and Eve flows", () => { - expect(googleWorkspaceSubject(scope.userId)).toEqual({ - id: scope.userId, + it("uses a user-scoped connector subject", () => { + expect(googleWorkspaceSubject(userId)).toEqual({ + id: userId, issuer: "openinstinct", type: "user", }); }); - it("reports connected accounts without exposing tokens", async () => { - const response: ConnectTokenResponse = { - claims: { email: "person@example.com" }, - connector: { id: "connector-id", type: "oauth", uid: "google/test" }, - expiresAt: Date.now() + 60_000, - token: "must-not-leak", - }; - vi.mocked(getTokenResponse).mockResolvedValue(response); - - await expect(readManagerSnapshot(scope)).resolves.toMatchObject({ - googleWorkspace: { - accountLabel: "person@example.com", - state: "connected", - }, - }); - expect(getTokenResponse).toHaveBeenCalledWith( - expect.any(String), - googleWorkspaceTokenParams(scope.userId), - { forceRefresh: true } - ); - }); - - it("reports a missing user grant as disconnected", async () => { - vi.mocked(getTokenResponse).mockRejectedValue( - new NoValidTokenError("No Google grant for this user.") - ); - await expect(readManagerSnapshot(scope)).resolves.toMatchObject({ - googleWorkspace: { accountLabel: null, state: "disconnected" }, - }); - }); - - it("maps reversible Gmail actions to system labels", () => { + it("maps reversible Gmail actions and protects consequential writes", () => { expect(gmailUpdateLabels("archive")).toEqual({ addLabelIds: [], removeLabelIds: ["INBOX"], @@ -97,17 +42,11 @@ describe("Google Workspace connection", () => { addLabelIds: ["UNREAD"], removeLabelIds: [], }); - }); - - it("requires approval for consequential writes only", () => { expect(googleWorkspaceWriteApproval("update_email")).toBe("not-applicable"); expect(googleWorkspaceWriteApproval("send_email")).toBe("user-approval"); - expect(googleWorkspaceWriteApproval("create_calendar_event")).toBe( - "user-approval" - ); }); - it("does not interpret Google FreeBusy errors as availability", () => { + it("does not treat calendar API errors as availability", () => { expect(() => parseCalendarAvailability({ calendars: { @@ -117,31 +56,5 @@ describe("Google Workspace connection", () => { }, }) ).toThrow(/missing@example\.com: notFound/u); - - expect( - parseCalendarAvailability({ - calendars: { - primary: { - busy: [ - { - end: "2026-08-27T15:00:00-04:00", - start: "2026-08-27T14:00:00-04:00", - }, - ], - }, - }, - }) - ).toEqual({ - calendars: { - primary: { - busy: [ - { - end: "2026-08-27T15:00:00-04:00", - start: "2026-08-27T14:00:00-04:00", - }, - ], - }, - }, - }); }); }); diff --git a/tests/manager-setup.test.ts b/tests/manager-setup.test.ts deleted file mode 100644 index 5acfe76d..00000000 --- a/tests/manager-setup.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createManagerImportUrl, - createManagerSetupUrl, - managerMutationSchema, - managerSetupRequestSchema, - parseManagerSetupSearchParams, -} from "@/modules/manager"; -import { isSameOrigin } from "@/lib/same-origin"; -import { serializePaymentCard } from "@/modules/manager/payment-card"; -import { parseChromePasswordsCsv } from "@/modules/manager/chrome-passwords"; -import { - serializeContactVaultPayload, - serializeLoginVaultPayload, -} from "@/modules/manager/vault-payload"; - -describe("self-hosted manager", () => { - it("builds a direct Chrome import URL", () => { - expect(createManagerImportUrl("https://assistant.example.com")).toBe( - "https://assistant.example.com/vault?import=chrome" - ); - }); - - it("builds a vault form URL without accepting a secret", () => { - expect( - managerSetupRequestSchema.safeParse({ - kind: "login", - secret: "must-not-enter-a-url", - target: "vault", - }).success - ).toBe(false); - expect( - managerSetupRequestSchema.safeParse({ - kind: "identity", - target: "vault", - }).success - ).toBe(false); - expect( - managerSetupRequestSchema.safeParse({ - account: "person@example.com", - identifierType: "email", - kind: "login", - label: "Personal login", - origin: "https://auth.uber.com", - target: "vault", - }).success - ).toBe(false); - expect( - managerSetupRequestSchema.safeParse({ - kind: "login", - label: "Personal login", - origin: "https://auth.uber.com", - target: "vault", - }).success - ).toBe(false); - - const url = new URL( - createManagerSetupUrl("https://assistant.example.com", { - identifierType: "email", - kind: "login", - label: "Personal login", - origin: "https://auth.uber.com", - target: "vault", - }) - ); - - expect(url.pathname).toBe("/vault"); - expect(Object.fromEntries(url.searchParams)).toEqual({ - identifier_type: "email", - kind: "login", - label: "Personal login", - origin: "https://auth.uber.com", - setup: "vault", - }); - - const addressUrl = new URL( - createManagerSetupUrl("https://assistant.example.com", { - kind: "address", - label: "Home address", - target: "vault", - }) - ); - - expect(addressUrl.pathname).toBe("/vault"); - expect(Object.fromEntries(addressUrl.searchParams)).toEqual({ - kind: "address", - label: "Home address", - setup: "vault", - }); - expect( - parseManagerSetupSearchParams(Object.fromEntries(addressUrl.searchParams)) - ).toEqual({ - data: { - kind: "address", - label: "Home address", - target: "vault", - }, - success: true, - }); - expect( - parseManagerSetupSearchParams({ - ...Object.fromEntries(addressUrl.searchParams), - identifier_type: "email", - }).success - ).toBe(false); - }); - - it("accepts a selected gateway model", () => { - expect( - managerMutationSchema.safeParse({ - action: "model.select", - modelId: "anthropic/claude-sonnet-4.5", - }).success - ).toBe(true); - }); - - it("accepts only login credentials in a bulk vault import", () => { - expect( - managerMutationSchema.safeParse({ - action: "vault.import", - items: [ - { - account: "", - kind: "login", - label: "GitHub", - secret: serializeLoginVaultPayload({ - authentication: { - password: "correct horse battery staple", - type: "password", - }, - identifier: { type: "email", value: "person@example.com" }, - kind: "login", - origin: "https://github.com", - version: 2, - }), - }, - ], - }).success - ).toBe(true); - expect( - managerMutationSchema.safeParse({ - action: "vault.import", - items: [ - { - account: "", - kind: "phone", - label: "Mobile", - secret: "+1 555 555 5555", - }, - ], - }).success - ).toBe(false); - }); - - it("normalizes Chrome CSV rows into origin-bound vault logins", () => { - const result = parseChromePasswordsCsv( - '\uFEFFname,url,username,password,note\r\nGitHub,https://github.com,octo@example.com,"comma,quote""and\nnewline",ignored\r\n' - ); - - expect(result.skipped).toBe(0); - expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ - account: "", - kind: "login", - label: "GitHub", - }); - expect(JSON.parse(result.items[0]?.secret ?? "")).toEqual({ - authentication: { - password: 'comma,quote"and\nnewline', - type: "password", - }, - identifier: { type: "email", value: "octo@example.com" }, - kind: "login", - origin: "https://github.com", - version: 2, - }); - }); - - it("does not expose removed runtime mutations", () => { - expect( - managerMutationSchema.safeParse({ - action: "connection.create", - input: { - account: "qwen3.5:27b", - endpoint: "http://127.0.0.1:11434/v1", - label: "Local model", - provider: "local-model", - secret: "", - }, - }).success - ).toBe(false); - }); - - it("requires complete structured payment-card details", () => { - const mutation = { - action: "vault.create", - input: { - account: "Visa · •••• 4242", - kind: "payment", - label: "Personal", - secret: "4242 4242 4242 4242", - }, - }; - - expect(managerMutationSchema.safeParse(mutation).success).toBe(false); - expect( - managerMutationSchema.safeParse({ - ...mutation, - input: { - ...mutation.input, - secret: serializePaymentCard({ - billingPostalCode: "11217", - cardholderName: "Ada Lovelace", - expirationMonth: 12, - expirationYear: 2030, - kind: "payment-card", - number: "4242424242424242", - securityCode: "123", - version: 1, - }), - }, - }).success - ).toBe(true); - }); - - it("requires versioned login and contact payloads", () => { - expect( - managerMutationSchema.safeParse({ - action: "vault.create", - input: { - account: "ada@example.com", - kind: "login", - label: "GitHub", - secret: "plain password", - }, - }).success - ).toBe(false); - expect( - managerMutationSchema.safeParse({ - action: "vault.create", - input: { - account: "", - kind: "login", - label: "GitHub", - secret: serializeLoginVaultPayload({ - authentication: { password: "secret", type: "password" }, - identifier: { type: "email", value: "ada@example.com" }, - kind: "login", - origin: "https://github.com", - version: 2, - }), - }, - }).success - ).toBe(true); - expect( - managerMutationSchema.safeParse({ - action: "vault.create", - input: { - account: "", - kind: "contact", - label: "Checkout", - secret: serializeContactVaultPayload({ - email: "ada@example.com", - kind: "contact", - phone: "+15555550100", - version: 1, - }), - }, - }).success - ).toBe(true); - }); - - it("allows only same-origin writes", () => { - const headers = { - host: "internal.example:3000", - origin: "https://assistant.example.com", - "x-forwarded-host": "assistant.example.com", - "x-forwarded-proto": "https", - }; - - expect( - isSameOrigin( - new Request("http://internal.example:3000/api/manager", { headers }) - ) - ).toBe(true); - expect( - isSameOrigin( - new Request("http://internal.example:3000/api/manager", { - headers: { ...headers, origin: "https://attacker.example.com" }, - }) - ) - ).toBe(false); - }); -}); diff --git a/tests/payment-card.test.ts b/tests/payment-card.test.ts index 412b8700..b0cf26c3 100644 --- a/tests/payment-card.test.ts +++ b/tests/payment-card.test.ts @@ -4,7 +4,7 @@ import { paymentCardSecretStringSchema, paymentCardType, serializePaymentCard, -} from "@/modules/manager/payment-card"; +} from "@/lib/vault"; describe("payment card vault values", () => { it("serializes a complete structured card secret", () => { diff --git a/tests/services.test.ts b/tests/services.test.ts index 87772e96..bcd20583 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -216,21 +216,30 @@ describe("database services", () => { false ); - const now = new Date().toISOString(); - await vault.createVaultItem(alice, { + const { serializeLoginVaultPayload } = await import("@/lib/vault"); + await vault.saveVaultItem(alice, { account: "alice@example.com", - createdAt: now, - id: "vault-alice", kind: "login", label: "Alice", - updatedAt: now, + secret: serializeLoginVaultPayload({ + authentication: { password: "correct horse", type: "password" }, + identifier: { type: "email", value: "alice@example.com" }, + kind: "login", + origin: "https://example.com", + version: 2, + }), }); - expect(await vault.readVaultItem(alice, "vault-alice")).toMatchObject({ - id: "vault-alice", + const [aliceVaultItem] = await vault.listVaultItems(alice); + expect(aliceVaultItem).toMatchObject({ + label: "Alice", }); - expect(await vault.readVaultItem(bob, "vault-alice")).toBeUndefined(); + expect( + await vault.readVaultItem(bob, aliceVaultItem?.id ?? "vault-alice") + ).toBeUndefined(); expect(await vault.listVaultItems(alice)).toHaveLength(1); - expect(await vault.deleteVaultItem(bob, "vault-alice")).toBe(false); + expect( + await vault.deleteVaultItem(bob, aliceVaultItem?.id ?? "vault-alice") + ).toBe(false); await secrets.writeEncryptedSecret(alice, "shared-id", "ciphertext-alice"); await secrets.writeEncryptedSecret(bob, "shared-id", "ciphertext-bob"); diff --git a/tests/source-layout.test.ts b/tests/source-layout.test.ts index df88f86d..57c2ba9e 100644 --- a/tests/source-layout.test.ts +++ b/tests/source-layout.test.ts @@ -8,7 +8,6 @@ const allowedSrcDirectories = [ "components", "hooks", "lib", - "modules", "trpc", ]; @@ -31,14 +30,11 @@ const expectedLibFiles = [ "request-scope.ts", "same-origin.ts", "utils.ts", + "vault.ts", "worker-completion.ts", "worker-events.ts", ]; -const expectedModuleDirectories = ["manager"]; - -const expectedModuleFiles: string[] = []; - function directories(directory: string) { return readdirSync(directory) .filter((entry) => statSync(join(directory, entry)).isDirectory()) @@ -52,7 +48,7 @@ function files(directory: string) { } describe("source layout", () => { - it("keeps feature-owned code under modules instead of src root", () => { + it("keeps src limited to application layers", () => { expect(directories("src")).toEqual(allowedSrcDirectories); }); @@ -64,9 +60,4 @@ describe("source layout", () => { } expect(files("src/lib")).toEqual(expectedLibFiles); }); - - it("keeps modules limited to cross-cutting product domains", () => { - expect(directories("src/modules")).toEqual(expectedModuleDirectories); - expect(files("src/modules")).toEqual(expectedModuleFiles); - }); }); diff --git a/tests/vault-autofill.test.ts b/tests/vault-autofill.test.ts index 9fd8d4aa..8b379ca2 100644 --- a/tests/vault-autofill.test.ts +++ b/tests/vault-autofill.test.ts @@ -1,28 +1,41 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AccessScope } from "@/lib/access-scope"; -import type { VaultItemKind } from "@/modules/manager"; -import { serializePaymentCard } from "@/modules/manager/payment-card"; +import { + serializeAddressVaultPayload, + serializeContactVaultPayload, + serializeLoginVaultPayload, + serializePaymentCard, + type VaultItemKind, +} from "@/lib/vault"; import { classifyNativeLoginControl, selectNativeLoginFills, type NativeLoginControlDescriptor, -} from "@/modules/manager/server/kernel-login-autofill"; +} from "@/agent/subagents/worker/lib/autofill/login"; import { buildNativeAutofillPayload, nativeAutofillSecretMarkingExpression, nativeAutofillTokens, -} from "@/modules/manager/server/kernel-native-autofill"; +} from "@/agent/subagents/worker/lib/autofill/native"; import { listAutofillSuggestions, materializeAutofillClaims, type AutofillVaultAdapter, -} from "@/modules/manager/server/vault-autofill"; -import { createVaultAutofillProvider } from "@/modules/manager/server/vault-autofill-provider"; -import { - serializeAddressVaultPayload, - serializeContactVaultPayload, - serializeLoginVaultPayload, -} from "@/modules/manager/vault-payload"; +} from "@/agent/subagents/worker/lib/autofill/service"; +import { vaultAutofillProvider } from "@/agent/subagents/worker/lib/autofill/provider"; + +const vaultStore = vi.hoisted(() => ({ + items: [] as { id: string }[], + secret: "", +})); + +vi.mock("@/db/services/vault", () => ({ + hasVaultSecret: async () => true, + listVaultItems: async () => vaultStore.items, + readVaultItem: async (_scope: unknown, id: string) => + vaultStore.items.find((item) => item.id === id), + readVaultSecret: async () => vaultStore.secret, +})); const scope: AccessScope = { userId: "user-1", @@ -63,29 +76,19 @@ describe("vault browser autofill", () => { label: "Travel card", updatedAt: "2026-08-27T00:00:00.000Z", }; - const provider = createVaultAutofillProvider({ - async hasSecret() { - return true; - }, - async listVaultItems() { - return [card]; - }, - async readSecret() { - return serializePaymentCard({ - billingPostalCode: "10001", - cardholderName: "Grace Hopper", - expirationMonth: 9, - expirationYear: 2031, - kind: "payment-card", - number: "4111111111111111", - securityCode: "321", - version: 1, - }); - }, - async readVaultItem() { - return card; - }, - }); + const provider = providerFor( + card, + serializePaymentCard({ + billingPostalCode: "10001", + cardholderName: "Grace Hopper", + expirationMonth: 9, + expirationYear: 2031, + kind: "payment-card", + number: "4111111111111111", + securityCode: "321", + version: 1, + }) + ); await expect( provider.listSuggestions( @@ -605,20 +608,9 @@ function vaultItem(kind: VaultItemKind, label: string, account: string) { } function providerFor(item: ReturnType, secret: string) { - return createVaultAutofillProvider({ - async hasSecret() { - return true; - }, - async listVaultItems() { - return [item]; - }, - async readSecret() { - return secret; - }, - async readVaultItem() { - return item; - }, - }); + vaultStore.items = [item]; + vaultStore.secret = secret; + return vaultAutofillProvider; } function claimValues( diff --git a/tests/vault-payload.test.ts b/tests/vault-payload.test.ts index f0fbc824..99197a4e 100644 --- a/tests/vault-payload.test.ts +++ b/tests/vault-payload.test.ts @@ -12,7 +12,7 @@ import { serializeAddressVaultPayload, serializeContactVaultPayload, serializeLoginVaultPayload, -} from "@/modules/manager/vault-payload"; +} from "@/lib/vault"; describe("versioned vault payloads", () => { it("stores password and passwordless login methods", () => { diff --git a/tests/vault-setup.test.ts b/tests/vault-setup.test.ts new file mode 100644 index 00000000..88119f87 --- /dev/null +++ b/tests/vault-setup.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + createVaultSetupUrl, + parseVaultSetupSearchParams, + serializeLoginVaultPayload, + vaultCreateItemSchema, + vaultImportItemsSchema, + vaultSetupRequestSchema, +} from "@/lib/vault"; + +describe("vault setup", () => { + it("creates and validates a secret-free setup link", () => { + expect( + vaultSetupRequestSchema.safeParse({ + kind: "login", + secret: "must-not-enter-a-url", + target: "vault", + }).success + ).toBe(false); + + const url = new URL( + createVaultSetupUrl("https://assistant.example.com", { + identifierType: "email", + kind: "login", + label: "Personal login", + origin: "https://auth.uber.com", + target: "vault", + }) + ); + + expect( + parseVaultSetupSearchParams(Object.fromEntries(url.searchParams)) + ).toMatchObject({ + data: { + kind: "login", + target: "vault", + }, + success: true, + }); + }); + + it("accepts only structured login items in a bulk import", () => { + const login = { + account: "", + kind: "login" as const, + label: "GitHub", + secret: serializeLoginVaultPayload({ + authentication: { password: "correct horse", type: "password" }, + identifier: { type: "email", value: "person@example.com" }, + kind: "login", + origin: "https://github.com", + version: 2, + }), + }; + expect(vaultImportItemsSchema.safeParse([login]).success).toBe(true); + expect( + vaultImportItemsSchema.safeParse([ + { ...login, kind: "phone", secret: "+15555550100" }, + ]).success + ).toBe(false); + }); + + it("requires a valid structured secret for new vault items", () => { + expect( + vaultCreateItemSchema.safeParse({ + account: "", + kind: "login", + label: "GitHub", + secret: "plain password", + }).success + ).toBe(false); + }); +}); From d5f949b2a26e67d8bdafcaa011683a2997531857 Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:39:35 -0400 Subject: [PATCH 3/4] refactor: group worker trace helpers --- agent/subagents/worker/hooks/trace-telemetry.ts | 4 ++-- .../worker/lib/{trace-domains.ts => trace/domains.ts} | 0 .../worker/lib/{trace-timeline.ts => trace/timeline.ts} | 0 agent/subagents/worker/tools/manage_browsers.ts | 2 +- tests/browser-trace-telemetry.test.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename agent/subagents/worker/lib/{trace-domains.ts => trace/domains.ts} (100%) rename agent/subagents/worker/lib/{trace-timeline.ts => trace/timeline.ts} (100%) diff --git a/agent/subagents/worker/hooks/trace-telemetry.ts b/agent/subagents/worker/hooks/trace-telemetry.ts index cf75aa9b..84d4cdd3 100644 --- a/agent/subagents/worker/hooks/trace-telemetry.ts +++ b/agent/subagents/worker/hooks/trace-telemetry.ts @@ -5,12 +5,12 @@ import { completeBrowserTrace, recordBrowserTraceEvents, } from "@/db/services/browser-traces"; -import { traceTimelineRows } from "@/agent/subagents/worker/lib/trace-timeline"; +import { traceTimelineRows } from "@/agent/subagents/worker/lib/trace/timeline"; import { listWorkerBrowserSessions } from "@/db/services/browsers"; import type { AccessScope } from "@/lib/access-scope"; import { scopeFromPrincipal } from "@/lib/access-scope"; import { taskCompletionOutputSchema } from "@/lib/worker-completion"; -import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace-domains"; +import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace/domains"; export const traceTelemetryDependencies = { beginBrowserTrace, diff --git a/agent/subagents/worker/lib/trace-domains.ts b/agent/subagents/worker/lib/trace/domains.ts similarity index 100% rename from agent/subagents/worker/lib/trace-domains.ts rename to agent/subagents/worker/lib/trace/domains.ts diff --git a/agent/subagents/worker/lib/trace-timeline.ts b/agent/subagents/worker/lib/trace/timeline.ts similarity index 100% rename from agent/subagents/worker/lib/trace-timeline.ts rename to agent/subagents/worker/lib/trace/timeline.ts diff --git a/agent/subagents/worker/tools/manage_browsers.ts b/agent/subagents/worker/tools/manage_browsers.ts index 4c7b15aa..bcae7116 100644 --- a/agent/subagents/worker/tools/manage_browsers.ts +++ b/agent/subagents/worker/tools/manage_browsers.ts @@ -20,7 +20,7 @@ import { requireOwnedBrowserSession } from "@/agent/subagents/worker/lib/owned-b import { domainFromUrl, harvestBrowserTraceDomains, -} from "@/agent/subagents/worker/lib/trace-domains"; +} from "@/agent/subagents/worker/lib/trace/domains"; const browserTimeoutFloorSeconds = 15 * 60; diff --git a/tests/browser-trace-telemetry.test.ts b/tests/browser-trace-telemetry.test.ts index dcc15306..1733f699 100644 --- a/tests/browser-trace-telemetry.test.ts +++ b/tests/browser-trace-telemetry.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HookContext } from "eve/hooks"; import { z } from "zod"; -import { domainFromUrl } from "@/agent/subagents/worker/lib/trace-domains"; +import { domainFromUrl } from "@/agent/subagents/worker/lib/trace/domains"; import traceTelemetry, { traceTelemetryDependencies, } from "../agent/subagents/worker/hooks/trace-telemetry"; From 317c5f0f4b222d982081a50aeb8c59720ee3061e Mon Sep 17 00:00:00 2001 From: Jason Hedman <40368124+jasonhedman@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:43:43 -0400 Subject: [PATCH 4/4] docs: clarify code organization boundaries --- AGENTS.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f43a60f1..e1652342 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,10 +58,19 @@ Run the validation the task requests. When it does not establish the behavior yo - The repository root owns the single Next.js application, Eve agent, and shared UI contract. - The workspace manager lives on `/` and the agent chat on `/chat`; browser execution belongs only to the declared worker's flat tool surface under `agent/subagents/worker/tools`. -- Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `src/lib/kernel.ts` and keep only cross-tool ownership guards under `agent/subagents/worker/lib`; do not add a Kernel extension or root browser connection. +- Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `src/lib/kernel.ts`; do not add a Kernel extension or root browser connection. +- `agent/subagents/worker/lib` is for code genuinely shared by worker tools. Group a shared worker domain in a lower-case folder, such as `trace/domains.ts` or `autofill/provider.ts`; do not use it as a holding area for a tool's one-off logic. - Validate runtime environment variables through `src/lib/env.ts`. `KERNEL_API_KEY` is required by the worker browser tools. - Run `pnpm check` and `pnpm build` before handing off changes. +## Code organization + +- Treat `src/lib` as a small shared infrastructure and contract boundary, not a default destination for application code. A file belongs there only when it has real cross-feature ownership; put database access in `db/services`, agent behavior under `agent`, and route or section behavior with its route. +- Do not add a generic `src/modules` layer. Give code a concrete owner and colocate it there. A route section owns its section components, forms, and local parsing; split it only when the files have distinct responsibilities. +- Prefer one cohesive call-site file for code used once. Do not add production factories, dependency containers, server wrappers, or files solely to make a unit test easier to mock. +- Use lower-case file and folder names. When several files share a domain prefix, make that prefix a folder and name files for their role, such as `trace/domains.ts` rather than `trace-domains.ts`. Do not introduce camel-case filenames. +- Avoid catch-all names such as `manager`, `store`, `helpers`, or `utils` for feature ownership. Reuse an existing narrowly named boundary or place the code at the concrete owner instead. + ## Design system Before planning or changing product UI: