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: 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 86eed637..b7addaf1 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -10,13 +10,13 @@ import { z } from "zod"; import { getAuth } 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 e3499d17..fa859bc5 100644 --- a/agent/lib/google-workspace/client.ts +++ b/agent/lib/google-workspace/client.ts @@ -5,8 +5,8 @@ import { z } from "zod"; 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, @@ -18,7 +18,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/hooks/trace-telemetry.ts b/agent/subagents/worker/hooks/trace-telemetry.ts index 80693008..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/task-completion"; -import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace-domains"; +import { taskCompletionOutputSchema } from "@/lib/worker-completion"; +import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace/domains"; export const traceTelemetryDependencies = { beginBrowserTrace, diff --git a/src/lib/manager/server/kernel-login-autofill.ts b/agent/subagents/worker/lib/autofill/login.ts similarity index 98% rename from src/lib/manager/server/kernel-login-autofill.ts rename to agent/subagents/worker/lib/autofill/login.ts index 8b6bd3ce..b2fff796 100644 --- a/src/lib/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/lib/manager/server/kernel-native-autofill.ts b/agent/subagents/worker/lib/autofill/native.ts similarity index 99% rename from src/lib/manager/server/kernel-native-autofill.ts rename to agent/subagents/worker/lib/autofill/native.ts index 5afecd8a..0e58efe0 100644 --- a/src/lib/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 "../../env"; -import type { AutofillClaim } from "../vault-autofill-protocol"; +import { env } from "@/lib/env"; +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/lib/manager/vault-autofill-protocol.ts b/agent/subagents/worker/lib/autofill/protocol.ts similarity index 100% rename from src/lib/manager/vault-autofill-protocol.ts rename to agent/subagents/worker/lib/autofill/protocol.ts diff --git a/src/lib/manager/server/vault-autofill-provider.ts b/agent/subagents/worker/lib/autofill/provider.ts similarity index 63% rename from src/lib/manager/server/vault-autofill-provider.ts rename to agent/subagents/worker/lib/autofill/provider.ts index 65183235..a77861e9 100644 --- a/src/lib/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/lib/manager/server/vault-autofill.ts b/agent/subagents/worker/lib/autofill/service.ts similarity index 92% rename from src/lib/manager/server/vault-autofill.ts rename to agent/subagents/worker/lib/autofill/service.ts index 07f568cf..8a4b6509 100644 --- a/src/lib/manager/server/vault-autofill.ts +++ b/agent/subagents/worker/lib/autofill/service.ts @@ -1,9 +1,9 @@ -import type { AccessScope } from "../../access-scope"; +import type { AccessScope } from "@/lib/access-scope"; import type { AutofillClaim, AutofillSuggestion, DetectedAutofillSurface, -} from "../vault-autofill-protocol"; +} from "./protocol"; export interface AutofillFillTarget { readonly availableTokens: ReadonlySet; 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/capture_browser_image.ts b/agent/subagents/worker/tools/capture_browser_image.ts index efae71fa..e41e67cb 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..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 "@/lib/manager/server/vault-autofill"; -import { vaultAutofillProvider } from "@/lib/manager/server/vault-autofill-provider"; import { currentKernelPageOrigin, fillWithKernelNativeAutofill, nativeAutofillTokens, -} from "@/lib/manager/server/kernel-native-autofill"; -import { fillFromVaultRequestSchema } from "@/lib/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 f9edebf6..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 "@/lib/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/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/agent/tools/request_vault_import.ts b/agent/tools/request_vault_import.ts index 289e9caf..02606fb3 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 { applicationOrigin } from "@/lib/application-origin"; -import { createManagerImportUrl } from "@/lib/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(applicationOrigin()), + url: new URL("/vault?import=chrome", applicationOrigin()).toString(), }; }, }); diff --git a/agent/tools/request_vault_setup.ts b/agent/tools/request_vault_setup.ts index 7d1a3d8b..74c36fde 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 "@/lib/manager"; +import { createVaultSetupUrl, vaultSetupRequestSchema } from "@/lib/vault"; import { applicationOrigin } from "@/lib/application-origin"; 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(applicationOrigin(), request), + url: createVaultSetupUrl(applicationOrigin(), request), }; }, }); 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..9ed0f3c0 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 "@/lib/manager"; import { db, vaultItems } from "@/db"; +import { + deleteEncryptedSecret, + readEncryptedSecret, + writeEncryptedSecret, +} from "@/db/services/secrets"; +import { ensureScope } from "@/db/services/scope"; +import { getInstallationSecrets } from "@/lib/installation-secrets"; 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,125 @@ 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); + if (!encrypted) return undefined; + const { secretEncryptionKey } = await getInstallationSecrets(); + return decryptVaultSecret(scope, id, encrypted, secretEncryptionKey); +} + +export async function hasVaultSecret(scope: AccessScope, id: string) { + return (await readEncryptedSecret(scope, id)) !== undefined; +} + +async function writeVaultSecret(scope: AccessScope, id: string, value: string) { + const { secretEncryptionKey } = await getInstallationSecrets(); + await writeEncryptedSecret( + scope, + id, + encryptVaultSecret(scope, id, value, secretEncryptionKey) + ); +} + +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, + secretEncryptionKey: string +) { + const iv = randomBytes(12); + const cipher = createCipheriv( + "aes-256-gcm", + Buffer.from(secretEncryptionKey, "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, + secretEncryptionKey: 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(secretEncryptionKey, "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/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 4301826c..ba0142a9 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,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 7decd855..8164917c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,9 +107,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) @@ -6258,9 +6255,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==} @@ -12913,8 +12907,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..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 "@/lib/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 0b72091a..921b88b9 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, @@ -29,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 ?? []) { @@ -48,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 493371d4..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 "@/lib/manager"; import { paymentCardBrand, paymentCardType, serializePaymentCard, -} from "@/lib/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 2c3f2bc3..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 "@/lib/manager"; -import { serializeContactVaultPayload } from "@/lib/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 ef20dd1c..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 "@/lib/manager"; import { loginIdentifierSchema, loginIdentifierTypeSchema, loginOriginSchema, serializeLoginVaultPayload, -} from "@/lib/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 508107ca..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 "@/lib/manager"; -import { parseChromePasswordsCsv } from "@/lib/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 5e09bb3a..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 "@/lib/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 68838d2b..0c6cb30f 100644 --- a/src/app/(authenticated)/(manager)/vault/page.tsx +++ b/src/app/(authenticated)/(manager)/vault/page.tsx @@ -1,34 +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 "@/lib/manager"; -import { readManagerSnapshot } from "@/lib/manager/server/store"; -import { z } from "zod"; -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) { - const scalar = z.string().safeParse(value); - return scalar.success ? scalar.data : value?.[0]; -} 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 340857ff..1c194cf6 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -28,7 +28,7 @@ import { } from "react"; import { Streamdown, type Components } from "streamdown"; import { z } from "zod"; -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 9087879e..00000000 --- a/src/lib/browser-images/server.ts +++ /dev/null @@ -1,226 +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 { blobAuthentication as resolveBlobAuthentication } from "@/lib/blob-authentication"; -import { env } from "@/lib/env"; - -const blobOptions = { access: "private" as const }; - -export const browserImageServerDependencies = { - del, - finalizeBrowserImageArtifact, - get, - put, - readReadyBrowserImageArtifact, -}; - -export function browserImageBlobAuthentication(input: { - readonly readWriteToken?: string; - readonly storeId?: string; -}) { - return resolveBlobAuthentication( - input, - "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 browserImageServerDependencies.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 browserImageServerDependencies.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 browserImageServerDependencies - .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 browserImageServerDependencies.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 browserImageServerDependencies.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 3160f304..00000000 --- a/src/lib/google-workspace/server.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - getTokenResponse, - NoValidTokenError, - revokeToken, - startAuthorization, - UserAuthorizationRequiredError, -} from "@vercel/connect"; -import type { AccessScope } from "@/lib/access-scope"; -import { env } from "@/lib/env"; -import { z } from "zod"; -import { googleWorkspaceSubject, googleWorkspaceTokenParams } from "./config"; - -export const googleWorkspaceServerDependencies = { - getTokenResponse, - revokeToken, - startAuthorization, -}; - -export async function getGoogleWorkspaceConnection(scope: AccessScope) { - try { - const response = await googleWorkspaceServerDependencies.getTokenResponse( - env.GOOGLE_CONNECTOR_UID, - googleWorkspaceTokenParams(scope.userId), - { forceRefresh: true } - ); - const email = z.string().safeParse(response.claims?.email); - return { - accountLabel: response.name ?? (email.success ? email.data : 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 googleWorkspaceServerDependencies.startAuthorization( - env.GOOGLE_CONNECTOR_UID, - googleWorkspaceTokenParams(scope.userId), - { callbackUrl, expiresInMs: 10 * 60_000 } - ); - return authorization.url; -} - -export async function disconnectGoogleWorkspace(scope: AccessScope) { - await googleWorkspaceServerDependencies.revokeToken( - env.GOOGLE_CONNECTOR_UID, - { - subject: googleWorkspaceSubject(scope.userId), - } - ); -} diff --git a/src/lib/manager/chrome-passwords.ts b/src/lib/manager/chrome-passwords.ts deleted file mode 100644 index 387cf06c..00000000 --- a/src/lib/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/lib/manager/index.ts b/src/lib/manager/index.ts deleted file mode 100644 index 1eae5361..00000000 --- a/src/lib/manager/index.ts +++ /dev/null @@ -1,167 +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) { - const scalar = z.string().safeParse(value); - return scalar.success ? scalar.data : value?.[0]; -} diff --git a/src/lib/manager/payment-card.ts b/src/lib/manager/payment-card.ts deleted file mode 100644 index bc907224..00000000 --- a/src/lib/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/lib/manager/server/secret-store.ts b/src/lib/manager/server/secret-store.ts deleted file mode 100644 index 865a0e34..00000000 --- a/src/lib/manager/server/secret-store.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { - deleteEncryptedSecret, - readEncryptedSecret, - writeEncryptedSecret, -} from "@/db/services/secrets"; -import { getInstallationSecrets } from "@/lib/installation-secrets"; -import type { AccessScope } from "../../access-scope"; - -export const secretStoreDependencies = { - deleteEncryptedSecret, - getInstallationSecrets, - readEncryptedSecret, - writeEncryptedSecret, -}; - -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 secretStoreDependencies.writeEncryptedSecret( - scope, - id, - await encryptSecret(scope, id, value) - ); -} - -export async function readSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - const encrypted = await secretStoreDependencies.readEncryptedSecret( - scope, - id - ); - return encrypted ? await decryptSecret(scope, id, encrypted) : undefined; -} - -export async function hasSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - return ( - (await secretStoreDependencies.readEncryptedSecret(scope, id)) !== undefined - ); -} - -export async function deleteSecret({ - id, - scope, -}: { - readonly id: string; - readonly namespace: "vault"; - readonly scope: AccessScope; -}) { - await secretStoreDependencies.deleteEncryptedSecret(scope, id); -} - -async function encryptSecret(scope: AccessScope, id: string, value: string) { - const { secretEncryptionKey } = - await secretStoreDependencies.getInstallationSecrets(); - const iv = randomBytes(12); - const cipher = createCipheriv( - "aes-256-gcm", - Buffer.from(secretEncryptionKey, "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("."); -} - -async function decryptSecret(scope: AccessScope, id: string, value: string) { - const { secretEncryptionKey } = - await secretStoreDependencies.getInstallationSecrets(); - 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(secretEncryptionKey, "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/lib/manager/server/store.ts b/src/lib/manager/server/store.ts deleted file mode 100644 index 1603c05f..00000000 --- a/src/lib/manager/server/store.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { ensureScope } from "@/db/services/scope"; -import { 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 { 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, modelSettings] = await Promise.all([ - getGoogleWorkspaceConnection(scope), - readManagerVaultItems(scope), - getModelSettings(scope), - ]); - - return { - browser: { available: true }, - googleWorkspace, - runtime: { inference: modelSettings.modelId }, - secretStore: secretStoreStatus(), - vaultItems: vaultRows, - }; -} - -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/lib/manager/server/vault.ts b/src/lib/manager/server/vault.ts deleted file mode 100644 index fd8c90b6..00000000 --- a/src/lib/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/lib/manager/vault-autofill.ts b/src/lib/manager/vault-autofill.ts deleted file mode 100644 index 5939e9f7..00000000 --- a/src/lib/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/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/manager/vault-payload.ts b/src/lib/vault.ts similarity index 56% rename from src/lib/manager/vault-payload.ts rename to src/lib/vault.ts index 0cbae9ad..eb377ab0 100644 --- a/src/lib/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,49 @@ 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) { + const parsed = z.union([z.string(), z.array(z.string())]).safeParse(value); + if (!parsed.success) return undefined; + return Array.isArray(parsed.data) ? parsed.data[0] : parsed.data; +} + function serializedPayloadSchema(schema: z.ZodType, message: string) { return z.string().superRefine((value, context) => { if (!parseSerializedPayload(schema, value)) { 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 f0803819..157e257a 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 5229279f..7f18db78 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 { taskCompletionOutputSchema } from "../task-completion"; +import { z } from "zod"; +import { taskCompletionOutputSchema } 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/trpc/router.test.ts b/src/trpc/router.test.ts index 6f3ab7c5..81085b9d 100644 --- a/src/trpc/router.test.ts +++ b/src/trpc/router.test.ts @@ -1,17 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as BrowserTraces from "@/db/services/browser-traces"; +import * as Chats from "@/db/services/chats"; import type { AccessScope } from "@/lib/access-scope"; -import { appRouter, routerDependencies } from "./router"; +import { appRouter } from "./router"; -const disconnectGoogleWorkspaceMock = vi.spyOn( - routerDependencies, - "disconnectGoogleWorkspace" -); -const listBrowserTracesMock = vi.spyOn(routerDependencies, "listBrowserTraces"); -const saveChatMock = vi.spyOn(routerDependencies, "saveChat"); -const startGoogleWorkspaceAuthorizationMock = vi.spyOn( - routerDependencies, - "startGoogleWorkspaceAuthorization" -); +const listBrowserTracesMock = vi.spyOn(BrowserTraces, "listBrowserTraces"); +const saveChatMock = vi.spyOn(Chats, "saveChat"); const scope = { userId: "user-1", @@ -22,10 +16,7 @@ describe("appRouter", () => { beforeEach(() => vi.clearAllMocks()); it("passes the authenticated scope and cursor to the trace history", async () => { - listBrowserTracesMock.mockResolvedValue({ - nextCursor: null, - traces: [], - }); + listBrowserTracesMock.mockResolvedValue({ nextCursor: null, traces: [] }); await appRouter .createCaller({ origin: "https://example.com", scope }) @@ -42,34 +33,4 @@ describe("appRouter", () => { ).rejects.toThrow("Too small"); expect(saveChatMock).not.toHaveBeenCalled(); }); - - it("returns a typed Google authorization redirect", async () => { - startGoogleWorkspaceAuthorizationMock.mockResolvedValue( - "https://accounts.google.com/authorize" - ); - - const result = await appRouter - .createCaller({ origin: "https://example.com", scope }) - .googleWorkspace.update("connect"); - - expect(startGoogleWorkspaceAuthorizationMock).toHaveBeenCalledWith( - scope, - "https://example.com/?google=connected" - ); - expect(result).toEqual({ - redirectTo: "https://accounts.google.com/authorize", - }); - }); - - it("surfaces Google connector failures", async () => { - disconnectGoogleWorkspaceMock.mockRejectedValue( - new Error("connector unavailable") - ); - - await expect( - appRouter - .createCaller({ origin: "https://example.com", scope }) - .googleWorkspace.update("disconnect") - ).rejects.toThrow("connector unavailable"); - }); }); diff --git a/src/trpc/router.ts b/src/trpc/router.ts index bd39e24a..0697e679 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -1,75 +1,128 @@ +import { gateway } from "ai"; +import { revokeToken, startAuthorization } from "@vercel/connect"; import { z } from "zod"; -import { readModelCatalog } from "@/lib/model-catalog/server"; import { listBrowserTraces } from "@/db/services/browser-traces"; import { saveChat } from "@/db/services/chats"; +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 { 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 { vaultCreateItemSchema, vaultImportItemsSchema } from "@/lib/vault"; import { createTRPCRouter, protectedProcedure } from "./init"; -export const routerDependencies = { - applyManagerMutation, - disconnectGoogleWorkspace, - listBrowserTraces, - readModelCatalog, - saveChat, - startGoogleWorkspaceAuthorization, -}; - export const appRouter = createTRPCRouter({ chats: { save: protectedProcedure .input(saveChatSchema) - .mutation(({ ctx, input }) => - routerDependencies.saveChat(ctx.scope, input) - ), + .mutation(({ ctx, input }) => saveChat(ctx.scope, input)), }, googleWorkspace: { update: protectedProcedure - .input(googleWorkspaceActionSchema) + .input(z.enum(["connect", "disconnect"])) .mutation(async ({ ctx, input }) => { if (input === "disconnect") { - await routerDependencies.disconnectGoogleWorkspace(ctx.scope); + await revokeToken(env.GOOGLE_CONNECTOR_UID, { + subject: googleWorkspaceSubject(ctx.scope.userId), + }); return { redirectTo: "/?google=disconnected" }; } const callbackUrl = new URL("/", ctx.origin); callbackUrl.searchParams.set("google", "connected"); return { - redirectTo: - await routerDependencies.startGoogleWorkspaceAuthorization( - ctx.scope, - callbackUrl.toString() - ), + redirectTo: await startGoogleWorkspaceAuthorization( + ctx.scope, + callbackUrl.toString() + ), }; }), }, - manager: { - mutate: protectedProcedure - .input(managerMutationSchema) - .output(managerSnapshotSchema) + settings: { + selectModel: protectedProcedure + .input(z.object({ modelId: z.string().trim().min(1).max(300) })) .mutation(({ ctx, input }) => - routerDependencies.applyManagerMutation(ctx.scope, input) + selectGatewayModel(ctx.scope, input.modelId) ), }, - models: { - list: protectedProcedure.query(routerDependencies.readModelCatalog), - }, traces: { list: protectedProcedure .input(z.object({ cursor: z.string().nullish() })) .query(({ ctx, input }) => - routerDependencies.listBrowserTraces( - ctx.scope, - input.cursor ?? undefined - ) + listBrowserTraces(ctx.scope, input.cursor ?? undefined) ), }, + 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), + }, }); 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; +} + +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..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 "@/lib/manager/server/kernel-native-autofill"' + 'from "../lib/autofill/native"' ); }); 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 04ea4c08..2c0b31bd 100644 --- a/tests/browser-image-route.test.ts +++ b/tests/browser-image-route.test.ts @@ -1,80 +1,38 @@ -/* oxlint-disable vitest/require-mock-type-parameters -- Hoisted auth and storage fakes are configured per test. */ +/* oxlint-disable anti-slop/no-module-mocking, vitest/require-mock-type-parameters -- The route owns the private Blob read. These fakes isolate external auth and storage without a production wrapper. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as AuthSession from "@/auth/session"; -import { GET } from "@/app/artifacts/[artifactId]/route"; -import * as BrowserImageServer from "@/lib/browser-images/server"; -import { authSessionFor } from "./helpers/auth-session"; const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -const getAuthSessionMock = vi.spyOn(AuthSession, "getAuthSession"); -const getBlobMock = vi.spyOn(BrowserImageServer, "getBrowserImageBlob"); -type OpenedBrowserImage = NonNullable< - Awaited> ->; - -function openedBrowserImage(statusCode: 200 | 304): OpenedBrowserImage { - const stream = statusCode === 200 ? new Response(png).body : null; - const artifact = { - browserSessionId: "browser-1", - byteSize: png.byteLength, - contentHash: "content-hash", - createdAt: "2026-08-31T00:00:00.000Z", - createdByUserId: "user-1", - filename: "Product image.png", - id: artifactId, - idempotencyKey: "image-call-1", - label: "Product image", - mediaType: "image/png", - rootSessionId: "root-session", - sourceKind: "viewport", - status: "ready", - storagePathname: `browser-images/workspace/${artifactId}`, - workerSessionId: "worker-1", - workspaceId: "workspace-1", - }; - const blob = { - cacheControl: "private, max-age=3600", - contentDisposition: 'inline; filename="Product image.png"', - downloadUrl: "https://blob.example/download", - etag: '"etag"', - pathname: `browser-images/workspace/${artifactId}`, - uploadedAt: new Date("2026-08-31T00:00:00.000Z"), - url: "https://blob.example/image", - }; - if (statusCode === 304) { - return { - artifact, - result: { - blob: { ...blob, contentType: null, size: null }, - headers: new Headers(), - statusCode, - stream: null, - }, - }; - } - if (!stream) throw new Error("The test Response did not expose a body."); - return { - artifact, - result: { - blob: { ...blob, contentType: "image/png", size: png.byteLength }, - headers: new Headers(), - statusCode, - stream, - }, - }; -} +const mocks = vi.hoisted(() => ({ + getAuthSession: vi.fn(), + getBlob: vi.fn(), + readArtifact: vi.fn(), +})); + +vi.mock("@/auth/session", () => ({ getAuthSession: mocks.getAuthSession })); +vi.mock("@/db/services/browser-images", () => ({ + readReadyBrowserImageArtifact: mocks.readArtifact, +})); +vi.mock("@vercel/blob", () => ({ + get: mocks.getBlob, +})); + +import { GET } from "@/app/artifacts/[artifactId]/route"; beforeEach(() => { vi.clearAllMocks(); - getAuthSessionMock.mockResolvedValue( - authSessionFor({ - id: "user-1", - phoneNumber: "+12025550123", - phoneNumberVerified: true, - }) - ); - getBlobMock.mockResolvedValue(openedBrowserImage(200)); + 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({ + blob: { contentType: "image/png", etag: '"etag"', size: png.byteLength }, + statusCode: 200, + stream: new Response(png).body, + }); }); describe("browser image route", () => { @@ -95,7 +53,11 @@ describe("browser image route", () => { }); it("passes conditional ETags through to private Blob", async () => { - getBlobMock.mockResolvedValue(openedBrowserImage(304)); + mocks.getBlob.mockResolvedValue({ + blob: { contentType: "image/png", etag: '"etag"', size: png.byteLength }, + statusCode: 304, + stream: null, + }); const response = await GET( request({ "if-none-match": '"etag"' }), @@ -103,39 +65,30 @@ describe("browser image route", () => { ); expect(response.status).toBe(304); - expect(getBlobMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: "better-auth:user-1" }), - artifactId, + expect(mocks.getBlob).toHaveBeenCalledWith( + "artifacts/product", expect.objectContaining({ ifNoneMatch: '"etag"' }) ); }); it.each([ ["unauthenticated", null, artifactId], - [ - "invalid id", - authSessionFor({ - id: "user-1", - phoneNumber: "+12025550123", - phoneNumberVerified: true, - }), - "not-an-id", - ], + ["invalid id", { user: { id: "user-1" } }, "not-an-id"], ])( "returns the same not-found response for %s requests", async (_name, session, id) => { - getAuthSessionMock.mockResolvedValue(session); + mocks.getAuthSession.mockResolvedValue(session); const response = await GET(request(), context(id)); expect(response.status).toBe(404); expect(await response.text()).toBe("Not found"); - expect(getBlobMock).not.toHaveBeenCalled(); + expect(mocks.getBlob).not.toHaveBeenCalled(); } ); it("does not reveal an unavailable or cross-workspace artifact", async () => { - getBlobMock.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 d0ed7445..00000000 --- a/tests/browser-image-storage.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* oxlint-disable vitest/require-mock-type-parameters -- Hoisted Blob and database fakes are configured per test. */ -import { createHash } from "node:crypto"; -import type { get } from "@vercel/blob"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type * as BrowserImageService from "@/db/services/browser-images"; -import { maximumBrowserImageBytes } from "@/lib/browser-images"; - -import { - browserImageBlobAuthentication, - browserImageServerDependencies, - persistReservedBrowserImage, - readBoundedResponse, - readBrowserImageBytes, -} from "@/lib/browser-images/server"; - -const delMock = vi.spyOn(browserImageServerDependencies, "del"); -const getMock = vi.spyOn(browserImageServerDependencies, "get"); -const putMock = vi.spyOn(browserImageServerDependencies, "put"); -const finalizeMock = vi.spyOn( - browserImageServerDependencies, - "finalizeBrowserImageArtifact" -); -const readReadyMock = vi.spyOn( - browserImageServerDependencies, - "readReadyBrowserImageArtifact" -); -type BlobGetResult = NonNullable>>; -type FinalizedArtifact = Awaited< - ReturnType ->; -type ReadyArtifact = NonNullable< - Awaited> ->; - -function blobGetResult(): BlobGetResult { - const stream = new Response(png).body; - if (!stream) throw new Error("The test Response did not expose a body."); - return { - blob: { - cacheControl: "private, max-age=3600", - contentDisposition: 'inline; filename="product.png"', - contentType: "image/png", - downloadUrl: "https://blob.example/download", - etag: '"etag"', - pathname: reservation.storagePathname, - size: png.byteLength, - uploadedAt: new Date("2026-08-31T00:00:00.000Z"), - url: "https://blob.example/image", - }, - headers: new Headers(), - statusCode: 200, - stream, - }; -} - -function readyArtifact(contentHash: string): ReadyArtifact { - return { - browserSessionId: "browser-1", - byteSize: png.byteLength, - contentHash, - createdAt: "2026-08-31T00:00:00.000Z", - createdByUserId: scope.userId, - filename: "product.png", - id: reservation.id, - idempotencyKey: "image-call-1", - label: "Product", - mediaType: "image/png", - rootSessionId: "root-session", - sourceKind: "viewport", - status: "ready", - storagePathname: reservation.storagePathname, - workerSessionId: "worker-1", - workspaceId: scope.workspaceId, - }; -} - -function finalizedArtifact(storagePathname: string): FinalizedArtifact { - return { - image: { - byteSize: png.byteLength, - filename: "product.png", - id: reservation.id, - label: "Product", - mediaType: "image/png", - url: `/artifacts/${reservation.id}`, - }, - storagePathname, - }; -} - -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(); - delMock.mockResolvedValue(undefined); - putMock.mockResolvedValue({ - contentDisposition: 'inline; filename="product.png"', - contentType: "image/png", - downloadUrl: "https://blob.example/download", - etag: '"etag"', - pathname: reservation.storagePathname, - url: "https://blob.example/image", - }); - finalizeMock.mockResolvedValue( - finalizedArtifact( - `${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(putMock).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(finalizeMock).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 () => { - readReadyMock.mockResolvedValue( - readyArtifact(createHash("sha256").update(png).digest("hex")) - ); - getMock.mockResolvedValue(blobGetResult()); - - const result = await readBrowserImageBytes(scope, reservation.id, { - rootSessionId: "root-session", - }); - - expect(readReadyMock).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 () => { - readReadyMock.mockResolvedValue(readyArtifact("not-the-hash")); - getMock.mockResolvedValue(blobGetResult()); - - 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`; - finalizeMock.mockResolvedValue(finalizedArtifact(winnerPathname)); - - await persistReservedBrowserImage(scope, reservation, { - bytes: png, - filename: "product.png", - sourceKind: "viewport", - }); - - const losingPathname = `${reservation.storagePathname}/${createHash( - "sha256" - ) - .update(png) - .digest("hex")}`; - expect(delMock).toHaveBeenCalledWith(losingPathname, { - token: "vercel_blob_rw_test", - }); - }); -}); diff --git a/tests/browser-images.test.ts b/tests/browser-images.test.ts index fef6fdf4..b866b889 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 { taskCompletionOutputSchema, 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( taskCompletionOutputSchema.parse({ message: "Done", status: "success" }) 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"; diff --git a/tests/google-workspace.test.ts b/tests/google-workspace.test.ts index feadfe3d..77e75d14 100644 --- a/tests/google-workspace.test.ts +++ b/tests/google-workspace.test.ts @@ -1,111 +1,39 @@ -import { NoValidTokenError, type ConnectTokenResponse } 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"; import { googleWorkspaceWriteApproval } from "@/agent/tools/google_workspace_write"; import { - GOOGLE_WORKSPACE_SCOPES, + googleWorkspaceScopes, googleWorkspaceSubject, googleWorkspaceTokenParams, -} from "@/lib/google-workspace/config"; -import { - getGoogleWorkspaceConnection, - googleWorkspaceServerDependencies, - startGoogleWorkspaceAuthorization, -} from "@/lib/google-workspace/server"; - -const getTokenResponseMock = vi.spyOn( - googleWorkspaceServerDependencies, - "getTokenResponse" -); -const startAuthorizationMock = vi.spyOn( - googleWorkspaceServerDependencies, - "startAuthorization" -); +} from "@/lib/google-workspace"; -afterEach(() => vi.clearAllMocks()); +const userId = "better-auth:user-123"; -const scope = { - userId: "better-auth:user-123", - workspaceId: "personal:workspace-123", -}; - -describe("Google Workspace connection", () => { +describe("Google Workspace", () => { 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(googleWorkspaceTokenParams(scope.userId)).toEqual({ - scopes: [...GOOGLE_WORKSPACE_SCOPES], - subject: googleWorkspaceSubject(scope.userId), + expect(googleWorkspaceScopes).not.toContain("*"); + expect(googleWorkspaceScopes).not.toContain("https://mail.google.com/"); + expect(googleWorkspaceTokenParams(userId)).toEqual({ + scopes: [...googleWorkspaceScopes], + subject: googleWorkspaceSubject(userId), }); expect(googleWorkspaceAuthOptions.tokenParams).toEqual({ - scopes: [...GOOGLE_WORKSPACE_SCOPES], + scopes: [...googleWorkspaceScopes], }); 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", - }; - getTokenResponseMock.mockResolvedValue(response); - - await expect(getGoogleWorkspaceConnection(scope)).resolves.toEqual({ - accountLabel: "person@example.com", - state: "connected", - }); - expect(getTokenResponseMock).toHaveBeenCalledWith( - expect.any(String), - googleWorkspaceTokenParams(scope.userId), - { forceRefresh: true } - ); - }); - - it("reports a missing user grant as disconnected", async () => { - getTokenResponseMock.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 () => { - startAuthorizationMock.mockResolvedValue({ - request: "request", - url: "https://connect.vercel.com/request", - verifier: "verifier", - }); - - await expect( - startGoogleWorkspaceAuthorization( - scope, - "https://openinstinct.example/?google=connected" - ) - ).resolves.toBe("https://connect.vercel.com/request"); - expect(startAuthorizationMock).toHaveBeenCalledWith( - expect.any(String), - googleWorkspaceTokenParams(scope.userId), - expect.objectContaining({ - callbackUrl: "https://openinstinct.example/?google=connected", - }) - ); - }); - - it("maps reversible Gmail actions to system labels", () => { + it("maps reversible Gmail actions and protects consequential writes", () => { expect(gmailUpdateLabels("archive")).toEqual({ addLabelIds: [], removeLabelIds: ["INBOX"], @@ -114,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: { @@ -134,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/linq-browser-image-delivery.test.ts b/tests/linq-browser-image-delivery.test.ts index 1c611002..f633ac32 100644 --- a/tests/linq-browser-image-delivery.test.ts +++ b/tests/linq-browser-image-delivery.test.ts @@ -1,26 +1,43 @@ -/* oxlint-disable vitest/require-mock-type-parameters -- The hoisted storage fake is configured per test. */ +/* oxlint-disable anti-slop/no-module-mocking, vitest/require-mock-type-parameters -- Linq delivery owns the Blob read. These fakes isolate storage without a production wrapper. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as BrowserImageServer from "@/lib/browser-images/server"; -import { prepareLinqBrowserImageDelivery } from "../agent/lib/linq-browser-image-delivery"; +import type { AccessScope } from "@/lib/access-scope"; const firstId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; const secondId = "206c3a7e-c0b8-4317-9e34-552cff646673"; -const readImageMock = vi.spyOn(BrowserImageServer, "readBrowserImageBytes"); +const mocks = vi.hoisted(() => ({ getBlob: vi.fn(), readArtifact: vi.fn() })); + +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"; const scope = { userId: "user-1", workspaceId: "workspace-1" }; beforeEach(() => { vi.clearAllMocks(); - readImageMock.mockImplementation(async (_scope, id) => - id === firstId - ? { - bytes: new Uint8Array([1, 2, 3]), - filename: "product.png", - id, - mediaType: "image/png", - } - : undefined + mocks.readArtifact.mockImplementation( + async (_scope: AccessScope, id: string) => + id === firstId + ? { + 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", () => { @@ -36,7 +53,7 @@ describe("Linq browser image delivery", () => { scope, }); - expect(readImageMock).toHaveBeenCalledExactlyOnceWith(scope, firstId, { + expect(mocks.readArtifact).toHaveBeenCalledExactlyOnceWith(scope, firstId, { rootSessionId: "root-session", signal: undefined, }); @@ -78,6 +95,6 @@ describe("Linq browser image delivery", () => { files: [], markdown, }); - expect(readImageMock).not.toHaveBeenCalled(); + expect(mocks.readArtifact).not.toHaveBeenCalled(); }); }); diff --git a/tests/linq-message-delivery.test.ts b/tests/linq-message-delivery.test.ts index 4c5624ae..fe5abb51 100644 --- a/tests/linq-message-delivery.test.ts +++ b/tests/linq-message-delivery.test.ts @@ -1,11 +1,68 @@ -/* oxlint-disable typescript/no-unsafe-type-assertion, vitest/require-mock-type-parameters -- Eve's Linq adapter exposes the handler context through a transitive Chat SDK `any`; the fixture supplies only the fields exercised here. */ +/* oxlint-disable anti-slop/no-module-mocking, typescript/no-unsafe-type-assertion, vitest/require-mock-type-parameters -- Linq delivery owns Blob I/O. These fakes isolate storage without adding a production wrapper; the handler fixture supplies only exercised Chat SDK fields. */ import type { HookContext } from "eve/hooks"; import { describe, expect, it, vi } from "vitest"; -import * as BrowserImageServer from "@/lib/browser-images/server"; +import type * as Blob from "@vercel/blob"; +import type { AccessScope } from "@/lib/access-scope"; import { linqChannelConfig } from "../agent/channels/linq"; import workerCancellationHook from "../agent/hooks/worker-cancellation-delivery"; -const readImageMock = vi.spyOn(BrowserImageServer, "readBrowserImageBytes"); +interface BrowserImage { + bytes: Uint8Array; + filename: string; + id: string; + mediaType: string; +} + +const linqChannelCapture = vi.hoisted(() => ({ + images: new Map(), + readImage: vi.fn< + ( + scope: AccessScope, + id: string, + options: { + readonly rootSessionId: string; + readonly signal?: AbortSignal; + } + ) => Promise + >(), +})); +vi.mock("@/db/services/browser-images", () => ({ + async readReadyBrowserImageArtifact( + scope: AccessScope, + id: string, + options: { readonly rootSessionId: string; readonly signal?: AbortSignal } + ) { + 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("@vercel/blob", async (importOriginal) => { + const blob = await importOriginal(); + return { + ...blob, + async get(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, + }; + }, + }; +}); const channelEvents = linqChannelConfig.events; const trackWorkerCancellation = channelEvents["action.result"]; const deliverCompletedMessage = channelEvents["message.completed"]; @@ -51,7 +108,7 @@ describe("Linq message delivery", () => { it("replaces scoped artifact markdown with native iMessage files", async () => { const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; - readImageMock.mockResolvedValue({ + linqChannelCapture.readImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), filename: "product.png", id: artifactId, @@ -67,7 +124,7 @@ describe("Linq message delivery", () => { sessionContext() ); - expect(readImageMock).toHaveBeenCalledWith( + expect(linqChannelCapture.readImage).toHaveBeenCalledWith( expect.objectContaining({ userId: "user-1", workspaceId: "workspace-1", @@ -90,14 +147,16 @@ describe("Linq message delivery", () => { it("sends multiple artifact images as one native attachment gallery", async () => { const firstArtifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; const secondArtifactId = "206c3a7e-c0b8-4317-9e34-552cff646673"; - readImageMock.mockImplementation(async (_scope, artifactId) => ({ - bytes: new Uint8Array( - artifactId === firstArtifactId ? [1, 2, 3] : [4, 5, 6] - ), - filename: artifactId === firstArtifactId ? "first.png" : "second.png", - id: artifactId, - mediaType: "image/png", - })); + linqChannelCapture.readImage.mockImplementation( + async (_scope, artifactId) => ({ + bytes: new Uint8Array( + artifactId === firstArtifactId ? [1, 2, 3] : [4, 5, 6] + ), + filename: artifactId === firstArtifactId ? "first.png" : "second.png", + id: artifactId, + mediaType: "image/png", + }) + ); const { context, post } = handlerContext(); await deliverCompletedMessage( @@ -131,7 +190,7 @@ describe("Linq message delivery", () => { it("keeps reply bubbles and attaches images to the final bubble", async () => { const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; - readImageMock.mockResolvedValue({ + linqChannelCapture.readImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), filename: "product.png", id: artifactId, diff --git a/tests/manager-setup.test.ts b/tests/manager-setup.test.ts deleted file mode 100644 index 03cd6a02..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 "@/lib/manager"; -import { isSameOrigin } from "@/lib/same-origin"; -import { serializePaymentCard } from "@/lib/manager/payment-card"; -import { parseChromePasswordsCsv } from "@/lib/manager/chrome-passwords"; -import { - serializeContactVaultPayload, - serializeLoginVaultPayload, -} from "@/lib/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 0de85ddf..b0cf26c3 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 "@/lib/vault"; describe("payment card vault values", () => { it("serializes a complete structured card secret", () => { diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts deleted file mode 100644 index 95effcb5..00000000 --- a/tests/secret-store.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - readSecret, - secretStoreDependencies, - writeSecret, -} from "@/lib/manager/server/secret-store"; - -const readEncryptedSecretMock = vi.spyOn( - secretStoreDependencies, - "readEncryptedSecret" -); -const writeEncryptedSecretMock = vi.spyOn( - secretStoreDependencies, - "writeEncryptedSecret" -); -const getInstallationSecretsMock = vi.spyOn( - secretStoreDependencies, - "getInstallationSecrets" -); -const scope = { userId: "user-1", workspaceId: "workspace-1" }; -let encrypted: string | undefined; - -beforeEach(() => { - vi.clearAllMocks(); - encrypted = undefined; - getInstallationSecretsMock.mockResolvedValue({ - betterAuthSecret: Buffer.alloc(32, 7).toString("base64"), - secretEncryptionKey: Buffer.alloc(32, 8).toString("base64"), - version: 1, - }); - writeEncryptedSecretMock.mockImplementation((_scope, _id, value) => { - encrypted = value; - return Promise.resolve(); - }); - readEncryptedSecretMock.mockImplementation(() => Promise.resolve(encrypted)); -}); - -describe("vault secret store", () => { - it("encrypts and decrypts with the provisioned installation key", async () => { - await writeSecret({ - id: "credential-1", - namespace: "vault", - scope, - value: "correct horse battery staple", - }); - - expect(encrypted).toMatch(/^v1\./u); - expect(encrypted).not.toContain("correct horse battery staple"); - await expect( - readSecret({ id: "credential-1", namespace: "vault", scope }) - ).resolves.toBe("correct horse battery staple"); - }); - - it("binds ciphertext to its workspace and item id", async () => { - await writeSecret({ - id: "credential-1", - namespace: "vault", - scope, - value: "secret", - }); - - await expect( - readSecret({ id: "credential-2", namespace: "vault", scope }) - ).rejects.toThrow(/authenticate|state/iu); - }); -}); diff --git a/tests/services.test.ts b/tests/services.test.ts index e2d3760d..e42f1c92 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -227,6 +227,7 @@ describe("database services", () => { false ); + const { serializeLoginVaultPayload } = await import("@/lib/vault"); await browserTraces.beginBrowserTrace(alice, { sessionId: "worker-alice", startedAt: "2026-08-31T00:00:00.000Z", @@ -303,21 +304,29 @@ describe("database services", () => { }); expect((await browserTraces.listBrowserTraces(bob)).traces).toEqual([]); - const now = new Date().toISOString(); - await vault.createVaultItem(alice, { + 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"); @@ -336,8 +345,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..0d1cc63c --- /dev/null +++ b/tests/source-layout.test.ts @@ -0,0 +1,67 @@ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const allowedSrcDirectories = [ + "app", + "auth", + "components", + "hooks", + "lib", + "trpc", +]; + +const disallowedLibDirectories = [ + "browser", + "browser-images", + "google-workspace", + "manager", + "model-catalog", + "task-history", +]; + +const expectedLibFiles = [ + "access-scope.ts", + "application-origin.ts", + "blob-authentication.ts", + "browser-artifact.ts", + "chat.ts", + "env.ts", + "google-workspace.ts", + "installation-secrets-schema.ts", + "installation-secrets.ts", + "kernel.ts", + "request-scope.ts", + "same-origin.ts", + "utils.ts", + "vault.ts", + "worker-completion.ts", + "worker-events.ts", +]; + +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 src limited to application layers", () => { + 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); + }); +}); diff --git a/tests/vault-autofill.test.ts b/tests/vault-autofill.test.ts index ddb37fd0..5e016485 100644 --- a/tests/vault-autofill.test.ts +++ b/tests/vault-autofill.test.ts @@ -1,30 +1,53 @@ +/* oxlint-disable anti-slop/no-module-mocking -- The vault provider intentionally reads the concrete vault service. This focused test replaces only persistence I/O without adding a production-only factory. */ import { runInNewContext } from "node:vm"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { AccessScope } from "@/lib/access-scope"; -import type { VaultItemKind } from "@/lib/manager"; -import { serializePaymentCard } from "@/lib/manager/payment-card"; +import { + serializeAddressVaultPayload, + serializeContactVaultPayload, + serializeLoginVaultPayload, + serializePaymentCard, + type VaultItemKind, +} from "@/lib/vault"; import { classifyNativeLoginControl, selectNativeLoginFills, type NativeLoginControlDescriptor, -} from "@/lib/manager/server/kernel-login-autofill"; +} from "@/agent/subagents/worker/lib/autofill/login"; import { buildNativeAutofillPayload, nativeAutofillSecretMarkingExpression, nativeAutofillTokens, -} from "@/lib/manager/server/kernel-native-autofill"; +} from "@/agent/subagents/worker/lib/autofill/native"; import { listAutofillSuggestions, materializeAutofillClaims, type AutofillVaultAdapter, -} from "@/lib/manager/server/vault-autofill"; -import { createVaultAutofillProvider } from "@/lib/manager/server/vault-autofill-provider"; -import { - serializeAddressVaultPayload, - serializeContactVaultPayload, - serializeLoginVaultPayload, -} from "@/lib/manager/vault-payload"; +} from "@/agent/subagents/worker/lib/autofill/service"; +import { vaultAutofillProvider } from "@/agent/subagents/worker/lib/autofill/provider"; + +interface VaultStore { + items: { id: string }[]; + secret: string; +} + +function createVaultStore(): VaultStore { + return { + items: [], + secret: "", + }; +} + +const vaultStore = vi.hoisted(createVaultStore); + +vi.mock("@/db/services/vault", () => ({ + hasVaultSecret: async () => true, + listVaultItems: async () => vaultStore.items, + readVaultItem: async (_scope: AccessScope, id: string) => + vaultStore.items.find((item) => item.id === id), + readVaultSecret: async () => vaultStore.secret, +})); const scope: AccessScope = { userId: "user-1", @@ -65,29 +88,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( @@ -607,20 +620,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 98f65cd9..99197a4e 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 "@/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); + }); +}); diff --git a/tests/worker-browser-images.test.ts b/tests/worker-browser-images.test.ts index a36a4264..17800a3c 100644 --- a/tests/worker-browser-images.test.ts +++ b/tests/worker-browser-images.test.ts @@ -1,12 +1,5 @@ -/* oxlint-disable typescript/no-unsafe-type-assertion, vitest/require-mock-type-parameters -- Eve owns the tool context and Vitest owns these hoisted provider fakes. */ +/* oxlint-disable anti-slop/no-module-mocking, typescript/no-unsafe-type-assertion, vitest/require-mock-type-parameters -- The tool owns Kernel and Blob I/O. These fakes isolate external APIs without adding a production wrapper. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as WorkerAccess from "@/agent/subagents/worker/lib/access"; -import * as OwnedBrowser from "@/agent/subagents/worker/lib/owned-browser"; -import * as ScreenshotMask from "@/agent/subagents/worker/lib/vault-screenshot-mask"; -import * as BrowserImageService from "@/db/services/browser-images"; -import * as BrowserImageServer from "@/lib/browser-images/server"; -import { kernel } from "@/lib/kernel"; -import captureBrowserImage from "../agent/subagents/worker/tools/capture_browser_image"; import { toolContextFor } from "./helpers/tool-context"; const artifactId = "0d01e667-d128-4bb7-a248-1ae21db72f4f"; @@ -20,23 +13,52 @@ const image = { url: `/artifacts/${artifactId}`, }; -const mocks = { - captureScreenshot: vi.spyOn(kernel.browsers.computer, "captureScreenshot"), - deleteFile: vi.spyOn(kernel.browsers.fs, "deleteFile"), - fetch: vi.spyOn(kernel.browsers, "fetch"), - mask: vi.spyOn(ScreenshotMask, "withVaultScreenshotMask"), - persist: vi.spyOn(BrowserImageServer, "persistReservedBrowserImage"), - playwrightExecute: vi.spyOn(kernel.browsers.playwright, "execute"), - readBoundedResponse: vi.spyOn(BrowserImageServer, "readBoundedResponse"), - readFile: vi.spyOn(kernel.browsers.fs, "readFile"), - reserve: vi.spyOn(BrowserImageService, "reserveBrowserImageArtifact"), - retrieve: vi.spyOn(kernel.browsers, "retrieve"), - requireOwnedBrowserSession: vi.spyOn( - OwnedBrowser, - "requireOwnedBrowserSession" - ), - requireWorkerScope: vi.spyOn(WorkerAccess, "requireWorkerScope"), -}; +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(), + readFile: vi.fn(), + reserve: vi.fn(), + retrieve: vi.fn(), + put: vi.fn(), + requireOwnedBrowserSession: vi.fn(), + requireWorkerScope: vi.fn(), +})); + +vi.mock("@/agent/subagents/worker/lib/access", () => ({ + requireWorkerScope: mocks.requireWorkerScope, +})); +vi.mock("@/agent/subagents/worker/lib/owned-browser", () => ({ + requireOwnedBrowserSession: mocks.requireOwnedBrowserSession, +})); +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("@vercel/blob", () => ({ + del: mocks.del, + put: mocks.put, +})); +vi.mock("@/lib/kernel", () => ({ + kernel: { + browsers: { + computer: { captureScreenshot: mocks.captureScreenshot }, + fetch: mocks.fetch, + fs: { deleteFile: mocks.deleteFile, readFile: mocks.readFile }, + playwright: { execute: mocks.playwrightExecute }, + retrieve: mocks.retrieve, + }, + }, +})); + +import captureBrowserImage from "../agent/subagents/worker/tools/capture_browser_image"; const scope = { userId: "user-1", workspaceId: "workspace-1" }; const reservation = { @@ -53,12 +75,17 @@ beforeEach(() => { workerSessionId: "worker-session-1", }); mocks.reserve.mockResolvedValue({ reservation, status: "pending" }); - mocks.persist.mockResolvedValue(image); - mocks.mask.mockImplementation(async (_sessionId, _signal, capture) => - capture() + 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: () => Promise + ) => 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); @@ -105,8 +132,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "viewport" }), - toolContext.abortSignal + expect.objectContaining({ sourceKind: "viewport" }) ); expect(result).toEqual({ image }); expect(JSON.stringify(result)).not.toContain("base64"); @@ -167,8 +193,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "image_resource" }), - expect.any(AbortSignal) + expect.objectContaining({ sourceKind: "image_resource" }) ); expect(JSON.stringify(mocks.persist.mock.calls)).not.toContain( "private=ignored" @@ -199,8 +224,7 @@ describe("capture_browser_image", () => { expect(mocks.persist).toHaveBeenCalledWith( scope, reservation, - expect.objectContaining({ sourceKind: "element" }), - toolContext.abortSignal + expect.objectContaining({ sourceKind: "element" }) ); });