From 1561073c78a4c0e693a91cad2501e522320d1855 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:38:18 +0000 Subject: [PATCH 01/27] chore: release release --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index cddbaefb9..c95106184 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa2ed919..e2dea4a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0](https://github.com/trycompai/crm/compare/v1.1.0...v1.2.0) (2026-08-07) + + +### Features + +* **api:** add microsoft sign-in and outlook mailbox sync ([#73](https://github.com/trycompai/crm/issues/73)) ([2a0062f](https://github.com/trycompai/crm/commit/2a0062fb76ffdaa5bbbb3848a5573b8b53cd0036)) + ## [1.1.0](https://github.com/trycompai/crm/compare/v1.0.0...v1.1.0) (2026-08-06) diff --git a/package.json b/package.json index 58f50e8b1..bd61fd43b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build": "turbo run build", From c7fc76ee5074152f691c55a9ce5fb017521fefc4 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Fri, 28 Aug 2026 11:25:55 +0300 Subject: [PATCH 02/27] feat(agent): add XMPP export tools gateway (#1) Implement XMPP gateway and expose agents' capabilities --- .env.example | 35 + .gitignore | 2 + .oxlintrc.json | 1 + AGENTS.md | 50 +- apps/agent/agent/channels/xmpp.ts | 101 ++ apps/agent/package.json | 8 + apps/agent/scripts/start.ts | 12 +- .../src/export-tools/define-export-tool.ts | 9 + apps/agent/src/export-tools/errors.ts | 70 + apps/agent/src/export-tools/eve-adapter.ts | 102 ++ apps/agent/src/export-tools/executor.ts | 38 + .../src/export-tools/export-tools.test.ts | 171 ++ apps/agent/src/export-tools/manifest.ts | 18 + apps/agent/src/export-tools/registry.ts | 20 + apps/agent/src/export-tools/schema.ts | 45 + apps/agent/src/export-tools/types.ts | 76 + apps/agent/src/export-tools/wire.ts | 62 + apps/agent/src/exports/handle_crm_request.ts | 68 + apps/agent/src/exports/ping.ts | 22 + apps/agent/src/xmpp/config.ts | 61 + apps/agent/src/xmpp/gateway-host.ts | 232 +++ apps/agent/src/xmpp/iq-handler.ts | 331 ++++ apps/agent/src/xmpp/manifest.test.ts | 24 + apps/agent/src/xmpp/manifest.ts | 76 + apps/agent/src/xmpp/task-store.ts | 253 +++ apps/agent/test/e2e/xmpp-export.e2e.ts | 142 ++ .../test/xmpp-task-store.integration.spec.ts | 113 ++ apps/agent/tsconfig.json | 4 +- bun.lock | 387 +++- docs/agent.md | 20 + docs/environment.md | 1 + docs/eve-export-tool-subsystem-spec.md | 1615 +++++++++++++++++ docs/setup.md | 34 + package.json | 3 +- packages/agent-xmpp/core/package.json | 28 + packages/agent-xmpp/core/src/index.ts | 1 + .../core/src/schema-worker-lineage.test.ts | 75 + packages/agent-xmpp/core/src/schema-worker.ts | 57 + packages/agent-xmpp/core/src/schema.test.ts | 23 + packages/agent-xmpp/core/src/schema.ts | 558 ++++++ packages/agent-xmpp/core/tsconfig.json | 18 + packages/agent-xmpp/gateway/package.json | 30 + .../agent-xmpp/gateway/src/agent-api-disco.ts | 465 +++++ packages/agent-xmpp/gateway/src/agent-send.ts | 53 + packages/agent-xmpp/gateway/src/config.ts | 99 + packages/agent-xmpp/gateway/src/delivery.ts | 137 ++ .../gateway/src/embedded-gateway.ts | 330 ++++ packages/agent-xmpp/gateway/src/hash-codec.ts | 22 + packages/agent-xmpp/gateway/src/json-codec.ts | 12 + .../agent-xmpp/gateway/src/protocol-error.ts | 66 + .../agent-xmpp/gateway/src/receipt-tracker.ts | 79 + .../gateway/src/review-fixes.test.ts | 98 + packages/agent-xmpp/gateway/src/rsm-codec.ts | 66 + .../agent-xmpp/gateway/src/runtime-mailbox.ts | 15 + .../agent-xmpp/gateway/src/stanza-router.ts | 163 ++ .../gateway/src/task-stanza-codec.ts | 630 +++++++ .../gateway/src/xep-plugins/chatstate.ts | 65 + .../gateway/src/xep-plugins/data-form.ts | 135 ++ .../agent-xmpp/gateway/src/xep-plugins/jid.ts | 9 + .../gateway/src/xep-plugins/message.ts | 258 +++ .../agent-xmpp/gateway/src/xep-plugins/muc.ts | 74 + .../gateway/src/xep-plugins/ping.ts | 20 + .../gateway/src/xep-plugins/presence.ts | 91 + .../gateway/src/xep-plugins/receipts.ts | 43 + .../gateway/src/xep-plugins/routing.ts | 25 + .../gateway/src/xep-plugins/search.ts | 124 ++ .../gateway/src/xep-plugins/vcard.ts | 22 + .../agent-xmpp/gateway/src/xmpp-component.ts | 434 +++++ .../agent-xmpp/gateway/src/xmpp-keepalive.ts | 63 + .../agent-xmpp/gateway/src/xmpp-shims.d.ts | 53 + packages/agent-xmpp/gateway/tsconfig.json | 18 + packages/agent-xmpp/protocol/package.json | 29 + .../agent-xmpp/protocol/schema/agent-api.xsd | 166 ++ .../agent-xmpp/protocol/schema/agent-task.xsd | 205 +++ .../protocol/schema/event.schema.json | 162 ++ .../protocol/schema/manifest.schema.json | 177 ++ .../protocol/schema/namespaces.json | 10 + packages/agent-xmpp/protocol/src/agent-api.ts | 91 + .../agent-xmpp/protocol/src/agent-message.ts | 159 ++ .../agent-xmpp/protocol/src/agent-task.ts | 71 + packages/agent-xmpp/protocol/src/bridge.ts | 59 + .../agent-xmpp/protocol/src/identifiers.ts | 112 ++ packages/agent-xmpp/protocol/src/index.ts | 8 + packages/agent-xmpp/protocol/src/jid.ts | 98 + .../agent-xmpp/protocol/src/namespaces.ts | 63 + .../protocol/src/strict-json.test.ts | 10 + .../agent-xmpp/protocol/src/strict-json.ts | 181 ++ packages/agent-xmpp/protocol/tsconfig.json | 18 + .../migration.sql | 33 + .../migration.sql | 6 + packages/db/prisma/schema.prisma | 44 + turbo.json | 22 + 92 files changed, 10334 insertions(+), 55 deletions(-) create mode 100644 apps/agent/agent/channels/xmpp.ts create mode 100644 apps/agent/src/export-tools/define-export-tool.ts create mode 100644 apps/agent/src/export-tools/errors.ts create mode 100644 apps/agent/src/export-tools/eve-adapter.ts create mode 100644 apps/agent/src/export-tools/executor.ts create mode 100644 apps/agent/src/export-tools/export-tools.test.ts create mode 100644 apps/agent/src/export-tools/manifest.ts create mode 100644 apps/agent/src/export-tools/registry.ts create mode 100644 apps/agent/src/export-tools/schema.ts create mode 100644 apps/agent/src/export-tools/types.ts create mode 100644 apps/agent/src/export-tools/wire.ts create mode 100644 apps/agent/src/exports/handle_crm_request.ts create mode 100644 apps/agent/src/exports/ping.ts create mode 100644 apps/agent/src/xmpp/config.ts create mode 100644 apps/agent/src/xmpp/gateway-host.ts create mode 100644 apps/agent/src/xmpp/iq-handler.ts create mode 100644 apps/agent/src/xmpp/manifest.test.ts create mode 100644 apps/agent/src/xmpp/manifest.ts create mode 100644 apps/agent/src/xmpp/task-store.ts create mode 100644 apps/agent/test/e2e/xmpp-export.e2e.ts create mode 100644 apps/agent/test/xmpp-task-store.integration.spec.ts create mode 100644 docs/eve-export-tool-subsystem-spec.md create mode 100644 packages/agent-xmpp/core/package.json create mode 100644 packages/agent-xmpp/core/src/index.ts create mode 100644 packages/agent-xmpp/core/src/schema-worker-lineage.test.ts create mode 100644 packages/agent-xmpp/core/src/schema-worker.ts create mode 100644 packages/agent-xmpp/core/src/schema.test.ts create mode 100644 packages/agent-xmpp/core/src/schema.ts create mode 100644 packages/agent-xmpp/core/tsconfig.json create mode 100644 packages/agent-xmpp/gateway/package.json create mode 100644 packages/agent-xmpp/gateway/src/agent-api-disco.ts create mode 100644 packages/agent-xmpp/gateway/src/agent-send.ts create mode 100644 packages/agent-xmpp/gateway/src/config.ts create mode 100644 packages/agent-xmpp/gateway/src/delivery.ts create mode 100644 packages/agent-xmpp/gateway/src/embedded-gateway.ts create mode 100644 packages/agent-xmpp/gateway/src/hash-codec.ts create mode 100644 packages/agent-xmpp/gateway/src/json-codec.ts create mode 100644 packages/agent-xmpp/gateway/src/protocol-error.ts create mode 100644 packages/agent-xmpp/gateway/src/receipt-tracker.ts create mode 100644 packages/agent-xmpp/gateway/src/review-fixes.test.ts create mode 100644 packages/agent-xmpp/gateway/src/rsm-codec.ts create mode 100644 packages/agent-xmpp/gateway/src/runtime-mailbox.ts create mode 100644 packages/agent-xmpp/gateway/src/stanza-router.ts create mode 100644 packages/agent-xmpp/gateway/src/task-stanza-codec.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/jid.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/message.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/muc.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/ping.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/presence.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/routing.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/search.ts create mode 100644 packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts create mode 100644 packages/agent-xmpp/gateway/src/xmpp-component.ts create mode 100644 packages/agent-xmpp/gateway/src/xmpp-keepalive.ts create mode 100644 packages/agent-xmpp/gateway/src/xmpp-shims.d.ts create mode 100644 packages/agent-xmpp/gateway/tsconfig.json create mode 100644 packages/agent-xmpp/protocol/package.json create mode 100644 packages/agent-xmpp/protocol/schema/agent-api.xsd create mode 100644 packages/agent-xmpp/protocol/schema/agent-task.xsd create mode 100644 packages/agent-xmpp/protocol/schema/event.schema.json create mode 100644 packages/agent-xmpp/protocol/schema/manifest.schema.json create mode 100644 packages/agent-xmpp/protocol/schema/namespaces.json create mode 100644 packages/agent-xmpp/protocol/src/agent-api.ts create mode 100644 packages/agent-xmpp/protocol/src/agent-message.ts create mode 100644 packages/agent-xmpp/protocol/src/agent-task.ts create mode 100644 packages/agent-xmpp/protocol/src/bridge.ts create mode 100644 packages/agent-xmpp/protocol/src/identifiers.ts create mode 100644 packages/agent-xmpp/protocol/src/index.ts create mode 100644 packages/agent-xmpp/protocol/src/jid.ts create mode 100644 packages/agent-xmpp/protocol/src/namespaces.ts create mode 100644 packages/agent-xmpp/protocol/src/strict-json.test.ts create mode 100644 packages/agent-xmpp/protocol/src/strict-json.ts create mode 100644 packages/agent-xmpp/protocol/tsconfig.json create mode 100644 packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql create mode 100644 packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql diff --git a/.env.example b/.env.example index 12fac543c..e05ec0b3b 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,41 @@ GOOGLE_CLIENT_SECRET="" # schedule. # AGENT_BRIDGE_SECRET="" +# Optional XMPP agent gateway. Set XMPP_COMPONENT_ENABLED to 1 and provide the +# component identity, component secret, and owning organization together. The +# gateway exposes only operations from apps/agent/src/exports. Task state is +# retained in PostgreSQL for 24 hours after admission. +# XMPP_COMPONENT_ENABLED="1" +# XMPP_COMPONENT_JID="gateway.agents.example.com" +# XMPP_COMPONENT_SECRET="" +# XMPP_ORGANIZATION_ID="" +# XMPP_COMPONENT_SERVICE="xmpp://127.0.0.1:5275" +# XMPP_DEFAULT_AGENT_JID="assistant@agents.example.com" +# XMPP_AGENT_DOMAIN="agents.example.com" +# XMPP_SERVER_DOMAIN="example.com" +# XMPP_GATEWAY_ID="gw-1" +# XMPP_AGENT_VERSION="1.0.0" + +# Comma-separated domains that can discover and invoke the endpoint. The +# server and agent domains are used when this value is absent. +# XMPP_ALLOWED_CALLER_DOMAINS="example.com,agents.example.com" + +# Comma-separated bare JIDs that can invoke destructive exports. Destructive +# exports remain visible but return forbidden when this value is absent. +# XMPP_ALLOW_DESTRUCTIVE_CALLERS="trusted-agent@agents.example.com" + +# Optional transport tuning. The copied gateway supplies safe defaults. +# XMPP_XML_LANG="en" +# XMPP_RECEIPT_TIMEOUT_MS="30000" +# XMPP_RECEIPT_MAX_RESENDS="0" +# XMPP_RECEIPT_SWEEP_MS="10000" +# XMPP_RECONNECT_INITIAL_MS="1000" +# XMPP_RECONNECT_MAX_MS="60000" +# XMPP_PING_INTERVAL_MS="60000" +# XMPP_PING_TIMEOUT_MS="10000" +# XMPP_PING_FAILURE_THRESHOLD="2" +# XMPP_MAX_PENDING_IQ_REQUESTS="256" + # PORT="3001" diff --git a/.gitignore b/.gitignore index 853852992..404b72c60 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ yarn-error.log* # Agent scratch files .scratch/ +.serena/ +.codegraph/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 8a779c4eb..138887c13 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ ".agents/**", ".claude/**", "apps/api/src/generated/**", + "packages/agent-xmpp/**", "packages/db/src/generated/**", "packages/ui/src/components/**", "tools/oxlint/anti-slop/**" diff --git a/AGENTS.md b/AGENTS.md index 2be0b3881..01a66cac2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,53 +251,15 @@ it: @docs/design.md -## Median Tasks +## Task tracking -Median can use a project-local workspace binding. If this repository has -`.median/config.json`, run `mdn` commands from inside this repository so the -correct Median workspace profile is selected. The local config stores only a -profile name; API keys stay in your user config. - -To bind this repository to a workspace: - -``` -mdn setup --local -``` - -Before starting work, check your assigned tasks: - -``` -mdn tasks --agent -``` - -When picking up a task: - -``` -mdn status in_progress --agent -``` - -When completing a task: - -``` -mdn status ready --agent -``` - -To create a new task: - -``` -mdn create --title "Description" --status todo --priority medium --agent -``` +Never use Median in this fork. Do not run `mdn` commands. Do not add Median task +IDs to commits or pull requests. ## Commit Messages & Pull Requests -Always include the Median task ID in commit messages and PR titles so tasks get marked automatically. - -``` -git commit -m "MDN-42 fix: resolve auth token expiry" -``` - -For pull requests, include the task ID in the title: +Use conventional commit messages and pull request titles. -``` -MDN-42 fix: resolve auth token expiry +```shell +git commit -m "fix: resolve auth token expiry" ``` diff --git a/apps/agent/agent/channels/xmpp.ts b/apps/agent/agent/channels/xmpp.ts new file mode 100644 index 000000000..637b09692 --- /dev/null +++ b/apps/agent/agent/channels/xmpp.ts @@ -0,0 +1,101 @@ +import { defineChannel, GET, POST } from "eve/channels"; +import { + ExportToolValidationError, + normalizeExportToolError, +} from "../../src/export-tools/errors"; +import { createEveExportSend } from "../../src/export-tools/eve-adapter"; +import { executeExportTool } from "../../src/export-tools/executor"; +import { exportToolManifest } from "../../src/export-tools/manifest"; +import { schemaIssues } from "../../src/export-tools/schema"; +import { + type ExportStreamEvent, + exportInvocationRequestSchema, + exportStreamEventSchema, +} from "../../src/export-tools/wire"; + +function authorized(request: Request): boolean { + const secret = process.env.AGENT_BRIDGE_SECRET; + return ( + Boolean(secret) && + request.headers.get("authorization") === `Bearer ${secret}` + ); +} + +function denied(): Response { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +export default defineChannel({ + routes: [ + GET("/internal/xmpp/export-tools/manifest", async (request) => { + if (!authorized(request)) return denied(); + return Response.json({ tools: exportToolManifest() }); + }), + POST("/internal/xmpp/export-tools/invoke", async (request, { send }) => { + if (!authorized(request)) return denied(); + const parsed = exportInvocationRequestSchema.safeParse( + await request.json(), + ); + if (!parsed.success) { + return Response.json( + { error: "Invalid invocation", issues: parsed.error.issues }, + { status: 400 }, + ); + } + const invocation = { + requestId: parsed.data.requestId, + operation: parsed.data.operation, + caller: parsed.data.caller, + }; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let sessionId: string | undefined; + const write = (value: ExportStreamEvent) => { + controller.enqueue( + encoder.encode( + `${JSON.stringify(exportStreamEventSchema.parse(value))}\n`, + ), + ); + }; + const eveSend = createEveExportSend(send, invocation, request.signal); + void executeExportTool(parsed.data.operation, parsed.data.arguments, { + abortSignal: request.signal, + invocation, + progress: async (update) => write({ type: "progress", update }), + send: async (agentRequest) => { + const result = await eveSend(agentRequest); + sessionId = result.sessionId; + return result; + }, + }) + .then((value) => write({ type: "result", value, sessionId })) + .catch((cause) => { + const error = normalizeExportToolError( + cause instanceof Error + ? cause + : new Error("Export tool threw a non-error value", { + cause, + }), + ); + write({ + type: "error", + error: { + code: error.code, + message: error.message, + issues: + error instanceof ExportToolValidationError + ? [...schemaIssues(error.issues)] + : undefined, + }, + }); + }) + .finally(() => controller.close()); + }, + }); + return new Response(stream, { + headers: { "content-type": "application/x-ndjson; charset=utf-8" }, + }); + }), + ], +}); diff --git a/apps/agent/package.json b/apps/agent/package.json index 2360a75ad..fdf8b0ccb 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -15,21 +15,29 @@ "check-types": "tsc --noEmit", "test": "CRM_TELEMETRY_DISABLED=1 bun test", "eval": "CRM_TELEMETRY_DISABLED=1 eve eval", + "e2e:xmpp": "bun test/e2e/xmpp-export.e2e.ts", "lint": "biome check .", "clean": "rm -rf .turbo .eve node_modules" }, "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/gateway": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", "context.dev": "2.10.0", "eve": "^0.29.4", + "ai": "7.0.47", + "ulid": "3.0.2", "zod": "^4.4.3" }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "@xmpp/client": "0.14.0", "just-bash": "^3.2.0", "microsandbox": "^0.6.8", "typescript": "^5.9.2" diff --git a/apps/agent/scripts/start.ts b/apps/agent/scripts/start.ts index af93f7e5c..016e4a20d 100644 --- a/apps/agent/scripts/start.ts +++ b/apps/agent/scripts/start.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { constants } from "node:os"; +import { startXmppGatewayHost } from "../src/xmpp/gateway-host"; const rawPort = process.env.AGENT_PORT ?? process.env.PORT ?? "2000"; const port = Number(rawPort); @@ -10,6 +11,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) { ); } +const gateway = + process.env.XMPP_COMPONENT_ENABLED === "1" + ? await startXmppGatewayHost() + : null; const cli = process.platform === "win32" ? "eve.cmd" : "eve"; const child = spawn(cli, ["start", "--port", String(port)], { stdio: "inherit", @@ -18,9 +23,10 @@ const child = spawn(cli, ["start", "--port", String(port)], { let settled = false; -const finish = (code: number) => { +const finish = async (code: number) => { if (settled) return; settled = true; + await gateway?.close(); process.exitCode = code; }; @@ -33,10 +39,10 @@ process.once("SIGTERM", forward); child.once("exit", (code, signal) => { const signalNumber = signal ? constants.signals[signal] : null; - finish(code ?? (signalNumber ? 128 + signalNumber : 1)); + void finish(code ?? (signalNumber ? 128 + signalNumber : 1)); }); child.once("error", (error) => { console.error(`[agent] could not start eve: ${error.message}`); - finish(1); + void finish(1); }); diff --git a/apps/agent/src/export-tools/define-export-tool.ts b/apps/agent/src/export-tools/define-export-tool.ts new file mode 100644 index 000000000..22c953ab3 --- /dev/null +++ b/apps/agent/src/export-tools/define-export-tool.ts @@ -0,0 +1,9 @@ +import type { ExportToolDefinition } from "./types"; + +export type DefinedExportTool = ExportToolDefinition; + +export function defineExportTool( + definition: ExportToolDefinition, +): DefinedExportTool { + return Object.freeze(definition); +} diff --git a/apps/agent/src/export-tools/errors.ts b/apps/agent/src/export-tools/errors.ts new file mode 100644 index 000000000..ae48f1781 --- /dev/null +++ b/apps/agent/src/export-tools/errors.ts @@ -0,0 +1,70 @@ +import type { StandardSchemaIssue } from "./types"; + +export type ExportToolErrorCode = + | "EXPORT_TOOL_NOT_FOUND" + | "INVALID_ARGUMENTS" + | "EXECUTION_FAILED" + | "AGENT_RUN_FAILED" + | "CANCELLED" + | "OUTPUT_VALIDATION_FAILED"; + +export class ExportToolError extends Error { + constructor( + message: string, + readonly code: ExportToolErrorCode, + options?: ErrorOptions, + ) { + super(message, options); + this.name = new.target.name; + } +} + +export class ExportToolNotFoundError extends ExportToolError { + constructor(readonly operation: string) { + super(`Export tool not found: ${operation}`, "EXPORT_TOOL_NOT_FOUND"); + } +} + +export class ExportToolValidationError extends ExportToolError { + constructor( + message: string, + readonly issues: readonly StandardSchemaIssue[], + code: Extract< + ExportToolErrorCode, + "INVALID_ARGUMENTS" | "OUTPUT_VALIDATION_FAILED" + >, + ) { + super(message, code); + } +} + +export class ExportToolExecutionError extends ExportToolError { + constructor(cause: unknown) { + super("Export tool execution failed", "EXECUTION_FAILED", { + cause, + }); + } +} + +export class ExportAgentRunError extends ExportToolError { + constructor(message: string, cause?: unknown) { + super(message, "AGENT_RUN_FAILED", { cause }); + } +} + +export class ExportCancelledError extends ExportToolError { + constructor() { + super("Export invocation was cancelled", "CANCELLED"); + } +} + +export function normalizeExportToolError(error: Error): ExportToolError { + if (error instanceof ExportToolError) return error; + if ( + error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + return new ExportCancelledError(); + } + return new ExportToolExecutionError(error); +} diff --git a/apps/agent/src/export-tools/eve-adapter.ts b/apps/agent/src/export-tools/eve-adapter.ts new file mode 100644 index 000000000..cf6bb2c97 --- /dev/null +++ b/apps/agent/src/export-tools/eve-adapter.ts @@ -0,0 +1,102 @@ +import type { SendFn, SendPayload, Session } from "eve/channels"; + +import { ExportAgentRunError, ExportCancelledError } from "./errors"; +import { toJsonSchema, validateSchema } from "./schema"; +import type { + ExportAgentRequest, + ExportAgentResult, + StandardSchemaV1, +} from "./types"; +import { type ExportInvocation, exportJsonValueSchema } from "./wire"; + +type ExportSession = Pick; + +export function createEveExportSend( + send: SendFn, + invocation: ExportInvocation, + abortSignal: AbortSignal, +): (request: ExportAgentRequest) => Promise> { + return async (request: ExportAgentRequest) => { + if (abortSignal.aborted) throw new ExportCancelledError(); + const payload: SendPayload = { + message: request.message, + context: + request.clientContext === undefined + ? undefined + : [JSON.stringify(request.clientContext)], + outputSchema: request.outputSchema + ? toJsonSchema(request.outputSchema) + : undefined, + }; + const options = { + auth: { + authenticator: "xmpp-agent-gateway", + principalType: "agent", + principalId: invocation.caller ?? "xmpp-agent-gateway", + attributes: { + requestId: invocation.requestId, + operation: invocation.operation, + }, + }, + continuationToken: invocation.requestId, + mode: request.taskMode === false ? "conversation" : "task", + title: request.title, + } as const; + const session = await send(payload, options); + let cancellation: ReturnType | undefined; + const cancel = () => + (cancellation ??= session.cancel().catch(() => ({ + status: "no_active_turn" as const, + }))); + abortSignal.addEventListener("abort", cancel, { once: true }); + try { + if (abortSignal.aborted) { + await cancel(); + throw new ExportCancelledError(); + } + return await collectAgentResult(session, request.outputSchema); + } finally { + abortSignal.removeEventListener("abort", cancel); + } + }; +} + +export async function collectAgentResult( + session: ExportSession, + schema?: StandardSchemaV1, +): Promise> { + const stream = await session.getEventStream(); + const reader = stream.getReader(); + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + const event = item.value; + if (event.type === "result.completed") { + const raw = exportJsonValueSchema.parse(event.data?.result); + const value = schema + ? await validateSchema( + schema, + raw, + "Invalid agent result", + "OUTPUT_VALIDATION_FAILED", + ) + : (raw as T); + return { sessionId: session.id, value }; + } + if (event.type === "turn.cancelled") { + throw new ExportCancelledError(); + } + if (event.type === "turn.failed" || event.type === "session.failed") { + throw new ExportAgentRunError( + String(event.data?.message ?? "Eve agent run failed"), + event.data, + ); + } + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + throw new ExportAgentRunError("Eve session ended without a result"); +} diff --git a/apps/agent/src/export-tools/executor.ts b/apps/agent/src/export-tools/executor.ts new file mode 100644 index 000000000..67617247a --- /dev/null +++ b/apps/agent/src/export-tools/executor.ts @@ -0,0 +1,38 @@ +import { ExportCancelledError, normalizeExportToolError } from "./errors"; +import { exportTool } from "./registry"; +import { validateSchema } from "./schema"; +import type { ExportToolContext } from "./types"; +import { type ExportJsonValue, exportJsonValueSchema } from "./wire"; + +export async function executeExportTool( + name: string, + rawInput: ExportJsonValue, + ctx: ExportToolContext, +): Promise { + const definition = exportTool(name); + if (ctx.abortSignal.aborted) throw new ExportCancelledError(); + const input = await validateSchema( + definition.inputSchema, + rawInput, + "Invalid export tool input", + "INVALID_ARGUMENTS", + ); + try { + const output = await definition.execute(input, ctx); + const jsonOutput = exportJsonValueSchema.parse(output); + if (!definition.outputSchema) return jsonOutput; + const validated = await validateSchema( + definition.outputSchema, + jsonOutput, + "Invalid export tool output", + "OUTPUT_VALIDATION_FAILED", + ); + return exportJsonValueSchema.parse(validated); + } catch (error) { + throw normalizeExportToolError( + error instanceof Error + ? error + : new Error("Export tool threw a non-error value", { cause: error }), + ); + } +} diff --git a/apps/agent/src/export-tools/export-tools.test.ts b/apps/agent/src/export-tools/export-tools.test.ts new file mode 100644 index 000000000..23f40b006 --- /dev/null +++ b/apps/agent/src/export-tools/export-tools.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "bun:test"; +import type { SendFn } from "eve/channels"; + +import handleCrmRequest from "../exports/handle_crm_request"; +import { ExportCancelledError, ExportToolValidationError } from "./errors"; +import { collectAgentResult, createEveExportSend } from "./eve-adapter"; +import { executeExportTool } from "./executor"; +import { exportToolManifest } from "./manifest"; +import type { ExportToolContext } from "./types"; +import { exportInvocationRequestSchema, exportStreamEventSchema } from "./wire"; + +function context( + overrides: Partial = {}, +): ExportToolContext { + return { + abortSignal: new AbortController().signal, + invocation: { requestId: "req_1", operation: "ping" }, + progress: async () => {}, + send: async () => { + throw new Error("Unexpected agent call"); + }, + ...overrides, + }; +} + +describe("export tools", () => { + it("runs a deterministic export without an agent call", async () => { + await expect(executeExportTool("ping", {}, context())).resolves.toEqual({ + status: "ok", + requestId: "req_1", + }); + }); + + it("rejects invalid input before progress or agent work", async () => { + const calls: string[] = []; + const run = executeExportTool( + "handle_crm_request", + { request: "" }, + context({ + progress: async () => { + calls.push("progress"); + }, + send: async () => { + calls.push("send"); + return { sessionId: "ses_1", value: {} as T }; + }, + }), + ); + await expect(run).rejects.toBeInstanceOf(ExportToolValidationError); + expect(calls).toEqual([]); + }); + + it("runs agent work between progress events", async () => { + const calls: string[] = []; + const result = await handleCrmRequest.execute( + { request: "Review the current relationship" }, + context({ + progress: async (update) => { + calls.push(update.stage ?? ""); + }, + send: async () => { + calls.push("send"); + return { + sessionId: "ses_1", + value: { summary: "Reviewed", actionsTaken: [] } as T, + }; + }, + }), + ); + expect(calls).toEqual(["reasoning", "send", "complete"]); + expect(result).toEqual({ summary: "Reviewed", actionsTaken: [] }); + }); + + it("publishes only the explicit export registry", () => { + expect(exportToolManifest().map((tool) => tool.name)).toEqual([ + "handle_crm_request", + "ping", + ]); + }); + + it("uses one strict wire contract for invocation and stream events", () => { + expect( + exportInvocationRequestSchema.safeParse({ + requestId: "req_1", + operation: "ping", + arguments: {}, + metadata: {}, + }).success, + ).toBe(false); + expect( + exportStreamEventSchema.safeParse({ + type: "result", + value: {}, + metadata: {}, + }).success, + ).toBe(false); + }); + + it("collects a native structured Eve result", async () => { + let streamCancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ + type: "result.completed", + data: { result: { summary: "Done", actionsTaken: [] } }, + }); + }, + cancel() { + streamCancelled = true; + }, + }); + await expect( + collectAgentResult( + { + id: "ses_1", + cancel: async () => ({ status: "accepted" }), + getEventStream: async () => stream, + }, + handleCrmRequest.outputSchema, + ), + ).resolves.toEqual({ + sessionId: "ses_1", + value: { summary: "Done", actionsTaken: [] }, + }); + expect(streamCancelled).toBe(true); + }); + + it("cancels when the request aborts while Eve accepts the send", async () => { + const controller = new AbortController(); + let cancellations = 0; + const send: SendFn = async () => { + controller.abort(); + return { + id: "ses_1", + continuationToken: "xmpp:req_1", + cancel: async () => { + cancellations++; + return { status: "accepted" }; + }, + getEventStream: async () => new ReadableStream(), + getStreamTailIndex: async () => -1, + }; + }; + const run = createEveExportSend( + send, + { requestId: "req_1", operation: "ping" }, + controller.signal, + ); + + await expect(run({ message: "Run" })).rejects.toBeInstanceOf( + ExportCancelledError, + ); + expect(cancellations).toBe(1); + }); + + it("maps Eve cancellation to the export cancellation error", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "turn.cancelled" }); + controller.close(); + }, + }); + await expect( + collectAgentResult({ + id: "ses_1", + cancel: async () => ({ status: "accepted" }), + getEventStream: async () => stream, + }), + ).rejects.toBeInstanceOf(ExportCancelledError); + }); +}); diff --git a/apps/agent/src/export-tools/manifest.ts b/apps/agent/src/export-tools/manifest.ts new file mode 100644 index 000000000..b98d9945c --- /dev/null +++ b/apps/agent/src/export-tools/manifest.ts @@ -0,0 +1,18 @@ +import { exportTools } from "./registry"; +import { toJsonSchema } from "./schema"; +import type { ExportToolManifestEntry } from "./types"; + +export function exportToolManifest(): readonly ExportToolManifestEntry[] { + return Object.entries(exportTools).map(([name, definition]) => { + const entry: ExportToolManifestEntry = { + name, + description: definition.description, + inputSchema: toJsonSchema(definition.inputSchema), + }; + if (definition.outputSchema) { + entry.outputSchema = toJsonSchema(definition.outputSchema); + } + if (definition.annotations) entry.annotations = definition.annotations; + return entry; + }); +} diff --git a/apps/agent/src/export-tools/registry.ts b/apps/agent/src/export-tools/registry.ts new file mode 100644 index 000000000..8575b6ed3 --- /dev/null +++ b/apps/agent/src/export-tools/registry.ts @@ -0,0 +1,20 @@ +import handleCrmRequest from "../exports/handle_crm_request"; +import ping from "../exports/ping"; +import { ExportToolNotFoundError } from "./errors"; +import type { AnyExportToolDefinition } from "./types"; + +export const exportTools = { + handle_crm_request: handleCrmRequest, + ping, +} as const; + +export function exportTool(name: string): AnyExportToolDefinition { + switch (name) { + case "handle_crm_request": + return handleCrmRequest as AnyExportToolDefinition; + case "ping": + return ping as AnyExportToolDefinition; + default: + throw new ExportToolNotFoundError(name); + } +} diff --git a/apps/agent/src/export-tools/schema.ts b/apps/agent/src/export-tools/schema.ts new file mode 100644 index 000000000..1c740a9e5 --- /dev/null +++ b/apps/agent/src/export-tools/schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { ExportToolValidationError } from "./errors"; +import type { + JsonSchema, + StandardSchemaIssue, + StandardSchemaV1, +} from "./types"; +import type { ExportJsonValue } from "./wire"; + +export async function validateSchema( + schema: StandardSchemaV1, + value: ExportJsonValue, + message: string, + code: "INVALID_ARGUMENTS" | "OUTPUT_VALIDATION_FAILED", +): Promise { + const result = await schema["~standard"].validate(value); + if (result.issues) { + throw new ExportToolValidationError(message, result.issues, code); + } + return result.value; +} + +export function schemaIssues( + issues: readonly StandardSchemaIssue[], +): ReadonlyArray<{ path: string; message: string }> { + return issues.map((issue) => ({ + path: + issue.path + ?.map((part) => String(part instanceof Object ? part.key : part)) + .join(".") ?? "", + message: issue.message, + })); +} + +export function toJsonSchema( + schema: StandardSchemaV1, +): JsonSchema { + if (schema instanceof z.ZodType) { + return z.toJSONSchema(schema, { target: "draft-2020-12" }) as JsonSchema; + } + throw new TypeError( + `JSON Schema export is unavailable for ${schema["~standard"].vendor}`, + ); +} diff --git a/apps/agent/src/export-tools/types.ts b/apps/agent/src/export-tools/types.ts new file mode 100644 index 000000000..b9e857445 --- /dev/null +++ b/apps/agent/src/export-tools/types.ts @@ -0,0 +1,76 @@ +import type { SendPayload } from "eve/channels"; + +import type { + ExportInvocation, + ExportJsonObject, + ExportProgress, +} from "./wire"; + +export interface StandardSchemaV1 { + readonly "~standard": { + readonly version: 1; + readonly vendor: string; + validate( + value: Input, + ): StandardSchemaResult | Promise>; + }; +} + +export type StandardSchemaResult = + | { readonly value: T; readonly issues?: undefined } + | { readonly issues: readonly StandardSchemaIssue[] }; + +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: ReadonlyArray; +} + +export interface ExportToolAnnotations { + readonly title?: string; + readonly idempotent?: boolean; + readonly readOnly?: boolean; + readonly destructive?: boolean; + readonly longRunning?: boolean; +} + +export interface ExportAgentRequest { + readonly message: NonNullable; + readonly outputSchema?: StandardSchemaV1; + readonly title?: string; + readonly taskMode?: boolean; + readonly clientContext?: ExportJsonObject; +} + +export interface ExportAgentResult { + readonly sessionId: string; + readonly value: T; +} + +export interface ExportToolContext { + readonly abortSignal: AbortSignal; + readonly invocation: ExportInvocation; + progress(update: ExportProgress): Promise; + send( + request: ExportAgentRequest, + ): Promise>; +} + +export interface ExportToolDefinition { + readonly description: string; + readonly inputSchema: StandardSchemaV1; + readonly outputSchema?: StandardSchemaV1; + readonly annotations?: ExportToolAnnotations; + execute(input: I, ctx: ExportToolContext): Promise | O; +} + +export type AnyExportToolDefinition = ExportToolDefinition; + +export type JsonSchema = NonNullable; + +export interface ExportToolManifestEntry { + readonly name: string; + readonly description: string; + readonly inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: ExportToolAnnotations; +} diff --git a/apps/agent/src/export-tools/wire.ts b/apps/agent/src/export-tools/wire.ts new file mode 100644 index 000000000..87aea9262 --- /dev/null +++ b/apps/agent/src/export-tools/wire.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const jsonObjectSchema = z.record(z.string(), z.json()); +export const exportJsonValueSchema = z.json(); + +export const exportInvocationSchema = z + .object({ + requestId: z.string().trim().min(1).max(160), + operation: z.string().trim().min(1).max(128), + caller: z.string().trim().min(1).max(3071).optional(), + }) + .strict(); + +export const exportInvocationRequestSchema = exportInvocationSchema.extend({ + arguments: exportJsonValueSchema, +}); + +export const exportProgressSchema = z + .object({ + stage: z.string().optional(), + percent: z.number().optional(), + message: z.string().optional(), + }) + .strict(); + +const exportIssueSchema = z + .object({ + path: z.string(), + message: z.string(), + }) + .strict(); + +export const exportStreamEventSchema = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("progress"), update: exportProgressSchema }) + .strict(), + z + .object({ + type: z.literal("result"), + value: exportJsonValueSchema, + sessionId: z.string().optional(), + }) + .strict(), + z + .object({ + type: z.literal("error"), + error: z + .object({ + code: z.string(), + message: z.string(), + issues: z.array(exportIssueSchema).optional(), + }) + .strict(), + }) + .strict(), +]); + +export type ExportInvocation = z.infer; +export type ExportProgress = z.infer; +export type ExportStreamEvent = z.infer; +export type ExportJsonObject = z.infer; +export type ExportJsonValue = z.infer; diff --git a/apps/agent/src/exports/handle_crm_request.ts b/apps/agent/src/exports/handle_crm_request.ts new file mode 100644 index 000000000..c065a9316 --- /dev/null +++ b/apps/agent/src/exports/handle_crm_request.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { defineExportTool } from "../export-tools/define-export-tool"; + +const resultSchema = z.object({ + summary: z.string().min(1), + actionsTaken: z.array( + z.object({ + type: z.string().min(1), + description: z.string().min(1), + }), + ), +}); + +export default defineExportTool({ + description: + "Process a bounded CRM request with the agent's normal evidence and action tools.", + inputSchema: z.object({ + request: z.string().trim().min(1).max(2_000), + record: z + .object({ + type: z.enum(["contact", "company", "deal"]), + id: z.string().trim().min(1).max(120), + }) + .optional(), + }), + outputSchema: resultSchema, + annotations: { + title: "Handle CRM request", + idempotent: false, + readOnly: false, + destructive: true, + longRunning: true, + }, + async execute(input, ctx) { + await ctx.progress({ + stage: "reasoning", + percent: 10, + message: "Processing CRM request", + }); + const target = input.record + ? `${input.record.type} ${input.record.id}` + : "the relevant CRM records"; + const result = await ctx.send({ + taskMode: true, + title: "Remote CRM request", + message: [ + `Process this external request about ${target}.`, + input.request, + "Use normal CRM evidence rules and available tools.", + "Report only actions that completed successfully.", + "Return a concise summary and the actions taken.", + ].join("\n\n"), + outputSchema: resultSchema, + clientContext: { + requestId: ctx.invocation.requestId, + caller: ctx.invocation.caller ?? null, + operation: ctx.invocation.operation, + }, + }); + await ctx.progress({ + stage: "complete", + percent: 100, + message: "CRM request complete", + }); + return result.value; + }, +}); diff --git a/apps/agent/src/exports/ping.ts b/apps/agent/src/exports/ping.ts new file mode 100644 index 000000000..3f1549d40 --- /dev/null +++ b/apps/agent/src/exports/ping.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { defineExportTool } from "../export-tools/define-export-tool"; + +export default defineExportTool({ + description: "Confirm that the CRM agent export endpoint is available.", + inputSchema: z.object({}), + outputSchema: z.object({ + status: z.literal("ok"), + requestId: z.string(), + }), + annotations: { + title: "Ping CRM agent", + idempotent: true, + readOnly: true, + destructive: false, + longRunning: false, + }, + execute(_input, ctx) { + return { status: "ok" as const, requestId: ctx.invocation.requestId }; + }, +}); diff --git a/apps/agent/src/xmpp/config.ts b/apps/agent/src/xmpp/config.ts new file mode 100644 index 000000000..4d268fc8a --- /dev/null +++ b/apps/agent/src/xmpp/config.ts @@ -0,0 +1,61 @@ +import { + type GatewayConfig, + loadConfig as loadGatewayConfig, +} from "@agent-xmpp/gateway"; + +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; + +export const XMPP_EXPORT = { + task: { + leaseMs: 30 * SECOND_MS, + retentionMs: 24 * HOUR_MS, + }, + gateway: { + sweepMs: 5 * SECOND_MS, + }, +} as const; + +export interface XmppHostConfig { + readonly gateway: GatewayConfig; + readonly organizationId: string; + readonly bridgeSecret: string; + readonly agentUrl: string; + readonly agentVersion?: string; + readonly allowedCallerDomains: ReadonlySet; + readonly destructiveCallers: ReadonlySet; +} + +export function loadXmppHostConfig(): XmppHostConfig { + const gateway = loadGatewayConfig(); + return { + gateway, + organizationId: requiredEnv("XMPP_ORGANIZATION_ID"), + bridgeSecret: requiredEnv("AGENT_BRIDGE_SECRET"), + agentUrl: process.env.AGENT_URL ?? "http://127.0.0.1:2000", + agentVersion: process.env.XMPP_AGENT_VERSION, + allowedCallerDomains: commaSeparatedSet( + process.env.XMPP_ALLOWED_CALLER_DOMAINS ?? + `${gateway.serverDomain},${gateway.agentDomain}`, + ), + destructiveCallers: commaSeparatedSet( + process.env.XMPP_ALLOW_DESTRUCTIVE_CALLERS, + ), + }; +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required env: ${name}`); + return value; +} + +function commaSeparatedSet(value: string | undefined): ReadonlySet { + return new Set( + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); +} diff --git a/apps/agent/src/xmpp/gateway-host.ts b/apps/agent/src/xmpp/gateway-host.ts new file mode 100644 index 000000000..3695238c9 --- /dev/null +++ b/apps/agent/src/xmpp/gateway-host.ts @@ -0,0 +1,232 @@ +import { + EmbeddedXmppGateway, + type GatewayRuntimeMailbox, + type TaskWireEvent, +} from "@agent-xmpp/gateway"; +import { + type AgentTaskError, + type AgentTaskEventType, + type AgentTaskRecord, + type McpToolResult, + terminalTaskStates, +} from "@agent-xmpp/protocol"; +import { ulid } from "ulid"; + +import { + type ExportJsonValue, + type ExportStreamEvent, + exportInvocationRequestSchema, + exportStreamEventSchema, + jsonObjectSchema, +} from "../export-tools/wire"; +import { loadXmppHostConfig, XMPP_EXPORT } from "./config"; +import { createXmppIqHandler } from "./iq-handler"; +import { createXmppManifest } from "./manifest"; +import { PostgresXmppTaskStore } from "./task-store"; + +export async function startXmppGatewayHost(): Promise<{ + close(): Promise; +}> { + const config = loadXmppHostConfig(); + const store = new PostgresXmppTaskStore(config.organizationId); + const agent = createXmppManifest({ + jid: config.gateway.defaultAgentJid, + organizationId: config.organizationId, + version: config.agentVersion, + }); + const controllers = new Map(); + let gateway: EmbeddedXmppGateway; + const emit = async ( + task: AgentTaskRecord, + type: AgentTaskEventType, + payload: TaskWireEvent["payload"], + ) => { + await gateway.deliverTaskEvent({ + taskId: task.taskId, + eventId: ulid(), + revision: task.revision, + type, + from: task.targetJid, + to: task.notificationJid || task.callerJid, + payload, + }); + }; + const run = async (initial: AgentTaskRecord) => { + const controller = new AbortController(); + controllers.set(initial.taskId, controller); + let task = initial; + try { + task = await store.transition(task.taskId, task.revision, { + state: "RUNNING", + }); + await emit(task, "status", { + state: "running", + updatedAt: task.updatedAt, + }); + const response = await fetch( + new URL("/internal/xmpp/export-tools/invoke", config.agentUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${config.bridgeSecret}`, + "content-type": "application/json", + }, + body: JSON.stringify( + exportInvocationRequestSchema.parse({ + requestId: task.taskId, + operation: task.tool, + arguments: task.arguments, + caller: task.callerJid, + }), + ), + signal: controller.signal, + }, + ); + if (!response.ok || !response.body) { + throw new Error(`Eve export endpoint returned ${response.status}`); + } + for await (const event of readNdjson(response.body)) { + if (event.type === "progress") { + task = await store.transition(task.taskId, task.revision, { + progress: jsonObjectSchema.parse(event.update), + }); + await emit(task, "progress", event.update); + continue; + } + if (event.type === "error") { + if (event.error.code === "CANCELLED") { + throw new DOMException(event.error.message, "AbortError"); + } + throw new ExportEndpointError(event.error.code, event.error.message); + } + const result = mcpResult(event.value); + const transition = { + state: "COMPLETED", + result: jsonObjectSchema.parse(result), + eveSessionId: event.sessionId, + } as const; + task = await store.transition(task.taskId, task.revision, transition); + await emit(task, "completed", { result }); + return; + } + throw new Error("Eve export endpoint ended without a result"); + } catch (error) { + const current = await store.get(initial.taskId); + if (!current || terminalTaskStates.has(current.state)) return; + if ( + (error instanceof DOMException && error.name === "AbortError") || + current.state === "cancelling" + ) { + task = await store.transition(current.taskId, current.revision, { + state: "CANCELLED", + }); + await emit(task, "cancelled", { reason: "Task cancelled" }); + return; + } + const failure: AgentTaskError = { + code: + error instanceof ExportEndpointError ? error.code : "gateway-error", + message: + error instanceof Error ? error.message : "Task execution failed", + retryable: !(error instanceof ExportEndpointError), + }; + task = await store.transition(current.taskId, current.revision, { + state: "FAILED", + error: jsonObjectSchema.parse(failure), + }); + await emit(task, "failed", { error: failure }); + } finally { + controllers.delete(initial.taskId); + } + }; + const iqHandler = createXmppIqHandler({ + componentJid: config.gateway.componentJid, + agent, + store, + allowedCallerDomains: config.allowedCallerDomains, + destructiveCallers: config.destructiveCallers, + onAccepted: (task) => { + void run(task).catch((error) => { + console.error(`[xmpp-gateway] task ${task.taskId} failed:`, error); + }); + }, + onCancel: async (task) => { + controllers.get(task.taskId)?.abort(); + }, + }); + const mailbox: GatewayRuntimeMailbox = { + async deliverInbound() {}, + async deliverFormResponse() {}, + async deliverTaskEvent(_event: TaskWireEvent) {}, + }; + gateway = new EmbeddedXmppGateway(config.gateway, mailbox, { + onIqGet: iqHandler, + resolveVirtualAgent: (jid) => + jid === agent.manifest.agent.jid + ? { + jid, + name: agent.manifest.agent.title ?? agent.manifest.agent.name, + } + : null, + }); + await store.failInterrupted(); + await gateway.start(); + const maintain = () => + Promise.all([store.renewLeases(), store.deleteExpired()]).catch((error) => { + console.error("[xmpp-gateway] task maintenance failed:", error); + }); + const sweep = setInterval(() => void maintain(), XMPP_EXPORT.gateway.sweepMs); + sweep.unref?.(); + return { + async close() { + clearInterval(sweep); + for (const controller of controllers.values()) controller.abort(); + await gateway.stop(); + }, + }; +} + +async function* readNdjson( + stream: ReadableStream, +): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + pending += decoder.decode(chunk.value, { stream: true }); + for (;;) { + const boundary = pending.indexOf("\n"); + if (boundary < 0) break; + const line = pending.slice(0, boundary); + pending = pending.slice(boundary + 1); + if (line) yield exportStreamEventSchema.parse(JSON.parse(line)); + } + } + pending += decoder.decode(); + if (pending.trim()) { + yield exportStreamEventSchema.parse(JSON.parse(pending)); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + +function mcpResult(value: ExportJsonValue): McpToolResult { + return { + content: [{ type: "text", text: JSON.stringify(value) }], + structuredContent: jsonObjectSchema.parse(value), + }; +} + +class ExportEndpointError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} diff --git a/apps/agent/src/xmpp/iq-handler.ts b/apps/agent/src/xmpp/iq-handler.ts new file mode 100644 index 000000000..8da4d8bd6 --- /dev/null +++ b/apps/agent/src/xmpp/iq-handler.ts @@ -0,0 +1,331 @@ +import { digestJson, validateJsonBounded } from "@agent-xmpp/core"; +import { + buildAcceptedResult, + buildAgentDirectory, + buildAgentInfo, + buildGatewayInfo, + buildManifestResult, + buildPingResponse, + buildSchemaResult, + buildTaskResultResponse, + buildTaskStateResponse, + buildToolCollectionInfo, + buildToolInfo, + buildToolItems, + DISCO_INFO_NS, + DISCO_ITEMS_NS, + type Element, + hiddenObjectError, + isPingRequest, + ProtocolError, + parseManifestRequest, + parseSchemaRequest, + parseTaskCancellation, + parseTaskInput, + parseTaskInvocation, + parseTaskRecoveryRequest, + protocolErrorIq, + toolFromNode, + toolsNode, + xml, +} from "@agent-xmpp/gateway"; +import { + AGENT_TASK_NS, + type AgentTaskRecord, + bareJid, + type RegisteredAgent, + terminalTaskStates, +} from "@agent-xmpp/protocol"; +import { ulid } from "ulid"; +import { z } from "zod"; + +import { XMPP_EXPORT } from "./config"; +import { + type PostgresXmppTaskStore, + XmppTaskConflictError, +} from "./task-store"; + +export interface XmppIqHandlerOptions { + readonly componentJid: string; + readonly agent: RegisteredAgent; + readonly store: PostgresXmppTaskStore; + readonly allowedCallerDomains: ReadonlySet; + readonly destructiveCallers: ReadonlySet; + onAccepted(task: AgentTaskRecord): void; + onCancel(task: AgentTaskRecord, reason?: string): Promise; +} + +export function createXmppIqHandler(options: XmppIqHandlerOptions) { + return async (stanza: Element): Promise => { + try { + return await routeIq(stanza, options); + } catch (error) { + if (error instanceof ProtocolError) { + return protocolErrorIq(stanza, error); + } + if (error instanceof XmppTaskConflictError) { + return protocolErrorIq( + stanza, + new ProtocolError("conflict", error.message), + ); + } + return protocolErrorIq( + stanza, + new ProtocolError("internal-server-error", "Request processing failed"), + ); + } + }; +} + +async function routeIq( + stanza: Element, + options: XmppIqHandlerOptions, +): Promise { + if ( + stanza.name !== "iq" || + !["get", "set"].includes(String(stanza.attrs.type)) + ) { + return null; + } + const from = String(stanza.attrs.from ?? ""); + const caller = bareJid(from); + const callerDomain = caller.split("@")[1] ?? caller; + if (!options.allowedCallerDomains.has(callerDomain)) { + throw hiddenObjectError(); + } + const to = bareJid(String(stanza.attrs.to ?? "")); + if ( + isPingRequest(stanza) && + (to === options.componentJid || to === options.agent.manifest.agent.jid) + ) { + return buildPingResponse(stanza); + } + if (to === options.componentJid) return routeGateway(stanza, options); + if (to !== options.agent.manifest.agent.jid) throw hiddenObjectError(); + return routeAgent(stanza, options, caller); +} + +function routeGateway( + stanza: Element, + options: XmppIqHandlerOptions, +): Element | null { + const info = stanza.getChild("query", DISCO_INFO_NS); + const items = stanza.getChild("query", DISCO_ITEMS_NS); + if (info && !info.attrs.node) + return buildGatewayInfo(stanza, options.componentJid); + if (items && !items.attrs.node) { + return buildAgentDirectory(stanza, options.componentJid, [options.agent]); + } + return null; +} + +async function routeAgent( + stanza: Element, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + const invocation = parseTaskInvocation(stanza); + if (invocation) return acceptInvocation(stanza, invocation, options, caller); + const cancellation = parseTaskCancellation(stanza); + if (cancellation) return cancelTask(stanza, cancellation, options, caller); + if (parseTaskInput(stanza)) { + throw new ProtocolError( + "unexpected-request", + "Task input is not supported", + ); + } + const recovery = parseTaskRecoveryRequest(stanza); + if (recovery) { + const task = await options.store.getForCaller( + recovery.taskId, + caller, + options.agent.manifest.agent.jid, + ); + if (!task) throw hiddenObjectError(); + if (recovery.kind === "result" && !terminalTaskStates.has(task.state)) { + throw new ProtocolError("unexpected-request", "Task is not terminal"); + } + return recovery.kind === "state" + ? buildTaskStateResponse(stanza, task) + : buildTaskResultResponse(stanza, task); + } + const info = stanza.getChild("query", DISCO_INFO_NS); + const items = stanza.getChild("query", DISCO_ITEMS_NS); + const manifestRequest = parseManifestRequest(stanza); + const schemaRequest = parseSchemaRequest(stanza); + const version = options.agent.manifest.agent.version; + if (info && !info.attrs.node) return buildAgentInfo(stanza, options.agent); + if (info?.attrs.node === toolsNode(version)) { + return buildToolCollectionInfo(stanza, options.agent); + } + if (items?.attrs.node === toolsNode(version)) { + return buildToolItems(stanza, options.agent); + } + if (info?.attrs.node) { + const selected = toolFromNode(String(info.attrs.node)); + if (!selected || selected.version !== version) throw hiddenObjectError(); + const tool = options.agent.tools.find( + (candidate) => candidate.name === selected.name, + ); + if (!tool) throw hiddenObjectError(); + return buildToolInfo(stanza, options.agent, tool); + } + if (manifestRequest) { + if (manifestRequest.version && manifestRequest.version !== version) + throw hiddenObjectError(); + return buildManifestResult(stanza, options.agent); + } + if (schemaRequest) { + if ( + schemaRequest.version !== version || + schemaRequest.manifestHash !== options.agent.manifestHash + ) { + throw new ProtocolError("conflict", "Manifest selection conflict"); + } + const tool = options.agent.tools.find( + (candidate) => candidate.name === schemaRequest.tool, + ); + if (!tool) throw hiddenObjectError(); + return buildSchemaResult( + stanza, + options.agent, + tool, + schemaRequest.direction, + ); + } + return null; +} + +async function acceptInvocation( + stanza: Element, + invocation: NonNullable>, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + if ( + invocation.apiVersion !== options.agent.manifest.agent.version || + invocation.manifestHash !== options.agent.manifestHash + ) { + throw new ProtocolError("conflict", "Manifest selection conflict"); + } + const tool = options.agent.tools.find( + (candidate) => candidate.name === invocation.tool, + ); + if (!tool) throw hiddenObjectError(); + if (tool.xmpp?.approvalRequired && !options.destructiveCallers.has(caller)) { + throw new ProtocolError("forbidden", "Tool approval is unavailable"); + } + const errors = await validateJsonBounded( + tool.inputSchema, + invocation.arguments, + ); + if (errors.length) { + throw new ProtocolError( + "bad-request", + `Argument validation failed: ${errors.join("; ")}`, + ); + } + const now = new Date(); + const deadline = invocation.deadline + ? new Date(invocation.deadline) + : undefined; + if (deadline && deadline.getTime() <= now.getTime()) { + throw new ProtocolError("not-acceptable", "Task deadline is expired"); + } + const maximumTimeoutSeconds = tool.xmpp?.maximumTimeoutSeconds; + if ( + deadline && + maximumTimeoutSeconds && + deadline.getTime() > now.getTime() + maximumTimeoutSeconds * 1_000 + ) { + throw new ProtocolError( + "not-acceptable", + "Task deadline exceeds the tool maximum", + ); + } + const retainUntil = new Date( + Math.max( + now.getTime() + XMPP_EXPORT.task.retentionMs, + deadline?.getTime() ?? 0, + ), + ); + const fingerprint = digestJson({ + caller, + target: invocation.toJid, + requestId: invocation.requestId, + tool: invocation.tool, + apiVersion: invocation.apiVersion, + manifestHash: invocation.manifestHash, + arguments: invocation.arguments, + deadline: invocation.deadline ?? null, + }); + const admission = { + id: ulid(), + requestId: invocation.requestId, + callerJid: caller, + notificationJid: invocation.notificationJid, + targetJid: invocation.toJid, + tool: invocation.tool, + apiVersion: invocation.apiVersion, + manifestHash: invocation.manifestHash, + fingerprint, + arguments: z.record(z.string(), z.json()).parse(invocation.arguments), + retainUntil, + }; + if (deadline) Object.assign(admission, { deadline }); + const admitted = await options.store.admit(admission); + if (!admitted.replay) options.onAccepted(admitted.task); + return buildAcceptedResult( + stanza, + { + requestId: admitted.task.requestId, + taskId: admitted.task.taskId, + revision: admitted.task.revision, + created: admitted.task.createdAt, + retainUntil: admitted.task.retainUntil, + }, + options.agent.manifest.agent.jid, + ); +} + +async function cancelTask( + stanza: Element, + cancellation: NonNullable>, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + const task = await options.store.getForCaller( + cancellation.taskId, + caller, + options.agent.manifest.agent.jid, + ); + if (!task) throw hiddenObjectError(); + if (terminalTaskStates.has(task.state)) { + throw new ProtocolError("unexpected-request", "Task is terminal"); + } + if (task.revision !== cancellation.expectedRevision) { + throw new ProtocolError("conflict", "Task revision conflict"); + } + const cancelling = await options.store.transition( + task.taskId, + task.revision, + { + state: "CANCELLING", + }, + ); + await options.onCancel(cancelling, cancellation.reason); + return xml( + "iq", + { + type: "result", + id: stanza.attrs.id, + from: options.agent.manifest.agent.jid, + to: stanza.attrs.from, + }, + xml("cancel-accepted", { + xmlns: AGENT_TASK_NS, + "task-id": cancelling.taskId, + revision: String(cancelling.revision), + }), + ); +} diff --git a/apps/agent/src/xmpp/manifest.test.ts b/apps/agent/src/xmpp/manifest.test.ts new file mode 100644 index 000000000..4091687fe --- /dev/null +++ b/apps/agent/src/xmpp/manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_API_NS } from "@agent-xmpp/protocol"; + +import { createXmppManifest } from "./manifest"; + +describe("XMPP export manifest", () => { + it("derives the public tool surface from export schemas", () => { + const agent = createXmppManifest({ + jid: "assistant@agents.example.com", + organizationId: "org_1", + }); + expect(agent.tools.map((tool) => tool.name)).toEqual([ + "handle_crm_request", + "ping", + ]); + expect(agent.tools[0]?.xmpp?.approvalRequired).toBe(true); + expect(agent.manifest.tools[0]?.[AGENT_API_NS]).toEqual( + expect.objectContaining({ + supportsProgress: true, + supportsCancellation: true, + }), + ); + }); +}); diff --git a/apps/agent/src/xmpp/manifest.ts b/apps/agent/src/xmpp/manifest.ts new file mode 100644 index 000000000..1b87dd775 --- /dev/null +++ b/apps/agent/src/xmpp/manifest.ts @@ -0,0 +1,76 @@ +import { + canonicalJson, + digestJson, + registeredTools, + validateManifest, +} from "@agent-xmpp/core"; +import { + AGENT_API_NS, + type AgentApiManifest, + type RegisteredAgent, +} from "@agent-xmpp/protocol"; + +import { exportToolManifest } from "../export-tools/manifest"; + +export interface XmppManifestOptions { + readonly jid: string; + readonly organizationId: string; + readonly version?: string; +} + +export function createXmppManifest( + options: XmppManifestOptions, +): RegisteredAgent { + const tools = exportToolManifest().map((tool) => { + const annotations: NonNullable< + AgentApiManifest["tools"][number]["annotations"] + > = {}; + const manifestTool: AgentApiManifest["tools"][number] = { + name: tool.name, + description: tool.description, + inputSchema: { ...tool.inputSchema }, + annotations, + [AGENT_API_NS]: { + supportsProgress: true, + supportsCancellation: true, + supportsInput: false, + approvalRequired: tool.annotations?.destructive === true, + }, + }; + if (tool.annotations?.title) manifestTool.title = tool.annotations.title; + if (tool.outputSchema) { + manifestTool.outputSchema = { ...tool.outputSchema }; + } + if (tool.annotations?.readOnly !== undefined) { + annotations.readOnlyHint = tool.annotations.readOnly; + } + if (tool.annotations?.destructive !== undefined) { + annotations.destructiveHint = tool.annotations.destructive; + } + if (tool.annotations?.idempotent !== undefined) { + annotations.idempotentHint = tool.annotations.idempotent; + } + return manifestTool; + }); + const manifest = validateManifest({ + manifestSpecVersion: "0", + agent: { + jid: options.jid, + name: "compcrm", + title: "CRM Agent", + description: "Researches CRM records and performs bounded CRM work.", + version: options.version ?? "1.0.0", + }, + implementation: { name: "compcrm-eve", version: "1.0.0" }, + tools, + } satisfies AgentApiManifest); + return { + manifest, + manifestHash: digestJson(manifest), + canonicalManifest: canonicalJson(manifest), + tools: registeredTools(manifest), + tenantId: options.organizationId, + active: true, + registeredAt: new Date().toISOString(), + }; +} diff --git a/apps/agent/src/xmpp/task-store.ts b/apps/agent/src/xmpp/task-store.ts new file mode 100644 index 000000000..b4e8f7698 --- /dev/null +++ b/apps/agent/src/xmpp/task-store.ts @@ -0,0 +1,253 @@ +import type { + AgentTaskError, + AgentTaskRecord, + McpToolResult, +} from "@agent-xmpp/protocol"; +import { db, Prisma, type XmppAgentTaskState } from "@crm/db"; +import { z } from "zod"; + +import { XMPP_EXPORT } from "./config"; + +const storedTaskResult = z.object({ + content: z.array(z.object({ type: z.literal("text"), text: z.string() })), + structuredContent: z.record(z.string(), z.unknown()).optional(), +}); + +const storedTaskError = z.object({ + code: z.string(), + message: z.string(), + retryable: z.boolean(), +}); + +export interface AdmitXmppTask { + readonly id: string; + readonly requestId: string; + readonly callerJid: string; + readonly notificationJid: string; + readonly targetJid: string; + readonly tool: string; + readonly apiVersion: string; + readonly manifestHash: string; + readonly fingerprint: string; + readonly arguments: Prisma.InputJsonValue; + readonly deadline?: Date; + readonly retainUntil: Date; +} + +export interface XmppTaskTransition { + readonly state?: XmppAgentTaskState; + readonly progress?: Prisma.InputJsonValue; + readonly result?: Prisma.InputJsonValue; + readonly error?: Prisma.InputJsonValue; + readonly summary?: string; + readonly eveSessionId?: string; +} + +export class XmppTaskConflictError extends Error { + constructor(readonly kind: "replay" | "revision") { + super(`XMPP task ${kind} conflict`); + } +} + +export class PostgresXmppTaskStore { + constructor( + readonly organizationId: string, + readonly ownerId = crypto.randomUUID(), + readonly leaseMs = XMPP_EXPORT.task.leaseMs, + ) {} + + async admit( + input: AdmitXmppTask, + ): Promise<{ task: AgentTaskRecord; replay: boolean }> { + const replayKey = { + organizationId: this.organizationId, + callerJid: input.callerJid, + targetJid: input.targetJid, + requestId: input.requestId, + }; + const where = { + organizationId_callerJid_targetJid_requestId: replayKey, + }; + const existing = await db.xmppAgentTask.findUnique({ + where: { + ...where, + }, + }); + if (existing) return this.replay(existing, input.fingerprint); + try { + const created = await db.xmppAgentTask.create({ + data: { + ...input, + organizationId: this.organizationId, + ownerId: this.ownerId, + leaseUntil: this.leaseUntil(), + }, + }); + return { task: taskRecord(created), replay: false }; + } catch (error) { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + throw error; + } + const concurrent = await db.xmppAgentTask.findUniqueOrThrow({ + where, + }); + return this.replay(concurrent, input.fingerprint); + } + } + + async get(id: string): Promise { + const task = await db.xmppAgentTask.findFirst({ + where: { id, organizationId: this.organizationId }, + }); + return task ? taskRecord(task) : null; + } + + async getForCaller( + id: string, + callerJid: string, + targetJid: string, + ): Promise { + const task = await db.xmppAgentTask.findFirst({ + where: { + id, + organizationId: this.organizationId, + callerJid, + targetJid, + }, + }); + return task ? taskRecord(task) : null; + } + + async transition( + id: string, + expectedRevision: number, + transition: XmppTaskTransition, + ): Promise { + const updated = await db.xmppAgentTask.updateMany({ + where: { + id, + organizationId: this.organizationId, + revision: expectedRevision, + }, + data: { + ...transition, + leaseUntil: + transition.state !== undefined && + terminalDatabaseStates.has(transition.state) + ? null + : this.leaseUntil(), + revision: { increment: 1 }, + }, + }); + if (updated.count !== 1) throw new XmppTaskConflictError("revision"); + return taskRecord( + await db.xmppAgentTask.findFirstOrThrow({ + where: { id, organizationId: this.organizationId }, + }), + ); + } + + async failInterrupted(now = new Date()): Promise { + const result = await db.xmppAgentTask.updateMany({ + where: { + organizationId: this.organizationId, + state: { in: ["ACCEPTED", "RUNNING", "CANCELLING"] }, + OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }], + }, + data: { + state: "FAILED", + ownerId: null, + leaseUntil: null, + revision: { increment: 1 }, + error: { + code: "gateway-restarted", + message: "The XMPP gateway restarted before the task completed", + retryable: true, + }, + }, + }); + return result.count; + } + + async renewLeases(now = new Date()): Promise { + const result = await db.xmppAgentTask.updateMany({ + where: { + organizationId: this.organizationId, + ownerId: this.ownerId, + state: { in: ["ACCEPTED", "RUNNING", "CANCELLING"] }, + }, + data: { leaseUntil: this.leaseUntil(now) }, + }); + return result.count; + } + + async deleteExpired(now = new Date()): Promise { + const result = await db.xmppAgentTask.deleteMany({ + where: { + organizationId: this.organizationId, + retainUntil: { lt: now }, + state: { in: ["COMPLETED", "FAILED", "CANCELLED"] }, + }, + }); + return result.count; + } + + private replay(task: Prisma.XmppAgentTaskModel, fingerprint: string) { + if (task.fingerprint !== fingerprint) { + throw new XmppTaskConflictError("replay"); + } + return { task: taskRecord(task), replay: true }; + } + + private leaseUntil(now = new Date()): Date { + return new Date(now.getTime() + this.leaseMs); + } +} + +const terminalDatabaseStates = new Set([ + "COMPLETED", + "FAILED", + "CANCELLED", +]); + +const taskStates = { + ACCEPTED: "accepted", + RUNNING: "running", + CANCELLING: "cancelling", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", +} satisfies Record; + +function taskRecord(task: Prisma.XmppAgentTaskModel): AgentTaskRecord { + const record: AgentTaskRecord = { + taskId: task.id, + requestId: task.requestId, + callerJid: task.callerJid, + notificationJid: task.notificationJid, + targetJid: task.targetJid, + tenantId: task.organizationId, + tool: task.tool, + apiVersion: task.apiVersion, + manifestHash: task.manifestHash, + arguments: task.arguments, + state: taskStates[task.state], + revision: task.revision, + fingerprint: task.fingerprint, + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + retainUntil: task.retainUntil.toISOString(), + }; + if (task.deadline) record.deadline = task.deadline.toISOString(); + if (task.result) { + record.result = storedTaskResult.parse(task.result) satisfies McpToolResult; + } + if (task.error) { + record.error = storedTaskError.parse(task.error) satisfies AgentTaskError; + } + if (task.summary) record.summary = task.summary; + return record; +} diff --git a/apps/agent/test/e2e/xmpp-export.e2e.ts b/apps/agent/test/e2e/xmpp-export.e2e.ts new file mode 100644 index 000000000..9c5db24d5 --- /dev/null +++ b/apps/agent/test/e2e/xmpp-export.e2e.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { + buildTaskInvocation, + type Element, + parseTaskEvent, + xml, +} from "@agent-xmpp/gateway"; +import { AGENT_TASK_NS, type AgentTaskRecord } from "@agent-xmpp/protocol"; +import { client } from "@xmpp/client"; +import { z } from "zod"; +import { createXmppManifest } from "../../src/xmpp/manifest"; + +const domain = process.env.XMPP_E2E_DOMAIN ?? "example.org"; +const service = process.env.XMPP_E2E_SERVICE ?? "xmpp://127.0.0.1:15222"; +const username = process.env.XMPP_E2E_USERNAME ?? "john"; +const password = process.env.XMPP_E2E_PASSWORD ?? "secret"; +const callerJid = `${username}@${domain}`; +const targetJid = + process.env.XMPP_DEFAULT_AGENT_JID ?? `assistant@gateway.${domain}`; +const organizationId = process.env.XMPP_ORGANIZATION_ID; +const bridgeSecret = process.env.AGENT_BRIDGE_SECRET; + +if (process.env.XMPP_E2E_ALLOW_SELF_SIGNED === "1") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +} + +assert.ok(organizationId, "XMPP_ORGANIZATION_ID is required"); +assert.ok(bridgeSecret, "AGENT_BRIDGE_SECRET is required"); + +const manifestResponse = await fetch( + `${process.env.AGENT_URL ?? "http://127.0.0.1:2000"}/internal/xmpp/export-tools/manifest`, + { headers: { authorization: `Bearer ${bridgeSecret}` } }, +); +assert.equal(manifestResponse.status, 200); +const exported = z + .object({ tools: z.array(z.object({ name: z.string() })) }) + .parse(await manifestResponse.json()); +assert.deepEqual( + exported.tools.map((tool) => tool.name), + ["handle_crm_request", "ping"], +); + +const agent = createXmppManifest({ + jid: targetJid, + organizationId, + version: process.env.XMPP_AGENT_VERSION, +}); +const xmpp = client({ + service, + domain, + username, + password, + resource: `compcrm-e2e-${randomUUID()}`, +}); +const stanzas: Element[] = []; +const waiters = new Set<() => void>(); +xmpp.on("stanza", (stanza) => { + stanzas.push(stanza); + for (const notify of waiters) notify(); +}); + +try { + await xmpp.start(); + await xmpp.send(xml("presence")); + const requestId = `request-${randomUUID()}`; + const task: AgentTaskRecord = { + taskId: `caller-${randomUUID()}`, + requestId, + callerJid, + notificationJid: callerJid, + targetJid, + tenantId: organizationId, + tool: "ping", + apiVersion: agent.manifest.agent.version, + manifestHash: agent.manifestHash, + arguments: {}, + state: "accepted", + revision: 0, + fingerprint: "caller-fingerprint", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + retainUntil: new Date(Date.now() + 60_000).toISOString(), + }; + const invocation = buildTaskInvocation(task); + await xmpp.send(invocation); + const acceptedIq = await waitFor( + (stanza) => + stanza.is("iq") && + stanza.attrs.id === invocation.attrs.id && + stanza.attrs.type === "result", + ); + const accepted = acceptedIq.getChild("accepted", AGENT_TASK_NS); + assert.ok(accepted); + const taskId = String(accepted.attrs["task-id"]); + const completedStanza = await waitFor((stanza) => { + const event = parseTaskEvent(stanza); + return event?.taskId === taskId && event.type === "completed"; + }); + const completed = parseTaskEvent(completedStanza); + assert.ok(completed); + const result = z + .object({ + structuredContent: z.object({ + status: z.literal("ok"), + requestId: z.string(), + }), + }) + .parse(completed.payload.result); + assert.deepEqual(result.structuredContent, { + status: "ok", + requestId: taskId, + }); + console.log("XMPP export E2E passed"); +} finally { + await xmpp.stop(); +} + +async function waitFor( + predicate: (stanza: Element) => boolean, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const match = stanzas.find(predicate); + if (match) return match; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Timed out waiting for XMPP stanza"); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiters.delete(notify); + reject(new Error("Timed out waiting for XMPP stanza")); + }, remaining); + const notify = () => { + clearTimeout(timer); + waiters.delete(notify); + resolve(); + }; + waiters.add(notify); + }); + } +} diff --git a/apps/agent/test/xmpp-task-store.integration.spec.ts b/apps/agent/test/xmpp-task-store.integration.spec.ts new file mode 100644 index 000000000..35fad1326 --- /dev/null +++ b/apps/agent/test/xmpp-task-store.integration.spec.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { XMPP_EXPORT } from "../src/xmpp/config"; +import { PostgresXmppTaskStore } from "../src/xmpp/task-store"; + +const suffix = crypto.randomUUID(); +const organizationId = `xmpp-store-${suffix}`; +const otherOrganizationId = `xmpp-store-other-${suffix}`; +const store = new PostgresXmppTaskStore(organizationId); +const LEASE_EXPIRATION_MARGIN_MS = 1; + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: organizationId, + name: "XMPP Store Test", + slug: organizationId, + createdAt: new Date(), + }, + { + id: otherOrganizationId, + name: "XMPP Store Other Test", + slug: otherOrganizationId, + createdAt: new Date(), + }, + ], + }); +}); + +afterAll(async () => { + await db.organization.deleteMany({ + where: { id: { in: [organizationId, otherOrganizationId] } }, + }); + await db.$disconnect(); +}); + +describe("PostgreSQL XMPP task state", () => { + it("replays identical requests and rejects changed requests", async () => { + const first = await store.admit(admission("replay", "fingerprint-a")); + const replay = await store.admit(admission("replay", "fingerprint-a")); + + expect(first.replay).toBe(false); + expect(replay.replay).toBe(true); + expect(replay.task.taskId).toBe(first.task.taskId); + + await expect( + store.admit(admission("replay", "fingerprint-b")), + ).rejects.toThrow("replay conflict"); + }); + + it("enforces organization ownership and optimistic revisions", async () => { + const admitted = await store.admit(admission("transition", "transition")); + const running = await store.transition(admitted.task.taskId, 0, { + state: "RUNNING", + progress: { stage: "started" }, + }); + + expect(running.state).toBe("running"); + expect(running.revision).toBe(1); + expect( + await new PostgresXmppTaskStore(otherOrganizationId).get( + admitted.task.taskId, + ), + ).toBeNull(); + await expect( + store.transition(admitted.task.taskId, 0, { state: "COMPLETED" }), + ).rejects.toThrow("revision conflict"); + }); + + it("fails interrupted work and deletes expired terminal rows", async () => { + const interrupted = await store.admit( + admission("interrupted", "interrupted"), + ); + const expired = await store.admit( + admission("expired", "expired", new Date(Date.now() - 1_000)), + ); + await store.transition(expired.task.taskId, 0, { state: "COMPLETED" }); + + const recoveringStore = new PostgresXmppTaskStore( + organizationId, + "recovering-owner", + ); + expect(await recoveringStore.failInterrupted()).toBe(0); + expect((await store.get(interrupted.task.taskId))?.state).toBe("accepted"); + expect( + await recoveringStore.failInterrupted( + new Date( + Date.now() + XMPP_EXPORT.task.leaseMs + LEASE_EXPIRATION_MARGIN_MS, + ), + ), + ).toBeGreaterThanOrEqual(1); + expect((await store.get(interrupted.task.taskId))?.state).toBe("failed"); + expect(await store.deleteExpired()).toBe(1); + expect(await store.get(expired.task.taskId)).toBeNull(); + }); +}); + +function admission(requestId: string, fingerprint: string, retainUntil?: Date) { + return { + id: crypto.randomUUID(), + requestId, + callerJid: "caller@example.test", + notificationJid: "caller@example.test/device", + targetJid: "assistant@agents.example.test", + tool: "ping", + apiVersion: "1.0.0", + manifestHash: "manifest", + fingerprint, + arguments: { message: requestId }, + retainUntil: retainUntil ?? new Date(Date.now() + 60_000), + }; +} diff --git a/apps/agent/tsconfig.json b/apps/agent/tsconfig.json index 14ff6177c..ef1b137fe 100644 --- a/apps/agent/tsconfig.json +++ b/apps/agent/tsconfig.json @@ -5,8 +5,8 @@ "module": "preserve", "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "types": ["node"] + "types": ["node", "bun"] }, - "include": ["agent/**/*.ts", "evals/**/*.ts"], + "include": ["agent/**/*.ts", "evals/**/*.ts", "src/**/*.ts"], "exclude": ["node_modules", ".eve"] } diff --git a/bun.lock b/bun.lock index 1c6df3e63..f6df338a2 100644 --- a/bun.lock +++ b/bun.lock @@ -17,17 +17,24 @@ "name": "agent", "version": "0.0.1", "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/gateway": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", + "ai": "7.0.47", "context.dev": "2.10.0", "eve": "^0.29.4", + "ulid": "3.0.2", "zod": "^4.4.3", }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "@xmpp/client": "0.14.0", "just-bash": "^3.2.0", "microsandbox": "^0.6.8", "typescript": "^5.9.2", @@ -117,6 +124,46 @@ "typescript": "^5", }, }, + "packages/agent-xmpp/core": { + "name": "@agent-xmpp/core", + "version": "0.1.0", + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "ajv": "8.17.1", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "4.23.12", + "typescript": "^5.7.0", + }, + }, + "packages/agent-xmpp/gateway": { + "name": "@agent-xmpp/gateway", + "version": "0.1.0", + "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", + "@xmpp/component": "0.14.0", + "@xmpp/xml": "0.14.0", + "ulid": "3.0.2", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, + "packages/agent-xmpp/protocol": { + "name": "@agent-xmpp/protocol", + "version": "0.1.0", + "dependencies": { + "idn-hostname": "15.1.10", + "precis-wasm": "0.1.0", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, "packages/auth": { "name": "@crm/auth", "version": "0.0.0", @@ -252,6 +299,12 @@ "sharp", ], "packages": { + "@agent-xmpp/core": ["@agent-xmpp/core@workspace:packages/agent-xmpp/core"], + + "@agent-xmpp/gateway": ["@agent-xmpp/gateway@workspace:packages/agent-xmpp/gateway"], + + "@agent-xmpp/protocol": ["@agent-xmpp/protocol@workspace:packages/agent-xmpp/protocol"], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N1P6bdW/aC5rxLeuGYgx3X4el3DoZy8UWlky+g+AeIZSmxaEi/AToHJL4cmZ6nCPHk1byqJWwC+PaOZG0hK0dw=="], "@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], @@ -436,6 +489,58 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -882,6 +987,8 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], @@ -1028,6 +1135,8 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], @@ -1096,6 +1205,8 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1118,7 +1229,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], @@ -1178,19 +1289,93 @@ "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xmpp/base64": ["@xmpp/base64@0.14.0", "", {}, "sha512-tz2LuzLMtjGSVVeuXDnDw19H+uOrqhrRFcQKJ3THV0HVjerDewT8WKXdrtBVSQ7n4Ue0k+awYcIE7qDD9rwMMQ=="], + + "@xmpp/client": ["@xmpp/client@0.14.0", "", { "dependencies": { "@xmpp/client-core": "^0.14.0", "@xmpp/iq": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/reconnect": "^0.14.0", "@xmpp/resolve": "^0.14.0", "@xmpp/resource-binding": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/sasl-anonymous": "^0.14.0", "@xmpp/sasl-ht-sha-256-none": "^0.14.0", "@xmpp/sasl-plain": "^0.14.0", "@xmpp/sasl-scram-sha-1": "^0.14.0", "@xmpp/sasl2": "^0.14.0", "@xmpp/starttls": "^0.14.0", "@xmpp/stream-features": "^0.14.0", "@xmpp/stream-management": "^0.14.0", "@xmpp/tcp": "^0.14.0", "@xmpp/tls": "^0.14.0", "@xmpp/websocket": "^0.14.0", "saslmechanisms": "^0.1.1" } }, "sha512-Y6k77iifYOGuWncOLRr6dVmbsQvcsIFjYI9pXhkSzQDDNnd3hVJGgo/Xx+W/0/o1+kg/tPm/NwX5Yq77NTd9xA=="], + + "@xmpp/client-core": ["@xmpp/client-core@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0", "saslmechanisms": "^0.1.1" } }, "sha512-fW0C6vn4y8Jp1g8uBmCETuOmEDXpjFc/mfY8P0J88nhK2AdPOB5m0ntz0yyAXcPd16smnHVckiOsk9qBjF9dDA=="], + + "@xmpp/component": ["@xmpp/component@0.14.0", "", { "dependencies": { "@xmpp/component-core": "^0.14.0", "@xmpp/iq": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/reconnect": "^0.14.0" } }, "sha512-+o9FDP/eOn71NreR3wI+2jxm+csYM0eVX+WtvKy94SrPSmX59cDPpVe2IABIfAInx7bTggz6k/qgrUHuiMZzMQ=="], + + "@xmpp/component-core": ["@xmpp/component-core@0.14.0", "", { "dependencies": { "@xmpp/connection-tcp": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-rzrnLUxDu9vTxgWH0b8R8Mou67x5fZWy/0K6jWlB09jaltx4ovvewI+Mfis9LueMO9naNzjbq0PuxWII/HmccA=="], + + "@xmpp/connection": ["@xmpp/connection@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-VRnukXwiXWCsRVQVISUiAxHHgRWH37nxdUaz50o0+cUAYyqtlHIJ2zy+ZHMlxkEWtl+XciNLxwkJKbypJFQheQ=="], + + "@xmpp/connection-tcp": ["@xmpp/connection-tcp@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-gvYyUghuCHW0qalEYaZvbHeTnEoXc+hCCWP/qvoVGjjSQNwkYRUsy1/8RP/sA6RlQ2sR0nhpkJkJE6K/4rsFIQ=="], + + "@xmpp/error": ["@xmpp/error@0.14.0", "", {}, "sha512-b4W/MwAZl8basfmYhcemK3De92xBOiSrIz0VY2cnN2ss1VMsa4d32GuaJGBIpa4ulN/2DFb3vPCeTn5jXvHAJw=="], + + "@xmpp/events": ["@xmpp/events@0.14.0", "", { "dependencies": { "events": "^3.3.0" } }, "sha512-6mKRIEi69sYr8H9SrkJKaXobOgCq/sU6/pKTQS5cuQZCy0qLuyx9uttkOyAYmtUbVD+HSrXYS72KhMpdQQh88Q=="], + + "@xmpp/id": ["@xmpp/id@0.14.0", "", {}, "sha512-0n8OFYPWkBYDi5fHGiJT6SLg9ncOflTmINzjHzt5A3NxEMVrYmqxbRB44u3NLMk1gBNqGtEkc2wl0WHjS0tHWg=="], + + "@xmpp/iq": ["@xmpp/iq@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/id": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-0r3QVKR4XAvkZ4shQwPBkSM21sSfj26Cg8+AYBkd0BKJTde7mRQCISBNidt1xiXA5VPH1+6Qx7fmtPzk3uel+Q=="], + + "@xmpp/jid": ["@xmpp/jid@0.14.0", "", {}, "sha512-ggiNgjblkeHPbHJ7JLOMIqxc1qQQt0gV+LhXI16afib2wLr120YgvRChY04gjkvQ+IF2sGz7mzPvcAukTmNGHA=="], + + "@xmpp/middleware": ["@xmpp/middleware@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0", "koa-compose": "^4.1.0" } }, "sha512-UGm7ed5NEMapE/z0jqY5daGSBqto/iciDjtyncoXHZdOnR4iFYFF9gSO/65Z1HsL2pbHQpcK0WS+Kn+FGOxPTw=="], + + "@xmpp/reconnect": ["@xmpp/reconnect@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0" } }, "sha512-XaaT3KrFLf1ZfYZCIx11zauT8bIVXRz9FMaGj4mofq1tC8SiKvIIqJXSgrouhkibb9vPeM9XSiGkH47B/1hc6g=="], + + "@xmpp/resolve": ["@xmpp/resolve@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-S4Rhupb2zS4EtT4EFUJLPl2pPSFPfrjp/1bxPil+zBt9sNcbrvIwSNaYeQAAfLD/yYb8/DhaxC/blvdHyM1Xzw=="], + + "@xmpp/resource-binding": ["@xmpp/resource-binding@0.14.0", "", { "dependencies": { "@xmpp/xml": "^0.14.0" } }, "sha512-b8EyHUOpkAS25b6cpOQ8fv7cCLfOpJq4dkgs6Td0MdODMmcfWEh38siZKaP0OoNmYWZCxBSANdsBmeidozJxqA=="], + + "@xmpp/sasl": ["@xmpp/sasl@0.14.0", "", { "dependencies": { "@xmpp/base64": "^0.14.0", "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-C6mWvtRhJCCjv0Q6uuc3FUuDdgUhnX7lCF9afmNT5umy1XB0l1iRmBJYTriSHNBwgErM3i3mydafeRjpuQfP5Q=="], + + "@xmpp/sasl-anonymous": ["@xmpp/sasl-anonymous@0.14.0", "", { "dependencies": { "sasl-anonymous": "^0.1.0" } }, "sha512-lZ8jaYuKBpfQ2T5Bv+7yxII9OduV1a+mlNkKtG94WchrVzR8fU6VpbTrez88KajA42ngcVCtiO3i6tPepYyK8Q=="], + + "@xmpp/sasl-ht-sha-256-none": ["@xmpp/sasl-ht-sha-256-none@0.14.0", "", {}, "sha512-LmMSzX35bI/V2lV26k/hYPm8Pn7InsBEX72b86+ugIBQpzD8/jmB1F/Mfm5vrbWkMLl2KWe61gE+L/p0mvLZdA=="], + + "@xmpp/sasl-plain": ["@xmpp/sasl-plain@0.14.0", "", { "dependencies": { "sasl-plain": "^0.1.0" } }, "sha512-TGQX6gsCi6LP2lPNkkd+HoZ1ynCesRMQrtJrWbDAa6oMyCwie9xuBcG/5lDJ5fn03G7xxRh1YSQp+x8yFK+q4w=="], + + "@xmpp/sasl-scram-sha-1": ["@xmpp/sasl-scram-sha-1@0.14.0", "", { "dependencies": { "sasl-scram-sha-1": "^1.3.0" } }, "sha512-0ZrsMDx9Haou2yTCzDCwvMsYPvHCxF2bkpyBuiPoYAOWRsIQH1TssZK3d88N7wSvvojter905DNwLPizdaF3og=="], + + "@xmpp/sasl2": ["@xmpp/sasl2@0.14.0", "", { "dependencies": { "@xmpp/base64": "^0.14.0", "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-OcDIDr9xwiOO58yqr6hj0eh9CfKOvwBbNDNhC0wByA07cwOsoojLb1dv3WR7pKk1cETv6wHWQ1o/leRoDB/Yzg=="], + + "@xmpp/starttls": ["@xmpp/starttls@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/tls": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-n21Oy5pyD6Cipo96SAVI1ASx/5qo/z3W7J1TWZSG3/gGgrTaCB+VSV0xI+FqRexVJysg3lw3cBidYQL+Lp/pHw=="], + + "@xmpp/stream-features": ["@xmpp/stream-features@0.14.0", "", {}, "sha512-XoogP53qv1lzq/TNnzydT0KmmDKsJ7vpbV1c33fcuBDY0LCmPWA2U5th+Atn3JjEMlyznpVDFco9BF1NgFTHzg=="], + + "@xmpp/stream-management": ["@xmpp/stream-management@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/time": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-i7qLB8KXzLtm2TZXgtVlBprI4Vm8lykJ7Gth2TFavBTky+PQFNr2BH4lGxxvZ7/WkYbhx2jsmDT9JjI7sUCRdw=="], + + "@xmpp/tcp": ["@xmpp/tcp@0.14.0", "", { "dependencies": { "@xmpp/connection-tcp": "^0.14.0" } }, "sha512-vJb5Y60ub+YClK01l+653+qpIe6vedw/82szuB4IU50eTDTL8voer6eGK7AMJOJzoZzRofbLh8Ow5txAyZ8vXw=="], + + "@xmpp/time": ["@xmpp/time@0.14.0", "", {}, "sha512-KlOLwZXXYrRiIZ+Lg0Rg+iDxIBLfCgVQOkIaYG2nieLVtbrHItI5gv2eI2I6m0cPsX8LajGkCpvQTqUCNZclag=="], + + "@xmpp/tls": ["@xmpp/tls@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/connection-tcp": "^0.14.0", "@xmpp/events": "^0.14.0" } }, "sha512-nY/nHlHYgs3i2+xwt/5Ff3J4aCdZADdTOH+NwdfgqBIK9kWDJPW43nnIrbrZgmUL3YOjW5sb1lUI20bFUJACww=="], + + "@xmpp/websocket": ["@xmpp/websocket@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-mRjpRHtOujrPIT7I/X2xvn8w6X/7J8gGPfGIKNGLJHBQu1OlT5noUd++mslCAcGdCOf5TGwtJvE7a8CPtSnTFg=="], + + "@xmpp/xml": ["@xmpp/xml@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "ltx": "^3.1.2" } }, "sha512-1rrj3SaIM51wtJUn1l2n4FK2e1QLfEmma9bsD1utaaosA8elpCyWi12QDPfaYoTyWSXNvQ7mGzvFevUJUs1mcQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent": ["agent@workspace:apps/agent"], "ai": ["ai@7.0.47", "", { "dependencies": { "@ai-sdk/gateway": "4.0.36", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-e0MpNtufu6JmcmwMUTgM1smCRkP5z014iPaKgnCm7kmMsTSIZbOJN2vglhPq0WIj/6x7ewVi6W0FyIR0pjaE3Q=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1216,6 +1401,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], @@ -1280,6 +1467,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1546,12 +1735,16 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1562,12 +1755,16 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eve": ["eve@0.29.4", "", { "dependencies": { "nitro": "3.0.260610-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.38", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-EwOmL37l+Iuu7Umno7at3flFpMs4AtT9cg1J4dt7EZ8ZdxaCvGXwvKGwiulMkG8oXkMQ5CNhxMi2ruHJwrtpwQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], @@ -1576,6 +1773,8 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.6.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA=="], @@ -1642,6 +1841,8 @@ "fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], @@ -1734,6 +1935,8 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "idn-hostname": ["idn-hostname@15.1.10", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-/mSXWRhVasTJ7Z4z18523rTA6CmStYN29yDt+oXi9fe1/M0SO2Un1BgUr3v28aAZG5hWicyUtIamC/juXt3nZQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1846,6 +2049,8 @@ "knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="], + "koa-compose": ["koa-compose@4.1.0", "", {}, "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="], + "kysely": ["kysely@0.29.4", "", {}, "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -1902,6 +2107,8 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "ltx": ["ltx@3.1.2", "", {}, "sha512-tFSKojN92FqNK6eRTmKK/ROUTUYVWKAxgohz523TPhF1G3nR3DXQS/I7/705rEPrDSloKDgMdRlh0qgMFQoVYw=="], + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2090,6 +2297,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], @@ -2214,6 +2423,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "precis-wasm": ["precis-wasm@0.1.0", "", {}, "sha512-0DIxaIaiZRX4TJ9tMDuXBbgGGcCx93SaunKfcvrM91kbMRGW1NVbIalO0Ku4hF6H1ltl2aLKl9wDodmelL+IkQ=="], + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -2232,6 +2443,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], @@ -2358,6 +2571,14 @@ "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "sasl-anonymous": ["sasl-anonymous@0.1.0", "", {}, "sha512-x+0sdsV0Gie2EexxAUsx6ZoB+X6OCthlNBvAQncQxreEWQJByAPntj0EAgTlJc2kZicoc+yFzeR6cl8VfsQGfA=="], + + "sasl-plain": ["sasl-plain@0.1.0", "", {}, "sha512-X8mCSfR8y0NryTu0tuVyr4IS2jBunBgyG+3a0gEEkd0nlHGiyqJhlc4EIkzmSwaa7F8S4yo+LS6Cu5qxRkJrmg=="], + + "sasl-scram-sha-1": ["sasl-scram-sha-1@1.4.0", "", {}, "sha512-kdP8uAFkak8flnmKTldWnQ98IuC15ASdKxNHWwbqexRj7szHs7y+JEtigLkPoq9gnp/SAxxU4O2huL8OLqSr9w=="], + + "saslmechanisms": ["saslmechanisms@0.1.1", "", {}, "sha512-pVlvK5ysevz8MzybRnDIa2YMxn0OJ7b9lDiWhMoaKPoJ7YkAg/7YtNjUgaYzElkwHxsw8dBMhaEn7UP6zxEwPg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], @@ -2394,6 +2615,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -2422,9 +2645,11 @@ "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -2484,10 +2709,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -2514,6 +2743,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "turbo": ["turbo@2.10.8", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.8", "@turbo/darwin-arm64": "2.10.8", "@turbo/linux-64": "2.10.8", "@turbo/linux-arm64": "2.10.8", "@turbo/windows-64": "2.10.8", "@turbo/windows-arm64": "2.10.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg=="], @@ -2534,13 +2765,15 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="], + "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -2594,6 +2827,10 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2604,6 +2841,8 @@ "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2650,6 +2889,12 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@agent-xmpp/core/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@agent-xmpp/gateway/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@agent-xmpp/protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -2706,8 +2951,20 @@ "@chevrotain/gast/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "@crm/auth/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/db/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/env/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/telemetry/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/ui/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@crm/ui/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@crm/validation/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2728,6 +2985,8 @@ "@mermaid-js/parser/@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@nestjs/config/dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2736,12 +2995,16 @@ "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@prisma/streams-local/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@prisma/streams-local/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "@prisma/studio-core/@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], @@ -2900,6 +3163,20 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/body-parser/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/connect/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/pg/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/send/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/serve-static/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/superagent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2908,8 +3185,14 @@ "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + "agent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "agent/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "api/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "api/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "app/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -2930,6 +3213,8 @@ "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "bun-types/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2942,6 +3227,8 @@ "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], @@ -3032,6 +3319,12 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "vite/rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3050,6 +3343,18 @@ "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "@crm/auth/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/db/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/env/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/telemetry/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/ui/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/validation/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3078,6 +3383,20 @@ "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/connect/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/pg/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/send/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/serve-static/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/superagent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3090,7 +3409,9 @@ "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "agent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "api/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "app/next/@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], @@ -3114,6 +3435,8 @@ "app/next/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3146,6 +3469,60 @@ "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/docs/agent.md b/docs/agent.md index e1fc98353..28e2cd00b 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -219,6 +219,26 @@ missing key removes a place to look. **Never an error, never throws.** `capabilitiesFrom()`/`markdownFor()` are the pure halves. `contextDevKey()` is the only resolver, and `lib/context-dev.ts` memoises its client on the key string. +## XMPP export tools + +`src/exports` contains the explicit external operation allowlist. An export uses +`defineExportTool` and cannot expose a normal Eve tool accidentally. + +`src/export-tools` owns validation, manifests, execution, and the Eve adapter. The +adapter calls the current channel's `send` function and uses native task mode. + +`agent/channels/xmpp.ts` is the required Eve channel location. It provides authenticated +manifest and invocation routes for the XMPP gateway host. + +`src/xmpp` owns ProtoXEP routing and PostgreSQL task persistence. Every task belongs to +one organization. The replay key includes the organization, caller, target, and request. + +The copied protocol, validation, and gateway runtime sources derive from Clawdike commit +`d2386b42741410533cb302ffee1540d33192b34c`. + +The gateway starts only when `XMPP_COMPONENT_ENABLED=1`. Missing XMPP configuration +removes the capability and leaves the normal agent process available. + ## Budget and scheduling - `lib/focus.ts` — per-session budget in `defineState`; running out is a normal ending. diff --git a/docs/environment.md b/docs/environment.md index 22417c60e..e95fb55cd 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -116,6 +116,7 @@ single place that knows what is set. | `BLOB_READ_WRITE_TOKEN` | Mirrors logos and photos into Blob | | `AI_GATEWAY_API_KEY` | The model. Not needed on Vercel (OIDC) | | `AGENT_BRIDGE_SECRET` | The rep-facing Agent panel — see `agent.md` | +| `XMPP_COMPONENT_*`, `XMPP_*` | Optional XMPP agent gateway — see `setup.md` | `BLOB_READ_WRITE_TOKEN` is also in `env.validation.ts` and `apps/api/turbo.json` because the API and the seed write pictures too. The Next.js app is deliberately diff --git a/docs/eve-export-tool-subsystem-spec.md b/docs/eve-export-tool-subsystem-spec.md new file mode 100644 index 000000000..843c2c373 --- /dev/null +++ b/docs/eve-export-tool-subsystem-spec.md @@ -0,0 +1,1615 @@ +# `exportTool` Subsystem Specification for Eve Agents + +## Status + +**Proposed** + +This document specifies an `exportTool` subsystem for an Eve agent that exposes selected agent-level operations to external callers such as an XMPP agent gateway. + +The key design goal is to expose **agent operations**, not raw Eve tools. + +An exported operation may contain: + +1. deterministic application logic, +2. durable evidence/artifact creation, +3. optional agentic reasoning through Eve, +4. local Eve tool calls made by that agent, +5. subagent or remote-agent delegation, +6. a typed, machine-readable final result. + +The external caller sees one stable operation such as: + +```text +handle_recording(url) +``` + +while the local implementation may internally perform a deterministic preprocessing pipeline and only invoke the LLM when judgment is actually required. + +--- + +# 1. Motivation + +An Eve tool is primarily a capability **called by the local model**. + +For example: + +```text +model -> crm_create_task(...) +``` + +An exported operation has the opposite direction: + +```text +remote agent -> local Eve agent operation +``` + +Treating the exported operation itself as an Eve tool is awkward because the remote caller has already selected the operation, and the operation may need to invoke the local agent for reasoning. + +The subsystem therefore introduces a separate abstraction: + +```text +exportTool +``` + +Despite the name, an `exportTool` is not necessarily an Eve model tool. It is an externally callable operation implemented by the application hosting the Eve agent. + +Its internal execution may be fully deterministic, fully agentic, or a mixture of both. + +--- + +# 2. Design Goals + +The subsystem MUST: + +- expose a deliberate, allowlisted set of remotely callable operations; +- describe each operation with a typed input schema; +- optionally describe a typed output schema; +- allow arbitrary deterministic TypeScript before and after LLM execution; +- allow the operation to inject work into the already-running Eve runtime without HTTP loopback; +- avoid creating `new Client({ host })` when the invocation already runs inside the Eve process; +- allow the agent to use its normal instructions, tools, skills, sandbox, state, and subagents; +- preserve cancellation; +- support progress reporting; +- support deterministic validation before any LLM tokens are spent; +- allow manifest/tool metadata generation for the external gateway; +- keep externally visible operation schemas separate from the model-facing Eve tool set; +- make it impossible to accidentally export every Eve tool; +- make deterministic-only operations possible without invoking an LLM. + +The subsystem SHOULD: + +- use Standard Schema-compatible schemas; +- support Zod directly; +- support JSON Schema export; +- map one remote invocation to one Eve task/session by default; +- make the Eve invocation mechanism replaceable; +- permit future structured-output support without changing exported operation implementations. + +--- + +# 3. Non-goals + +The subsystem does not implement: + +- XMPP stanza parsing; +- XMPP discovery; +- XMPP task persistence; +- Deepgram itself; +- CRM APIs; +- Eve's runtime; +- generic MCP transport. + +Those are consumers or dependencies of this subsystem. + +The subsystem is specifically the Eve-side execution and exported-operation layer. + +--- + +# 4. Conceptual Model + +The primary abstraction is: + +```text +External caller + | + v +exportTool operation + | + +---- deterministic TypeScript + | + +---- services / database / evidence + | + +---- optional ctx.send(...) + | + v + Eve agent turn + | + +---- LLM reasoning + +---- local Eve tools + +---- skills + +---- subagents + +---- sandbox + | + v + result +``` + +Example: + +```text +handle_recording(url) + | + +-- fetch URL + +-- transcode to 16 kHz mono + +-- call Deepgram + +-- persist transcript as evidence + | + +-- ctx.send("Process evidence ev_123...") + | + +-- agent reads evidence + +-- reasons about transcript + +-- updates CRM + +-- delegates work + +-- returns summary/actions +``` + +The deterministic steps do not consume model tokens. + +--- + +# 5. Filesystem Layout + +Recommended layout: + +```text +agent/ +├── agent.ts +├── instructions.md +│ +├── tools/ +│ ├── read_evidence.ts +│ ├── crm_find_contact.ts +│ ├── crm_add_note.ts +│ └── crm_create_task.ts +│ +├── channels/ +│ └── xmpp.ts +│ +└── exports/ + ├── handle_recording.ts + └── ping.ts + +src/ +├── export-tools/ +│ ├── define-export-tool.ts +│ ├── registry.ts +│ ├── executor.ts +│ ├── schema.ts +│ ├── context.ts +│ └── errors.ts +│ +├── services/ +│ ├── recordings.ts +│ ├── audio.ts +│ ├── deepgram.ts +│ └── evidence.ts +│ +└── xmpp/ + ├── gateway-client.ts + └── manifest.ts +``` + +`agent/tools/` remains the set of capabilities callable by the local Eve model. + +`agent/exports/` contains the operations callable by remote agents. + +These two sets MUST NOT be conflated. + +--- + +# 6. Public API + +## 6.1 `defineExportTool` + +The basic API: + +```ts +export const handleRecording = defineExportTool({ + description: "Handle a recording and perform appropriate follow-up work", + + inputSchema: z.object({ + url: z.string().url(), + }), + + outputSchema: z.object({ + evidenceId: z.string(), + summary: z.string(), + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), + }), + + async execute(input, ctx) { + // arbitrary deterministic and/or agentic work + }, +}); +``` + +The definition MUST be a plain serializable-capability description plus an executable function. + +Proposed types: + +```ts +export interface ExportToolDefinition { + readonly description: string; + + readonly inputSchema: StandardSchemaV1; + + readonly outputSchema?: StandardSchemaV1; + + readonly annotations?: ExportToolAnnotations; + + execute( + input: I, + ctx: ExportToolContext, + ): Promise | O; +} + +export interface ExportToolAnnotations { + readonly title?: string; + readonly idempotent?: boolean; + readonly readOnly?: boolean; + readonly destructive?: boolean; + readonly longRunning?: boolean; +} +``` + +Helper: + +```ts +export function defineExportTool( + definition: ExportToolDefinition, +): ExportToolDefinition { + return definition; +} +``` + +This wrapper may later attach a symbol or metadata marker so the registry can reject arbitrary objects. + +--- + +# 7. Naming + +The operation name SHOULD come from the filename, matching Eve's filesystem-first style. + +Example: + +```text +agent/exports/handle_recording.ts +``` + +becomes: + +```text +handle_recording +``` + +Do not duplicate the name inside the definition unless a later requirement justifies aliases. + +This avoids: + +```ts +defineExportTool({ + name: "handle_recording", + ... +}) +``` + +and keeps naming consistent with Eve tools. + +--- + +# 8. ExportTool Context + +The core context type: + +```ts +export interface ExportToolContext { + /** + * Abort when the external task is cancelled or the surrounding + * Eve runtime is shutting down. + */ + readonly abortSignal: AbortSignal; + + /** + * Identity and metadata for the remote invocation. + */ + readonly invocation: ExportInvocation; + + /** + * Deterministic application services. + */ + readonly services: ApplicationServices; + + /** + * Report progress to the external task system. + */ + progress(update: ExportProgress): Promise; + + /** + * Start a turn in the already-running Eve runtime. + * + * This is intentionally an abstraction over Eve's current + * channel send primitive rather than an HTTP Client. + */ + send( + request: ExportAgentRequest, + ): Promise>; +} +``` + +Supporting types: + +```ts +export interface ExportInvocation { + readonly requestId: string; + readonly operation: string; + readonly caller?: string; + readonly metadata?: Record; +} + +export interface ExportProgress { + readonly stage?: string; + readonly percent?: number; + readonly message?: string; +} + +export interface ExportAgentRequest { + readonly message: string | UserContent; + + /** + * Optional structured result contract. + * + * The adapter is responsible for mapping this to the best + * Eve runtime mechanism available in the installed Eve version. + */ + readonly outputSchema?: StandardSchemaV1; + + readonly title?: string; + + /** + * For remote RPC-style invocation this should normally be true. + */ + readonly taskMode?: boolean; + + /** + * Non-durable metadata that may be injected as ephemeral + * context if the Eve entry path supports it. + */ + readonly clientContext?: unknown; +} + +export interface ExportAgentResult { + readonly sessionId: string; + readonly value: T; +} +``` + +The operation MUST NOT instantiate an Eve HTTP client. + +It receives `ctx.send()` from the integration boundary. + +--- + +# 9. Why `ctx.send()` Is an Adapter + +Eve's public APIs distinguish several contexts. + +Eve custom channels can call `send()` from an inbound channel handler to start or resume a session. This runs the normal Eve runtime in-process. + +However, the exact set of supported options on channel-based `send()` differs across Eve entry surfaces, and structured `outputSchema` propagation is not uniformly available in every channel API at the time of writing. + +Therefore the exported operation SHOULD NOT depend directly on a specific Eve internal signature. + +Bad: + +```ts +async execute(input, ctx) { + return internalEveRuntimePrivateFunction(...); +} +``` + +Also undesirable: + +```ts +const client = new Client({ + host: process.env.EVE_URL!, +}); +``` + +Recommended: + +```ts +async execute(input, ctx) { + return ctx.send({ + message: "...", + outputSchema: ResultSchema, + taskMode: true, + }); +} +``` + +The channel/integration adapter owns the Eve-version-specific implementation. + +This creates a small compatibility seam. + +--- + +# 10. Runtime Integration + +The preferred integration point is an Eve custom channel or another authored Eve runtime entrypoint that already has access to the in-process `send()` capability. + +Conceptually: + +```ts +export default defineChannel({ + routes: [ + // optional HTTP routes if needed by the integration + ], + + async receive(input, runtime) { + // runtime.send is Eve's in-process session dispatch primitive + }, +}); +``` + +For an XMPP bridge that already runs in the same Node process, the bridge should hand an invocation to the export-tool executor while providing an adapter around the live Eve `send` function. + +Pseudo-code: + +```ts +async function onXmppInvocation(invocation, eveRuntimeCtx) { + return executeExportTool( + invocation.tool, + invocation.arguments, + { + invocation, + abortSignal: invocation.abortSignal, + services, + progress: invocation.progress, + + send: async (request) => { + return sendThroughEveRuntime( + eveRuntimeCtx, + request, + ); + }, + }, + ); +} +``` + +The `exportTool` implementation itself remains unaware of XMPP and Eve transport details. + +--- + +# 11. Registry + +The registry explicitly defines the public surface. + +Example: + +```ts +import handleRecording from "../../agent/exports/handle_recording"; +import ping from "../../agent/exports/ping"; + +export const exportTools = { + handle_recording: handleRecording, + ping, +} as const; +``` + +The registry MUST be explicit. + +Do not recursively export everything in `agent/tools`. + +Automatic filesystem discovery of `agent/exports/*.ts` is acceptable if the directory itself is the allowlist. + +--- + +# 12. Invocation Executor + +The executor performs: + +1. tool lookup, +2. input validation, +3. context creation, +4. execution, +5. optional output validation, +6. normalized error conversion. + +Example: + +```ts +export async function executeExportTool( + name: string, + rawInput: unknown, + ctx: ExportToolContext, +): Promise { + const definition = exportTools[name]; + + if (!definition) { + throw new ExportToolNotFoundError(name); + } + + const inputResult = + await definition.inputSchema["~standard"].validate(rawInput); + + if (inputResult.issues) { + throw new ExportToolValidationError( + "Invalid export tool input", + inputResult.issues, + ); + } + + const output = await definition.execute( + inputResult.value, + ctx, + ); + + if (!definition.outputSchema) { + return output; + } + + const outputResult = + await definition.outputSchema["~standard"].validate(output); + + if (outputResult.issues) { + throw new ExportToolValidationError( + "Invalid export tool output", + outputResult.issues, + ); + } + + return outputResult.value; +} +``` + +Input validation MUST occur before deterministic processing and before any LLM call. + +Output validation SHOULD occur before sending the result back to the gateway. + +--- + +# 13. Full Example: `handle_recording` + +## 13.1 Schema + +```ts +// agent/exports/handle_recording.ts + +import { z } from "zod"; +import { defineExportTool } from "../../src/export-tools/define-export-tool"; + +export const HandleRecordingInput = z.object({ + url: z.string().url(), +}); + +export const HandleRecordingOutput = z.object({ + evidenceId: z.string(), + + transcript: z.object({ + durationSeconds: z.number().nonnegative(), + language: z.string().optional(), + }), + + summary: z.string(), + + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), +}); +``` + +--- + +## 13.2 Implementation + +```ts +export default defineExportTool({ + description: + "Fetch and transcribe a recording, preserve the transcript as evidence, " + + "then have the secretary agent interpret it and perform appropriate follow-up work.", + + inputSchema: HandleRecordingInput, + outputSchema: HandleRecordingOutput, + + annotations: { + title: "Handle recording", + idempotent: false, + readOnly: false, + longRunning: true, + }, + + async execute({ url }, ctx) { + // + // Deterministic phase + // + + await ctx.progress({ + stage: "fetch", + percent: 5, + message: "Fetching recording", + }); + + const recording = await ctx.services.recordings.fetch(url, { + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "transcode", + percent: 20, + message: "Transcoding recording", + }); + + const audio = await ctx.services.audio.transcode(recording, { + sampleRate: 16_000, + channels: 1, + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "transcribe", + percent: 40, + message: "Transcribing recording", + }); + + const transcription = + await ctx.services.deepgram.transcribe(audio, { + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "evidence", + percent: 60, + message: "Storing transcript as evidence", + }); + + const evidence = await ctx.services.evidence.create({ + type: "recording-transcript", + + source: { + kind: "url", + url, + }, + + content: transcription.text, + + metadata: { + durationSeconds: transcription.durationSeconds, + language: transcription.language, + provider: "deepgram", + }, + }); + + // + // Agentic phase + // + + await ctx.progress({ + stage: "reasoning", + percent: 70, + message: "Processing transcript", + }); + + const agentResult = await ctx.send({ + taskMode: true, + + title: "Process recording", + + message: ` +A new recording has been transcribed and stored as evidence. + +Evidence ID: ${evidence.id} + +Process this recording according to your secretary responsibilities. + +Inspect the evidence using the available evidence tools. Determine the +important facts, commitments, requests, deadlines, follow-ups, and CRM +implications. Perform appropriate actions using your available tools and +agents. + +Do not claim an action was performed unless the corresponding tool call +succeeded. + +Return: +- a concise summary; +- the actions actually taken. +`.trim(), + + outputSchema: z.object({ + summary: z.string(), + + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), + }), + + clientContext: { + invocation: "handle_recording", + externalRequestId: ctx.invocation.requestId, + caller: ctx.invocation.caller, + }, + }); + + await ctx.progress({ + stage: "complete", + percent: 100, + message: "Recording processed", + }); + + return { + evidenceId: evidence.id, + + transcript: { + durationSeconds: transcription.durationSeconds, + language: transcription.language, + }, + + summary: agentResult.value.summary, + actionsTaken: agentResult.value.actionsTaken, + }; + }, +}); +``` + +The important property is that the model sees none of the fetch/transcode/Deepgram machinery. + +The LLM is invoked only after the transcript has been produced. + +--- + +# 14. Evidence Tool + +The agent needs a way to inspect the transcript. + +Recommended model-facing Eve tool: + +```ts +// agent/tools/read_evidence.ts + +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { evidence } from "../../src/services/evidence"; + +export default defineTool({ + description: + "Read stored evidence such as transcripts, messages, and documents.", + + inputSchema: z.object({ + id: z.string(), + start: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(20000).optional(), + }), + + async execute({ id, start = 0, limit = 8000 }, ctx) { + const item = await evidence.get(id); + + if (!item) { + throw new Error(`Evidence not found: ${id}`); + } + + const chunk = item.content.slice(start, start + limit); + + return { + id: item.id, + type: item.type, + content: chunk, + start, + end: start + chunk.length, + totalLength: item.content.length, + hasMore: start + chunk.length < item.content.length, + }; + }, +}); +``` + +For long transcripts, also expose: + +```text +search_evidence(id, query) +``` + +so the agent does not need to inject a complete hour-long transcript into model context. + +--- + +# 15. In-Process `ctx.send()` Adapter + +The exported-operation context SHOULD expose a stable application-level `send()` method. + +The integration layer maps it to Eve's current runtime API. + +Conceptual implementation: + +```ts +function makeExportToolContext( + invocation: XmppInvocation, + eveChannelContext: EveChannelContext, +): ExportToolContext { + return { + abortSignal: invocation.abortSignal, + + invocation: { + requestId: invocation.requestId, + operation: invocation.tool, + caller: invocation.from, + }, + + services, + + progress: async (update) => { + await invocation.reportProgress(update); + }, + + send: async (request) => { + const result = await eveChannelContext.send( + request.message, + { + auth: invocationAuth(invocation), + mode: request.taskMode ? "task" : undefined, + title: request.title, + + // If/when the selected Eve send surface accepts these directly: + // outputSchema: request.outputSchema, + // clientContext: request.clientContext, + }, + ); + + return await collectAgentResult( + result, + request.outputSchema, + ); + }, + }; +} +``` + +This snippet is intentionally adapter-level pseudocode. + +The installed Eve version determines the exact channel `send()` result type and which per-turn fields are directly supported. + +The operation API remains stable. + +--- + +# 16. Structured Result Compatibility + +At the time of this design, Eve supports structured task results through `outputSchema` in task-mode/client-oriented surfaces, and emits `result.completed` for such turns. + +However, not all custom-channel/cross-channel entry surfaces currently propagate `outputSchema` uniformly. + +Therefore the subsystem MUST isolate this behavior behind: + +```ts +ctx.send(...) +``` + +and: + +```ts +collectAgentResult(...) +``` + +The preferred order of implementation is: + +1. use native Eve structured-output support when available on the in-process send path; +2. otherwise use a small compatibility adapter; +3. do not make the exported operation instantiate an HTTP `Client`; +4. do not import private Eve runtime internals. + +A future Eve upgrade should require changing only the adapter. + +--- + +# 17. Result Collection + +`ctx.send()` should resolve only when the task-mode turn reaches a terminal state. + +Conceptually: + +```ts +async function collectAgentResult( + session: EveSessionHandle, + schema?: StandardSchemaV1, +): Promise> { + for await (const event of session.stream()) { + switch (event.type) { + case "result.completed": + return { + sessionId: session.id, + value: event.data.result as T, + }; + + case "turn.failed": + case "session.failed": + throw new ExportAgentRunError(event); + + case "turn.cancelled": + throw new ExportCancelledError(); + } + } + + throw new ExportAgentRunError( + "Eve session ended without a result", + ); +} +``` + +Exact event names/types MUST follow the installed Eve version. + +--- + +# 18. Cancellation + +Cancellation MUST propagate end-to-end: + +```text +remote cancellation + | + v +export invocation AbortController + | + +-- fetch abort + +-- transcode abort/kill + +-- Deepgram abort + +-- evidence write abort where possible + | + +-- Eve turn cancellation +``` + +All deterministic services SHOULD accept an `AbortSignal`. + +Example: + +```ts +await recordings.fetch(url, { + signal: ctx.abortSignal, +}); +``` + +The Eve adapter SHOULD bind the same cancellation source to the Eve task/session cancellation API. + +The mapping should be retained: + +```text +external task id -> Eve session id / turn id +``` + +until completion. + +--- + +# 19. Progress + +Progress is application-level and independent of LLM text. + +Recommended stages for `handle_recording`: + +```text +5% fetch +20% transcode +40% transcribe +60% evidence +70% reasoning +100% complete +``` + +The percentages are advisory. + +The gateway should treat stage/message as more authoritative than exact percentage. + +Agent stream events MAY also be translated into richer progress messages, but the exported operation should not depend on model narration. + +--- + +# 20. Error Model + +Define normalized error types: + +```ts +export class ExportToolNotFoundError extends Error {} +export class ExportToolValidationError extends Error {} +export class ExportToolExecutionError extends Error {} +export class ExportAgentRunError extends Error {} +export class ExportCancelledError extends Error {} +``` + +Suggested externally visible error codes: + +```text +EXPORT_TOOL_NOT_FOUND +INVALID_ARGUMENTS +DETERMINISTIC_PROCESSING_FAILED +AGENT_RUN_FAILED +CANCELLED +OUTPUT_VALIDATION_FAILED +INTERNAL_ERROR +``` + +Do not leak arbitrary stack traces to remote callers. + +Log the full cause locally. + +--- + +# 21. Idempotency + +Some exported operations cause side effects. + +`handle_recording` may: + +- create evidence, +- update CRM state, +- create tasks, +- send messages. + +The invocation subsystem SHOULD pass a stable `requestId`. + +Deterministic side effects SHOULD use it as an idempotency key where practical. + +Example: + +```ts +const evidence = await evidence.create({ + idempotencyKey: + `handle_recording:${ctx.invocation.requestId}:transcript`, + ... +}); +``` + +For an external retry of the same invocation, the subsystem SHOULD avoid creating duplicate evidence or CRM work. + +If the XMPP gateway already guarantees exactly-once logical task identity, use that task/request identifier. + +--- + +# 22. Manifest Generation + +The externally visible tool manifest is generated from `agent/exports/`, not from `agent/tools/`. + +For each export: + +```ts +interface ExportToolManifestEntry { + name: string; + description: string; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: ExportToolAnnotations; +} +``` + +Conceptually: + +```ts +function manifestEntry( + name: string, + definition: ExportToolDefinition, +): ExportToolManifestEntry { + return { + name, + description: definition.description, + inputSchema: toJsonSchema(definition.inputSchema), + outputSchema: definition.outputSchema + ? toJsonSchema(definition.outputSchema) + : undefined, + annotations: definition.annotations, + }; +} +``` + +Do not maintain a second handwritten schema in the gateway. + +The authored schema is the source of truth. + +--- + +# 23. Security Boundary + +Only definitions under the export registry are remotely callable. + +For example: + +```text +agent/tools/bash.ts +agent/tools/write_file.ts +agent/tools/send_email.ts +``` + +do NOT automatically become: + +```text +remote.bash(...) +remote.write_file(...) +remote.send_email(...) +``` + +The public XMPP surface might expose only: + +```text +handle_recording +prepare_followup +process_invoice +``` + +The local model may internally call `send_email`, but a remote caller cannot invoke it directly unless it is intentionally exported. + +--- + +# 24. Deterministic-only Export + +Not every exported operation needs Eve. + +Example: + +```ts +export default defineExportTool({ + description: "Return the current agent build information", + + inputSchema: z.object({}), + + outputSchema: z.object({ + version: z.string(), + commit: z.string(), + }), + + async execute(_input, ctx) { + return { + version: ctx.services.build.version, + commit: ctx.services.build.commit, + }; + }, +}); +``` + +No model call occurs. + +This is a useful property of the abstraction. + +--- + +# 25. Agent-only Export + +At the other extreme: + +```ts +export default defineExportTool({ + description: "Review a customer situation and decide what to do", + + inputSchema: z.object({ + customerId: z.string(), + }), + + outputSchema: ReviewResult, + + async execute({ customerId }, ctx) { + const result = await ctx.send({ + taskMode: true, + message: + `Review customer ${customerId} according to your normal responsibilities.`, + outputSchema: ReviewResult, + }); + + return result.value; + }, +}); +``` + +The subsystem supports both extremes without changing the external contract. + +--- + +# 26. Recommended Session Semantics + +For RPC-style exported operations, the default SHOULD be: + +```text +one remote invocation -> one fresh Eve task/session +``` + +Reasons: + +- no accidental conversational contamination; +- deterministic ownership; +- straightforward cancellation; +- straightforward task/result mapping; +- simple retry semantics. + +Future exported operations MAY explicitly opt into a durable conversation/session key, but this should not be the default. + +--- + +# 27. Auth and Principal Mapping + +The integration layer should decide which Eve principal represents the remote invocation. + +Possible policies: + +```text +service principal: + xmpp-agent-gateway + +forwarded caller: + agent@example.com + +compound principal: + xmpp:agent@example.com +``` + +The exported operation should not construct Eve authentication manually. + +Provide it through the `ctx.send()` adapter. + +--- + +# 28. Testing + +## 28.1 Unit-test deterministic processing + +Mock `ctx.send()`. + +Example: + +```ts +it("transcribes before invoking the agent", async () => { + const calls: string[] = []; + + const ctx = makeTestContext({ + recordings: { + fetch: async () => { + calls.push("fetch"); + return recording; + }, + }, + + audio: { + transcode: async () => { + calls.push("transcode"); + return wav; + }, + }, + + deepgram: { + transcribe: async () => { + calls.push("transcribe"); + return { + text: "hello", + durationSeconds: 2, + }; + }, + }, + + evidence: { + create: async () => { + calls.push("evidence"); + return { id: "ev_1" }; + }, + }, + + send: async () => { + calls.push("send"); + return { + sessionId: "ses_1", + value: { + summary: "hello", + actionsTaken: [], + }, + }; + }, + }); + + await handleRecording.execute( + { url: "https://example.com/a.mp3" }, + ctx, + ); + + expect(calls).toEqual([ + "fetch", + "transcode", + "transcribe", + "evidence", + "send", + ]); +}); +``` + +This test spends zero model tokens. + +--- + +## 28.2 Validation test + +Verify invalid URLs fail before service calls: + +```ts +await expect( + executeExportTool( + "handle_recording", + { url: "not a URL" }, + ctx, + ), +).rejects.toThrow(ExportToolValidationError); +``` + +--- + +## 28.3 Deterministic-only test + +Verify an export can complete without calling `ctx.send()`. + +--- + +## 28.4 Eve integration test + +Use Eve's testing/eval facilities or a deterministic mock model to assert: + +- the exported operation creates an Eve task; +- the evidence ID is in the task message; +- the local agent calls `read_evidence`; +- CRM tools can be called; +- a structured result is eventually returned. + +--- + +## 28.5 Cancellation test + +Cancel while: + +1. fetching, +2. transcoding, +3. transcribing, +4. waiting on Eve. + +Each should settle as `CANCELLED`. + +--- + +# 29. Observability + +Every export invocation SHOULD log: + +```text +requestId +operation +caller +startTime +endTime +duration +deterministic stage timings +Eve sessionId +result status +error code +``` + +Recommended trace structure: + +```text +export.handle_recording +├── fetch +├── transcode +├── deepgram +├── evidence.create +└── eve.send + ├── session + ├── model steps + └── tool calls +``` + +Do not log full transcripts by default. + +--- + +# 30. Suggested Initial Implementation Order + +1. Implement `defineExportTool`. +2. Implement explicit registry. +3. Implement Standard Schema input/output validation. +4. Implement `ExportToolContext`. +5. Implement XMPP invocation -> executor wiring. +6. Implement progress and cancellation. +7. Implement in-process Eve `send()` adapter. +8. Implement `handle_recording`. +9. Implement `read_evidence`. +10. Generate gateway manifest from export schemas. +11. Add idempotency. +12. Add integration tests. +13. Add structured-result compatibility shim if the selected Eve channel entry path cannot pass `outputSchema` directly. + +--- + +# 31. Minimal End-to-End Example + +The smallest meaningful implementation is: + +```ts +// agent/exports/handle_recording.ts + +export default defineExportTool({ + description: "Transcribe and process a recording", + + inputSchema: z.object({ + url: z.string().url(), + }), + + outputSchema: z.object({ + evidenceId: z.string(), + summary: z.string(), + }), + + async execute({ url }, ctx) { + // deterministic + const input = await ctx.services.recordings.fetch(url, { + signal: ctx.abortSignal, + }); + + const wav = await ctx.services.audio.transcode(input, { + sampleRate: 16_000, + channels: 1, + signal: ctx.abortSignal, + }); + + const transcript = + await ctx.services.deepgram.transcribe(wav, { + signal: ctx.abortSignal, + }); + + const evidence = await ctx.services.evidence.create({ + type: "transcript", + content: transcript.text, + }); + + // agentic + const run = await ctx.send({ + taskMode: true, + + message: + `Process transcript evidence ${evidence.id}. ` + + `Perform appropriate follow-up actions.`, + + outputSchema: z.object({ + summary: z.string(), + }), + }); + + return { + evidenceId: evidence.id, + summary: run.value.summary, + }; + }, +}); +``` + +The corresponding Eve-side evidence tool: + +```ts +// agent/tools/read_evidence.ts + +export default defineTool({ + description: "Read stored evidence", + + inputSchema: z.object({ + id: z.string(), + }), + + async execute({ id }) { + return evidence.get(id); + }, +}); +``` + +And the conceptual in-process adapter: + +```ts +const ctx = { + ..., + + send: async (request) => { + // Adapt the currently active Eve channel/runtime `send` + // primitive here. Do not instantiate `eve/client`. + + const session = await eveSend( + request.message, + { + mode: request.taskMode ? "task" : undefined, + title: request.title, + }, + ); + + return collectAgentResult( + session, + request.outputSchema, + ); + }, +}; +``` + +This is the central pattern the subsystem should preserve. + +--- + +# 32. Architectural Rule of Thumb + +Use deterministic code when the answer is procedural and known: + +```text +fetch +decode +transcode +hash +parse +validate +call API +persist +``` + +Use Eve when the operation requires judgment: + +```text +interpret +prioritize +classify ambiguous information +decide whether an action is warranted +choose among tools +compose context-sensitive communication +delegate to another agent +``` + +An `exportTool` is the orchestrator that can combine both. + +--- + +# 33. Eve API Notes + +This design intentionally stays within Eve's public concepts. + +Relevant current Eve behavior: + +- Eve tools are typed actions called by the model and receive runtime `ctx`. +- Custom channels normalize inbound work and use an in-process `send()` path to start or resume Eve sessions. +- Eve task-mode runs are intended for work that runs to completion rather than interactive HITL conversation. +- Eve emits `result.completed` for turns that use structured output. +- Eve's public TypeScript API documentation explicitly warns that APIs not exported through the public package surface are framework internals. +- Current Eve channel/cross-channel APIs do not expose every structured-output option uniformly; therefore this specification deliberately hides Eve send mechanics behind the local `ctx.send()` adapter. + +Do not import private Eve workflow/session internals to implement `exportTool`. + +--- + +# 34. References + +Eve documentation and source: + +- https://github.com/vercel/eve/blob/main/docs/tools/overview.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/overview.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/slack.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/eve.mdx +- https://github.com/vercel/eve/blob/main/docs/concepts/sessions-runs-and-streaming.md +- https://github.com/vercel/eve/blob/main/docs/reference/typescript-api.md +- https://github.com/vercel/eve/blob/main/docs/agent-config.md +- https://github.com/vercel/eve/blob/main/docs/schedules.mdx +- https://github.com/vercel/eve/issues/214 +- https://github.com/vercel/eve/issues/1270 + +XMPP gateway client reference: + +- https://raw.githubusercontent.com/romanbsd/xmpp-agent-gateway/refs/heads/master/docs/xmpp-client-guide.md diff --git a/docs/setup.md b/docs/setup.md index 03b8912f7..55c5a24af 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -142,6 +142,40 @@ DATABASE_URL="…" bunx prisma migrate diff \ strings, asserted by `packages/env/test/root.spec.ts`. **Generate your own secret**; never reuse one from an example, a tutorial, or another environment. +## XMPP agent gateway + +The gateway starts with the agent when `XMPP_COMPONENT_ENABLED=1`. +It uses the root `.env` and the same PostgreSQL database. + +Set these required values: + +```sh +XMPP_COMPONENT_ENABLED="1" +XMPP_COMPONENT_JID="gateway.agents.example.com" +XMPP_COMPONENT_SECRET="..." +XMPP_ORGANIZATION_ID="..." +AGENT_BRIDGE_SECRET="..." +``` + +The XMPP server must contain the component account. +The gateway connects through `XMPP_COMPONENT_SERVICE`. +The default address is `xmpp://127.0.0.1:5275`. + +`XMPP_ALLOWED_CALLER_DOMAINS` limits discovery and invocation. +`XMPP_ALLOW_DESTRUCTIVE_CALLERS` lists bare JIDs that can run destructive exports. +An empty destructive caller list denies every destructive export. + +The gateway exposes only `apps/agent/src/exports` registry entries. +It stores task state in PostgreSQL for recovery and replay protection. + +Run the live invocation check against a configured test server: + +```sh +XMPP_E2E_ALLOW_SELF_SIGNED=1 bun run --filter=agent e2e:xmpp +``` + +Use `XMPP_E2E_ALLOW_SELF_SIGNED` only with an isolated server certificate. + ## Tests ```sh diff --git a/package.json b/package.json index 534ec9d00..4c236c3ad 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "packages/agent-xmpp/*" ] } diff --git a/packages/agent-xmpp/core/package.json b/packages/agent-xmpp/core/package.json new file mode 100644 index 000000000..189b578c4 --- /dev/null +++ b/packages/agent-xmpp/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@agent-xmpp/core", + "version": "0.1.0", + "description": "ProtoXEP schema validation and canonicalization", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "ajv": "8.17.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "4.23.12", + "typescript": "^5.7.0" + } +} diff --git a/packages/agent-xmpp/core/src/index.ts b/packages/agent-xmpp/core/src/index.ts new file mode 100644 index 000000000..8868041f9 --- /dev/null +++ b/packages/agent-xmpp/core/src/index.ts @@ -0,0 +1 @@ +export * from './schema.js'; diff --git a/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts b/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts new file mode 100644 index 000000000..e37f5aa33 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema-worker-lineage.test.ts @@ -0,0 +1,75 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "node:events"; + +interface WorkerRequest { + id: number; +} + +class FakeWorker extends EventEmitter { + static instances: FakeWorker[] = []; + request?: WorkerRequest; + + constructor() { + super(); + FakeWorker.instances.push(this); + } + + unref(): void {} + + postMessage(request: WorkerRequest): void { + this.request = request; + } + + terminate(): Promise { + return Promise.resolve(0); + } +} + +function workerAt(index: number): FakeWorker { + const worker = FakeWorker.instances[index]; + if (!worker) throw new Error(`missing worker ${index}`); + return worker; +} + +mock.module("node:worker_threads", () => ({ Worker: FakeWorker })); + +const schemaModule = "./schema.ts?worker-lineage"; +const { closeSchemaWorkers, SCHEMA_WORKER_FAILURE_LIMIT, validateJsonBounded } = + await import(schemaModule); + +afterAll(async () => { + await closeSchemaWorkers(); + mock.restore(); +}); + +describe("schema worker replacement lineages", () => { + it("stops one worker lineage after three interleaved failures", async () => { + const schema = { type: "string" } as const; + + for ( + let generation = 0; + generation < SCHEMA_WORKER_FAILURE_LIMIT; + generation++ + ) { + const unaffectedValidation = validateJsonBounded(schema, "valid"); + const failedValidation = validateJsonBounded(schema, "failed"); + const unaffectedWorker = workerAt(0); + const failingWorker = workerAt(generation + 1); + const unaffectedRequest = unaffectedWorker.request; + if (!unaffectedRequest) throw new Error("missing unaffected request"); + + unaffectedWorker.emit("message", { + id: unaffectedRequest.id, + errors: [], + }); + failingWorker.emit("error", new Error(`failure ${generation + 1}`)); + + await expect(unaffectedValidation).resolves.toEqual([]); + await expect(failedValidation).rejects.toThrow( + `failure ${generation + 1}`, + ); + } + + expect(FakeWorker.instances).toHaveLength(SCHEMA_WORKER_FAILURE_LIMIT + 1); + }); +}); diff --git a/packages/agent-xmpp/core/src/schema-worker.ts b/packages/agent-xmpp/core/src/schema-worker.ts new file mode 100644 index 000000000..60fdcc5fb --- /dev/null +++ b/packages/agent-xmpp/core/src/schema-worker.ts @@ -0,0 +1,57 @@ +import { parentPort } from 'node:worker_threads'; + +import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'; +import { isXep0082DateTime } from '@agent-xmpp/protocol'; + +interface ValidationRequest { + id: number; + schemaHash: string; + schema: Record; + value: unknown; +} + +interface ValidationResponse { + id: number; + errors?: string[]; + failure?: string; +} + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat('uri', { + type: 'string', + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat('date-time', isXep0082DateTime); + +const validators = new Map(); + +parentPort?.on('message', (request: ValidationRequest) => { + const response: ValidationResponse = { id: request.id }; + try { + let validate = validators.get(request.schemaHash); + if (!validate) { + validate = ajv.compile(request.schema); + validators.set(request.schemaHash, validate); + } + response.errors = validate(request.value) ? [] : (validate.errors ?? []).map(formatError); + } catch (error) { + response.failure = error instanceof Error ? error.message : String(error); + } + parentPort?.postMessage(response); +}); + +function formatError(error: ErrorObject): string { + return `${error.instancePath || '$'} ${error.message ?? error.keyword}`; +} diff --git a/packages/agent-xmpp/core/src/schema.test.ts b/packages/agent-xmpp/core/src/schema.test.ts new file mode 100644 index 000000000..18fde5c38 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema.test.ts @@ -0,0 +1,23 @@ +import { afterAll, describe, expect, it } from "bun:test"; + +import { closeSchemaWorkers, validateJsonBounded } from "./schema.js"; + +afterAll(async () => { + await closeSchemaWorkers(); +}); + +describe("bounded schema validation", () => { + it("starts source workers with the declared loader", async () => { + await expect( + validateJsonBounded( + { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, + { value: "ok" }, + ), + ).resolves.toEqual([]); + }); +}); diff --git a/packages/agent-xmpp/core/src/schema.ts b/packages/agent-xmpp/core/src/schema.ts new file mode 100644 index 000000000..7be084e80 --- /dev/null +++ b/packages/agent-xmpp/core/src/schema.ts @@ -0,0 +1,558 @@ +import { createHash } from "node:crypto"; +import { Worker } from "node:worker_threads"; +import { + type AgentApiManifest, + assertUnicodeScalarString, + DEFAULT_JSON_LIMITS, + isApiVersion, + isNormalizedEndpointJid, + isToolName, + isXep0082DateTime, + type JsonSchema, + parseStrictJson, + type RegisteredTool, + XMPP_TOOL_EXTENSION_KEY, +} from "@agent-xmpp/protocol"; +import EVENT_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/event.schema.json" with { + type: "json", +}; +import MANIFEST_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/manifest.schema.json" with { + type: "json", +}; +import { + Ajv2020, + type ErrorObject, + type ValidateFunction, +} from "ajv/dist/2020.js"; + +export const MANIFEST_MAX_BYTES = 1_048_576; +export const SCHEMA_MAX_BYTES = 262_144; +export const SCHEMA_MAX_DEPTH = 64; +export const SCHEMA_MAX_NODES = 10_000; +export const SCHEMA_MAX_PATTERN_BYTES = 4_096; +export const SCHEMA_MAX_PENDING_VALIDATIONS = 64; + +export class SchemaResourceLimitError extends Error {} + +const MANIFEST_SCHEMA = MANIFEST_SCHEMA_DOCUMENT as JsonSchema; +const EVENT_SCHEMA = EVENT_SCHEMA_DOCUMENT as JsonSchema; + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat("uri", { + type: "string", + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat("date-time", isXep0082DateTime); +const manifestValidator = ajv.compile(MANIFEST_SCHEMA); +const validatorCache = new Map(); +const SCHEMA_WORKER_COUNT = 2; +export const SCHEMA_WORKER_FAILURE_LIMIT = 3; +const DEFAULT_SCHEMA_TIMEOUT_MS = 500; + +interface WorkerRequest { + id: number; + schemaHash: string; + schema: JsonSchema; + value: unknown; +} + +interface WorkerResponse { + id: number; + errors?: string[]; + failure?: string; +} + +interface PendingValidation { + request: WorkerRequest; + timeoutMs: number; + resolve: (errors: string[]) => void; + reject: (error: Error) => void; +} + +interface SchemaWorkerSlot { + worker: Worker; + consecutiveFailures: number; + pending?: PendingValidation; + timer?: ReturnType; +} + +let nextValidationId = 1; +const validationQueue: PendingValidation[] = []; +const schemaWorkers: SchemaWorkerSlot[] = []; + +/** RFC 8785 JSON Canonicalization Scheme serialization. */ +export function canonicalJson(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") { + assertUnicodeScalarString(value); + return JSON.stringify(value); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("non-finite JSON number"); + return Object.is(value, -0) ? "0" : JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${canonicalJson(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + throw new Error(`unsupported JSON value: ${typeof value}`); +} + +/** XEP-0300 SHA-256 value: standard padded Base64, without an algorithm prefix. */ +export function digestJson(value: unknown): string { + return createHash("sha256") + .update(canonicalJson(value), "utf8") + .digest("base64"); +} + +export function assertJsonValueBounded( + value: unknown, + maxBytes: number, + label = "JSON value", +): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new Error("JSON byte limit must be a positive integer"); + const pending: Array<{ value: unknown; depth: number }> = [ + { value, depth: 0 }, + ]; + const visited = new WeakSet(); + let members = 0; + while (pending.length > 0) { + const current = pending.pop()!; + if (current.depth > DEFAULT_JSON_LIMITS.maxDepth) { + throw new SchemaResourceLimitError( + `${label} exceeds JSON depth ${DEFAULT_JSON_LIMITS.maxDepth}`, + ); + } + if (typeof current.value === "string") { + if ( + Buffer.byteLength(current.value, "utf8") > + DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON string`, + ); + } + continue; + } + if (current.value === null || typeof current.value !== "object") continue; + if (visited.has(current.value)) + throw new Error(`${label} contains a cyclic value`); + visited.add(current.value); + const entries = Array.isArray(current.value) + ? current.value.map((item) => [undefined, item] as const) + : Object.entries(current.value as Record); + members += entries.length; + if (members > DEFAULT_JSON_LIMITS.maxMembers) { + throw new SchemaResourceLimitError(`${label} exceeds JSON member limit`); + } + for (const [key, child] of entries) { + if ( + key !== undefined && + Buffer.byteLength(key, "utf8") > DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON member name`, + ); + } + pending.push({ value: child, depth: current.depth + 1 }); + } + } + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} is not a JSON value`); + if (Buffer.byteLength(encoded, "utf8") > maxBytes) { + throw new SchemaResourceLimitError(`${label} exceeds ${maxBytes} bytes`); + } +} + +export function parseManifestJson(text: string): AgentApiManifest { + return validateManifest( + parseStrictJson(text, { maxBytes: MANIFEST_MAX_BYTES }), + ); +} + +export function validateManifest(value: unknown): AgentApiManifest { + const canonical = canonicalJson(value); + if (Buffer.byteLength(canonical, "utf8") > MANIFEST_MAX_BYTES) { + throw new SchemaResourceLimitError("manifest exceeds 1 MiB"); + } + if (!manifestValidator(value)) + throw new Error( + `invalid manifest: ${formatErrors(manifestValidator.errors)}`, + ); + const manifest = value as AgentApiManifest; + if (!isNormalizedEndpointJid(manifest.agent.jid)) { + throw new Error("agent.jid must be a normalized endpoint bare JID"); + } + if (!isApiVersion(manifest.agent.version)) + throw new Error("agent.version must be a valid API version"); + for (const [member, uri] of [ + ["agent.homepage", manifest.agent.homepage], + ["agent.avatarUrl", manifest.agent.avatarUrl], + ] as const) { + if (uri !== undefined && !isPublicProfileHttpsUri(uri)) { + throw new Error( + `${member} must be an absolute lowercase HTTPS URI with a non-empty host and no userinfo`, + ); + } + } + const names = new Set(); + for (const tool of manifest.tools) { + assertUnicodeScalarString(tool.name); + if (!isToolName(tool.name)) throw new Error("invalid XML tool name"); + if (names.has(tool.name)) throw new Error(`duplicate tool: ${tool.name}`); + names.add(tool.name); + preflightSchema(tool.inputSchema, `tool ${tool.name} inputSchema`); + if (tool.outputSchema) + preflightSchema(tool.outputSchema, `tool ${tool.name} outputSchema`); + const extension = tool[XMPP_TOOL_EXTENSION_KEY] as + | Record + | undefined; + const defaultTimeout = extension?.defaultTimeoutSeconds; + const maximumTimeout = extension?.maximumTimeoutSeconds; + if ( + typeof defaultTimeout === "number" && + typeof maximumTimeout === "number" && + defaultTimeout > maximumTimeout + ) { + throw new Error( + `tool ${tool.name} default timeout exceeds maximum timeout`, + ); + } + } + return manifest; +} + +function isPublicProfileHttpsUri(value: string): boolean { + if ( + !value.startsWith("https://") || + !/^[A-Za-z0-9\-._~:/?[\]@!$&'()*+,;=%]+$/.test(value) || + /%(?![0-9A-Fa-f]{2})/.test(value) || + !URL.canParse(value) + ) { + return false; + } + const authority = value.slice("https://".length).split(/[/?]/, 1)[0]!; + if (authority.endsWith(":")) return false; + const uri = new URL(value); + return ( + uri.protocol === "https:" && + uri.hostname.length > 0 && + uri.username === "" && + uri.password === "" && + uri.hash === "" + ); +} + +export function registeredTools(manifest: AgentApiManifest): RegisteredTool[] { + return manifest.tools.map((tool) => ({ + ...tool, + inputSchemaHash: digestJson(tool.inputSchema), + outputSchemaHash: tool.outputSchema + ? digestJson(tool.outputSchema) + : undefined, + xmpp: tool[XMPP_TOOL_EXTENSION_KEY] as RegisteredTool["xmpp"], + })); +} + +export function validateJson(schema: JsonSchema, value: unknown): string[] { + preflightSchema(schema, "schema"); + const hash = digestJson(schema); + let validate = validatorCache.get(hash); + if (!validate) { + const compiled = ajv.compile(schema); + validatorCache.set(hash, compiled); + validate = compiled; + } + return validate(value) ? [] : (validate.errors ?? []).map(formatError); +} + +/** + * Evaluate caller-controlled schemas away from the host event loop. Workers are + * bounded and replaced after a timeout; each worker caches validators by the + * canonical schema hash. + */ +export function validateJsonBounded( + schema: JsonSchema, + value: unknown, + timeoutMs = DEFAULT_SCHEMA_TIMEOUT_MS, +): Promise { + preflightSchema(schema, "schema"); + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject( + new Error("schema timeout must be a positive integer"), + ); + } + const pendingCount = + validationQueue.length + + schemaWorkers.filter((slot) => slot.pending).length; + if (pendingCount >= SCHEMA_MAX_PENDING_VALIDATIONS) { + return Promise.reject( + new SchemaResourceLimitError("schema validation queue is full"), + ); + } + return new Promise((resolve, reject) => { + validationQueue.push({ + request: { + id: nextValidationId++, + schemaHash: digestJson(schema), + schema, + value, + }, + timeoutMs, + resolve, + reject, + }); + ensureSchemaWorkers(); + dispatchValidationQueue(); + }); +} + +export function validateTaskEventPayload( + type: + | "status" + | "progress" + | "input_required" + | "completed" + | "failed" + | "cancelled", + payload: unknown, +): Promise { + const schema: JsonSchema = { ...EVENT_SCHEMA, $ref: `#/$defs/${type}` }; + delete schema.$id; + return validateJsonBounded(schema, payload); +} + +export async function closeSchemaWorkers(): Promise { + const workers = schemaWorkers.splice(0); + for (const slot of workers) { + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(new Error("schema validator worker closed")); + await slot.worker.terminate(); + } + while (validationQueue.length) + validationQueue + .shift()! + .reject(new Error("schema validator worker closed")); +} + +function ensureSchemaWorkers(): void { + while (schemaWorkers.length < SCHEMA_WORKER_COUNT) { + const slot = createSchemaWorkerLineage(); + if (!slot) return; + schemaWorkers.push(slot); + } +} + +function createSchemaWorkerLineage( + consecutiveFailures = 0, +): SchemaWorkerSlot | undefined { + let failures = consecutiveFailures; + let lastError = new Error("schema validator worker failed"); + while (failures < SCHEMA_WORKER_FAILURE_LIMIT) { + try { + return createSchemaWorker(failures); + } catch (error) { + lastError = error instanceof Error ? error : lastError; + failures++; + } + } + rejectValidationQueue(lastError); + return undefined; +} + +function createSchemaWorker(consecutiveFailures: number): SchemaWorkerSlot { + const sourceMode = import.meta.url.endsWith(".ts"); + const worker = new Worker( + new URL( + sourceMode ? "./schema-worker.ts" : "./schema-worker.js", + import.meta.url, + ), + { + execArgv: sourceMode ? ["--import", "tsx"] : undefined, + }, + ); + worker.unref(); + const slot: SchemaWorkerSlot = { worker, consecutiveFailures }; + worker.on("message", (response: WorkerResponse) => + settleWorker(slot, response), + ); + worker.on("error", (error) => replaceWorker(slot, error)); + worker.on("exit", (code) => { + if (schemaWorkers.includes(slot) && code !== 0) { + replaceWorker( + slot, + new Error(`schema validator worker exited with code ${code}`), + ); + } + }); + return slot; +} + +function dispatchValidationQueue(): void { + for (const slot of schemaWorkers) { + if (slot.pending) continue; + const pending = validationQueue.shift(); + if (!pending) return; + slot.pending = pending; + slot.timer = setTimeout(() => { + replaceWorker( + slot, + new SchemaResourceLimitError( + `schema validation timed out after ${pending.timeoutMs}ms`, + ), + ); + }, pending.timeoutMs); + slot.timer.unref?.(); + slot.worker.postMessage(pending.request); + } +} + +function settleWorker(slot: SchemaWorkerSlot, response: WorkerResponse): void { + const pending = slot.pending; + if (!pending || pending.request.id !== response.id) return; + if (slot.timer) clearTimeout(slot.timer); + slot.timer = undefined; + slot.pending = undefined; + if (response.failure) + pending.reject(new Error(`schema validation failed: ${response.failure}`)); + else { + slot.consecutiveFailures = 0; + pending.resolve(response.errors ?? []); + } + dispatchValidationQueue(); +} + +function replaceWorker(slot: SchemaWorkerSlot, error: Error): void { + const index = schemaWorkers.indexOf(slot); + if (index < 0) return; + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(error); + slot.pending = undefined; + void slot.worker.terminate(); + schemaWorkers.splice(index, 1); + const consecutiveFailures = slot.consecutiveFailures + 1; + if (consecutiveFailures >= SCHEMA_WORKER_FAILURE_LIMIT) { + rejectValidationQueue(error); + return; + } + const replacement = createSchemaWorkerLineage(consecutiveFailures); + if (!replacement) return; + schemaWorkers.splice(index, 0, replacement); + dispatchValidationQueue(); +} + +function rejectValidationQueue(error: Error): void { + while (validationQueue.length) validationQueue.shift()!.reject(error); +} + +export function preflightSchema(schema: JsonSchema, label: string): void { + assertSchemaComplexity(schema, label); + const encoded = canonicalJson(schema); + if (Buffer.byteLength(encoded, "utf8") > SCHEMA_MAX_BYTES) { + throw new SchemaResourceLimitError(`${label} exceeds 256 KiB`); + } + if (!ajv.validateSchema(schema)) + throw new Error( + `${label} is not a valid JSON Schema: ${formatErrors(ajv.errors)}`, + ); +} + +function assertSchemaComplexity(schema: JsonSchema, label: string): void { + const pending: Array<{ value: unknown; depth: number }> = [ + { value: schema, depth: 0 }, + ]; + let nodes = 0; + while (pending.length > 0) { + const { value, depth } = pending.pop()!; + if (++nodes > SCHEMA_MAX_NODES) { + throw new SchemaResourceLimitError( + `${label} exceeds ${SCHEMA_MAX_NODES} nodes`, + ); + } + if (depth > SCHEMA_MAX_DEPTH) { + throw new SchemaResourceLimitError( + `${label} exceeds depth ${SCHEMA_MAX_DEPTH}`, + ); + } + if (!value || typeof value !== "object") continue; + const object = value as Record; + for (const keyword of ["$ref", "$dynamicRef"] as const) { + const reference = object[keyword]; + if (typeof reference === "string" && !reference.startsWith("#")) { + throw new Error(`${label} contains forbidden external ${keyword}`); + } + } + if (object.$vocabulary && typeof object.$vocabulary === "object") { + for (const [vocabulary, required] of Object.entries( + object.$vocabulary as Record, + )) { + if ( + required === true && + !vocabulary.startsWith("https://json-schema.org/draft/2020-12/vocab/") + ) { + throw new Error( + `${label} requires unsupported vocabulary ${vocabulary}`, + ); + } + } + } + if (typeof object.pattern === "string") + assertPattern(object.pattern, label); + if ( + object.patternProperties && + typeof object.patternProperties === "object" + ) { + for (const pattern of Object.keys( + object.patternProperties as Record, + )) { + assertPattern(pattern, label); + } + } + const children = Array.isArray(value) ? value : Object.values(object); + for (const child of children) + pending.push({ value: child, depth: depth + 1 }); + } +} + +function assertPattern(pattern: string, label: string): void { + if (Buffer.byteLength(pattern, "utf8") > SCHEMA_MAX_PATTERN_BYTES) { + throw new SchemaResourceLimitError( + `${label} contains a pattern exceeding ${SCHEMA_MAX_PATTERN_BYTES} bytes`, + ); + } + try { + new RegExp(pattern, "u"); + } catch (error) { + throw new Error(`${label} contains an invalid ECMA-262 pattern`, { + cause: error, + }); + } +} + +function formatError(error: ErrorObject): string { + return `${error.instancePath || "$"} ${error.message ?? error.keyword}`; +} + +function formatErrors(errors: ErrorObject[] | null | undefined): string { + return ( + (errors ?? []).map(formatError).join("; ") || "schema validation failed" + ); +} diff --git a/packages/agent-xmpp/core/tsconfig.json b/packages/agent-xmpp/core/tsconfig.json new file mode 100644 index 000000000..cb2ac4fc1 --- /dev/null +++ b/packages/agent-xmpp/core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/gateway/package.json b/packages/agent-xmpp/gateway/package.json new file mode 100644 index 000000000..303e3ca15 --- /dev/null +++ b/packages/agent-xmpp/gateway/package.json @@ -0,0 +1,30 @@ +{ + "name": "@agent-xmpp/gateway", + "version": "0.1.0", + "description": "XMPP component and ProtoXEP wire codecs for agent gateways", + "type": "module", + "main": "./dist/embedded-gateway.js", + "types": "./dist/embedded-gateway.d.ts", + "exports": { + ".": { + "types": "./dist/embedded-gateway.d.ts", + "import": "./dist/embedded-gateway.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", + "@xmpp/component": "0.14.0", + "@xmpp/xml": "0.14.0", + "ulid": "3.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/agent-xmpp/gateway/src/agent-api-disco.ts b/packages/agent-xmpp/gateway/src/agent-api-disco.ts new file mode 100644 index 000000000..75c62ffe2 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-api-disco.ts @@ -0,0 +1,465 @@ +import { + AGENT_API_NS, + AGENT_DIRECTORY_NS, + AGENT_ENDPOINT_NS, + AGENT_TASK_NS, + AGENT_TOOL_NS, + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + JSON_SCHEMA_MEDIA_TYPE, + isApiVersion, + isToolName, + type AgentApiManifest, + type AgentXmppNamespaces, + type RegisteredAgent, + type RegisteredTool, + parseStrictJson, +} from '@agent-xmpp/protocol'; +import { canonicalJson, validateManifest } from '@agent-xmpp/core'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildHash, parseHash } from './hash-codec.js'; +import { ProtocolError } from './protocol-error.js'; +import { buildRsm, pageRsm, parseRsm } from './rsm-codec.js'; +import { VCARD_TEMP_NS } from './xep-plugins/vcard.js'; + +export const DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info'; +export const DISCO_ITEMS_NS = 'http://jabber.org/protocol/disco#items'; +export const DATA_FORMS_NS = 'jabber:x:data'; +export const SEARCH_NS = 'jabber:iq:search'; +export { AGENT_DIRECTORY_NS, AGENT_API_NS, AGENT_TOOL_NS, AGENT_ENDPOINT_NS, AGENT_TASK_NS }; + +export interface ManifestRequest { + version?: string; +} + +export interface SchemaRequest { + tool: string; + version: string; + direction: 'input' | 'output'; + manifestHash: string; +} + +interface PayloadShape { + requiredAttributes?: readonly string[]; + optionalAttributes?: readonly string[]; + children?: readonly { name: string; xmlns?: string }[]; +} + +function assertPayloadShape(payload: Element, shape: PayloadShape): void { + const required = shape.requiredAttributes ?? []; + const allowed = new Set(['xmlns', ...required, ...(shape.optionalAttributes ?? [])]); + if ( + required.some((name) => payload.attrs[name] === undefined || payload.attrs[name] === '') || + Object.keys(payload.attrs).some((name) => !allowed.has(name)) + ) { + throw new Error(`${payload.name} has invalid attributes`); + } + + const expectedChildren = shape.children ?? []; + const actualChildren = payload.getChildElements(); + if ( + payload.children.some((child) => typeof child === 'string' && child.trim() !== '') || + actualChildren.length !== expectedChildren.length || + actualChildren.some( + (child, index) => + child.name !== expectedChildren[index]!.name || + (expectedChildren[index]!.xmlns !== undefined && child.attrs.xmlns !== expectedChildren[index]!.xmlns), + ) + ) { + throw new Error(`${payload.name} has invalid children`); + } +} + +function requestPayload( + request: Element, + name: string, + namespace: string, + expectedIqType: 'get' | 'set', +): Element | null { + if (request.name !== 'iq') return null; + const payload = request.getChild(name, namespace); + if (!payload) return null; + if (request.attrs.type !== expectedIqType) throw new Error(`${name} requires an IQ of type ${expectedIqType}`); + return payload; +} + +export function parseManifestRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ManifestRequest | null { + const payload = requestPayload(request, 'manifest-request', namespaces.api, 'get'); + if (!payload) return null; + assertPayloadShape(payload, { optionalAttributes: ['version'] }); + const version = payload.attrs.version === undefined ? undefined : String(payload.attrs.version); + if (version !== undefined && !isApiVersion(version)) throw new Error('manifest-request has an invalid version'); + return version === undefined ? {} : { version }; +} + +export function parseSchemaRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): SchemaRequest | null { + const payload = requestPayload(request, 'schema-request', namespaces.api, 'get'); + if (!payload) return null; + assertPayloadShape(payload, { + requiredAttributes: ['tool', 'version', 'direction'], + children: [{ name: 'hash', xmlns: namespaces.hashes }], + }); + const tool = String(payload.attrs.tool); + const version = String(payload.attrs.version); + const direction = String(payload.attrs.direction); + if (!isToolName(tool) || !isApiVersion(version) || (direction !== 'input' && direction !== 'output')) { + throw new Error('schema-request has invalid attributes'); + } + return { + tool, + version, + direction, + manifestHash: parseHash(payload).value, + }; +} + +function resultIq(request: Element, from: string, child: Element): Element { + return xml('iq', { type: 'result', id: request.attrs.id, from, to: request.attrs.from }, child); +} + +function field(name: string, value: string, type?: string): Element { + return xml('field', { var: name, ...(type ? { type } : {}) }, xml('value', {}, value)); +} + +function resultForm(formType: string, fields: Element[]): Element { + return xml('x', { xmlns: DATA_FORMS_NS, type: 'result' }, field('FORM_TYPE', formType, 'hidden'), ...fields); +} + +function features(...values: string[]): Element[] { + return values.map((value) => xml('feature', { var: value })); +} + +const HUMAN_FEATURES = [ + 'urn:xmpp:ping', + 'urn:xmpp:receipts', + 'http://jabber.org/protocol/chatstates', + 'urn:xmpp:reply:0', + 'urn:xmpp:sid:0', + 'urn:xmpp:hints', +]; + +export function buildGatewayInfo( + request: Element, + componentJid: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_INFO_NS }, + xml('identity', { category: 'automation', type: 'agent-gateway', name: 'NanoClaw XMPP Agent Gateway' }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + SEARCH_NS, + DATA_FORMS_NS, + namespaces.directory, + namespaces.admin, + ...HUMAN_FEATURES, + ), + ), + ); +} + +export function buildDirectoryInfo(request: Element, componentJid: string): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_INFO_NS, node: AGENT_DIRECTORY_NS }, + xml('identity', { category: 'automation', type: 'agent-directory', name: 'NanoClaw Agent Directory' }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildAgentDirectory( + request: Element, + componentJid: string, + agents: RegisteredAgent[], + _namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild('query', DISCO_ITEMS_NS)!; + const page = pageRsm(agents, (agent) => agent.manifest.agent.jid, parseRsm(query)); + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_ITEMS_NS, ...(query.attrs.node ? { node: query.attrs.node } : {}) }, + ...page.items.map((agent) => + xml('item', { + jid: agent.manifest.agent.jid, + name: agent.manifest.agent.title ?? agent.manifest.agent.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildAgentInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const identity = agent.manifest.agent; + const taskFeatures = new Set([namespaces.task]); + for (const tool of agent.tools) { + if (tool.xmpp?.supportsProgress) taskFeatures.add(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.add(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.add(namespaces.input); + } + return resultIq( + request, + identity.jid, + xml( + 'query', + { xmlns: DISCO_INFO_NS }, + xml('identity', { category: 'automation', type: 'agent-endpoint', name: identity.title ?? identity.name }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + namespaces.endpoint, + namespaces.manifest, + namespaces.schema, + ...taskFeatures, + VCARD_TEMP_NS, + ...HUMAN_FEATURES, + ), + resultForm(namespaces.endpointInfo, [ + field('server_name', identity.name), + field('server_title', identity.title ?? identity.name), + ...(identity.description ? [field('description', identity.description)] : []), + field('version', identity.version), + field('manifest_hash_algo', 'sha-256'), + field('manifest_hash_value', agent.manifestHash), + field('cold_start_supported', '1'), + field('request_replay_seconds', '86400'), + ]), + ), + ); +} + +export function buildToolItems( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild('query', DISCO_ITEMS_NS)!; + const page = pageRsm( + agent.tools, + (tool) => toolNode(agent.manifest.agent.version, tool.name, namespaces), + parseRsm(query), + (tool) => tool.name, + ); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { xmlns: DISCO_ITEMS_NS, node: toolsNode(agent.manifest.agent.version, namespaces) }, + ...page.items.map((tool) => + xml('item', { + jid: agent.manifest.agent.jid, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + name: tool.title ?? tool.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildToolCollectionInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { xmlns: DISCO_INFO_NS, node: toolsNode(agent.manifest.agent.version, namespaces) }, + xml('identity', { category: 'automation', type: 'agent-tool-collection' }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildToolInfo( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const taskFeatures: string[] = [namespaces.task]; + if (tool.xmpp?.supportsProgress) taskFeatures.push(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.push(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.push(namespaces.input); + const optionalBoolean = (name: string, value: boolean | undefined): Element[] => + value === undefined ? [] : [field(name, value ? '1' : '0')]; + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { + xmlns: DISCO_INFO_NS, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + }, + xml('identity', { category: 'automation', type: 'agent-tool', name: tool.title ?? tool.name }), + ...features(DISCO_INFO_NS, namespaces.tool, ...taskFeatures), + resultForm(namespaces.toolInfo, [ + field('name', tool.name), + ...(tool.title ? [field('title', tool.title)] : []), + ...(tool.description ? [field('description', tool.description)] : []), + field('api_version', agent.manifest.agent.version), + field('input_schema_hash_algo', 'sha-256'), + field('input_schema_hash_value', tool.inputSchemaHash), + ...(tool.outputSchemaHash + ? [field('output_schema_hash_algo', 'sha-256'), field('output_schema_hash_value', tool.outputSchemaHash)] + : []), + ...optionalBoolean('read_only', tool.annotations?.readOnlyHint), + ...optionalBoolean('destructive', tool.annotations?.destructiveHint), + ...optionalBoolean('idempotent', tool.annotations?.idempotentHint), + ...optionalBoolean('open_world', tool.annotations?.openWorldHint), + ]), + ), + ); +} + +export function buildSchemaResult( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + direction: 'input' | 'output', + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const schema = direction === 'input' ? tool.inputSchema : tool.outputSchema; + const schemaHash = direction === 'input' ? tool.inputSchemaHash : tool.outputSchemaHash; + if (!schema || !schemaHash) throw new Error('schema not found'); + const canonicalSchema = canonicalJson(schema); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'schema', + { + xmlns: namespaces.api, + tool: tool.name, + version: agent.manifest.agent.version, + direction, + 'media-type': JSON_SCHEMA_MEDIA_TYPE, + }, + xml('manifest-hash', {}, buildHash(agent.manifestHash)), + xml('schema-hash', {}, buildHash(schemaHash)), + xml('json', {}, canonicalSchema), + ), + ); +} + +export function buildManifestResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'manifest', + { xmlns: namespaces.api, version: agent.manifest.agent.version, 'media-type': JSON_MEDIA_TYPE }, + buildHash(agent.manifestHash), + xml('json', {}, agent.canonicalManifest), + ), + ); +} + +export function toolsNode(version: string, namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES): string { + if (!isApiVersion(version)) throw new Error('invalid API version'); + return `${namespaces.tools}#${version}`; +} + +export function toolsVersionFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string | null { + const prefix = `${namespaces.tools}#`; + if (!node.startsWith(prefix)) return null; + const version = node.slice(prefix.length); + return isApiVersion(version) ? version : null; +} + +export function toolNode( + version: string, + name: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string { + if (!isApiVersion(version)) throw new Error('invalid API version'); + if (!isToolName(name)) throw new Error('invalid tool name'); + return `${namespaces.tool}#${version}#${Buffer.from(name, 'utf8').toString('base64url')}`; +} + +export function toolFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): { version: string; name: string } | null { + const prefix = `${namespaces.tool}#`; + if (!node.startsWith(prefix)) return null; + const separator = node.indexOf('#', prefix.length); + if (separator < 0) return null; + const version = node.slice(prefix.length, separator); + const encoded = node.slice(separator + 1); + if (!isApiVersion(version) || !encoded || encoded.includes('=') || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null; + try { + const bytes = Buffer.from(encoded, 'base64url'); + if (bytes.toString('base64url') !== encoded) return null; + const name = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (!isToolName(name)) return null; + return { version, name }; + } catch { + return null; + } +} + +export function parseManifestRegistration( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AgentApiManifest | null { + if (request.name !== 'iq' || request.attrs.type !== 'set') return null; + const manifest = request.getChild('register', namespaces.api)?.getChild('manifest', namespaces.api); + if (!manifest || manifest.attrs['media-type'] !== JSON_MEDIA_TYPE) return null; + try { + return validateManifest(parseStrictJson(manifest.getText(), { maxBytes: 1_048_576 })); + } catch (error) { + throw new ProtocolError('bad-request', error instanceof Error ? error.message : 'Invalid manifest'); + } +} + +export function buildManifestRegistrationResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + request.attrs.to ? String(request.attrs.to) : agent.manifest.agent.jid, + xml( + 'registered', + { xmlns: namespaces.api, jid: agent.manifest.agent.jid, version: agent.manifest.agent.version }, + buildHash(agent.manifestHash), + ), + ); +} diff --git a/packages/agent-xmpp/gateway/src/agent-send.ts b/packages/agent-xmpp/gateway/src/agent-send.ts new file mode 100644 index 000000000..22f223e3d --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-send.ts @@ -0,0 +1,53 @@ +/** + * Emits XEP-0085 Chat State Notifications on the agent's behalf (composing while + * the agent works, paused/inactive when it stops). States are directed to the same + * 1:1 resource or MUC room the inbound message came from. + * + * @see https://xmpp.org/extensions/xep-0085.html + */ +import type { Element } from '@xmpp/xml'; + +import type { InboundChatTargets } from './delivery.js'; +import { buildComposingStanza, buildInactiveStanza, buildPausedStanza } from './xep-plugins/chatstate.js'; + +async function sendChatStateForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, + state: 'composing' | 'paused' | 'inactive', +): Promise { + const build = + state === 'composing' ? buildComposingStanza : state === 'paused' ? buildPausedStanza : buildInactiveStanza; + await sendOutbound( + build({ + from: agentJid, + to: targets.to, + threadId: targets.threadId, + groupchat: targets.groupchat, + }), + ); +} + +export async function sendComposingForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'composing'); +} + +export async function sendPausedForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'paused'); +} + +export async function sendInactiveForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'inactive'); +} diff --git a/packages/agent-xmpp/gateway/src/config.ts b/packages/agent-xmpp/gateway/src/config.ts new file mode 100644 index 000000000..46bd42e47 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/config.ts @@ -0,0 +1,99 @@ +import { DEFAULT_PROTOCOL_NAMESPACES, type AgentXmppNamespaces } from '@agent-xmpp/protocol'; + +export interface GatewayConfig { + gatewayId: string; + /** Component JID, e.g. gateway.agents.example */ + componentJid: string; + /** Delegated domain for virtual agent JIDs, e.g. agents.example */ + agentDomain: string; + /** XMPP server domain used as the XEP-0199 keepalive target. */ + serverDomain: string; + /** xmpp://host:5275 or xmpps://host:5347 */ + componentService: string; + componentSecret: string; + defaultAgentJid: string; + /** Default inherited language for human-readable XML text. */ + xmlLang?: string; + /** XEP-0184: how long to wait for a before a resend is due (ms). */ + receiptTimeoutMs: number; + /** + * XEP-0184: max resends of an un-acked message before giving up. + * Default 0 (observe-only): absence of a receipt is NOT evidence of failure — many + * clients/servers don't implement receipts and XMPP doesn't guarantee dedup of equal + * stanza/origin ids, so resending would duplicate ordinary messages. Only raise this + * for a deployment where every peer is known to support XEP-0184 and dedups. + */ + receiptMaxResends: number; + /** How often the resend sweep runs (ms). */ + receiptSweepMs: number; + /** Initial reconnect delay; subsequent failures back off exponentially. */ + reconnectInitialMs: number; + /** Maximum reconnect delay. */ + reconnectMaxMs: number; + /** Send XEP-0199 after this much connection inactivity. */ + pingIntervalMs: number; + /** Time allowed for an XEP-0199 response. */ + pingTimeoutMs: number; + /** Consecutive ping failures before forcing a reconnect. */ + pingFailureThreshold: number; + /** Maximum concurrent inbound or outbound IQ requests held by the component. */ + maxPendingIqRequests?: number; + protocolNamespaces?: AgentXmppNamespaces; +} + +/** Non-negative integer (0 is meaningful, e.g. observe-only resends). */ +function envNonNegInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : fallback; +} + +/** Strictly-positive integer — for interval/timeout values where 0 would busy-loop. */ +function envPosInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : fallback; +} + +function env(name: string, fallback?: string): string { + const v = process.env[name]; + if (v !== undefined && v !== '') return v; + if (fallback !== undefined) return fallback; + throw new Error(`Missing required env: ${name}`); +} + +function envLanguageTag(name: string): string | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + return value.length <= 64 && /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(value) ? value : undefined; +} + +export function loadConfig(): GatewayConfig { + const componentJid = env('XMPP_COMPONENT_JID'); + const agentDomain = process.env.XMPP_AGENT_DOMAIN || componentJid.split('.').slice(1).join('.') || componentJid; + const inferredServerDomain = componentJid.split('.').slice(1).join('.') || componentJid; + const reconnectInitialMs = envPosInt('XMPP_RECONNECT_INITIAL_MS', 1_000); + const reconnectMaxMs = Math.max(reconnectInitialMs, envPosInt('XMPP_RECONNECT_MAX_MS', 60_000)); + return { + gatewayId: process.env.XMPP_GATEWAY_ID || 'gw-1', + componentJid, + agentDomain, + serverDomain: process.env.XMPP_SERVER_DOMAIN || inferredServerDomain, + componentService: env('XMPP_COMPONENT_SERVICE', 'xmpp://127.0.0.1:5275'), + componentSecret: env('XMPP_COMPONENT_SECRET'), + defaultAgentJid: process.env.XMPP_DEFAULT_AGENT_JID || `assistant@${agentDomain}`, + xmlLang: envLanguageTag('XMPP_XML_LANG'), + receiptTimeoutMs: envPosInt('XMPP_RECEIPT_TIMEOUT_MS', 30_000), + receiptMaxResends: envNonNegInt('XMPP_RECEIPT_MAX_RESENDS', 0), + receiptSweepMs: envPosInt('XMPP_RECEIPT_SWEEP_MS', 10_000), + reconnectInitialMs, + reconnectMaxMs, + pingIntervalMs: envPosInt('XMPP_PING_INTERVAL_MS', 60_000), + pingTimeoutMs: envPosInt('XMPP_PING_TIMEOUT_MS', 10_000), + pingFailureThreshold: envPosInt('XMPP_PING_FAILURE_THRESHOLD', 2), + maxPendingIqRequests: envPosInt('XMPP_MAX_PENDING_IQ_REQUESTS', 256), + protocolNamespaces: DEFAULT_PROTOCOL_NAMESPACES, + }; +} diff --git a/packages/agent-xmpp/gateway/src/delivery.ts b/packages/agent-xmpp/gateway/src/delivery.ts new file mode 100644 index 000000000..f9c986f5f --- /dev/null +++ b/packages/agent-xmpp/gateway/src/delivery.ts @@ -0,0 +1,137 @@ +/** + * Inbound delivery gating and routing. 1:1 (XMPP `chat`) messages always pass; + * groupchat (XEP-0045) messages are delivered only when the agent is mentioned — + * via XEP-0513 explicit mentions or the plaintext `@nick` fallback in routing.ts. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ +import type { AgentMessage, BridgeFormResponsePayload, BridgeInboundPayload } from '@agent-xmpp/protocol'; +import { agentMessageText } from '@agent-xmpp/protocol'; + +import type { GatewayConfig } from './config.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { buildInboundEnvelope } from './xep-plugins/message.js'; +import { bareJid } from './xep-plugins/jid.js'; +import { mucRoomFromStanza } from './xep-plugins/muc.js'; +import { isMentionForAgent, shouldDeliverInbound } from './xep-plugins/routing.js'; + +export interface InboundDeliveryContext { + agentMsg: AgentMessage; + agentJid: string; + deliveryId: string; + stanzaType: string; + from: string; + redelivered?: boolean; +} + +export function shouldAcceptStanza(stanzaType: string, from: string, bodyText: string, agentNick: string): boolean { + const room = mucRoomFromStanza(from); + const isGroup = stanzaType === 'groupchat' || !!room; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + return shouldDeliverInbound(stanzaType, isGroup, isMention); +} + +export interface InboundChatTargets { + /** Reply/typing destination and host router session key: MUC room JID or bare sender JID. */ + to: string; + threadId: string | null; + /** True for MUC/groupchat traffic. */ + groupchat: boolean; +} + +/** Resolve where replies and typing notifications for an inbound stanza should go. */ +export function resolveInboundChatTargets( + from: string, + stanzaType: string, + agentMsg: Pick, +): InboundChatTargets { + const room = mucRoomFromStanza(from); + const groupchat = stanzaType === 'groupchat' || !!room; + const to = groupchat && room ? room : bareJid(agentMsg.from); + const threadId = agentMsg.threadId || (groupchat ? room || null : null); + return { to, threadId, groupchat }; +} + +export function buildBridgePayload( + config: GatewayConfig, + ctx: InboundDeliveryContext, +): BridgeInboundPayload { + const { agentMsg, agentJid, deliveryId, stanzaType, from, redelivered } = ctx; + const { to: platformId, threadId, groupchat: isGroup } = resolveInboundChatTargets(from, stanzaType, agentMsg); + const bodyText = agentMessageText(agentMsg); + const agentNick = agentJid.split('@')[0]; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + + const envelope = buildInboundEnvelope( + agentMsg, + config.gatewayId, + deliveryId, + { + stanzaId: agentMsg.id, + stableId: agentMsg.id, + stanzaType: stanzaType as 'chat' | 'groupchat', + }, + redelivered, + ); + + return { + platformId, + // RFC 6121 section 8.5.2.1: reply to the originating resource. Keeping + // routing on the bare JID avoids creating one NanoClaw session per client. + replyTo: isGroup ? undefined : from, + threadId, + agentJid, + isMention, + isGroup, + envelope, + }; +} + +export async function pushInboundToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: InboundDeliveryContext, +): Promise { + await mailbox.deliverInbound(buildBridgePayload(config, ctx)); +} + +export interface FormResponseContext { + agentJid: string; + from: string; + stanzaType: string; + questionId: string; + selectedIndex: number; +} + +export function buildFormResponsePayload( + _config: GatewayConfig, + ctx: FormResponseContext, +): BridgeFormResponsePayload { + const { to: platformId, threadId, groupchat: isGroup } = resolveInboundChatTargets( + ctx.from, + ctx.stanzaType, + { from: ctx.from, threadId: undefined }, + ); + + return { + type: 'form_response', + agentJid: ctx.agentJid, + platformId, + threadId, + questionId: ctx.questionId, + selectedIndex: ctx.selectedIndex, + // In a MUC the occupant identity is the resource (room@muc/nick); keep the full JID so + // the answer is attributed to the responder, not to the room. + userId: isGroup ? ctx.from : platformId, + timestamp: new Date().toISOString(), + }; +} + +export async function pushFormResponseToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: FormResponseContext, +): Promise { + await mailbox.deliverFormResponse(buildFormResponsePayload(config, ctx)); +} diff --git a/packages/agent-xmpp/gateway/src/embedded-gateway.ts b/packages/agent-xmpp/gateway/src/embedded-gateway.ts new file mode 100644 index 000000000..05efec428 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/embedded-gateway.ts @@ -0,0 +1,330 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + bareJid, + type AgentXmppNamespaces, + type OutboundDeliverRequest, +} from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildGatewayInfo, DISCO_INFO_NS } from './agent-api-disco.js'; +import type { GatewayConfig } from './config.js'; +export { loadConfig } from './config.js'; +export type { GatewayConfig } from './config.js'; +import { sendComposingForAgent, sendInactiveForAgent, sendPausedForAgent } from './agent-send.js'; +import { StanzaRouter, type ResolveVirtualAgentFn } from './stanza-router.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { applyStoreHints, buildOutboundStanza } from './xep-plugins/message.js'; +import { isMucJid } from './xep-plugins/muc.js'; +import { buildTaskEvent, type TaskWireEvent } from './task-stanza-codec.js'; +import { + createComponentSession, + type IqGetHandler, + type IqRequestOptions, + type XmppComponentSession, +} from './xmpp-component.js'; +import { RECEIPTS_NS } from './xep-plugins/receipts.js'; +import { ReceiptTracker } from './receipt-tracker.js'; +import { PING_NS } from './xep-plugins/ping.js'; +import { XmppKeepalive } from './xmpp-keepalive.js'; +import { buildAvailablePresence, buildUnavailablePresence, type VirtualAgentIdentity } from './xep-plugins/presence.js'; + +export interface EmbeddedIqHandlerOptions { + componentJid: string; + protocolNamespaces?: AgentXmppNamespaces; +} + +export interface PresenceSubscription { + agentJid: string; + subscriberJid: string; +} + +export interface PresenceSubscriptionStore { + listPresenceSubscriptions(): PresenceSubscription[]; + setPresenceSubscription(agentJid: string, subscriberJid: string, subscribed: boolean): void; +} + +export type XmppComponentSessionFactory = (config: GatewayConfig, onIqGet?: IqGetHandler) => XmppComponentSession; + +export interface EmbeddedXmppGatewayDependencies { + onIqGet?: IqGetHandler; + resolveVirtualAgent?: ResolveVirtualAgentFn; + presenceStore?: PresenceSubscriptionStore; + componentSessionFactory?: XmppComponentSessionFactory; +} + +function presenceRouteKey(route: PresenceSubscription): string { + return `${bareJid(route.agentJid).toLowerCase()}\u0000${bareJid(route.subscriberJid).toLowerCase()}`; +} + +class InMemoryPresenceSubscriptionStore implements PresenceSubscriptionStore { + private readonly subscriptions = new Map(); + + listPresenceSubscriptions(): PresenceSubscription[] { + return [...this.subscriptions.values()]; + } + + setPresenceSubscription(agentJid: string, subscriberJid: string, subscribed: boolean): void { + const route = { agentJid: bareJid(agentJid), subscriberJid: bareJid(subscriberJid) }; + const key = presenceRouteKey(route); + if (subscribed) this.subscriptions.set(key, route); + else this.subscriptions.delete(key); + } +} + +/** + * Root service identity belongs to the reusable gateway itself. Host handlers + * extend this surface with directory, endpoint, task, and administrative IQs. + */ +export function createEmbeddedIqHandler(options: EmbeddedIqHandlerOptions, downstream?: IqGetHandler): IqGetHandler { + return async (stanza) => { + const to = bareJid(String(stanza.attrs.to ?? '')); + const info = stanza.getChild('query', DISCO_INFO_NS); + if (stanza.attrs.type === 'get' && to === bareJid(options.componentJid) && info && !info.attrs.node) { + return buildGatewayInfo(stanza, options.componentJid, options.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES); + } + return (await downstream?.(stanza)) ?? null; + }; +} + +/** In-process XMPP channel runtime. All agent IO crosses GatewayRuntimeMailbox. */ +export class EmbeddedXmppGateway { + private session: XmppComponentSession | null = null; + private router: StanzaRouter | null = null; + private readonly receipts: ReceiptTracker; + private sweepTimer: ReturnType | null = null; + private keepalive: XmppKeepalive | null = null; + private connectionState: ReturnType = 'offline'; + private readonly presenceStore: PresenceSubscriptionStore; + private readonly publishedPresence = new Map(); + private presenceSync: Promise = Promise.resolve(); + + constructor( + private readonly config: GatewayConfig, + private readonly mailbox: GatewayRuntimeMailbox, + private readonly dependencies: EmbeddedXmppGatewayDependencies = {}, + ) { + this.presenceStore = dependencies.presenceStore ?? new InMemoryPresenceSubscriptionStore(); + this.receipts = new ReceiptTracker({ + timeoutMs: config.receiptTimeoutMs, + maxResends: config.receiptMaxResends, + }); + } + + async start(): Promise { + if (this.session) return; + const createSession = this.dependencies.componentSessionFactory ?? createComponentSession; + const session = createSession(this.config, createEmbeddedIqHandler(this.config, this.dependencies.onIqGet)); + const sendForAgent = async (_agentJid: string, stanza: Element) => session.send(stanza); + const router = new StanzaRouter( + this.config, + this.mailbox, + sendForAgent, + this.dependencies.resolveVirtualAgent, + (id) => this.receipts.ack(id), + (agent, subscriberJid, subscribed) => this.updatePresenceSubscription(agent, subscriberJid, subscribed), + ); + session.onStanza((stanza) => void router.handleIncoming(stanza)); + session.onStateChange((state) => { + const wasOnline = this.connectionState === 'online'; + this.connectionState = state; + if (state === 'online' && !wasOnline) { + this.publishedPresence.clear(); + void this.syncPresence(session); + } + }); + this.session = session; + this.router = router; + await session.start(); + this.connectionState = session.getState(); + this.keepalive = new XmppKeepalive( + { intervalMs: this.config.pingIntervalMs, failureThreshold: this.config.pingFailureThreshold }, + { + getState: () => session.getState(), + getLastActivityAt: () => session.getLastActivityAt(), + ping: async () => { + await session.requestIq( + xml( + 'iq', + { type: 'get', from: this.config.componentJid, to: this.config.serverDomain }, + xml('ping', { xmlns: PING_NS }), + ), + { timeoutMs: this.config.pingTimeoutMs }, + ); + }, + forceReconnect: (reason) => session.forceReconnect(reason), + }, + ); + this.keepalive.start(); + this.sweepTimer = setInterval(() => this.resendUnacked(), this.config.receiptSweepMs); + this.sweepTimer.unref?.(); + } + + async stop(): Promise { + const session = this.session; + this.connectionState = 'stopping'; + if (session) await this.publishUnavailablePresence(session); + this.session = null; + this.router = null; + this.keepalive?.stop(); + this.keepalive = null; + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + // Drop pending receipts so a restart's sweep can't resend this session's stanzas. + this.receipts.clear(); + if (session) await session.stop(); + this.publishedPresence.clear(); + this.connectionState = 'offline'; + } + + private updatePresenceSubscription(agent: VirtualAgentIdentity, subscriberJid: string, subscribed: boolean): void { + const route = { + agentJid: bareJid(agent.jid), + subscriberJid: bareJid(subscriberJid), + }; + this.presenceStore.setPresenceSubscription(route.agentJid, route.subscriberJid, subscribed); + void this.syncPresence(); + } + + syncPresence(session = this.session): Promise { + if (!session || session.getState() !== 'online') return Promise.resolve(); + const run = this.presenceSync + .catch(() => undefined) + .then(() => this.reconcilePresence(session)) + .catch((err) => { + console.error('[xmpp-gateway] presence synchronization failed:', err); + }); + this.presenceSync = run; + return run; + } + + private async reconcilePresence(session: XmppComponentSession): Promise { + const desired = new Map(); + for (const subscription of this.presenceStore.listPresenceSubscriptions()) { + const agent = this.dependencies.resolveVirtualAgent?.(bareJid(subscription.agentJid)); + if (!agent) continue; + const route = { agent, subscriberJid: bareJid(subscription.subscriberJid) }; + desired.set(presenceRouteKey(subscription), route); + } + + for (const [key, route] of this.publishedPresence) { + if (desired.has(key)) continue; + await session.send(buildUnavailablePresence(route.agent, route.subscriberJid)); + this.publishedPresence.delete(key); + } + for (const [key, route] of desired) { + if (this.publishedPresence.has(key)) continue; + await session.send(buildAvailablePresence(route.agent, route.subscriberJid)); + this.publishedPresence.set(key, route); + } + } + + private async publishUnavailablePresence(session: XmppComponentSession): Promise { + for (const route of this.publishedPresence.values()) { + await session.send(buildUnavailablePresence(route.agent, route.subscriberJid)).catch((err) => { + console.error('[xmpp-gateway] unavailable presence send failed:', err); + }); + } + } + + /** + * XEP-0184 sweep. Default is observe-only (receiptMaxResends=0): un-acked messages + * simply expire from tracking, since a missing receipt does not mean the message failed + * and blind resends would duplicate ordinary messages. When an operator opts into + * resends, we retry up to the cap and log the ones that still go unconfirmed. + */ + private resendUnacked(): void { + const session = this.session; + if (!session || !this.isConnected()) return; + const { resend, gaveUp } = this.receipts.due(Date.now()); + for (const stanza of resend) { + void session.send(stanza).catch((err) => { + console.error('[xmpp-gateway] receipt resend failed:', err); + }); + } + // Only noteworthy when resends were actually attempted; observe-only expiry is normal. + if (this.config.receiptMaxResends > 0) { + for (const id of gaveUp) { + console.error( + `[xmpp-gateway] no delivery receipt for ${id} after ${this.config.receiptMaxResends} resends; giving up`, + ); + } + } + } + + isConnected(): boolean { + return this.session !== null && this.connectionState === 'online'; + } + + /** Send an IQ get/set and await its correlated result or error response. */ + requestIq(stanza: Element, options?: IqRequestOptions): Promise { + return this.requiredSession().requestIq(stanza, options); + } + + /** + * The single outbound send path. Any stanza carrying an XEP-0184 is + * registered for receipt tracking *before* the send resolves — otherwise a fast peer's + * could arrive before registration and be dropped, leaving a delivered + * message pending (and, with resends enabled, later duplicated). If the send itself + * fails, the entry is removed. + */ + private async sendTracked(stanza: Element): Promise { + const session = this.requiredSession(); + const id = String(stanza.attrs.id ?? ''); + const track = id !== '' && stanza.getChild('request', RECEIPTS_NS) != null; + if (track) this.receipts.register(id, stanza); + try { + await session.send(stanza); + } catch (err) { + if (track) this.receipts.ack(id); + throw err; + } + return id; + } + + async deliver(input: OutboundDeliverRequest & { from: string }): Promise { + const built = buildOutboundStanza({ ...input, lang: input.lang ?? this.config.xmlLang }, input.from); + // XEP-0334 so an offline 1:1 peer still gets it; MUC messages aren't stored. + const stanza = applyStoreHints(built, built.attrs.type === 'chat' ? { store: true } : undefined); + return this.sendTracked(stanza); + } + + async deliverTaskEvent(event: TaskWireEvent): Promise { + return this.sendTracked(buildTaskEvent(event, this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES)); + } + + async setTyping( + from: string, + to: string, + threadId: string | null, + state: 'composing' | 'paused' | 'inactive', + ): Promise { + const session = this.requiredSession(); + const targets = { to, threadId, groupchat: isMucJid(to) }; + const send = (stanza: Element) => session.send(stanza); + if (state === 'inactive') await sendInactiveForAgent(send, from, targets); + else if (state === 'paused') await sendPausedForAgent(send, from, targets); + else await sendComposingForAgent(send, from, targets); + } + + private requiredSession(): XmppComponentSession { + if (!this.session) throw new Error('XMPP gateway is not connected'); + return this.session; + } +} + +export type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +export { xml, type Element } from '@xmpp/xml'; +export { IqResponseError } from './xmpp-component.js'; +export type { IqRequestOptions } from './xmpp-component.js'; +export * from './agent-api-disco.js'; +export * from './task-stanza-codec.js'; +export * from './hash-codec.js'; +export * from './json-codec.js'; +export * from './rsm-codec.js'; +export * from './protocol-error.js'; +export * from './xep-plugins/ping.js'; +export * from './xep-plugins/presence.js'; +export * from './xep-plugins/search.js'; +export * from './xep-plugins/vcard.js'; diff --git a/packages/agent-xmpp/gateway/src/hash-codec.ts b/packages/agent-xmpp/gateway/src/hash-codec.ts new file mode 100644 index 000000000..016e2598b --- /dev/null +++ b/packages/agent-xmpp/gateway/src/hash-codec.ts @@ -0,0 +1,22 @@ +import { HASHES_NS } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export interface Sha256Hash { + algorithm: 'sha-256'; + value: string; +} + +export function buildHash(value: string): Element { + return xml('hash', { xmlns: HASHES_NS, algo: 'sha-256' }, value); +} + +export function parseHash(parent: Element, wrapper?: string): Sha256Hash { + const container = wrapper ? parent.getChild(wrapper) : parent; + const hash = container?.getChild('hash', HASHES_NS); + if (!hash || hash.attrs.algo !== 'sha-256') throw new Error('a sha-256 XEP-0300 hash is required'); + const value = hash.getText(); + if (!/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/.test(value)) { + throw new Error('invalid SHA-256 Base64 hash'); + } + return { algorithm: 'sha-256', value }; +} diff --git a/packages/agent-xmpp/gateway/src/json-codec.ts b/packages/agent-xmpp/gateway/src/json-codec.ts new file mode 100644 index 000000000..4c6e62b92 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/json-codec.ts @@ -0,0 +1,12 @@ +import { JSON_MEDIA_TYPE, parseStrictJson } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export function parseJsonElement(element: Element, maxBytes = 1_048_576): unknown { + const mediaType = String(element.attrs['media-type'] ?? ''); + if (mediaType && mediaType !== JSON_MEDIA_TYPE) throw new Error(`unsupported JSON media type: ${mediaType}`); + return parseStrictJson(element.getText(), { maxBytes }); +} + +export function jsonElement(name: string, namespace: string, canonicalJson: string): Element { + return xml(name, { xmlns: namespace, 'media-type': JSON_MEDIA_TYPE }, canonicalJson); +} diff --git a/packages/agent-xmpp/gateway/src/protocol-error.ts b/packages/agent-xmpp/gateway/src/protocol-error.ts new file mode 100644 index 000000000..b8a52d342 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/protocol-error.ts @@ -0,0 +1,66 @@ +import { xml, type Element } from '@xmpp/xml'; + +export type StanzaErrorCondition = + | 'bad-request' + | 'forbidden' + | 'item-not-found' + | 'not-acceptable' + | 'conflict' + | 'resource-constraint' + | 'service-unavailable' + | 'unexpected-request' + | 'internal-server-error'; + +const STANZA_ERRORS_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas'; + +export class ProtocolError extends Error { + constructor( + readonly condition: StanzaErrorCondition, + message: string, + readonly type: 'cancel' | 'modify' | 'auth' | 'wait' = stanzaErrorType(condition), + ) { + super(message); + } +} + +export function protocolErrorIq(request: Element, error: unknown): Element { + const protocolError = + error instanceof ProtocolError ? error : new ProtocolError('internal-server-error', 'Request processing failed'); + const echoRequestPayload = + protocolError.condition !== 'bad-request' && + protocolError.condition !== 'resource-constraint' && + protocolError.condition !== 'internal-server-error'; + return xml( + 'iq', + { + type: 'error', + id: request.attrs.id, + from: request.attrs.to, + to: request.attrs.from, + }, + ...(echoRequestPayload ? request.children : []), + xml( + 'error', + { type: protocolError.type }, + xml(protocolError.condition, { xmlns: STANZA_ERRORS_NS }), + xml('text', { xmlns: STANZA_ERRORS_NS }, safeMessage(protocolError)), + ), + ); +} + +export function hiddenObjectError(): ProtocolError { + return new ProtocolError('item-not-found', 'The requested object was not found'); +} + +function safeMessage(error: ProtocolError): string { + if (error.condition === 'item-not-found') return 'The requested object was not found'; + if (error.condition === 'internal-server-error') return 'Request processing failed'; + return error.message.slice(0, 512); +} + +function stanzaErrorType(condition: StanzaErrorCondition): 'cancel' | 'modify' | 'auth' | 'wait' { + if (condition === 'bad-request' || condition === 'not-acceptable') return 'modify'; + if (condition === 'forbidden') return 'auth'; + if (condition === 'resource-constraint' || condition === 'service-unavailable') return 'wait'; + return 'cancel'; +} diff --git a/packages/agent-xmpp/gateway/src/receipt-tracker.ts b/packages/agent-xmpp/gateway/src/receipt-tracker.ts new file mode 100644 index 000000000..917e83abe --- /dev/null +++ b/packages/agent-xmpp/gateway/src/receipt-tracker.ts @@ -0,0 +1,79 @@ +/** + * XEP-0184 outbound delivery-receipt tracking. + * + * A component send only tells us the server accepted the stanza, not that the peer + * received it. For 1:1 messages that carry a , we register the stanza here; + * when the peer returns we `ack` it, and messages left un-acked past the + * timeout are handed back by `due` for a bounded number of resends. Resends reuse the + * same stanza (same id + origin-id), so conformant peers dedup per XEP-0184 §8. + * + * Pure and synchronous — no timers, no IO — so it unit-tests without a live connection. + * + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from '@xmpp/xml'; + +interface PendingReceipt { + stanza: Element; + sentAt: number; + attempts: number; +} + +export interface ReceiptTrackerOptions { + timeoutMs: number; + maxResends: number; +} + +/** What a sweep produced: stanzas to resend now, and ids we've given up on. */ +export interface ReceiptSweep { + resend: Element[]; + gaveUp: string[]; +} + +export class ReceiptTracker { + private readonly pending = new Map(); + + constructor(private readonly options: ReceiptTrackerOptions) {} + + /** Record a receipt-requested send keyed by its stanza id. */ + register(id: string, stanza: Element, now: number = Date.now()): void { + if (!id) return; + this.pending.set(id, { stanza, sentAt: now, attempts: 0 }); + } + + /** Peer confirmed delivery of `id`; stop tracking it. */ + ack(id: string): void { + this.pending.delete(id); + } + + /** Drop all pending state — called on gateway stop so a restart can't resend a prior session's stanzas. */ + clear(): void { + this.pending.clear(); + } + + /** + * Entries whose timeout has elapsed: each still under the resend cap is re-armed and + * returned in `resend`; each at the cap is dropped and returned in `gaveUp`. + */ + due(now: number = Date.now()): ReceiptSweep { + const resend: Element[] = []; + const gaveUp: string[] = []; + for (const [id, entry] of this.pending) { + if (now - entry.sentAt < this.options.timeoutMs) continue; + if (entry.attempts >= this.options.maxResends) { + this.pending.delete(id); + gaveUp.push(id); + continue; + } + entry.attempts += 1; + entry.sentAt = now; + resend.push(entry.stanza); + } + return { resend, gaveUp }; + } + + /** Number of messages still awaiting a receipt (for tests / diagnostics). */ + get size(): number { + return this.pending.size; + } +} diff --git a/packages/agent-xmpp/gateway/src/review-fixes.test.ts b/packages/agent-xmpp/gateway/src/review-fixes.test.ts new file mode 100644 index 000000000..fd92d3166 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/review-fixes.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'bun:test'; +import { AGENT_API_NS, AGENT_TASK_NS } from '@agent-xmpp/protocol'; +import { xml } from '@xmpp/xml'; + +import { parseManifestRegistration } from './agent-api-disco.js'; +import { ProtocolError } from './protocol-error.js'; +import { buildAcceptedResult, parseAcceptedResult, parseTaskEvent, parseTaskResult } from './task-stanza-codec.js'; + +const requestId = 'request-identifier-0001'; +const taskId = 'task-identifier-000001'; +const eventId = 'event-identifier-00001'; + +describe('review regressions', () => { + it('preserves the current revision in replay acceptance results', () => { + const request = xml('iq', { + type: 'set', + id: 'invoke', + from: 'caller@example.test', + }); + const response = buildAcceptedResult( + request, + { + requestId, + taskId, + revision: 4, + created: '2026-08-27T18:00:00.000Z', + retainUntil: '2026-08-28T18:00:00.000Z', + }, + 'assistant@agents.example.test', + ); + + expect(parseAcceptedResult(response)?.revision).toBe(4); + }); + + it('rejects invalid and foreign registration manifests', () => { + const invalid = xml( + 'iq', + { type: 'set' }, + xml( + 'register', + { xmlns: AGENT_API_NS }, + xml('manifest', { xmlns: AGENT_API_NS, 'media-type': 'application/json' }, '{}'), + ), + ); + const foreign = xml( + 'iq', + { type: 'set' }, + xml( + 'register', + { xmlns: AGENT_API_NS }, + xml('manifest', { xmlns: 'urn:example:foreign', 'media-type': 'application/json' }, '{}'), + ), + ); + + expect(() => parseManifestRegistration(invalid)).toThrow(ProtocolError); + expect(parseManifestRegistration(foreign)).toBeNull(); + }); + + it('rejects non-object task result and event payloads', () => { + const result = xml( + 'iq', + { type: 'result' }, + xml( + 'task-result', + { + xmlns: AGENT_TASK_NS, + 'task-id': taskId, + state: 'completed', + revision: '1', + 'media-type': 'application/json', + }, + 'null', + ), + ); + const event = xml( + 'message', + { + type: 'normal', + from: 'assistant@agents.example.test', + to: 'caller@example.test', + }, + xml( + 'event', + { + xmlns: AGENT_TASK_NS, + 'task-id': taskId, + 'event-id': eventId, + revision: '1', + type: 'status', + }, + '[]', + ), + ); + + expect(() => parseTaskResult(result)).toThrow('invalid task-result payload'); + expect(() => parseTaskEvent(event)).toThrow('invalid task event payload'); + }); +}); diff --git a/packages/agent-xmpp/gateway/src/rsm-codec.ts b/packages/agent-xmpp/gateway/src/rsm-codec.ts new file mode 100644 index 000000000..5d2f0b89b --- /dev/null +++ b/packages/agent-xmpp/gateway/src/rsm-codec.ts @@ -0,0 +1,66 @@ +import { RSM_NS } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export interface RsmRequest { + max: number; + after?: string; + before?: string; +} + +export interface RsmPage { + items: T[]; + first?: string; + last?: string; + count: number; +} + +type RsmOrderKey = string | Uint8Array; + +export function parseRsm(parent: Element, defaultMax = 100, maximum = 100): RsmRequest { + const set = parent.getChild('set', RSM_NS); + const requested = Number(set?.getChildText('max') ?? defaultMax); + const max = Number.isInteger(requested) && requested >= 0 ? Math.min(requested, maximum) : defaultMax; + return { + max, + after: set?.getChildText('after') ?? undefined, + before: set?.getChildText('before') ?? undefined, + }; +} + +export function pageRsm( + items: T[], + id: (item: T) => string, + request: RsmRequest, + orderKey: (item: T) => RsmOrderKey = id, +): RsmPage { + const ordered = [...items].sort((left, right) => + Buffer.compare(Buffer.from(orderKey(left)), Buffer.from(orderKey(right))), + ); + let start = request.after ? ordered.findIndex((item) => id(item) === request.after) + 1 : 0; + if (request.after && start === 0) start = ordered.length; + let end = ordered.length; + if (request.before !== undefined) { + const before = request.before === '' ? ordered.length : ordered.findIndex((item) => id(item) === request.before); + end = before < 0 ? 0 : before; + start = Math.max(0, end - request.max); + } else { + end = Math.min(end, start + request.max); + } + const page = ordered.slice(start, end); + return { + items: page, + first: page[0] ? id(page[0]) : undefined, + last: page.at(-1) ? id(page.at(-1)!) : undefined, + count: ordered.length, + }; +} + +export function buildRsm(page: RsmPage): Element { + return xml( + 'set', + { xmlns: RSM_NS }, + ...(page.first ? [xml('first', {}, page.first)] : []), + ...(page.last ? [xml('last', {}, page.last)] : []), + xml('count', {}, String(page.count)), + ); +} diff --git a/packages/agent-xmpp/gateway/src/runtime-mailbox.ts b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts new file mode 100644 index 000000000..662e6c2de --- /dev/null +++ b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts @@ -0,0 +1,15 @@ +import type { BridgeFormResponsePayload, BridgeInboundPayload } from '@agent-xmpp/protocol'; +import type { TaskWireEvent } from './task-stanza-codec.js'; + +/** + * Last-mile transport between the XMPP gateway and an agent runtime. + * + * NanoClaw implements this with per-session inbound.db writes. The interface + * intentionally contains no HTTP or provider concepts so another mailbox can + * replace it later without changing XMPP routing. + */ +export interface GatewayRuntimeMailbox { + deliverInbound(payload: BridgeInboundPayload): Promise; + deliverFormResponse(payload: BridgeFormResponsePayload): Promise; + deliverTaskEvent(event: TaskWireEvent): Promise; +} diff --git a/packages/agent-xmpp/gateway/src/stanza-router.ts b/packages/agent-xmpp/gateway/src/stanza-router.ts new file mode 100644 index 000000000..a7f190ea1 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/stanza-router.ts @@ -0,0 +1,163 @@ +/** + * Central inbound stanza dispatch for the component. Routes by stanza kind and spec: + * presence -> RFC 6121 §3 roster/probe handling (presence.ts) + * ask-question submit -> XEP-0004 Data Forms (data-form.ts) + * agent-task payloads -> configured gateway-private task namespace (task-stanza-codec.ts) + * XEP-0085/0184/0333 -> chat states & receipts are swallowed, not delivered (receipts.ts) + * message -> normalized to AgentMessage (message.ts), then delivery-gated + * + * On accepted 1:1 messages the router emits an XEP-0085 composing state and, when the + * sender opted in with an XEP-0184 , a delivery receipt. + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + * @see https://xmpp.org/extensions/xep-0085.html + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from '@xmpp/xml'; + +import { DEFAULT_PROTOCOL_NAMESPACES, type AgentMessage } from '@agent-xmpp/protocol'; + +import { sendComposingForAgent } from './agent-send.js'; +import { bareJid } from './xep-plugins/jid.js'; +import type { GatewayConfig } from './config.js'; +import { + pushFormResponseToBridge, + pushInboundToBridge, + resolveInboundChatTargets, + shouldAcceptStanza, + type InboundDeliveryContext, +} from './delivery.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { isAgentJid, resolveTargetAgentJid, stanzaToAgentMessage } from './xep-plugins/message.js'; +import { parseAskQuestionSubmit } from './xep-plugins/data-form.js'; +import { + buildReceivedReceipt, + isAckOrReceiptStanza, + receivedReceiptId, + requestsReceipt, +} from './xep-plugins/receipts.js'; +import { parseTaskEvent } from './task-stanza-codec.js'; +import { handleVirtualAgentPresence, type VirtualAgentIdentity } from './xep-plugins/presence.js'; + +export type SendStanzaFn = (stanza: Element) => Promise; +export type SendForAgentFn = (agentJid: string, stanza: Element) => Promise; +export type ResolveVirtualAgentFn = (jid: string) => VirtualAgentIdentity | null; +export type UpdatePresenceSubscriptionFn = ( + agent: VirtualAgentIdentity, + subscriberJid: string, + subscribed: boolean, +) => void; + +export class StanzaRouter { + constructor( + private config: GatewayConfig, + private mailbox: GatewayRuntimeMailbox, + private sendForAgent: SendForAgentFn, + private resolveVirtualAgent?: ResolveVirtualAgentFn, + private onReceipt?: (ackedId: string) => void, + private updatePresenceSubscription?: UpdatePresenceSubscriptionFn, + ) {} + + async handleIncoming(stanza: Element): Promise { + if (stanza.name === 'presence') { + const to = bareJid(String(stanza.attrs.to ?? '')); + const agent = this.resolveVirtualAgent?.(to); + if (agent) { + const result = handleVirtualAgentPresence(stanza, agent); + const change = result.subscriptionChange; + if (change) this.updatePresenceSubscription?.(agent, change.subscriberJid, change.subscribed); + for (const response of result.responses) { + await this.sendForAgent(agent.jid, response); + } + } + return; + } + if (stanza.name !== 'message') return; + + const toBare = bareJid(String(stanza.attrs.to ?? '')); + // Stanzas arrive on the component JID; resolve which registered agent they target. + const agentJid = resolveTargetAgentJid(toBare, this.config.agentDomain, this.config.defaultAgentJid); + + if (!isAgentJid(agentJid, this.config.agentDomain) && agentJid !== this.config.defaultAgentJid) { + return; + } + + const from = stanza.attrs.from as string; + const fromBare = bareJid(from); + const agentBare = bareJid(agentJid); + // C2S inbox receives agent self-sent stanzas (outbound loopback) — drop them. + if (fromBare && agentBare && fromBare === agentBare) return; + const namespaces = this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES; + if (stanza.getChildren('event', namespaces.task).length > 0) { + try { + const taskEvent = parseTaskEvent(stanza, namespaces); + if (taskEvent) await this.mailbox.deliverTaskEvent(taskEvent); + } catch (err) { + console.error('[xmpp-gateway] invalid task lifecycle event:', err instanceof Error ? err.message : err); + } + return; + } + + const formSubmit = parseAskQuestionSubmit(stanza); + if (formSubmit) { + const type = (stanza.attrs.type as string) || 'chat'; + await pushFormResponseToBridge(this.config, this.mailbox, { + agentJid, + from, + stanzaType: type, + questionId: formSubmit.questionId, + selectedIndex: formSubmit.selectedIndex, + }); + return; + } + + if (isAckOrReceiptStanza(stanza)) { + // XEP-0184: a peer's confirms one of our outbound messages. + const acked = receivedReceiptId(stanza); + if (acked) this.onReceipt?.(acked); + return; + } + const agentMsg = stanzaToAgentMessage(stanza, this.config.agentDomain); + if (!agentMsg) return; + + const type = (stanza.attrs.type as string) || 'chat'; + const agentNick = agentJid.split('@')[0]; + const bodyText = typeof agentMsg.body === 'string' ? agentMsg.body : JSON.stringify(agentMsg.body); + + if (!shouldAcceptStanza(type, from, bodyText, agentNick)) return; + + const stanzaId = agentMsg.id; + + const ctx: InboundDeliveryContext = { + agentMsg, + agentJid, + deliveryId: stanzaId, + stanzaType: type, + from, + redelivered: false, + }; + + void sendComposingForAgent( + (stanza) => this.sendForAgent(agentJid, stanza), + agentJid, + resolveInboundChatTargets(from, type, agentMsg), + ).catch((err) => { + console.error('[xmpp-gateway] composing notification send failed:', err); + }); + + try { + await pushInboundToBridge(this.config, this.mailbox, ctx); + } catch (err) { + console.error('[xmpp-gateway] inbound delivery failed:', err instanceof Error ? err.message : err); + return; + } + + // XEP-0184: ack only 1:1 messages that explicitly requested a receipt. + // Groupchat receipts are not used (§5.5) and unsolicited ones spam the sender. + if (from && type === 'chat' && requestsReceipt(stanza)) { + await this.sendForAgent(agentJid, buildReceivedReceipt(from, agentJid, stanzaId)).catch((err) => { + console.error('[xmpp-gateway] received receipt send failed:', err); + }); + } + } +} diff --git a/packages/agent-xmpp/gateway/src/task-stanza-codec.ts b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts new file mode 100644 index 000000000..8033f2186 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts @@ -0,0 +1,630 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + bareJid, + isApiVersion, + isNormalizedEndpointJid, + isOpaqueIdentifier, + isToolName, + isXep0082DateTime, + parseStrictJson, + taskEventTypes, + taskStates, + terminalTaskStates, + type AgentTaskEventType, + type AgentTaskRecord, + type AgentTaskState, + type AgentXmppNamespaces, + type McpToolResult, + type PendingTaskInput, +} from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildHash, parseHash } from './hash-codec.js'; + +export interface ParsedTaskInvocation { + requestId: string; + tool: string; + apiVersion: string; + manifestHash: string; + callerJid: string; + notificationJid: string; + toJid: string; + arguments: unknown; + deadline?: string; +} + +export interface ParsedTaskRecoveryRequest { + kind: 'state' | 'result'; + taskId: string; +} + +export interface ParsedTaskCancellation { + taskId: string; + expectedRevision: number; + reason?: string; +} + +export interface ParsedTaskInput { + taskId: string; + requestId: string; + expectedRevision: number; + input: unknown; +} + +export interface TaskWireEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + from: string; + to: string; + payload: Record; +} + +export interface AcceptedTask { + requestId: string; + taskId: string; + revision: number; + created: string; + retainUntil: string; +} + +export interface TaskStateSnapshot { + taskId: string; + endpoint: string; + state: AgentTaskState; + revision: number; + apiVersion: string; + manifestHash: string; + created: string; + updated: string; + retainUntil: string; + deadline?: string; + resultAvailable: boolean; + pendingInput?: PendingTaskInput; +} + +export interface TaskResultSnapshot { + taskId: string; + state: Extract; + revision: number; + result?: McpToolResult; + error?: { code: string; message: string; retryable: boolean; details?: Record }; + summary?: string; +} + +export function parseTaskInvocation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInvocation | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'set') return null; + const payloads = stanza.getChildElements(); + const invoke = stanza.getChild('invoke', namespaces.task); + if (!invoke) return null; + if (payloads.length !== 1 || payloads[0] !== invoke) { + throw new Error('invoke must be the only IQ payload'); + } + assertOnlyAttributes(invoke, ['xmlns', 'request-id', 'tool', 'api-version']); + const children = invoke.getChildElements(); + const expectedNames = + children.length === 3 ? ['manifest-hash', 'arguments', 'deadline'] : ['manifest-hash', 'arguments']; + if ( + children.length < 2 || + children.length > 3 || + children.some((child, index) => child.name !== expectedNames[index] || child.getNS() !== namespaces.task) + ) { + throw new Error('invoke children must be manifest-hash, arguments, then optional deadline'); + } + const [manifestHashElement, argumentsElement, deadlineElement] = children; + assertOnlyAttributes(manifestHashElement!, ['xmlns']); + assertOnlyAttributes(argumentsElement!, ['xmlns', 'media-type']); + if (deadlineElement) assertOnlyAttributes(deadlineElement, ['xmlns']); + const hashChildren = manifestHashElement!.getChildElements(); + if (hashChildren.length !== 1 || hashChildren[0]!.name !== 'hash' || hashChildren[0]!.getNS() !== namespaces.hashes) { + throw new Error('manifest-hash must contain exactly one XEP-0300 hash'); + } + assertOnlyAttributes(hashChildren[0]!, ['xmlns', 'algo']); + if (argumentsElement!.getChildElements().length > 0 || (deadlineElement?.getChildElements().length ?? 0) > 0) { + throw new Error('arguments and deadline must contain character data only'); + } + const requestId = String(invoke.attrs['request-id'] ?? ''); + const tool = String(invoke.attrs.tool ?? ''); + const apiVersion = String(invoke.attrs['api-version'] ?? ''); + const targetJid = String(stanza.attrs.to ?? ''); + const deadline = deadlineElement?.getText(); + if ( + !isOpaqueIdentifier(requestId) || + !isToolName(tool) || + !isApiVersion(apiVersion) || + !isNormalizedEndpointJid(targetJid) || + (deadline !== undefined && !isXep0082DateTime(deadline)) || + argumentsElement!.attrs['media-type'] !== JSON_MEDIA_TYPE + ) { + throw new Error('invoke is missing required attributes or JSON arguments'); + } + return { + requestId, + tool, + apiVersion, + manifestHash: parseHash(invoke, 'manifest-hash').value, + callerJid: bareJid(String(stanza.attrs.from ?? '')), + notificationJid: String(stanza.attrs.from ?? ''), + toJid: targetJid, + arguments: parseStrictJson(argumentsElement!.getText()), + deadline, + }; +} + +export function parseTaskRecoveryRequest( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskRecoveryRequest | null { + const state = stanza.getChild('task-state-request', namespaces.task); + const result = stanza.getChild('task-result-request', namespaces.task); + const payload = state ?? result; + if (!payload) return null; + assertIqRequest(stanza, payload, 'get'); + assertOnlyAttributes(payload, ['xmlns', 'task-id']); + assertEmptyElement(payload); + const taskId = String(payload.attrs['task-id'] ?? ''); + if (!isOpaqueId(taskId)) throw new Error('invalid task recovery identifier'); + return { kind: state ? 'state' : 'result', taskId }; +} + +export function parseTaskCancellation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskCancellation | null { + const cancel = stanza.getChild('cancel', namespaces.task); + if (!cancel) return null; + assertIqRequest(stanza, cancel, 'set'); + assertOnlyAttributes(cancel, ['xmlns', 'task-id', 'expected-revision']); + const children = cancel.getChildElements(); + if (children.length > 1 || children.some((child) => child.name !== 'reason' || child.getNS() !== namespaces.task)) { + throw new Error('cancel may contain only one reason'); + } + if (cancel.children.some((child) => typeof child === 'string' && child.trim() !== '')) { + throw new Error('cancel may not contain direct character data'); + } + const reason = children[0]; + if (reason) { + assertOnlyAttributes(reason, ['xmlns']); + if (reason.getChildElements().length > 0) throw new Error('reason must contain character data only'); + } + const taskId = String(cancel.attrs['task-id'] ?? ''); + const expectedRevision = parseNonNegativeInteger(cancel.attrs['expected-revision']); + if (!isOpaqueId(taskId)) throw new Error('invalid cancellation identifier'); + return { taskId, expectedRevision, ...(reason ? { reason: reason.getText() } : {}) }; +} + +export function parseTaskInput( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInput | null { + const provide = stanza.getChild('provide-input', namespaces.task); + if (!provide) return null; + assertIqRequest(stanza, provide, 'set'); + assertOnlyAttributes(provide, ['xmlns', 'task-id', 'request-id', 'expected-revision']); + const children = provide.getChildElements(); + if ( + children.length !== 1 || + children[0]!.name !== 'input' || + children[0]!.getNS() !== namespaces.task || + provide.children.some((child) => typeof child === 'string' && child.trim() !== '') + ) { + throw new Error('provide-input must contain exactly one input'); + } + const input = children[0]!; + assertOnlyAttributes(input, ['xmlns', 'media-type']); + if (input.attrs['media-type'] !== JSON_MEDIA_TYPE || input.getChildElements().length > 0) { + throw new Error('input must contain JSON character data'); + } + const taskId = String(provide.attrs['task-id'] ?? ''); + const requestId = String(provide.attrs['request-id'] ?? ''); + const expectedRevision = parseNonNegativeInteger(provide.attrs['expected-revision']); + if (!isOpaqueId(taskId) || !isOpaqueId(requestId)) throw new Error('invalid task input identifier'); + return { taskId, requestId, expectedRevision, input: parseStrictJson(input.getText()) }; +} + +export function buildTaskInvocation( + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `invoke-${task.requestId}`, + }, + xml( + 'invoke', + { + xmlns: namespaces.task, + 'request-id': task.requestId, + tool: task.tool, + 'api-version': task.apiVersion, + }, + xml('manifest-hash', {}, buildHash(task.manifestHash)), + xml('arguments', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(task.arguments)), + ...(task.deadline ? [xml('deadline', {}, task.deadline)] : []), + ), + ); +} + +export function buildAcceptedResult( + request: Element, + accepted: AcceptedTask, + from: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from, to: request.attrs.from }, + xml('accepted', { + xmlns: namespaces.task, + 'request-id': accepted.requestId, + 'task-id': accepted.taskId, + revision: String(accepted.revision), + created: accepted.created, + 'retain-until': accepted.retainUntil, + }), + ); +} + +export function parseAcceptedResult( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AcceptedTask | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const accepted = stanza.getChild('accepted', namespaces.task); + if (!accepted) return null; + const revision = Number(accepted.attrs.revision); + if (!Number.isSafeInteger(revision) || revision < 0) throw new Error('accepted task has invalid revision'); + const parsed: AcceptedTask = { + requestId: String(accepted.attrs['request-id'] ?? ''), + taskId: String(accepted.attrs['task-id'] ?? ''), + revision, + created: String(accepted.attrs.created ?? ''), + retainUntil: String(accepted.attrs['retain-until'] ?? ''), + }; + if ( + !parsed.requestId || + !parsed.taskId || + !isXep0082DateTime(parsed.created) || + !isXep0082DateTime(parsed.retainUntil) + ) { + throw new Error('accepted task is missing required attributes'); + } + if (!isOpaqueId(parsed.requestId) || !isOpaqueId(parsed.taskId)) { + throw new Error('accepted task contains an invalid opaque identifier'); + } + return parsed; +} + +export function buildTaskStateResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: task.targetJid, to: request.attrs.from }, + xml( + 'task-state', + { + xmlns: namespaces.task, + 'task-id': task.taskId, + endpoint: task.targetJid, + state: task.state, + revision: String(task.revision), + 'api-version': task.apiVersion, + created: task.createdAt, + updated: task.updatedAt, + 'retain-until': task.retainUntil, + 'result-available': terminalTaskStates.has(task.state) ? 'true' : 'false', + ...(task.deadline ? { deadline: task.deadline } : {}), + }, + xml('manifest-hash', {}, xml('hash', { xmlns: namespaces.hashes, algo: 'sha-256' }, task.manifestHash)), + ...(task.pendingInput + ? [xml('pending-input', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(task.pendingInput))] + : []), + ), + ); +} + +export function buildTaskResultResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const payload = + task.state === 'completed' + ? { result: task.result, ...(task.summary ? { summary: task.summary } : {}) } + : task.state === 'failed' + ? { error: task.error } + : {}; + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: task.targetJid, to: request.attrs.from }, + xml( + 'task-result', + { + xmlns: namespaces.task, + 'task-id': task.taskId, + state: task.state, + revision: String(task.revision), + 'media-type': JSON_MEDIA_TYPE, + }, + JSON.stringify(payload), + ), + ); +} + +export function buildTaskStateRequest(task: AgentTaskRecord, remoteTaskId: string): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'get', + id: `state-${task.taskId}-${task.revision}`, + }, + xml('task-state-request', { xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, 'task-id': remoteTaskId }), + ); +} + +export function buildTaskResultRequest(task: AgentTaskRecord, remoteTaskId: string): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'get', + id: `result-${task.taskId}-${task.revision}`, + }, + xml('task-result-request', { xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, 'task-id': remoteTaskId }), + ); +} + +export function buildTaskCancellation( + task: AgentTaskRecord, + remoteTaskId: string, + expectedRevision: number, + reason?: string, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `cancel-${task.taskId}-${expectedRevision}`, + }, + xml( + 'cancel', + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + 'task-id': remoteTaskId, + 'expected-revision': String(expectedRevision), + }, + ...(reason !== undefined ? [xml('reason', {}, reason)] : []), + ), + ); +} + +export function buildTaskInput( + task: AgentTaskRecord, + remoteTaskId: string, + requestId: string, + expectedRevision: number, + input: unknown, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `input-${task.taskId}-${expectedRevision}`, + }, + xml( + 'provide-input', + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + 'task-id': remoteTaskId, + 'request-id': requestId, + 'expected-revision': String(expectedRevision), + }, + xml('input', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(input)), + ), + ); +} + +export function parseTaskActionResult( + stanza: Element, + name: 'cancel-accepted' | 'input-accepted', +): { taskId: string; revision: number } | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const accepted = stanza.getChild(name, DEFAULT_PROTOCOL_NAMESPACES.task); + if (!accepted) return null; + const taskId = String(accepted.attrs['task-id'] ?? ''); + const revision = Number(accepted.attrs.revision); + if (!isOpaqueId(taskId) || !Number.isSafeInteger(revision) || revision < 1) { + throw new Error(`invalid ${name} result`); + } + return { taskId, revision }; +} + +export function parseTaskStateResult(stanza: Element): TaskStateSnapshot | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const state = stanza.getChild('task-state', DEFAULT_PROTOCOL_NAMESPACES.task); + if (!state) return null; + const taskState = String(state.attrs.state ?? '') as AgentTaskState; + const taskId = String(state.attrs['task-id'] ?? ''); + const revision = Number(state.attrs.revision); + const endpoint = String(state.attrs.endpoint ?? ''); + const apiVersion = String(state.attrs['api-version'] ?? ''); + const created = String(state.attrs.created ?? ''); + const updated = String(state.attrs.updated ?? ''); + const retainUntil = String(state.attrs['retain-until'] ?? ''); + if ( + !isOpaqueId(taskId) || + !taskStates.includes(taskState) || + !Number.isSafeInteger(revision) || + revision < 0 || + !isNormalizedEndpointJid(endpoint) || + !isApiVersion(apiVersion) || + !isXep0082DateTime(created) || + !isXep0082DateTime(updated) || + !isXep0082DateTime(retainUntil) || + (state.attrs.deadline !== undefined && !isXep0082DateTime(String(state.attrs.deadline))) + ) { + throw new Error('invalid task-state result'); + } + const pending = state.getChild('pending-input'); + if (pending && pending.attrs['media-type'] !== JSON_MEDIA_TYPE) { + throw new Error('pending task input must use application/json'); + } + return { + taskId, + endpoint, + state: taskState, + revision, + apiVersion, + manifestHash: parseHash(state, 'manifest-hash').value, + created, + updated, + retainUntil, + deadline: state.attrs.deadline ? String(state.attrs.deadline) : undefined, + resultAvailable: state.attrs['result-available'] === 'true', + pendingInput: pending ? (parseStrictJson(pending.getText()) as PendingTaskInput) : undefined, + }; +} + +export function parseTaskResult(stanza: Element): TaskResultSnapshot | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const result = stanza.getChild('task-result', DEFAULT_PROTOCOL_NAMESPACES.task); + if (!result) return null; + const taskId = String(result.attrs['task-id'] ?? ''); + const state = String(result.attrs.state ?? '') as TaskResultSnapshot['state']; + const revision = Number(result.attrs.revision); + if ( + !isOpaqueId(taskId) || + !terminalTaskStates.has(state) || + !Number.isSafeInteger(revision) || + revision < 1 || + result.attrs['media-type'] !== JSON_MEDIA_TYPE + ) { + throw new Error('invalid task-result'); + } + const payload = parseTaskPayload(result.getText() || '{}', 'invalid task-result payload'); + return { + taskId, + state, + revision, + result: payload.result as McpToolResult | undefined, + error: payload.error as TaskResultSnapshot['error'], + summary: typeof payload.summary === 'string' ? payload.summary : undefined, + }; +} + +export function parseTaskEvent( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): TaskWireEvent | null { + if (stanza.name !== 'message') return null; + const events = stanza.getChildren('event', namespaces.task); + if (events.length === 0) return null; + if (events.length !== 1) throw new Error('task event message must contain exactly one event'); + const messageType = String(stanza.attrs.type ?? 'normal'); + if (messageType !== 'normal') throw new Error('invalid task event message type'); + const event = events[0]!; + assertOnlyAttributes(event, ['xmlns', 'task-id', 'event-id', 'revision', 'type']); + if (event.getChildElements().length > 0) throw new Error('task event payload must contain character data only'); + const type = String(event.attrs.type ?? '') as AgentTaskEventType; + if (!taskEventTypes.includes(type)) { + throw new Error('unknown task event type'); + } + const revision = Number(event.attrs.revision); + if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('invalid task event revision'); + const taskId = String(event.attrs['task-id'] ?? ''); + const eventId = String(event.attrs['event-id'] ?? ''); + if (!isOpaqueId(taskId) || !isOpaqueId(eventId)) throw new Error('invalid task event identifier'); + return { + taskId, + eventId, + revision, + type, + from: String(stanza.attrs.from ?? ''), + to: String(stanza.attrs.to ?? ''), + payload: parseTaskPayload(event.getText() || '{}', 'invalid task event payload'), + }; +} + +export function buildTaskEvent( + event: TaskWireEvent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'message', + { from: event.from, to: event.to, type: 'normal', id: event.eventId }, + xml( + 'event', + { + xmlns: namespaces.task, + 'task-id': event.taskId, + 'event-id': event.eventId, + revision: String(event.revision), + type: event.type, + }, + JSON.stringify(event.payload), + ), + ); +} + +export function isOpaqueId(value: string): boolean { + return isOpaqueIdentifier(value); +} + +function assertIqRequest(stanza: Element, payload: Element, type: 'get' | 'set'): void { + if ( + stanza.name !== 'iq' || + stanza.attrs.type !== type || + stanza.getChildElements().length !== 1 || + stanza.getChildElements()[0] !== payload + ) { + throw new Error(`${payload.name} must be the only payload of an IQ ${type}`); + } +} + +function assertEmptyElement(element: Element): void { + if ( + element.getChildElements().length > 0 || + element.children.some((child) => typeof child === 'string' && child.trim() !== '') + ) { + throw new Error(`${element.name} must be empty`); + } +} + +function parseNonNegativeInteger(value: unknown): number { + if (typeof value !== 'string' || !/^\+?\d+$/.test(value)) throw new Error('missing non-negative integer'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error('invalid non-negative integer'); + return parsed; +} + +function assertOnlyAttributes(element: Element, allowed: readonly string[]): void { + const allowedNames = new Set(allowed); + if (Object.keys(element.attrs).some((name) => !allowedNames.has(name))) { + throw new Error(`${element.name} has unsupported attributes`); + } +} + +function parseTaskPayload(text: string, errorMessage: string): Record { + const value = parseStrictJson(text); + if (value === null || Array.isArray(value) || typeof value !== 'object') throw new Error(errorMessage); + return value as Record; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts new file mode 100644 index 000000000..b76e3c0e5 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts @@ -0,0 +1,65 @@ +/** + * XEP-0085 Chat State Notifications. + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { bareJid } from './jid.js'; + +const CHATSTATES_NS = 'http://jabber.org/protocol/chatstates'; + +export function isChatStateStanza(stanza: Element): boolean { + if (stanza.name !== 'message') return false; + const body = stanza.getChildText('body'); + if (body?.trim()) return false; + for (const child of stanza.children) { + if (typeof child !== 'object' || child === null) continue; + if (child.attrs?.xmlns === CHATSTATES_NS) return true; + } + return false; +} + +export function buildComposingStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'composing' }); +} + +export function buildPausedStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'paused' }); +} + +export function buildInactiveStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'inactive' }); +} + +function buildChatStateStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; + state: 'composing' | 'paused' | 'inactive'; +}): Element { + // XEP-0085 states belong to the same 1:1 resource as the chat response. + const to = opts.groupchat ? bareJid(opts.to) : opts.to; + const type = opts.groupchat ? 'groupchat' : 'chat'; + const children: Element[] = [xml(opts.state, { xmlns: CHATSTATES_NS })]; + if (opts.threadId) { + children.unshift(xml('thread', {}, opts.threadId)); + } + return xml('message', { type, to, from: bareJid(opts.from) }, ...children); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts new file mode 100644 index 000000000..6d92e2358 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts @@ -0,0 +1,135 @@ +/** + * XEP-0004 Data Forms — ask_user_question multiple-choice via list-single fields. + * Outbound forms also carry XEP-0359 origin IDs. The optional reply marker uses + * the XEP-0461 namespace including `to`; it always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context (see message.ts header for detail). + * + * @see https://xmpp.org/extensions/xep-0004.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0461.html + */ + +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; + +import type { AskQuestionPayload, OutboundDeliverRequest } from '@agent-xmpp/protocol'; + +import { bareJid, isMucJid } from './jid.js'; +import { RECEIPTS_NS } from './receipts.js'; + +export const DATA_FORM_NS = 'jabber:x:data'; +export const ASK_QUESTION_FORM_TYPE = 'urn:xmpp:nanoclaw:ask-question:0'; + +const ORIGIN_ID_NS = 'urn:xmpp:sid:0'; +const REPLY_NS = 'urn:xmpp:reply:0'; + +export interface AskQuestionSubmit { + questionId: string; + selectedIndex: number; +} + +function optionLabel(raw: AskQuestionPayload['options'][number]): string { + return typeof raw === 'string' ? raw : raw.label; +} + +function buildBodyFallback(payload: AskQuestionPayload): string { + const labels = payload.options.map(optionLabel); + return `${payload.title}\n\n${payload.question}\n\nOptions: ${labels.join(', ')}`; +} + +function hiddenField(varName: string, value: string): Element { + return xml('field', { var: varName, type: 'hidden' }, xml('value', {}, value)); +} + +function listSingleField(payload: AskQuestionPayload): Element { + const options = payload.options.map((raw, idx) => + xml('option', { label: optionLabel(raw) }, xml('value', {}, String(idx))), + ); + return xml('field', { var: 'response', type: 'list-single', label: 'Choose one' }, ...options); +} + +export function isAskQuestionContent(content: unknown): content is AskQuestionPayload { + if (!content || typeof content !== 'object') return false; + const c = content as Record; + return ( + c.type === 'ask_question' && + typeof c.questionId === 'string' && + typeof c.title === 'string' && + typeof c.question === 'string' && + Array.isArray(c.options) && + c.options.length > 0 + ); +} + +export function buildAskQuestionFormStanza( + req: OutboundDeliverRequest, + fromJid: string, + payload: AskQuestionPayload, +): Element { + const id = ulid(); + const children: Element[] = [ + xml('body', {}, buildBodyFallback(payload)), + xml( + 'x', + { xmlns: DATA_FORM_NS, type: 'form' }, + xml('title', {}, payload.title), + xml('instructions', {}, payload.question), + hiddenField('FORM_TYPE', ASK_QUESTION_FORM_TYPE), + hiddenField('questionId', payload.questionId), + listSingleField(payload), + ), + xml('origin-id', { xmlns: ORIGIN_ID_NS, id }), + ]; + + if (req.threadId) { + children.unshift(xml('thread', {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + const isMuc = isMucJid(req.to); + children.push(xml('reply', { xmlns: REPLY_NS, id: req.inReplyTo, to: isMuc ? req.to : bareJid(req.to) })); + } + + const isMuc = isMucJid(req.to); + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 forms only (never MUC), so the + // form is tracked and resent like any other chat message sent through deliver(). + if (!isMuc) { + children.push(xml('request', { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve the initiating resource for 1:1 replies. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? 'groupchat' : 'chat'; + + return xml('message', { type, id, to, from: fromJid, ...(req.lang ? { 'xml:lang': req.lang } : {}) }, ...children); +} + +function dataFormFieldValue(form: Element, varName: string): string | null { + for (const child of form.children) { + if (typeof child === 'string') continue; + if (child.name !== 'field' || child.attrs.var !== varName) continue; + const value = child.getChildText('value'); + return value ?? null; + } + return null; +} + +export function parseAskQuestionSubmit(stanza: Element): AskQuestionSubmit | null { + if (stanza.name !== 'message') return null; + + const form = stanza.getChild('x', DATA_FORM_NS); + if (!form || form.attrs.type !== 'submit') return null; + + const formType = dataFormFieldValue(form, 'FORM_TYPE'); + if (formType !== ASK_QUESTION_FORM_TYPE) return null; + + const questionId = dataFormFieldValue(form, 'questionId'); + const responseRaw = dataFormFieldValue(form, 'response'); + if (!questionId || responseRaw === null) return null; + + const selectedIndex = Number(responseRaw); + if (!Number.isInteger(selectedIndex) || selectedIndex < 0) return null; + + return { questionId, selectedIndex }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts new file mode 100644 index 000000000..e4c4d0858 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts @@ -0,0 +1,9 @@ +/** Shared JID helpers (kept cycle-free so both message.ts and muc.ts can import it). */ + +/** Bare JID (localpart@domain) — strips any /resource. */ +export { bareJid } from '@agent-xmpp/protocol'; + +/** True for MUC room JIDs on the conventional `conference.` / `groups.` service domains. */ +export function isMucJid(jid: string): boolean { + return jid.includes('@conference.') || jid.includes('@groups.'); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/message.ts b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts new file mode 100644 index 000000000..a4b15912d --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts @@ -0,0 +1,258 @@ +/** + * Message normalization and construction. + * + * The JSON payload nests its content in a + * child per XEP-0335; `datatype` still carries a MIME type rather than a + * schema namespace, and the JSON body is the gateway's own + * kind/contentType/body envelope, not caller-defined XEP-0432 content — + * both are deliberate gateway conventions, not spec violations. The reply + * marker carries `to` per XEP-0461, but always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context; the spec's groupchat-specific + * stanza-id-selection rule is not implemented. The content-type marker, + * XEP-0334 processing hints, and XEP-0359 origin IDs use their standard + * namespaces. + * + * @see https://xmpp.org/extensions/xep-0432.html + * @see https://xmpp.org/extensions/xep-0481.html + * @see https://xmpp.org/extensions/xep-0461.html + * @see https://xmpp.org/extensions/xep-0334.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0335.html + */ + +import { createHash } from 'crypto'; + +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; + +import { bareJid, isMucJid } from './jid.js'; + +import type { + AgentMessage, + InboundMessage, + MessageKind, + MessagePolicy, + OutboundDeliverRequest, + XmppSourceMetadata, +} from '@agent-xmpp/protocol'; + +import { buildAskQuestionFormStanza, isAskQuestionContent } from './data-form.js'; +import { RECEIPTS_NS } from './receipts.js'; + +const JSON_NS = 'urn:xmpp:json-msg:0'; +const ORIGIN_ID_NS = 'urn:xmpp:sid:0'; +const REPLY_NS = 'urn:xmpp:reply:0'; +const STORE_NS = 'urn:xmpp:hints'; +const CONTENT_TYPE_NS = 'urn:xmpp:content'; + +export function extractStableId(stanza: Element): string { + const attrId = stanza.attrs.id as string | undefined; + if (attrId) return attrId; + const origin = stanza.getChild('origin-id', ORIGIN_ID_NS); + if (origin?.attrs.id) return origin.attrs.id as string; + // No stanza id: derive a deterministic id from content so a redelivered stanza + // dedups instead of being processed twice. ponytail: content hash — two identical + // id-less messages collide; acceptable since servers virtually always stamp `id`. + const from = (stanza.attrs.from as string) || ''; + const to = (stanza.attrs.to as string) || ''; + const body = stanza.getChildText('body') || ''; + const thread = stanza.getChild('thread')?.getText() || ''; + const digest = createHash('sha256').update(`${from}\n${to}\n${thread}\n${body}`).digest('hex'); + return `derived-${digest.slice(0, 26)}`; +} + +function payloadText(stanza: Element): string | null { + const payload = stanza.getChild('payload', JSON_NS); + if (!payload) return null; + return payload.getChildText('json', 'urn:xmpp:json:0') || null; +} + +function parseJsonPayload(stanza: Element): { kind: MessageKind; contentType: string; body: unknown } | null { + const payload = stanza.getChild('payload', JSON_NS); + if (!payload) return null; + const datatype = (payload.attrs.datatype as string) || 'application/json'; + const raw = payloadText(stanza); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { + kind?: MessageKind; + contentType?: string; + body?: unknown; + }; + return { + kind: parsed.kind || 'text', + contentType: parsed.contentType || datatype, + body: parsed.body ?? parsed, + }; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed JSON payload falls back to raw text + } catch { + return { kind: 'text', contentType: datatype, body: raw }; + } +} + +function bodyText(stanza: Element): string { + return stanza.getChildText('body') || ''; +} + +export function stanzaToAgentMessage(stanza: Element, agentDomain: string): AgentMessage | null { + if (stanza.name !== 'message') return null; + const type = (stanza.attrs.type as string) || 'chat'; + if (type === 'error' || type === 'headline') return null; + + const from = stanza.attrs.from as string; + const to = stanza.attrs.to as string; + if (!from || !to) return null; + + const id = extractStableId(stanza); + const threadEl = stanza.getChild('thread'); + // XEP-0201: the thread id is the element's text content, not a child or attribute. + const threadId = threadEl?.getText()?.trim() || (threadEl?.attrs as { id?: string })?.id; + + const replyEl = stanza.getChild('reply', REPLY_NS); + const replyTo = replyEl?.attrs.id as string | undefined; + + const json = parseJsonPayload(stanza); + const text = bodyText(stanza); + const isMuc = type === 'groupchat'; + const roomId = isMuc ? bareJid(from) : undefined; + const fromBare = bareJid(from); + + let kind: MessageKind = json?.kind || 'text'; + let contentType = json?.contentType || 'text/plain'; + let body: unknown = json?.body ?? text; + + const ctEl = stanza.getChild('content', CONTENT_TYPE_NS); + if (ctEl?.attrs.type) contentType = ctEl.attrs.type as string; + + if (!json && text.startsWith('{')) { + try { + const parsed = JSON.parse(text); + if (parsed.kind) kind = parsed.kind; + if (parsed.contentType) contentType = parsed.contentType; + body = parsed.body ?? parsed; + // eslint-disable-next-line no-catch-all/no-catch-all -- body looks like JSON but isn't; keep as plain text + } catch { + /* plain text */ + } + } + + // XEP-0513: MUC mentions MUST address by `occupantid` (XEP-0421) when the room + // supports it; only outside MUC (or in occupant-id-less rooms) is `jid` used. + // Accept either so occupant-id-addressed mentions aren't silently dropped. + const mentions = stanza + .getChildren('mention', 'urn:xmpp:mentions:0') + .map((el) => (el.attrs.jid ?? el.attrs.occupantid) as string) + .filter(Boolean); + const extensions: Record = {}; + if (mentions.length) extensions.mentions = mentions; + + return { + id, + from: isMuc ? from : fromBare, + to: bareJid(to), + threadId: threadId || undefined, + roomId, + kind, + contentType, + body, + replyTo, + extensions: Object.keys(extensions).length ? extensions : undefined, + }; +} + +export function buildInboundEnvelope( + msg: AgentMessage, + gatewayId: string, + deliveryId: string, + xmppMeta: XmppSourceMetadata, + redelivered?: boolean, +): InboundMessage { + return { + type: 'inbound.message', + message: msg, + delivery: { + receivedAt: new Date().toISOString(), + gatewayId, + deliveryId, + redelivered, + }, + xmpp: xmppMeta, + }; +} + +export function isAgentJid(jid: string, agentDomain: string): boolean { + const bare = bareJid(jid); + return bare.endsWith(`@${agentDomain}`); +} + +export function resolveTargetAgentJid(to: string, agentDomain: string, defaultAgent: string): string { + const bare = bareJid(to); + if (isAgentJid(bare, agentDomain)) return bare; + // Traffic to the bare component address is attributed to the default agent for this gateway. + return defaultAgent; +} + +export function buildOutboundStanza(req: OutboundDeliverRequest, fromJid: string): Element { + if (isAskQuestionContent(req.content)) { + return buildAskQuestionFormStanza(req, fromJid, req.content); + } + + const id = req.id ?? ulid(); + const text = + typeof req.content === 'string' + ? req.content + : (req.content as { text?: string })?.text || + (typeof req.content === 'object' && req.content !== null ? JSON.stringify(req.content) : String(req.content)); + + const contentType = 'text/plain'; + const payload = { + kind: 'text', + contentType, + body: req.content, + }; + + const children: Element[] = [xml('body', {}, text)]; + const isMuc = isMucJid(req.to); + + if (req.threadId) { + children.push(xml('thread', {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + children.push(xml('reply', { xmlns: REPLY_NS, id: req.inReplyTo, to: isMuc ? req.to : bareJid(req.to) })); + } + + children.push( + xml('origin-id', { xmlns: ORIGIN_ID_NS, id: id }), + xml('content', { xmlns: CONTENT_TYPE_NS, type: contentType }), + xml( + 'payload', + { xmlns: JSON_NS, datatype: contentType }, + xml('json', { xmlns: 'urn:xmpp:json:0' }, JSON.stringify(payload)), + ), + ); + + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 messages only (never MUC), + // so the gateway can confirm the peer received it and resend otherwise. + if (!isMuc) { + children.push(xml('request', { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve a full JID when replying to the + // resource that originated a 1:1 chat. Proactive sends can still use bare JIDs. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? 'groupchat' : 'chat'; + + return xml('message', { type, id, to, from: fromJid, ...(req.lang ? { 'xml:lang': req.lang } : {}) }, ...children); +} + +export function applyStoreHints(stanza: Element, policy?: MessagePolicy): Element { + if (policy?.store === false) { + return xml('message', stanza.attrs, ...stanza.children, xml('no-store', { xmlns: STORE_NS })); + } + if (policy?.store === true) { + return xml('message', stanza.attrs, ...stanza.children, xml('store', { xmlns: STORE_NS })); + } + return stanza; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts new file mode 100644 index 000000000..066669562 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts @@ -0,0 +1,74 @@ +/** + * XEP-0045 Multi-User Chat presence and groupchat messages. + * Mentions use XEP-0513 wire format. Outbound uses the `jid` address form (the + * spec's non-anonymous fallback) — the gateway does not yet track XEP-0421 + * occupant-ids, which XEP-0513 mandates for rooms that support them. No begin/end + * offsets, since the gateway doesn't track where in the body a mention occurs. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { isMucJid } from './jid.js'; +import { buildOutboundStanza } from './message.js'; + +export { isMucJid }; + +const MUC_NS = 'http://jabber.org/protocol/muc'; + +export interface XmppJoinRoomInput { roomJid: string; nickname?: string; password?: string } +export interface XmppLeaveRoomInput { roomJid: string; nickname?: string } +export interface XmppSendRoomMessageInput { + roomJid: string; + body: string; + threadId?: string; + mentions?: string[]; +} + +export function buildJoinPresence(input: XmppJoinRoomInput, agentJid: string): Element { + const nick = input.nickname || agentJid.split('@')[0]; + const roomWithNick = `${input.roomJid}/${nick}`; + // XEP-0045 §7.2.2: request zero history so joining doesn't flood the agent + // with the room's backlog as fresh inbound messages. + const mucChildren: Element[] = [xml('history', { maxstanzas: '0' })]; + if (input.password) { + mucChildren.unshift(xml('password', {}, input.password)); + } + return xml('presence', { to: roomWithNick, from: agentJid }, xml('x', { xmlns: MUC_NS }, ...mucChildren)); +} + +export function buildLeavePresence(input: XmppLeaveRoomInput, agentJid: string, nickname?: string): Element { + const nick = nickname || input.nickname || agentJid.split('@')[0]; + return xml('presence', { + to: `${input.roomJid}/${nick}`, + from: agentJid, + type: 'unavailable', + }); +} + +export function buildRoomMessage(input: XmppSendRoomMessageInput, fromJid: string): Element { + const stanza = buildOutboundStanza( + { + from: fromJid, + to: input.roomJid, + threadId: input.threadId, + content: input.body, + }, + fromJid, + ); + stanza.attrs.type = 'groupchat'; + + for (const m of input.mentions ?? []) { + stanza.append(xml('mention', { xmlns: 'urn:xmpp:mentions:0', jid: m })); + } + + return stanza; +} + +export function mucRoomFromStanza(from: string): string | null { + if (!from.includes('/')) return null; + const [room] = from.split('/'); + return isMucJid(room) ? room : null; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts new file mode 100644 index 000000000..bfd74980c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts @@ -0,0 +1,20 @@ +/** + * XEP-0199 XMPP Ping. + * @see https://xmpp.org/extensions/xep-0199.html + */ +import { xml, type Element } from '@xmpp/xml'; + +export const PING_NS = 'urn:xmpp:ping'; + +export function isPingRequest(stanza: Element): boolean { + return stanza.name === 'iq' && stanza.attrs.type === 'get' && stanza.getChild('ping', PING_NS) != null; +} + +export function buildPingResponse(stanza: Element): Element { + return xml('iq', { + type: 'result', + id: stanza.attrs.id, + from: stanza.attrs.to, + to: stanza.attrs.from, + }); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts new file mode 100644 index 000000000..aa64d1680 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts @@ -0,0 +1,91 @@ +/** + * Virtual-agent presence for an XEP-0114 component. + * + * Openfire cannot publish presence for virtual JIDs because they are not C2S + * accounts. The component therefore completes roster subscriptions and + * answers server probes itself. + * + * State mapping (RFC 6121): + * subscribe -> subscribed + available (§3.1.4 approving an inbound request) + * probe / '' -> available (§4.3 responding to a presence probe) + * unsubscribe -> unsubscribed (§3.3.2 canceling a subscription) + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + */ +import { xml, type Element } from '@xmpp/xml'; + +import { bareJid } from './jid.js'; + +export interface VirtualAgentIdentity { + jid: string; + name: string; +} + +export interface PresenceSubscriptionChange { + subscriberJid: string; + subscribed: boolean; +} + +export interface VirtualAgentPresenceResult { + responses: Element[]; + subscriptionChange?: PresenceSubscriptionChange; +} + +export const VIRTUAL_AGENT_RESOURCE = 'gateway'; + +export function virtualAgentPresenceJid(agent: VirtualAgentIdentity): string { + return `${bareJid(agent.jid)}/${VIRTUAL_AGENT_RESOURCE}`; +} + +export function buildAvailablePresence(agent: VirtualAgentIdentity, to: string): Element { + return xml( + 'presence', + { from: virtualAgentPresenceJid(agent), to }, + xml('show', {}, 'chat'), + xml('status', {}, `${agent.name} is available`), + ); +} + +export function buildUnavailablePresence(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { + type: 'unavailable', + from: virtualAgentPresenceJid(agent), + to, + }); +} + +export function buildSubscriptionAccepted(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { type: 'subscribed', from: bareJid(agent.jid), to }); +} + +export function buildSubscriptionRemoved(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { type: 'unsubscribed', from: bareJid(agent.jid), to }); +} + +export function handleVirtualAgentPresence(stanza: Element, agent: VirtualAgentIdentity): VirtualAgentPresenceResult { + if (stanza.name !== 'presence') return { responses: [] }; + const to = String(stanza.attrs.from ?? ''); + if (!to) return { responses: [] }; + const type = String(stanza.attrs.type ?? ''); + const subscriberJid = bareJid(to); + if (type === 'subscribe') { + return { + responses: [buildSubscriptionAccepted(agent, to), buildAvailablePresence(agent, to)], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === 'probe') { + return { + responses: [buildAvailablePresence(agent, to)], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === '') return { responses: [buildAvailablePresence(agent, to)] }; + if (type === 'unsubscribe') { + return { + responses: [buildSubscriptionRemoved(agent, to)], + subscriptionChange: { subscriberJid, subscribed: false }, + }; + } + return { responses: [] }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts new file mode 100644 index 000000000..e475d9258 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts @@ -0,0 +1,43 @@ +/** + * XEP-0184 Message Delivery Receipts. + * Bodyless XEP-0085 chat states are filtered by the same routing guard. + * + * @see https://xmpp.org/extensions/xep-0184.html + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { isChatStateStanza } from './chatstate.js'; + +export const RECEIPTS_NS = 'urn:xmpp:receipts'; + +/** The id a peer's acknowledges, or null if the stanza isn't a receipt. */ +export function receivedReceiptId(stanza: Element): string | null { + if (stanza.name !== 'message') return null; + return (stanza.getChild('received', RECEIPTS_NS)?.attrs.id as string | undefined) ?? null; +} + +/** True for XEP-0085 chat states and XEP-0184 receipt stanzas with no conversational body. */ +export function isAckOrReceiptStanza(stanza: Element): boolean { + if (isChatStateStanza(stanza)) return true; + if (stanza.name !== 'message') return false; + const body = stanza.getChildText('body'); + if (body?.trim()) return false; + if (stanza.getChild('received', RECEIPTS_NS)) return true; + if (stanza.getChild('request', RECEIPTS_NS)) return true; + return false; +} + +/** XEP-0184: only ack when the sender opted in with . */ +export function requestsReceipt(stanza: Element): boolean { + return stanza.name === 'message' && stanza.getChild('request', RECEIPTS_NS) != null; +} + +export function buildReceivedReceipt(to: string, from: string, messageId: string): Element { + return xml( + 'message', + { to, from, id: `receipt-${messageId}` }, + xml('received', { xmlns: RECEIPTS_NS, id: messageId }), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts new file mode 100644 index 000000000..2ee1566b6 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts @@ -0,0 +1,25 @@ +/** + * Plain-text @nick matching is the compatibility fallback for clients that do + * not send XEP-0513 Explicit Mentions. + * @see https://xmpp.org/extensions/xep-0513.html + */ +export function shouldDeliverInbound(stanzaType: string, isGroup: boolean, isMention: boolean): boolean { + if (!isGroup) return true; + return isMention; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectMention(body: string, agentNick?: string): boolean { + if (!agentNick) return false; + // Escape metachars: a JID localpart can contain '.', '(', etc. Unescaped, they + // either false-match or make new RegExp throw and drop the stanza. + return new RegExp(`@${escapeRegExp(agentNick)}\\b`, 'i').test(body); +} + +export function isMentionForAgent(stanzaType: string, body: string, agentNick: string): boolean { + if (stanzaType === 'chat') return true; + return detectMention(body, agentNick); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/search.ts b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts new file mode 100644 index 000000000..9c994ff35 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts @@ -0,0 +1,124 @@ +/** + * Agent-directory search via XEP-0055, with legacy fields for widely deployed + * clients and the recommended XEP-0004 form extension. + * + * @see https://xmpp.org/extensions/xep-0055.html + */ +import type { RegisteredAgent } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { DATA_FORMS_NS, SEARCH_NS } from '../agent-api-disco.js'; +import { buildRsm, pageRsm, parseRsm } from '../rsm-codec.js'; + +const INSTRUCTIONS = 'Enter a nickname to search for matching agents. Leave it empty to list up to 100 agents.'; +const MAX_SEARCH_RESULTS = 100; + +function resultIq(request: Element, componentJid: string, query: Element): Element { + return xml( + 'iq', + { + type: 'result', + id: request.attrs.id, + from: componentJid, + to: request.attrs.from, + ...(request.attrs['xml:lang'] ? { 'xml:lang': request.attrs['xml:lang'] } : {}), + }, + query, + ); +} + +function dataFormFieldValue(form: Element, name: string): string { + return ( + form + .getChildren('field') + .find((field) => field.attrs.var === name) + ?.getChildText('value') + ?.trim() ?? '' + ); +} + +function nickname(agent: RegisteredAgent): string { + return agent.manifest.agent.title ?? agent.manifest.agent.name; +} + +export function buildSearchFields(request: Element, componentJid: string): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + xml('instructions', {}, INSTRUCTIONS), + xml('nick'), + xml( + 'x', + { xmlns: DATA_FORMS_NS, type: 'form' }, + xml('title', {}, 'Agent Directory Search'), + xml('instructions', {}, INSTRUCTIONS), + xml('field', { type: 'hidden', var: 'FORM_TYPE' }, xml('value', {}, SEARCH_NS)), + xml('field', { type: 'text-single', label: 'Nickname', var: 'nick' }), + ), + ), + ); +} + +export function buildSearchResults(request: Element, componentJid: string, agents: RegisteredAgent[]): Element { + const query = request.getChild('query', SEARCH_NS); + const dataForm = query?.getChild('x', DATA_FORMS_NS); + if (dataForm && dataForm.attrs.type !== 'submit') { + return resultIq(request, componentJid, xml('query', { xmlns: SEARCH_NS })); + } + const submittedForm = dataForm; + const needle = (submittedForm ? dataFormFieldValue(submittedForm, 'nick') : (query?.getChildText('nick') ?? '')) + .trim() + .toLowerCase(); + const matches = agents.filter((agent) => { + if (!needle) return true; + const identity = agent.manifest.agent; + const localpart = identity.jid.split('@', 1)[0] ?? ''; + return [localpart, identity.name, identity.title ?? ''].some((value) => value.toLowerCase().includes(needle)); + }); + const page = pageRsm(matches, (agent) => agent.manifest.agent.jid, parseRsm(query!, MAX_SEARCH_RESULTS)); + + if (submittedForm) { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + xml( + 'x', + { xmlns: DATA_FORMS_NS, type: 'result' }, + xml('field', { type: 'hidden', var: 'FORM_TYPE' }, xml('value', {}, SEARCH_NS)), + xml( + 'reported', + {}, + xml('field', { var: 'jid', label: 'Jabber ID', type: 'jid-single' }), + xml('field', { var: 'nick', label: 'Nickname', type: 'text-single' }), + ), + ...page.items.map((agent) => + xml( + 'item', + {}, + xml('field', { var: 'jid' }, xml('value', {}, agent.manifest.agent.jid)), + xml('field', { var: 'nick' }, xml('value', {}, nickname(agent))), + ), + ), + ), + buildRsm(page), + ), + ); + } + + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + ...page.items.map((agent) => xml('item', { jid: agent.manifest.agent.jid }, xml('nick', {}, nickname(agent)))), + buildRsm(page), + ), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts new file mode 100644 index 000000000..867aae44c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts @@ -0,0 +1,22 @@ +/** vCard-temp identity for virtual agents. @see https://xmpp.org/extensions/xep-0054.html */ +import type { RegisteredAgent } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export const VCARD_TEMP_NS = 'vcard-temp'; + +export function buildAgentVcard(request: Element, agent: RegisteredAgent): Element { + const identity = agent.manifest.agent; + const children = [ + xml('FN', {}, identity.title ?? identity.name), + xml('NICKNAME', {}, identity.name), + xml('JABBERID', {}, identity.jid), + ...(identity.description ? [xml('DESC', {}, identity.description)] : []), + ...(identity.homepage ? [xml('URL', {}, identity.homepage)] : []), + ...(identity.avatarUrl ? [xml('PHOTO', {}, xml('EXTVAL', {}, identity.avatarUrl))] : []), + ]; + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: identity.jid, to: request.attrs.from }, + xml('vCard', { xmlns: VCARD_TEMP_NS }, ...children), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-component.ts b/packages/agent-xmpp/gateway/src/xmpp-component.ts new file mode 100644 index 000000000..75764d144 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-component.ts @@ -0,0 +1,434 @@ +/** + * External-component session using XEP-0114 Jabber Component Protocol. + * @see https://xmpp.org/extensions/xep-0114.html + */ +import { component } from '@xmpp/component'; +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; +import { bareJid } from '@agent-xmpp/protocol'; + +import type { GatewayConfig } from './config.js'; + +export interface XmppComponentSession { + send: (stanza: Element) => Promise; + requestIq: (stanza: Element, options?: IqRequestOptions) => Promise; + start: () => Promise; + stop: () => Promise; + forceReconnect: (reason: string) => Promise; + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + onStateChange: (handler: (state: XmppConnectionState) => void) => void; + onStanza: (handler: (stanza: Element) => void) => void; +} + +export type XmppConnectionState = 'offline' | 'connecting' | 'online' | 'stopping'; + +export interface IqRequestOptions { + timeoutMs?: number; + signal?: AbortSignal; +} + +export class IqResponseError extends Error { + constructor(public readonly response: Element) { + const id = String(response.attrs.id ?? 'unknown'); + const stanzaError = response.getChild('error'); + const condition = stanzaError?.children.find( + (child): child is Element => typeof child !== 'string' && child.name !== 'text', + ); + super(`IQ request ${id} failed${condition ? `: ${condition.name}` : ''}`); + this.name = 'IqResponseError'; + } +} + +export type IqGetHandler = (stanza: Element) => Element | null | Promise; + +const STANZA_ERROR_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas'; +const DEFAULT_IQ_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_PENDING_IQ_REQUESTS = 256; + +interface PendingIqRequest { + resolve: (stanza: Element) => void; + reject: (error: Error) => void; + timer: ReturnType; + signal?: AbortSignal; + onAbort?: () => void; + expectedFrom: string; + expectedTo: string; +} + +function abortError(id: string): Error { + const error = new Error(`IQ request ${id} aborted`); + error.name = 'AbortError'; + return error; +} + +/** + * RFC 6120 §8.3 stanza error for an IQ get/set the gateway does not handle. + * `service-unavailable` (type cancel) is the standard "no such handler" reply; + * the original request payload is echoed back per the SHOULD in §8.3.1. + */ +export function buildIqError(request: Element): Element { + return xml( + 'iq', + { type: 'error', id: request.attrs.id, from: request.attrs.to, to: request.attrs.from }, + ...request.children.filter((c): c is Element => typeof c !== 'string'), + xml('error', { type: 'cancel' }, xml('service-unavailable', { xmlns: STANZA_ERROR_NS })), + ); +} + +function iqMiddlewareReply(response: Element): Element | true { + if (response.attrs.type === 'error') { + return ( + response.getChild('error') ?? + xml('error', { type: 'cancel' }, xml('service-unavailable', { xmlns: STANZA_ERROR_NS })) + ); + } + return response.getChildElements()[0] ?? true; +} + +export type IqDisposition = { kind: 'respond'; stanza: Element } | { kind: 'error' } | { kind: 'dispatch' }; + +/** + * Decide how an inbound stanza is handled by the component: + * - IQ get/set the gateway answers -> `respond` with the built reply + * - IQ get/set nothing handled -> `error` (RFC 6120 §8.2.3 requires a reply) + * - everything else, incl. IQ result/error responses to our own outbound requests + * and all message/presence stanzas -> `dispatch` to the registered stanza handlers + */ +export async function dispositionForStanza(stanza: Element, onIqGet?: IqGetHandler): Promise { + if (stanza.name === 'iq') { + const type = String(stanza.attrs.type ?? ''); + if (type === 'get' || type === 'set') { + const response = (await onIqGet?.(stanza)) ?? null; + return response ? { kind: 'respond', stanza: response } : { kind: 'error' }; + } + } + return { kind: 'dispatch' }; +} + +export function reconnectDelayMs(attempt: number, initialMs: number, maxMs: number, random = Math.random): number { + const exponential = Math.min(maxMs, initialMs * 2 ** Math.min(Math.max(attempt - 1, 0), 30)); + return Math.max(1, Math.round(exponential * (0.8 + random() * 0.4))); +} + +export function createComponentSession(config: GatewayConfig, onIqGet?: IqGetHandler): XmppComponentSession { + const maxPendingIqRequests = config.maxPendingIqRequests ?? DEFAULT_MAX_PENDING_IQ_REQUESTS; + if (!Number.isSafeInteger(maxPendingIqRequests) || maxPendingIqRequests <= 0) { + throw new Error('maxPendingIqRequests must be a positive integer'); + } + const stanzaHandlers: Array<(stanza: Element) => void> = []; + const stateHandlers: Array<(state: XmppConnectionState) => void> = []; + const pendingIqRequests = new Map(); + let activeInboundIq = 0; + let activeClient: ReturnType | null = null; + let state: XmppConnectionState = 'offline'; + let stopped = true; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let lastActivityAt = Date.now(); + let onlineAttempt: { + client: ReturnType; + resolve: () => void; + reject: (error: Error) => void; + } | null = null; + + const transition = (next: XmppConnectionState): void => { + if (state === next) return; + state = next; + for (const handler of stateHandlers) handler(next); + }; + + const settleIqRequest = (id: string, responseOrError: Element | Error): boolean => { + const pending = pendingIqRequests.get(id); + if (!pending) return false; + if (!(responseOrError instanceof Error)) { + const responseFrom = bareJid(String(responseOrError.attrs.from ?? '')); + const responseTo = bareJid(String(responseOrError.attrs.to ?? '')); + if ( + (pending.expectedFrom && responseFrom !== pending.expectedFrom) || + (pending.expectedTo && responseTo !== pending.expectedTo) + ) { + return false; + } + } + + pendingIqRequests.delete(id); + clearTimeout(pending.timer); + if (pending.signal && pending.onAbort) pending.signal.removeEventListener('abort', pending.onAbort); + + if (responseOrError instanceof Error) pending.reject(responseOrError); + else if (responseOrError.attrs.type === 'error') pending.reject(new IqResponseError(responseOrError)); + else pending.resolve(responseOrError); + return true; + }; + + const rejectPendingIqRequests = (reason: string): void => { + for (const id of [...pendingIqRequests.keys()]) { + settleIqRequest(id, new Error(`IQ request ${id} failed: ${reason}`)); + } + }; + + const rejectOnlineAttempt = (client: ReturnType, reason: Error): void => { + if (onlineAttempt?.client !== client) return; + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.reject(reason); + }; + + const clearReconnectTimer = (): void => { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + }; + + const scheduleReconnect = (): void => { + if (stopped || reconnectTimer) return; + reconnectAttempt += 1; + const delay = reconnectDelayMs(reconnectAttempt, config.reconnectInitialMs, config.reconnectMaxMs); + console.error(`[xmpp-gateway] reconnect attempt ${reconnectAttempt} scheduled in ${delay}ms`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectClient(); + }, delay); + reconnectTimer.unref?.(); + }; + + const handleConnectionLoss = (client: ReturnType, reason: string): void => { + if (activeClient !== client || stopped || state === 'stopping') return; + activeClient = null; + transition('offline'); + rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + scheduleReconnect(); + }; + + const createClient = (): ReturnType => { + const client = component({ + service: config.componentService, + domain: config.componentJid, + password: config.componentSecret, + }); + // The package helper makes only one fixed-delay attempt. The gateway owns a + // capped, jittered supervisor and creates a clean client for every attempt. + client.reconnect.stop(); + + // @xmpp/component installs its IQ callee as middleware. Handling IQs only + // from the raw `stanza` event races that callee: it immediately emits + // service-unavailable while our listener later emits the valid result. + // Participate in the middleware chain so every request gets exactly one + // response. + const middlewareClient = client as typeof client & { + middleware: { + use(handler: (context: { stanza: Element }, next: () => Promise) => Promise): unknown; + }; + }; + middlewareClient.middleware.use(async (context, next) => { + const stanza = context.stanza as Element; + const type = String(stanza.attrs.type ?? ''); + if (stanza.name !== 'iq' || (type !== 'get' && type !== 'set')) return next(); + if (activeInboundIq >= maxPendingIqRequests) { + return xml('error', { type: 'wait' }, xml('resource-constraint', { xmlns: STANZA_ERROR_NS })); + } + activeInboundIq++; + try { + const disposition = await dispositionForStanza(stanza, onIqGet); + const response = disposition.kind === 'respond' ? disposition.stanza : buildIqError(stanza); + return iqMiddlewareReply(response); + } catch (err) { + console.error('[xmpp-gateway] inbound IQ handling failed:', err); + return iqMiddlewareReply(buildIqError(stanza)); + } finally { + activeInboundIq--; + } + }); + + client.on('stanza', (stanza: Element) => { + if (activeClient !== client) return; + lastActivityAt = Date.now(); + const type = String(stanza.attrs.type ?? ''); + const id = String(stanza.attrs.id ?? ''); + // Correlate outbound requests before any inbound protocol routing. + if (stanza.name === 'iq' && id && (type === 'result' || type === 'error') && settleIqRequest(id, stanza)) { + return; + } + + if (stanza.name === 'iq' && (type === 'get' || type === 'set')) { + // The @xmpp/component IQ middleware above owns the response. + return; + } + for (const handler of stanzaHandlers) handler(stanza); + }); + + client.on('error', (err: Error) => { + if (activeClient === client) { + console.error('[xmpp-gateway] component error:', err.message); + rejectOnlineAttempt(client, err); + } + }); + client.on('online', () => { + if (activeClient !== client || stopped) return; + reconnectAttempt = 0; + clearReconnectTimer(); + lastActivityAt = Date.now(); + transition('online'); + if (onlineAttempt?.client === client) { + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.resolve(); + } + }); + client.on('disconnect', () => handleConnectionLoss(client, 'component disconnected')); + client.on('offline', () => handleConnectionLoss(client, 'component went offline')); + return client; + }; + + async function connectClient(): Promise { + if (stopped || state === 'connecting' || state === 'online') return; + transition('connecting'); + const client = createClient(); + activeClient = client; + try { + // Avoid Component.start(): @xmpp/connection creates an internal + // `online` promise before `open()`, and both promises reject on a + // connection error. Only one is awaited upstream, producing an + // unhandled rejection during ordinary reconnect failures. + await client.connect(config.componentService); + const onlinePromise = new Promise((resolve, reject) => { + onlineAttempt = { client, resolve, reject }; + }); + // A disconnect can reject this while client.open() is still pending. + // Handle that timing window immediately; awaiting the original promise + // below still propagates the rejection. + void onlinePromise.catch(() => undefined); + try { + await client.open({ domain: config.componentJid }); + await onlinePromise; + } catch (error: unknown) { + rejectOnlineAttempt(client, error instanceof Error ? error : new Error(String(error))); + await onlinePromise.catch(() => undefined); + throw error; + } + if (activeClient === client && !stopped) { + reconnectAttempt = 0; + lastActivityAt = Date.now(); + transition('online'); + console.error(`[xmpp-gateway] component online: ${config.componentJid}`); + } + } catch (error: unknown) { + if (activeClient === client) activeClient = null; + client.reconnect.stop(); + transition('offline'); + const message = error instanceof Error ? error.message : String(error); + console.error(`[xmpp-gateway] component connection failed: ${message}`); + scheduleReconnect(); + await client.stop().catch(() => undefined); + } + } + + const send = async (stanza: Element): Promise => { + const client = activeClient; + if (state !== 'online' || !client) throw new Error('XMPP component is offline'); + await client.send(stanza); + lastActivityAt = Date.now(); + }; + + const requestIq = (stanza: Element, options: IqRequestOptions = {}): Promise => { + if (state !== 'online') return Promise.reject(new Error('Cannot send IQ request while component is offline')); + + const type = String(stanza.attrs.type ?? ''); + if (stanza.name !== 'iq' || (type !== 'get' && type !== 'set')) { + return Promise.reject(new Error('Outbound IQ request must be an or stanza')); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_IQ_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || !Number.isInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject(new Error('IQ request timeoutMs must be a positive integer')); + } + if (pendingIqRequests.size >= maxPendingIqRequests) { + return Promise.reject(new Error(`Too many pending IQ requests (limit ${maxPendingIqRequests})`)); + } + + const id = String(stanza.attrs.id ?? '') || ulid(); + if (pendingIqRequests.has(id)) { + return Promise.reject(new Error(`IQ request id is already pending: ${id}`)); + } + stanza.attrs.id = id; + + if (options.signal?.aborted) return Promise.reject(abortError(id)); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + settleIqRequest(id, new Error(`IQ request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + + const pending: PendingIqRequest = { + resolve, + reject, + timer, + signal: options.signal, + expectedFrom: bareJid(String(stanza.attrs.to ?? '')), + expectedTo: bareJid(String(stanza.attrs.from ?? '')), + }; + if (options.signal) { + pending.onAbort = () => settleIqRequest(id, abortError(id)); + options.signal.addEventListener('abort', pending.onAbort, { once: true }); + } + pendingIqRequests.set(id, pending); + + try { + send(stanza).catch((error: unknown) => { + const sendError = error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + }); + } catch (error: unknown) { + const sendError = error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + } + }); + }; + + return { + send, + requestIq, + start: async () => { + if (!stopped) return; + stopped = false; + reconnectAttempt = 0; + clearReconnectTimer(); + await connectClient(); + }, + stop: async () => { + if (stopped && state === 'offline') return; + stopped = true; + clearReconnectTimer(); + transition('stopping'); + rejectPendingIqRequests('component stopped'); + const client = activeClient; + activeClient = null; + if (client) rejectOnlineAttempt(client, new Error('component stopped')); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + transition('offline'); + }, + forceReconnect: async (reason) => { + if (stopped || state === 'stopping') return; + const client = activeClient; + activeClient = null; + transition('offline'); + if (client) rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + scheduleReconnect(); + }, + getState: () => state, + getLastActivityAt: () => lastActivityAt, + onStateChange: (handler) => stateHandlers.push(handler), + onStanza: (handler) => { + stanzaHandlers.push(handler); + }, + }; +} + +export { xml }; diff --git a/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts new file mode 100644 index 000000000..35b58aabe --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts @@ -0,0 +1,63 @@ +import type { XmppConnectionState } from './xmpp-component.js'; + +export interface XmppKeepaliveOptions { + intervalMs: number; + failureThreshold: number; +} + +export interface XmppKeepaliveCallbacks { + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + ping: () => Promise; + forceReconnect: (reason: string) => Promise; + now?: () => number; +} + +/** Idle XEP-0199 probe loop. Connection recovery remains owned by the session supervisor. */ +export class XmppKeepalive { + private timer: ReturnType | null = null; + private inFlight = false; + private consecutiveFailures = 0; + + constructor( + private readonly options: XmppKeepaliveOptions, + private readonly callbacks: XmppKeepaliveCallbacks, + ) {} + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.check(), this.options.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.inFlight = false; + this.consecutiveFailures = 0; + } + + private async check(): Promise { + if (this.inFlight || this.callbacks.getState() !== 'online') return; + const now = this.callbacks.now?.() ?? Date.now(); + if (now - this.callbacks.getLastActivityAt() < this.options.intervalMs) return; + + this.inFlight = true; + try { + await this.callbacks.ping(); + this.consecutiveFailures = 0; + } catch (error: unknown) { + this.consecutiveFailures += 1; + const message = error instanceof Error ? error.message : String(error); + console.error( + `[xmpp-gateway] keepalive failed (${this.consecutiveFailures}/${this.options.failureThreshold}): ${message}`, + ); + if (this.consecutiveFailures >= this.options.failureThreshold) { + this.consecutiveFailures = 0; + await this.callbacks.forceReconnect('XEP-0199 keepalive failure threshold reached'); + } + } finally { + this.inFlight = false; + } + } +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts new file mode 100644 index 000000000..b244f7c52 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts @@ -0,0 +1,53 @@ +declare module '@xmpp/xml' { + export class Element { + name: string; + attrs: Record; + children: Array; + + getChild(name: string, xmlns?: string): Element | undefined; + getChildElements(): Element[]; + getChildren(name: string, xmlns?: string): Element[]; + getChildText(name: string, xmlns?: string): string | null; + getNS(): string; + getText(): string; + append(child: Element | string): this; + toString(): string; + } + + export class Parser { + on(event: 'start' | 'element' | 'end', handler: (element: Element) => void): this; + on(event: 'error', handler: (error: Error) => void): this; + write(data: string): void; + end(data?: string): void; + } + + export function xml( + name: string, + attrs?: Record, + ...children: Array + ): Element; + + export default xml; +} + +declare module '@xmpp/component' { + import type { Element } from '@xmpp/xml'; + + interface ComponentClient { + on(event: 'stanza', handler: (stanza: Element) => void): this; + on(event: 'error', handler: (error: Error) => void): this; + on(event: 'offline', handler: () => void): this; + on(event: 'online', handler: () => void): this; + on(event: 'disconnect', handler: () => void): this; + reconnect: { + stop(): void; + }; + connect(service: string): Promise; + open(options: { domain: string }): Promise; + send(stanza: Element): Promise; + start(): Promise; + stop(): Promise; + } + + export function component(options: { service: string; domain: string; password: string }): ComponentClient; +} diff --git a/packages/agent-xmpp/gateway/tsconfig.json b/packages/agent-xmpp/gateway/tsconfig.json new file mode 100644 index 000000000..f1a44108c --- /dev/null +++ b/packages/agent-xmpp/gateway/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/protocol/package.json b/packages/agent-xmpp/protocol/package.json new file mode 100644 index 000000000..cdac241ea --- /dev/null +++ b/packages/agent-xmpp/protocol/package.json @@ -0,0 +1,29 @@ +{ + "name": "@agent-xmpp/protocol", + "version": "0.1.0", + "description": "Shared XMPP agent gateway protocol types", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./schema/event.schema.json": "./schema/event.schema.json", + "./schema/manifest.schema.json": "./schema/manifest.schema.json" + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "idn-hostname": "15.1.10", + "precis-wasm": "0.1.0" + } +} diff --git a/packages/agent-xmpp/protocol/schema/agent-api.xsd b/packages/agent-xmpp/protocol/schema/agent-api.xsd new file mode 100644 index 000000000..794ff611d --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-api.xsd @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/agent-task.xsd b/packages/agent-xmpp/protocol/schema/agent-task.xsd new file mode 100644 index 000000000..ecb7107a7 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-task.xsd @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/event.schema.json b/packages/agent-xmpp/protocol/schema/event.schema.json new file mode 100644 index 000000000..ce1c03753 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/event.schema.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-task:0:event-json", + "$defs": { + "status": { + "type": "object", + "required": [ + "state", + "updatedAt" + ], + "properties": { + "state": { + "enum": [ + "running", + "input_required", + "cancelling" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "progress": { + "type": "object", + "minProperties": 1, + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "stage": { + "type": "string", + "maxLength": 256 + }, + "message": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "input_required": { + "type": "object", + "required": [ + "requestId", + "question", + "inputSchema", + "createdAt" + ], + "properties": { + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{22,128}$" + }, + "question": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "inputSchema": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "toolResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": {}, + "isError": { + "type": "boolean" + }, + "_meta": { + "type": "object" + } + }, + "additionalProperties": false + }, + "completed": { + "type": "object", + "required": [ + "result" + ], + "properties": { + "result": { + "$ref": "#/$defs/toolResult" + }, + "summary": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "failed": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message", + "retryable" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9_.:-]{1,128}$" + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "object" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "cancelled": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/agent-xmpp/protocol/schema/manifest.schema.json b/packages/agent-xmpp/protocol/schema/manifest.schema.json new file mode 100644 index 000000000..b721941c7 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/manifest.schema.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-api:0:manifest-json", + "type": "object", + "required": [ + "manifestSpecVersion", + "agent", + "tools" + ], + "properties": { + "manifestSpecVersion": { + "const": "0" + }, + "agent": { + "type": "object", + "required": [ + "jid", + "name", + "version" + ], + "properties": { + "jid": { + "type": "string", + "minLength": 3, + "maxLength": 3071 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 4096 + }, + "version": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,64}$" + }, + "vendor": { + "type": "string", + "maxLength": 256 + }, + "homepage": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + } + }, + "additionalProperties": false + }, + "implementation": { + "type": "object", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "additionalProperties": false + }, + "mcpProtocolVersion": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "tools": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "required": [ + "name", + "inputSchema" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 8192 + }, + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "annotations": { + "type": "object" + }, + "execution": { + "type": "object" + }, + "_meta": { + "type": "object" + }, + "urn:xmpp:agent-api:0": { + "type": "object", + "properties": { + "supportsProgress": { + "type": "boolean" + }, + "supportsCancellation": { + "type": "boolean" + }, + "supportsInput": { + "type": "boolean" + }, + "defaultTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "maximumTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "requiredPermissions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "uniqueItems": true + }, + "approvalRequired": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "patternProperties": { + "^[a-z][a-z0-9+.-]*:": {} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packages/agent-xmpp/protocol/schema/namespaces.json b/packages/agent-xmpp/protocol/schema/namespaces.json new file mode 100644 index 000000000..b2f541447 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/namespaces.json @@ -0,0 +1,10 @@ +[ + "urn:xmpp:agent-directory:0", + "urn:xmpp:agent-api:0", + "urn:xmpp:agent-tools:0", + "urn:xmpp:agent-tool:0", + "urn:xmpp:agent-endpoint:0", + "urn:xmpp:agent-endpoint-info:0", + "urn:xmpp:agent-tool-info:0", + "urn:xmpp:agent-task:0" +] diff --git a/packages/agent-xmpp/protocol/src/agent-api.ts b/packages/agent-xmpp/protocol/src/agent-api.ts new file mode 100644 index 000000000..dddc3e108 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-api.ts @@ -0,0 +1,91 @@ +import type { AGENT_API_NS } from './namespaces.js'; + +export type JsonSchema = Record; + +/** MCP Tool annotations are preserved exactly; absence is distinct from false. */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +export interface McpTool { + name: string; + title?: string; + description?: string; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: McpToolAnnotations; + execution?: Record; + _meta?: Record; + [extension: `${string}:${string}`]: unknown; +} + +export interface XmppToolExtension { + supportsProgress?: boolean; + supportsCancellation?: boolean; + supportsInput?: boolean; + defaultTimeoutSeconds?: number; + maximumTimeoutSeconds?: number; + requiredPermissions?: string[]; + approvalRequired?: boolean; + tags?: string[]; +} + +export interface AgentApiManifest { + manifestSpecVersion: '0'; + agent: { + jid: string; + name: string; + title?: string; + description?: string; + version: string; + vendor?: string; + homepage?: string; + /** Public avatar URI served via XEP-0054 PHOTO/EXTVAL. */ + avatarUrl?: string; + }; + implementation?: { name: string; version: string }; + mcpProtocolVersion?: string; + tools: McpTool[]; +} + +export interface RegisteredTool extends McpTool { + inputSchemaHash: string; + outputSchemaHash?: string; + xmpp?: XmppToolExtension; +} + +export interface RegisteredAgent { + manifest: AgentApiManifest; + manifestHash: string; + canonicalManifest: string; + tools: RegisteredTool[]; + tenantId: string; + active: boolean; + registeredAt: string; +} + +export interface VirtualMcpEndpoint { + endpointId: string; + manifestSpecVersion: AgentApiManifest['manifestSpecVersion']; + implementation?: AgentApiManifest['implementation']; + mcpProtocolVersion?: AgentApiManifest['mcpProtocolVersion']; + server: { + name: string; + title?: string; + description?: string; + version: string; + }; + xmpp: { + jid: string; + toolsNode: string; + features: string[]; + }; + authorization: { visible: boolean; invocable: boolean }; + tools: RegisteredTool[]; +} + +export const XMPP_TOOL_EXTENSION_KEY: typeof AGENT_API_NS = 'urn:xmpp:agent-api:0'; diff --git a/packages/agent-xmpp/protocol/src/agent-message.ts b/packages/agent-xmpp/protocol/src/agent-message.ts new file mode 100644 index 000000000..dac2fd786 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-message.ts @@ -0,0 +1,159 @@ +/** Normative types from Agent XMPP Adapter API Surface v0.1 */ + +export type MessageKind = 'text' | 'task' | 'result' | 'error' | 'file' | 'command' | 'event'; + +export type Sensitivity = 'public' | 'internal' | 'confidential' | 'secret'; + +export interface TraceContext { + tenantId?: string; + workflowId?: string; + runId?: string; + spanId?: string; + correlationId?: string; +} + +export interface MessagePolicy { + store?: boolean; + ttlSeconds?: number | null; + trainingAllowed?: boolean; + containsPii?: boolean; + sensitivity?: Sensitivity; +} + +export interface FileRef { + id?: string; + name?: string; + url: string; + mediaType?: string; + sizeBytes?: number; + sha256?: string; + description?: string; + expiresAt?: string; + encrypted?: boolean; + metadata?: Record; +} + +export interface AgentMessage { + id: string; + from: string; + to: string; + threadId?: string; + roomId?: string; + kind: MessageKind; + contentType: string; + body: unknown; + replyTo?: string; + attachments?: FileRef[]; + trace?: TraceContext; + policy?: MessagePolicy; + extensions?: Record; +} + +export interface XmppSourceMetadata { + stanzaId?: string; + stableId?: string; + stanzaType?: 'chat' | 'groupchat' | 'normal' | 'headline' | 'error'; + fromResource?: string; + toResource?: string; + mucOccupantId?: string; + delayed?: { + stamp: string; + from?: string; + }; + rawNamespaces?: string[]; +} + +export interface DeliveryMeta { + receivedAt: string; + gatewayId: string; + deliveryId: string; + redelivered?: boolean; +} + +export interface InboundMessage { + type: 'inbound.message'; + message: AgentMessage; + delivery: DeliveryMeta; + xmpp?: XmppSourceMetadata; +} + +export interface InboundEvent { + type: 'inbound.event'; + event: Record; + delivery: DeliveryMeta; +} + +export interface InboundCommand { + type: 'inbound.command'; + command: string; + args?: Record; + delivery: DeliveryMeta; +} + +export interface InboundLifecycleEvent { + type: 'inbound.lifecycle'; + lifecycle: Record; + delivery: DeliveryMeta; +} + +export type InboundEnvelope = InboundMessage | InboundEvent | InboundCommand | InboundLifecycleEvent; + +/** ask_user_question payload — shared between host delivery and XMPP form rendering. */ +export interface AskQuestionOption { + label: string; + selectedLabel?: string; + value?: string; +} + +export type AskQuestionOptionInput = string | AskQuestionOption; + +export interface AskQuestionPayload { + type: 'ask_question'; + questionId: string; + title: string; + question: string; + options: AskQuestionOptionInput[]; +} + +/** Bridge webhook payload: routing + normalized message for NanoClaw. */ +export interface BridgeInboundPayload { + platformId: string; + /** Full sender JID used for replies and chat states; routing still uses platformId. */ + replyTo?: string; + threadId: string | null; + isMention?: boolean; + isGroup?: boolean; + agentJid: string; + envelope: InboundMessage; +} + +/** XEP-0004 form submit for ask_user_question — routed to host onAction, not the agent. */ +export interface BridgeFormResponsePayload { + type: 'form_response'; + agentJid: string; + platformId: string; + threadId: string | null; + questionId: string; + selectedIndex: number; + userId: string; + timestamp: string; +} + +export type BridgeWebhookPayload = BridgeInboundPayload | BridgeFormResponsePayload; + +export function isBridgeFormResponsePayload(payload: BridgeWebhookPayload): payload is BridgeFormResponsePayload { + return 'type' in payload && payload.type === 'form_response'; +} + +/** Gateway outbound deliver request from NanoClaw bridge. */ +export interface OutboundDeliverRequest { + id?: string; + from: string; + to: string; + /** BCP 47 language tag used as the stanza's inherited xml:lang. */ + lang?: string; + threadId?: string | null; + content: unknown; + inReplyTo?: string; + files?: Array<{ filename: string; dataBase64: string; mediaType?: string }>; +} diff --git a/packages/agent-xmpp/protocol/src/agent-task.ts b/packages/agent-xmpp/protocol/src/agent-task.ts new file mode 100644 index 000000000..6aafb4246 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-task.ts @@ -0,0 +1,71 @@ +export const taskStates = [ + 'accepted', + 'running', + 'input_required', + 'cancelling', + 'cancelled', + 'failed', + 'completed', +] as const; +export type AgentTaskState = (typeof taskStates)[number]; + +export const terminalTaskStates = new Set(['cancelled', 'failed', 'completed']); + +export interface AgentTaskError { + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +export interface McpToolResult { + content: Array>; + structuredContent?: unknown; + isError?: boolean; + _meta?: Record; +} + +export interface PendingTaskInput { + requestId: string; + question: string; + inputSchema: Record; + createdAt: string; + expiresAt?: string; +} + +export interface AgentTaskRecord { + taskId: string; + requestId: string; + callerJid: string; + notificationJid: string; + targetJid: string; + tenantId: string; + tool: string; + apiVersion: string; + manifestHash: string; + arguments: unknown; + state: AgentTaskState; + revision: number; + fingerprint: string; + callerSessionId?: string; + createdAt: string; + updatedAt: string; + deadline?: string; + retainUntil: string; + result?: McpToolResult; + error?: AgentTaskError; + summary?: string; + pendingInput?: PendingTaskInput; +} + +export const taskEventTypes = ['status', 'progress', 'input_required', 'completed', 'failed', 'cancelled'] as const; +export type AgentTaskEventType = (typeof taskEventTypes)[number]; + +export interface AgentTaskEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + payload: Record; + createdAt: string; +} diff --git a/packages/agent-xmpp/protocol/src/bridge.ts b/packages/agent-xmpp/protocol/src/bridge.ts new file mode 100644 index 000000000..e8f9563a2 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/bridge.ts @@ -0,0 +1,59 @@ +import type { AgentMessage, BridgeInboundPayload, InboundMessage } from './agent-message.js'; + +/** + * True when the gateway normalized a XEP-0432-inspired JSON payload (agent-to-agent), + * as opposed to a plain human `` stanza (kind=text, contentType=text/plain, string body). + */ +export function isXmppAgentEnvelope(msg: AgentMessage): boolean { + if (msg.kind !== 'text') return true; + if (msg.contentType !== 'text/plain') return true; + return typeof msg.body !== 'string'; +} + +/** Extract the normative AgentMessage from a NanoClaw XMPP inbound content JSON blob. */ +export function agentMessageFromNanoclawContent(raw: string): AgentMessage | null { + try { + const parsed = JSON.parse(raw) as { envelope?: InboundMessage }; + if (parsed.envelope?.type !== 'inbound.message') return null; + return parsed.envelope.message; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed inbound content returns null + } catch { + return null; + } +} + +/** Human-readable text from a normative AgentMessage. */ +export function agentMessageText(msg: AgentMessage): string { + if (typeof msg.body === 'string') return msg.body; + if (msg.body && typeof msg.body === 'object' && 'text' in msg.body) { + return String((msg.body as { text?: unknown }).text ?? ''); + } + return JSON.stringify(msg.body); +} + +/** NanoClaw channel adapter inbound shape — preserves normative envelope. */ +export interface NanoclawXmppInbound { + id: string; + kind: 'chat'; + content: { text: string; envelope: InboundMessage }; + timestamp: string; + isMention?: boolean; + isGroup?: boolean; +} + +export function nanoclawInboundFromBridge(payload: BridgeInboundPayload): NanoclawXmppInbound { + const { envelope } = payload; + const text = agentMessageText(envelope.message); + return { + id: envelope.message.id, + // Generic AgentMessage(kind="task") is structured conversation content, + // not a durable gateway task. Only the agent-task stanza codec creates a + // task record and exposes lifecycle tools, so an arbitrary message id can + // never be mistaken for a registered task id. + kind: 'chat', + content: { text, envelope }, + timestamp: envelope.delivery.receivedAt, + isMention: payload.isMention, + isGroup: payload.isGroup, + }; +} diff --git a/packages/agent-xmpp/protocol/src/identifiers.ts b/packages/agent-xmpp/protocol/src/identifiers.ts new file mode 100644 index 000000000..0f844fe5f --- /dev/null +++ b/packages/agent-xmpp/protocol/src/identifiers.ts @@ -0,0 +1,112 @@ +const API_VERSION = /^[A-Za-z0-9._~-]{1,64}$/; +const OPAQUE_IDENTIFIER = /^[A-Za-z0-9._~-]{22,128}$/; +const XEP_0082_DATE_TIME = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/; + +interface Xep0082Instant { + epochSecond: bigint; + fraction: string; +} + +export function isApiVersion(value: string): boolean { + return API_VERSION.test(value); +} + +export function isOpaqueIdentifier(value: string): boolean { + return OPAQUE_IDENTIFIER.test(value); +} + +export function isXep0082DateTime(value: string): boolean { + const match = XEP_0082_DATE_TIME.exec(value); + if (!match) return false; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, , , offsetHourText, offsetMinuteText] = + match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if (year === 0 || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false; + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!; + if (day < 1 || day > daysInMonth) return false; + if (offsetHourText) { + const offsetHour = Number(offsetHourText); + const offsetMinute = Number(offsetMinuteText); + if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return false; + } + return true; +} + +/** + * Compare represented XEP-0082 instants without truncating fractional seconds. + * Both inputs must already satisfy isXep0082DateTime(). + */ +export function compareXep0082DateTimes(left: string, right: string): number { + const a = parseXep0082Instant(left); + const b = parseXep0082Instant(right); + if (!a || !b) throw new Error('invalid XEP-0082 date-time'); + if (a.epochSecond < b.epochSecond) return -1; + if (a.epochSecond > b.epochSecond) return 1; + const width = Math.max(a.fraction.length, b.fraction.length); + const aFraction = a.fraction.padEnd(width, '0'); + const bFraction = b.fraction.padEnd(width, '0'); + return aFraction < bFraction ? -1 : aFraction > bFraction ? 1 : 0; +} + +export function compareXep0082DateTimeToDate(value: string, date: Date): number { + return compareXep0082DateTimes(value, date.toISOString()); +} + +/** Smallest integral epoch millisecond that is not before the represented instant. */ +export function xep0082DateTimeToEpochMillisecondsCeil(value: string): number { + const instant = parseXep0082Instant(value); + if (!instant) throw new Error('invalid XEP-0082 date-time'); + const milliseconds = Number(instant.epochSecond) * 1_000; + const firstThreeDigits = Number(instant.fraction.padEnd(3, '0').slice(0, 3)); + const hasSubMillisecondRemainder = /[1-9]/.test(instant.fraction.slice(3)); + return milliseconds + firstThreeDigits + (hasSubMillisecondRemainder ? 1 : 0); +} + +export function isXml10Text(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint !== 0x09 && + codePoint !== 0x0a && + codePoint !== 0x0d && + (codePoint < 0x20 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + codePoint === 0xfffe || + codePoint === 0xffff || + codePoint > 0x10ffff) + ) { + return false; + } + } + return true; +} + +export function isToolName(value: string): boolean { + return value.length > 0 && isXml10Text(value); +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseXep0082Instant(value: string): Xep0082Instant | null { + if (!isXep0082DateTime(value)) return null; + const match = XEP_0082_DATE_TIME.exec(value)!; + const [, year, month, day, hour, minute, second, fraction = '', offsetSign, offsetHour = '0', offsetMinute = '0'] = + match; + const date = new Date(0); + date.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); + date.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const signedOffsetSeconds = + (offsetSign === '-' ? -1 : 1) * (Number(offsetHour) * 60 + Number(offsetMinute)) * 60; + return { + epochSecond: BigInt(date.getTime() / 1_000 - signedOffsetSeconds), + fraction, + }; +} diff --git a/packages/agent-xmpp/protocol/src/index.ts b/packages/agent-xmpp/protocol/src/index.ts new file mode 100644 index 000000000..b5f44d576 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/index.ts @@ -0,0 +1,8 @@ +export * from './namespaces.js'; +export * from './agent-message.js'; +export * from './agent-api.js'; +export * from './agent-task.js'; +export * from './bridge.js'; +export * from './identifiers.js'; +export * from './jid.js'; +export * from './strict-json.js'; diff --git a/packages/agent-xmpp/protocol/src/jid.ts b/packages/agent-xmpp/protocol/src/jid.ts new file mode 100644 index 000000000..bd73cfc91 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/jid.ts @@ -0,0 +1,98 @@ +import { createRequire } from 'node:module'; +import { isIP } from 'node:net'; +import { readFileSync } from 'node:fs'; +import IdnHostname from 'idn-hostname'; +import { initSync, usernamecasemapped_enforce } from 'precis-wasm/precis_wasm.js'; + +/** Return the addressable bare JID, stripping any resource suffix. */ +export function bareJid(jid: string): string { + return jid.split('/')[0] ?? jid; +} + +const LOCALPART_EXCLUDED = /["&'/:<>@]/u; +const DOMAINPART_EXCLUDED = /[/@]/u; +const DNS_LABEL_SEPARATOR_AT_END = /[.\u3002\uff0e\uff61]$/u; +const IPV6_ZONE = /^(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+$/u; +const require = createRequire(import.meta.url); +const { idnHostname, punycode } = IdnHostname; +let precisInitialized = false; + +/** + * Prepare the RFC 7622 bare-JID shape used for ProtoXEP endpoints. + * Endpoint identities require a localpart and deliberately reject resources. + */ +export function normalizeEndpointJid(value: string): string | null { + if (value.includes('/') || value.indexOf('@') <= 0 || value.indexOf('@') !== value.lastIndexOf('@')) return null; + const [rawLocal, rawDomain] = value.split('@'); + const local = normalizeLocalpart(rawLocal!); + const domain = normalizeDomain(rawDomain!); + if (!local || !domain || LOCALPART_EXCLUDED.test(local) || utf8Length(local) > 1023 || utf8Length(domain) > 1023) { + return null; + } + return `${local}@${domain}`; +} + +export function isNormalizedEndpointJid(value: string): boolean { + return normalizeEndpointJid(value) === value; +} + +export function sameEndpointJid(left: string, right: string): boolean { + const preparedLeft = normalizeEndpointJid(left); + return preparedLeft !== null && preparedLeft === normalizeEndpointJid(right); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).length; +} + +function normalizeLocalpart(value: string): string | null { + initializePrecis(); + try { + return usernamecasemapped_enforce(value) as string; + } catch (error) { + if (error instanceof Error || typeof error === 'string') return null; + throw error; + } +} + +function initializePrecis(): void { + if (precisInitialized) return; + const wasmPath = require.resolve('precis-wasm/precis_wasm_bg.wasm'); + initSync({ module: readFileSync(wasmPath) }); + precisInitialized = true; +} + +function normalizeDomain(value: string): string | null { + const withoutFinalSeparator = value.replace(DNS_LABEL_SEPARATOR_AT_END, ''); + if (!withoutFinalSeparator || DOMAINPART_EXCLUDED.test(withoutFinalSeparator)) { + return null; + } + + const ipLiteral = normalizeIpLiteral(withoutFinalSeparator); + if (ipLiteral !== undefined) return ipLiteral; + + try { + const ascii = idnHostname(withoutFinalSeparator); + const unicode = punycode.toUnicode(ascii).normalize('NFC').toLowerCase().normalize('NFC'); + return idnHostname(unicode) === ascii ? unicode : null; + } catch (error) { + if (error instanceof Error) return null; + throw error; + } +} + +function normalizeIpLiteral(value: string): string | null | undefined { + if (!value.startsWith('[') && !value.endsWith(']')) return undefined; + if (!value.startsWith('[') || !value.endsWith(']')) return null; + + const content = value.slice(1, -1); + const zoneDelimiter = content.indexOf('%25'); + const address = zoneDelimiter === -1 ? content : content.slice(0, zoneDelimiter); + const zone = zoneDelimiter === -1 ? undefined : content.slice(zoneDelimiter + 3); + if (isIP(address) !== 6 || (zone !== undefined && !IPV6_ZONE.test(zone))) return null; + + const hostname = new URL(`http://[${address}]/`).hostname.toLowerCase(); + if (zone === undefined) return hostname; + const normalizedZone = zone.replace(/%[0-9A-Fa-f]{2}/gu, (encoded) => encoded.toUpperCase()); + return `${hostname.slice(0, -1)}%25${normalizedZone}]`; +} diff --git a/packages/agent-xmpp/protocol/src/namespaces.ts b/packages/agent-xmpp/protocol/src/namespaces.ts new file mode 100644 index 000000000..5a4dac217 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/namespaces.ts @@ -0,0 +1,63 @@ +/** ProtoXEP XMPP Agent Gateway 0.0.3 protocol constants. */ +export interface AgentXmppNamespaces { + directory: typeof AGENT_DIRECTORY_NS; + api: typeof AGENT_API_NS; + manifest: typeof AGENT_MANIFEST_FEATURE; + schema: typeof AGENT_SCHEMA_FEATURE; + selfRegister: typeof AGENT_SELF_REGISTER_FEATURE; + admin: typeof AGENT_ADMIN_FEATURE; + tools: typeof AGENT_TOOLS_NS; + tool: typeof AGENT_TOOL_NS; + endpoint: typeof AGENT_ENDPOINT_NS; + endpointInfo: typeof AGENT_ENDPOINT_INFO_FORM; + toolInfo: typeof AGENT_TOOL_INFO_FORM; + task: typeof AGENT_TASK_NS; + progress: typeof AGENT_TASK_PROGRESS_FEATURE; + cancel: typeof AGENT_TASK_CANCEL_FEATURE; + input: typeof AGENT_TASK_INPUT_FEATURE; + hashes: typeof HASHES_NS; + rsm: typeof RSM_NS; +} + +export const AGENT_DIRECTORY_NS = 'urn:xmpp:agent-directory:0'; +export const AGENT_API_NS = 'urn:xmpp:agent-api:0'; +export const AGENT_MANIFEST_FEATURE = `${AGENT_API_NS}#manifest` as const; +export const AGENT_SCHEMA_FEATURE = `${AGENT_API_NS}#schema` as const; +export const AGENT_SELF_REGISTER_FEATURE = `${AGENT_API_NS}#self-register` as const; +export const AGENT_ADMIN_FEATURE = `${AGENT_API_NS}#admin` as const; +export const AGENT_TOOLS_NS = 'urn:xmpp:agent-tools:0'; +export const AGENT_TOOL_NS = 'urn:xmpp:agent-tool:0'; +export const AGENT_ENDPOINT_NS = 'urn:xmpp:agent-endpoint:0'; +export const AGENT_ENDPOINT_INFO_FORM = 'urn:xmpp:agent-endpoint-info:0'; +export const AGENT_TOOL_INFO_FORM = 'urn:xmpp:agent-tool-info:0'; +export const AGENT_TASK_NS = 'urn:xmpp:agent-task:0'; +export const AGENT_TASK_PROGRESS_FEATURE = `${AGENT_TASK_NS}#progress` as const; +export const AGENT_TASK_CANCEL_FEATURE = `${AGENT_TASK_NS}#cancel` as const; +export const AGENT_TASK_INPUT_FEATURE = `${AGENT_TASK_NS}#input` as const; +export const HASHES_NS = 'urn:xmpp:hashes:2'; +export const RSM_NS = 'http://jabber.org/protocol/rsm'; + +export const DEFAULT_PROTOCOL_NAMESPACES: Readonly = Object.freeze({ + directory: AGENT_DIRECTORY_NS, + api: AGENT_API_NS, + manifest: AGENT_MANIFEST_FEATURE, + schema: AGENT_SCHEMA_FEATURE, + selfRegister: AGENT_SELF_REGISTER_FEATURE, + admin: AGENT_ADMIN_FEATURE, + tools: AGENT_TOOLS_NS, + tool: AGENT_TOOL_NS, + endpoint: AGENT_ENDPOINT_NS, + endpointInfo: AGENT_ENDPOINT_INFO_FORM, + toolInfo: AGENT_TOOL_INFO_FORM, + task: AGENT_TASK_NS, + progress: AGENT_TASK_PROGRESS_FEATURE, + cancel: AGENT_TASK_CANCEL_FEATURE, + input: AGENT_TASK_INPUT_FEATURE, + hashes: HASHES_NS, + rsm: RSM_NS, +}); + +export const AGENT_MANIFEST_SPEC_VERSION = '0'; +export const AGENT_API_SPEC_VERSION = AGENT_MANIFEST_SPEC_VERSION; +export const JSON_MEDIA_TYPE = 'application/json'; +export const JSON_SCHEMA_MEDIA_TYPE = 'application/schema+json'; diff --git a/packages/agent-xmpp/protocol/src/strict-json.test.ts b/packages/agent-xmpp/protocol/src/strict-json.test.ts new file mode 100644 index 000000000..af06604fd --- /dev/null +++ b/packages/agent-xmpp/protocol/src/strict-json.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'bun:test'; + +import { parseStrictJson } from './strict-json.js'; + +describe('parseStrictJson', () => { + it('parses many numeric tokens without copying each remaining suffix', () => { + const values = Array.from({ length: 20_000 }, (_, index) => index % 10); + expect(parseStrictJson(JSON.stringify(values))).toEqual(values); + }); +}); diff --git a/packages/agent-xmpp/protocol/src/strict-json.ts b/packages/agent-xmpp/protocol/src/strict-json.ts new file mode 100644 index 000000000..d368e3cbb --- /dev/null +++ b/packages/agent-xmpp/protocol/src/strict-json.ts @@ -0,0 +1,181 @@ +export interface StrictJsonLimits { + maxBytes: number; + maxDepth: number; + maxStringBytes: number; + maxMembers: number; +} + +export const DEFAULT_JSON_LIMITS: Readonly = Object.freeze({ + maxBytes: 1_048_576, + maxDepth: 64, + maxStringBytes: 1_048_576, + maxMembers: 100_000, +}); + +export class JsonResourceLimitError extends Error {} + +const utf8Length = (value: string): number => new TextEncoder().encode(value).length; +const NUMBER_PATTERN = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y; + +/** A bounded JSON parser that detects duplicate object names before information is lost. */ +export function parseStrictJson(text: string, limits: Partial = {}): unknown { + const resolved = { ...DEFAULT_JSON_LIMITS, ...limits }; + assertJsonLimits(resolved); + if (utf8Length(text) > resolved.maxBytes) throw new JsonResourceLimitError('JSON payload exceeds byte limit'); + let offset = 0; + let members = 0; + const fail = (message: string): never => { + throw new Error(`${message} at JSON offset ${offset}`); + }; + const whitespace = (): void => { + while (offset < text.length && /[\t\n\r ]/.test(text[offset]!)) offset++; + }; + const parseString = (): string => { + if (text[offset++] !== '"') return fail('expected string'); + let result = ''; + while (offset < text.length) { + const character = text[offset++]!; + if (character === '"') { + if (utf8Length(result) > resolved.maxStringBytes) { + throw new JsonResourceLimitError(`JSON string exceeds byte limit at JSON offset ${offset}`); + } + assertUnicodeScalarString(result); + return result; + } + if (character === '\\') { + const escape = text[offset++]!; + const simple: Record = { + '"': '"', + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + }; + if (escape in simple) result += simple[escape]; + else if (escape === 'u') { + const hex = text.slice(offset, offset + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid Unicode escape'); + result += String.fromCharCode(Number.parseInt(hex, 16)); + offset += 4; + } else fail('invalid string escape'); + } else { + if (character.charCodeAt(0) < 0x20) fail('unescaped control character'); + result += character; + } + } + return fail('unterminated string'); + }; + const parseNumber = (): number => { + NUMBER_PATTERN.lastIndex = offset; + const match = NUMBER_PATTERN.exec(text); + if (!match) return fail('invalid number'); + offset = NUMBER_PATTERN.lastIndex; + const number = Number(match[0]); + if (!Number.isFinite(number)) fail('number is not finite'); + if (/^-?[0-9]+$/.test(match[0])) { + const integer = BigInt(match[0]); + if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER)) { + fail('integer is outside the lossless range'); + } + } + return number; + }; + const parseValue = (depth: number): unknown => { + if (depth > resolved.maxDepth) { + throw new JsonResourceLimitError(`JSON nesting exceeds depth limit at JSON offset ${offset}`); + } + whitespace(); + const character = text[offset]; + if (character === '"') return parseString(); + if (character === '{') { + offset++; + const result: Record = {}; + const names = new Set(); + whitespace(); + if (text[offset] === '}') { + offset++; + return result; + } + while (true) { + whitespace(); + if (text[offset] !== '"') fail('expected object member name'); + const name = parseString(); + if (names.has(name)) fail(`duplicate object member ${JSON.stringify(name)}`); + names.add(name); + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError(`JSON member count exceeds limit at JSON offset ${offset}`); + } + whitespace(); + if (text[offset++] !== ':') fail('expected colon'); + result[name] = parseValue(depth + 1); + whitespace(); + const separator = text[offset++]; + if (separator === '}') return result; + if (separator !== ',') fail('expected comma or object end'); + } + } + if (character === '[') { + offset++; + const result: unknown[] = []; + whitespace(); + if (text[offset] === ']') { + offset++; + return result; + } + while (true) { + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError(`JSON member count exceeds limit at JSON offset ${offset}`); + } + result.push(parseValue(depth + 1)); + whitespace(); + const separator = text[offset++]; + if (separator === ']') return result; + if (separator !== ',') fail('expected comma or array end'); + } + } + if (text.startsWith('true', offset)) { + offset += 4; + return true; + } + if (text.startsWith('false', offset)) { + offset += 5; + return false; + } + if (text.startsWith('null', offset)) { + offset += 4; + return null; + } + if (character === '-' || (character !== undefined && /[0-9]/.test(character))) return parseNumber(); + return fail('expected JSON value'); + }; + const value = parseValue(0); + whitespace(); + if (offset !== text.length) fail('trailing data'); + return value; +} + +function assertJsonLimits(limits: StrictJsonLimits): void { + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) { + throw new Error(`${name} must be a ${name === 'maxDepth' ? 'non-negative' : 'positive'} safe integer`); + } + } +} + +export function assertUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const low = value.charCodeAt(index + 1); + if (!Number.isInteger(low) || low < 0xdc00 || low > 0xdfff) { + throw new Error('lone high surrogate is not permitted'); + } + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error('lone low surrogate is not permitted'); + } + } +} diff --git a/packages/agent-xmpp/protocol/tsconfig.json b/packages/agent-xmpp/protocol/tsconfig.json new file mode 100644 index 000000000..f1a44108c --- /dev/null +++ b/packages/agent-xmpp/protocol/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql new file mode 100644 index 000000000..e5b37b192 --- /dev/null +++ b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql @@ -0,0 +1,33 @@ +CREATE TYPE "XmppAgentTaskState" AS ENUM ('ACCEPTED', 'RUNNING', 'CANCELLING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +CREATE TABLE "xmppAgentTask" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "callerJid" TEXT NOT NULL, + "notificationJid" TEXT NOT NULL, + "targetJid" TEXT NOT NULL, + "tool" TEXT NOT NULL, + "apiVersion" TEXT NOT NULL, + "manifestHash" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "arguments" JSONB NOT NULL, + "state" "XmppAgentTaskState" NOT NULL DEFAULT 'ACCEPTED', + "revision" INTEGER NOT NULL DEFAULT 0, + "progress" JSONB, + "result" JSONB, + "error" JSONB, + "summary" TEXT, + "eveSessionId" TEXT, + "deadline" TIMESTAMP(3), + "retainUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "xmppAgentTask_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "xmppAgentTask_organizationId_callerJid_targetJid_requestId_key" ON "xmppAgentTask"("organizationId", "callerJid", "targetJid", "requestId"); +CREATE INDEX "xmppAgentTask_organizationId_state_updatedAt_idx" ON "xmppAgentTask"("organizationId", "state", "updatedAt"); +CREATE INDEX "xmppAgentTask_retainUntil_idx" ON "xmppAgentTask"("retainUntil"); + +ALTER TABLE "xmppAgentTask" ADD CONSTRAINT "xmppAgentTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql b/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql new file mode 100644 index 000000000..79e4a56f8 --- /dev/null +++ b/packages/db/prisma/migrations/20260827180000_xmpp_task_leases/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "xmppAgentTask" +ADD COLUMN "ownerId" TEXT, +ADD COLUMN "leaseUntil" TIMESTAMP(3); + +CREATE INDEX "xmppAgentTask_organizationId_state_leaseUntil_idx" +ON "xmppAgentTask"("organizationId", "state", "leaseUntil"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..aeec49033 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -209,6 +209,15 @@ enum AgentConversationKind { BUILDER } +enum XmppAgentTaskState { + ACCEPTED + RUNNING + CANCELLING + COMPLETED + FAILED + CANCELLED +} + enum AgentDefinitionStatus { DRAFT DEPLOYING @@ -496,6 +505,40 @@ model AgentTask { @@map("agentTask") } +model XmppAgentTask { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requestId String + callerJid String + notificationJid String + targetJid String + tool String + apiVersion String + manifestHash String + fingerprint String + arguments Json + state XmppAgentTaskState @default(ACCEPTED) + revision Int @default(0) + progress Json? + result Json? + error Json? + summary String? + eveSessionId String? + ownerId String? + leaseUntil DateTime? + deadline DateTime? + retainUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, callerJid, targetJid, requestId]) + @@index([organizationId, state, updatedAt]) + @@index([organizationId, state, leaseUntil]) + @@index([retainUntil]) + @@map("xmppAgentTask") +} + model AgentEvent { id String @id sessionId String @@ -1486,6 +1529,7 @@ model Organization { website String? members Member[] invitations Invitation[] + xmppAgentTasks XmppAgentTask[] @@unique([slug]) @@map("organization") diff --git a/turbo.json b/turbo.json index 110f9bd94..65aae934d 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,28 @@ "VERCEL_OIDC_TOKEN", "AGENT_URL", "AGENT_BRIDGE_SECRET", + "XMPP_COMPONENT_ENABLED", + "XMPP_COMPONENT_JID", + "XMPP_COMPONENT_SECRET", + "XMPP_COMPONENT_SERVICE", + "XMPP_ORGANIZATION_ID", + "XMPP_DEFAULT_AGENT_JID", + "XMPP_AGENT_DOMAIN", + "XMPP_SERVER_DOMAIN", + "XMPP_GATEWAY_ID", + "XMPP_AGENT_VERSION", + "XMPP_ALLOWED_CALLER_DOMAINS", + "XMPP_ALLOW_DESTRUCTIVE_CALLERS", + "XMPP_XML_LANG", + "XMPP_RECEIPT_TIMEOUT_MS", + "XMPP_RECEIPT_MAX_RESENDS", + "XMPP_RECEIPT_SWEEP_MS", + "XMPP_RECONNECT_INITIAL_MS", + "XMPP_RECONNECT_MAX_MS", + "XMPP_PING_INTERVAL_MS", + "XMPP_PING_TIMEOUT_MS", + "XMPP_PING_FAILURE_THRESHOLD", + "XMPP_MAX_PENDING_IQ_REQUESTS", "CRM_TELEMETRY_DISABLED", "DO_NOT_TRACK", "VERCEL", From 8fb82ccb44f6731c0cda6b2e59c53adaff60e25c Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sun, 30 Aug 2026 20:34:02 -0500 Subject: [PATCH 03/27] Retain minimal headless construction CRM scope (#2) * feat: add general contractor project workflow * refactor: DRY contact name and primary contact derivation * fix(api): satisfy anti-slop lint in deal contact and gc field updates * fix: address PR review feedback on project workflow * fix: require customers for construction projects * docs: map database to construction workflow * refactor: remove unsupported construction fields * fix: align customer deletion copy * docs: track construction changes * refactor: restore upstream application UI * docs: update construction change map * fix: preserve upstream project mappings * test: isolate auth environment defaults * feat: retain minimal construction CRM changes * docs: align construction mapping terms * fix: restore CRM vocabulary in construction POC * fix: consolidate additive CRM migration --------- Co-authored-by: Roman Shterenzon --- apps/agent/agent/lib/custom-agent-dispatch.ts | 1 + .../durable-agent-runtime.integration.spec.ts | 23 ++- apps/api/src/agent/agent-runs.service.ts | 2 + apps/api/src/companies/companies.service.ts | 10 + apps/api/src/deals/deals.contracts.ts | 28 +++ apps/api/src/deals/deals.service.ts | 52 ++++++ apps/api/test/agent-events.spec.ts | 4 +- apps/api/test/agent-runs.spec.ts | 21 +++ apps/api/test/bulk.spec.ts | 17 +- apps/api/test/deal-contacts.spec.ts | 112 ++++++++++++ apps/api/test/record-delete.spec.ts | 79 ++++++-- apps/api/test/setup.ts | 16 ++ docs/api.md | 1 + docs/construction-change-map.md | 173 ++++++++++++++++++ .../migration.sql | 72 ++++++++ packages/db/prisma/schema.prisma | 66 +++++++ 16 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 docs/construction-change-map.md create mode 100644 packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index aff74d24e..e62ba967d 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -361,6 +361,7 @@ export async function queueEventAgentRuns( versionId: trigger.versionId, triggerId: trigger.id, triggerType: "EVENT", + dealId: recordKind === "deal" ? recordId : null, idempotencyKey, correlationId: `trigger:${trigger.id}:event:${task.id}`, input: { diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index 27f74db78..d7dd10523 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -227,17 +227,26 @@ describe("durable custom-agent runtime", () => { }, select: { id: true }, }); + const eventDeal = await db.deal.create({ + data: { + id: `event-deal-${suffix}`, + name: "Event deal", + ownerId: userId, + companyId, + }, + select: { id: true }, + }); const occurredAt = new Date().toISOString(); const task = { id: `event-task-${suffix}`, contactId: null, companyId: null, - dealId: `event-deal-${suffix}`, + dealId: eventDeal.id, payload: { type: "deal.closed", - record: { kind: "deal", id: `event-deal-${suffix}` }, + record: { kind: "deal", id: eventDeal.id }, occurredAt, - data: { from: "NEGOTIATION", to: "CLOSED_WON" }, + data: { from: "DECISION_MAKER_BOUGHT_IN", to: "CLOSED_WON" }, }, }; @@ -249,22 +258,24 @@ describe("durable custom-agent runtime", () => { const runs = await db.agentRun.findMany({ where: { triggerId: trigger.id }, - select: { triggerType: true, status: true, input: true }, + select: { triggerType: true, status: true, dealId: true, input: true }, }); expect(runs).toEqual([ { triggerType: "EVENT", status: "QUEUED", + dealId: eventDeal.id, input: { event: { type: "deal.closed", occurredAt, - data: { from: "NEGOTIATION", to: "CLOSED_WON" }, + data: { from: "DECISION_MAKER_BOUGHT_IN", to: "CLOSED_WON" }, }, - record: { kind: "deal", id: `event-deal-${suffix}` }, + record: { kind: "deal", id: eventDeal.id }, }, }, ]); + await db.deal.delete({ where: { id: eventDeal.id } }); }); it("advances a due trigger only when its run is committed", async () => { diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts index 6cff770b4..66a06b2f8 100644 --- a/apps/api/src/agent/agent-runs.service.ts +++ b/apps/api/src/agent/agent-runs.service.ts @@ -260,6 +260,7 @@ export class AgentRunsService { versionId: true, triggerId: true, triggerType: true, + dealId: true, input: true, }, }); @@ -302,6 +303,7 @@ export class AgentRunsService { initiatedById: userId, triggerId: previous.triggerId, triggerType: previous.triggerType, + dealId: previous.dealId, input: previous.input ?? Prisma.DbNull, idempotencyKey: input.clientRequestId, correlationId: randomUUID(), diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 3960dc33c..46d4c6081 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -453,6 +453,16 @@ export class CompaniesService { return null; } + const deal = await tx.deal.findFirst({ + where: { companyId: id }, + select: { id: true }, + }); + if (deal) { + throw new ConflictException( + "Delete this company's deals before deleting the company.", + ); + } + const targets = await this.stamp.targetsOf( { OR: [{ companyId: id }, { deal: { companyId: id } }] }, tx, diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts index a80b38b56..3613bc1a2 100644 --- a/apps/api/src/deals/deals.contracts.ts +++ b/apps/api/src/deals/deals.contracts.ts @@ -49,6 +49,13 @@ export const dealCreateInput = z.object({ amountCents, currency: currencyCode.optional(), expectedCloseDate: z.string().nullable().optional(), + leadSource: z.string().trim().nullable().optional(), + projectType: z.string().trim().nullable().optional(), + addressLine1: z.string().trim().nullable().optional(), + addressLine2: z.string().trim().nullable().optional(), + city: z.string().trim().nullable().optional(), + state: z.string().trim().nullable().optional(), + postalCode: z.string().trim().nullable().optional(), }); export type DealCreateInput = z.infer; @@ -61,6 +68,13 @@ const dealUpdateInput = z.object({ amountCents, currency: currencyCode.optional(), expectedCloseDate: z.string().nullable().optional(), + leadSource: z.string().trim().nullable().optional(), + projectType: z.string().trim().nullable().optional(), + addressLine1: z.string().trim().nullable().optional(), + addressLine2: z.string().trim().nullable().optional(), + city: z.string().trim().nullable().optional(), + state: z.string().trim().nullable().optional(), + postalCode: z.string().trim().nullable().optional(), fields: recordFieldValues.optional(), }); @@ -200,6 +214,13 @@ const dealListRowOutput = z.object({ currency: z.string(), company: dealCompanyOutput, owner: dealOwnerOutput, + leadSource: z.string().nullable(), + projectType: z.string().nullable(), + addressLine1: z.string().nullable(), + addressLine2: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + postalCode: z.string().nullable(), amountCents: z.number().nullable(), baseAmountCents: z.number().nullable(), expectedCloseDate: z.string().nullable(), @@ -233,6 +254,13 @@ export const dealDetailOutput = z.object({ closedReason: z.string().nullable(), company: dealCompanyDetailOutput, owner: dealOwnerOutput, + leadSource: z.string().nullable(), + projectType: z.string().nullable(), + addressLine1: z.string().nullable(), + addressLine2: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + postalCode: z.string().nullable(), fields: z.array(recordFieldOutput), amountCents: z.number().nullable(), baseAmountCents: z.number().nullable(), diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 4a4d01c86..be49b3903 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -87,6 +87,17 @@ const CONTACT_SELECT = { const LOSING = new Set(LOSING_DEAL_STAGES); +const DEAL_TEXT_FIELDS = [ + "leadSource", + "projectType", + "addressLine1", + "addressLine2", + "city", + "state", + "postalCode", +] as const; +type DealTextField = (typeof DEAL_TEXT_FIELDS)[number]; + const SORTABLE: OrderByColumns = { name: (dir) => [{ name: dir }], company: (dir) => [{ company: { name: dir } }, { name: "asc" }], @@ -130,6 +141,13 @@ export class DealsService { id: true, name: true, stage: true, + leadSource: true, + projectType: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, amount: true, currency: true, baseAmount: true, @@ -199,6 +217,13 @@ export class DealsService { name: true, description: true, stage: true, + leadSource: true, + projectType: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, stageChangedAt: true, amount: true, currency: true, @@ -277,6 +302,7 @@ export class DealsService { currency, ...fx, expectedCloseDate: parseDate(input.expectedCloseDate), + ...dealTextInput(input), }, select: { id: true, name: true, companyId: true }, }); @@ -330,6 +356,10 @@ export class DealsService { if (input.expectedCloseDate !== undefined) { data.expectedCloseDate = parseDate(input.expectedCloseDate); } + const dealFields = dealTextInput(input); + for (const field of DEAL_TEXT_FIELDS) { + if (dealFields[field] !== undefined) data[field] = dealFields[field]; + } if (input.amountCents !== undefined || input.currency !== undefined) { const current = await this.db.deal.findUnique({ @@ -881,6 +911,28 @@ function roleOrNull(value: string | null): string | null { return value === null ? null : blankToNull(value); } +function textOrNull( + value: string | null | undefined, +): string | null | undefined { + return value === undefined + ? undefined + : value === null + ? null + : blankToNull(value); +} + +type DealTextFields = Record; + +function dealTextInput( + input: Pick, +): DealTextFields { + const out = {} as DealTextFields; + for (const field of DEAL_TEXT_FIELDS) { + out[field] = textOrNull(input[field]); + } + return out; +} + function parseDate(value: string | null | undefined): Date | null { if (value === null || value === undefined || value === "") return null; const date = new Date(value); diff --git a/apps/api/test/agent-events.spec.ts b/apps/api/test/agent-events.spec.ts index 2fe38f520..91faa47f4 100644 --- a/apps/api/test/agent-events.spec.ts +++ b/apps/api/test/agent-events.spec.ts @@ -122,7 +122,7 @@ describe("CRM agent events", () => { type: "deal.closed", record: { kind: "deal", id: dealId }, occurredAt: closedAt, - data: { companyId, from: "NEGOTIATION", to: "CLOSED_WON" }, + data: { companyId, from: "DECISION_MAKER_BOUGHT_IN", to: "CLOSED_WON" }, }); }); @@ -155,7 +155,7 @@ describe("CRM agent events", () => { type: "deal.closed", record: { kind: "deal", id: dealId }, occurredAt: closedAt.toISOString(), - data: { companyId, from: "NEGOTIATION", to: "CLOSED_WON" }, + data: { companyId, from: "DECISION_MAKER_BOUGHT_IN", to: "CLOSED_WON" }, }, finishedAt: null, }); diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 22d4d8c40..bbc5e4513 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -12,6 +12,8 @@ const outsiderId = `agent-run-outsider-${suffix}`; const memberId = `agent-run-member-${suffix}`; let agentId = ""; let versionId = ""; +let companyId = ""; +let dealId = ""; let pokeCount = 0; let cancelPokes: string[] = []; const trigger = { @@ -77,6 +79,16 @@ beforeAll(async () => { select: { id: true }, }); versionId = version.id; + const company = await db.company.create({ + data: { name: `Agent run company ${suffix}` }, + select: { id: true }, + }); + companyId = company.id; + const deal = await db.deal.create({ + data: { name: "Agent run deal", companyId, ownerId: userId }, + select: { id: true }, + }); + dealId = deal.id; await db.agentDefinition.update({ where: { id: agentId }, data: { currentVersionId: versionId }, @@ -115,6 +127,8 @@ afterAll(async () => { }); } await db.member.deleteMany({ where: { id: memberId } }); + await db.deal.deleteMany({ where: { id: dealId } }); + await db.company.deleteMany({ where: { id: companyId } }); await db.user.deleteMany({ where: { id: { in: [userId, outsiderId] } } }); }); @@ -322,6 +336,7 @@ describe("manual agent runs", () => { await db.agentRun.update({ where: { id: first.id }, data: { + dealId, status: "FAILED", errorCode: "TEST_FAILURE", errorMessage: "Broke before the retry.", @@ -359,6 +374,12 @@ describe("manual agent runs", () => { select: { versionId: true }, }), ).toEqual({ versionId }); + expect( + await db.agentRun.findUniqueOrThrow({ + where: { id: retried.id }, + select: { dealId: true }, + }), + ).toEqual({ dealId }); expect( await db.agentAuditEvent.findFirstOrThrow({ where: { agentId, type: "run.requested", requestId: clientRequestId }, diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index 9a0315188..6cd62ff3c 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -208,7 +208,7 @@ describe("purging a selection", () => { ).toBeNull(); }); - it("takes a company's deals with it", async () => { + it("refuses to delete a company that still has deals", async () => { const doomed = await companies.create({ name: `Doomed Co ${suffix}`, domain: `doomed-${domain}`, @@ -221,13 +221,20 @@ describe("purging a selection", () => { expect(await companies.bulkPurge([doomed.id])).toEqual({ requested: 1, - succeeded: 1, + succeeded: 0, skipped: 0, - failed: 0, - message: null, + failed: 1, + message: "Delete this company's deals before deleting the company.", }); - expect(await db.deal.findUnique({ where: { id: deal.id } })).toBeNull(); + expect( + await db.deal.findUnique({ + where: { id: deal.id }, + select: { companyId: true }, + }), + ).toEqual({ companyId: doomed.id }); + await db.deal.delete({ where: { id: deal.id } }); + await db.company.delete({ where: { id: doomed.id } }); }); }); diff --git a/apps/api/test/deal-contacts.spec.ts b/apps/api/test/deal-contacts.spec.ts index ccf9af4e4..53383fe98 100644 --- a/apps/api/test/deal-contacts.spec.ts +++ b/apps/api/test/deal-contacts.spec.ts @@ -93,6 +93,118 @@ beforeAll(async () => { afterAll(clean); describe("bringing a contact onto a deal", () => { + it("rejects a database deal without a company", async () => { + const attempt = + db.$executeRaw`INSERT INTO "deal" ("id", "name", "ownerId", "updatedAt") VALUES (${`no-company-${suffix}`}, ${`No company ${suffix}`}, ${userId}, ${new Date()})`.then( + async () => { + await db.deal.delete({ where: { id: `no-company-${suffix}` } }); + }, + ); + + await expect(attempt).rejects.toThrow(); + }); + + it("creates, updates, lists, and reads deal fields", async () => { + const deal = await deals.create({ + name: `Deal ${suffix}`, + companyId, + ownerId: userId, + leadSource: "Referral", + projectType: "Type A", + addressLine1: "1 Main Street", + addressLine2: "Unit 2", + city: "Austin", + state: "TX", + postalCode: "78701", + }); + + await deals.update(deal.id, { + leadSource: "Website", + projectType: "Type B", + addressLine1: "2 Main Street", + addressLine2: "Unit 3", + city: "Dallas", + state: "TX", + postalCode: "75201", + }); + + const detail = await deals.byId(deal.id); + expect(detail).toMatchObject({ + stage: "DEMO_BOOKED", + company: { id: companyId }, + leadSource: "Website", + projectType: "Type B", + addressLine1: "2 Main Street", + addressLine2: "Unit 3", + city: "Dallas", + state: "TX", + postalCode: "75201", + }); + + const list = await deals.list({ + q: "", + page: 1, + pageSize: 100, + sort: "createdAt", + dir: "desc", + status: "all", + owner: [], + stage: [], + closing: [], + fields: {}, + archived: false, + }); + expect(list.rows.find((row) => row.id === deal.id)).toMatchObject({ + leadSource: "Website", + projectType: "Type B", + addressLine1: "2 Main Street", + addressLine2: "Unit 3", + city: "Dallas", + state: "TX", + postalCode: "75201", + }); + + await deals.purge(deal.id); + }); + + it("stores a document and its line items on a deal", async () => { + const document = await db.document.create({ + data: { + dealId, + type: "ESTIMATE", + number: `DRAFT-${suffix}`, + status: "DRAFT", + recipientSnapshot: { name: "Recipient" }, + contractorSnapshot: { name: "Company" }, + projectSnapshot: { name: "Deal" }, + subtotal: "100.00", + tax: "8.25", + total: "108.25", + lineItems: { + create: { + description: "Line item", + quantity: "1.00", + unitPrice: "100.00", + total: "100.00", + position: 0, + }, + }, + }, + select: { + id: true, + currency: true, + issuedAt: true, + lineItems: { select: { description: true } }, + }, + }); + + expect(document.issuedAt).toBeNull(); + expect(document.currency).toBe("USD"); + expect(document.lineItems[0]?.description).toBe("Line item"); + + await db.document.delete({ where: { id: document.id } }); + }); + it("offers the people at the deal's company and nobody else", async () => { const options = await deals.contactOptions(dealId); const ids = options.map((option) => option.id); diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index cac084ab1..c02052c60 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -71,7 +71,11 @@ const ours = { OR: domains.map((host) => ({ email: { endsWith: `@${host}` } })), }; -async function parked(subject: { contactId?: string; companyId?: string }) { +async function parked(subject: { + contactId?: string; + companyId?: string; + dealId?: string; +}) { return db.agentTask.create({ data: { ...subject, @@ -105,6 +109,9 @@ async function clean() { }, }); await db.agentEvent.deleteMany({ where: { contactId: { in: contactIds } } }); + await db.deal.deleteMany({ + where: { OR: [{ companyId: { in: companyIds } }, { ownerId: userId }] }, + }); await db.contact.deleteMany({ where: ours }); await db.company.deleteMany({ where: { domain: { in: domains } } }); await db.suppressedContact.deleteMany({ where: ours }); @@ -246,7 +253,7 @@ describe("purging a contact", () => { }); describe("purging a company", () => { - it("takes its deals and leaves its people without a company", async () => { + it("refuses to delete a company that still has deals", async () => { const company = await companies.create({ name: "Doomed", domain: doomedDomain, @@ -262,25 +269,47 @@ describe("purging a company", () => { select: { id: true }, }); - await parked({ companyId: company.id }); - - expect(await companies.purge(company.id)).toEqual({ - id: company.id, - name: "Doomed", + const companyTask = await parked({ companyId: company.id }); + const dealTask = await parked({ + companyId: company.id, + dealId: deal.id, }); - expect(await db.deal.findUnique({ where: { id: deal.id } })).toBeNull(); + await expect(companies.purge(company.id)).rejects.toThrow( + "Delete this company's deals before deleting the company.", + ); + + expect( + await db.deal.findUnique({ + where: { id: deal.id }, + select: { companyId: true }, + }), + ).toEqual({ companyId: company.id }); expect(await db.agentTask.count({ where: { companyId: company.id } })).toBe( - 0, + 2, ); + expect( + await db.agentTask.findUnique({ where: { id: companyTask.id } }), + ).not.toBe(null); + expect( + await db.agentTask.findUnique({ + where: { id: dealTask.id }, + select: { companyId: true, dealId: true }, + }), + ).toEqual({ companyId: company.id, dealId: deal.id }); const survivor = await db.contact.findUnique({ where: { id: contact.id }, select: { companyId: true }, }); - expect(survivor?.companyId).toBeNull(); + expect(survivor?.companyId).toBe(company.id); + await db.agentTask.deleteMany({ + where: { id: { in: [companyTask.id, dealTask.id] } }, + }); + await db.deal.delete({ where: { id: deal.id } }); await db.contact.delete({ where: { id: contact.id } }); + await db.company.delete({ where: { id: company.id } }); }); }); @@ -333,7 +362,7 @@ describe("the activity stamps a purge leaves behind", () => { ).toEqual({ lastActivityAt: null }); }); - it("follow a deleted company through the deals it takes with it", async () => { + it("keeps deal activity when company deletion is refused", async () => { const company = await companies.create({ name: "Orphaner", domain: orphanDomain, @@ -353,6 +382,7 @@ describe("the activity stamps a purge leaves behind", () => { data: { type: "MEETING", subject: "Only ever attached to the deal", + companyId: company.id, contactId: contact.id, dealId: deal.id, createdById: userId, @@ -361,15 +391,32 @@ describe("the activity stamps a purge leaves behind", () => { }); await stamp.touch({ contactId: contact.id, dealId: deal.id }, at); - await companies.purge(company.id); + await expect(companies.purge(company.id)).rejects.toThrow( + "Delete this company's deals before deleting the company.", + ); expect( - await db.contact.findUnique({ - where: { id: contact.id }, - select: { companyId: true, lastActivityAt: true }, + await db.deal.findUnique({ + where: { id: deal.id }, + select: { companyId: true }, }), - ).toEqual({ companyId: null, lastActivityAt: null }); + ).toEqual({ companyId: company.id }); + expect( + await db.activity.findFirst({ + where: { dealId: deal.id }, + select: { companyId: true, dealId: true }, + }), + ).toEqual({ companyId: company.id, dealId: deal.id }); + + const survivor = await db.contact.findUnique({ + where: { id: contact.id }, + select: { companyId: true, lastActivityAt: true }, + }); + expect(survivor?.companyId).toBe(company.id); + expect(survivor?.lastActivityAt).not.toBeNull(); await db.contact.delete({ where: { id: contact.id } }); + await db.deal.delete({ where: { id: deal.id } }); + await db.company.delete({ where: { id: company.id } }); }); }); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index f8ec45ffe..3f4eaa08d 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -1,5 +1,21 @@ import { afterAll } from "bun:test"; +const fallback = (key: string, value: string) => { + if (!process.env[key]) { + process.env[key] = value; + } +}; + +fallback( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/crm?schema=public", +); +fallback("BETTER_AUTH_SECRET", "test-secret-at-least-32-characters-long"); +fallback("API_URL", "http://localhost:3001"); +fallback("ALLOWED_SIGN_IN", "example.com"); +fallback("GOOGLE_CLIENT_ID", "test-google-client-id"); +fallback("GOOGLE_CLIENT_SECRET", "test-google-client-secret"); + afterAll(async () => { if (!process.env.DATABASE_URL) return; const { db } = await import("@crm/db"); diff --git a/docs/api.md b/docs/api.md index 93f4df63f..1a9bd3632 100644 --- a/docs/api.md +++ b/docs/api.md @@ -273,6 +273,7 @@ Below is `purge`'s contract — everything that used to be `delete`'s: transaction**. Never automatic. - **Purging a company does not suppress its domain** — its people survive with no company, and domain suppression stays the explicit Settings → Connections control. +- **A company with a deal cannot be purged.** Delete its deals first. - **Clear `AgentTask` and `AgentEvent` yourself** — they carry `contactId`/`companyId` with no foreign key, so nothing cascades. - **Recompute `lastActivityAt` on exactly the records the purge reached.** diff --git a/docs/construction-change-map.md b/docs/construction-change-map.md new file mode 100644 index 000000000..4295b66e9 --- /dev/null +++ b/docs/construction-change-map.md @@ -0,0 +1,173 @@ +# CompCRM construction change map + +This is the canonical Markdown map for the headless construction POC. It +records the original CRM names, their construction meanings, and every +retained database or parameter change. + +| Item | Value | +| --- | --- | +| Base branch | `origin/master` | +| Base SHA | `c7fc76ee5074152f691c55a9ce5fb017521fefc4` | +| Audited branch | `feat/construction-crm` | +| Audited SHA before this implementation | `c505c3e1e868de74f68666a1011fe51d4d9d2856` | +| Last updated | 2026-08-29 | + +## Vocabulary and boundary + +| Original CRM name | Construction meaning | Stored and API name | +| --- | --- | --- | +| `Company` | Customer or Household | `Company`, `company` | +| `Contact` | Person | `Contact`, `contact` | +| `Deal` | Project | `Deal`, `deal` | +| `DealContact` | Project Contact | `DealContact`, `dealContact` | +| `DealStage` | Project Status | `DealStage`, `stage` | +| `Artifact` | Project File | `Artifact`, `artifact` | +| `Document` | Estimate or Invoice | `Document`, `document` | +| `DocumentLineItem` | Estimate or Invoice Line | `DocumentLineItem`, `documentLineItem` | + +The POC has one human GC role. Other operational roles are bots or agents. +`User`, `Organization`, and `Member` remain inherited access infrastructure. +The future middleware may translate construction terms into CRM terms. This +branch does not implement that middleware. + +## DealStage mapping + +The stored enum and the API `stage` parameter retain the original values. + +| Original `DealStage` value | Construction status | +| --- | --- | +| `DEMO_BOOKED` | Lead | +| `QUALIFIED_TO_BUY` | Estimating | +| `CONTRACT_SENT` | Contracted | +| `DECISION_MAKER_BOUGHT_IN` | In progress | +| `CLOSED_WON` | Complete | +| `CLOSED_LOST` | Lost | +| `UNQUALIFIED_TO_BUY` | Disqualified | + +The default remains `DEMO_BOOKED`. No code mapping or enum rename is retained. + +## Retained database changes + +### Existing models + +| Model or field | Retained change | +| --- | --- | +| `Deal.leadSource` | Optional `String` for lead source. | +| `Deal.projectType` | Optional `String` for construction type. | +| `Deal.addressLine1` | Optional `String` for the job-site address. | +| `Deal.addressLine2` | Optional `String` for a second address line. | +| `Deal.city` | Optional `String` for the job-site city. | +| `Deal.state` | Optional `String` for the job-site state or region. | +| `Deal.postalCode` | Optional `String` for the job-site postal code. | +| `AgentRun.dealId` | Optional `String` with a nullable `Deal` relation, `ON DELETE SET NULL`, and an index on `(dealId, createdAt)`. | +| `Deal.agentRuns` | Reverse `AgentRun[]` relation. | +| `Deal.artifacts` | Reverse `Artifact[]` relation. | +| `Deal.documents` | Reverse `Document[]` relation. | + +`Deal.companyId` remains required. Its original Company foreign key and +`ON DELETE CASCADE` behavior remain. `DealStage` remains the original enum. +`Contact` has no `displayName` or `businessName`. `DealContact` has no +`isPrimary` field or unique primary-contact index. + +### `Artifact` + +| Field | Definition | +| --- | --- | +| `id` | Required CUID string primary key. | +| `dealId` | Required foreign key to `Deal.id`. | +| `deal` | Required relation to `Deal`, cascade on delete. | +| `type` | Required string. | +| `fileName` | Required string. | +| `storageKey` | Required string. | +| `createdAt` | Timestamp with current-time default. | + +Index: `(dealId, createdAt)`. + +### `Document` + +| Field | Definition | +| --- | --- | +| `id` | Required CUID string primary key. | +| `dealId` | Required foreign key to `Deal.id`. | +| `deal` | Required relation to `Deal`, cascade on delete. | +| `type` | Required string. | +| `number` | Required string. | +| `status` | Required string. | +| `currency` | Required string, default `USD`. | +| `issuedAt` | Optional timestamp. | +| `dueAt` | Optional timestamp. | +| `recipientSnapshot` | Required JSON. | +| `contractorSnapshot` | Required JSON. | +| `projectSnapshot` | Required JSON. | +| `subtotal` | Required `Decimal(14,2)`. | +| `tax` | Required `Decimal(14,2)`. | +| `total` | Required `Decimal(14,2)`. | +| `lineItems` | `DocumentLineItem[]` relation. | +| `createdAt` | Timestamp with current-time default. | +| `updatedAt` | Required timestamp with Prisma `@updatedAt`. | + +Index: `(dealId, createdAt)`. + +### `DocumentLineItem` + +| Field | Definition | +| --- | --- | +| `id` | Required CUID string primary key. | +| `documentId` | Required foreign key to `Document.id`. | +| `document` | Required relation to `Document`, cascade on delete. | +| `description` | Required string. | +| `quantity` | Required `Decimal(14,2)`. | +| `unitPrice` | Required `Decimal(14,2)`. | +| `total` | Required `Decimal(14,2)`. | +| `position` | Required integer. | + +Index: `(documentId, position)`. + +## Additive migration + +`20260829120000_add_deal_metadata_documents` is the single branch migration. +It adds the seven optional Deal text fields, the optional AgentRun Deal +relation and index, and the Artifact, Document, and DocumentLineItem tables +with their required indexes and delete behavior. + +The migration adds no data changes. It does not change DealStage, Deal.companyId, +Contact, DealContact, Activity, or SavedView. + +## Retained API parameters and behavior + +| Surface | Retained change | +| --- | --- | +| Deal create | Accepts optional `leadSource`, `projectType`, `addressLine1`, `addressLine2`, `city`, `state`, and `postalCode`. Existing `name`, required `companyId`, required `ownerId`, physical `stage`, amount, currency, and expected-close inputs remain unchanged. | +| Deal update | Accepts the same seven optional text fields. Blank strings become null. Existing physical CRM parameters remain unchanged. | +| Deal list | Returns the seven fields on each row. Existing physical output names remain unchanged. | +| Deal detail | Returns the seven fields. Existing physical output names remain unchanged. | +| Company purge | Rejects deletion while a Deal references the Company, with the original CRM conflict text. Other original purge behavior remains. | +| Agent runs | Event-triggered runs store `dealId` for physical Deal records. Retry loading and creation copy the nullable `dealId`. | + +No API alias, primary-contact output, cross-Company attach behavior, closed-stage +create restriction, attached-contact search, company clearing, or construction +wording change is retained. + +## Original behavior retained + +- Company is required for every Deal at the database and API boundary. +- Original CRM names, routes, OpenAPI tags, error text, event text, and stage + values remain in application code. +- `DealContact` keeps its composite key `(dealId, contactId)`, optional `role`, + same-Company attach rule, and original service behavior. +- Contact labels remain calculated from `firstName` and optional `lastName`. +- The web UI, landing copy, agent prompts, agent tool wording, and construction + status code mappings are restored to the original branch behavior. +- No construction UI is shipped. + +## Retained focused tests + +- Deal field create, update, list, and detail behavior. +- Required database Company relation for a Deal. +- Document and DocumentLineItem persistence. +- Company purge refusal while a Deal exists, including preserved project + activity and queued task records. +- AgentRun Deal context on event dispatch and retry. +- Original physical DealStage behavior in agent event fixtures. + +The full test commands and their results belong in the implementation report. diff --git a/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql b/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql new file mode 100644 index 000000000..f8b5bf7fd --- /dev/null +++ b/packages/db/prisma/migrations/20260829120000_add_deal_metadata_documents/migration.sql @@ -0,0 +1,72 @@ +BEGIN; + +ALTER TABLE "deal" + ADD COLUMN "leadSource" TEXT, + ADD COLUMN "projectType" TEXT, + ADD COLUMN "addressLine1" TEXT, + ADD COLUMN "addressLine2" TEXT, + ADD COLUMN "city" TEXT, + ADD COLUMN "state" TEXT, + ADD COLUMN "postalCode" TEXT; + +ALTER TABLE "agentRun" ADD COLUMN "dealId" TEXT; +CREATE INDEX "agentRun_dealId_createdAt_idx" ON "agentRun" ("dealId", "createdAt"); +ALTER TABLE "agentRun" + ADD CONSTRAINT "agentRun_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE "artifact" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "storageKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "artifact_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "artifact_dealId_createdAt_idx" ON "artifact" ("dealId", "createdAt"); +ALTER TABLE "artifact" + ADD CONSTRAINT "artifact_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "document" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "number" TEXT NOT NULL, + "status" TEXT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "issuedAt" TIMESTAMP(3), + "dueAt" TIMESTAMP(3), + "recipientSnapshot" JSONB NOT NULL, + "contractorSnapshot" JSONB NOT NULL, + "projectSnapshot" JSONB NOT NULL, + "subtotal" DECIMAL(14,2) NOT NULL, + "tax" DECIMAL(14,2) NOT NULL, + "total" DECIMAL(14,2) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "document_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "document_dealId_createdAt_idx" ON "document" ("dealId", "createdAt"); +ALTER TABLE "document" + ADD CONSTRAINT "document_dealId_fkey" + FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "documentLineItem" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "description" TEXT NOT NULL, + "quantity" DECIMAL(14,2) NOT NULL, + "unitPrice" DECIMAL(14,2) NOT NULL, + "total" DECIMAL(14,2) NOT NULL, + "position" INTEGER NOT NULL, + CONSTRAINT "documentLineItem_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "documentLineItem_documentId_position_idx" + ON "documentLineItem" ("documentId", "position"); +ALTER TABLE "documentLineItem" + ADD CONSTRAINT "documentLineItem_documentId_fkey" + FOREIGN KEY ("documentId") REFERENCES "document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aeec49033..e36263290 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -834,6 +834,9 @@ model AgentRun { initiatedById String? initiatedBy User? @relation("AgentRunInitiator", fields: [initiatedById], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + triggerType AgentTriggerType status AgentRunStatus @default(QUEUED) principalId String? @@ -870,6 +873,7 @@ model AgentRun { @@index([versionId, createdAt]) @@index([status, createdAt]) @@index([triggerId, createdAt]) + @@index([dealId, createdAt]) @@map("agentRun") } @@ -959,6 +963,7 @@ model AgentAuditEvent { model Deal { id String @id @default(cuid()) conversations AgentConversation[] + agentRuns AgentRun[] name String description String? companyId String @@ -979,12 +984,22 @@ model Deal { fxRate Decimal? @db.Decimal(20, 10) fxRateAt DateTime? + leadSource String? + projectType String? + addressLine1 String? + addressLine2 String? + city String? + state String? + postalCode String? + lastActivityAt DateTime? archivedAt DateTime? contacts DealContact[] activities Activity[] fieldValues FieldValue[] + artifacts Artifact[] + documents Document[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1032,6 +1047,57 @@ model DealContact { @@map("dealContact") } +model Artifact { + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + type String + fileName String + storageKey String + createdAt DateTime @default(now()) + + @@index([dealId, createdAt]) + @@map("artifact") +} + +model Document { + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + type String + number String + status String + currency String @default("USD") + issuedAt DateTime? + dueAt DateTime? + recipientSnapshot Json + contractorSnapshot Json + projectSnapshot Json + subtotal Decimal @db.Decimal(14, 2) + tax Decimal @db.Decimal(14, 2) + total Decimal @db.Decimal(14, 2) + lineItems DocumentLineItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([dealId, createdAt]) + @@map("document") +} + +model DocumentLineItem { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + description String + quantity Decimal @db.Decimal(14, 2) + unitPrice Decimal @db.Decimal(14, 2) + total Decimal @db.Decimal(14, 2) + position Int + + @@index([documentId, position]) + @@map("documentLineItem") +} + enum FieldEntity { COMPANY CONTACT From 59a51382f8760011d2d2f62ef1a76bec32de5e41 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Thu, 3 Sep 2026 12:40:04 +0300 Subject: [PATCH 04/27] feat: bundle kaneo project management against the shared database (#5) Integrate Kaneo's project management into the CRM without reimplementation and without a second schema. - Kaneo is a git submodule at vendor/kaneo pointing at the crm-integration branch of the romanbsd/kaneo fork, which carries the CRM deltas. - One Postgres schema, owned by Prisma. The 32 kaneo tables are generated from an ORM-agnostic abstract model (packages/kaneo-domain), with a Drizzle binding and a parity test proving the generated schema matches what kaneo's code expects. kaneo tables use snake_case physical columns; shared auth tables keep the CRM's camelCase. activity and invitation are renamed to task_activity and workspace_invitation to avoid collisions. - kaneo's own Hono controllers and web UI are mounted and served: dev:kaneo boots the API against the shared database, serves the web SPA, proxies /api and bridges /ws. kaneo's startup Drizzle migrations are gated. - One session cookie (crm.session_token) valid at both apps, one shared Better Auth identity, and CRM roles mapped onto kaneo's on sign-in. - The eve agent reads projects and tasks through Prisma and drives writes through kaneo's extracted controller functions directly, acting as the workspace owner. Documented in vendor/FORK-DELTA.md, adrs/kaneo.md and docs/kaneo-integration.md. --- .gitmodules | 4 + .oxlintrc.json | 13 +- adrs/kaneo.md | 49 + apps/agent/agent/lib/kaneo-writes.ts | 106 ++ apps/agent/agent/lib/kaneo.ts | 147 +++ apps/agent/agent/tools/project_list.ts | 12 + apps/agent/agent/tools/task_comment.ts | 16 + apps/agent/agent/tools/task_create.ts | 33 + apps/agent/agent/tools/task_list.ts | 25 + apps/agent/agent/tools/task_read.ts | 20 + apps/agent/agent/tools/task_update.ts | 29 + apps/agent/test/kaneo.integration.spec.ts | 136 +++ biome.jsonc | 11 +- bun.lock | 145 +-- docs/kaneo-integration.md | 117 ++ package.json | 3 + packages/auth/src/organization.ts | 70 ++ .../20260902181324_kaneo_domain/migration.sql | 769 ++++++++++++ .../migration.sql | 8 + .../migration.sql | 1 + packages/db/prisma/schema.prisma | 529 +++++++++ packages/kaneo-domain/kaneo.prisma | 521 ++++++++ packages/kaneo-domain/package.json | 24 + .../kaneo-domain/scripts/generate-prisma.ts | 15 + packages/kaneo-domain/src/drizzle.ts | 220 ++++ packages/kaneo-domain/src/dsl.ts | 207 ++++ packages/kaneo-domain/src/index.ts | 4 + packages/kaneo-domain/src/kaneo.ts | 1043 +++++++++++++++++ packages/kaneo-domain/src/parity.test.ts | 67 ++ packages/kaneo-domain/src/parity.ts | 152 +++ packages/kaneo-domain/src/prisma.test.ts | 47 + packages/kaneo-domain/src/prisma.ts | 299 +++++ packages/kaneo-domain/tsconfig.json | 9 + packages/telemetry/src/allowlist.ts | 6 + tools/kaneo-dev.ts | 175 +++ vendor/FORK-DELTA.md | 63 + vendor/kaneo | 1 + 37 files changed, 4981 insertions(+), 115 deletions(-) create mode 100644 .gitmodules create mode 100644 adrs/kaneo.md create mode 100644 apps/agent/agent/lib/kaneo-writes.ts create mode 100644 apps/agent/agent/lib/kaneo.ts create mode 100644 apps/agent/agent/tools/project_list.ts create mode 100644 apps/agent/agent/tools/task_comment.ts create mode 100644 apps/agent/agent/tools/task_create.ts create mode 100644 apps/agent/agent/tools/task_list.ts create mode 100644 apps/agent/agent/tools/task_read.ts create mode 100644 apps/agent/agent/tools/task_update.ts create mode 100644 apps/agent/test/kaneo.integration.spec.ts create mode 100644 docs/kaneo-integration.md create mode 100644 packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql create mode 100644 packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql create mode 100644 packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql create mode 100644 packages/kaneo-domain/kaneo.prisma create mode 100644 packages/kaneo-domain/package.json create mode 100644 packages/kaneo-domain/scripts/generate-prisma.ts create mode 100644 packages/kaneo-domain/src/drizzle.ts create mode 100644 packages/kaneo-domain/src/dsl.ts create mode 100644 packages/kaneo-domain/src/index.ts create mode 100644 packages/kaneo-domain/src/kaneo.ts create mode 100644 packages/kaneo-domain/src/parity.test.ts create mode 100644 packages/kaneo-domain/src/parity.ts create mode 100644 packages/kaneo-domain/src/prisma.test.ts create mode 100644 packages/kaneo-domain/src/prisma.ts create mode 100644 packages/kaneo-domain/tsconfig.json create mode 100644 tools/kaneo-dev.ts create mode 100644 vendor/FORK-DELTA.md create mode 160000 vendor/kaneo diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e97e9c134 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/kaneo"] + path = vendor/kaneo + url = git@github.com:romanbsd/kaneo.git + branch = crm-integration diff --git a/.oxlintrc.json b/.oxlintrc.json index 138887c13..46a979b09 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,7 +17,8 @@ "packages/agent-xmpp/**", "packages/db/src/generated/**", "packages/ui/src/components/**", - "tools/oxlint/anti-slop/**" + "tools/oxlint/anti-slop/**", + "vendor/**" ], "rules": { "anti-slop/no-chained-type-assertions": "error", @@ -32,6 +33,16 @@ "anti-slop/no-widen-then-assert": "error" }, "overrides": [ + { + "files": ["packages/kaneo-domain/src/**"], + "rules": { + "anti-slop/no-chained-type-assertions": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-unknown-parameters": "off", + "anti-slop/no-unsafe-dictionary-type": "off", + "anti-slop/no-widen-then-assert": "off" + } + }, { "files": [ "**/test/**", diff --git a/adrs/kaneo.md b/adrs/kaneo.md new file mode 100644 index 000000000..51f4112e4 --- /dev/null +++ b/adrs/kaneo.md @@ -0,0 +1,49 @@ +# Bundle Kaneo through an ORM-agnostic domain model + +## What we want to change + +Bring project management into the CRM as one domain, not two. Kaneo and the CRM are +both Postgres and both better-auth. We want one physical schema, one migration stream, +and the eve agent reading and writing project data through the same `@crm/db` client it +already uses for contacts, companies and deals. + +## Why the current situation is a problem + +- Running Kaneo with its own database means two copies of `project` and `task`, and a + sync bus between them that will be debugged forever. +- Pointing Kaneo's Drizzle schema at our Prisma-owned tables by hand-patching a vendored + `schema.ts` works once and drifts forever. Every upstream release conflicts with the + patch. +- Two better-auth plugins over one `user` table creates two membership models that + silently diverge. + +## What we will do instead + +- **Fork and bundle Kaneo** (see `vendor/FORK-DELTA.md`). The fork is the trunk; all + work lands there first. Generic improvements are donated upstream as pull requests. +- **Extract Kaneo's domain model into an ORM-agnostic definition** — a typed TypeScript + builder DSL that models tables, columns, constraints and relations at the Postgres + level, in a `packages/domain`-style package. This is the generic piece and is + upstream pull request number one. +- **Bind the abstract model on each side.** Kaneo's Drizzle schema is generated from it + (behavior-identical, held by a golden DDL parity test). The CRM generates a Prisma + schema fragment from it, committed, merged via `prismaSchemaFolder`. The parity test + is two scratch databases, one `prisma db push` and one `drizzle-kit` push, `pg_dump` + both, diff must be empty. +- **Prisma owns all migrations.** Kaneo's own migration runner is disabled in the fork. +- **Identity unifies on the existing better-auth organization plugin.** Kaneo's workspace + plugin is disabled; the one workspace maps to `WORKSPACE_ID`. One membership model. +- **One writer per aggregate.** `task.number` derives from `project.lastTaskNumber` in a + single transaction; whichever runtime owns a table owns that counter. + +## What it breaks + +- Kaneo's upstream code must keep producing identical DDL, or the parity test fails. +- The abstract model adds a codegen step and a boundary rule (no `@crm/*` imports in the + generic packages). +- Upstream may reject the extraction. That is not a failure: the fork carries it, the + parity test keeps it honest, and nothing blocks on acceptance. + +## Status + +Accepted. Vendoring is in place; the extraction is next. \ No newline at end of file diff --git a/apps/agent/agent/lib/kaneo-writes.ts b/apps/agent/agent/lib/kaneo-writes.ts new file mode 100644 index 000000000..9de5ffbb0 --- /dev/null +++ b/apps/agent/agent/lib/kaneo-writes.ts @@ -0,0 +1,106 @@ +import { db } from "@crm/db"; +import { WORKSPACE_ID } from "@crm/db/workspace"; +import createKaneoComment from "../../../../vendor/kaneo/apps/api/src/activity/controllers/create-comment"; +import createKaneoTask from "../../../../vendor/kaneo/apps/api/src/task/controllers/create-task"; +import updateKaneoTask from "../../../../vendor/kaneo/apps/api/src/task/controllers/update-task"; + +export type CreateTaskInput = { + projectId: string; + title: string; + description?: string; + status?: string; + priority?: string; + assigneeId?: string; + dueDate?: string; +}; + +export type UpdateTaskInput = { + title?: string; + description?: string; + status?: string; + priority?: string; + assigneeId?: string | null; + dueDate?: string | null; +}; + +async function agentPrincipal(): Promise { + const owner = await db.member.findFirst({ + where: { organizationId: WORKSPACE_ID, role: "owner" }, + orderBy: { createdAt: "asc" }, + select: { userId: true }, + }); + if (!owner) { + throw new Error("no workspace owner to act as the agent"); + } + return owner.userId; +} + +export async function createTask(input: CreateTaskInput) { + const currentUserId = await agentPrincipal(); + const task = await createKaneoTask({ + projectId: input.projectId, + currentUserId, + userId: input.assigneeId, + title: input.title, + status: input.status ?? "to-do", + priority: input.priority, + description: input.description, + dueDate: input.dueDate ? new Date(input.dueDate) : undefined, + }); + return { id: task.id, number: task.number, title: task.title }; +} + +export async function updateTask(taskId: string, input: UpdateTaskInput) { + const currentUserId = await agentPrincipal(); + const current = await db.projectTask.findUnique({ + where: { id: taskId }, + select: { + title: true, + status: true, + startDate: true, + dueDate: true, + projectId: true, + description: true, + priority: true, + position: true, + userId: true, + }, + }); + if (!current) { + throw new Error(`task ${taskId} does not exist`); + } + const task = await updateKaneoTask( + taskId, + input.title ?? current.title, + input.status ?? current.status, + current.startDate ?? undefined, + input.dueDate !== undefined + ? input.dueDate + ? new Date(input.dueDate) + : undefined + : (current.dueDate ?? undefined), + current.projectId, + input.description !== undefined + ? input.description + : (current.description ?? ""), + input.priority ?? current.priority, + current.position ?? 0, + input.assigneeId !== undefined + ? (input.assigneeId ?? undefined) + : (current.userId ?? undefined), + currentUserId, + ); + return { + id: task.id, + title: task.title, + status: task.status, + priority: task.priority, + dueDate: task.dueDate, + }; +} + +export async function addTaskComment(taskId: string, content: string) { + const userId = await agentPrincipal(); + const activity = await createKaneoComment(taskId, userId, content); + return { id: activity.id, createdAt: activity.createdAt }; +} diff --git a/apps/agent/agent/lib/kaneo.ts b/apps/agent/agent/lib/kaneo.ts new file mode 100644 index 000000000..9a6cf3ac5 --- /dev/null +++ b/apps/agent/agent/lib/kaneo.ts @@ -0,0 +1,147 @@ +import { db } from "@crm/db"; + +export type TaskListFilter = { + projectId?: string; + assigneeId?: string; + status?: string; + limit?: number; +}; + +type UserName = { id: string; name: string; email: string | null }; + +async function userNames( + userIds: Array, +): Promise> { + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) { + return new Map(); + } + const users = await db.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true, email: true }, + }); + return new Map(users.map((user) => [user.id, user])); +} + +export async function listProjects() { + const projects = await db.project.findMany({ + where: { archivedAt: null }, + orderBy: { position: "asc" }, + select: { + id: true, + name: true, + slug: true, + description: true, + position: true, + _count: { select: { projectTasks: true } }, + }, + }); + return projects.map((project) => ({ + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + taskCount: project._count.projectTasks, + })); +} + +export async function listTasks(filter: TaskListFilter) { + const tasks = await db.projectTask.findMany({ + where: { + projectId: filter.projectId, + userId: filter.assigneeId, + status: filter.status, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + take: filter.limit ?? 50, + select: { + id: true, + number: true, + title: true, + status: true, + priority: true, + dueDate: true, + projectId: true, + userId: true, + project: { select: { name: true } }, + }, + }); + const names = await userNames(tasks.map((task) => task.userId)); + return tasks.map((task) => ({ + id: task.id, + number: task.number, + title: task.title, + status: task.status, + priority: task.priority, + dueDate: task.dueDate, + projectId: task.projectId, + projectName: task.project.name, + assignee: task.userId + ? { + id: task.userId, + name: names.get(task.userId)?.name ?? null, + email: names.get(task.userId)?.email ?? null, + } + : null, + })); +} + +export async function readTask(taskId: string) { + const task = await db.projectTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + number: true, + title: true, + description: true, + status: true, + priority: true, + startDate: true, + dueDate: true, + projectId: true, + userId: true, + project: { select: { name: true } }, + column: { select: { name: true } }, + labels: { select: { name: true, color: true } }, + taskActivities: { + where: { type: "comment" }, + orderBy: { createdAt: "asc" }, + select: { content: true, createdAt: true }, + }, + }, + }); + if (!task) { + return null; + } + const names = await userNames([task.userId]); + const assignee = task.userId + ? { + id: task.userId, + name: names.get(task.userId)?.name ?? null, + email: names.get(task.userId)?.email ?? null, + } + : null; + return { + id: task.id, + number: task.number, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + startDate: task.startDate, + dueDate: task.dueDate, + projectId: task.projectId, + projectName: task.project.name, + columnName: task.column?.name ?? null, + assignee, + labels: task.labels.map((label) => label.name), + comments: task.taskActivities + .filter((activity): activity is { content: string; createdAt: Date } => + Boolean(activity.content), + ) + .map((activity) => ({ + content: activity.content, + createdAt: activity.createdAt, + })), + }; +} diff --git a/apps/agent/agent/tools/project_list.ts b/apps/agent/agent/tools/project_list.ts new file mode 100644 index 000000000..377480504 --- /dev/null +++ b/apps/agent/agent/tools/project_list.ts @@ -0,0 +1,12 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { listProjects } from "../lib/kaneo"; + +export default defineTool({ + description: + "List the project-management projects, each with its task count and id. Use this before creating or finding tasks, so you never ask a rep for a project id. Free.", + inputSchema: z.object({}), + async execute() { + return { projects: await listProjects() }; + }, +}); diff --git a/apps/agent/agent/tools/task_comment.ts b/apps/agent/agent/tools/task_comment.ts new file mode 100644 index 000000000..9efbf5e20 --- /dev/null +++ b/apps/agent/agent/tools/task_comment.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { addTaskComment } from "../lib/kaneo-writes"; + +export default defineTool({ + description: + "Add a comment to a project task. A rep will see it in the task's comment thread. Free.", + inputSchema: z.object({ + taskId: z.string().describe("The id of the task to comment on."), + content: z.string().min(1).max(4000).describe("The comment text."), + }), + async execute({ taskId, content }) { + const comment = await addTaskComment(taskId, content); + return { comment }; + }, +}); diff --git a/apps/agent/agent/tools/task_create.ts b/apps/agent/agent/tools/task_create.ts new file mode 100644 index 000000000..f7d73de0e --- /dev/null +++ b/apps/agent/agent/tools/task_create.ts @@ -0,0 +1,33 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { createTask } from "../lib/kaneo-writes"; + +export default defineTool({ + description: + "Create a task in a project. The project id comes from project_list. Returns the new task id and number. The task starts in the 'to-do' state with kaneo's default priority unless you say otherwise. Free.", + inputSchema: z.object({ + projectId: z.string().describe("The project to create the task in."), + title: z.string().min(1).max(500).describe("The task title."), + description: z + .string() + .optional() + .describe("A longer description of what the task is."), + status: z.string().optional(), + priority: z.string().optional(), + assigneeId: z + .string() + .optional() + .describe("The user id to assign the task to."), + dueDate: z + .string() + .optional() + .describe("An ISO date, for example 2026-09-30."), + }), + async execute(input) { + const task = await createTask(input); + return { + task, + note: "The task is created in the project. A rep will see it on the board.", + }; + }, +}); diff --git a/apps/agent/agent/tools/task_list.ts b/apps/agent/agent/tools/task_list.ts new file mode 100644 index 000000000..1ae70027b --- /dev/null +++ b/apps/agent/agent/tools/task_list.ts @@ -0,0 +1,25 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { listTasks } from "../lib/kaneo"; + +export default defineTool({ + description: + "List project tasks by project, assignee or status. Returns each task with its id and number, so you never have to ask a rep for one. Free.", + inputSchema: z.object({ + projectId: z.string().optional().describe("The project to list tasks for."), + assigneeId: z + .string() + .optional() + .describe("Only tasks assigned to this user id."), + status: z + .string() + .optional() + .describe( + "Only tasks with this status, for example 'to-do' or 'in-progress'.", + ), + limit: z.number().int().min(1).max(100).default(50), + }), + async execute(input) { + return { tasks: await listTasks(input) }; + }, +}); diff --git a/apps/agent/agent/tools/task_read.ts b/apps/agent/agent/tools/task_read.ts new file mode 100644 index 000000000..8fac9c785 --- /dev/null +++ b/apps/agent/agent/tools/task_read.ts @@ -0,0 +1,20 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { readTask } from "../lib/kaneo"; + +export default defineTool({ + description: + "Read one project task in full: description, status, priority, due date, assignee, labels and its comments. Free.", + inputSchema: z.object({ + taskId: z.string().describe("The id of the task to read."), + }), + async execute({ taskId }) { + const task = await readTask(taskId); + return task + ? { task } + : { + task: null, + note: "No task with that id exists. Say so rather than guessing.", + }; + }, +}); diff --git a/apps/agent/agent/tools/task_update.ts b/apps/agent/agent/tools/task_update.ts new file mode 100644 index 000000000..a8051465d --- /dev/null +++ b/apps/agent/agent/tools/task_update.ts @@ -0,0 +1,29 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { updateTask } from "../lib/kaneo-writes"; + +export default defineTool({ + description: + "Update a task: its title, description, status, priority, assignee or due date. Pass only the fields that change. Free.", + inputSchema: z.object({ + taskId: z.string().describe("The id of the task to update."), + title: z.string().min(1).max(500).optional(), + description: z.string().optional(), + status: z.string().optional(), + priority: z.string().optional(), + assigneeId: z + .string() + .nullable() + .optional() + .describe("Set to null to clear the assignee."), + dueDate: z + .string() + .nullable() + .optional() + .describe("An ISO date, or null to clear it."), + }), + async execute({ taskId, ...input }) { + const task = await updateTask(taskId, input); + return { task }; + }, +}); diff --git a/apps/agent/test/kaneo.integration.spec.ts b/apps/agent/test/kaneo.integration.spec.ts new file mode 100644 index 000000000..1f4a45721 --- /dev/null +++ b/apps/agent/test/kaneo.integration.spec.ts @@ -0,0 +1,136 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { WORKSPACE_ID } from "@crm/db/workspace"; +import { listProjects, listTasks, readTask } from "../agent/lib/kaneo"; +import { + addTaskComment, + createTask, + updateTask, +} from "../agent/lib/kaneo-writes"; + +const OWNER_ID = "kaneo-agent-test-owner"; + +describe("kaneo agent operations", () => { + let projectId: string; + + beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: "Agent test", + slug: "agent-test", + createdAt: new Date(), + }, + update: {}, + }); + await db.workspace.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: "Agent test", + slug: "agent-test", + createdAt: new Date(), + }, + update: {}, + }); + await db.user.upsert({ + where: { id: OWNER_ID }, + create: { + id: OWNER_ID, + name: "Agent Test Owner", + email: `agent-owner-${Date.now()}@test.local`, + }, + update: {}, + }); + await db.member.upsert({ + where: { + organizationId_userId: { + organizationId: WORKSPACE_ID, + userId: OWNER_ID, + }, + }, + create: { + id: crypto.randomUUID(), + organizationId: WORKSPACE_ID, + userId: OWNER_ID, + role: "owner", + createdAt: new Date(), + }, + update: { role: "owner" }, + }); + const project = await db.project.create({ + data: { + workspaceId: WORKSPACE_ID, + name: "Agent integration", + slug: `agent-integration-${Date.now()}`, + }, + }); + projectId = project.id; + await db.projectColumn.createMany({ + data: [ + { projectId, name: "To do", slug: "to-do", position: 0 }, + { projectId, name: "In progress", slug: "in-progress", position: 1 }, + { projectId, name: "Done", slug: "done", position: 2 }, + ], + }); + }); + + afterAll(async () => { + await db.project + .delete({ where: { id: projectId } }) + .catch(() => undefined); + await db.member + .delete({ + where: { + organizationId_userId: { + organizationId: WORKSPACE_ID, + userId: OWNER_ID, + }, + }, + }) + .catch(() => undefined); + await db.user.delete({ where: { id: OWNER_ID } }).catch(() => undefined); + }); + + it("creates tasks with sequential numbers", async () => { + const first = await createTask({ projectId, title: "First" }); + const second = await createTask({ + projectId, + title: "Second", + status: "in-progress", + }); + expect(first.number).toBe(1); + expect(second.number).toBe(2); + }); + + it("lists tasks and projects", async () => { + const projects = await listProjects(); + const found = projects.find((project) => project.id === projectId); + expect(found?.taskCount).toBe(2); + + const all = await listTasks({ projectId }); + expect(all).toHaveLength(2); + + const open = await listTasks({ projectId, status: "to-do" }); + expect(open).toHaveLength(1); + }); + + it("updates a task", async () => { + const [task] = await listTasks({ projectId }); + const updated = await updateTask(task.id, { + status: "done", + priority: "high", + }); + expect(updated.status).toBe("done"); + expect(updated.priority).toBe("high"); + }); + + it("reads a task with its comments", async () => { + const [task] = await listTasks({ projectId }); + await addTaskComment(task.id, "The agent left this note."); + const read = await readTask(task.id); + expect(read?.comments).toHaveLength(1); + expect(read?.comments[0].content).toBe("The agent left this note."); + }); +}); diff --git a/biome.jsonc b/biome.jsonc index f93ad2386..5cb97a490 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -18,7 +18,8 @@ "!.claude", "!.agents", "!tools/oxlint/anti-slop", - "!skills-lock.json" + "!skills-lock.json", + "!vendor/**" ] }, "formatter": { @@ -67,6 +68,14 @@ } }, "overrides": [ + { + "includes": ["packages/kaneo-domain/src/drizzle.ts"], + "linter": { + "rules": { + "suspicious": { "noExplicitAny": "off" } + } + } + }, { "includes": ["apps/app/**"], "linter": { diff --git a/bun.lock b/bun.lock index f6df338a2..9fd8557a9 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "devDependencies": { "@biomejs/biome": "^2.4.10", "@oxlint/plugins": "1.78.0", + "@paralleldrive/cuid2": "^3.3.0", + "drizzle-orm": "^0.45.2", "knip": "6.32.2", "oxlint": "1.78.0", "turbo": "^2.10.8", @@ -217,6 +219,17 @@ "typescript": "5.9.2", }, }, + "packages/kaneo-domain": { + "name": "@crm/kaneo-domain", + "version": "0.0.0", + "devDependencies": { + "@crm/typescript-config": "workspace:*", + "@paralleldrive/cuid2": "^3.3.0", + "@types/node": "^24.10.1", + "drizzle-orm": "^0.45.2", + "typescript": "5.9.2", + }, + }, "packages/telemetry": { "name": "@crm/telemetry", "version": "0.0.0", @@ -453,6 +466,8 @@ "@crm/env": ["@crm/env@workspace:packages/env"], + "@crm/kaneo-domain": ["@crm/kaneo-domain@workspace:packages/kaneo-domain"], + "@crm/telemetry": ["@crm/telemetry@workspace:packages/telemetry"], "@crm/typescript-config": ["@crm/typescript-config@workspace:packages/typescript-config"], @@ -819,7 +834,7 @@ "@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="], - "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], "@pierre/diffs": ["@pierre/diffs@1.3.4", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-6Dt48jQIL+H54QyNB943Oqn5p4uRw7NpiXLSEapnaYgYvTmSHNPAUCDfg6AcRC+W02z0IYJnJJvcs3lsNp7gCw=="], @@ -987,8 +1002,6 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], @@ -1135,8 +1148,6 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], @@ -1205,8 +1216,6 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1289,20 +1298,6 @@ "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], - "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - - "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], - - "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], - - "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], @@ -1401,8 +1396,6 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], @@ -1429,6 +1422,8 @@ "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -1467,8 +1462,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1699,7 +1692,7 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], - "drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1729,14 +1722,14 @@ "env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="], + "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -1755,8 +1748,6 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eve": ["eve@0.29.4", "", { "dependencies": { "nitro": "3.0.260610-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.38", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-EwOmL37l+Iuu7Umno7at3flFpMs4AtT9cg1J4dt7EZ8ZdxaCvGXwvKGwiulMkG8oXkMQ5CNhxMi2ruHJwrtpwQ=="], @@ -1773,8 +1764,6 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.6.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA=="], @@ -2297,8 +2286,6 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], - "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], - "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], @@ -2615,8 +2602,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -2645,11 +2630,9 @@ "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -2709,14 +2692,10 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -2827,10 +2806,6 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], - - "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], - "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2841,8 +2816,6 @@ "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2915,6 +2888,8 @@ "@better-auth/cli/better-auth": ["better-auth@1.4.22", "", { "dependencies": { "@better-auth/core": "1.4.22", "@better-auth/telemetry": "1.4.22", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-CXQ7ZLDkf/I9iaVTNuejJ7FlWal50hRPIv1n0lqMipvthEoMx+2RQyNXUvzGRjltSe5d9rcZPI3IxdtS1A5+YA=="], + "@better-auth/cli/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], "@better-auth/core/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], @@ -2957,6 +2932,8 @@ "@crm/env/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@crm/kaneo-domain/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@crm/telemetry/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@crm/ui/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], @@ -2989,14 +2966,10 @@ "@nestjs/config/dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], - "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], - "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], @@ -3257,6 +3230,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "formidable/@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "just-bash/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3319,12 +3294,6 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - - "vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], - - "vite/rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3349,6 +3318,8 @@ "@crm/env/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@crm/kaneo-domain/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@crm/telemetry/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@crm/ui/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -3461,6 +3432,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "formidable/@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "multer/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -3469,60 +3442,6 @@ "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - - "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - - "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - - "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - - "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - - "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - - "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - - "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - - "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - - "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - - "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - - "vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], - - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], - - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], - - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], - - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], - - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], - - "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], - - "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], - - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], - - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], - - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], - - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], - - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/docs/kaneo-integration.md b/docs/kaneo-integration.md new file mode 100644 index 000000000..8774a09a7 --- /dev/null +++ b/docs/kaneo-integration.md @@ -0,0 +1,117 @@ +# Kaneo integration + +How Kaneo's project management is bundled into the CRM: one database, kaneo's own +controllers and web UI served as-is, one session, and the agent calling kaneo's +controller functions directly. + +Read `vendor/FORK-DELTA.md` for the fork changes that make this work, and +`docs/api.md` for the CRM's own rules. The strategy decision is in +`adrs/kaneo.md`. + +## The kaneo source + +Kaneo lives at `vendor/kaneo` as a git submodule pointing at the +`crm-integration` branch of the `romanbsd/kaneo` fork. The CRM-specific +deltas (column names, renames, cookie prefix, migration gate) are committed +in that branch, not copied into this repository. Updating kaneo means +rebasing the fork branch on upstream and bumping the submodule pointer — see +`vendor/FORK-DELTA.md`. + +A fresh checkout must initialize the submodule and install kaneo's +dependencies before `dev:kaneo` runs: + +```sh +git submodule update --init vendor/kaneo +cd vendor/kaneo && bun install +``` + +## The one database + +Kaneo and the CRM share one Postgres schema, owned by Prisma in +`packages/db/prisma/schema.prisma`. Prisma runs every migration; kaneo's own +migrator is disabled (`KANEO_SKIP_DRIZZLE_MIGRATIONS`, fork delta). + +- The 32 kaneo tables are generated from the abstract model in + `packages/kaneo-domain` (`bun run generate:prisma` regenerates + `kaneo.prisma`, appended into `schema.prisma`). The Drizzle binding and the + parity test in that package prove the generated schema matches what kaneo's + code expects. +- **Kaneo tables keep snake_case physical columns** (`project_id`, `created_at`), + matching kaneo's Drizzle schema. The Prisma fragment emits a column `@map` + for each. +- **The shared auth tables keep the CRM's camelCase physical columns** + (`emailVerified`, `createdAt`). Kaneo's `schema.ts` is patched to read those + names, so both ORMs see the same rows. +- Two physical renames avoid collisions with the CRM's live tables: + `activity` → `task_activity`, `invitation` → `workspace_invitation`. +- Migrations: `kaneo_domain` (the 32 tables), `kaneo_auth_columns` (kaneo's + nullable user/session columns), `kaneo_comment_user_nullable` (agent-authored + comments without a user). + +## The mount + +`bun run dev:kaneo` serves the whole stack: + +1. Builds the `@kaneo/*` workspace packages if their `dist` is missing. +2. Boots kaneo's own Hono API (`vendor/kaneo/apps/api`) against the shared + database, with the migration gate on. +3. Serves kaneo's built web SPA (`vendor/kaneo/apps/web/dist`) at root, + proxies `/api/*` to the API, and bridges `/ws` websockets. + +`tools/kaneo-dev.ts` is the dev server. Production hosting is not wired yet: +kaneo's websockets and scheduler need a long-lived process, so Vercel serverless +cannot host the API; the API runs as its own service. + +## Authentication + +One session cookie, one identity. + +- Both apps run Better Auth over the same `user`/`session`/`account`/ + `verification` tables and the same secret (`BETTER_AUTH_SECRET` mapped to + kaneo's `AUTH_SECRET`). +- Kaneo's Better Auth uses `cookiePrefix: "crm"` (fork delta), so + `crm.session_token` is valid at both apps. Both use the same cookie cache + format. +- The CRM owns sign-in. Its `ensureWorkspaceMembership` hook (packages/auth) + also mirrors the single workspace and each signing-in user into kaneo's + `workspace`/`workspace_member` tables, mapping CRM roles onto kaneo's + (`owner`/`admin` → `admin`, `member` → `member`). The sync degrades + independently: a kaneo-table failure never blocks sign-in. +- Kaneo's boot seeds its default `workspace_role` rows (viewer/member/admin) + against the shared workspace. + +## The agent surface + +The eve agent reads projects and tasks through Prisma and writes through +kaneo's own controller functions. + +- **Reads (native Prisma, free):** `project_list`, `task_list`, `task_read` + (`apps/agent/agent/lib/kaneo.ts`). No kaneo runtime needed for reads. +- **Writes (kaneo's controllers, direct):** `task_create`, `task_update`, + `task_comment` call kaneo's extracted controller functions in-process + (`apps/agent/agent/lib/kaneo-writes.ts`), with no HTTP, no MCP, no Hono + context. This reuses kaneo's exact behavior: status validation against the + project's columns, atomic task numbering, assignable-user checks, and + comment rows in the activity feed (`task_activity`, type `comment`) that + kaneo's UI actually renders. +- **The principal:** a dispatched agent has no user, but kaneo's controllers + require `currentUserId`. The agent acts as the workspace owner (the first + `owner` member of the workspace). Decide once; this is the whole permission + model for agent writes. + +Why not MCP: kaneo's MCP server is a thin HTTP client over kaneo's REST API, +and it is user-OAuth-scoped. Eve can consume MCP natively, but the agent has no +user principal for dispatched runs, and an HTTP hop adds a runtime dependency +for the same tables. The direct-controller path removes the transport entirely. + +## Open decisions + +- **Production hosting:** kaneo's API needs a long-lived process (websockets, + scheduler). Not wired. +- **`/api/auth` co-serving:** both apps serve auth at `/api/auth`; fine on + separate ports in dev, one must move when co-served under one origin. +- **Inline routes:** a minority of kaneo's routes are inline Hono handlers, not + extracted controllers; those stay HTTP-only. +- **Activity events:** kaneo's controllers publish events; the agent process + registers no event listeners, so event-driven side effects (notifications) + do not fire for agent writes. \ No newline at end of file diff --git a/package.json b/package.json index 4c236c3ad..1d678b6cf 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build", "dev": "turbo run dev", + "dev:kaneo": "bun tools/kaneo-dev.ts", "lint": "turbo run lint", "lint:slop": "oxlint", "lint:dead": "knip --no-progress", @@ -26,6 +27,8 @@ "devDependencies": { "@biomejs/biome": "^2.4.10", "@oxlint/plugins": "1.78.0", + "@paralleldrive/cuid2": "^3.3.0", + "drizzle-orm": "^0.45.2", "knip": "6.32.2", "oxlint": "1.78.0", "turbo": "^2.10.8", diff --git a/packages/auth/src/organization.ts b/packages/auth/src/organization.ts index df863e2f3..1bf9d0a82 100644 --- a/packages/auth/src/organization.ts +++ b/packages/auth/src/organization.ts @@ -107,9 +107,79 @@ export async function ensureWorkspaceMembership( error, ); return undefined; + } finally { + await syncKaneoMembership(userId); } } +async function syncKaneoMembership(userId: string): Promise { + try { + await db.$transaction(async (tx) => { + const workspace = await tx.organization.findUnique({ + where: { id: WORKSPACE_ID }, + select: { name: true, slug: true, createdAt: true }, + }); + if (!workspace) { + return; + } + + await tx.workspace.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: workspace.name, + slug: workspace.slug, + createdAt: workspace.createdAt, + }, + update: { name: workspace.name, slug: workspace.slug }, + }); + + const membership = await tx.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { role: true }, + }); + const role = toKaneoRole(toWorkspaceRole(membership?.role ?? "member")); + + const existing = await tx.workspaceMember.findFirst({ + where: { workspaceId: WORKSPACE_ID, userId }, + select: { id: true, role: true }, + }); + if (existing) { + if (existing.role !== role) { + await tx.workspaceMember.update({ + where: { id: existing.id }, + data: { role }, + }); + } + return; + } + await tx.workspaceMember.create({ + data: { + id: crypto.randomUUID(), + workspaceId: WORKSPACE_ID, + userId, + role, + joinedAt: new Date(), + }, + }); + }); + } catch (error) { + console.error( + `[auth] could not sync user ${userId} into the kaneo workspace; the next sign-in will retry`, + error, + ); + } +} + +function toKaneoRole(role: WorkspaceRole): string { + if (role === "owner" || role === "admin") { + return "admin"; + } + return "member"; +} + export function toWorkspaceRole(value: string): WorkspaceRole { return isWorkspaceRole(value) ? value : "member"; } diff --git a/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql b/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql new file mode 100644 index 000000000..67fa56adc --- /dev/null +++ b/packages/db/prisma/migrations/20260902181324_kaneo_domain/migration.sql @@ -0,0 +1,769 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "user_avatar" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "data" BYTEA NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_avatar_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "logo" TEXT, + "metadata" TEXT, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspace_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_member" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "joined_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspace_member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_billing" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "founding_free" BOOLEAN NOT NULL DEFAULT false, + "trial_ends_at" TIMESTAMP(3), + "creem_customer_id" TEXT, + "creem_subscription_id" TEXT, + "creem_product_id" TEXT, + "plan" TEXT, + "billing_interval" TEXT, + "status" TEXT, + "seats" INTEGER NOT NULL DEFAULT 1, + "current_period_end" TIMESTAMP(3), + "canceled_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_billing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "trial_grant" ( + "email_hash" TEXT NOT NULL, + "trial_ends_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trial_grant_pkey" PRIMARY KEY ("email_hash") +); + +-- CreateTable +CREATE TABLE "billing_event" ( + "id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "updated_at" TIMESTAMP(3), + + CONSTRAINT "team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_member" ( + "id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3), + + CONSTRAINT "team_member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_invitation" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT, + "team_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "inviter_id" TEXT NOT NULL, + + CONSTRAINT "workspace_invitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_role" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_role_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "icon" TEXT DEFAULT 'Layout', + "name" TEXT NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "is_public" BOOLEAN DEFAULT false, + "archived_at" TIMESTAMP(3), + "last_task_number" INTEGER NOT NULL DEFAULT 0, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "column" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "icon" TEXT, + "color" TEXT, + "is_final" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "column_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workflow_rule" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "integration_type" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "column_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workflow_rule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "position" INTEGER DEFAULT 0, + "number" INTEGER DEFAULT 1, + "assignee_id" TEXT, + "title" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'to-do', + "column_id" TEXT, + "priority" TEXT NOT NULL DEFAULT 'low', + "start_date" TIMESTAMP(3), + "due_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "billing_reminder_sent" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "reminder_type" TEXT NOT NULL, + "trial_ends_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_reminder_sent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "job_lease" ( + "name" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "job_lease_pkey" PRIMARY KEY ("name") +); + +-- CreateTable +CREATE TABLE "task_reminder_sent" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "reminder_type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_reminder_sent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "time_entry" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "user_id" TEXT, + "description" TEXT, + "start_time" TIMESTAMP(3) NOT NULL, + "end_time" TIMESTAMP(3), + "duration" INTEGER DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "time_entry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_activity" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" TEXT, + "content" TEXT, + "event_data" JSONB, + "external_user_name" TEXT, + "external_user_avatar" TEXT, + "external_source" TEXT, + "external_url" TEXT, + + CONSTRAINT "task_activity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "asset" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "task_id" TEXT, + "activity_id" TEXT, + "object_key" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "kind" TEXT NOT NULL DEFAULT 'image', + "surface" TEXT NOT NULL DEFAULT 'description', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "asset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "label" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "task_id" TEXT, + "workspace_id" TEXT, + + CONSTRAINT "label_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "title" TEXT, + "content" TEXT, + "type" TEXT NOT NULL DEFAULT 'info', + "event_data" JSONB, + "is_read" BOOLEAN DEFAULT false, + "resource_id" TEXT, + "resource_type" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_preference" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_server_url" TEXT, + "ntfy_topic" TEXT, + "ntfy_token" TEXT, + "gotify_enabled" BOOLEAN NOT NULL DEFAULT false, + "gotify_server_url" TEXT, + "gotify_token" TEXT, + "webhook_enabled" BOOLEAN NOT NULL DEFAULT false, + "webhook_url" TEXT, + "webhook_secret" TEXT, + "task_assignment_enabled" BOOLEAN NOT NULL DEFAULT true, + "task_comment_enabled" BOOLEAN NOT NULL DEFAULT true, + "task_status_change_enabled" BOOLEAN NOT NULL DEFAULT true, + "due_date_reminder_enabled" BOOLEAN NOT NULL DEFAULT true, + "due_date_reminder_lead_time_minutes" INTEGER NOT NULL DEFAULT 1440, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_preference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_workspace_rule" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "ntfy_enabled" BOOLEAN NOT NULL DEFAULT false, + "gotify_enabled" BOOLEAN NOT NULL DEFAULT false, + "webhook_enabled" BOOLEAN NOT NULL DEFAULT false, + "project_mode" TEXT NOT NULL DEFAULT 'all', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_workspace_rule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_workspace_project" ( + "id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "workspace_rule_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notification_workspace_project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "github_integration" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "repository_owner" TEXT NOT NULL, + "repository_name" TEXT NOT NULL, + "installation_id" INTEGER, + "is_active" BOOLEAN DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_integration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "integration" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "config" TEXT NOT NULL, + "is_active" BOOLEAN DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "integration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "external_link" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "integration_id" TEXT NOT NULL, + "resource_type" TEXT NOT NULL, + "external_id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "title" TEXT, + "metadata" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "external_link_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_relation" ( + "id" TEXT NOT NULL, + "source_task_id" TEXT NOT NULL, + "target_task_id" TEXT NOT NULL, + "relation_type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_relation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "device_code" ( + "id" TEXT NOT NULL, + "device_code" TEXT NOT NULL, + "user_code" TEXT NOT NULL, + "user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL, + "last_polled_at" TIMESTAMP(3), + "polling_interval" INTEGER, + "client_id" TEXT, + "scope" TEXT, + + CONSTRAINT "device_code_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mcp_oauth_state" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "key" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mcp_oauth_state_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_avatar_user_id_unique" ON "user_avatar"("user_id"); + +-- CreateIndex +CREATE INDEX "user_avatar_userId_idx" ON "user_avatar"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_slug_key" ON "workspace"("slug"); + +-- CreateIndex +CREATE INDEX "workspace_member_workspaceId_idx" ON "workspace_member"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_member_userId_idx" ON "workspace_member"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_billing_workspace_id_unique" ON "workspace_billing"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_billing_creem_subscription_id_key" ON "workspace_billing"("creem_subscription_id"); + +-- CreateIndex +CREATE INDEX "workspace_billing_workspaceId_idx" ON "workspace_billing"("workspace_id"); + +-- CreateIndex +CREATE INDEX "team_workspaceId_idx" ON "team"("workspace_id"); + +-- CreateIndex +CREATE INDEX "teamMember_teamId_idx" ON "team_member"("team_id"); + +-- CreateIndex +CREATE INDEX "teamMember_userId_idx" ON "team_member"("user_id"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_workspaceId_idx" ON "workspace_invitation"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_email_idx" ON "workspace_invitation"("email"); + +-- CreateIndex +CREATE INDEX "workspace_invitation_inviterId_idx" ON "workspace_invitation"("inviter_id"); + +-- CreateIndex +CREATE INDEX "workspace_role_workspaceId_idx" ON "workspace_role"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_role_role_idx" ON "workspace_role"("role"); + +-- CreateIndex +CREATE INDEX "project_workspaceId_position_idx" ON "project"("workspace_id", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_workspace_id_id_unique" ON "project"("workspace_id", "id"); + +-- CreateIndex +CREATE INDEX "column_projectId_idx" ON "column"("project_id"); + +-- CreateIndex +CREATE INDEX "workflow_rule_projectId_idx" ON "workflow_rule"("project_id"); + +-- CreateIndex +CREATE INDEX "workflow_rule_columnId_idx" ON "workflow_rule"("column_id"); + +-- CreateIndex +CREATE INDEX "task_projectId_idx" ON "task"("project_id"); + +-- CreateIndex +CREATE INDEX "task_dueDate_idx" ON "task"("due_date"); + +-- CreateIndex +CREATE INDEX "task_assigneeId_idx" ON "task"("assignee_id"); + +-- CreateIndex +CREATE INDEX "task_columnId_idx" ON "task"("column_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_project_number_unique" ON "task"("project_id", "number"); + +-- CreateIndex +CREATE INDEX "billing_reminder_sent_workspaceId_idx" ON "billing_reminder_sent"("workspace_id"); + +-- CreateIndex +CREATE INDEX "billing_reminder_sent_userId_idx" ON "billing_reminder_sent"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "billing_reminder_sent_user_type_unique" ON "billing_reminder_sent"("user_id", "reminder_type"); + +-- CreateIndex +CREATE INDEX "task_reminder_sent_taskId_idx" ON "task_reminder_sent"("task_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_reminder_sent_task_type_unique" ON "task_reminder_sent"("task_id", "reminder_type"); + +-- CreateIndex +CREATE INDEX "time_entry_taskId_idx" ON "time_entry"("task_id"); + +-- CreateIndex +CREATE INDEX "time_entry_userId_idx" ON "time_entry"("user_id"); + +-- CreateIndex +CREATE INDEX "activity_task_id_idx" ON "task_activity"("task_id"); + +-- CreateIndex +CREATE INDEX "activity_userId_idx" ON "task_activity"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "activity_task_external_source_external_url_unique" ON "task_activity"("task_id", "external_source", "external_url"); + +-- CreateIndex +CREATE UNIQUE INDEX "asset_object_key_key" ON "asset"("object_key"); + +-- CreateIndex +CREATE INDEX "asset_workspaceId_idx" ON "asset"("workspace_id"); + +-- CreateIndex +CREATE INDEX "asset_projectId_idx" ON "asset"("project_id"); + +-- CreateIndex +CREATE INDEX "asset_taskId_idx" ON "asset"("task_id"); + +-- CreateIndex +CREATE INDEX "asset_activityId_idx" ON "asset"("activity_id"); + +-- CreateIndex +CREATE INDEX "asset_createdBy_idx" ON "asset"("created_by"); + +-- CreateIndex +CREATE INDEX "label_task_id_idx" ON "label"("task_id"); + +-- CreateIndex +CREATE INDEX "label_workspace_id_idx" ON "label"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "label_task_name_unique" ON "label"("task_id", "name"); + +-- CreateIndex +CREATE INDEX "notification_userId_idx" ON "notification"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_preference_user_id_key" ON "user_notification_preference"("user_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_rule_userId_idx" ON "user_notification_workspace_rule"("user_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_rule_workspaceId_idx" ON "user_notification_workspace_rule"("workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_rule_user_workspace_unique" ON "user_notification_workspace_rule"("user_id", "workspace_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_rule_workspace_id_id_unique" ON "user_notification_workspace_rule"("workspace_id", "id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_ruleId_idx" ON "user_notification_workspace_project"("workspace_rule_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_projectId_idx" ON "user_notification_workspace_project"("project_id"); + +-- CreateIndex +CREATE INDEX "user_notification_workspace_project_workspaceId_projectId_idx" ON "user_notification_workspace_project"("workspace_id", "project_id"); + +-- CreateIndex +CREATE INDEX "unwp_workspaceId_workspaceRuleId_idx" ON "user_notification_workspace_project"("workspace_id", "workspace_rule_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_notification_workspace_project_rule_project_unique" ON "user_notification_workspace_project"("workspace_rule_id", "project_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "github_integration_project_id_key" ON "github_integration"("project_id"); + +-- CreateIndex +CREATE INDEX "integration_projectId_idx" ON "integration"("project_id"); + +-- CreateIndex +CREATE INDEX "integration_type_idx" ON "integration"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "integration_project_type_unique" ON "integration"("project_id", "type"); + +-- CreateIndex +CREATE INDEX "external_link_taskId_idx" ON "external_link"("task_id"); + +-- CreateIndex +CREATE INDEX "external_link_integrationId_idx" ON "external_link"("integration_id"); + +-- CreateIndex +CREATE INDEX "external_link_externalId_idx" ON "external_link"("external_id"); + +-- CreateIndex +CREATE INDEX "external_link_resourceType_idx" ON "external_link"("resource_type"); + +-- CreateIndex +CREATE INDEX "comment_task_idx" ON "comment"("task_id"); + +-- CreateIndex +CREATE INDEX "comment_user_idx" ON "comment"("user_id"); + +-- CreateIndex +CREATE INDEX "task_relation_source_idx" ON "task_relation"("source_task_id"); + +-- CreateIndex +CREATE INDEX "task_relation_target_idx" ON "task_relation"("target_task_id"); + +-- CreateIndex +CREATE INDEX "device_code_user_id_idx" ON "device_code"("user_id"); + +-- CreateIndex +CREATE INDEX "mcp_oauth_state_expiresAt_idx" ON "mcp_oauth_state"("expires_at"); + +-- AddForeignKey +ALTER TABLE "workspace_member" ADD CONSTRAINT "workspace_member_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_billing" ADD CONSTRAINT "workspace_billing_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team" ADD CONSTRAINT "team_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_role" ADD CONSTRAINT "workspace_role_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project" ADD CONSTRAINT "project_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "column" ADD CONSTRAINT "column_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_rule" ADD CONSTRAINT "workflow_rule_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workflow_rule" ADD CONSTRAINT "workflow_rule_column_id_fkey" FOREIGN KEY ("column_id") REFERENCES "column"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task" ADD CONSTRAINT "task_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task" ADD CONSTRAINT "task_column_id_fkey" FOREIGN KEY ("column_id") REFERENCES "column"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "billing_reminder_sent" ADD CONSTRAINT "billing_reminder_sent_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_reminder_sent" ADD CONSTRAINT "task_reminder_sent_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_activity" ADD CONSTRAINT "task_activity_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset" ADD CONSTRAINT "asset_activity_id_fkey" FOREIGN KEY ("activity_id") REFERENCES "task_activity"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "label" ADD CONSTRAINT "label_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "label" ADD CONSTRAINT "label_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_rule" ADD CONSTRAINT "user_notification_workspace_rule_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_workspace_fkey" FOREIGN KEY ("workspace_id", "workspace_rule_id") REFERENCES "user_notification_workspace_rule"("workspace_id", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_id_project_i_fkey" FOREIGN KEY ("workspace_id", "project_id") REFERENCES "project"("workspace_id", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "github_integration" ADD CONSTRAINT "github_integration_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "integration" ADD CONSTRAINT "integration_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_link" ADD CONSTRAINT "external_link_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_link" ADD CONSTRAINT "external_link_integration_id_fkey" FOREIGN KEY ("integration_id") REFERENCES "integration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD CONSTRAINT "comment_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_relation" ADD CONSTRAINT "task_relation_source_task_id_fkey" FOREIGN KEY ("source_task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_relation" ADD CONSTRAINT "task_relation_target_task_id_fkey" FOREIGN KEY ("target_task_id") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql b/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql new file mode 100644 index 000000000..074da25c9 --- /dev/null +++ b/packages/db/prisma/migrations/20260902182231_kaneo_auth_columns/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "user" ADD COLUMN "locale" TEXT; +ALTER TABLE "user" ADD COLUMN "role" TEXT; +ALTER TABLE "user" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user" ADD COLUMN "banned" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user" ADD COLUMN "banReason" TEXT; +ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3); +ALTER TABLE "session" ADD COLUMN "activeTeamId" TEXT; +ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT; diff --git a/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql b/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql new file mode 100644 index 000000000..b6677bd8c --- /dev/null +++ b/packages/db/prisma/migrations/20260902213708_kaneo_comment_user_nullable/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "comment" ALTER COLUMN "user_id" DROP NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e36263290..323269eb5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -18,6 +18,12 @@ model User { email String emailVerified Boolean @default(false) image String? + locale String? + role String? + isAnonymous Boolean @default(false) + banned Boolean @default(false) + banReason String? + banExpires DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] @@ -120,6 +126,8 @@ model Session { user User @relation(fields: [userId], references: [id], onDelete: Cascade) activeOrganizationId String? + activeTeamId String? + impersonatedBy String? @@unique([token]) @@index([userId]) @@ -1693,3 +1701,524 @@ model Apikey { @@index([key]) @@map("apikey") } +model UserAvatar { + id String @id @default(cuid()) + userId String @map("user_id") @unique(map: "user_avatar_user_id_unique") + mimeType String @map("mime_type") + size Int + data Bytes + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "user_avatar_userId_idx") + @@map("user_avatar") +} + +model Workspace { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata String? + description String? + createdAt DateTime @map("created_at") + workspaceMembers WorkspaceMember[] + workspaceBillings WorkspaceBilling[] + teams Team[] + workspaceInvitations WorkspaceInvitation[] + workspaceRoles WorkspaceRole[] + projects Project[] + billingReminderSents BillingReminderSent[] + assets Asset[] + labels Label[] + userNotificationWorkspaceRules UserNotificationWorkspaceRule[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@map("workspace") +} + +model WorkspaceMember { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + userId String @map("user_id") + role String @default("member") + joinedAt DateTime @map("joined_at") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_member_workspaceId_idx") + @@index([userId], map: "workspace_member_userId_idx") + @@map("workspace_member") +} + +model WorkspaceBilling { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") @unique(map: "workspace_billing_workspace_id_unique") + foundingFree Boolean @map("founding_free") @default(false) + trialEndsAt DateTime? @map("trial_ends_at") + creemCustomerId String? @map("creem_customer_id") + creemSubscriptionId String? @map("creem_subscription_id") @unique + creemProductId String? @map("creem_product_id") + plan String? + billingInterval String? @map("billing_interval") + status String? + seats Int @default(1) + currentPeriodEnd DateTime? @map("current_period_end") + canceledAt DateTime? @map("canceled_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_billing_workspaceId_idx") + @@map("workspace_billing") +} + +model TrialGrant { + emailHash String @map("email_hash") @id + trialEndsAt DateTime @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + @@map("trial_grant") +} + +model BillingEvent { + id String @id + eventType String @map("event_type") + processedAt DateTime @map("processed_at") @default(now()) + @@map("billing_event") +} + +model Team { + id String @id + name String + workspaceId String @map("workspace_id") + createdAt DateTime @map("created_at") + updatedAt DateTime? @map("updated_at") @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + teamMembers TeamMember[] + @@index([workspaceId], map: "team_workspaceId_idx") + @@map("team") +} + +model TeamMember { + id String @id + teamId String @map("team_id") + userId String @map("user_id") + createdAt DateTime? @map("created_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + @@index([teamId], map: "teamMember_teamId_idx") + @@index([userId], map: "teamMember_userId_idx") + @@map("team_member") +} + +model WorkspaceInvitation { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + email String + role String? + teamId String? @map("team_id") + status String @default("pending") + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + inviterId String @map("inviter_id") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_invitation_workspaceId_idx") + @@index([email], map: "workspace_invitation_email_idx") + @@index([inviterId], map: "workspace_invitation_inviterId_idx") + @@map("workspace_invitation") +} + +model WorkspaceRole { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + role String + permission String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_role_workspaceId_idx") + @@index([role], map: "workspace_role_role_idx") + @@map("workspace_role") +} + +model Project { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + slug String + icon String? @default("Layout") + name String + description String? + createdAt DateTime @map("created_at") @default(now()) + isPublic Boolean? @map("is_public") @default(false) + archivedAt DateTime? @map("archived_at") + lastTaskNumber Int @map("last_task_number") @default(0) + position Int @default(0) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectColumns ProjectColumn[] + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + assets Asset[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + githubIntegrations GithubIntegration[] + integrations Integration[] + @@unique([workspaceId, id], map: "project_workspace_id_id_unique") + @@index([workspaceId, position], map: "project_workspaceId_position_idx") + @@map("project") +} + +model ProjectColumn { + id String @id @default(cuid()) + projectId String @map("project_id") + name String + slug String + position Int @default(0) + icon String? + color String? + isFinal Boolean @map("is_final") @default(false) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + @@index([projectId], map: "column_projectId_idx") + @@map("column") +} + +model WorkflowRule { + id String @id @default(cuid()) + projectId String @map("project_id") + integrationType String @map("integration_type") + eventType String @map("event_type") + columnId String @map("column_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn @relation(fields: [columnId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([projectId], map: "workflow_rule_projectId_idx") + @@index([columnId], map: "workflow_rule_columnId_idx") + @@map("workflow_rule") +} + +model ProjectTask { + id String @id @default(cuid()) + projectId String @map("project_id") + position Int? @default(0) + number Int? @default(1) + userId String? @map("assignee_id") + title String + description String? + status String @default("to-do") + columnId String? @map("column_id") + priority String @default("low") + startDate DateTime? @map("start_date") + dueDate DateTime? @map("due_date") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn? @relation(fields: [columnId], references: [id], onDelete: SetNull, onUpdate: Cascade) + taskReminderSents TaskReminderSent[] + timeEntries TimeEntry[] + taskActivities TaskActivity[] + assets Asset[] + labels Label[] + externalLinks ExternalLink[] + taskComments TaskComment[] + taskRelations TaskRelation[] + taskRelations2 TaskRelation[] @relation("TaskRelationToProjectTask_1") + @@unique([projectId, number], map: "task_project_number_unique") + @@index([projectId], map: "task_projectId_idx") + @@index([dueDate], map: "task_dueDate_idx") + @@index([userId], map: "task_assigneeId_idx") + @@index([columnId], map: "task_columnId_idx") + @@map("task") +} + +model BillingReminderSent { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + reminderType String @map("reminder_type") + trialEndsAt DateTime? @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([userId, reminderType], map: "billing_reminder_sent_user_type_unique") + @@index([workspaceId], map: "billing_reminder_sent_workspaceId_idx") + @@index([userId], map: "billing_reminder_sent_userId_idx") + @@map("billing_reminder_sent") +} + +model JobLease { + name String @id + owner String + expiresAt DateTime @map("expires_at") + @@map("job_lease") +} + +model TaskReminderSent { + id String @id @default(cuid()) + taskId String @map("task_id") + reminderType String @map("reminder_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, reminderType], map: "task_reminder_sent_task_type_unique") + @@index([taskId], map: "task_reminder_sent_taskId_idx") + @@map("task_reminder_sent") +} + +model TimeEntry { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + description String? + startTime DateTime @map("start_time") + endTime DateTime? @map("end_time") + duration Int? @default(0) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "time_entry_taskId_idx") + @@index([userId], map: "time_entry_userId_idx") + @@map("time_entry") +} + +model TaskActivity { + id String @id @default(cuid()) + taskId String @map("task_id") + type String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + userId String? @map("user_id") + content String? + eventData Json? @map("event_data") + externalUserName String? @map("external_user_name") + externalUserAvatar String? @map("external_user_avatar") + externalSource String? @map("external_source") + externalUrl String? @map("external_url") + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + assets Asset[] + @@unique([taskId, externalSource, externalUrl], map: "activity_task_external_source_external_url_unique") + @@index([taskId], map: "activity_task_id_idx") + @@index([userId], map: "activity_userId_idx") + @@map("task_activity") +} + +model Asset { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + projectId String @map("project_id") + taskId String? @map("task_id") + activityId String? @map("activity_id") + objectKey String @map("object_key") @unique + filename String + mimeType String @map("mime_type") + size Int + kind String @default("image") + surface String @default("description") + createdBy String? @map("created_by") + createdAt DateTime @map("created_at") @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + activity TaskActivity? @relation(fields: [activityId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "asset_workspaceId_idx") + @@index([projectId], map: "asset_projectId_idx") + @@index([taskId], map: "asset_taskId_idx") + @@index([activityId], map: "asset_activityId_idx") + @@index([createdBy], map: "asset_createdBy_idx") + @@map("asset") +} + +model Label { + id String @id @default(cuid()) + name String + color String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + taskId String? @map("task_id") + workspaceId String? @map("workspace_id") + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, name], map: "label_task_name_unique") + @@index([taskId], map: "label_task_id_idx") + @@index([workspaceId], map: "label_workspace_id_idx") + @@map("label") +} + +model Notification { + id String @id @default(cuid()) + userId String @map("user_id") + title String? + content String? + type String @default("info") + eventData Json? @map("event_data") + isRead Boolean? @map("is_read") @default(false) + resourceId String? @map("resource_id") + resourceType String? @map("resource_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "notification_userId_idx") + @@map("notification") +} + +model UserNotificationPreference { + id String @id @default(cuid()) + userId String @map("user_id") @unique + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + ntfyServerUrl String? @map("ntfy_server_url") + ntfyTopic String? @map("ntfy_topic") + ntfyToken String? @map("ntfy_token") + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + gotifyServerUrl String? @map("gotify_server_url") + gotifyToken String? @map("gotify_token") + webhookEnabled Boolean @map("webhook_enabled") @default(false) + webhookUrl String? @map("webhook_url") + webhookSecret String? @map("webhook_secret") + taskAssignmentEnabled Boolean @map("task_assignment_enabled") @default(true) + taskCommentEnabled Boolean @map("task_comment_enabled") @default(true) + taskStatusChangeEnabled Boolean @map("task_status_change_enabled") @default(true) + dueDateReminderEnabled Boolean @map("due_date_reminder_enabled") @default(true) + dueDateReminderLeadTimeMinutes Int @map("due_date_reminder_lead_time_minutes") @default(1440) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@map("user_notification_preference") +} + +model UserNotificationWorkspaceRule { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + isActive Boolean @map("is_active") @default(true) + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + webhookEnabled Boolean @map("webhook_enabled") @default(false) + projectMode String @map("project_mode") @default("all") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@unique([userId, workspaceId], map: "user_notification_workspace_rule_user_workspace_unique") + @@unique([workspaceId, id], map: "user_notification_workspace_rule_workspace_id_id_unique") + @@index([userId], map: "user_notification_workspace_rule_userId_idx") + @@index([workspaceId], map: "user_notification_workspace_rule_workspaceId_idx") + @@map("user_notification_workspace_rule") +} + +model UserNotificationWorkspaceProject { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + workspaceRuleId String @map("workspace_rule_id") + projectId String @map("project_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceRule UserNotificationWorkspaceRule @relation(fields: [workspaceId, workspaceRuleId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [workspaceId, projectId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + @@unique([workspaceRuleId, projectId], map: "user_notification_workspace_project_rule_project_unique") + @@index([workspaceRuleId], map: "user_notification_workspace_project_ruleId_idx") + @@index([projectId], map: "user_notification_workspace_project_projectId_idx") + @@index([workspaceId, projectId], map: "user_notification_workspace_project_workspaceId_projectId_idx") + @@index([workspaceId, workspaceRuleId], map: "unwp_workspaceId_workspaceRuleId_idx") + @@map("user_notification_workspace_project") +} + +model GithubIntegration { + id String @id @default(cuid()) + projectId String @map("project_id") @unique + repositoryOwner String @map("repository_owner") + repositoryName String @map("repository_name") + installationId Int? @map("installation_id") + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@map("github_integration") +} + +model Integration { + id String @id @default(cuid()) + projectId String @map("project_id") + type String + config String + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalLinks ExternalLink[] + @@unique([projectId, type], map: "integration_project_type_unique") + @@index([projectId], map: "integration_projectId_idx") + @@index([type], map: "integration_type_idx") + @@map("integration") +} + +model ExternalLink { + id String @id @default(cuid()) + taskId String @map("task_id") + integrationId String @map("integration_id") + resourceType String @map("resource_type") + externalId String @map("external_id") + url String + title String? + metadata String? + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "external_link_taskId_idx") + @@index([integrationId], map: "external_link_integrationId_idx") + @@index([externalId], map: "external_link_externalId_idx") + @@index([resourceType], map: "external_link_resourceType_idx") + @@map("external_link") +} + +model TaskComment { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + content String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "comment_task_idx") + @@index([userId], map: "comment_user_idx") + @@map("comment") +} + +model TaskRelation { + id String @id @default(cuid()) + sourceTaskId String @map("source_task_id") + targetTaskId String @map("target_task_id") + relationType String @map("relation_type") + createdAt DateTime @map("created_at") @default(now()) + sourceTask ProjectTask @relation(fields: [sourceTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + targetTask ProjectTask @relation("TaskRelationToProjectTask_1", fields: [targetTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([sourceTaskId], map: "task_relation_source_idx") + @@index([targetTaskId], map: "task_relation_target_idx") + @@map("task_relation") +} + +model DeviceCode { + id String @id @default(cuid()) + deviceCode String @map("device_code") + userCode String @map("user_code") + userId String? @map("user_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + expiresAt DateTime @map("expires_at") + status String + lastPolledAt DateTime? @map("last_polled_at") + pollingInterval Int? @map("polling_interval") + clientId String? @map("client_id") + scope String? + @@index([userId], map: "device_code_user_id_idx") + @@map("device_code") +} + +model McpOauthState { + id String @id @default(cuid()) + kind String + key String + payload Json + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([expiresAt], map: "mcp_oauth_state_expiresAt_idx") + @@map("mcp_oauth_state") +} \ No newline at end of file diff --git a/packages/kaneo-domain/kaneo.prisma b/packages/kaneo-domain/kaneo.prisma new file mode 100644 index 000000000..efc435b74 --- /dev/null +++ b/packages/kaneo-domain/kaneo.prisma @@ -0,0 +1,521 @@ +model UserAvatar { + id String @id @default(cuid()) + userId String @map("user_id") @unique(map: "user_avatar_user_id_unique") + mimeType String @map("mime_type") + size Int + data Bytes + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "user_avatar_userId_idx") + @@map("user_avatar") +} + +model Workspace { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata String? + description String? + createdAt DateTime @map("created_at") + workspaceMembers WorkspaceMember[] + workspaceBillings WorkspaceBilling[] + teams Team[] + workspaceInvitations WorkspaceInvitation[] + workspaceRoles WorkspaceRole[] + projects Project[] + billingReminderSents BillingReminderSent[] + assets Asset[] + labels Label[] + userNotificationWorkspaceRules UserNotificationWorkspaceRule[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@map("workspace") +} + +model WorkspaceMember { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + userId String @map("user_id") + role String @default("member") + joinedAt DateTime @map("joined_at") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_member_workspaceId_idx") + @@index([userId], map: "workspace_member_userId_idx") + @@map("workspace_member") +} + +model WorkspaceBilling { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") @unique(map: "workspace_billing_workspace_id_unique") + foundingFree Boolean @map("founding_free") @default(false) + trialEndsAt DateTime? @map("trial_ends_at") + creemCustomerId String? @map("creem_customer_id") + creemSubscriptionId String? @map("creem_subscription_id") @unique + creemProductId String? @map("creem_product_id") + plan String? + billingInterval String? @map("billing_interval") + status String? + seats Int @default(1) + currentPeriodEnd DateTime? @map("current_period_end") + canceledAt DateTime? @map("canceled_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_billing_workspaceId_idx") + @@map("workspace_billing") +} + +model TrialGrant { + emailHash String @map("email_hash") @id + trialEndsAt DateTime @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + @@map("trial_grant") +} + +model BillingEvent { + id String @id + eventType String @map("event_type") + processedAt DateTime @map("processed_at") @default(now()) + @@map("billing_event") +} + +model Team { + id String @id + name String + workspaceId String @map("workspace_id") + createdAt DateTime @map("created_at") + updatedAt DateTime? @map("updated_at") @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + teamMembers TeamMember[] + @@index([workspaceId], map: "team_workspaceId_idx") + @@map("team") +} + +model TeamMember { + id String @id + teamId String @map("team_id") + userId String @map("user_id") + createdAt DateTime? @map("created_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + @@index([teamId], map: "teamMember_teamId_idx") + @@index([userId], map: "teamMember_userId_idx") + @@map("team_member") +} + +model WorkspaceInvitation { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + email String + role String? + teamId String? @map("team_id") + status String @default("pending") + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + inviterId String @map("inviter_id") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId], map: "workspace_invitation_workspaceId_idx") + @@index([email], map: "workspace_invitation_email_idx") + @@index([inviterId], map: "workspace_invitation_inviterId_idx") + @@map("workspace_invitation") +} + +model WorkspaceRole { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + role String + permission String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "workspace_role_workspaceId_idx") + @@index([role], map: "workspace_role_role_idx") + @@map("workspace_role") +} + +model Project { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + slug String + icon String? @default("Layout") + name String + description String? + createdAt DateTime @map("created_at") @default(now()) + isPublic Boolean? @map("is_public") @default(false) + archivedAt DateTime? @map("archived_at") + lastTaskNumber Int @map("last_task_number") @default(0) + position Int @default(0) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectColumns ProjectColumn[] + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + assets Asset[] + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + githubIntegrations GithubIntegration[] + integrations Integration[] + @@unique([workspaceId, id], map: "project_workspace_id_id_unique") + @@index([workspaceId, position], map: "project_workspaceId_position_idx") + @@map("project") +} + +model ProjectColumn { + id String @id @default(cuid()) + projectId String @map("project_id") + name String + slug String + position Int @default(0) + icon String? + color String? + isFinal Boolean @map("is_final") @default(false) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workflowRules WorkflowRule[] + projectTasks ProjectTask[] + @@index([projectId], map: "column_projectId_idx") + @@map("column") +} + +model WorkflowRule { + id String @id @default(cuid()) + projectId String @map("project_id") + integrationType String @map("integration_type") + eventType String @map("event_type") + columnId String @map("column_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn @relation(fields: [columnId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([projectId], map: "workflow_rule_projectId_idx") + @@index([columnId], map: "workflow_rule_columnId_idx") + @@map("workflow_rule") +} + +model ProjectTask { + id String @id @default(cuid()) + projectId String @map("project_id") + position Int? @default(0) + number Int? @default(1) + userId String? @map("assignee_id") + title String + description String? + status String @default("to-do") + columnId String? @map("column_id") + priority String @default("low") + startDate DateTime? @map("start_date") + dueDate DateTime? @map("due_date") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + column ProjectColumn? @relation(fields: [columnId], references: [id], onDelete: SetNull, onUpdate: Cascade) + taskReminderSents TaskReminderSent[] + timeEntries TimeEntry[] + taskActivities TaskActivity[] + assets Asset[] + labels Label[] + externalLinks ExternalLink[] + taskComments TaskComment[] + taskRelations TaskRelation[] + taskRelations2 TaskRelation[] @relation("TaskRelationToProjectTask_1") + @@unique([projectId, number], map: "task_project_number_unique") + @@index([projectId], map: "task_projectId_idx") + @@index([dueDate], map: "task_dueDate_idx") + @@index([userId], map: "task_assigneeId_idx") + @@index([columnId], map: "task_columnId_idx") + @@map("task") +} + +model BillingReminderSent { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + reminderType String @map("reminder_type") + trialEndsAt DateTime? @map("trial_ends_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([userId, reminderType], map: "billing_reminder_sent_user_type_unique") + @@index([workspaceId], map: "billing_reminder_sent_workspaceId_idx") + @@index([userId], map: "billing_reminder_sent_userId_idx") + @@map("billing_reminder_sent") +} + +model JobLease { + name String @id + owner String + expiresAt DateTime @map("expires_at") + @@map("job_lease") +} + +model TaskReminderSent { + id String @id @default(cuid()) + taskId String @map("task_id") + reminderType String @map("reminder_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, reminderType], map: "task_reminder_sent_task_type_unique") + @@index([taskId], map: "task_reminder_sent_taskId_idx") + @@map("task_reminder_sent") +} + +model TimeEntry { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String? @map("user_id") + description String? + startTime DateTime @map("start_time") + endTime DateTime? @map("end_time") + duration Int? @default(0) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "time_entry_taskId_idx") + @@index([userId], map: "time_entry_userId_idx") + @@map("time_entry") +} + +model TaskActivity { + id String @id @default(cuid()) + taskId String @map("task_id") + type String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + userId String? @map("user_id") + content String? + eventData Json? @map("event_data") + externalUserName String? @map("external_user_name") + externalUserAvatar String? @map("external_user_avatar") + externalSource String? @map("external_source") + externalUrl String? @map("external_url") + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + assets Asset[] + @@unique([taskId, externalSource, externalUrl], map: "activity_task_external_source_external_url_unique") + @@index([taskId], map: "activity_task_id_idx") + @@index([userId], map: "activity_userId_idx") + @@map("task_activity") +} + +model Asset { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + projectId String @map("project_id") + taskId String? @map("task_id") + activityId String? @map("activity_id") + objectKey String @map("object_key") @unique + filename String + mimeType String @map("mime_type") + size Int + kind String @default("image") + surface String @default("description") + createdBy String? @map("created_by") + createdAt DateTime @map("created_at") @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + activity TaskActivity? @relation(fields: [activityId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([workspaceId], map: "asset_workspaceId_idx") + @@index([projectId], map: "asset_projectId_idx") + @@index([taskId], map: "asset_taskId_idx") + @@index([activityId], map: "asset_activityId_idx") + @@index([createdBy], map: "asset_createdBy_idx") + @@map("asset") +} + +model Label { + id String @id @default(cuid()) + name String + color String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + taskId String? @map("task_id") + workspaceId String? @map("workspace_id") + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([taskId, name], map: "label_task_name_unique") + @@index([taskId], map: "label_task_id_idx") + @@index([workspaceId], map: "label_workspace_id_idx") + @@map("label") +} + +model Notification { + id String @id @default(cuid()) + userId String @map("user_id") + title String? + content String? + type String @default("info") + eventData Json? @map("event_data") + isRead Boolean? @map("is_read") @default(false) + resourceId String? @map("resource_id") + resourceType String? @map("resource_type") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([userId], map: "notification_userId_idx") + @@map("notification") +} + +model UserNotificationPreference { + id String @id @default(cuid()) + userId String @map("user_id") @unique + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + ntfyServerUrl String? @map("ntfy_server_url") + ntfyTopic String? @map("ntfy_topic") + ntfyToken String? @map("ntfy_token") + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + gotifyServerUrl String? @map("gotify_server_url") + gotifyToken String? @map("gotify_token") + webhookEnabled Boolean @map("webhook_enabled") @default(false) + webhookUrl String? @map("webhook_url") + webhookSecret String? @map("webhook_secret") + taskAssignmentEnabled Boolean @map("task_assignment_enabled") @default(true) + taskCommentEnabled Boolean @map("task_comment_enabled") @default(true) + taskStatusChangeEnabled Boolean @map("task_status_change_enabled") @default(true) + dueDateReminderEnabled Boolean @map("due_date_reminder_enabled") @default(true) + dueDateReminderLeadTimeMinutes Int @map("due_date_reminder_lead_time_minutes") @default(1440) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@map("user_notification_preference") +} + +model UserNotificationWorkspaceRule { + id String @id @default(cuid()) + userId String @map("user_id") + workspaceId String @map("workspace_id") + isActive Boolean @map("is_active") @default(true) + emailEnabled Boolean @map("email_enabled") @default(false) + ntfyEnabled Boolean @map("ntfy_enabled") @default(false) + gotifyEnabled Boolean @map("gotify_enabled") @default(false) + webhookEnabled Boolean @map("webhook_enabled") @default(false) + projectMode String @map("project_mode") @default("all") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceProjects UserNotificationWorkspaceProject[] + @@unique([userId, workspaceId], map: "user_notification_workspace_rule_user_workspace_unique") + @@unique([workspaceId, id], map: "user_notification_workspace_rule_workspace_id_id_unique") + @@index([userId], map: "user_notification_workspace_rule_userId_idx") + @@index([workspaceId], map: "user_notification_workspace_rule_workspaceId_idx") + @@map("user_notification_workspace_rule") +} + +model UserNotificationWorkspaceProject { + id String @id @default(cuid()) + workspaceId String @map("workspace_id") + workspaceRuleId String @map("workspace_rule_id") + projectId String @map("project_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userNotificationWorkspaceRule UserNotificationWorkspaceRule @relation(fields: [workspaceId, workspaceRuleId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + project Project @relation(fields: [workspaceId, projectId], references: [workspaceId, id], onDelete: Cascade, onUpdate: Cascade) + @@unique([workspaceRuleId, projectId], map: "user_notification_workspace_project_rule_project_unique") + @@index([workspaceRuleId], map: "user_notification_workspace_project_ruleId_idx") + @@index([projectId], map: "user_notification_workspace_project_projectId_idx") + @@index([workspaceId, projectId], map: "user_notification_workspace_project_workspaceId_projectId_idx") + @@index([workspaceId, workspaceRuleId], map: "unwp_workspaceId_workspaceRuleId_idx") + @@map("user_notification_workspace_project") +} + +model GithubIntegration { + id String @id @default(cuid()) + projectId String @map("project_id") @unique + repositoryOwner String @map("repository_owner") + repositoryName String @map("repository_name") + installationId Int? @map("installation_id") + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@map("github_integration") +} + +model Integration { + id String @id @default(cuid()) + projectId String @map("project_id") + type String + config String + isActive Boolean? @map("is_active") @default(true) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalLinks ExternalLink[] + @@unique([projectId, type], map: "integration_project_type_unique") + @@index([projectId], map: "integration_projectId_idx") + @@index([type], map: "integration_type_idx") + @@map("integration") +} + +model ExternalLink { + id String @id @default(cuid()) + taskId String @map("task_id") + integrationId String @map("integration_id") + resourceType String @map("resource_type") + externalId String @map("external_id") + url String + title String? + metadata String? + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "external_link_taskId_idx") + @@index([integrationId], map: "external_link_integrationId_idx") + @@index([externalId], map: "external_link_externalId_idx") + @@index([resourceType], map: "external_link_resourceType_idx") + @@map("external_link") +} + +model TaskComment { + id String @id @default(cuid()) + taskId String @map("task_id") + userId String @map("user_id") + content String + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + task ProjectTask @relation(fields: [taskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([taskId], map: "comment_task_idx") + @@index([userId], map: "comment_user_idx") + @@map("comment") +} + +model TaskRelation { + id String @id @default(cuid()) + sourceTaskId String @map("source_task_id") + targetTaskId String @map("target_task_id") + relationType String @map("relation_type") + createdAt DateTime @map("created_at") @default(now()) + sourceTask ProjectTask @relation(fields: [sourceTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + targetTask ProjectTask @relation("TaskRelationToProjectTask_1", fields: [targetTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([sourceTaskId], map: "task_relation_source_idx") + @@index([targetTaskId], map: "task_relation_target_idx") + @@map("task_relation") +} + +model DeviceCode { + id String @id @default(cuid()) + deviceCode String @map("device_code") + userCode String @map("user_code") + userId String? @map("user_id") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + expiresAt DateTime @map("expires_at") + status String + lastPolledAt DateTime? @map("last_polled_at") + pollingInterval Int? @map("polling_interval") + clientId String? @map("client_id") + scope String? + @@index([userId], map: "device_code_user_id_idx") + @@map("device_code") +} + +model McpOauthState { + id String @id @default(cuid()) + kind String + key String + payload Json + expiresAt DateTime @map("expires_at") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @default(now()) @updatedAt + @@index([expiresAt], map: "mcp_oauth_state_expiresAt_idx") + @@map("mcp_oauth_state") +} \ No newline at end of file diff --git a/packages/kaneo-domain/package.json b/packages/kaneo-domain/package.json new file mode 100644 index 000000000..7af09fd11 --- /dev/null +++ b/packages/kaneo-domain/package.json @@ -0,0 +1,24 @@ +{ + "name": "@crm/kaneo-domain", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./drizzle": "./src/drizzle.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "lint": "biome check .", + "test": "bun test", + "generate:prisma": "bun scripts/generate-prisma.ts", + "clean": "rm -rf .turbo node_modules" + }, + "devDependencies": { + "@crm/typescript-config": "workspace:*", + "@paralleldrive/cuid2": "^3.3.0", + "@types/node": "^24.10.1", + "drizzle-orm": "^0.45.2", + "typescript": "5.9.2" + } +} diff --git a/packages/kaneo-domain/scripts/generate-prisma.ts b/packages/kaneo-domain/scripts/generate-prisma.ts new file mode 100644 index 000000000..8f64cd999 --- /dev/null +++ b/packages/kaneo-domain/scripts/generate-prisma.ts @@ -0,0 +1,15 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { + DEFAULT_EXCLUDE, + DEFAULT_RENAME, + kaneoSchema, + toPrismaFragment, +} from "../src/index"; + +const fragment = toPrismaFragment(kaneoSchema, { + exclude: DEFAULT_EXCLUDE, + rename: DEFAULT_RENAME, +}); +writeFileSync(path.join(import.meta.dir, "..", "kaneo.prisma"), fragment); +console.log(`wrote kaneo.prisma (${fragment.split("\n").length} lines)`); diff --git a/packages/kaneo-domain/src/drizzle.ts b/packages/kaneo-domain/src/drizzle.ts new file mode 100644 index 000000000..9b0f3488e --- /dev/null +++ b/packages/kaneo-domain/src/drizzle.ts @@ -0,0 +1,220 @@ +import { createId } from "@paralleldrive/cuid2"; +import { sql, Table } from "drizzle-orm"; +import { + boolean, + customType, + foreignKey, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { + ColumnDef, + ColumnRef, + ForeignKeyDef, + IndexDef, + SchemaDef, + TableDef, +} from "./dsl"; + +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, +}); + +type PgTable = ReturnType; +type Column = ReturnType; + +const tableNameSymbol = ( + Table as unknown as { Symbol: { Name: symbol; Columns: symbol } } +).Symbol; + +export interface DrizzleSchema { + tables: Record; + columns: Record>; +} + +function buildColumn( + def: ColumnDef, + resolveRef: (ref: ColumnRef) => Column, +): any { + let col: any; + switch (def.type) { + case "text": + col = text(def.name); + break; + case "boolean": + col = boolean(def.name); + break; + case "integer": + col = integer(def.name); + break; + case "timestamp": + col = timestamp(def.name, { + mode: "date", + withTimezone: def.withTimezone, + }); + break; + case "jsonb": + col = jsonb(def.name); + break; + case "bytea": + col = bytea(def.name); + break; + } + + if (def.primary) { + col = col.primaryKey(); + } + if (def.notNull) { + col = col.notNull(); + } + if (def.unique !== null) { + col = def.unique === "" ? col.unique() : col.unique(def.unique); + } + const defaultValue = def.default; + if (defaultValue) { + switch (defaultValue.kind) { + case "literal": + col = col.default(defaultValue.value); + break; + case "now": + col = col.defaultNow(); + break; + case "cuid": + col = col.$defaultFn(() => createId()); + break; + case "client": + col = + defaultValue.value === false + ? col.$defaultFn(() => false) + : defaultValue.value === true + ? col.$defaultFn(() => true) + : col.$defaultFn(() => defaultValue.value); + break; + } + } + if (def.onUpdateNow) { + col = col.$onUpdate(() => new Date()); + } + const ref = def.ref; + if (ref) { + col = col.references(() => resolveRef(ref), { + onDelete: ref.onDelete, + onUpdate: ref.onUpdate, + }); + } + return col; +} + +function buildIndexes(def: TableDef, t: Record) { + const build = (idx: IndexDef) => { + const cols = idx.columns.map((c) => t[c]) as [any, ...any[]]; + switch (idx.kind) { + case "unique": + return unique(idx.name).on(...cols); + case "uniqueIndex": + return idx.where + ? uniqueIndex(idx.name) + .on(...cols) + .where(sql.raw(idx.where)) + : uniqueIndex(idx.name).on(...cols); + case "index": + return index(idx.name).on(...cols); + } + }; + return (def.indexes ?? []).map(build); +} + +function buildForeignKeys( + def: TableDef, + t: Record, + resolveTargetColumn: (table: string, column: string) => Column, +) { + const build = (fk: ForeignKeyDef) => { + const localColumns: any = fk.columns.map((c) => t[c]); + const foreignColumns: any = fk.refColumns.map((c) => + resolveTargetColumn(fk.refTable, c), + ); + const fkBuilder = foreignKey({ columns: localColumns, foreignColumns }); + if (fk.onDelete) { + fkBuilder.onDelete(fk.onDelete); + } + if (fk.onUpdate) { + fkBuilder.onUpdate(fk.onUpdate); + } + return fkBuilder; + }; + return (def.foreignKeys ?? []).map(build); +} + +export function toDrizzleSchema(schema: SchemaDef): DrizzleSchema { + const built: Record = {}; + const columns: Record> = {}; + + const resolveRef = (ref: ColumnRef): Column => { + const tableColumns = columns[ref.table]; + if (!tableColumns) { + throw new Error( + `kaneo domain: ref target table ${ref.table} is not built`, + ); + } + const refColumn = ref.columns[0]; + if (!refColumn) { + throw new Error( + `kaneo domain: ref target column ${ref.table} has no columns`, + ); + } + const col = tableColumns[refColumn]; + if (!col) { + throw new Error( + `kaneo domain: ref target column ${ref.table}.${refColumn} is not built`, + ); + } + return col; + }; + + const attachedColumns = (table: unknown) => + (table as Record)[tableNameSymbol.Columns] as + | Record + | undefined; + + for (const def of schema.tables) { + const columnBuilders: Record = {}; + for (const column of def.columns) { + columnBuilders[column.key] = buildColumn(column, resolveRef); + } + const table = pgTable(def.name, columnBuilders, (t) => [ + ...buildIndexes(def, t), + ...buildForeignKeys(def, t, (tableName, columnName) => { + const targetColumns = columns[tableName]; + const column = targetColumns?.[columnName]; + if (!column) { + throw new Error( + `kaneo domain: fk target column ${tableName}.${columnName} is not built`, + ); + } + return column; + }), + ]); + built[def.name] = table; + const tableColumnsMap: Record = {}; + columns[def.name] = tableColumnsMap; + for (const column of Object.values(attachedColumns(table) ?? {})) { + const loose = column as unknown as { + config?: { name?: string }; + name?: string; + }; + const physical = loose.config?.name ?? loose.name ?? ""; + tableColumnsMap[physical] = column; + } + } + + return { tables: built, columns }; +} diff --git a/packages/kaneo-domain/src/dsl.ts b/packages/kaneo-domain/src/dsl.ts new file mode 100644 index 000000000..d0ab9c186 --- /dev/null +++ b/packages/kaneo-domain/src/dsl.ts @@ -0,0 +1,207 @@ +export type ColumnType = + | "text" + | "boolean" + | "integer" + | "timestamp" + | "jsonb" + | "bytea"; + +export type RefAction = "cascade" | "set null" | "restrict"; + +export interface ColumnRef { + table: string; + columns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +export type ColumnDefault = + | { kind: "literal"; value: string | number | boolean } + | { kind: "now" } + | { kind: "cuid" } + | { kind: "client"; value: string | number | boolean }; + +export interface ColumnDef { + key: string; + name: string; + type: ColumnType; + withTimezone: boolean; + notNull: boolean; + primary: boolean; + unique: string | null; + default: ColumnDefault | null; + onUpdateNow: boolean; + ref: ColumnRef | null; +} + +export interface IndexDef { + name: string; + columns: string[]; + kind: "index" | "unique" | "uniqueIndex"; + where?: string; +} + +export interface ForeignKeyDef { + name?: string; + columns: string[]; + refTable: string; + refColumns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +export interface TableDef { + name: string; + columns: ColumnDef[]; + indexes?: IndexDef[]; + foreignKeys?: ForeignKeyDef[]; +} + +export class ColumnBuilder { + private readonly def: ColumnDef; + + constructor(key: string, name: string, type: ColumnType) { + this.def = { + key, + name, + type, + withTimezone: false, + notNull: false, + primary: false, + unique: null, + default: null, + onUpdateNow: false, + ref: null, + }; + } + + pk(): this { + this.def.primary = true; + return this; + } + + notNull(): this { + this.def.notNull = true; + return this; + } + + unique(name?: string): this { + this.def.unique = name ?? ""; + return this; + } + + default(value: string | number | boolean): this { + this.def.default = { kind: "literal", value }; + return this; + } + + defaultNow(): this { + this.def.default = { kind: "now" }; + return this; + } + + defaultCuid(): this { + this.def.default = { kind: "cuid" }; + return this; + } + + clientDefault(value: string | number | boolean): this { + this.def.default = { kind: "client", value }; + return this; + } + + onUpdateNow(): this { + this.def.onUpdateNow = true; + return this; + } + + withTimezone(): this { + this.def.withTimezone = true; + return this; + } + + ref( + table: string, + opts?: { columns?: string[]; onDelete?: RefAction; onUpdate?: RefAction }, + ): this { + this.def.ref = { + table, + columns: opts?.columns ?? ["id"], + onDelete: opts?.onDelete, + onUpdate: opts?.onUpdate, + }; + return this; + } + + toDef(): ColumnDef { + return this.def; + } +} + +export const t = { + text: (key: string, name: string) => new ColumnBuilder(key, name, "text"), + boolean: (key: string, name: string) => + new ColumnBuilder(key, name, "boolean"), + integer: (key: string, name: string) => + new ColumnBuilder(key, name, "integer"), + timestamp: (key: string, name: string) => + new ColumnBuilder(key, name, "timestamp"), + jsonb: (key: string, name: string) => new ColumnBuilder(key, name, "jsonb"), + bytea: (key: string, name: string) => new ColumnBuilder(key, name, "bytea"), +}; + +export function table( + name: string, + columns: Record, + opts?: { indexes?: IndexDef[]; foreignKeys?: ForeignKeyDef[] }, +): TableDef { + return { + name, + columns: Object.entries(columns).map(([key, builder]) => builder.toDef()), + indexes: opts?.indexes, + foreignKeys: opts?.foreignKeys, + }; +} + +export function index(name: string, columns: string[]): IndexDef { + return { name, columns, kind: "index" }; +} + +export function unique(name: string, columns: string[]): IndexDef { + return { name, columns, kind: "unique" }; +} + +export function uniqueIndex( + name: string, + columns: string[], + where?: string, +): IndexDef { + return { name, columns, kind: "uniqueIndex", where }; +} + +export function foreignKey(def: { + name?: string; + columns: string[]; + refTable: string; + refColumns: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; +}): ForeignKeyDef { + return def; +} + +export interface SchemaDef { + tables: TableDef[]; +} + +export function defineSchema(tables: TableDef[]): SchemaDef { + return { tables }; +} + +export function schemaTable(schema: SchemaDef, name: string): TableDef { + const found = schema.tables.find((t) => t.name === name); + if (!found) { + throw new Error(`kaneo domain: no table named ${name}`); + } + return found; +} diff --git a/packages/kaneo-domain/src/index.ts b/packages/kaneo-domain/src/index.ts new file mode 100644 index 000000000..2d957632d --- /dev/null +++ b/packages/kaneo-domain/src/index.ts @@ -0,0 +1,4 @@ +export { toDrizzleSchema } from "./drizzle"; +export * from "./dsl"; +export { kaneoSchema } from "./kaneo"; +export { DEFAULT_EXCLUDE, DEFAULT_RENAME, toPrismaFragment } from "./prisma"; diff --git a/packages/kaneo-domain/src/kaneo.ts b/packages/kaneo-domain/src/kaneo.ts new file mode 100644 index 000000000..dafb363f8 --- /dev/null +++ b/packages/kaneo-domain/src/kaneo.ts @@ -0,0 +1,1043 @@ +import { + defineSchema, + foreignKey, + index, + t, + table, + unique, + uniqueIndex, +} from "./dsl"; + +export const kaneoSchema = defineSchema([ + table("user", { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + email: t.text("email", "email").notNull().unique(), + emailVerified: t + .boolean("emailVerified", "emailVerified") + .notNull() + .clientDefault(false), + image: t.text("image", "image"), + locale: t.text("locale", "locale"), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updatedAt") + .defaultNow() + .onUpdateNow() + .notNull(), + isAnonymous: t.boolean("isAnonymous", "isAnonymous").default(false), + role: t.text("role", "role"), + banned: t.boolean("banned", "banned").default(false), + banReason: t.text("banReason", "banReason"), + banExpires: t.timestamp("banExpires", "banExpires"), + }), + table( + "session", + { + id: t.text("id", "id").pk(), + expiresAt: t.timestamp("expiresAt", "expiresAt").notNull(), + token: t.text("token", "token").notNull().unique(), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").onUpdateNow().notNull(), + ipAddress: t.text("ipAddress", "ipAddress"), + userAgent: t.text("userAgent", "userAgent"), + userId: t + .text("userId", "userId") + .notNull() + .ref("user", { onDelete: "cascade" }), + activeOrganizationId: t.text( + "activeOrganizationId", + "activeOrganizationId", + ), + activeTeamId: t.text("activeTeamId", "activeTeamId"), + impersonatedBy: t.text("impersonatedBy", "impersonatedBy"), + }, + { + indexes: [index("session_userId_idx", ["userId"])], + }, + ), + table( + "account", + { + id: t.text("id", "id").pk().defaultCuid(), + accountId: t.text("accountId", "accountId").notNull(), + providerId: t.text("providerId", "providerId").notNull(), + userId: t + .text("userId", "userId") + .notNull() + .ref("user", { onDelete: "cascade" }), + accessToken: t.text("accessToken", "accessToken"), + refreshToken: t.text("refreshToken", "refreshToken"), + idToken: t.text("idToken", "idToken"), + accessTokenExpiresAt: t.timestamp( + "accessTokenExpiresAt", + "accessToken_expires_at", + ), + refreshTokenExpiresAt: t.timestamp( + "refreshTokenExpiresAt", + "refreshToken_expires_at", + ), + scope: t.text("scope", "scope"), + password: t.text("password", "password"), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").onUpdateNow().notNull(), + }, + { + indexes: [index("account_userId_idx", ["userId"])], + }, + ), + table( + "user_avatar", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .unique("user_avatar_user_id_unique") + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + mimeType: t.text("mimeType", "mime_type").notNull(), + size: t.integer("size", "size").notNull(), + data: t.bytea("data", "data").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("user_avatar_userId_idx", ["userId"])], + }, + ), + table( + "verification", + { + id: t.text("id", "id").pk().defaultCuid(), + identifier: t.text("identifier", "identifier").notNull(), + value: t.text("value", "value").notNull(), + expiresAt: t.timestamp("expiresAt", "expiresAt").notNull(), + createdAt: t.timestamp("createdAt", "createdAt").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updatedAt") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("verification_identifier_idx", ["identifier"])], + }, + ), + table("workspace", { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + slug: t.text("slug", "slug").notNull().unique(), + logo: t.text("logo", "logo"), + metadata: t.text("metadata", "metadata"), + description: t.text("description", "description"), + createdAt: t.timestamp("createdAt", "created_at").notNull(), + }), + table( + "workspace_member", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + role: t.text("role", "role").notNull().default("member"), + joinedAt: t.timestamp("joinedAt", "joined_at").notNull(), + }, + { + indexes: [ + index("workspace_member_workspaceId_idx", ["workspaceId"]), + index("workspace_member_userId_idx", ["userId"]), + ], + }, + ), + table( + "workspace_billing", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .unique("workspace_billing_workspace_id_unique") + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + foundingFree: t + .boolean("foundingFree", "founding_free") + .notNull() + .default(false), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at"), + creemCustomerId: t.text("creemCustomerId", "creem_customer_id"), + creemSubscriptionId: t + .text("creemSubscriptionId", "creem_subscription_id") + .unique(), + creemProductId: t.text("creemProductId", "creem_product_id"), + plan: t.text("plan", "plan"), + billingInterval: t.text("billingInterval", "billing_interval"), + status: t.text("status", "status"), + seats: t.integer("seats", "seats").notNull().default(1), + currentPeriodEnd: t.timestamp("currentPeriodEnd", "current_period_end"), + canceledAt: t.timestamp("canceledAt", "canceled_at"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("workspace_billing_workspaceId_idx", ["workspaceId"])], + }, + ), + table("trial_grant", { + emailHash: t.text("emailHash", "email_hash").pk(), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }), + table("billing_event", { + id: t.text("id", "id").pk(), + eventType: t.text("eventType", "event_type").notNull(), + processedAt: t + .timestamp("processedAt", "processed_at") + .defaultNow() + .notNull(), + }), + table( + "team", + { + id: t.text("id", "id").pk(), + name: t.text("name", "name").notNull(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").notNull(), + updatedAt: t.timestamp("updatedAt", "updated_at").onUpdateNow(), + }, + { + indexes: [index("team_workspaceId_idx", ["workspaceId"])], + }, + ), + table( + "team_member", + { + id: t.text("id", "id").pk(), + teamId: t + .text("teamId", "team_id") + .notNull() + .ref("team", { onDelete: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at"), + }, + { + indexes: [ + index("teamMember_teamId_idx", ["teamId"]), + index("teamMember_userId_idx", ["userId"]), + ], + }, + ), + table( + "workspace_invitation", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade" }), + email: t.text("email", "email").notNull(), + role: t.text("role", "role"), + teamId: t.text("teamId", "team_id"), + status: t.text("status", "status").notNull().default("pending"), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + inviterId: t + .text("inviterId", "inviter_id") + .notNull() + .ref("user", { onDelete: "cascade" }), + }, + { + indexes: [ + index("workspace_invitation_workspaceId_idx", ["workspaceId"]), + index("workspace_invitation_email_idx", ["email"]), + index("workspace_invitation_inviterId_idx", ["inviterId"]), + ], + }, + ), + table( + "workspace_role", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + role: t.text("role", "role").notNull(), + permission: t.text("permission", "permission").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("workspace_role_workspaceId_idx", ["workspaceId"]), + index("workspace_role_role_idx", ["role"]), + ], + }, + ), + table( + "project", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + slug: t.text("slug", "slug").notNull(), + icon: t.text("icon", "icon").default("Layout"), + name: t.text("name", "name").notNull(), + description: t.text("description", "description"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + isPublic: t.boolean("isPublic", "is_public").default(false), + archivedAt: t.timestamp("archivedAt", "archived_at"), + lastTaskNumber: t + .integer("lastTaskNumber", "last_task_number") + .notNull() + .default(0), + position: t.integer("position", "position").notNull().default(0), + }, + { + indexes: [ + unique("project_workspace_id_id_unique", ["workspaceId", "id"]), + index("project_workspaceId_position_idx", ["workspaceId", "position"]), + ], + }, + ), + table( + "column", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + name: t.text("name", "name").notNull(), + slug: t.text("slug", "slug").notNull(), + position: t.integer("position", "position").notNull().default(0), + icon: t.text("icon", "icon"), + color: t.text("color", "color"), + isFinal: t.boolean("isFinal", "is_final").notNull().default(false), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("column_projectId_idx", ["projectId"])], + }, + ), + table( + "workflow_rule", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + integrationType: t.text("integrationType", "integration_type").notNull(), + eventType: t.text("eventType", "event_type").notNull(), + columnId: t + .text("columnId", "column_id") + .notNull() + .ref("column", { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("workflow_rule_projectId_idx", ["projectId"]), + index("workflow_rule_columnId_idx", ["columnId"]), + ], + }, + ), + table( + "task", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + position: t.integer("position", "position").default(0), + number: t.integer("number", "number").default(1), + userId: t + .text("userId", "assignee_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + title: t.text("title", "title").notNull(), + description: t.text("description", "description"), + status: t.text("status", "status").notNull().default("to-do"), + columnId: t + .text("columnId", "column_id") + .ref("column", { onDelete: "set null", onUpdate: "cascade" }), + priority: t.text("priority", "priority").notNull().default("low"), + startDate: t.timestamp("startDate", "start_date"), + dueDate: t.timestamp("dueDate", "due_date"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("task_projectId_idx", ["projectId"]), + index("task_dueDate_idx", ["dueDate"]), + index("task_assigneeId_idx", ["userId"]), + index("task_columnId_idx", ["columnId"]), + unique("task_project_number_unique", ["projectId", "number"]), + ], + }, + ), + table( + "billing_reminder_sent", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + reminderType: t.text("reminderType", "reminder_type").notNull(), + trialEndsAt: t.timestamp("trialEndsAt", "trial_ends_at"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("billing_reminder_sent_workspaceId_idx", ["workspaceId"]), + index("billing_reminder_sent_userId_idx", ["userId"]), + unique("billing_reminder_sent_user_type_unique", [ + "userId", + "reminderType", + ]), + ], + }, + ), + table("job_lease", { + name: t.text("name", "name").pk(), + owner: t.text("owner", "owner").notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + }), + table( + "task_reminder_sent", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + reminderType: t.text("reminderType", "reminder_type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("task_reminder_sent_taskId_idx", ["taskId"]), + unique("task_reminder_sent_task_type_unique", [ + "taskId", + "reminderType", + ]), + ], + }, + ), + table( + "time_entry", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + description: t.text("description", "description"), + startTime: t.timestamp("startTime", "start_time").notNull(), + endTime: t.timestamp("endTime", "end_time"), + duration: t.integer("duration", "duration").default(0), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("time_entry_taskId_idx", ["taskId"]), + index("time_entry_userId_idx", ["userId"]), + ], + }, + ), + table( + "task_activity", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + type: t.text("type", "type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + content: t.text("content", "content"), + eventData: t.jsonb("eventData", "event_data"), + externalUserName: t.text("externalUserName", "external_user_name"), + externalUserAvatar: t.text("externalUserAvatar", "external_user_avatar"), + externalSource: t.text("externalSource", "external_source"), + externalUrl: t.text("externalUrl", "external_url"), + }, + { + indexes: [ + index("activity_task_id_idx", ["taskId"]), + index("activity_userId_idx", ["userId"]), + unique("activity_task_external_source_external_url_unique", [ + "taskId", + "externalSource", + "externalUrl", + ]), + ], + }, + ), + table( + "asset", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + taskId: t + .text("taskId", "task_id") + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + activityId: t + .text("activityId", "activity_id") + .ref("task_activity", { onDelete: "cascade", onUpdate: "cascade" }), + objectKey: t.text("objectKey", "object_key").notNull().unique(), + filename: t.text("filename", "filename").notNull(), + mimeType: t.text("mimeType", "mime_type").notNull(), + size: t.integer("size", "size").notNull(), + kind: t.text("kind", "kind").notNull().default("image"), + surface: t.text("surface", "surface").notNull().default("description"), + createdBy: t + .text("createdBy", "created_by") + .ref("user", { onDelete: "set null", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }, + { + indexes: [ + index("asset_workspaceId_idx", ["workspaceId"]), + index("asset_projectId_idx", ["projectId"]), + index("asset_taskId_idx", ["taskId"]), + index("asset_activityId_idx", ["activityId"]), + index("asset_createdBy_idx", ["createdBy"]), + ], + }, + ), + table( + "label", + { + id: t.text("id", "id").pk().defaultCuid(), + name: t.text("name", "name").notNull(), + color: t.text("color", "color").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + taskId: t + .text("taskId", "task_id") + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + }, + { + indexes: [ + index("label_task_id_idx", ["taskId"]), + index("label_workspace_id_idx", ["workspaceId"]), + unique("label_task_name_unique", ["taskId", "name"]), + uniqueIndex( + "label_workspace_name_unique", + ["workspaceId", "name"], + "task_id is null", + ), + ], + }, + ), + table( + "notification", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + title: t.text("title", "title"), + content: t.text("content", "content"), + type: t.text("type", "type").notNull().default("info"), + eventData: t.jsonb("eventData", "event_data"), + isRead: t.boolean("isRead", "is_read").default(false), + resourceId: t.text("resourceId", "resource_id"), + resourceType: t.text("resourceType", "resource_type"), + createdAt: t + .timestamp("createdAt", "created_at") + .withTimezone() + .defaultNow() + .notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .withTimezone() + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [index("notification_userId_idx", ["userId"])], + }, + ), + table("user_notification_preference", { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .unique() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + emailEnabled: t + .boolean("emailEnabled", "email_enabled") + .notNull() + .default(false), + ntfyEnabled: t + .boolean("ntfyEnabled", "ntfy_enabled") + .notNull() + .default(false), + ntfyServerUrl: t.text("ntfyServerUrl", "ntfy_server_url"), + ntfyTopic: t.text("ntfyTopic", "ntfy_topic"), + ntfyToken: t.text("ntfyToken", "ntfy_token"), + gotifyEnabled: t + .boolean("gotifyEnabled", "gotify_enabled") + .notNull() + .default(false), + gotifyServerUrl: t.text("gotifyServerUrl", "gotify_server_url"), + gotifyToken: t.text("gotifyToken", "gotify_token"), + webhookEnabled: t + .boolean("webhookEnabled", "webhook_enabled") + .notNull() + .default(false), + webhookUrl: t.text("webhookUrl", "webhook_url"), + webhookSecret: t.text("webhookSecret", "webhook_secret"), + taskAssignmentEnabled: t + .boolean("taskAssignmentEnabled", "task_assignment_enabled") + .notNull() + .default(true), + taskCommentEnabled: t + .boolean("taskCommentEnabled", "task_comment_enabled") + .notNull() + .default(true), + taskStatusChangeEnabled: t + .boolean("taskStatusChangeEnabled", "task_status_change_enabled") + .notNull() + .default(true), + dueDateReminderEnabled: t + .boolean("dueDateReminderEnabled", "due_date_reminder_enabled") + .notNull() + .default(true), + dueDateReminderLeadTimeMinutes: t + .integer( + "dueDateReminderLeadTimeMinutes", + "due_date_reminder_lead_time_minutes", + ) + .notNull() + .default(1440), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }), + table( + "user_notification_workspace_rule", + { + id: t.text("id", "id").pk().defaultCuid(), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + isActive: t.boolean("isActive", "is_active").notNull().default(true), + emailEnabled: t + .boolean("emailEnabled", "email_enabled") + .notNull() + .default(false), + ntfyEnabled: t + .boolean("ntfyEnabled", "ntfy_enabled") + .notNull() + .default(false), + gotifyEnabled: t + .boolean("gotifyEnabled", "gotify_enabled") + .notNull() + .default(false), + webhookEnabled: t + .boolean("webhookEnabled", "webhook_enabled") + .notNull() + .default(false), + projectMode: t + .text("projectMode", "project_mode") + .notNull() + .default("all"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("user_notification_workspace_rule_userId_idx", ["userId"]), + index("user_notification_workspace_rule_workspaceId_idx", [ + "workspaceId", + ]), + unique("user_notification_workspace_rule_user_workspace_unique", [ + "userId", + "workspaceId", + ]), + unique("user_notification_workspace_rule_workspace_id_id_unique", [ + "workspaceId", + "id", + ]), + ], + }, + ), + table( + "user_notification_workspace_project", + { + id: t.text("id", "id").pk().defaultCuid(), + workspaceId: t + .text("workspaceId", "workspace_id") + .notNull() + .ref("workspace", { onDelete: "cascade", onUpdate: "cascade" }), + workspaceRuleId: t.text("workspaceRuleId", "workspace_rule_id").notNull(), + projectId: t.text("projectId", "project_id").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("user_notification_workspace_project_ruleId_idx", [ + "workspaceRuleId", + ]), + index("user_notification_workspace_project_projectId_idx", [ + "projectId", + ]), + index("user_notification_workspace_project_workspaceId_projectId_idx", [ + "workspaceId", + "projectId", + ]), + index("unwp_workspaceId_workspaceRuleId_idx", [ + "workspaceId", + "workspaceRuleId", + ]), + unique("user_notification_workspace_project_rule_project_unique", [ + "workspaceRuleId", + "projectId", + ]), + ], + foreignKeys: [ + foreignKey({ + columns: ["workspaceId", "workspaceRuleId"], + refTable: "user_notification_workspace_rule", + refColumns: ["workspace_id", "id"], + onDelete: "cascade", + onUpdate: "cascade", + }), + foreignKey({ + columns: ["workspaceId", "projectId"], + refTable: "project", + refColumns: ["workspace_id", "id"], + onDelete: "cascade", + onUpdate: "cascade", + }), + ], + }, + ), + table("github_integration", { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .unique() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + repositoryOwner: t.text("repositoryOwner", "repository_owner").notNull(), + repositoryName: t.text("repositoryName", "repository_name").notNull(), + installationId: t.integer("installationId", "installation_id"), + isActive: t.boolean("isActive", "is_active").default(true), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }), + table( + "integration", + { + id: t.text("id", "id").pk().defaultCuid(), + projectId: t + .text("projectId", "project_id") + .notNull() + .ref("project", { onDelete: "cascade", onUpdate: "cascade" }), + type: t.text("type", "type").notNull(), + config: t.text("config", "config").notNull(), + isActive: t.boolean("isActive", "is_active").default(true), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("integration_projectId_idx", ["projectId"]), + index("integration_type_idx", ["type"]), + unique("integration_project_type_unique", ["projectId", "type"]), + ], + }, + ), + table( + "external_link", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + integrationId: t + .text("integrationId", "integration_id") + .notNull() + .ref("integration", { onDelete: "cascade", onUpdate: "cascade" }), + resourceType: t.text("resourceType", "resource_type").notNull(), + externalId: t.text("externalId", "external_id").notNull(), + url: t.text("url", "url").notNull(), + title: t.text("title", "title"), + metadata: t.text("metadata", "metadata"), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("external_link_taskId_idx", ["taskId"]), + index("external_link_integrationId_idx", ["integrationId"]), + index("external_link_externalId_idx", ["externalId"]), + index("external_link_resourceType_idx", ["resourceType"]), + ], + }, + ), + table( + "comment", + { + id: t.text("id", "id").pk().defaultCuid(), + taskId: t + .text("taskId", "task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + userId: t + .text("userId", "user_id") + .notNull() + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + content: t.text("content", "content").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + index("comment_task_idx", ["taskId"]), + index("comment_user_idx", ["userId"]), + ], + }, + ), + table( + "task_relation", + { + id: t.text("id", "id").pk().defaultCuid(), + sourceTaskId: t + .text("sourceTaskId", "source_task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + targetTaskId: t + .text("targetTaskId", "target_task_id") + .notNull() + .ref("task", { onDelete: "cascade", onUpdate: "cascade" }), + relationType: t.text("relationType", "relation_type").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + }, + { + indexes: [ + index("task_relation_source_idx", ["sourceTaskId"]), + index("task_relation_target_idx", ["targetTaskId"]), + ], + }, + ), + table( + "apikey", + { + id: t.text("id", "id").pk().defaultCuid(), + configId: t.text("configId", "configId").notNull().default("default"), + name: t.text("name", "name"), + start: t.text("start", "start"), + referenceId: t + .text("referenceId", "referenceId") + .notNull() + .ref("user", { onDelete: "cascade" }), + prefix: t.text("prefix", "prefix"), + key: t.text("key", "key").notNull(), + userId: t.text("userId", "userId").ref("user", { onDelete: "cascade" }), + refillInterval: t.integer("refillInterval", "refillInterval"), + refillAmount: t.integer("refillAmount", "refillAmount"), + lastRefillAt: t.timestamp("lastRefillAt", "lastRefillAt"), + enabled: t.boolean("enabled", "enabled").default(true), + rateLimitEnabled: t + .boolean("rateLimitEnabled", "rateLimitEnabled") + .default(true), + rateLimitTimeWindow: t + .integer("rateLimitTimeWindow", "rateLimitTimeWindow") + .default(86400000), + rateLimitMax: t.integer("rateLimitMax", "rateLimitMax").default(10), + requestCount: t.integer("requestCount", "requestCount").default(0), + remaining: t.integer("remaining", "remaining"), + lastRequest: t.timestamp("lastRequest", "lastRequest"), + expiresAt: t.timestamp("expiresAt", "expiresAt"), + createdAt: t.timestamp("createdAt", "createdAt").notNull(), + updatedAt: t.timestamp("updatedAt", "updatedAt").notNull(), + permissions: t.text("permissions", "permissions"), + metadata: t.text("metadata", "metadata"), + }, + { + indexes: [ + index("apikey_configId_idx", ["configId"]), + index("apikey_key_idx", ["key"]), + index("apikey_referenceId_idx", ["referenceId"]), + index("apikey_userId_idx", ["userId"]), + ], + }, + ), + table( + "device_code", + { + id: t.text("id", "id").pk().defaultCuid(), + deviceCode: t.text("deviceCode", "device_code").notNull(), + userCode: t.text("userCode", "user_code").notNull(), + userId: t + .text("userId", "user_id") + .ref("user", { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + status: t.text("status", "status").notNull(), + lastPolledAt: t.timestamp("lastPolledAt", "last_polled_at"), + pollingInterval: t.integer("pollingInterval", "polling_interval"), + clientId: t.text("clientId", "client_id"), + scope: t.text("scope", "scope"), + }, + { + indexes: [ + uniqueIndex("device_code_device_code_uidx", ["deviceCode"]), + uniqueIndex("device_code_user_code_uidx", ["userCode"]), + index("device_code_user_id_idx", ["userId"]), + ], + }, + ), + table( + "mcp_oauth_state", + { + id: t.text("id", "id").pk().defaultCuid(), + kind: t.text("kind", "kind").notNull(), + key: t.text("key", "key").notNull(), + payload: t.jsonb("payload", "payload").notNull(), + expiresAt: t.timestamp("expiresAt", "expires_at").notNull(), + createdAt: t.timestamp("createdAt", "created_at").defaultNow().notNull(), + updatedAt: t + .timestamp("updatedAt", "updated_at") + .defaultNow() + .onUpdateNow() + .notNull(), + }, + { + indexes: [ + uniqueIndex("mcp_oauth_state_kind_key_uidx", ["kind", "key"]), + index("mcp_oauth_state_expiresAt_idx", ["expiresAt"]), + ], + }, + ), +]); diff --git a/packages/kaneo-domain/src/parity.test.ts b/packages/kaneo-domain/src/parity.test.ts new file mode 100644 index 000000000..bc87f1325 --- /dev/null +++ b/packages/kaneo-domain/src/parity.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import * as vendored from "../../../vendor/kaneo/apps/api/src/database/schema.ts"; +import { toDrizzleSchema } from "./drizzle"; +import { kaneoSchema } from "./kaneo"; +import { renderTable } from "./parity"; + +const PHYSICAL_TO_VENDORED = { + user: "userTable", + session: "sessionTable", + account: "accountTable", + user_avatar: "userAvatarTable", + verification: "verificationTable", + workspace: "workspaceTable", + workspace_member: "workspaceUserTable", + workspace_billing: "workspaceBillingTable", + trial_grant: "trialGrantTable", + billing_event: "billingEventTable", + team: "teamTable", + team_member: "teamMemberTable", + workspace_invitation: "invitationTable", + workspace_role: "workspaceRoleTable", + project: "projectTable", + column: "columnTable", + workflow_rule: "workflowRuleTable", + task: "taskTable", + billing_reminder_sent: "billingReminderSentTable", + job_lease: "jobLeaseTable", + task_reminder_sent: "taskReminderSentTable", + time_entry: "timeEntryTable", + task_activity: "activityTable", + asset: "assetTable", + label: "labelTable", + notification: "notificationTable", + user_notification_preference: "userNotificationPreferenceTable", + user_notification_workspace_rule: "userNotificationWorkspaceRuleTable", + user_notification_workspace_project: "userNotificationWorkspaceProjectTable", + github_integration: "githubIntegrationTable", + integration: "integrationTable", + external_link: "externalLinkTable", + comment: "commentTable", + task_relation: "taskRelationTable", + apikey: "apikeyTable", + device_code: "deviceCodeTable", + mcp_oauth_state: "mcpOauthStateTable", +}; + +describe("kaneo drizzle parity", () => { + const generated = toDrizzleSchema(kaneoSchema); + + it("exposes every vendored table", () => { + for (const [physical, exportName] of Object.entries(PHYSICAL_TO_VENDORED)) { + expect( + vendored[exportName as keyof typeof vendored], + `${exportName} exists`, + ).toBeDefined(); + expect(generated.tables[physical], `generated ${physical}`).toBeDefined(); + } + }); + + for (const [physical, exportName] of Object.entries(PHYSICAL_TO_VENDORED)) { + it(`${physical} (${exportName}) matches the vendored schema`, () => { + const original = vendored[exportName as keyof typeof vendored]; + const generatedTable = generated.tables[physical]; + expect(renderTable(generatedTable)).toBe(renderTable(original)); + }); + } +}); diff --git a/packages/kaneo-domain/src/parity.ts b/packages/kaneo-domain/src/parity.ts new file mode 100644 index 000000000..82d853eb8 --- /dev/null +++ b/packages/kaneo-domain/src/parity.ts @@ -0,0 +1,152 @@ +import { Table } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; + +const tableNameSymbol = (Table as unknown as { Symbol: { Name: symbol } }) + .Symbol.Name; + +function sqlText(chunk: unknown, out: string[]): void { + if (Array.isArray(chunk)) { + if (typeof chunk[0] === "string") { + out.push(chunk[0]); + } else { + for (const inner of chunk) { + sqlText(inner, out); + } + } + return; + } + if (chunk !== null && typeof chunk === "object") { + const obj = chunk as Record; + if (Array.isArray(obj.queryChunks)) { + for (const inner of obj.queryChunks as unknown[]) { + sqlText(inner, out); + } + return; + } + if (Array.isArray(obj.value)) { + for (const inner of obj.value as unknown[]) { + sqlText(inner, out); + } + return; + } + if ( + typeof obj.getSQL === "function" && + typeof obj.name === "string" && + obj.table + ) { + out.push(obj.name as string); + return; + } + if (typeof obj.isTable === "boolean" && obj.isTable) { + out.push(obj.name as string); + return; + } + out.push(String(obj.name ?? chunk)); + return; + } + out.push(String(chunk)); +} + +function normalizeSql(sql: unknown): string { + const out: string[] = []; + sqlText(sql, out); + return out.join("").replaceAll('"', "").replace(/\s+/g, " ").trim(); +} + +function tableName(table: unknown): string { + if (table === null || typeof table !== "object") { + return String(table); + } + const t = table as Record; + const name = t[tableNameSymbol]; + if (typeof name === "string") { + return name; + } + const config = t.config as { name?: string } | undefined; + if (config?.name) { + return config.name; + } + return String(t.name); +} + +function tokenizeDefault(column: { + defaultFn?: unknown; + default?: unknown; +}): string { + if (column.defaultFn) { + const src = String(column.defaultFn) + .replaceAll("!1", "false") + .replaceAll("!0", "true"); + if (src.includes("createId")) { + return "cuid"; + } + return `fn:${src.slice(0, 60)}`; + } + if (column.default === undefined || column.default === null) { + return "-"; + } + if (typeof column.default === "object") { + return `sql:${normalizeSql(column.default)}`; + } + return `lit:${String(column.default)}`; +} + +interface DrizzleColumn { + name: string; + notNull: boolean; + primary: boolean; + isUnique: boolean; + uniqueName?: string; + default?: unknown; + defaultFn?: unknown; + onUpdateFn?: unknown; + getSQLType(): string; +} + +export function renderTable(table: unknown): string { + const cfg = getTableConfig(table as Parameters[0]); + const lines: string[] = []; + lines.push(`TABLE ${cfg.name}`); + for (const col of cfg.columns as DrizzleColumn[]) { + const uniqueName = col.isUnique ? (col.uniqueName ?? "auto") : "-"; + lines.push( + `COL ${col.name} type=${col.getSQLType()} nn=${col.notNull} pk=${col.primary} uq=${uniqueName} def=${tokenizeDefault(col)} upd=${col.onUpdateFn ? 1 : 0}`, + ); + } + for (const idx of cfg.indexes ?? []) { + const c = ( + idx as unknown as { + config: { + name: string; + unique: boolean; + columns: { name: string }[]; + where?: unknown; + }; + } + ).config; + const where = c.where ? normalizeSql(c.where) : "-"; + lines.push( + `IDX ${c.name} unique=${c.unique} cols=[${c.columns.map((x) => x.name).join(",")}] where=${where}`, + ); + } + for (const uc of cfg.uniqueConstraints ?? []) { + const columns = (uc.columns as unknown[]).map( + (c) => (c as { name?: string }).name ?? String(c), + ); + lines.push(`UC ${uc.name} cols=[${columns.join(",")}]`); + } + for (const fk of cfg.foreignKeys ?? []) { + const ref = fk.reference() as { + name?: string; + columns: DrizzleColumn[]; + foreignTable: unknown; + foreignColumns: DrizzleColumn[]; + }; + const local = ref.columns.map((c) => c.name).join(","); + const foreign = ref.foreignColumns.map((c) => c.name).join(","); + lines.push( + `FK ${ref.name ?? "-"} local=[${local}] -> ${tableName(ref.foreignTable)}(${foreign}) del=${fk.onDelete ?? "-"} upd=${fk.onUpdate ?? "-"}`, + ); + } + return lines.join("\n"); +} diff --git a/packages/kaneo-domain/src/prisma.test.ts b/packages/kaneo-domain/src/prisma.test.ts new file mode 100644 index 000000000..30476c7c4 --- /dev/null +++ b/packages/kaneo-domain/src/prisma.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { kaneoSchema } from "./kaneo"; +import { DEFAULT_EXCLUDE, DEFAULT_RENAME, toPrismaFragment } from "./prisma"; + +const fragment = toPrismaFragment(kaneoSchema, { + exclude: DEFAULT_EXCLUDE, + rename: DEFAULT_RENAME, +}); + +describe("kaneo prisma fragment", () => { + it("emits every non-excluded kaneo table", () => { + const kept = kaneoSchema.tables.filter( + (t) => !DEFAULT_EXCLUDE.includes(t.name), + ); + for (const def of kept) { + expect(fragment).toContain(`@@map("${def.name}")`); + } + expect(fragment.match(/^model /gm)).toHaveLength(kept.length); + }); + + it("skips the excluded tables", () => { + for (const table of DEFAULT_EXCLUDE) { + expect(fragment).not.toContain(`@@map("${table}")`); + } + }); + + it("renames the collisions", () => { + expect(fragment).toContain("model ProjectTask"); + expect(fragment).toContain("model ProjectColumn"); + expect(fragment).toContain("model TaskActivity"); + expect(fragment).toContain("model TaskComment"); + }); + + it("never combines a scalar and a relation on one line", () => { + for (const line of fragment.split("\n")) { + expect(line).not.toMatch( + /^ {2}\w+ (String|Int|Boolean|DateTime|Json|Bytes)\?* \w+ [A-Z]\w* @relation/, + ); + } + }); + + it("maps columns to kaneo's snake_case physical names", () => { + expect(fragment).toContain('@map("project_id")'); + expect(fragment).toContain('@map("created_at")'); + expect(fragment).toContain('@map("joined_at")'); + }); +}); diff --git a/packages/kaneo-domain/src/prisma.ts b/packages/kaneo-domain/src/prisma.ts new file mode 100644 index 000000000..c9dd36154 --- /dev/null +++ b/packages/kaneo-domain/src/prisma.ts @@ -0,0 +1,299 @@ +import type { + ColumnDef, + ColumnRef, + ForeignKeyDef, + IndexDef, + RefAction, + SchemaDef, + TableDef, +} from "./dsl"; + +export interface PrismaBindingOptions { + exclude: string[]; + rename: Record; +} + +export const DEFAULT_EXCLUDE = [ + "user", + "session", + "account", + "verification", + "apikey", +]; + +export const DEFAULT_RENAME = { + task: "ProjectTask", + column: "ProjectColumn", + comment: "TaskComment", + activity: "TaskActivity", + workspace_invitation: "WorkspaceInvitation", +} as const satisfies Record; + +const REF_ACTIONS = { + cascade: "Cascade", + "set null": "SetNull", + restrict: "Restrict", +} as const satisfies Record; + +interface Edge { + localColumns: string[]; + refColumns: string[]; + target: string; + onDelete?: RefAction; + onUpdate?: RefAction; +} + +interface PrismaRelation { + model: string; + field: string; + target: string; + localKeys: string[]; + refKeys: string[]; + onDelete?: RefAction; + onUpdate?: RefAction; + named?: string; + backField: string; +} + +function pascalCase(name: string): string { + return name + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +function camelCase(name: string): string { + return name.charAt(0).toLowerCase() + name.slice(1); +} + +function pluralize(name: string): string { + if (/[^aeiou]y$/i.test(name)) { + return `${name.slice(0, -1)}ies`; + } + return `${name}s`; +} + +function prismaType(column: ColumnDef): string { + switch (column.type) { + case "text": + return "String"; + case "boolean": + return "Boolean"; + case "integer": + return "Int"; + case "timestamp": + return "DateTime"; + case "jsonb": + return "Json"; + case "bytea": + return "Bytes"; + } +} + +function defaultAttribute(column: ColumnDef): string | null { + const value = column.default; + if (!value) { + return null; + } + switch (value.kind) { + case "cuid": + return "@default(cuid())"; + case "now": + return "@default(now())"; + case "literal": + if (typeof value.value === "string") { + return `@default("${value.value}")`; + } + return `@default(${value.value})`; + case "client": + return null; + } +} + +function scalarAttributes(column: ColumnDef): string { + const parts: string[] = []; + if (column.primary) { + parts.push("@id"); + } + if (column.unique !== null) { + parts.push( + column.unique === "" ? "@unique" : `@unique(map: "${column.unique}")`, + ); + } + const def = defaultAttribute(column); + if (def) { + parts.push(def); + } + if (column.onUpdateNow) { + parts.push("@updatedAt"); + } + return parts.join(" "); +} + +function relationName( + model: string, + target: string, + index: number, +): string | undefined { + if (index === 0) { + return undefined; + } + return `${model}To${target}_${index}`; +} + +function relationFieldName(localKeys: string[], target: string): string { + if (localKeys.length === 1) { + const key = localKeys[0]; + if (key) { + const stripped = key.replace(/Id$/, ""); + if (stripped) { + return stripped; + } + } + } + return camelCase(target); +} + +function keyMap(def: TableDef): Map { + return new Map(def.columns.map((c) => [c.name, c.key])); +} + +function collectEdges(def: TableDef): Edge[] { + const fromColumn = (column: ColumnDef, ref: ColumnRef): Edge => ({ + localColumns: [column.name], + refColumns: ref.columns, + target: ref.table, + onDelete: ref.onDelete, + onUpdate: ref.onUpdate, + }); + const fromFk = (fk: ForeignKeyDef): Edge => ({ + localColumns: fk.columns, + refColumns: fk.refColumns, + target: fk.refTable, + onDelete: fk.onDelete, + onUpdate: fk.onUpdate, + }); + const columnEdges = def.columns + .filter((c) => c.ref) + .map((c) => fromColumn(c, c.ref!)); + return [...columnEdges, ...(def.foreignKeys ?? []).map(fromFk)]; +} + +export function toPrismaFragment( + schema: SchemaDef, + options: PrismaBindingOptions, +): string { + const tables = schema.tables.filter((t) => !options.exclude.includes(t.name)); + const modelName = (physical: string) => + options.rename[physical] ?? pascalCase(physical); + const keyMaps = new Map(tables.map((t) => [t.name, keyMap(t)])); + + const relations: PrismaRelation[] = []; + for (const def of tables) { + const edges = collectEdges(def); + const seen = new Map(); + for (const edge of edges) { + const pairKey = `${def.name}>${edge.target}`; + const index = seen.get(pairKey) ?? 0; + seen.set(pairKey, index + 1); + const targetMap = keyMaps.get(edge.target); + if (!targetMap) { + continue; + } + const localMap = keyMaps.get(def.name)!; + const localKeys = edge.localColumns.map((c) => localMap.get(c) ?? c); + const refKeys = edge.refColumns.map((c) => targetMap.get(c) ?? c); + const named = relationName( + modelName(def.name), + modelName(edge.target), + index, + ); + relations.push({ + model: modelName(def.name), + field: relationFieldName(localKeys, modelName(edge.target)), + target: modelName(edge.target), + localKeys, + refKeys, + onDelete: edge.onDelete, + onUpdate: edge.onUpdate, + named, + backField: pluralize(camelCase(modelName(def.name))), + }); + } + } + + const blocks: string[] = []; + for (const def of tables) { + const name = modelName(def.name); + const modelRelations = relations.filter((r) => r.model === name); + const backRelations = relations.filter((r) => r.target === name); + const usedBackFields = new Set(); + + const lines: string[] = []; + for (const column of def.columns) { + const base = `${column.key} ${prismaType(column)}${column.notNull || column.primary ? "" : "?"}`; + const columnMap = + column.key === column.name ? "" : ` @map("${column.name}")`; + const attrs = scalarAttributes(column); + lines.push(`${base}${columnMap}${attrs ? ` ${attrs}` : ""}`); + } + + for (const relation of modelRelations) { + const optional = relation.localKeys.some((key) => { + const column = def.columns.find((c) => c.key === key); + return column ? !column.notNull && !column.primary : false; + }); + lines.push( + `${relation.field} ${relation.target}${optional ? "?" : ""} @relation(${relationArgs(relation)})`, + ); + } + + for (const relation of backRelations) { + let backField = relation.backField; + let suffix = 2; + while (usedBackFields.has(backField)) { + backField = `${relation.backField}${suffix}`; + suffix += 1; + } + usedBackFields.add(backField); + const relName = relation.named ? `"${relation.named}"` : ""; + lines.push( + `${backField} ${relation.model}[]${relName ? ` @relation(${relName})` : ""}`, + ); + } + + for (const idx of def.indexes ?? []) { + if (idx.kind === "unique") { + lines.push(`@@unique([${idx.columns.join(", ")}], map: "${idx.name}")`); + } + } + for (const idx of def.indexes ?? []) { + if (idx.kind === "index") { + lines.push(`@@index([${idx.columns.join(", ")}], map: "${idx.name}")`); + } + } + + blocks.push( + `model ${name} {\n${lines.map((l) => ` ${l}`).join("\n")}\n @@map("${def.name}")\n}`, + ); + } + + return blocks.join("\n\n"); + + function relationArgs(relation: PrismaRelation): string { + const parts = [ + `fields: [${relation.localKeys.join(", ")}]`, + `references: [${relation.refKeys.join(", ")}]`, + ]; + if (relation.named) { + parts.unshift(`"${relation.named}"`); + } + if (relation.onDelete) { + parts.push(`onDelete: ${REF_ACTIONS[relation.onDelete]}`); + } + if (relation.onUpdate) { + parts.push(`onUpdate: ${REF_ACTIONS[relation.onUpdate]}`); + } + return parts.join(", "); + } +} diff --git a/packages/kaneo-domain/tsconfig.json b/packages/kaneo-domain/tsconfig.json new file mode 100644 index 000000000..d427aa526 --- /dev/null +++ b/packages/kaneo-domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@crm/typescript-config/internal-package.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/**/*.test.ts"] +} diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 5880dc2bd..fbb6c7d76 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -126,6 +126,7 @@ export const AGENT_TOOLS = [ "list_fields", "list_outstanding_work", "manage_fields", + "project_list", "read_company_history", "read_crm_history", "read_deal_history", @@ -139,6 +140,11 @@ export const AGENT_TOOLS = [ "set_chat_title", "set_contact_socials", "set_field_value", + "task_comment", + "task_create", + "task_list", + "task_read", + "task_update", "write_brief", "write_workspace_profile", ] as const; diff --git a/tools/kaneo-dev.ts b/tools/kaneo-dev.ts new file mode 100644 index 000000000..b3fc8e682 --- /dev/null +++ b/tools/kaneo-dev.ts @@ -0,0 +1,175 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +const DIST = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "apps", + "web", + "dist", +); +const API_DIR = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "apps", + "api", +); +const KANEO_PACKAGES = path.join( + import.meta.dir, + "..", + "vendor", + "kaneo", + "packages", +); +const API_ORIGIN = "http://127.0.0.1:1337"; +const PORT = 5173; + +function indexResponse(): Response { + return new Response(Bun.file(path.join(DIST, "index.html"))); +} + +function fileResponse(relativePath: string): Response { + const filePath = path.join(DIST, relativePath); + if ( + relativePath === "/" || + !existsSync(filePath) || + statSync(filePath).isDirectory() + ) { + return indexResponse(); + } + return new Response(Bun.file(filePath)); +} + +async function proxyRequest(req: Request): Promise { + const url = new URL(req.url); + const headers = new Headers(req.headers); + headers.delete("host"); + const body = ["GET", "HEAD"].includes(req.method) + ? undefined + : await req.arrayBuffer(); + const upstream = await fetch(`${API_ORIGIN}${url.pathname}${url.search}`, { + method: req.method, + headers, + body, + redirect: "manual", + }); + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete("content-encoding"); + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); +} + +export function startKaneoDevServer() { + return Bun.serve({ + port: PORT, + websocket: { + open(ws) { + const { upstream } = ws.data as { upstream: WebSocket }; + upstream.addEventListener("message", (event) => { + ws.send(event.data); + }); + upstream.addEventListener("close", () => { + ws.close(); + }); + upstream.addEventListener("error", () => { + ws.close(); + }); + }, + message(ws, message) { + (ws.data as { upstream: WebSocket }).upstream.send(message); + }, + close(ws) { + (ws.data as { upstream: WebSocket }).upstream.close(); + }, + }, + async fetch(req, server) { + const url = new URL(req.url); + + if (url.pathname.startsWith("/ws")) { + const upstream = new WebSocket( + `ws://127.0.0.1:1337${url.pathname}${url.search}`, + ); + if (server.upgrade(req, { data: { upstream } })) { + return undefined; + } + upstream.close(); + return new Response("upgrade failed", { status: 500 }); + } + + if (url.pathname.startsWith("/api/")) { + return proxyRequest(req); + } + + return fileResponse(url.pathname); + }, + }); +} + +if (import.meta.main) { + const packagesWithDist = ["email", "permissions", "mcp", "planka-import"]; + for (const pkg of packagesWithDist) { + if (!existsSync(path.join(KANEO_PACKAGES, pkg, "dist", "index.js"))) { + console.log(`building @kaneo/${pkg}...`); + const built = Bun.spawnSync(["bunx", "tsc"], { + cwd: path.join(KANEO_PACKAGES, pkg), + env: process.env, + }); + if (built.exitCode !== 0) { + console.error(`failed to build @kaneo/${pkg}`); + process.exit(1); + } + } + } + + if (!existsSync(path.join(DIST, "index.html"))) { + console.log("building kaneo web..."); + const webBuild = Bun.spawnSync(["bun", "run", "build"], { + cwd: path.join(import.meta.dir, "..", "vendor", "kaneo", "apps", "web"), + env: { + ...process.env, + VITE_API_URL: "http://localhost:5173", + VITE_CLIENT_URL: "http://localhost:5173", + }, + }); + if (webBuild.exitCode !== 0) { + console.error("failed to build kaneo web"); + process.exit(1); + } + } + + const apiEnv: Record = {}; + for (const key of Object.keys(process.env)) { + apiEnv[key] = process.env[key] ?? ""; + } + const authSecret = process.env.BETTER_AUTH_SECRET; + if (authSecret && !apiEnv.AUTH_SECRET) { + apiEnv.AUTH_SECRET = authSecret; + } + apiEnv.KANEO_SKIP_DRIZZLE_MIGRATIONS = "1"; + apiEnv.KANEO_CLIENT_URL = "http://localhost:5173"; + apiEnv.CORS_ORIGINS = "http://localhost:5173"; + + const api = Bun.spawn(["bunx", "tsx", "src/index.ts"], { + cwd: API_DIR, + env: apiEnv, + stdout: "inherit", + stderr: "inherit", + }); + + const server = startKaneoDevServer(); + console.log(`kaneo dev web: http://localhost:${server.port} → ${API_ORIGIN}`); + + const shutdown = () => { + api.kill(); + server.stop(true); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/vendor/FORK-DELTA.md b/vendor/FORK-DELTA.md new file mode 100644 index 000000000..c21cd1edf --- /dev/null +++ b/vendor/FORK-DELTA.md @@ -0,0 +1,63 @@ +# Kaneo fork delta + +The fork is the trunk. Upstream is a drain. All work lands here first, and generic +improvements are donated upstream as separate pull requests so that upstream merges +shrink this delta instead of growing it. Nothing waits on upstream. + +## Source + +Kaneo is a git **submodule** at `vendor/kaneo`, pointing at a branch on the fork. +The deltas below live in that branch, not in this repository. + +- Fork: https://github.com/romanbsd/kaneo +- Branch: `crm-integration` +- Pinned commit: `b99b332b963f49087247cab29b70d3e03598b2c0` +- Based on upstream: `46539164c68669cec15b1528835c10ad0a66355e` + +## Updating the submodule + +Rebase the fork branch on upstream main, then bump the pointer here: + +```sh +git -C vendor/kaneo fetch origin +git -C vendor/kaneo checkout crm-integration +git -C vendor/kaneo merge origin/main # resolve deltas, if any +git -C vendor/kaneo push origin crm-integration +git -C vendor/kaneo log --oneline -1 +git add vendor/kaneo # records the new commit +``` + +A fresh checkout needs the submodule initialized and kaneo's dependencies +installed before `dev:kaneo` runs: + +```sh +git submodule update --init vendor/kaneo +cd vendor/kaneo && bun install +``` + +## Rules + +- `vendor/` is outside the bun workspaces on purpose. The root `turbo.json` and + `package.json` do not see it, so a broken Kaneo build cannot take down the CRM's. + When a piece of Kaneo is brought into the CRM build, it is extracted first and wired + into `packages/*` or `apps/*` on its own. +- Generic packages (the domain model, any binding) never import `@crm/*`, never read a + constant from the CRM, never assume a single tenant. A change that trips that rule is + fork-specific and stays here. +- One root `.env`. No per-package `.env`. +- A bundled Kaneo feature is an optional capability: a missing key removes the feature, + never throws. + +## Delta log + +A change in this file, with its reason. Generic improvements are tracked as upstream +pull requests; only fork-specific or unmerged changes are listed. + +| Change | Reason | Upstream PR | +| --- | --- | --- | +| Removed `apps/web/.env.development` and `.env.production` | One root `.env` rule; per-app env files are placeholders that invite confusion | — | +| Renamed `activity` table to `task_activity` in `apps/api/src/database/schema.ts` | Collides with the CRM's live `activity` timeline table in the one shared schema; the CRM's keeps the name | — | +| Renamed `invitation` table and its indexes to `workspace_invitation*` in `apps/api/src/database/schema.ts` | Collides with the CRM's better-auth org-plugin `invitation` table and its auto-named indexes | — | +| Renamed shared auth table columns (`user`, `session`, `account`, `verification`, `apikey`) to camelCase in `apps/api/src/database/schema.ts` | The CRM owns these tables with camelCase physical names; kaneo's auth reads them | — | +| Gated the startup Drizzle migrations and schema utilities behind `KANEO_SKIP_DRIZZLE_MIGRATIONS` in `apps/api/src/index.ts` | Prisma owns the schema; kaneo's own migrator must not run against the shared database | — | +| Set `advanced.cookiePrefix: "crm"` in `apps/api/src/auth.ts` | Shares the CRM's session cookie; one session token valid at both apps (same secret and session table) | — | \ No newline at end of file diff --git a/vendor/kaneo b/vendor/kaneo new file mode 160000 index 000000000..bfa867bba --- /dev/null +++ b/vendor/kaneo @@ -0,0 +1 @@ +Subproject commit bfa867bba54d8f2ec0a8e376fe4f4b81969dd97c From f1031ca103fa21b8db9bde800120e39334023cd1 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Thu, 3 Sep 2026 12:40:23 +0300 Subject: [PATCH 05/27] feat(api): register push tokens for mobile notifications (#6) A POST /push-tokens endpoint stores a device FCM token for the signed-in user (upsert by token, transferred to the latest owner), and DELETE /push-tokens removes it if it belongs to the caller. Tokens are validated with zod at the boundary (platform is ios|android). Add the push_token table, the module wiring, and e2e coverage for the unauthenticated paths. --- .oxlintrc.json | 1 + apps/api/src/app.module.ts | 2 + .../src/push-tokens/push-tokens.contracts.ts | 10 +++ .../src/push-tokens/push-tokens.controller.ts | 38 +++++++++ .../api/src/push-tokens/push-tokens.module.ts | 9 ++ .../src/push-tokens/push-tokens.service.ts | 54 ++++++++++++ apps/api/test/auth.e2e.spec.ts | 14 ++++ apps/api/test/push-tokens.spec.ts | 83 +++++++++++++++++++ .../20260903120000_push_tokens/migration.sql | 20 +++++ packages/db/prisma/schema.prisma | 15 ++++ 10 files changed, 246 insertions(+) create mode 100644 apps/api/src/push-tokens/push-tokens.contracts.ts create mode 100644 apps/api/src/push-tokens/push-tokens.controller.ts create mode 100644 apps/api/src/push-tokens/push-tokens.module.ts create mode 100644 apps/api/src/push-tokens/push-tokens.service.ts create mode 100644 apps/api/test/push-tokens.spec.ts create mode 100644 packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql diff --git a/.oxlintrc.json b/.oxlintrc.json index 46a979b09..3f7d5c6cd 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -69,6 +69,7 @@ "packages/db/src/fields.ts", "packages/db/src/fields-shape.ts", "apps/api/src/fields/**", + "apps/api/src/push-tokens/**", "apps/app/components/crm/inline-field.tsx" ], "rules": { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 82a345df2..8b2b2c822 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -26,6 +26,7 @@ import { LoggingModule } from "./logging/logging.module"; import { logAuthRoute } from "./logging/request-logger.middleware"; import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; +import { PushTokensModule } from "./push-tokens/push-tokens.module"; import { SavedViewsModule } from "./saved-views/saved-views.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; @@ -79,6 +80,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; TrackingModule, ArchiveModule, SavedViewsModule, + PushTokensModule, ], }) export class AppModule {} diff --git a/apps/api/src/push-tokens/push-tokens.contracts.ts b/apps/api/src/push-tokens/push-tokens.contracts.ts new file mode 100644 index 000000000..ed1cb247f --- /dev/null +++ b/apps/api/src/push-tokens/push-tokens.contracts.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const pushPlatformSchema = z.enum(["ios", "android"]); + +export const registerPushTokenInput = z.object({ + token: z.string().trim().min(1), + platform: pushPlatformSchema, +}); + +export type RegisterPushTokenInput = z.infer; diff --git a/apps/api/src/push-tokens/push-tokens.controller.ts b/apps/api/src/push-tokens/push-tokens.controller.ts new file mode 100644 index 000000000..cbc9ca5a2 --- /dev/null +++ b/apps/api/src/push-tokens/push-tokens.controller.ts @@ -0,0 +1,38 @@ +import { type auth, SESSION_COOKIE_NAME } from "@crm/auth"; +import { Body, Controller, Delete, Post, Query } from "@nestjs/common"; +import { + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { Session, type UserSession } from "@thallesp/nestjs-better-auth"; +import { PushTokensService } from "./push-tokens.service"; + +type CrmSession = UserSession; + +@ApiTags("Push tokens") +@ApiCookieAuth(SESSION_COOKIE_NAME) +@Controller("push-tokens") +export class PushTokensController { + constructor(private readonly pushTokens: PushTokensService) {} + + @Post() + @ApiOperation({ summary: "Register this device's FCM token" }) + @ApiOkResponse({ description: "The token was stored." }) + @ApiUnauthorizedResponse({ description: "No valid session." }) + register(@Session() session: CrmSession, @Body() body: unknown) { + return this.pushTokens.register(session.user.id, body); + } + + @Delete() + @ApiOperation({ summary: "Remove this device's FCM token" }) + @ApiOkResponse({ + description: "The token was removed if it belonged to the caller.", + }) + @ApiUnauthorizedResponse({ description: "No valid session." }) + unregister(@Session() session: CrmSession, @Query("token") token: string) { + return this.pushTokens.unregister(session.user.id, token); + } +} diff --git a/apps/api/src/push-tokens/push-tokens.module.ts b/apps/api/src/push-tokens/push-tokens.module.ts new file mode 100644 index 000000000..9f3d0aada --- /dev/null +++ b/apps/api/src/push-tokens/push-tokens.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { PushTokensController } from "./push-tokens.controller"; +import { PushTokensService } from "./push-tokens.service"; + +@Module({ + controllers: [PushTokensController], + providers: [PushTokensService], +}) +export class PushTokensModule {} diff --git a/apps/api/src/push-tokens/push-tokens.service.ts b/apps/api/src/push-tokens/push-tokens.service.ts new file mode 100644 index 000000000..664d5f6ef --- /dev/null +++ b/apps/api/src/push-tokens/push-tokens.service.ts @@ -0,0 +1,54 @@ +import type { Db } from "@crm/db"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { + type RegisterPushTokenInput, + registerPushTokenInput, +} from "./push-tokens.contracts"; + +@Injectable() +export class PushTokensService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async register(userId: string, body: unknown): Promise<{ ok: true }> { + const input = this.parse(body); + await this.db.pushToken.upsert({ + where: { token: input.token }, + create: { + userId, + token: input.token, + platform: input.platform, + }, + update: { + userId, + platform: input.platform, + }, + }); + return { ok: true }; + } + + async unregister( + userId: string, + token: string | undefined, + ): Promise<{ ok: true }> { + const value = token?.trim() ?? ""; + if (!value) { + throw new BadRequestException("token is required"); + } + + await this.db.pushToken.deleteMany({ + where: { token: value, userId }, + }); + return { ok: true }; + } + + private parse(body: unknown): RegisterPushTokenInput { + const parsed = registerPushTokenInput.safeParse(body); + if (!parsed.success) { + throw new BadRequestException( + "token and platform (ios|android) are required", + ); + } + return parsed.data; + } +} diff --git a/apps/api/test/auth.e2e.spec.ts b/apps/api/test/auth.e2e.spec.ts index d5d79131d..a628f245c 100644 --- a/apps/api/test/auth.e2e.spec.ts +++ b/apps/api/test/auth.e2e.spec.ts @@ -41,6 +41,20 @@ describe("Auth (e2e)", () => { await request(app.getHttpServer()).get("/auth/me").expect(401); }); + it("rejects unauthenticated push-token registration", async () => { + await request(app.getHttpServer()) + .post("/push-tokens") + .send({ token: "fcm-test", platform: "ios" }) + .expect(401); + }); + + it("rejects unauthenticated push-token deletion", async () => { + await request(app.getHttpServer()) + .delete("/push-tokens") + .query({ token: "fcm-test" }) + .expect(401); + }); + it("allows an unauthenticated request to an optional-auth route", async () => { const response = await request(app.getHttpServer()) .get("/auth/session") diff --git a/apps/api/test/push-tokens.spec.ts b/apps/api/test/push-tokens.spec.ts new file mode 100644 index 000000000..2bf230326 --- /dev/null +++ b/apps/api/test/push-tokens.spec.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { BadRequestException } from "@nestjs/common"; +import { PushTokensService } from "../src/push-tokens/push-tokens.service"; + +const suffix = process.env.TEST_RUN_ID ?? "push-tokens-spec"; +const userId = `user-push-${suffix}`; +const otherUserId = `user-push-other-${suffix}`; +const token = `fcm-${suffix}`; + +let service: PushTokensService; + +beforeAll(async () => { + await db.pushToken.deleteMany({ + where: { OR: [{ userId }, { userId: otherUserId }, { token }] }, + }); + await db.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } }); + await db.user.create({ + data: { id: userId, name: "Push User", email: `${userId}@example.test` }, + }); + await db.user.create({ + data: { + id: otherUserId, + name: "Other Push User", + email: `${otherUserId}@example.test`, + }, + }); + service = new PushTokensService(db); +}); + +afterAll(async () => { + await db.pushToken.deleteMany({ + where: { OR: [{ userId }, { userId: otherUserId }, { token }] }, + }); + await db.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } }); +}); + +describe("PushTokensService", () => { + it("upserts a token for the current user", async () => { + await expect( + service.register(userId, { token, platform: "ios" }), + ).resolves.toEqual({ ok: true }); + + const row = await db.pushToken.findUnique({ where: { token } }); + expect(row?.userId).toBe(userId); + expect(row?.platform).toBe("ios"); + }); + + it("moves the token to the latest user", async () => { + await service.register(otherUserId, { token, platform: "android" }); + + const row = await db.pushToken.findUnique({ where: { token } }); + expect(row?.userId).toBe(otherUserId); + expect(row?.platform).toBe("android"); + }); + + it("deletes a token that belongs to the caller", async () => { + await expect(service.unregister(otherUserId, token)).resolves.toEqual({ + ok: true, + }); + expect(await db.pushToken.findUnique({ where: { token } })).toBeNull(); + }); + + it("treats an unknown token as already gone", async () => { + await expect(service.unregister(userId, "missing-token")).resolves.toEqual({ + ok: true, + }); + }); + + it("does not delete another user's token", async () => { + await service.register(userId, { token, platform: "ios" }); + await expect(service.unregister(otherUserId, token)).resolves.toEqual({ + ok: true, + }); + expect(await db.pushToken.findUnique({ where: { token } })).not.toBeNull(); + }); + + it("rejects a missing token on register", async () => { + await expect( + service.register(userId, { platform: "ios" }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql b/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql new file mode 100644 index 000000000..e8a516a8e --- /dev/null +++ b/packages/db/prisma/migrations/20260903120000_push_tokens/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "push_token" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "push_token_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_token_token_unique" ON "push_token"("token"); + +-- CreateIndex +CREATE INDEX "push_token_userId_idx" ON "push_token"("user_id"); + +-- AddForeignKey +ALTER TABLE "push_token" ADD CONSTRAINT "push_token_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 323269eb5..b6e584481 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -55,11 +55,26 @@ model User { ssoproviders SsoProvider[] apiKeys Apikey[] + pushTokens PushToken[] @@unique([email]) @@map("user") } +model PushToken { + id String @id @default(cuid()) + userId String @map("user_id") + token String + platform String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token], map: "push_token_token_unique") + @@index([userId], map: "push_token_userId_idx") + @@map("push_token") +} + model SlackMemberMatch { id String @id @default(cuid()) crmUserId String @unique From 84423ad68d2c8ef02f14699f3acbc111a4752529 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Fri, 4 Sep 2026 15:33:16 +0300 Subject: [PATCH 06/27] feat(auth): add OAuth 2.1 and OIDC support (#3) --- .env.example | 2 +- AGENTS.md | 4 + .../test/slack-membership.integration.spec.ts | 1 + .../test/slack-people.integration.spec.ts | 1 + apps/api/package.json | 2 +- apps/api/src/app.module.ts | 6 +- apps/api/src/auth/auth.controller.ts | 28 +- apps/api/src/auth/auth.module.ts | 20 +- apps/api/src/auth/oauth-bootstrap.service.ts | 19 + .../api/src/auth/oauth-metadata.controller.ts | 27 + .../src/auth/request-principal.decorator.ts | 7 + apps/api/src/auth/request-principal.guard.ts | 83 + .../api/src/auth/request-principal.service.ts | 190 +++ apps/api/src/auth/request-principal.ts | 24 + .../conversation-attachments.controller.ts | 15 +- apps/api/src/create-app.ts | 35 +- apps/api/src/mailbox/mailbox-token.service.ts | 37 +- apps/api/src/trpc/context.types.ts | 3 + .../src/trpc/middlewares/auth.middleware.ts | 7 +- .../middlewares/oauth-scope.middleware.ts | 30 + .../middlewares/session-only.middleware.ts | 3 +- apps/api/src/trpc/trpc.context.ts | 33 +- apps/api/src/trpc/trpc.module.ts | 8 +- apps/api/test/auth.e2e.spec.ts | 228 ++- apps/api/test/mailbox-purge.spec.ts | 2 + apps/api/test/oauth-openapi.e2e.spec.ts | 42 + .../slack/slack-connect-button.tsx | 4 +- .../(landing)/oauth/consent/consent-form.tsx | 56 + apps/app/app/(landing)/oauth/consent/page.tsx | 78 + apps/app/app/(landing)/sign-in/page.tsx | 37 +- .../app/(landing)/sign-in/social-sign-in.tsx | 11 +- .../app/app/(landing)/sign-in/sso-sign-in.tsx | 11 +- apps/app/components/auth-shell.tsx | 9 + apps/app/lib/oauth-query.ts | 54 + apps/app/package.json | 2 +- apps/app/proxy.ts | 2 +- apps/app/test/oauth-query.spec.ts | 20 + bun.lock | 175 +- docs/api.md | 28 +- docs/connections.md | 19 +- docs/exposed-api.md | 1484 +++++++++++++++++ docs/oauth-oidc-crm-implementation.md | 1188 +++++++++++++ docs/setup.md | 12 + packages/auth/package.json | 13 +- .../auth/scripts/reconcile-oauth-client.ts | 7 + .../auth/scripts/register-oauth-client.ts | 153 ++ packages/auth/src/auth.ts | 43 +- packages/auth/src/client.ts | 4 +- packages/auth/src/index.ts | 17 + packages/auth/src/oauth-client.ts | 48 + packages/auth/src/oauth-config.ts | 69 + packages/auth/src/oauth-resource.ts | 45 + packages/auth/src/oauth-scope.ts | 22 + packages/auth/src/slack-connect.ts | 53 +- .../auth/test/oauth-client-fields.spec.ts | 34 + packages/auth/test/oauth-scope.spec.ts | 52 + .../test/slack-connect.integration.spec.ts | 200 ++- packages/auth/tsconfig.json | 2 +- packages/db/README.md | 2 +- .../migration.sql | 206 +++ .../migration.sql | 107 ++ packages/db/prisma/schema.prisma | 259 ++- 62 files changed, 5050 insertions(+), 333 deletions(-) create mode 100644 apps/api/src/auth/oauth-bootstrap.service.ts create mode 100644 apps/api/src/auth/oauth-metadata.controller.ts create mode 100644 apps/api/src/auth/request-principal.decorator.ts create mode 100644 apps/api/src/auth/request-principal.guard.ts create mode 100644 apps/api/src/auth/request-principal.service.ts create mode 100644 apps/api/src/auth/request-principal.ts create mode 100644 apps/api/src/trpc/middlewares/oauth-scope.middleware.ts create mode 100644 apps/api/test/oauth-openapi.e2e.spec.ts create mode 100644 apps/app/app/(landing)/oauth/consent/consent-form.tsx create mode 100644 apps/app/app/(landing)/oauth/consent/page.tsx create mode 100644 apps/app/lib/oauth-query.ts create mode 100644 apps/app/test/oauth-query.spec.ts create mode 100644 docs/exposed-api.md create mode 100644 docs/oauth-oidc-crm-implementation.md create mode 100644 packages/auth/scripts/reconcile-oauth-client.ts create mode 100644 packages/auth/scripts/register-oauth-client.ts create mode 100644 packages/auth/src/oauth-client.ts create mode 100644 packages/auth/src/oauth-config.ts create mode 100644 packages/auth/src/oauth-resource.ts create mode 100644 packages/auth/src/oauth-scope.ts create mode 100644 packages/auth/test/oauth-client-fields.spec.ts create mode 100644 packages/auth/test/oauth-scope.spec.ts create mode 100644 packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql create mode 100644 packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql diff --git a/.env.example b/.env.example index e05ec0b3b..8dec3d5ae 100644 --- a/.env.example +++ b/.env.example @@ -53,7 +53,7 @@ GOOGLE_CLIENT_SECRET="" # MICROSOFT_CLIENT_SECRET="" # Optional. Enables Slack account linking on Settings > Connections. -# Add APP_URL + /api/auth/oauth2/callback/slack as the Slack OAuth redirect URL. +# Add API_URL + /api/auth/callback/slack as the Slack OAuth redirect URL. # SLACK_CLIENT_ID="" # SLACK_CLIENT_SECRET="" diff --git a/AGENTS.md b/AGENTS.md index 01a66cac2..82f333d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,6 +260,10 @@ IDs to commits or pull requests. Use conventional commit messages and pull request titles. +Open pull requests against `master` in `romanbsd/compcrm` unless the user states +another repository or base branch. This rule overrides repository and base branch +defaults in other project documents and skills. + ```shell git commit -m "fix: resolve auth token expiry" ``` diff --git a/apps/agent/test/slack-membership.integration.spec.ts b/apps/agent/test/slack-membership.integration.spec.ts index b5796ad9d..61a6030f9 100644 --- a/apps/agent/test/slack-membership.integration.spec.ts +++ b/apps/agent/test/slack-membership.integration.spec.ts @@ -24,6 +24,7 @@ async function connect() { where: { id: ACCOUNT_ID }, create: { id: ACCOUNT_ID, + issuer: "local:oauth:slack", accountId: "T-JOIN-SPEC", providerId: "slack", userId: USER_ID, diff --git a/apps/agent/test/slack-people.integration.spec.ts b/apps/agent/test/slack-people.integration.spec.ts index 91fc768d7..93e452efe 100644 --- a/apps/agent/test/slack-people.integration.spec.ts +++ b/apps/agent/test/slack-people.integration.spec.ts @@ -27,6 +27,7 @@ async function connect() { where: { id: ACCOUNT_ID }, create: { id: ACCOUNT_ID, + issuer: "local:oauth:slack", accountId: "T-SPEC", providerId: "slack", userId: USER_ID, diff --git a/apps/api/package.json b/apps/api/package.json index 151aec0f8..9ccbf28dc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -38,7 +38,7 @@ "@thallesp/nestjs-better-auth": "^2.7.0", "@trpc/server": "^11.18.0", "@vercel/blob": "^2.6.1", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8b2b2c822..14264797d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -50,7 +50,11 @@ import { WorkspaceModule } from "./workspace/workspace.module"; AppCacheModule, DatabaseModule, CrmModule, - BetterAuthModule.forRoot({ auth, middleware: logAuthRoute }), + BetterAuthModule.forRoot({ + auth, + middleware: logAuthRoute, + disableGlobalAuthGuard: true, + }), AuthModule, HealthModule, TrpcModule, diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index dc283ca70..6e7b4385f 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,23 +1,23 @@ -import { type auth, SESSION_COOKIE_NAME } from "@crm/auth"; +import { SESSION_COOKIE_NAME } from "@crm/auth"; import { Controller, Get } from "@nestjs/common"; import { + ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiOperation, + ApiSecurity, ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { - OptionalAuth, - Session, - type UserSession, -} from "@thallesp/nestjs-better-auth"; +import { OptionalAuth } from "@thallesp/nestjs-better-auth"; import { AuthService } from "./auth.service"; - -type CrmSession = UserSession; +import type { RequestPrincipal } from "./request-principal"; +import { Principal } from "./request-principal.decorator"; @ApiTags("Auth") @ApiCookieAuth(SESSION_COOKIE_NAME) +@ApiSecurity("apiKey") +@ApiBearerAuth("oauth") @Controller("auth") export class AuthController { constructor(private readonly authService: AuthService) {} @@ -26,8 +26,8 @@ export class AuthController { @ApiOperation({ summary: "Get the signed-in user's profile" }) @ApiOkResponse({ description: "The signed-in user's profile." }) @ApiUnauthorizedResponse({ description: "No valid session." }) - async getMe(@Session() session: CrmSession) { - return { user: await this.authService.getProfile(session.user.id) }; + async getMe(@Principal() principal: RequestPrincipal) { + return { user: await this.authService.getProfile(principal.user.id) }; } @Get("session") @@ -38,15 +38,15 @@ export class AuthController { @ApiOkResponse({ description: "Whether the request is authenticated, and as whom.", }) - getSession(@Session() session?: CrmSession) { - if (!session) { + getSession(@Principal() principal: RequestPrincipal | null) { + if (!principal) { return { authenticated: false, user: null }; } return { authenticated: true, - user: { id: session.user.id, email: session.user.email }, - expiresAt: session.session.expiresAt, + user: { id: principal.user.id, email: principal.user.email }, + expiresAt: principal.expiresAt, }; } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 505c5c855..ea44a8006 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,11 +1,23 @@ -import { Module } from "@nestjs/common"; +import { Global, Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { AuthHooksService } from "./auth-hooks.service"; +import { OAuthBootstrapService } from "./oauth-bootstrap.service"; +import { OAuthMetadataController } from "./oauth-metadata.controller"; +import { RequestPrincipalGuard } from "./request-principal.guard"; +import { RequestPrincipalService } from "./request-principal.service"; +@Global() @Module({ - controllers: [AuthController], - providers: [AuthService, AuthHooksService], - exports: [AuthService], + controllers: [AuthController, OAuthMetadataController], + providers: [ + AuthService, + AuthHooksService, + OAuthBootstrapService, + RequestPrincipalService, + { provide: APP_GUARD, useClass: RequestPrincipalGuard }, + ], + exports: [AuthService, RequestPrincipalService], }) export class AuthModule {} diff --git a/apps/api/src/auth/oauth-bootstrap.service.ts b/apps/api/src/auth/oauth-bootstrap.service.ts new file mode 100644 index 000000000..1643d3e5f --- /dev/null +++ b/apps/api/src/auth/oauth-bootstrap.service.ts @@ -0,0 +1,19 @@ +import { ensureOfficialOAuthClient } from "@crm/auth"; +import { Injectable, Logger, type OnModuleInit } from "@nestjs/common"; + +@Injectable() +export class OAuthBootstrapService implements OnModuleInit { + private readonly logger = new Logger(OAuthBootstrapService.name); + + async onModuleInit(): Promise { + try { + await ensureOfficialOAuthClient(); + this.logger.log({ message: "Official OAuth client reconciled" }); + } catch (error) { + this.logger.error({ + message: "Official OAuth client reconciliation failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/apps/api/src/auth/oauth-metadata.controller.ts b/apps/api/src/auth/oauth-metadata.controller.ts new file mode 100644 index 000000000..4dc47ae33 --- /dev/null +++ b/apps/api/src/auth/oauth-metadata.controller.ts @@ -0,0 +1,27 @@ +import { auth, getProtectedResourceMetadata, OAUTH } from "@crm/auth"; +import { Controller, Get } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; + +@ApiTags("OAuth") +@AllowAnonymous() +@Controller() +export class OAuthMetadataController { + @Get(".well-known/oauth-authorization-server/api/auth") + @ApiOperation({ summary: "Get OAuth authorization-server metadata" }) + @ApiOkResponse({ description: "OAuth authorization-server metadata." }) + getAuthorizationServerMetadata() { + return auth.api.getOAuthServerConfig(); + } + + @Get(".well-known/oauth-protected-resource/api") + @ApiOperation({ summary: "Get OAuth protected-resource metadata" }) + @ApiOkResponse({ description: "OAuth protected-resource metadata." }) + getResourceMetadata() { + return getProtectedResourceMetadata({ + resource: OAUTH.resource, + authorization_servers: [OAUTH.issuer], + scopes_supported: [OAUTH.scopes.crm.read, OAUTH.scopes.crm.write], + }); + } +} diff --git a/apps/api/src/auth/request-principal.decorator.ts b/apps/api/src/auth/request-principal.decorator.ts new file mode 100644 index 000000000..6a6b19124 --- /dev/null +++ b/apps/api/src/auth/request-principal.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import type { RequestPrincipal } from "./request-principal"; + +export const Principal = createParamDecorator( + (_data: undefined, context: ExecutionContext): RequestPrincipal | null => + context.switchToHttp().getRequest().principal ?? null, +); diff --git a/apps/api/src/auth/request-principal.guard.ts b/apps/api/src/auth/request-principal.guard.ts new file mode 100644 index 000000000..6e674e66a --- /dev/null +++ b/apps/api/src/auth/request-principal.guard.ts @@ -0,0 +1,83 @@ +import { + bearerChallenge, + oauthScopeFailure, + requiredCrmScope, +} from "@crm/auth"; +import { + BadRequestException, + type CanActivate, + type ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import type { Request, Response } from "express"; +import { RequestPrincipalError } from "./request-principal"; +import { RequestPrincipalService } from "./request-principal.service"; + +@Injectable() +export class RequestPrincipalGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly principals: RequestPrincipalService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const isPublic = this.reflector.getAllAndOverride("PUBLIC", [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const isOptional = this.reflector.getAllAndOverride("OPTIONAL", [ + context.getHandler(), + context.getClass(), + ]); + + try { + request.principal = await this.principals.resolve(request); + } catch (error) { + if (!(error instanceof RequestPrincipalError)) throw error; + if (isOptional && error.status === 401) { + request.principal = null; + return true; + } + if (error.challenge) + response.setHeader("WWW-Authenticate", error.challenge); + if (error.status === 400) throw new BadRequestException(error.message); + if (error.status === 403) throw new ForbiddenException(error.message); + throw new UnauthorizedException(error.message); + } + + if (isOptional) return true; + if (!request.principal) { + response.setHeader("WWW-Authenticate", bearerChallenge()); + throw new UnauthorizedException(); + } + if (request.principal.credentialKind === "oauth") { + const requiredScope = requiredCrmScope( + !["GET", "HEAD"].includes(request.method), + ); + const failure = oauthScopeFailure( + request.principal.scopes, + requiredScope, + ); + if (failure) { + response.setHeader("WWW-Authenticate", failure.challenge); + throw new ForbiddenException(failure.message); + } + } + return true; + } +} + +declare global { + namespace Express { + interface Request { + principal?: import("./request-principal").RequestPrincipal | null; + } + } +} diff --git a/apps/api/src/auth/request-principal.service.ts b/apps/api/src/auth/request-principal.service.ts new file mode 100644 index 000000000..738d65453 --- /dev/null +++ b/apps/api/src/auth/request-principal.service.ts @@ -0,0 +1,190 @@ +import { + API_KEY_HEADER, + apiUrl, + auth, + bearerChallenge, + OAUTH, + parseScopes, + SESSION_COOKIE_NAME, + type SessionUser, + verifyAccessTokenRequest, +} from "@crm/auth"; +import type { Db } from "@crm/db"; +import { Injectable, Logger } from "@nestjs/common"; +import { fromNodeHeaders } from "better-auth/node"; +import type { Request } from "express"; +import { z } from "zod"; +import { InjectDatabase } from "../database/database.constants"; +import { + type CredentialKind, + type RequestPrincipal, + RequestPrincipalError, +} from "./request-principal"; + +const oauthClaims = z.object({ + sub: z.string().trim().min(1), + client_id: z.string().trim().min(1).optional(), + azp: z.string().trim().min(1).optional(), + scope: z.string().default(""), + exp: z.number().int().positive(), +}); + +@Injectable() +export class RequestPrincipalService { + private readonly logger = new Logger(RequestPrincipalService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async resolve(request: Request): Promise { + const kinds = credentialKinds(request); + if (kinds.length > 1) { + throw new RequestPrincipalError( + 400, + "multiple_credentials", + "Send exactly one credential type.", + ); + } + + const kind = kinds[0]; + if (!kind) return null; + + try { + const principal = + kind === "oauth" + ? await this.resolveOAuth(request) + : await this.resolveBetterAuth(request, kind); + this.logger.debug({ + message: "Authentication accepted", + method: kind, + clientId: principal.clientId, + userId: principal.user.id, + }); + return principal; + } catch (error) { + const reason = + error instanceof RequestPrincipalError + ? error.code + : error instanceof Error + ? error.name + : "invalid_credential"; + this.logger.warn({ + message: "Authentication rejected", + method: kind, + reason, + }); + if (error instanceof RequestPrincipalError) throw error; + throw unauthorized("invalid_token", "The credential is invalid."); + } + } + + private async resolveBetterAuth( + request: Request, + kind: Exclude, + ): Promise { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(request.headers), + }); + if (!session) { + throw unauthorized("invalid_credential", "The credential is invalid."); + } + + return { + credentialKind: kind, + user: session.user, + clientId: null, + scopes: new Set(), + session, + expiresAt: session.session.expiresAt, + }; + } + + private async resolveOAuth(request: Request): Promise { + const claims = oauthClaims.parse( + await verifyAccessTokenRequest( + { + authorizationHeader: headerValue(request.headers.authorization), + method: request.method, + url: new URL(request.originalUrl || request.url, apiUrl).toString(), + }, + { + verifyOptions: { + issuer: OAUTH.issuer, + audience: OAUTH.resource, + }, + }, + ), + ); + const clientId = claims.client_id ?? claims.azp; + if (!clientId) { + throw unauthorized( + "invalid_token", + "The token has no client identifier.", + ); + } + + const [user, client] = await Promise.all([ + this.db.user.findUnique({ + where: { id: claims.sub }, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + image: true, + createdAt: true, + updatedAt: true, + }, + }), + this.db.oauthClient.findUnique({ + where: { clientId }, + select: { disabled: true }, + }), + ]); + if (!user || !client || client.disabled) { + throw unauthorized("invalid_token", "The token principal is inactive."); + } + + return { + credentialKind: "oauth", + user: user satisfies SessionUser, + clientId, + scopes: parseScopes(claims.scope), + session: null, + expiresAt: new Date(claims.exp * 1000), + }; + } +} + +export function credentialKinds(request: Request): CredentialKind[] { + const kinds: CredentialKind[] = []; + if (hasSessionCookie(request.headers.cookie)) kinds.push("session"); + if (headerValue(request.headers[API_KEY_HEADER])) kinds.push("apiKey"); + if (isBearerAuthorization(request.headers.authorization)) kinds.push("oauth"); + return kinds; +} + +function hasSessionCookie(cookieHeader: string | undefined): boolean { + if (!cookieHeader) return false; + const names = new Set([ + SESSION_COOKIE_NAME, + `__Secure-${SESSION_COOKIE_NAME}`, + ]); + return cookieHeader.split(";").some((entry) => { + const separator = entry.indexOf("="); + return separator > 0 && names.has(entry.slice(0, separator).trim()); + }); +} + +function headerValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value.find(Boolean) ?? null; + return value?.trim() || null; +} + +function isBearerAuthorization(value: string | string[] | undefined): boolean { + const authorization = headerValue(value); + return authorization ? /^Bearer\s+\S+/i.test(authorization) : false; +} + +function unauthorized(code: string, message: string): RequestPrincipalError { + return new RequestPrincipalError(401, code, message, bearerChallenge(code)); +} diff --git a/apps/api/src/auth/request-principal.ts b/apps/api/src/auth/request-principal.ts new file mode 100644 index 000000000..541b294ae --- /dev/null +++ b/apps/api/src/auth/request-principal.ts @@ -0,0 +1,24 @@ +import type { Session, SessionUser } from "@crm/auth"; + +export type CredentialKind = "session" | "apiKey" | "oauth"; + +export type RequestPrincipal = { + credentialKind: CredentialKind; + user: SessionUser; + clientId: string | null; + scopes: ReadonlySet; + session: Session | null; + expiresAt: Date | null; +}; + +export class RequestPrincipalError extends Error { + constructor( + readonly status: 400 | 401 | 403, + readonly code: string, + message: string, + readonly challenge?: string, + ) { + super(message); + this.name = "RequestPrincipalError"; + } +} diff --git a/apps/api/src/conversations/conversation-attachments.controller.ts b/apps/api/src/conversations/conversation-attachments.controller.ts index fe1d08609..85c247d20 100644 --- a/apps/api/src/conversations/conversation-attachments.controller.ts +++ b/apps/api/src/conversations/conversation-attachments.controller.ts @@ -1,4 +1,4 @@ -import { type auth, SESSION_COOKIE_NAME } from "@crm/auth"; +import { SESSION_COOKIE_NAME } from "@crm/auth"; import { Controller, Get, @@ -8,21 +8,24 @@ import { StreamableFile, } from "@nestjs/common"; import { + ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, + ApiSecurity, ApiTags, } from "@nestjs/swagger"; -import { Session, type UserSession } from "@thallesp/nestjs-better-auth"; import type { Response } from "express"; +import type { RequestPrincipal } from "../auth/request-principal"; +import { Principal } from "../auth/request-principal.decorator"; import { ConversationsService } from "./conversations.service"; -type CrmSession = UserSession; - @ApiTags("Conversations") @ApiCookieAuth(SESSION_COOKIE_NAME) +@ApiSecurity("apiKey") +@ApiBearerAuth("oauth") @Controller("api/conversations/attachments") export class ConversationAttachmentsController { constructor(private readonly conversations: ConversationsService) {} @@ -39,12 +42,12 @@ export class ConversationAttachmentsController { async read( @Param("id") id: string, @Query("share") shareToken: string | undefined, - @Session() session: CrmSession, + @Principal() principal: RequestPrincipal, @Res({ passthrough: true }) response: Response, ) { const attachment = await this.conversations.attachment( id, - session.user.id, + principal.user.id, shareToken, ); const content = Buffer.from(attachment.content); diff --git a/apps/api/src/create-app.ts b/apps/api/src/create-app.ts index 4a436e71c..8739410ce 100644 --- a/apps/api/src/create-app.ts +++ b/apps/api/src/create-app.ts @@ -1,4 +1,4 @@ -import { API_KEY_HEADER, apiUrl, SESSION_COOKIE_NAME } from "@crm/auth"; +import { API_KEY_HEADER, apiUrl, OAUTH, SESSION_COOKIE_NAME } from "@crm/auth"; import { ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { @@ -14,6 +14,7 @@ import { generateOpenApiDocument, } from "trpc-to-openapi"; import { AppModule } from "./app.module"; +import { RequestPrincipalService } from "./auth/request-principal.service"; import { ContextLogger } from "./logging/context-logger"; import { REST_BRIDGE_PATH } from "./trpc/openapi"; import { createBaseTrpcContext } from "./trpc/trpc.context"; @@ -52,6 +53,12 @@ export async function createApp(): Promise { in: "header", name: API_KEY_HEADER, } as const; + const oauthSecurityScheme = { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: `CompCRM OAuth access token with ${OAUTH.scopes.crm.read} or ${OAUTH.scopes.crm.write} scope.`, + } as const; // SwaggerModule.setup() registers its Express routes synchronously, so it must // happen before app.init() the same way the REST bridge does — Nest's own @@ -70,7 +77,10 @@ export async function createApp(): Promise { "Every tRPC procedure, reachable over REST for tooling that cannot speak tRPC. Same validation, same middlewares, same services as the tRPC transport — this only translates the wire format.", version: "1.0", baseUrl: `${apiUrl}${REST_BRIDGE_PATH}`, - securitySchemes: { apiKey: apiKeySecurityScheme }, + securitySchemes: { + apiKey: apiKeySecurityScheme, + oauth: oauthSecurityScheme, + }, }); const swaggerConfig = new DocumentBuilder() @@ -81,8 +91,22 @@ export async function createApp(): Promise { .setVersion("1.0") .addCookieAuth(SESSION_COOKIE_NAME) .addApiKey(apiKeySecurityScheme, "apiKey") + .addBearerAuth(oauthSecurityScheme, "oauth") .build(); const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + for (const path of Object.values(trpcDocument.paths ?? {})) { + for (const method of [ + "get", + "post", + "put", + "patch", + "delete", + ] as const) { + const operation = path[method]; + if (!operation?.security?.length) continue; + operation.security = [{ cookie: [] }, { apiKey: [] }, { oauth: [] }]; + } + } swaggerDocument.paths = { ...swaggerDocument.paths, @@ -90,6 +114,10 @@ export async function createApp(): Promise { }; swaggerDocument.components = { ...swaggerDocument.components, + securitySchemes: { + ...swaggerDocument.components?.securitySchemes, + ...trpcDocument.components?.securitySchemes, + }, schemas: { ...swaggerDocument.components?.schemas, ...(trpcDocument.components?.schemas as NonNullable< @@ -106,10 +134,11 @@ export async function createApp(): Promise { await app.init(); const { appRouter } = app.get(AppRouterHost); + const principals = app.get(RequestPrincipalService); restBridge = createOpenApiExpressMiddleware({ router: appRouter, - createContext: ({ req }) => createBaseTrpcContext(req), + createContext: ({ req }) => createBaseTrpcContext(req, principals), }); return app; diff --git a/apps/api/src/mailbox/mailbox-token.service.ts b/apps/api/src/mailbox/mailbox-token.service.ts index bc5df6ece..245461b86 100644 --- a/apps/api/src/mailbox/mailbox-token.service.ts +++ b/apps/api/src/mailbox/mailbox-token.service.ts @@ -32,22 +32,14 @@ export class MailboxTokenService { userId: string, providerId: MailboxProviderId, ): Promise> { - const account = await this.db.account.findFirst({ - where: { userId, providerId }, + const account = await this.db.account.findUnique({ + where: { userId_providerId: { userId, providerId } }, select: { scope: true }, }); return parseScopes(account?.scope); } - async isConnected(userId: string, source: SyncSource): Promise { - const scopes = await this.grantedScopes( - userId, - PROVIDER_FOR_SOURCE[source], - ); - return scopes.has(SCOPE_FOR_SOURCE[source]); - } - async signInAccounts(userId: string): Promise { return this.db.account.findMany({ where: { userId }, @@ -59,8 +51,8 @@ export class MailboxTokenService { userId: string, providerId: MailboxProviderId, ): Promise { - const account = await this.db.account.findFirst({ - where: { userId, providerId }, + const account = await this.db.account.findUnique({ + where: { userId_providerId: { userId, providerId } }, select: { refreshToken: true }, }); @@ -72,8 +64,17 @@ export class MailboxTokenService { source: SyncSource, ): Promise { const providerId = PROVIDER_FOR_SOURCE[source]; - - if (!(await this.isConnected(userId, source))) { + const account = await this.db.account.findUnique({ + where: { userId_providerId: { userId, providerId } }, + select: { id: true, scope: true }, + }); + if (!account) { + return { + outcome: "needs-reconnect", + reason: `${label(providerId)} has no connected account.`, + }; + } + if (!parseScopes(account.scope).has(SCOPE_FOR_SOURCE[source])) { return { outcome: "not-connected", reason: `The ${source} scope has not been granted.`, @@ -82,7 +83,7 @@ export class MailboxTokenService { try { const { accessToken } = await auth.api.getAccessToken({ - body: { providerId, userId }, + body: { accountId: account.id, userId }, }); if (!accessToken) { @@ -138,8 +139,10 @@ export class MailboxTokenService { } private async revokeWithGoogle(userId: string): Promise { - const account = await this.db.account.findFirst({ - where: { userId, providerId: GOOGLE_PROVIDER_ID }, + const account = await this.db.account.findUnique({ + where: { + userId_providerId: { userId, providerId: GOOGLE_PROVIDER_ID }, + }, select: { refreshToken: true, accessToken: true }, }); diff --git a/apps/api/src/trpc/context.types.ts b/apps/api/src/trpc/context.types.ts index 39b4c9ffd..386cd5b36 100644 --- a/apps/api/src/trpc/context.types.ts +++ b/apps/api/src/trpc/context.types.ts @@ -1,11 +1,14 @@ import type { Session, SessionUser } from "@crm/auth"; import type { Request } from "express"; +import type { RequestPrincipal } from "../auth/request-principal"; export type BaseTrpcContext = { req?: Request; + principal: RequestPrincipal | null; session: Session | null; }; export type AuthedTrpcContext = BaseTrpcContext & { user: SessionUser; + principal: RequestPrincipal; }; diff --git a/apps/api/src/trpc/middlewares/auth.middleware.ts b/apps/api/src/trpc/middlewares/auth.middleware.ts index a76be83bd..4447339b9 100644 --- a/apps/api/src/trpc/middlewares/auth.middleware.ts +++ b/apps/api/src/trpc/middlewares/auth.middleware.ts @@ -12,15 +12,16 @@ import type { AuthedTrpcContext, BaseTrpcContext } from "../context.types"; export class AuthMiddleware implements TRPCMiddleware { async use(opts: MiddlewareOptions): Promise { const ctx = opts.ctx as BaseTrpcContext; - const user = ctx.session?.user; + const principal = ctx.principal; - if (!user) { + if (!principal) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + const user = principal.user; setRequestUserId(user.id); - const nextCtx: AuthedTrpcContext = { ...ctx, user }; + const nextCtx: AuthedTrpcContext = { ...ctx, principal, user }; return opts.next({ ctx: nextCtx }); } } diff --git a/apps/api/src/trpc/middlewares/oauth-scope.middleware.ts b/apps/api/src/trpc/middlewares/oauth-scope.middleware.ts new file mode 100644 index 000000000..dd27aa6af --- /dev/null +++ b/apps/api/src/trpc/middlewares/oauth-scope.middleware.ts @@ -0,0 +1,30 @@ +import { oauthScopeFailure, requiredCrmScope } from "@crm/auth"; +import { Injectable } from "@nestjs/common"; +import { TRPCError } from "@trpc/server"; +import type { + MiddlewareOptions, + MiddlewareResponse, + TRPCMiddleware, +} from "nestjs-trpc"; +import type { BaseTrpcContext } from "../context.types"; + +@Injectable() +export class OAuthScopeMiddleware implements TRPCMiddleware { + async use(opts: MiddlewareOptions): Promise { + const ctx = opts.ctx as BaseTrpcContext; + const principal = ctx.principal; + if (principal?.credentialKind !== "oauth") return opts.next(); + + const requiredScope = requiredCrmScope(opts.type === "mutation"); + const failure = oauthScopeFailure(principal.scopes, requiredScope); + if (failure) { + ctx.req?.res?.setHeader("WWW-Authenticate", failure.challenge); + throw new TRPCError({ + code: "FORBIDDEN", + message: failure.message, + }); + } + + return opts.next(); + } +} diff --git a/apps/api/src/trpc/middlewares/session-only.middleware.ts b/apps/api/src/trpc/middlewares/session-only.middleware.ts index 1216ad998..2ebddc477 100644 --- a/apps/api/src/trpc/middlewares/session-only.middleware.ts +++ b/apps/api/src/trpc/middlewares/session-only.middleware.ts @@ -1,4 +1,3 @@ -import { API_KEY_HEADER } from "@crm/auth"; import { Injectable } from "@nestjs/common"; import { TRPCError } from "@trpc/server"; import type { @@ -12,7 +11,7 @@ import type { BaseTrpcContext } from "../context.types"; export class SessionOnlyMiddleware implements TRPCMiddleware { async use(opts: MiddlewareOptions): Promise { const ctx = opts.ctx as BaseTrpcContext; - if (ctx.req?.headers[API_KEY_HEADER]) { + if (ctx.principal?.credentialKind !== "session") { throw new TRPCError({ code: "UNAUTHORIZED" }); } return opts.next(); diff --git a/apps/api/src/trpc/trpc.context.ts b/apps/api/src/trpc/trpc.context.ts index 873d5a801..e811248dd 100644 --- a/apps/api/src/trpc/trpc.context.ts +++ b/apps/api/src/trpc/trpc.context.ts @@ -1,25 +1,40 @@ -import { auth } from "@crm/auth"; import { Injectable } from "@nestjs/common"; -import { fromNodeHeaders } from "better-auth/node"; +import { TRPCError } from "@trpc/server"; import type { Request } from "express"; import type { ContextOptions, TRPCContext } from "nestjs-trpc"; +import { RequestPrincipalError } from "../auth/request-principal"; +import { RequestPrincipalService } from "../auth/request-principal.service"; import type { BaseTrpcContext } from "./context.types"; export async function createBaseTrpcContext( req: Request | undefined, + principals: RequestPrincipalService, ): Promise { - const session = req - ? await auth.api - .getSession({ headers: fromNodeHeaders(req.headers) }) - .catch(() => null) - : null; - return { req, session }; + try { + const principal = req ? await principals.resolve(req) : null; + return { req, principal, session: principal?.session ?? null }; + } catch (error) { + if (!(error instanceof RequestPrincipalError)) throw error; + if (error.challenge) + req?.res?.setHeader("WWW-Authenticate", error.challenge); + throw new TRPCError({ + code: + error.status === 400 + ? "BAD_REQUEST" + : error.status === 403 + ? "FORBIDDEN" + : "UNAUTHORIZED", + message: error.message, + }); + } } @Injectable() export class TrpcContext implements TRPCContext { + constructor(private readonly principals: RequestPrincipalService) {} + async create(opts: ContextOptions): Promise { const req = "req" in opts ? opts.req : undefined; - return createBaseTrpcContext(req); + return createBaseTrpcContext(req, this.principals); } } diff --git a/apps/api/src/trpc/trpc.module.ts b/apps/api/src/trpc/trpc.module.ts index a4ffa5602..231ad47ed 100644 --- a/apps/api/src/trpc/trpc.module.ts +++ b/apps/api/src/trpc/trpc.module.ts @@ -5,6 +5,7 @@ import { formatTrpcError } from "./error-formatter"; import { AuthMiddleware } from "./middlewares/auth.middleware"; import { DomainErrorMiddleware } from "./middlewares/domain-error.middleware"; import { LoggingMiddleware } from "./middlewares/logging.middleware"; +import { OAuthScopeMiddleware } from "./middlewares/oauth-scope.middleware"; import { SessionOnlyMiddleware } from "./middlewares/session-only.middleware"; import { TrpcContext } from "./trpc.context"; import { TrpcErrorHandler } from "./trpc-error.handler"; @@ -17,7 +18,11 @@ import { TrpcErrorHandler } from "./trpc-error.handler"; logger: new ContextLogger(), errorFormatter: formatTrpcError, onError: TrpcErrorHandler, - globalMiddlewares: [LoggingMiddleware, DomainErrorMiddleware], + globalMiddlewares: [ + LoggingMiddleware, + DomainErrorMiddleware, + OAuthScopeMiddleware, + ], }), ], providers: [ @@ -25,6 +30,7 @@ import { TrpcErrorHandler } from "./trpc-error.handler"; TrpcErrorHandler, LoggingMiddleware, DomainErrorMiddleware, + OAuthScopeMiddleware, AuthMiddleware, SessionOnlyMiddleware, ], diff --git a/apps/api/test/auth.e2e.spec.ts b/apps/api/test/auth.e2e.spec.ts index a628f245c..0b0e9c8d1 100644 --- a/apps/api/test/auth.e2e.spec.ts +++ b/apps/api/test/auth.e2e.spec.ts @@ -2,6 +2,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { INestApplication } from "@nestjs/common"; import { Test, type TestingModule } from "@nestjs/testing"; import request from "supertest"; +import { z } from "zod"; + +Object.assign(process.env, { NODE_ENV: "test" }); +const testDatabaseUrl = process.env.TEST_DATABASE_URL; +if (!testDatabaseUrl) throw new Error("TEST_DATABASE_URL is required."); +process.env.DATABASE_URL = testDatabaseUrl; const fallback = (key: string, value: string) => { if (!process.env[key]) { @@ -9,11 +15,6 @@ const fallback = (key: string, value: string) => { } }; -fallback( - "DATABASE_URL", - "postgresql://postgres:postgres@localhost:5432/crm?schema=public", -); -fallback("BETTER_AUTH_SECRET", "test-secret-at-least-32-characters-long"); fallback("API_URL", "http://localhost:3001"); fallback("ALLOWED_SIGN_IN", "example.com"); fallback("GOOGLE_CLIENT_ID", "test-google-client-id"); @@ -63,12 +64,209 @@ describe("Auth (e2e)", () => { expect(response.body).toEqual({ authenticated: false, user: null }); }); + it("ignores an invalid credential on an optional-auth route", async () => { + const response = await request(app.getHttpServer()) + .get("/auth/session") + .set("authorization", "Bearer invalid") + .expect(200); + + expect(response.body).toEqual({ authenticated: false, user: null }); + }); + it("mounts the Better Auth handler", async () => { const response = await request(app.getHttpServer()).get("/api/auth/ok"); expect(response.status).not.toBe(404); }); + it("publishes OAuth authorization-server metadata", async () => { + const response = await request(app.getHttpServer()) + .get("/.well-known/oauth-authorization-server/api/auth") + .expect(200); + + expect(response.body.issuer).toBe("http://localhost:3001/api/auth"); + expect(response.body.authorization_endpoint).toBe( + "http://localhost:3001/api/auth/oauth2/authorize", + ); + expect(response.body.code_challenge_methods_supported).toContain("S256"); + }); + + it("keeps public metadata available with an invalid bearer token", async () => { + await request(app.getHttpServer()) + .get("/.well-known/oauth-authorization-server/api/auth") + .set("authorization", "Bearer invalid") + .expect(200); + }); + + it("publishes OpenID Connect metadata", async () => { + const response = await request(app.getHttpServer()) + .get("/api/auth/.well-known/openid-configuration") + .expect(200); + + expect(response.body.issuer).toBe("http://localhost:3001/api/auth"); + expect(response.body.jwks_uri).toBe("http://localhost:3001/api/auth/jwks"); + }); + + it("publishes protected-resource metadata", async () => { + const response = await request(app.getHttpServer()) + .get("/.well-known/oauth-protected-resource/api") + .expect(200); + + expect(response.body.resource).toBe("http://localhost:3001/api"); + expect(response.body.scopes_supported).toEqual(["crm.read", "crm.write"]); + }); + + it("rejects multiple credential types", async () => { + await request(app.getHttpServer()) + .get("/auth/session") + .set("cookie", "crm.session_token=invalid") + .set("authorization", "Bearer invalid") + .expect(400); + }); + + it("returns a bearer challenge for an invalid token", async () => { + const response = await request(app.getHttpServer()) + .get("/auth/me") + .set("authorization", "Bearer invalid") + .expect(401); + + expect(response.headers["www-authenticate"]).toContain("invalid_token"); + }); + + it("issues and refreshes OAuth tokens through PKCE", async () => { + const { db } = await import("@crm/db"); + const userId = `oauth-test-${crypto.randomUUID()}`; + const sessionToken = `oauth-session-${crypto.randomUUID()}`; + const verifier = `${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll( + "-", + "", + ); + const challenge = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), + ).toString("base64url"); + + await db.user.create({ + data: { + id: userId, + email: `${userId}@example.com`, + name: "OAuth Test", + emailVerified: true, + updatedAt: new Date(), + }, + }); + await db.session.create({ + data: { + id: sessionToken, + token: sessionToken, + userId, + expiresAt: new Date(Date.now() + 60_000), + updatedAt: new Date(), + }, + }); + + try { + const cookie = `crm.session_token=${await signCookieValue(sessionToken)}`; + const cookiePrincipal = await request(app.getHttpServer()) + .get("/auth/session") + .set("cookie", cookie) + .set("authorization", "Basic ignored") + .expect(200); + expect(cookiePrincipal.body.user.id).toBe(userId); + + const authorization = await request(app.getHttpServer()) + .get("/api/auth/oauth2/authorize") + .set("cookie", cookie) + .query({ + client_id: "compcrm-flutter", + redirect_uri: "ai.trycrm.app:/oauth/callback", + response_type: "code", + scope: "openid profile email offline_access crm.read", + code_challenge: challenge, + code_challenge_method: "S256", + state: "oauth-test-state", + nonce: "oauth-test-nonce", + resource: "http://localhost:3001/api", + }) + .expect(302); + const location = authorization.headers.location; + if (!location) + throw new Error("OAuth authorization returned no redirect."); + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get("code"); + expect(code).toBeTruthy(); + expect(redirectUrl.searchParams.get("state")).toBe("oauth-test-state"); + + const tokenResponse = await request(app.getHttpServer()) + .post("/api/auth/oauth2/token") + .type("form") + .send({ + grant_type: "authorization_code", + client_id: "compcrm-flutter", + redirect_uri: "ai.trycrm.app:/oauth/callback", + resource: "http://localhost:3001/api", + code, + code_verifier: verifier, + }) + .expect(200); + expect(tokenResponse.body.access_token).toEqual(expect.any(String)); + expect(tokenResponse.body.id_token).toEqual(expect.any(String)); + expect(tokenResponse.body.refresh_token).toEqual(expect.any(String)); + expect(tokenResponse.body.access_token.split(".")).toHaveLength(3); + const encodedPayload = tokenResponse.body.access_token.split(".")[1]; + if (!encodedPayload) throw new Error("Access token has no payload."); + const accessTokenPayload = z + .object({ exp: z.number() }) + .parse( + JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")), + ); + expect(accessTokenPayload.exp).toBeGreaterThan( + Math.floor(Date.now() / 1000), + ); + expect(accessTokenPayload.exp).toBeLessThanOrEqual( + Math.floor(Date.now() / 1000) + 610, + ); + const { verifyAccessTokenRequest } = await import("@crm/auth"); + await verifyAccessTokenRequest( + new Request("http://localhost:3001/auth/session", { + headers: { + authorization: `Bearer ${tokenResponse.body.access_token}`, + }, + }), + { + verifyOptions: { + issuer: "http://localhost:3001/api/auth", + audience: "http://localhost:3001/api", + }, + }, + ); + + const principal = await request(app.getHttpServer()) + .get("/auth/session") + .set("authorization", `Bearer ${tokenResponse.body.access_token}`) + .expect(200); + expect(principal.body.user.id).toBe(userId); + await request(app.getHttpServer()) + .head("/auth/me") + .set("authorization", `Bearer ${tokenResponse.body.access_token}`) + .expect(200); + + const refreshed = await request(app.getHttpServer()) + .post("/api/auth/oauth2/token") + .type("form") + .send({ + grant_type: "refresh_token", + client_id: "compcrm-flutter", + refresh_token: tokenResponse.body.refresh_token, + resource: "http://localhost:3001/api", + }) + .expect(200); + expect(refreshed.body.access_token).toEqual(expect.any(String)); + expect(refreshed.body.refresh_token).toEqual(expect.any(String)); + } finally { + await db.user.delete({ where: { id: userId } }); + } + }); + it("lets the sign-in page read what it may offer", async () => { const response = await request(app.getHttpServer()) .get("/api/trpc/sso.signInOptions") @@ -93,3 +291,23 @@ describe("Auth (e2e)", () => { expect(response.status).toBe(401); }); }); + +async function signCookieValue(value: string): Promise { + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) throw new Error("BETTER_AUTH_SECRET is required."); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ); + return encodeURIComponent( + `${value}.${Buffer.from(signature).toString("base64")}`, + ); +} diff --git a/apps/api/test/mailbox-purge.spec.ts b/apps/api/test/mailbox-purge.spec.ts index 7545fa6d9..b04fd265e 100644 --- a/apps/api/test/mailbox-purge.spec.ts +++ b/apps/api/test/mailbox-purge.spec.ts @@ -290,6 +290,7 @@ describe("disconnecting Microsoft", () => { await db.account.create({ data: { id: `ms-${suffix}`, + issuer: `https://login.microsoftonline.com/${suffix}/v2.0`, accountId: `ms-account-${suffix}`, providerId: MICROSOFT_PROVIDER_ID, userId: outlookRep, @@ -349,6 +350,7 @@ describe("disconnecting Google", () => { await db.account.create({ data: { id: `goog-${suffix}`, + issuer: "https://accounts.google.com", accountId: `goog-account-${suffix}`, providerId: GOOGLE_PROVIDER_ID, userId: gmailRep, diff --git a/apps/api/test/oauth-openapi.e2e.spec.ts b/apps/api/test/oauth-openapi.e2e.spec.ts new file mode 100644 index 000000000..f27d2abc2 --- /dev/null +++ b/apps/api/test/oauth-openapi.e2e.spec.ts @@ -0,0 +1,42 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import type { INestApplication } from "@nestjs/common"; +import request from "supertest"; + +const fallback = (key: string, value: string) => { + if (!process.env[key]) process.env[key] = value; +}; + +fallback("BETTER_AUTH_SECRET", "test-secret-at-least-32-characters-long"); +fallback("API_URL", "http://localhost:3001"); +fallback("ALLOWED_SIGN_IN", "example.com"); +fallback("GOOGLE_CLIENT_ID", "test-google-client-id"); +fallback("GOOGLE_CLIENT_SECRET", "test-google-client-secret"); + +describe("OAuth OpenAPI", () => { + let app: INestApplication; + + beforeAll(async () => { + const { createApp } = await import("../src/create-app"); + app = await createApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("publishes cookie, API-key, and OAuth security schemes", async () => { + const response = await request(app.getHttpServer()) + .get("/openapi.json") + .expect(200); + + expect(response.body.components.securitySchemes).toMatchObject({ + apiKey: { type: "apiKey", name: "x-api-key", in: "header" }, + oauth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }, + }); + expect(response.body.paths["/companies/{id}"].get.security).toEqual([ + { cookie: [] }, + { apiKey: [] }, + { oauth: [] }, + ]); + }); +}); diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx index 74b7fc389..5a463f458 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx @@ -30,8 +30,8 @@ const CONNECT_ERRORS = new Map([ async function startSlackOAuth(slug: string) { try { - const { error } = await authClient.oauth2.link({ - providerId: "slack", + const { error } = await authClient.linkSocial({ + provider: "slack", callbackURL: `${window.location.origin}/${slug}/settings/connections/slack/people`, errorCallbackURL: `${window.location.origin}/${slug}/settings/connections/slack?provider=slack`, }); diff --git a/apps/app/app/(landing)/oauth/consent/consent-form.tsx b/apps/app/app/(landing)/oauth/consent/consent-form.tsx new file mode 100644 index 000000000..254010f00 --- /dev/null +++ b/apps/app/app/(landing)/oauth/consent/consent-form.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { authClient } from "@crm/auth/client"; +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useState } from "react"; +import { toast } from "sonner"; + +export type ConsentFormProps = { + oauthQuery: string; +}; + +export function ConsentForm({ oauthQuery }: ConsentFormProps) { + const [pending, setPending] = useState<"approve" | "deny" | null>(null); + + async function decide(accept: boolean) { + setPending(accept ? "approve" : "deny"); + try { + const { data, error } = await authClient.oauth2.consent({ + accept, + oauth_query: oauthQuery, + }); + if (error || !data || !("url" in data)) { + toast.error(error?.message ?? "Could not complete authorization."); + setPending(null); + return; + } + window.location.assign(data.url); + } catch { + toast.error("Could not complete authorization."); + setPending(null); + } + } + + return ( +
+ + +
+ ); +} diff --git a/apps/app/app/(landing)/oauth/consent/page.tsx b/apps/app/app/(landing)/oauth/consent/page.tsx new file mode 100644 index 000000000..3de9a8b57 --- /dev/null +++ b/apps/app/app/(landing)/oauth/consent/page.tsx @@ -0,0 +1,78 @@ +import { auth, isOAuthScope, type OAuthScope, parseScopes } from "@crm/auth"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { + AuthHeading, + AuthShell, + InvalidOAuthRequest, +} from "@/components/auth-shell"; +import { consentSearchParams, serializeOAuthQuery } from "@/lib/oauth-query"; +import { getSession } from "@/lib/session"; +import { ConsentForm } from "./consent-form"; + +export const metadata: Metadata = { + title: "Authorize application", +}; + +export const instant = false; + +type OAuthConsentPageProps = { + searchParams: Promise>; +}; + +const PERMISSIONS = { + openid: "Confirm your identity", + profile: "Read your profile", + email: "Read your email address", + offline_access: "Keep access after you close the application", + "crm.read": "Read CRM records", + "crm.write": "Create and change CRM records", +} as const satisfies Record; + +export default async function OAuthConsentPage({ + searchParams, +}: OAuthConsentPageProps) { + const parsedParams = consentSearchParams.safeParse(await searchParams); + if (!parsedParams.success) { + return ( + + + + ); + } + const params = parsedParams.data; + const oauthQuery = serializeOAuthQuery(params); + + const session = await getSession(); + if (!session) redirect(`/sign-in?${oauthQuery}`); + + const client = await auth.api.getOAuthClientPublic({ + query: { client_id: params.client_id }, + headers: await headers(), + }); + const scopes = [...parseScopes(params.scope)]; + + return ( + + +
+

+ This application requests these permissions: +

+
    + {scopes.map((scope) => ( +
  • {permissionLabel(scope)}
  • + ))} +
+ +
+
+ ); +} +function permissionLabel(scope: string): string { + return isOAuthScope(scope) ? PERMISSIONS[scope] : scope; +} diff --git a/apps/app/app/(landing)/sign-in/page.tsx b/apps/app/app/(landing)/sign-in/page.tsx index 5b0e451af..f79556472 100644 --- a/apps/app/app/(landing)/sign-in/page.tsx +++ b/apps/app/app/(landing)/sign-in/page.tsx @@ -1,8 +1,15 @@ +import { auth } from "@crm/auth"; import type { MailboxProviderId } from "@crm/auth/scopes"; import type { Metadata } from "next"; +import { headers } from "next/headers"; import { redirect, unstable_rethrow } from "next/navigation"; import { Suspense } from "react"; -import { AuthHeading, AuthShell } from "@/components/auth-shell"; +import { + AuthHeading, + AuthShell, + InvalidOAuthRequest, +} from "@/components/auth-shell"; +import { serializeOAuthQuery, signInSearchParams } from "@/lib/oauth-query"; import { getSession } from "@/lib/session"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { SocialSignIn } from "./social-sign-in"; @@ -60,13 +67,29 @@ export default function SignInPage({ searchParams }: PageProps<"/sign-in">) { async function SignIn({ searchParams, }: Pick, "searchParams">) { - const [session, options, { method }] = await Promise.all([ + const [session, options, params] = await Promise.all([ currentSession(), signInOptions(), searchParams, ]); + const parsedParams = signInSearchParams.safeParse(params); + if (!parsedParams.success) { + return ; + } + const parsedSearchParams = parsedParams.data; + const oauthQuery = parsedSearchParams.sig + ? serializeOAuthQuery(parsedSearchParams) + : null; + const method = parsedSearchParams.method; if (session) { + if (oauthQuery) { + const continuation = await auth.api.oauth2Continue({ + body: { oauth_query: oauthQuery }, + headers: await headers(), + }); + if ("url" in continuation) redirect(continuation.url); + } redirect("/"); } @@ -110,9 +133,15 @@ async function SignIn({ description="Sign in with your account to continue." /> - {showSso ? : null} + {showSso ? ( + + ) : null} {social.map((provider) => ( - + ))} ); diff --git a/apps/app/app/(landing)/sign-in/social-sign-in.tsx b/apps/app/app/(landing)/sign-in/social-sign-in.tsx index 882f89c12..f3e7df734 100644 --- a/apps/app/app/(landing)/sign-in/social-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/social-sign-in.tsx @@ -9,6 +9,7 @@ import { Spinner } from "@crm/ui/components/spinner"; import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; +import { oauthSignInOptions } from "@/lib/oauth-query"; type ProviderChoice = { label: string; @@ -20,7 +21,13 @@ const PROVIDERS = { microsoft: { label: "Continue with Microsoft", Logo: MicrosoftLogo }, } as const satisfies Record; -export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { +export function SocialSignIn({ + provider, + oauthQuery, +}: { + provider: MailboxProviderId; + oauthQuery: string | null; +}) { const [pending, setPending] = useState(false); const { label, Logo } = PROVIDERS[provider]; @@ -38,7 +45,7 @@ export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { const { error } = await signIn.social({ provider, callbackURL: `${origin}/`, - errorCallbackURL: `${origin}/sign-in`, + ...oauthSignInOptions(oauthQuery, origin), }); if (error) fail(error.message); diff --git a/apps/app/app/(landing)/sign-in/sso-sign-in.tsx b/apps/app/app/(landing)/sign-in/sso-sign-in.tsx index 623592766..51c836351 100644 --- a/apps/app/app/(landing)/sign-in/sso-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/sso-sign-in.tsx @@ -5,13 +5,20 @@ import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; import { useState } from "react"; import { toast } from "sonner"; +import { oauthSignInOptions } from "@/lib/oauth-query"; export type SsoProvider = { providerId: string; name: string; }; -export function SsoSignIn({ providers }: { providers: SsoProvider[] }) { +export function SsoSignIn({ + providers, + oauthQuery, +}: { + providers: SsoProvider[]; + oauthQuery: string | null; +}) { const [pending, setPending] = useState(null); async function handleClick(providerId: string) { @@ -22,7 +29,7 @@ export function SsoSignIn({ providers }: { providers: SsoProvider[] }) { const { error } = await signIn.sso({ providerId, callbackURL: `${origin}/`, - errorCallbackURL: `${origin}/sign-in`, + ...oauthSignInOptions(oauthQuery, origin), }); if (error) { diff --git a/apps/app/components/auth-shell.tsx b/apps/app/components/auth-shell.tsx index 72a493866..a5cb372aa 100644 --- a/apps/app/components/auth-shell.tsx +++ b/apps/app/components/auth-shell.tsx @@ -75,3 +75,12 @@ export function AuthHeading({ ); } + +export function InvalidOAuthRequest() { + return ( + + ); +} diff --git a/apps/app/lib/oauth-query.ts b/apps/app/lib/oauth-query.ts new file mode 100644 index 000000000..f53f81200 --- /dev/null +++ b/apps/app/lib/oauth-query.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +const searchParamValue = z.union([ + z.string(), + z.array(z.string()), + z.undefined(), +]); + +const searchParams = z.record(z.string(), searchParamValue); + +export const signInSearchParams = searchParams.and( + z.object({ + method: z.string().optional(), + sig: z.string().optional(), + }), +); + +export const consentSearchParams = searchParams.and( + z.object({ + client_id: z.string(), + scope: z.string().optional(), + sig: z.string(), + }), +); + +export function serializeOAuthQuery( + params: z.infer, +): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) query.append(key, item); + } else if (value !== undefined) { + query.set(key, value); + } + } + return query.toString(); +} + +export type OAuthSignInOptions = { + errorCallbackURL: string; + oauth_query?: string; +}; + +export function oauthSignInOptions( + oauthQuery: string | null, + origin: string, +): OAuthSignInOptions { + const errorUrl = new URL("/sign-in", origin); + if (oauthQuery) errorUrl.search = oauthQuery; + const options: OAuthSignInOptions = { errorCallbackURL: errorUrl.toString() }; + if (oauthQuery) options.oauth_query = oauthQuery; + return options; +} diff --git a/apps/app/package.json b/apps/app/package.json index 48519e3c8..af84e9ab2 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -26,7 +26,7 @@ "@trpc/server": "^11.18.0", "@trpc/tanstack-react-query": "^11.18.0", "api": "workspace:*", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "eve": "^0.29.4", "next": "16.3.0", "next-themes": "^0.4.6", diff --git a/apps/app/proxy.ts b/apps/app/proxy.ts index 6040b7068..ff2e7ab89 100644 --- a/apps/app/proxy.ts +++ b/apps/app/proxy.ts @@ -14,7 +14,7 @@ const LANDING_PATH = "/"; const SIGN_IN_PATH = "/sign-in"; -const UNGATED = ["/grant-access", "/eve"]; +const UNGATED = ["/grant-access", "/eve", "/oauth"]; const ANONYMOUS = ["/t"]; diff --git a/apps/app/test/oauth-query.spec.ts b/apps/app/test/oauth-query.spec.ts new file mode 100644 index 000000000..f34301a72 --- /dev/null +++ b/apps/app/test/oauth-query.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { oauthSignInOptions } from "@/lib/oauth-query"; + +describe("oauthSignInOptions", () => { + it("returns the plain sign-in page when there is no OAuth request", () => { + expect(oauthSignInOptions(null, "https://crm.example")).toEqual({ + errorCallbackURL: "https://crm.example/sign-in", + }); + }); + + it("carries the OAuth request into the error URL and the callback", () => { + expect( + oauthSignInOptions("client_id=cmp&scope=crm.read", "https://crm.example"), + ).toEqual({ + errorCallbackURL: + "https://crm.example/sign-in?client_id=cmp&scope=crm.read", + oauth_query: "client_id=cmp&scope=crm.read", + }); + }); +}); diff --git a/bun.lock b/bun.lock index 9fd8557a9..00c12bb47 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ "@thallesp/nestjs-better-auth": "^2.7.0", "@trpc/server": "^11.18.0", "@vercel/blob": "^2.6.1", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -103,7 +103,7 @@ "@trpc/server": "^11.18.0", "@trpc/tanstack-react-query": "^11.18.0", "api": "workspace:*", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "eve": "^0.29.4", "next": "16.3.0", "next-themes": "^0.4.6", @@ -170,19 +170,20 @@ "name": "@crm/auth", "version": "0.0.0", "dependencies": { - "@better-auth/api-key": "1.6.25", - "@better-auth/sso": "1.6.25", + "@better-auth/api-key": "1.7.2", + "@better-auth/oauth-provider": "1.7.2", + "@better-auth/sso": "1.7.2", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/validation": "workspace:*", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "zod": "^4.4.3", }, "devDependencies": { - "@better-auth/cli": "^1.4.22", "@crm/typescript-config": "workspace:*", "@types/node": "^24.10.1", "@types/react": "^19.2.18", + "auth": "1.7.2", "react": "^19.2.8", "typescript": "5.9.2", }, @@ -396,27 +397,27 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@better-auth/api-key": ["@better-auth/api-key@1.6.25", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "better-auth": "^1.6.25", "better-call": "1.3.7" } }, "sha512-A6f3YLN8Ve+D4R7f8jrSKh8Mpu9+bmc8PIb9BFgGgspcedM1PjpSEI5JKAWPUhiGgMeJdCGeCpRP+27jQmW9/w=="], + "@better-auth/api-key": ["@better-auth/api-key@1.7.2", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-jih45wZaQ83lVYdChSnasxb8iax8p7AYetZ/XiImA9Yuag+cqmLBsaLPNvds5aZV18hLpmvzVkuzf7H/k1X88g=="], - "@better-auth/cli": ["@better-auth/cli@1.4.22", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@better-auth/core": "1.4.22", "@better-auth/telemetry": "1.4.22", "@better-auth/utils": "0.3.0", "@clack/prompts": "^0.11.0", "@mrleebo/prisma-ast": "^0.13.0", "@prisma/client": "^5.22.0", "@types/pg": "^8.15.5", "better-auth": "1.4.22", "better-sqlite3": "^12.2.0", "c12": "^3.2.0", "chalk": "^5.6.2", "commander": "^12.1.0", "dotenv": "^17.2.2", "drizzle-orm": "^0.41.0", "open": "^10.2.0", "pg": "^8.16.3", "prettier": "^3.6.2", "prompts": "^2.4.2", "semver": "^7.7.2", "yocto-spinner": "^0.2.3", "zod": "^4.3.5" }, "bin": { "better-auth": "dist/index.mjs" } }, "sha512-7azgrNiP1zJXMLqoLgCVj3KsZeYWLHeaGMapYlLblS6yU/o5n//sn1HBasRD2z4HBy2Etz/C7V483/mYvRHI2g=="], + "@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="], - "@better-auth/core": ["@better-auth/core@1.4.22", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-l20Ia10lI9iGL+bkjggamQP9lQuiAeB/EYfEx5EQ4AcPrLojG6Doc0UDw5VZM66VXcMGs3bgC8P7WiaJv4Walg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2" } }, "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2" } }, "sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ=="], + "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.2", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-td7FnUz3lLKXFXN+0RbZe3ygaHqxpRqDG+gxbfSZbztbVfG7vuZtR4ba3uccsodAw7anchhIg2xwVP1/zlcxcw=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ=="], - "@better-auth/sso": ["@better-auth/sso@1.6.25", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.25", "better-call": "1.3.7" } }, "sha512-Svbh1DFrMGlPms4YNuahtNqnro8jwPeOjGfnSEPn1x67+QcP140n9liHxXCWmFgwN1qbpU2VhiqNRQ0goWo85A=="], + "@better-auth/sso": ["@better-auth/sso@1.7.2", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-8tmkAGdcVu8Tr/+LPfSRgs/5a8YE1uU8OeaN5mAzsSdMWR4iwZdwQlJJmU8f9qZyIpNL/B6mIfDc5vaVUy1ECQ=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.4.22", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.22" } }, "sha512-ltoRysWQIbVlSgmVvn2EiFDkmLmtLAs9IVBvvwavGNFAklE7UcSlzR4BM5fllx5Vax927sou9MvZgUhgHRI62A=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ=="], - "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], @@ -448,17 +449,19 @@ "@carbon/icons-react": ["@carbon/icons-react@11.85.0", "", { "dependencies": { "@carbon/icon-helpers": "^10.79.0", "@ibm/telemetry-js": "^1.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16" } }, "sha512-+fRNYyqR8aCZRSgMA8sJ+GVfCmS0pK8FaDl9rQDuy4ZqYldZ4Yj8K3YIxmcSLlvTxMmNtGAxoa80xlhXROYDQg=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], + "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], - "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], - "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], "@crm/auth": ["@crm/auth@workspace:packages/auth"], @@ -664,7 +667,7 @@ "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], - "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.16.0", "", { "dependencies": { "chevrotain": "^12.0.0", "lilconfig": "^2.1.0" } }, "sha512-a9ELYNIflEQP38tSu6gnUgSAWgXjuhMvC52868K5sWgyRmYsjiJMWTUmm8iy7hZT5EN2ZKVydMTFP2q3/+6ccg=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], @@ -1302,7 +1305,7 @@ "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], "@xmpp/base64": ["@xmpp/base64@0.14.0", "", {}, "sha512-tz2LuzLMtjGSVVeuXDnDw19H+uOrqhrRFcQKJ3THV0HVjerDewT8WKXdrtBVSQ7n4Ue0k+awYcIE7qDD9rwMMQ=="], @@ -1404,6 +1407,8 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "auth": ["auth@1.7.2", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@better-auth/core": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@clack/prompts": "^1.6.0", "@mrleebo/prisma-ast": "^0.16.0", "better-auth": "1.7.2", "c12": "^4.0.0-beta.5", "chalk": "^5.6.2", "commander": "^15.0.0", "dotenv": "^17.3.1", "get-tsconfig": "^4.14.0", "jiti": "^2.7.0", "open": "^11.0.0", "prettier": "^3.8.1", "prompts": "^2.4.2", "semver": "^7.8.4", "yocto-spinner": "^1.2.0", "zod": "^4.3.6" }, "bin": { "better-auth": "./dist/index.mjs", "auth": "./dist/index.mjs" } }, "sha512-1c/FD5L2FkWzZXpbIV72X8gxXW0FepmGUBv0l3bn50SGMNOxfNGfU4k9yWonhM0r5i+l/39rTYtNPIS8Q0anUA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1414,18 +1419,14 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], - "better-auth": ["better-auth@1.6.25", "", { "dependencies": { "@better-auth/core": "1.6.25", "@better-auth/drizzle-adapter": "1.6.25", "@better-auth/kysely-adapter": "1.6.25", "@better-auth/memory-adapter": "1.6.25", "@better-auth/mongo-adapter": "1.6.25", "@better-auth/prisma-adapter": "1.6.25", "@better-auth/telemetry": "1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw=="], + "better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="], - "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], + "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], "better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="], - "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -1448,7 +1449,7 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "c12": ["c12@4.0.0-beta.5", "", { "dependencies": { "confbox": "^0.2.4", "defu": "^6.1.7", "exsolve": "^1.0.8", "pathe": "^2.0.3", "pkg-types": "^2.3.1", "rc9": "^3.0.1" }, "peerDependencies": { "chokidar": "^5", "dotenv": "*", "giget": "*", "jiti": "*", "magicast": "*" }, "optionalPeers": ["chokidar", "dotenv", "giget", "jiti", "magicast"] }, "sha512-yWGCPCQGJeFq4R0mFg5HOhC3Rg+B0PCdM+ldXWUhughoGgeeq8/tjRmXh4/lmhKWyhf+KOFxB/JMXf0Yv1Fd5A=="], "cache-manager": ["cache-manager@7.2.9", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "keyv": "^5.6.0" } }, "sha512-d4vceEyYe95gPxEyQchlEOH9vJlkNRW8G6gzFzzMTxJK9PahYMhC9chrEqgZN0HulROjgw3IzmWVNk7Q7ytiGw=="], @@ -1472,7 +1473,7 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -1512,7 +1513,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], @@ -1784,8 +1785,14 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], "fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="], @@ -1802,8 +1809,6 @@ "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -2302,7 +2307,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], @@ -2494,8 +2499,6 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], - "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], @@ -2536,7 +2539,7 @@ "rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="], - "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -2696,9 +2699,9 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + "tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], - "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2820,7 +2823,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], @@ -2848,7 +2851,7 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], + "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], @@ -2872,6 +2875,8 @@ "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2880,52 +2885,6 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@better-auth/api-key/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/api-key/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/cli/@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], - - "@better-auth/cli/better-auth": ["better-auth@1.4.22", "", { "dependencies": { "@better-auth/core": "1.4.22", "@better-auth/telemetry": "1.4.22", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-CXQ7ZLDkf/I9iaVTNuejJ7FlWal50hRPIv1n0lqMipvthEoMx+2RQyNXUvzGRjltSe5d9rcZPI3IxdtS1A5+YA=="], - - "@better-auth/cli/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], - - "@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - - "@better-auth/core/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], - - "@better-auth/core/kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], - - "@better-auth/drizzle-adapter/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/drizzle-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/kysely-adapter/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/kysely-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/memory-adapter/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/memory-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/mongo-adapter/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/mongo-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/prisma-adapter/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/prisma-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/sso/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "@better-auth/sso/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "@better-auth/telemetry/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - - "@chevrotain/cst-dts-gen/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - - "@chevrotain/gast/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "@crm/auth/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@crm/db/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], @@ -2952,8 +2911,6 @@ "@dotenvx/dotenvx/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "@dotenvx/dotenvx/yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -2970,6 +2927,8 @@ "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], + "@prisma/config/c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], @@ -3176,20 +3135,12 @@ "app/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "better-auth/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], - - "better-auth/@better-auth/telemetry": ["@better-auth/telemetry@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw=="], - - "better-auth/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - - "better-call/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "bun-types/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - "chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3282,12 +3233,12 @@ "rolldown/@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + "samlify/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "shadcn/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "shadcn/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -3298,20 +3249,12 @@ "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@better-auth/cli/better-auth/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - - "@better-auth/cli/better-auth/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], - - "@better-auth/cli/better-auth/kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], - - "@better-auth/core/better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], - - "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "@crm/auth/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@crm/db/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -3440,8 +3383,6 @@ "nitro/h3/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], - "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3450,10 +3391,6 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@better-auth/cli/better-auth/better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], - - "@better-auth/cli/better-auth/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], diff --git a/docs/api.md b/docs/api.md index 1a9bd3632..915a19b53 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ request. - **Both reads run concurrently**, but order decides which is *asked* — the research read is never made while onboarding is open. - **An unreachable API fails open** (`unknown` lets the request through). -- **`/sign-in`, `/grant-access`, `/eve` are ungated.** `/sign-in` is the only path a +- **`/sign-in`, `/grant-access`, `/eve`, `/oauth` are ungated.** `/sign-in` is the only path a stranger may read; `/` joins it only when `IS_MARKETING` is set. - **There is no way past the key gate but to answer** — Skip stranded installs, every later company sitting `PENDING` with nothing saying so. @@ -128,9 +128,29 @@ self-hoster's admin cannot redeploy. ## tRPC is the data surface; REST is auth and health only +### Three credentials, one principal + +`RequestPrincipalService` resolves a session cookie, `x-api-key`, or CompCRM OAuth token. + +It rejects requests that contain more than one credential type. + +OAuth tokens require the CompCRM issuer, API audience, active client, and current user. + +OAuth tRPC queries require `crm.read`. + +OAuth tRPC mutations require `crm.write`. + +Nest controllers use `RequestPrincipalGuard` and the same principal. + +API-key management remains session-only. + +Better Auth publishes OAuth and OpenID Connect endpoints under `/api/auth`. + +The root well-known routes expose authorization-server and protected-resource metadata. + - **One router per module**, `*.router.ts` (the codegen glob), with - `@Router({ alias })` and `@UseMiddlewares(AuthMiddleware)`. **No `AuthMiddleware` - means public — there is no other guard.** + `@Router({ alias })` and `@UseMiddlewares(AuthMiddleware)`. A router without + `AuthMiddleware` remains public on the tRPC transport. - **Routers are thin**: zod in, service call out; Prisma lives in `*.service.ts`. - Services throw Nest's `HttpException` family; `DomainErrorMiddleware` maps them. - **Filter, sort and paginate in Prisma.** List procedures take `listInput` and return @@ -148,6 +168,8 @@ under `/rest` generated from every tRPC procedure. Swagger UI renders it at `/`. `createApp` builds both halves and merges them, so nothing is generated at build time and no file is checked in — the document is whatever the routers are. +Protected operations describe cookie, API-key, and OAuth bearer alternatives. + `SwaggerModule.setup` runs **before** `app.init()`, because it registers its Express routes synchronously and Nest's own routing would otherwise shadow them. The factory form defers building the document to the first request, which is what lets it read diff --git a/docs/connections.md b/docs/connections.md index b5d95c9bb..422c1180a 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -45,17 +45,22 @@ undo it. Connecting is the same decision as disconnecting, because `replaceSlackConnection` deletes every other Slack account row: a second person connecting *replaces* the workspace's Slack, and every deployed agent then reads from and posts to whichever -Slack they installed. Hiding the button is not enough — `authClient.oauth2.link` -is one POST. +Slack they installed. Hiding the button is not enough. `authClient.linkSocial` +sends one POST. `slackConnectGuard` (`packages/auth/src/slack-connect.ts`) is Better Auth's `hooks.before`, and it asks the same `canManageConnections` the API does. It -covers all three doors: `/oauth2/link`, `/sign-in/oauth2` (Slack is a connection, -never a sign-in method, and that endpoint needs no session) and -`/oauth2/callback/slack`. The callback is the one that matters — refusing there -happens **before the code is exchanged**, so a refused attempt writes no +guards Slack account-linking starts through `/link-social`. Public Slack sign-in +through `/sign-in/social` remains available to Better Auth. The guard also +reads the server-generated OAuth state before `/callback/slack`. It guards the +callback only when that state identifies an account-linking transaction. A +normal Slack sign-in callback remains available to Better Auth. The callback +check happens before Better Auth exchanges the code. A refused link writes no `SlackWorkspaceGrant` user token and deletes no bot token. Google and Microsoft -sign in on different paths and never reach the guard. +callbacks never reach the Slack guard. + +Existing Slack applications must replace `/api/auth/oauth2/callback/slack` with +`/api/auth/callback/slack` before this upgrade reaches production. A workspace with no owner and no admin lets any member connect. There is nobody left to ask, and a fresh install must not be locked out of its first connection. diff --git a/docs/exposed-api.md b/docs/exposed-api.md new file mode 100644 index 000000000..19e597b5c --- /dev/null +++ b/docs/exposed-api.md @@ -0,0 +1,1484 @@ +# Exposed API reference + +This document describes the HTTP API exposed by the CRM API process. + +It covers authentication, authorization, tRPC, REST, native controllers, and internal routes. + +Verified on 2026-08-29 against the current source and generated router. + +> [!WARNING] +> The process mounts more routes than the supported CRM contract. +> Use tRPC or the REST bridge for CRM data. +> Use Better Auth routes only for sign-in, sessions, OAuth, and SSO callbacks. + +> [!WARNING] +> Conversation sharing is not anonymous in the current implementation. +> Both the shared conversation procedure and attachment controller require authentication. + +> [!CAUTION] +> Better Auth mounts organization, invitation, SAML, and API-key routes. +> These routes do not use the CRM service authorization paths. +> Treat them as unsupported until their policy behavior receives a separate audit. + +## Surface summary + +| Surface | Base path | Count | Primary purpose | +| --- | --- | ---: | --- | +| tRPC | `/api/trpc` | 160 procedures | Type-safe application data API | +| REST bridge | `/rest` | 159 operations | OpenAPI transport for tRPC procedures | +| Better Auth | `/api/auth` | Version-dependent | Sign-in, sessions, OAuth, providers, and plugin endpoints | +| Native controllers | Various paths | 18 operations | Health, profile, tracking, attachments, and cron work | +| Swagger UI | `/` | 1 page | Interactive REST documentation | +| OpenAPI JSON | `/openapi.json` | 1 document | Native controllers and REST bridge | + +The tRPC router contains 21 namespaces. + +The REST bridge omits only `users.me`. + +The native `GET /auth/me` endpoint provides the equivalent profile operation. + +## Choose a transport + +Use tRPC from the web application or another TypeScript client. + +Use the REST bridge from scripts and systems that use OpenAPI. + +Use native controllers for health, auth status, tracking intake, files, and scheduled work. + +Use Better Auth endpoints for authentication protocol flows. + +Do not use raw Better Auth organization routes as the CRM workspace API. + +## Quick start + +The local API uses `http://localhost:3001` by default. + +Fetch the generated OpenAPI document: + +```bash +curl --fail-with-body http://localhost:3001/openapi.json +``` + +Check API and database health: + +```bash +curl --fail-with-body http://localhost:3001/health +``` + +Call an authenticated REST bridge route with an API key: + +```bash +curl --fail-with-body \ + --header 'x-api-key: crm_REPLACE_WITH_KEY' \ + http://localhost:3001/rest/companies/options?q=acme +``` + +Call a REST mutation: + +```bash +curl --fail-with-body \ + --request POST \ + --header 'content-type: application/json' \ + --header 'x-api-key: crm_REPLACE_WITH_KEY' \ + --data '{"name":"Acme","domain":"acme.com"}' \ + http://localhost:3001/rest/companies +``` + +Call a tRPC query with a browser session: + +```typescript +const result = await trpc.companies.byId.query({ id: companyId }); +``` + +The OpenAPI document remains the authority for serialized REST parameters. + +The Zod contracts remain the authority for accepted values. + +## Authentication + +### Browser sessions + +Better Auth owns browser sessions under `/api/auth/*`. + +The session cookie name is `crm.session_token`. + +Production enables secure cookies. + +`AUTH_COOKIE_DOMAIN` enables cross-subdomain cookies when configured. + +Sessions expire after seven days. + +Active sessions refresh after one day. + +The signed cookie cache lasts five minutes. + +Better Auth uses database-backed rate limiting. + +Trusted origins come from `APP_URL` and configured origins. + +Email and password authentication is disabled. + +Google and Microsoft social sign-in are optional. + +OIDC SSO providers are stored in the database. + +The sign-in allow-list comes from `ALLOWED_SIGN_IN`. + +An empty allow-list rejects every new user. + +A session creation hook adds the user to the singleton workspace. + +The first user becomes the workspace owner. + +### API keys + +API keys use the `x-api-key` request header. + +Generated keys use the `crm_` prefix. + +A key name contains between one and 64 characters. + +A key can expire after one through 365 days. + +A null expiration creates a non-expiring key. + +The key plugin converts a valid key into a Better Auth session. + +That session lets the tRPC authorization middleware identify the key owner. + +Most tRPC and REST bridge operations accept sessions or API keys. + +The `apiKeys` namespace accepts browser sessions only. + +Its middleware rejects every request containing `x-api-key`. + +Use these supported management routes: + +| Operation | tRPC | REST | +| --- | --- | --- | +| List keys | `apiKeys.list` | `GET /rest/api-keys` | +| Create key | `apiKeys.create` | `POST /rest/api-keys` | +| Revoke key | `apiKeys.revoke` | `DELETE /rest/api-keys/{id}` | + +The create response returns the complete key once. + +Later list responses return only key metadata and the visible prefix. + +### Cron bearer secret + +Internal scheduled routes use `Authorization: Bearer `. + +They do not use browser sessions or API keys. + +Each route compares the complete header with a timing-safe comparison. + +An absent `CRON_SECRET` returns `503 Service Unavailable`. + +A missing or incorrect bearer value returns `403 Forbidden`. + +### Anonymous access + +The following supported operations need no session: + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/health` | Check API and database liveness | +| GET | `/auth/session` | Report optional session state | +| GET | `/rest/sso/sign-in-options` | List configured sign-in choices | +| GET | `/api/t/config/:siteId` | Read public tracking configuration | +| POST | `/api/t/e` | Submit tracking events | +| GET | `/api/auth/ok` | Check Better Auth availability | +| POST | `/api/auth/sign-in/social` | Start social sign-in | +| POST | `/api/auth/sign-in/sso` | Start SSO sign-in | +| GET or POST | OAuth and SSO callbacks | Complete provider authentication | + +Tracking collection always returns `204 No Content`. + +It returns 204 for accepted, rejected, and unreadable batches. + +This behavior prevents the collector from exposing processing details. + +## Authorization + +### Authentication middleware + +Every protected tRPC router uses `AuthMiddleware`. + +The middleware reads the Better Auth session from the request context. + +It returns `UNAUTHORIZED` when no session user exists. + +It adds the authenticated user to the tRPC context. + +The same middleware runs through tRPC and the REST bridge. + +The REST bridge does not implement a second authorization policy. + +Native Nest controllers use the Better Auth module guard. + +`@AllowAnonymous()` disables that guard for a route. + +`@OptionalAuth()` allows both signed-in and signed-out requests. + +### Workspace roles + +The singleton workspace has three roles. + +| Role | General CRM data | Workspace settings | Member roles | Currency | Tracking | SSO | Shared Slack connection | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `owner` | Read and write | Manage | Manage | Manage | Manage | Manage | Manage | +| `admin` | Read and write | Manage | Manage | Manage | Manage | Manage | Manage | +| `member` | Read and write | Read | Read | Read | Read | Read | Read | + +The API is single tenant. + +CRM records do not contain an organization authorization boundary. + +Every authenticated workspace user can access the general CRM data surface. + +Owner and admin checks occur inside services. + +The UI permission flags do not replace service checks. + +### Protected management operations + +| Area | Restricted operations | Required role | +| --- | --- | --- | +| Workspace | Update name, slug, or website | Owner or admin | +| Workspace | Change a member role | Owner or admin | +| Currency | Change reporting currency or rates | Owner or admin | +| Tracking | Change flags, domains, identifiers, or verification | Owner or admin | +| SSO | Register or remove a provider | Owner or admin | +| Slack | Connect, reconnect, or disconnect the shared workspace | Owner or admin | + +The workspace role change uses a transaction. + +It locks owner rows before it counts them. + +It refuses to demote the last owner. + +SSO list and settings reads require authentication. + +SSO provider registration and removal also require owner or admin status. + +Tracking activity reads remain available to every authenticated user. + +### Agent object authorization + +Every agent operation requires an authenticated workspace member. + +A private draft is visible only to its creator. + +A missing private draft returns `NOT_FOUND`. + +This response does not reveal the draft to another user. + +An agent creator can manage that agent. + +A workspace owner or admin can manage another creator's agent. + +Other members can read non-private agents. + +Other members cannot change agents that they did not create. + +### Record authorization + +Companies, contacts, deals, activities, fields, and saved views use workspace-wide access. + +These records have no per-user read boundary. + +Owner fields support assignment and filtering. + +Owner fields do not restrict record access. + +API keys act with the identity of their owning user. + +Service-level role checks still apply to API-key requests. + +### Current authorization findings + +The conversation sharing procedures use the protected router middleware. + +The attachment controller also requires a session. + +A share token does not provide anonymous access by itself. + +Raw Better Auth organization endpoints remain mounted. + +Those endpoints do not call `WorkspaceService`. + +Raw Better Auth API-key endpoints also remain mounted. + +Use the supported session-only `apiKeys` namespace for key management. + +The general settings mutations have authentication checks only. + +Any authenticated member or API key can change those values. + +## Implementing a Dart or Flutter client + +Use the REST bridge for Dart and Flutter applications. + +The bridge exposes the same validation, services, and authorization as tRPC. + +Do not implement the tRPC wire format in Dart. + +TypeScript router types do not provide Dart runtime validation. + +Generate Dart models from the runtime OpenAPI document instead. + +### Select an authentication model + +The API supports browser sessions, API keys, and OAuth access tokens. + +The OAuth server supports native Authorization Code Flow with PKCE. + +Choose the model from this table. + +| Client | Recommended credential | Current support | Main restriction | +| --- | --- | --- | --- | +| Flutter mobile | OAuth with PKCE | Supported | The mobile application still needs AppAuth integration | +| Flutter desktop | OAuth with PKCE | Supported | Register an exact desktop redirect URI | +| Flutter web | Better Auth cookie | Supported | Deployment must satisfy origin and cookie rules | +| Public mobile distribution | Native PKCE flow | Supported | Use the registered `compcrm-flutter` client | +| Server-side Dart | API key | Supported | The server must protect and rotate the key | + +Do not ship one shared API key inside the application bundle. + +Every installed application can extract a bundled secret. + +Provision a separate key for each user or managed device. + +Revoke only the affected key after loss or compromise. + +### Recommended client structure + +Keep transport, credentials, generated models, and application state separate. + +```text +lib/ +├── api/ +│ ├── generated/ +│ ├── crm_api_client.dart +│ ├── crm_api_error.dart +│ └── crm_auth_interceptor.dart +├── auth/ +│ ├── credential_store.dart +│ ├── secure_api_key_store.dart +│ └── session_controller.dart +├── workspace/ +│ ├── workspace_capabilities.dart +│ └── workspace_repository.dart +└── features/ +``` + +The generated package owns wire models and endpoint methods. + +Repositories convert wire models into application domain models. + +Widgets consume application state and never read credentials. + +One client instance must target one configured CRM origin. + +Never forward CRM credentials during redirects to another origin. + +### Generate the Dart client + +Fetch `/openapi.json` from the same release that the client targets. + +Store the document as a reviewed build input. + +Generate a `dart-dio` client with a pinned OpenAPI Generator release. + +```bash +curl --fail-with-body \ + https://crm.example.com/openapi.json \ + --output api/openapi.json + +openapi-generator-cli generate \ + --input-spec api/openapi.json \ + --generator-name dart-dio \ + --output packages/compcrm_api +``` + +Do not edit generated files manually. + +Regenerate them when `/openapi.json` changes. + +Review model nullability, date values, enums, and operation names after generation. + +Fail continuous integration when regeneration changes committed output. + +The runtime document can change without a committed OpenAPI artifact. + +Capture the document from the deployed version before releasing the client. + +Use an injected `http.Client` for a small prototype. + +Use generated code for a maintained application. + +### Store an API key + +Use platform secure storage for native Flutter applications. + +Do not use `SharedPreferences`, source constants, assets, logs, or analytics properties. + +The `flutter_secure_storage` package provides a common Keychain and Keystore interface. + +Wrap the package behind a small application interface. + +```dart +abstract interface class CredentialStore { + Future readApiKey(); + Future writeApiKey(String value); + Future deleteApiKey(); +} +``` + +```dart +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +final class SecureApiKeyStore implements CredentialStore { + SecureApiKeyStore(this._storage); + + static const _key = 'compcrm_api_key'; + final FlutterSecureStorage _storage; + + @override + Future readApiKey() => _storage.read(key: _key); + + @override + Future writeApiKey(String value) { + return _storage.write(key: _key, value: value); + } + + @override + Future deleteApiKey() => _storage.delete(key: _key); +} +``` + +Disable Android backup for storage that contains the wrapped key. + +Configure iOS Keychain accessibility for the required background behavior. + +Require device authentication before high-risk operations when the product needs that control. + +Flutter web must not treat browser storage as a secure secret vault. + +Use the Better Auth cookie model for Flutter web. + +### Attach the API key + +Add `x-api-key` only to requests for the configured CRM origin. + +Do not add the key to Better Auth sign-in requests. + +Do not add the key to image URLs or external attachment redirects. + +```dart +import 'package:dio/dio.dart'; + +abstract interface class ApiKeyProvider { + String? get apiKey; +} + +final class CrmApiKeyInterceptor extends Interceptor { + CrmApiKeyInterceptor({ + required this.apiKeys, + required this.crmOrigin, + }); + + final ApiKeyProvider apiKeys; + final Uri crmOrigin; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + final request = options.uri; + final sameOrigin = request.origin == crmOrigin.origin; + + if (sameOrigin) { + final apiKey = apiKeys.apiKey; + if (apiKey != null && apiKey.isNotEmpty) { + options.headers['x-api-key'] = apiKey; + } + } + + handler.next(options); + } +} +``` + +Load the secure value before constructing authenticated repositories. + +Keep the loaded value only inside the authentication controller. + +Construct `Dio` with a fixed HTTPS base URL in production. + +Reject cleartext production URLs during application configuration. + +Disable request-header logging for authenticated requests. + +Redact `x-api-key`, `cookie`, and `set-cookie` in every diagnostic sink. + +### Bootstrap identity and capabilities + +Call `GET /rest/workspace` after loading a credential. + +This operation accepts a browser session or API key. + +It returns the workspace and the viewer's role. + +It also returns `canRename` and `canChangeRoles` capabilities. + +```dart +enum WorkspaceRole { owner, admin, member } + +final class WorkspaceCapabilities { + const WorkspaceCapabilities({ + required this.role, + required this.canRename, + required this.canChangeRoles, + }); + + final WorkspaceRole? role; + final bool canRename; + final bool canChangeRoles; + + bool get canManageWorkspace => + role == WorkspaceRole.owner || role == WorkspaceRole.admin; +} +``` + +Use server capability fields when the response provides them. + +Centralize any temporary role mapping in one domain module. + +Do not repeat role comparisons inside widgets. + +The server remains the authorization authority. + +Local capability checks control presentation only. + +Every mutation must still handle `403 Forbidden`. + +Do not send an organization identifier or tenant header. + +The API resolves the fixed singleton workspace internally. + +### Handle authentication state + +Model authentication as explicit states. + +| State | Meaning | Allowed action | +| --- | --- | --- | +| `unknown` | Secure storage has not completed | Show startup progress | +| `signedOut` | No credential exists | Show provisioning instructions | +| `checking` | The client validates a credential | Block protected mutations | +| `signedIn` | Workspace bootstrap succeeded | Load protected data | +| `forbidden` | Authentication succeeded without required permission | Keep the credential and explain access | +| `expired` | The server rejected the credential | Delete the credential and sign out | +| `offline` | The server is unreachable | Show cached data under local policy | + +Do not treat every network failure as a sign-out. + +Only an authentication response invalidates the local credential. + +A `403` response does not invalidate authentication. + +A timeout does not prove credential failure. + +Validate the stored key through a protected REST request at startup. + +Use `GET /rest/workspace` for this check. + +### Map API failures + +Convert transport failures into one sealed application error hierarchy. + +| HTTP result | Client meaning | Recommended action | +| ---: | --- | --- | +| 400 | Invalid client input | Display field or request feedback | +| 401 | Missing, revoked, or invalid credential | Delete the key and require provisioning | +| 403 | Authenticated user lacks permission | Preserve the key and disable that action | +| 404 | Resource is absent or unavailable | Close stale detail state and refresh its list | +| 409 | A domain conflict exists | Refresh the record and show the conflict | +| 429 | The server rate limit applies | Respect `Retry-After` and delay further calls | +| 500–599 | The server failed | Preserve state and offer a bounded retry | +| Network failure | Connectivity failed | Preserve credentials and expose offline state | + +Do not display raw server stacks or database messages. + +Keep a request identifier when the response exposes one. + +Never include credentials or full response bodies in crash reports. + +### Retry safely + +Retry idempotent reads after transient network failures. + +Use capped exponential backoff with random jitter. + +Respect `Retry-After` for rate-limit responses. + +Do not retry create, update, archive, purge, or bulk mutations automatically. + +A retry can repeat a completed mutation after a lost response. + +Require an explicit user retry for those operations. + +Cancel in-flight list requests when a newer filter replaces them. + +Use server pagination instead of downloading complete collections. + +### Cache without weakening authorization + +Partition cached records by API origin and authenticated identity. + +Do not reuse one user's cache after credential replacement. + +Clear sensitive cached records during sign-out. + +Encrypt sensitive offline data when the product retains it. + +Do not infer authorization from previously cached roles. + +Refresh workspace capabilities after sign-in and role-related `403` responses. + +Keep optimistic updates reversible. + +Restore previous state when the server rejects a mutation. + +### Support Flutter web + +Use same-origin deployment when possible. + +The browser then manages the Better Auth cookie. + +Cross-origin deployments require correct trusted origins, CORS, HTTPS, and credentialed requests. + +Never copy the session cookie into Dart application storage. + +Do not expose `crm.session_token` to application JavaScript. + +Use `/auth/session` to check optional browser authentication. + +Use `/auth/me` to read the signed-in profile. + +Call `/rest/workspace` to load role and capability data. + +### Recommended authentication target + +OAuth with PKCE is the supported native credential for public applications. + +The CRM already authenticates browser users through Better Auth. + +It also accepts Google, Microsoft, and stored OIDC SSO providers. + +These integrations make CRM an OAuth client. + +They do not make CRM an OAuth authorization server. + +Add Better Auth's OAuth 2.1 Provider plugin for native and third-party clients. + +Do not build custom authorization and token endpoints. + +The provider supports OAuth discovery, OIDC, PKCE, refresh tokens, revocation, audiences, and JWKS. + +Keep browser sessions for the web application. + +Keep API keys for scripts and controlled server integrations. + +Use OAuth access tokens for public Flutter applications. + +Do not use Better Auth's bearer-session plugin as the final mobile protocol. + +That plugin transports a session token but does not create an OAuth authorization contract. + +Do not accept upstream provider tokens directly. + +Google, Microsoft, and enterprise tokens target their own audiences and policies. + +CompCRM must issue one consistent access token after upstream authentication. + +### OAuth implementation status + +The CRM now provides these capabilities. + +| Capability | Current implementation | +| --- | --- | +| OAuth authorization server | Better Auth OAuth Provider runs under `/api/auth` | +| OIDC discovery | The issuer publishes OpenID Connect metadata and JWKS | +| Official mobile client | Startup reconciles public client `compcrm-flutter` | +| PKCE authorization | The native client requires Authorization Code and PKCE | +| CRM access tokens | Tokens use the `${API_URL}/api` audience | +| Bearer validation | One request-principal service verifies access tokens | +| Scope enforcement | Queries require `crm.read`; mutations require `crm.write` | +| Refresh lifecycle | Refresh tokens rotate with a 30-second reuse interval | +| OpenAPI security | Cookie, API-key, and bearer alternatives are documented | +| OAuth integration tests | Tests cover discovery, challenges, mixed credentials, and OpenAPI | + +The Better Auth runtime and plugins use version `1.7.2`. + +The Better Auth CLI package `auth` uses version `1.7.2`. + +Device-session management remains future work. + +### Recommended server design + +Use CompCRM's Better Auth installation as the authorization server. + +Add the Better Auth JWT and OAuth 2.1 Provider plugins. + +Disable the standalone JWT token endpoint when OAuth Provider owns token issuance. + +Disable automatic JWT response headers for the same reason. + +Apply the required Better Auth schema migration. + +Review the generated migration before applying it. + +Register Flutter as a public client. + +Set its token authentication method to `none`. + +Do not assign a client secret to Flutter. + +Require Authorization Code with S256 PKCE. + +Disable dynamic client registration. + +Register each production redirect URI exactly. + +Use claimed HTTPS links instead of custom schemes when platform support permits them. + +Configure one protected resource identifier for the CRM REST API. + +Use that resource identifier as the access-token audience. + +Define these initial scopes. + +| Scope | Purpose | +| --- | --- | +| `openid` | Request OIDC identity | +| `profile` | Request basic profile claims | +| `email` | Request the signed-in email claim | +| `offline_access` | Request refresh-token issuance | +| `crm.read` | Read CRM resources | +| `crm.write` | Change CRM resources | + +Keep scopes coarse and client-focused. + +Keep workspace roles fine-grained and user-focused. + +An access token must satisfy both checks. + +For example, `crm.write` allows a write-capable client. + +The user's current workspace role still decides administrative access. + +Do not encode owner or admin authority as a durable token claim. + +A role can change before the token expires. + +Resolve workspace membership and role from current server data. + +Keep API-key management browser-session-only. + +Do not enable client credentials during the first OAuth phase. + +Client credentials need a service-principal model that does not exist today. + +Never represent a machine client as a synthetic user. + +### Verify bearer tokens in the API + +Create one OAuth verification boundary before controller or tRPC authorization. + +Use Better Auth's `verifyAccessTokenRequest` resource-server API. + +Validate these values for every bearer request. + +| Value | Required check | +| --- | --- | +| Signature | Verify against the provider JWKS | +| `iss` | Match the configured CompCRM issuer exactly | +| `aud` | Match the CRM resource identifier exactly | +| `exp` | Reject expired access tokens | +| `nbf` | Reject tokens that are not active | +| `sub` | Resolve an existing CompCRM user | +| `scope` | Require the procedure's read or write scope | + +Reject opaque bearer values during the first implementation. + +JWT validation avoids an introspection request for every API call. + +Map the verified subject into the existing authenticated context. + +Then run the existing service and role authorization. + +Do not create a second authorization policy inside the verifier. + +Return a standards-compliant bearer challenge for invalid tokens. + +Preserve current cookie and API-key behavior during migration. + +The three credential types must converge on one authenticated user context. + +```mermaid +sequenceDiagram + participant Flutter + participant Browser + participant Auth as Better Auth OAuth Provider + participant API as CRM REST API + participant Services as CRM Services + + Flutter->>Browser: Authorization request with state and PKCE + Browser->>Auth: Google, Microsoft, or enterprise sign-in + Auth-->>Flutter: Authorization code through claimed link + Flutter->>Auth: Code exchange with PKCE verifier + Auth-->>Flutter: Access, ID, and refresh tokens + Flutter->>API: Bearer access token + API->>API: Verify signature, issuer, audience, and scope + API->>Services: User identity and current workspace context + Services-->>Flutter: Role-authorized response +``` + +### Implement the Flutter OIDC flow + +Use `flutter_appauth` for Android, iOS, and macOS. + +It uses the system browser and supports discovery, Authorization Code, and PKCE. + +Configure the CRM issuer, public client identifier, redirect URI, resource, and scopes. + +Read authorization and token endpoints from OIDC discovery. + +Do not hardcode provider-specific Google or Microsoft endpoints. + +Request `openid`, `profile`, `email`, `offline_access`, `crm.read`, and `crm.write`. + +Use the access token in `Authorization: Bearer `. + +Never send the ID token to the REST API. + +Keep the current access token in memory. + +Store the rotated refresh token in platform secure storage. + +Store only the minimum identity data required for startup presentation. + +Serialize refresh operations through one in-flight operation. + +Retry one failed request after a successful refresh. + +Do not create a refresh loop after another `401` response. + +Delete tokens after `invalid_grant`, explicit sign-out, or device revocation. + +Treat user cancellation as a normal signed-out result. + +Do not use an embedded web view for sign-in. + +Do not send tokens through application links. + +Only the short-lived authorization code returns through the link. + +### Roll out OAuth safely + +Implement the work in bounded phases. + +1. The CRM aligns Better Auth runtime packages. +2. The CRM adds provider plugins, migrations, discovery, and the public client. +3. The CRM adds bearer verification and OpenAPI bearer security. +4. The CRM enforces `crm.read` and `crm.write` before service authorization. +5. The Flutter application adds AppAuth sign-in, secure refresh, and sign-out. +6. A later CRM change adds device-session management and revocation. +7. API keys remain available for automation. + +Do not remove cookie sessions or API keys during the first release. + +Run old and new authentication paths through the same authorization tests. + +### OAuth and OIDC references + +- [Better Auth OAuth 2.1 Provider](https://better-auth.com/docs/plugins/oauth-provider) +- [Better Auth resource-server verification](https://better-auth.com/docs/plugins/oauth-provider#api-server) +- [Better Auth JWT plugin](https://better-auth.com/docs/plugins/jwt) +- [Better Auth bearer-session plugin](https://better-auth.com/docs/plugins/bearer) +- [Flutter AppAuth package](https://pub.dev/packages/flutter_appauth) + +### Test the Flutter integration + +Use an injected HTTP client in unit tests. + +Test these cases before release. + +| Test | Expected result | +| --- | --- | +| Request targets the CRM origin | The client adds `x-api-key` | +| Request targets another origin | The client omits `x-api-key` | +| Credential storage returns null | The client enters `signedOut` | +| Workspace bootstrap returns 401 | The client deletes the stored key | +| Workspace bootstrap returns 403 | The client preserves the stored key | +| A member opens management UI | Restricted controls stay disabled | +| The server returns 429 | The client respects `Retry-After` | +| A mutation loses its response | The client does not retry automatically | +| The OpenAPI document changes | Generated client drift fails continuous integration | +| Logging captures a request | Credential headers remain redacted | +| Discovery reports the wrong issuer | The authentication test fails | +| A public client omits PKCE | The authorization request fails | +| An access token has another audience | The REST request returns 401 | +| An access token lacks `crm.read` | A protected read returns 403 | +| A refresh token is reused | The token family follows the configured reuse policy | +| A device session is revoked | Its next refresh fails | + +Run an integration test against the current API release. + +Use a dedicated test user and a short-lived API key. + +Revoke that key after the test suite. + +Never use a production owner key in automated tests. + +### Platform requirements + +Android applications need the `INTERNET` permission. + +macOS applications need the network client entitlement. + +iOS and Android builds need secure storage configuration. + +Production applications must use HTTPS. + +Development cleartext exceptions must target local development hosts only. + +### Flutter implementation references + +- [Flutter networking guidance](https://docs.flutter.dev/data-and-backend/networking) +- [Flutter authenticated request example](https://docs.flutter.dev/cookbook/networking/authenticated-requests) +- [OpenAPI Generator Dart Dio documentation](https://openapi-generator.tech/docs/generators/dart-dio/) +- [Flutter secure storage package](https://pub.dev/packages/flutter_secure_storage) + +## Request validation and errors + +tRPC inputs use Zod schemas. + +The REST bridge uses the same schemas and services. + +Native DTO validation removes unknown properties. + +Native DTO validation also rejects non-whitelisted properties. + +The global pipe enables implicit type conversion. + +List procedures filter, sort, and paginate inside Prisma. + +Typical list responses use this shape: + +```json +{ + "rows": [], + "total": 0, + "facetCounts": {} +} +``` + +Domain services throw Nest HTTP exceptions. + +The tRPC domain middleware maps selected statuses. + +| HTTP status | tRPC code | +| ---: | --- | +| 400 | `BAD_REQUEST` | +| 401 | `UNAUTHORIZED` | +| 403 | `FORBIDDEN` | +| 404 | `NOT_FOUND` | +| 409 | `CONFLICT` | +| 429 | `TOO_MANY_REQUESTS` | +| Other | `INTERNAL_SERVER_ERROR` | + +Zod errors receive readable messages. + +Do not depend on internal stack traces or database error text. + +## OpenAPI behavior + +Swagger UI is available at `/`. + +The JSON document is available at `/openapi.json`. + +The document is built at runtime. + +It merges Nest controller metadata with the tRPC REST bridge. + +The document does not come from a committed artifact. + +Router changes therefore change the runtime document. + +Every `restMeta` call defaults to protected. + +Only `sso.signInOptions` sets `protect: false`. + +The document declares three security schemes. + +| Scheme | Location | Name | +| --- | --- | --- | +| Cookie | Cookie header | `crm.session_token` | +| API key | Header | `x-api-key` | +| OAuth bearer | Authorization header | `Bearer ` | + +Better Auth owns its routes independently. + +Its full mounted route set is larger than the Swagger-supported CRM contract. + +## Native controller inventory + +| Method | Path | Access | Purpose | OpenAPI | +| --- | --- | --- | --- | --- | +| GET | `/auth/me` | Session | Read the current user profile | Included | +| GET | `/auth/session` | Optional session | Read session state | Included | +| GET | `/health` | Public | Check API and database | Included | +| GET | `/api/conversations/attachments/:id` | Session | Download an attachment | Included | +| GET | `/api/t/config/:siteId` | Public | Read tracking configuration | Included | +| POST | `/api/t/e` | Public | Collect tracking events | Included | +| GET | `/internal/sync/mailboxes` | Cron bearer | Run due mailbox work | Included | +| POST | `/internal/sync/mailboxes` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/sync/google` | Cron bearer | Mailbox alias | Included | +| POST | `/internal/sync/google` | Cron bearer | Legacy mailbox alias | Excluded | +| GET | `/internal/sync/rates` | Cron bearer | Refresh exchange rates | Included | +| POST | `/internal/sync/rates` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/telemetry/rollup` | Cron bearer | Roll up telemetry | Included | +| POST | `/internal/telemetry/rollup` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/archive/prune` | Cron bearer | Purge expired archives | Included | +| POST | `/internal/archive/prune` | Cron bearer | Legacy scheduler method | Excluded | +| GET | `/internal/tracking/retention` | Cron bearer | Sweep tracking data | Included | +| POST | `/internal/tracking/retention` | Cron bearer | Legacy scheduler method | Excluded | + +The attachment endpoint accepts an optional `share` query parameter. + +The global session guard still runs before the controller method. + +## Better Auth mounted route inventory + +This table reports the Better Auth paths that CompCRM uses or exposes intentionally. + +A mounted path is not always enabled or supported. + +Email-password operations remain mounted but reject their disabled flow. + +Organization creation and deletion are disabled by configuration. + +The CRM product supports OIDC SSO configuration through `sso.*`. + +The SAML protocol routes come from the Better Auth SSO plugin. + +| Method | Path | Better Auth operation | +| --- | --- | --- | +| POST | `/api/auth/sign-in/social` | `signInSocial` | +| GET / POST | `/api/auth/callback/:id` | `callbackOAuth` | +| GET / POST | `/api/auth/get-session` | `getSession` | +| POST | `/api/auth/sign-out` | `signOut` | +| POST | `/api/auth/sign-up/email` | `signUpEmail` | +| POST | `/api/auth/sign-in/email` | `signInEmail` | +| POST | `/api/auth/reset-password` | `resetPassword` | +| POST | `/api/auth/verify-password` | `verifyPassword` | +| GET | `/api/auth/verify-email` | `verifyEmail` | +| POST | `/api/auth/send-verification-email` | `sendVerificationEmail` | +| POST | `/api/auth/change-email` | `changeEmail` | +| POST | `/api/auth/change-password` | `changePassword` | +| POST | `/api/auth/update-session` | `updateSession` | +| POST | `/api/auth/update-user` | `updateUser` | +| POST | `/api/auth/delete-user` | `deleteUser` | +| POST | `/api/auth/request-password-reset` | `requestPasswordReset` | +| GET | `/api/auth/reset-password/:token` | `requestPasswordResetCallback` | +| GET | `/api/auth/list-sessions` | `listSessions` | +| POST | `/api/auth/revoke-session` | `revokeSession` | +| POST | `/api/auth/revoke-sessions` | `revokeSessions` | +| POST | `/api/auth/revoke-other-sessions` | `revokeOtherSessions` | +| POST | `/api/auth/link-social` | `linkSocialAccount` | +| GET | `/api/auth/list-accounts` | `listUserAccounts` | +| GET | `/api/auth/delete-user/callback` | `deleteUserCallback` | +| POST | `/api/auth/unlink-account` | `unlinkAccount` | +| POST | `/api/auth/refresh-token` | `refreshToken` | +| POST | `/api/auth/get-access-token` | `getAccessToken` | +| GET | `/api/auth/account-info` | `accountInfo` | +| POST | `/api/auth/organization/create` | `createOrganization` | +| POST | `/api/auth/organization/update` | `updateOrganization` | +| POST | `/api/auth/organization/delete` | `deleteOrganization` | +| POST | `/api/auth/organization/set-active` | `setActiveOrganization` | +| GET | `/api/auth/organization/get-full-organization` | `getFullOrganization` | +| GET | `/api/auth/organization/list` | `listOrganizations` | +| POST | `/api/auth/organization/invite-member` | `createInvitation` | +| POST | `/api/auth/organization/cancel-invitation` | `cancelInvitation` | +| POST | `/api/auth/organization/accept-invitation` | `acceptInvitation` | +| GET | `/api/auth/organization/get-invitation` | `getInvitation` | +| POST | `/api/auth/organization/reject-invitation` | `rejectInvitation` | +| GET | `/api/auth/organization/list-invitations` | `listInvitations` | +| GET | `/api/auth/organization/get-active-member` | `getActiveMember` | +| POST | `/api/auth/organization/check-slug` | `checkOrganizationSlug` | +| POST | `/api/auth/organization/remove-member` | `removeMember` | +| POST | `/api/auth/organization/update-member-role` | `updateMemberRole` | +| POST | `/api/auth/organization/leave` | `leaveOrganization` | +| GET | `/api/auth/organization/list-user-invitations` | `listUserInvitations` | +| GET | `/api/auth/organization/list-members` | `listMembers` | +| GET | `/api/auth/organization/get-active-member-role` | `getActiveMemberRole` | +| POST | `/api/auth/organization/has-permission` | `hasPermission` | +| GET | `/api/auth/sso/saml2/sp/metadata` | `spMetadata` | +| POST | `/api/auth/sso/register` | `registerSSOProvider` | +| POST | `/api/auth/sign-in/sso` | `signInSSO` | +| GET | `/api/auth/sso/callback/:providerId` | `callbackSSO` | +| GET | `/api/auth/sso/callback` | `callbackSSOShared` | +| GET / POST | `/api/auth/sso/saml2/callback/:providerId` | `callbackSSOSAML` | +| POST | `/api/auth/sso/saml2/sp/acs/:providerId` | `acsEndpoint` | +| GET / POST | `/api/auth/sso/saml2/sp/slo/:providerId` | `sloEndpoint` | +| POST | `/api/auth/sso/saml2/logout/:providerId` | `initiateSLO` | +| GET | `/api/auth/sso/providers` | `listSSOProviders` | +| GET | `/api/auth/sso/get-provider` | `getSSOProvider` | +| POST | `/api/auth/sso/update-provider` | `updateSSOProvider` | +| POST | `/api/auth/sso/delete-provider` | `deleteSSOProvider` | +| POST | `/api/auth/api-key/create` | `createApiKey` | +| GET | `/api/auth/api-key/get` | `getApiKey` | +| POST | `/api/auth/api-key/update` | `updateApiKey` | +| POST | `/api/auth/api-key/delete` | `deleteApiKey` | +| GET | `/api/auth/api-key/list` | `listApiKeys` | +| GET | `/api/auth/oauth2/authorize` | `oauth2Authorize` | +| POST | `/api/auth/oauth2/token` | `oauth2Token` | +| POST | `/api/auth/oauth2/consent` | `oauth2Consent` | +| POST | `/api/auth/oauth2/continue` | `oauth2Continue` | +| GET / POST | `/api/auth/oauth2/userinfo` | `oauth2UserInfo` | +| POST | `/api/auth/oauth2/introspect` | `oauth2Introspect` | +| POST | `/api/auth/oauth2/revoke` | `oauth2Revoke` | +| GET / POST | `/api/auth/oauth2/end-session` | `oauth2EndSession` | +| GET / POST | `/api/auth/oauth2/end-session/confirm` | `oauth2EndSessionConfirmation` | +| GET | `/api/auth/.well-known/openid-configuration` | `getOpenIdConfig` | +| GET | `/api/auth/.well-known/oauth-authorization-server` | `getOAuthServerConfig` | +| GET | `/api/auth/jwks` | `getJwks` | +| GET | `/api/auth/ok` | `ok` | +| GET | `/api/auth/error` | `error` | + +## tRPC and REST bridge inventory + +The access values use these meanings. + +| Access value | Meaning | +| --- | --- | +| `public` | No session or API key | +| `session-only` | Browser session required | +| `session-or-api-key` | Browser session, valid `x-api-key`, or OAuth access token | + +The input and output names refer to Zod schemas in router contract modules. + + +### `activities` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `activities.timeline` | query | `GET /rest/activities` | `timelineInput` | `timelineOutput` | session-or-api-key | +| `activities.timelineCounts` | query | `GET /rest/activities/counts` | `timelineCountsInput` | `timelineCountsOutput` | session-or-api-key | +| `activities.myTasks` | query | `GET /rest/activities/my-tasks` | `myTasksInput` | `myTasksOutput` | session-or-api-key | +| `activities.create` | mutation | `POST /rest/activities` | `activityCreateInput` | `activityCreateOutput` | session-or-api-key | +| `activities.complete` | mutation | `PATCH /rest/activities/{id}/complete` | `completeInput` | `completeOutput` | session-or-api-key | + +### `agents` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `agents.list` | query | `GET /rest/agents` | `none` | `agentListOutput` | session-or-api-key | +| `agents.revise` | mutation | `POST /rest/agents/{id}/revise` | `agentReviseInput` | `agentReviseOutput` | session-or-api-key | +| `agents.files` | query | `GET /rest/agents/{id}/files` | `agentIdInput` | `agentFilesOutput` | session-or-api-key | +| `agents.saveFile` | mutation | `POST /rest/agents/{id}/save-file` | `agentSaveFileInput` | `agentSaveFileOutput` | session-or-api-key | +| `agents.byId` | query | `GET /rest/agents/{id}` | `agentIdInput` | `agentByIdOutput` | session-or-api-key | +| `agents.history` | query | `GET /rest/agents/{id}/history` | `agentHistoryInput` | `agentHistoryOutput` | session-or-api-key | +| `agents.activity` | query | `GET /rest/agents/{id}/activity` | `agentHistoryInput` | `agentActivityOutput` | session-or-api-key | +| `agents.update` | mutation | `PATCH /rest/agents/{id}` | `agentUpdateInput` | `agentUpdateOutput` | session-or-api-key | +| `agents.deploy` | mutation | `POST /rest/agents/{id}/deploy` | `agentDeployInput` | `agentDeployOutput` | session-or-api-key | +| `agents.pause` | mutation | `POST /rest/agents/{id}/pause` | `agentIdInput` | `agentPauseOutput` | session-or-api-key | +| `agents.resume` | mutation | `POST /rest/agents/{id}/resume` | `agentIdInput` | `agentResumeOutput` | session-or-api-key | +| `agents.archive` | mutation | `POST /rest/agents/{id}/archive` | `agentIdInput` | `agentArchiveOutput` | session-or-api-key | +| `agents.restore` | mutation | `POST /rest/agents/{id}/restore` | `agentIdInput` | `agentRestoreOutput` | session-or-api-key | +| `agents.remove` | mutation | `DELETE /rest/agents/{id}` | `agentIdInput` | `agentRemoveOutput` | session-or-api-key | +| `agents.runNow` | mutation | `POST /rest/agents/{id}/run` | `agentRunNowInput` | `agentRunNowOutput` | session-or-api-key | +| `agents.retryRun` | mutation | `POST /rest/agents/{id}/runs/{runId}/retry` | `agentRetryRunInput` | `agentRetryRunOutput` | session-or-api-key | +| `agents.cancelRun` | mutation | `POST /rest/agents/{id}/runs/{runId}/cancel` | `agentCancelRunInput` | `agentCancelRunOutput` | session-or-api-key | + +### `apiKeys` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `apiKeys.list` | query | `GET /rest/api-keys` | `apiKeyListInput` | `apiKeyListOutput` | session-only | +| `apiKeys.create` | mutation | `POST /rest/api-keys` | `createApiKeyInput` | `createApiKeyOutput` | session-only | +| `apiKeys.revoke` | mutation | `DELETE /rest/api-keys/{id}` | `revokeApiKeyInput` | `revokeApiKeyOutput` | session-only | + +### `companies` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `companies.list` | query | `POST /rest/companies/search` | `companyListInput` | `companyListOutput` | session-or-api-key | +| `companies.byId` | query | `GET /rest/companies/{id}` | `companyIdInput` | `companyDetailOutput` | session-or-api-key | +| `companies.options` | query | `GET /rest/companies/options` | `companyOptionsInput` | `companyOptionOutput` | session-or-api-key | +| `companies.create` | mutation | `POST /rest/companies` | `companyCreateInput` | `companySummaryOutput` | session-or-api-key | +| `companies.update` | mutation | `PATCH /rest/companies/{id}` | `companyUpdateArgs` | `companySummaryOutput` | session-or-api-key | +| `companies.archive` | mutation | `POST /rest/companies/{id}/archive` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.restore` | mutation | `POST /rest/companies/{id}/restore` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.purge` | mutation | `DELETE /rest/companies/{id}` | `companyIdInput` | `companyArchiveResultOutput` | session-or-api-key | +| `companies.bulkAssignOwner` | mutation | `POST /rest/companies/bulk-assign-owner` | `companyBulkOwnerInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkEnrich` | mutation | `POST /rest/companies/bulk-enrich` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkArchive` | mutation | `POST /rest/companies/bulk-archive` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkRestore` | mutation | `POST /rest/companies/bulk-restore` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.bulkPurge` | mutation | `POST /rest/companies/bulk-purge` | `companyBulkInput` | `companyBulkResultOutput` | session-or-api-key | +| `companies.enrich` | mutation | `POST /rest/companies/{id}/enrich` | `companyIdInput` | `companyEnrichOutput` | session-or-api-key | +| `companies.research` | mutation | `POST /rest/companies/{id}/research` | `companyIdInput` | `companyResearchOutput` | session-or-api-key | +| `companies.setPrimaryContact` | mutation | `POST /rest/companies/{companyId}/set-primary-contact` | `setPrimaryContactInput` | `companySetPrimaryContactOutput` | session-or-api-key | + +### `contacts` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `contacts.list` | query | `POST /rest/contacts/search` | `contactListInput` | `contactListOutput` | session-or-api-key | +| `contacts.byId` | query | `GET /rest/contacts/{id}` | `contactIdInput` | `contactByIdOutput` | session-or-api-key | +| `contacts.create` | mutation | `POST /rest/contacts` | `contactCreateInput` | `contactBasicOutput` | session-or-api-key | +| `contacts.update` | mutation | `PATCH /rest/contacts/{id}` | `contactUpdateArgs` | `contactBasicOutput` | session-or-api-key | +| `contacts.archive` | mutation | `POST /rest/contacts/{id}/archive` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.restore` | mutation | `POST /rest/contacts/{id}/restore` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.purge` | mutation | `DELETE /rest/contacts/{id}` | `contactIdInput` | `contactNameOutput` | session-or-api-key | +| `contacts.enrich` | mutation | `POST /rest/contacts/{id}/enrich` | `contactIdInput` | `contactEnrichOutput` | session-or-api-key | +| `contacts.bulkAssignOwner` | mutation | `POST /rest/contacts/bulk-assign-owner` | `contactBulkOwnerInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkSetCompany` | mutation | `POST /rest/contacts/bulk-set-company` | `contactBulkCompanyInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkEnrich` | mutation | `POST /rest/contacts/bulk-enrich` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkArchive` | mutation | `POST /rest/contacts/bulk-archive` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkRestore` | mutation | `POST /rest/contacts/bulk-restore` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.bulkPurge` | mutation | `POST /rest/contacts/bulk-purge` | `contactBulkInput` | `bulkResultOutput` | session-or-api-key | +| `contacts.decideFact` | mutation | `POST /rest/contacts/decide-fact` | `factDecisionInput` | `decideFactOutput` | session-or-api-key | + +### `conversations` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `conversations.list` | query | `GET /rest/conversations` | `conversationListInput` | `conversationListOutput` | session-or-api-key | +| `conversations.builderList` | query | `GET /rest/conversations/builder` | `none` | `builderListOutput` | session-or-api-key | +| `conversations.builderResources` | query | `GET /rest/conversations/builder-resources` | `builderResourceSearchInput` | `builderResourcesOutput` | session-or-api-key | +| `conversations.builderById` | query | `GET /rest/conversations/builder/{id}` | `conversationIdInput` | `builderConversationDetailOutput` | session-or-api-key | +| `conversations.events` | query | `GET /rest/conversations/{id}/events` | `conversationEventsInput` | `conversationEventsOutput` | session-or-api-key | +| `conversations.save` | mutation | `POST /rest/conversations` | `conversationSaveInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.createBuilder` | mutation | `POST /rest/conversations/builder` | `builderConversationCreateInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.submitBuilder` | mutation | `POST /rest/conversations/{id}/submit-builder` | `builderConversationSubmitInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.answerBuilderQuestion` | mutation | `POST /rest/conversations/{id}/answer-builder-question` | `builderQuestionResponseInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.rateBuilderResponse` | mutation | `POST /rest/conversations/{id}/rate-builder-response` | `builderResponseRatingInput` | `builderResponseRatingOutput` | session-or-api-key | +| `conversations.markRead` | mutation | `PATCH /rest/conversations/{id}/read` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.shareStatus` | query | `GET /rest/conversations/{id}/share` | `conversationIdInput` | `conversationShareStatusOutput` | session-or-api-key | +| `conversations.createShare` | mutation | `POST /rest/conversations/{id}/share` | `conversationIdInput` | `conversationShareTokenOutput` | session-or-api-key | +| `conversations.revokeShare` | mutation | `DELETE /rest/conversations/{id}/share` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | +| `conversations.shared` | query | `GET /rest/conversations/shared/{token}` | `sharedConversationInput` | `sharedConversationOutput` | session-or-api-key | +| `conversations.remove` | mutation | `DELETE /rest/conversations/{id}` | `conversationIdInput` | `conversationIdOutput` | session-or-api-key | + +### `currency` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `currency.settings` | query | `GET /rest/currency/settings` | `none` | `currencySettingsOutput` | session-or-api-key | +| `currency.setReportingCurrency` | mutation | `PATCH /rest/currency/reporting-currency` | `setReportingCurrencyInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.setManualRate` | mutation | `PUT /rest/currency/rates/{currency}` | `setManualRateInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.removeManualRate` | mutation | `DELETE /rest/currency/rates/{currency}` | `removeManualRateInput` | `currencySettingsOutput` | session-or-api-key | +| `currency.refreshRates` | mutation | `POST /rest/currency/rates/refresh` | `none` | `currencySettingsOutput` | session-or-api-key | + +### `dashboard` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `dashboard.summary` | query | `GET /rest/dashboard/summary` | `dashboardSummaryInput` | `dashboardSummaryOutput` | session-or-api-key | + +### `deals` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `deals.list` | query | `POST /rest/deals/search` | `dealListInput` | `dealListOutput` | session-or-api-key | +| `deals.byId` | query | `GET /rest/deals/{id}` | `dealIdInput` | `dealDetailOutput` | session-or-api-key | +| `deals.create` | mutation | `POST /rest/deals` | `dealCreateInput` | `dealCreateOutput` | session-or-api-key | +| `deals.update` | mutation | `PATCH /rest/deals/{id}` | `dealUpdateArgs` | `dealMutateOutput` | session-or-api-key | +| `deals.archive` | mutation | `POST /rest/deals/{id}/archive` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.restore` | mutation | `POST /rest/deals/{id}/restore` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.purge` | mutation | `DELETE /rest/deals/{id}` | `dealIdInput` | `dealMutateOutput` | session-or-api-key | +| `deals.setStage` | mutation | `PATCH /rest/deals/{id}/stage` | `setStageInput` | `dealSetStageOutput` | session-or-api-key | +| `deals.contactOptions` | query | `GET /rest/deals/{dealId}/contact-options` | `dealContactsInput` | `dealContactOptionsOutput` | session-or-api-key | +| `deals.attachContact` | mutation | `POST /rest/deals/{dealId}/contacts` | `dealAttachContactInput` | `dealContactLinkOutput` | session-or-api-key | +| `deals.detachContact` | mutation | `DELETE /rest/deals/{dealId}/contacts/{contactId}` | `dealDetachContactInput` | `dealContactLinkOutput` | session-or-api-key | +| `deals.setContactRole` | mutation | `PATCH /rest/deals/{dealId}/contacts/{contactId}/role` | `dealContactRoleInput` | `dealContactRoleOutput` | session-or-api-key | +| `deals.bulkAssignOwner` | mutation | `POST /rest/deals/bulk-assign-owner` | `dealBulkOwnerInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkSetStage` | mutation | `POST /rest/deals/bulk-set-stage` | `dealBulkStageInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkArchive` | mutation | `POST /rest/deals/bulk-archive` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkRestore` | mutation | `POST /rest/deals/bulk-restore` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | +| `deals.bulkPurge` | mutation | `POST /rest/deals/bulk-purge` | `dealBulkInput` | `dealBulkResultOutput` | session-or-api-key | + +### `enrichment` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `enrichment.queue` | query | `GET /rest/enrichment/queue` | `enrichmentQueueInput` | `enrichmentQueueOutput` | session-or-api-key | + +### `fields` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `fields.list` | query | `GET /rest/fields` | `fieldListInput` | `fieldListOutput` | session-or-api-key | +| `fields.byKey` | query | `GET /rest/fields/{entity}/{key}` | `fieldByKeyInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.filters` | query | `GET /rest/fields/{entity}/filterable` | `fieldEntityInput` | `fieldFiltersOutput` | session-or-api-key | +| `fields.coverage` | query | `GET /rest/fields/{id}/coverage` | `fieldIdInput` | `fieldCoverageOutput` | session-or-api-key | +| `fields.create` | mutation | `POST /rest/fields` | `fieldCreateInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.update` | mutation | `PATCH /rest/fields/{id}` | `fieldUpdateArgs` | `serializedFieldOutput` | session-or-api-key | +| `fields.reorder` | mutation | `POST /rest/fields/reorder` | `fieldReorderInput` | `fieldReorderOutput` | session-or-api-key | +| `fields.archive` | mutation | `POST /rest/fields/{id}/archive` | `fieldIdInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.restore` | mutation | `POST /rest/fields/{id}/restore` | `fieldIdInput` | `serializedFieldOutput` | session-or-api-key | +| `fields.delete` | mutation | `DELETE /rest/fields/{id}` | `fieldIdInput` | `fieldDeleteOutput` | session-or-api-key | +| `fields.backfill` | mutation | `POST /rest/fields/{id}/backfill` | `fieldIdInput` | `fieldBackfillOutput` | session-or-api-key | + +### `google` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `google.status` | query | `GET /rest/google/status` | `none` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.purgeSyncedData` | mutation | `POST /rest/google/purge-synced-data` | `none` | `purgeSyncedDataOutput` | session-or-api-key | +| `google.revokeAccess` | mutation | `POST /rest/google/revoke` | `none` | `revokeAccessOutput` | session-or-api-key | +| `google.syncNow` | mutation | `POST /rest/google/sync` | `none` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.setAutoCreate` | mutation | `PATCH /rest/google/auto-create` | `setAutoCreateInput` | `googleConnectionStatusOutput` | session-or-api-key | +| `google.suppressDomain` | mutation | `POST /rest/google/suppress-domain` | `suppressDomainInput` | `suppressDomainOutput` | session-or-api-key | +| `google.thread` | query | `GET /rest/google/threads/{threadId}` | `threadInput` | `emailThreadOutput` | session-or-api-key | +| `google.event` | query | `GET /rest/google/events/{eventId}` | `calendarEventInput` | `calendarEventOutput` | session-or-api-key | + +### `microsoft` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `microsoft.status` | query | `GET /rest/microsoft/status` | `none` | `microsoftConnectionStatusOutput` | session-or-api-key | +| `microsoft.purgeSyncedData` | mutation | `POST /rest/microsoft/purge-synced-data` | `none` | `purgeSyncedDataOutput` | session-or-api-key | +| `microsoft.revokeAccess` | mutation | `POST /rest/microsoft/revoke` | `none` | `revokeAccessOutput` | session-or-api-key | +| `microsoft.syncNow` | mutation | `POST /rest/microsoft/sync` | `none` | `microsoftConnectionStatusOutput` | session-or-api-key | +| `microsoft.setAutoCreate` | mutation | `PATCH /rest/microsoft/auto-create` | `setOutlookAutoCreateInput` | `microsoftConnectionStatusOutput` | session-or-api-key | + +### `savedViews` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `savedViews.list` | query | `GET /rest/saved-views` | `savedViewListInput` | `savedViewListOutput` | session-or-api-key | +| `savedViews.create` | mutation | `POST /rest/saved-views` | `savedViewCreateInput` | `savedViewOutput` | session-or-api-key | +| `savedViews.update` | mutation | `PATCH /rest/saved-views/{id}` | `savedViewUpdateArgs` | `savedViewOutput` | session-or-api-key | +| `savedViews.delete` | mutation | `DELETE /rest/saved-views/{id}` | `savedViewIdInput` | `savedViewDeleteOutput` | session-or-api-key | + +### `search` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `search.quick` | query | `GET /rest/search` | `quickInput` | `quickOutput` | session-or-api-key | + +### `settings` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `settings.agentModel` | query | `GET /rest/settings/agent-model` | `none` | `agentModelOutput` | session-or-api-key | +| `settings.modelCatalog` | query | `GET /rest/settings/model-catalog` | `none` | `modelCatalogOutput` | session-or-api-key | +| `settings.setAgentModel` | mutation | `PATCH /rest/settings/agent-model` | `setAgentModelInput` | `agentModelOutput` | session-or-api-key | +| `settings.researchKey` | query | `GET /rest/settings/research-key` | `none` | `researchKeyOutput` | session-or-api-key | +| `settings.setResearchKey` | mutation | `PATCH /rest/settings/research-key` | `setResearchKeyInput` | `researchKeyOutput` | session-or-api-key | +| `settings.archiveRetention` | query | `GET /rest/settings/archive-retention` | `none` | `archiveRetentionOutput` | session-or-api-key | +| `settings.setArchiveRetention` | mutation | `PATCH /rest/settings/archive-retention` | `setArchiveRetentionDaysInput` | `archiveRetentionOutput` | session-or-api-key | + +### `slack` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `slack.status` | query | `GET /rest/slack/status` | `none` | `slackStatusOutput` | session-or-api-key | +| `slack.matches` | query | `GET /rest/slack/matches` | `none` | `slackMatchesOutput` | session-or-api-key | +| `slack.channels` | query | `GET /rest/slack/channels` | `slackChannelsInput` | `slackChannelsOutput` | session-or-api-key | +| `slack.joinChannel` | mutation | `POST /rest/slack/channels/{channelId}/join` | `slackJoinChannelInput` | `slackJoinChannelOutput` | session-or-api-key | +| `slack.refreshPeople` | mutation | `POST /rest/slack/people/refresh` | `none` | `slackRefreshPeopleOutput` | session-or-api-key | +| `slack.createChannel` | mutation | `POST /rest/slack/channels` | `slackCreateChannelInput` | `slackCreateChannelOutput` | session-or-api-key | +| `slack.disconnect` | mutation | `DELETE /rest/slack/connection` | `none` | `slackDisconnectOutput` | session-or-api-key | + +### `sso` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `sso.signInOptions` | query | `GET /rest/sso/sign-in-options` | `none` | `ssoSignInOptionsOutput` | public | +| `sso.settings` | query | `GET /rest/sso/settings` | `none` | `ssoSettingsOutput` | session-or-api-key | +| `sso.list` | query | `GET /rest/sso` | `ssoProviderListInput` | `ssoProviderListOutput` | session-or-api-key | +| `sso.register` | mutation | `POST /rest/sso` | `registerSsoProviderInput` | `ssoProviderOutput` | session-or-api-key | +| `sso.remove` | mutation | `DELETE /rest/sso/{providerId}` | `deleteSsoProviderInput` | `deleteSsoProviderOutput` | session-or-api-key | + +### `tracking` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `tracking.settings` | query | `GET /rest/tracking/settings` | `none` | `trackingSettingsOutput` | session-or-api-key | +| `tracking.setFlag` | mutation | `PATCH /rest/tracking/flags` | `trackingFlagInput` | `z.void()` | session-or-api-key | +| `tracking.setCookieLifetime` | mutation | `PATCH /rest/tracking/cookie-lifetime` | `cookieLifetimeInput` | `z.void()` | session-or-api-key | +| `tracking.addDomain` | mutation | `POST /rest/tracking/domains` | `addDomainInput` | `trackedDomainOutput` | session-or-api-key | +| `tracking.removeDomain` | mutation | `DELETE /rest/tracking/domains/{id}` | `removeDomainInput` | `z.void()` | session-or-api-key | +| `tracking.rotateSiteId` | mutation | `POST /rest/tracking/site-id/rotate` | `none` | `rotateSiteIdOutput` | session-or-api-key | +| `tracking.verify` | mutation | `POST /rest/tracking/verify` | `verifyInput` | `verifyOutput` | session-or-api-key | +| `tracking.sources` | query | `GET /rest/tracking/sources` | `none` | `sourcesOutput` | session-or-api-key | +| `tracking.companyActivity` | query | `GET /rest/tracking/companies/{companyId}/activity` | `companyActivityInput` | `websiteActivityOutput` | session-or-api-key | +| `tracking.contactActivity` | query | `GET /rest/tracking/contacts/{contactId}/activity` | `contactActivityInput` | `websiteActivityOutput` | session-or-api-key | + +### `users` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `users.me` | query | Not exposed over REST | `none` | `inferred` | session-or-api-key | +| `users.list` | query | `GET /rest/users` | `none` | `usersListOutput` | session-or-api-key | + +### `workspace` + +| tRPC procedure | Type | REST bridge | Input schema | Output schema | Access | +| --- | --- | --- | --- | --- | --- | +| `workspace.get` | query | `GET /rest/workspace` | `none` | `workspaceOutput` | session-or-api-key | +| `workspace.members` | query | `POST /rest/workspace/members/search` | `memberListInput` | `memberListOutput` | session-or-api-key | +| `workspace.update` | mutation | `PATCH /rest/workspace` | `updateWorkspaceInput` | `workspaceOutput` | session-or-api-key | +| `workspace.setMemberRole` | mutation | `PATCH /rest/workspace/members/{memberId}/role` | `setMemberRoleInput` | `workspaceMemberOutput` | session-or-api-key | + + +## Evidence, findings, and source paths + +| Evidence | Finding | Source path | +| --- | --- | --- | +| tRPC router AST contains 160 decorated procedures | The application exposes 160 tRPC procedures | `apps/api/src/**/*.router.ts` | +| Generated router contains 160 procedure definitions | Generated client types match the router count | `apps/api/src/generated/server.ts` | +| 159 procedures contain `restMeta` | The REST bridge exposes 159 operations | `apps/api/src/trpc/openapi.ts` | +| Better Auth installs session, OAuth, SSO, and API-key plugins | Better Auth mounts a larger protocol surface | `packages/auth/src/auth.ts` | +| `AuthMiddleware` requires a request principal | Protected tRPC routes reject anonymous access | `apps/api/src/trpc/middlewares/auth.middleware.ts` | +| `SessionOnlyMiddleware` requires a session principal | API-key management requires browser sessions | `apps/api/src/trpc/middlewares/session-only.middleware.ts` | +| Role helpers accept owner and admin | Management permissions share one role boundary | `packages/auth/src/organization.ts` | +| Shared conversation routes use `AuthMiddleware` | Share tokens do not create anonymous access | `apps/api/src/conversations/conversations.router.ts` | +| Native attachment controller requires `@Principal` | Attachments accept every supported user credential | `apps/api/src/conversations/conversation-attachments.controller.ts` | +| Better Auth configures `jwt` and `oauthProvider` | CRM issues OAuth and OIDC tokens | `packages/auth/src/auth.ts` | +| tRPC context resolves one request principal | REST bearer tokens share the authentication boundary | `apps/api/src/trpc/trpc.context.ts` | +| OAuth scope middleware checks procedure type | Queries and mutations require distinct CRM scopes | `apps/api/src/trpc/middlewares/oauth-scope.middleware.ts` | +| OpenAPI declares three credential alternatives | Generated clients receive bearer configuration | `apps/api/src/create-app.ts` | +| Better Auth runtime packages use 1.7.2 | Runtime auth dependencies share one version | `packages/auth/package.json` | + +## Maintenance checklist + +Run tRPC generation after adding or changing a procedure. + +Commit `apps/api/src/generated/server.ts` with router changes. + +Keep `restMeta` on every supported REST bridge procedure. + +Set `protect: false` only for intentionally public procedures. + +Apply `AuthMiddleware` at the router or procedure boundary. + +Add service-level permission checks for restricted writes. + +Keep API-key management behind `SessionOnlyMiddleware`. + +Update this inventory when controllers, routers, or Better Auth plugins change. + +Verify `/openapi.json` after starting the current API process. + +Keep Better Auth runtime packages on one tested version. + +Register OAuth clients administratively and keep dynamic registration disabled. + +Verify issuer, audience, scope, PKCE, refresh rotation, and revocation before mobile release. diff --git a/docs/oauth-oidc-crm-implementation.md b/docs/oauth-oidc-crm-implementation.md new file mode 100644 index 000000000..a3ccb8018 --- /dev/null +++ b/docs/oauth-oidc-crm-implementation.md @@ -0,0 +1,1188 @@ +# CRM OAuth 2.1 and OpenID Connect Changes + +This document specifies CRM changes for secure Flutter access. + +It preserves browser sessions and API keys. + +It adds standards-based OAuth 2.1 and OpenID Connect support. + +The CRM remains a single-tenant system. + +> [!IMPORTANT] +> The CRM implementation in this document is complete. +> The Flutter client work remains a separate application change. + +## Implementation status + +Implemented on `feat/oauth`: + +- Better Auth runtime packages use version 1.7.2. +- Better Auth CLI package `auth` uses version 1.7.2. +- OAuth Provider and JWT plugins issue CompCRM tokens. +- Prisma migration `20260829113915_add_oauth_provider` adds OAuth storage. +- Startup reconciles the official `compcrm-flutter` public client. +- One request-principal resolver accepts cookies, API keys, and bearer tokens. +- tRPC queries require `crm.read` for OAuth callers. +- tRPC mutations require `crm.write` for OAuth callers. +- Native protected controllers use the same principal and scope guard. +- OAuth, OpenID Connect, protected-resource, and JWKS metadata are public. +- OpenAPI describes cookie, API-key, and bearer alternatives. +- The web application preserves signed authorization state through sign-in. +- The web application provides a custom-client consent page. + +## 1. Executive decision + +CompCRM must become an OAuth 2.1 authorization server for first-party mobile clients. + +The server must also expose OpenID Connect discovery and identity tokens. + +The Flutter application must use Authorization Code Flow with PKCE. + +The CRM API must accept short-lived OAuth access tokens. + +The browser application must continue to use Better Auth session cookies. + +Existing integrations must continue to use API keys. + +The three credential types must resolve into one request principal. + +Current role checks must remain the final authorization boundary. + +OAuth scopes must restrict each client before role checks run. + +The implementation must not accept access tokens from upstream identity providers. + +The implementation must not add tenant headers or organization parameters. + +### 1.1 Recommendation summary + +| Decision | Recommendation | +| --- | --- | +| Authorization server | Use the Better Auth OAuth Provider plugin. | +| Identity protocol | Enable OpenID Connect through the `openid` scope. | +| Mobile flow | Use Authorization Code Flow with PKCE and a system browser. | +| Mobile client | Register one public native client for the official Flutter application. | +| Client secret | Do not issue a secret to the Flutter application. | +| Access tokens | Use signed JWT access tokens with a ten-minute lifetime. | +| Refresh tokens | Rotate refresh tokens and expire them after 30 days. | +| Refresh retry | Allow a 30-second reuse interval for lost mobile responses. | +| API authorization | Require OAuth scopes and current CRM roles. | +| Browser access | Keep existing session-cookie behavior. | +| Automation access | Keep existing API-key behavior. | +| Multi-tenancy | Do not add tenant selection or tenant claims. | +| Dynamic registration | Keep dynamic client registration disabled. | +| Token exchange | Do not add token exchange in the first release. | +| Client credentials | Do not add machine OAuth grants in the first release. | +| Proof of possession | Do not add DPoP in the first release. | + +## 2. Starting state + +CompCRM currently uses Better Auth as an authentication client and session manager. + +The API mounts Better Auth routes under `/api/auth/*`. + +The web application authenticates with Better Auth cookies. + +The API also accepts Better Auth API keys. + +The API does not accept OAuth bearer access tokens. + +The API does not publish OAuth authorization-server metadata. + +The database does not contain OAuth client, consent, token, or signing-key tables. + +The OpenAPI documents contain cookie and API-key schemes only. + +The tRPC context loads a Better Auth session from request headers. + +The current authorization middleware requires a session user. + +The current SSO feature connects CompCRM to an external OIDC provider. + +That feature makes CompCRM an OIDC client. + +It does not make CompCRM an authorization server. + +### 2.1 Existing authentication paths + +| Caller | Credential | Authentication path | Current result | +| --- | --- | --- | --- | +| Web browser | Session cookie | Better Auth session lookup | Supported | +| Integration | `x-api-key` | Better Auth API-key session | Supported | +| Flutter application | Bearer access token | No verifier exists | Not supported | +| External OIDC provider | Authorization response | Better Auth SSO | Supported for web sign-in | + +### 2.2 Existing authorization model + +CompCRM uses one fixed workspace. + +`WORKSPACE_ID` identifies this workspace. + +Sign-in adds the user to this workspace. + +Workspace membership uses the existing organization records. + +Owner, administrator, and member roles control CRM actions. + +Service procedures enforce those permissions. + +The OAuth implementation must reuse these checks. + +### 2.3 Verified source findings + +| Evidence | Finding | Implementation path | +| --- | --- | --- | +| `packages/auth/src/auth.ts` | Better Auth has SSO, organization, API-key, and generic OAuth plugins. | Add OAuth Provider and JWT plugins here. | +| `apps/api/src/trpc/trpc.context.ts` | The context only requests a Better Auth session. | Resolve one typed request principal here. | +| `apps/api/src/trpc/middlewares/auth.middleware.ts` | Protected procedures require `ctx.session.user`. | Require `ctx.principal.user` instead. | +| `apps/api/src/trpc/middlewares/session-only.middleware.ts` | Session-only access checks the API-key header. | Check the resolved credential kind instead. | +| `apps/api/src/create-app.ts` | OpenAPI defines cookie and API-key security. | Add an OAuth bearer scheme. | +| `apps/app/proxy.ts` | The proxy gates routes with a session cookie. | Permit authenticated OAuth consent routes. | +| `packages/db/prisma/schema.prisma` | OAuth Provider tables were absent. | The migration adds the generated models. | +| `packages/auth/package.json` | Runtime packages used version 1.6.25. | Runtime packages now use version 1.7.2. | + +## 3. Target architecture + +The CRM owns authorization, token issuance, and token verification. + +An upstream SSO provider still owns optional workforce authentication. + +The Flutter client never handles the upstream provider token directly. + +The Flutter client receives only CompCRM tokens. + +```mermaid +flowchart LR + Flutter[Flutter application] -->|Authorization Code and PKCE| Auth[CompCRM authorization server] + Browser[Web browser] -->|Session cookie| API[CompCRM API] + Integration[Integration] -->|API key| API + Auth -->|Login redirect| SSO[Optional upstream OIDC provider] + SSO -->|CompCRM session| Auth + Auth -->|ID token and access token| Flutter + Flutter -->|Bearer access token| API + API --> Principal[Request principal resolver] + Principal --> Scope[OAuth scope check] + Scope --> Role[Current workspace role check] + Role --> Service[Existing CRM services] +``` + +### 3.1 Trust boundaries + +The Flutter application is a public client. + +It cannot protect a client secret. + +PKCE protects the authorization code. + +The system browser protects the user authentication session. + +The API validates every bearer token locally or through the provider verifier. + +The API never trusts mobile claims without signature validation. + +The database protects refresh tokens, client records, consent records, and signing keys. + +### 3.2 Protocol endpoints + +Better Auth must provide the applicable OAuth and OpenID Connect endpoints. + +The final paths depend on the mounted Better Auth base path. + +Integration tests must verify the public paths before release. + +The public surface must include these capabilities: + +| Capability | Standard endpoint purpose | +| --- | --- | +| Authorization | Starts user authorization and returns a code. | +| Token | Exchanges codes and refresh tokens. | +| User information | Returns identity claims for valid access tokens. | +| Revocation | Revokes supported tokens. | +| Introspection | Reports token state for authorized callers. | +| End session | Ends the related login session. | +| Authorization metadata | Publishes OAuth server capabilities. | +| OpenID metadata | Publishes OIDC issuer and endpoint metadata. | +| JWKS | Publishes public signing keys. | + +The issuer must use the public `API_URL` origin. + +The login and consent pages must use the public `APP_URL` origin. + +Discovery documents must report the exact public endpoints. + +Proxy or load-balancer rewrites must not change the reported issuer. + +## 4. Required CRM changes + +### 4.1 Align authentication dependencies + +Align `better-auth` and every runtime plugin first. + +Use version 1.7.2 for all runtime packages. + +Use CLI version 1.4.22. + +The CLI uses an independent release sequence. + +Commit the package-lock changes with the implementation. + +Run existing authentication tests before schema generation. + +Run them again after dependency alignment. + +Do not combine a version upgrade with unrelated authentication refactoring. + +### 4.2 Add the OAuth Provider plugin + +Add `@better-auth/oauth-provider` to `packages/auth`. + +Add the Better Auth JWT plugin from the aligned release. + +Keep existing SSO, organization, API-key, and generic OAuth plugins. + +Disable the standalone JWT `/token` endpoint. + +Disable JWT headers on normal session responses. + +Only the OAuth Provider must issue API access tokens. + +Create `packages/auth/src/oauth-config.ts` for tunable OAuth values. + +Group every duration and scope in one exported constant. + +Do not place token durations beside individual consumers. + +The configuration should contain these values: + +```ts +const MINUTE_SECONDS = 60; +const DAY_SECONDS = 24 * 60 * MINUTE_SECONDS; + +export const OAUTH = { + accessTokenTtlSeconds: 10 * MINUTE_SECONDS, + authorizationCodeTtlSeconds: 10 * MINUTE_SECONDS, + refreshTokenTtlSeconds: 30 * DAY_SECONDS, + refreshTokenReuseIntervalSeconds: 30, + scopes: { + identity: ["openid", "profile", "email", "offline_access"], + crm: ["crm.read", "crm.write"], + }, + resource: new URL("/api", apiUrl).toString(), +} as const; +``` + +Do not duplicate these values in the API or Flutter application. + +### 4.3 Register the official Flutter client + +Register one public native OAuth client. + +Use a stable identifier such as `compcrm-flutter`. + +Do not assign a client secret. + +Set the token endpoint authentication method to `none`. + +Permit only authorization-code and refresh-token grants. + +Permit only the code response type. + +Require PKCE with `S256`. + +Use exact redirect URIs. + +Reject wildcard redirect URIs. + +Use an application-owned HTTPS link where platform support is complete. + +Use a reverse-domain private scheme only as a controlled fallback. + +Use loopback redirects only for desktop development. + +Enable `skipConsent` only for the bundled first-party client. + +Keep consent for every custom client. + +Do not enable dynamic client registration. + +Create an idempotent client reconciliation command. + +The command must create or update only the official client record. + +The command must reject unsafe redirect URI changes. + +Custom self-hosted clients require an administrator registration command. + +Reconcile the bundled client from the repository root: + +```bash +bun run --filter=@crm/auth oauth:reconcile-client +``` + +Register a custom public native client from the repository root: + +```bash +bun run --filter=@crm/auth oauth:register-client --client-id example-native --name "Example Native" --redirect-uri com.example.app:/oauth/callback --post-logout-redirect-uri com.example.app:/oauth/logout +``` + +Repeat `--redirect-uri` or `--post-logout-redirect-uri` for each exact address. + +The registration command rejects duplicates, fragments, wildcards, and unsafe transport schemes. + +### 4.4 Generate the database schema + +Run the Better Auth CLI against the final plugin configuration. + +Inspect the generated Prisma changes before creating a migration. + +Expected records include OAuth clients, consents, access tokens, and refresh tokens. + +Expected records also include client assertions and signing keys. + +Exact model names depend on the aligned Better Auth release. + +The CLI parser rejects the existing valid Prisma filtered indexes. + +Generate the OAuth schema into an isolated Prisma file. + +Merge the exact generated models into the repository schema. + +Create one reviewed Prisma migration. + +Run the migration against a disposable test database first. + +The test database name must end with `_test`. + +Verify migration rollback behavior through a database snapshot. + +Do not delete OAuth tables during an application rollback. + +### 4.5 Create a unified request principal + +Create a domain type at the authentication boundary. + +Do not pass untyped authentication data through the API. + +The type should represent these fields: + +```ts +type CredentialKind = "session" | "apiKey" | "oauth"; + +type RequestPrincipal = { + credentialKind: CredentialKind; + user: SessionUser; + clientId: string | null; + scopes: ReadonlySet; +}; +``` + +Derive the real type from validated provider output where available. + +Do not trust arbitrary token claim objects. + +Create a request-principal service in `apps/api/src/auth`. + +The service must inspect the request once. + +It must reject requests containing multiple credential types. + +This rule prevents credential confusion attacks. + +The resolver must use this order: + +1. Detect cookie, API-key, and bearer credentials. +2. Reject an ambiguous request with HTTP 400. +3. Verify OAuth bearer credentials with the provider verifier. +4. Resolve session and API-key credentials through Better Auth. +5. Return one typed principal. +6. Return no principal for an anonymous request. + +The bearer verifier must validate these properties: + +| Property | Required validation | +| --- | --- | +| Signature | A current trusted JWKS key signs the token. | +| Issuer | The issuer exactly matches the public CompCRM issuer. | +| Audience | The audience includes the CompCRM API resource. | +| Expiry | The current time precedes `exp`. | +| Activation | The current time follows `nbf`, when present. | +| Subject | The subject maps to a current CompCRM user. | +| Client | The client identifier names an enabled OAuth client. | +| Scope | The token contains every required OAuth scope. | + +Reject malformed bearer values with HTTP 401. + +Reject expired or invalid tokens with HTTP 401. + +Reject insufficient scopes with HTTP 403. + +Return a standards-compatible `WWW-Authenticate` header. + +Never convert a verification failure into an anonymous request silently. + +### 4.6 Update tRPC context and middleware + +Add `principal` to `BaseTrpcContext`. + +Keep `session` temporarily when existing call sites still need it. + +Remove the duplicate session field after migration. + +Change `AuthMiddleware` to require `ctx.principal.user`. + +Build `AuthedTrpcContext` from the typed principal. + +Change session-only checks to inspect `credentialKind`. + +Do not inspect raw credential headers in downstream middleware. + +The API-key management router must accept browser sessions only. + +OAuth tokens must not create, list, or revoke API keys. + +API keys must not manage other API keys. + +### 4.7 Enforce OAuth scopes + +Define the first CRM resource scopes as follows: + +| Scope | Meaning | +| --- | --- | +| `openid` | Request an OpenID Connect identity token. | +| `profile` | Request standard profile claims. | +| `email` | Request standard email claims. | +| `offline_access` | Request refresh-token access. | +| `crm.read` | Read CRM resources. | +| `crm.write` | Create, update, or delete CRM resources. | + +OAuth scopes restrict the client. + +Workspace roles restrict the user. + +Both checks must pass. + +Do not place owner or administrator status in durable access-token claims. + +Role changes must take effect without waiting for token expiry. + +Existing service authorization must load current membership and role data. + +Use `crm.read` for tRPC queries. + +Use `crm.write` for tRPC mutations. + +Apply equivalent policies to native REST controllers. + +Public procedures must remain public. + +Session and API-key behavior must remain unchanged during the first release. + +Add finer scopes only after a real client needs them. + +Avoid entity-specific scopes during the first release. + +### 4.8 Update Nest controller authentication + +The Better Auth Nest integration currently protects controller routes. + +Bearer support must use the same request-principal resolver. + +Create one shared guard or decorator for authenticated controllers. + +Do not create a second authorization policy for controllers. + +Public controllers must use an explicit public marker. + +Protected controllers must require the unified principal. + +Controller scope failures must match tRPC failures. + +### 4.9 Add OAuth security to OpenAPI + +Add an HTTP bearer scheme with JWT format. + +Keep the cookie scheme. + +Keep the API-key scheme. + +Describe protected operations with alternative security requirements. + +The alternatives must mean cookie OR API key OR bearer token. + +They must not mean all three credentials together. + +Add the bearer scheme to the REST bridge document. + +Keep public operations without security requirements. + +Document `crm.read` and `crm.write` for OAuth clients. + +Regenerate any committed client artifacts after the document changes. + +### 4.10 Add login and consent routing + +Reuse the existing `/sign-in` page for user authentication. + +Add an OAuth consent page for custom clients. + +Place the page under the existing landing route group. + +Add the OAuth route prefix to the proxy ungated list. + +Anonymous users must still redirect to `/sign-in`. + +Authenticated users must bypass onboarding gates during authorization. + +The server page must load client, scope, and consent data. + +The client component must render finished plain data. + +The client component must not import `@crm/auth` or `@crm/db`. + +Shared controls must come from `packages/ui`. + +The page must show these values: + +- Application name. +- Requested CRM permissions. +- Signed-in account. +- Approve action. +- Deny action. + +The official client normally skips this page. + +The page remains necessary for custom clients. + +### 4.11 Verify discovery routing + +Better Auth is mounted under `/api/auth/*`. + +OAuth discovery uses standard well-known locations. + +The Nest adapter and proxy must expose the locations correctly. + +Add end-to-end tests for authorization metadata. + +Add end-to-end tests for OpenID configuration. + +Add an explicit Nest route adapter when automatic routing fails. + +Do not ship a discovery document with unreachable endpoints. + +### 4.12 Add bounded authentication logs + +Log the authentication method and result. + +Log a bounded OAuth error code. + +Log the client identifier after validation. + +Log the request identifier and user identifier. + +Never log access tokens. + +Never log refresh tokens. + +Never log authorization codes. + +Never log request headers, bodies, or query strings. + +Do not add product telemetry for this change. + +Use operational logs and security metrics only. + +## 5. Flutter integration contract + +The Flutter application must use a system authentication browser. + +Embedded web views must not handle user authentication. + +Use `flutter_appauth` for discovery, PKCE, authorization, and refresh. + +Use `flutter_secure_storage` for refresh-token storage. + +Keep access tokens in memory when practical. + +Never store a client secret in the application. + +### 5.1 Mobile authorization sequence + +```mermaid +sequenceDiagram + participant App as Flutter application + participant Browser as System browser + participant Auth as CompCRM authorization server + participant API as CompCRM API + + App->>App: Create verifier, challenge, state, and nonce + App->>Browser: Open authorization request + Browser->>Auth: Send authorization request and challenge + Auth->>Browser: Authenticate the user + Auth->>Browser: Approve the trusted client + Auth->>Browser: Redirect with code and state + Browser->>App: Deliver redirect URI + App->>App: Validate state + App->>Auth: Exchange code and verifier + Auth->>App: Return ID, access, and refresh tokens + App->>App: Validate ID token nonce and claims + App->>API: Send bearer access token + API->>App: Return CRM data +``` + +### 5.2 Authorization request + +The client must request these scopes: + +```text +openid profile email offline_access crm.read crm.write +``` + +The client must send these values: + +- Exact registered client identifier. +- Exact registered redirect URI. +- Exact `${API_URL}/api` resource in authorization and token requests. +- Response type `code`. +- PKCE challenge method `S256`. +- Cryptographically random state. +- Cryptographically random nonce. + +The client must validate returned state before code exchange. + +The client must validate the ID token nonce. + +The client must validate issuer, audience, signature, and expiry. + +### 5.3 Token storage + +Store the refresh token in platform secure storage. + +Store the current access token in process memory. + +Store token expiry beside the access token. + +Do not store tokens in shared preferences. + +Do not print tokens during development. + +Do not send tokens to crash reporting systems. + +Delete all tokens after logout or unrecoverable refresh failure. + +Use the strictest available platform storage configuration. + +### 5.4 Refresh behavior + +Refresh shortly before access-token expiry. + +Allow only one refresh operation at a time. + +Queue concurrent API requests behind that operation. + +Replace the stored refresh token after every successful refresh. + +Retry one lost refresh response within the reuse interval. + +Stop retrying after `invalid_grant`. + +Clear local credentials after terminal refresh failure. + +Return the user to sign-in. + +Do not loop refresh attempts. + +### 5.5 API client behavior + +Send the access token in the `Authorization` header. + +Use the `Bearer` scheme. + +Never place tokens in query parameters. + +Use the tRPC client for full CRM feature coverage. + +Use generated REST clients only for the documented REST bridge. + +The OpenAPI schema cannot describe every tRPC procedure. + +Regenerate REST models from the public `/openapi.json` document. + +Prefer the OpenAPI Generator `dart-dio` target for REST clients. + +Keep authentication and retry behavior in one Dio interceptor. + +Do not retry mutations after uncertain transport failures automatically. + +### 5.6 Logout behavior + +Revoke the refresh token when supported. + +Call the end-session endpoint when the user requests full logout. + +Delete local tokens even when remote revocation fails. + +Close the local authenticated application state. + +Do not treat local deletion as server revocation. + +## 6. Security requirements + +### 6.1 Token lifetime and revocation + +Use a ten-minute access-token lifetime. + +Use a 30-day refresh-token lifetime. + +Rotate refresh tokens on every use. + +Use a 30-second refresh reuse interval. + +Revoke the refresh chain after detected reuse outside that interval. + +JWT access tokens remain valid until expiry. + +Session revocation must stop future refresh operations. + +Client disablement must stop authorization and refresh operations. + +Signing-key rotation must preserve active public keys during overlap. + +### 6.2 Redirect security + +Match redirect URIs exactly. + +Require HTTPS for claimed web redirects. + +Allow loopback HTTP only for local native clients. + +Reject fragments in registered redirect URIs. + +Reject wildcard hosts and paths. + +Review private scheme ownership for Android and iOS. + +Use universal links or app links where practical. + +### 6.3 Request security + +Reject multiple credential types. + +Reject bearer tokens on session-only routes. + +Reject missing required resource audiences. + +Reject missing required scopes. + +Apply rate limits to authorization, token, refresh, and revocation endpoints. + +Keep Better Auth database rate limiting enabled. + +Use generic public error messages. + +Log bounded internal reason codes. + +### 6.4 Authorization security + +Never authorize from an ID token. + +Never authorize from email claims alone. + +Map the access-token subject to the current user. + +Load current workspace membership before sensitive actions. + +Load current role state before sensitive actions. + +Keep service-level ownership and permission checks. + +Scopes must never grant a role the user lacks. + +### 6.5 Key protection + +Protect the OAuth signing-key database records. + +Restrict production database access. + +Protect `BETTER_AUTH_SECRET` through the existing secret process. + +Never export private JWKS values to application logs. + +Back up signing keys with the database. + +Define a documented emergency rotation process. + +## 7. Database and deployment plan + +### 7.1 Migration order + +1. Align Better Auth package versions. +2. Add provider and JWT plugin configuration. +3. Generate the Prisma schema. +4. Review every generated model and index. +5. Create the database migration. +6. Run migration tests against a `_test` database. +7. Deploy database changes before API changes. +8. Deploy authorization endpoints and bearer verification. +9. Reconcile the official Flutter client. +10. Release the Flutter application. + +### 7.2 Compatibility deployment + +The first API deployment must retain cookies and API keys. + +The new bearer path must be additive. + +Existing tRPC callers must continue without changes. + +Existing integration keys must continue without changes. + +Existing browser sessions must survive the deployment. + +The Flutter client must launch after discovery tests pass. + +### 7.3 Rollback + +Disable the official OAuth client first. + +Stop new authorization and refresh operations. + +Keep the OAuth database tables. + +Keep signing keys until every issued token expires. + +Roll back the API implementation after token expiry. + +Continue browser and API-key access throughout rollback. + +Do not drop OAuth tables during an emergency rollback. + +## 8. File change map + +| Path | Implemented change | +| --- | --- | +| `packages/auth/package.json` | Align Better Auth versions and add OAuth Provider. | +| `packages/auth/src/auth.ts` | Configure OAuth Provider and JWT plugins. | +| `packages/auth/src/oauth-config.ts` | Define resources, scopes, durations, and client policy. | +| `packages/auth/src/env.ts` | No change. Existing public URLs provide every value. | +| `packages/db/prisma/schema.prisma` | Add generated OAuth Provider and JWKS models. | +| `packages/db/prisma/migrations/_add_oauth_provider` | Add the reviewed database migration. | +| `apps/api/src/auth/request-principal.ts` | Define the validated principal type. | +| `apps/api/src/auth/request-principal.service.ts` | Resolve session, API-key, or OAuth credentials. | +| `apps/api/src/trpc/context.types.ts` | Add the principal to API contexts. | +| `apps/api/src/trpc/trpc.context.ts` | Resolve the principal once per request. | +| `apps/api/src/trpc/middlewares/auth.middleware.ts` | Authenticate through the principal. | +| `apps/api/src/trpc/middlewares/session-only.middleware.ts` | Require the session credential kind. | +| `apps/api/src/trpc/middlewares/oauth-scope.middleware.ts` | Enforce OAuth query and mutation scopes. | +| `apps/api/src/trpc/trpc.module.ts` | Register shared authentication and scope middleware. | +| `apps/api/src/create-app.ts` | Add bearer security to both OpenAPI documents. | +| `apps/api/src/app.module.ts` | Register any required auth services and route adapters. | +| `apps/app/proxy.ts` | Permit authenticated OAuth flow pages. | +| `apps/app/app/(landing)/oauth/consent/page.tsx` | Render the server-owned consent page. | +| `packages/ui` | No change. Existing buttons and spinners render consent actions. | +| `apps/api/test/auth.e2e.spec.ts` | Cover discovery, PKCE issuance, refresh, validation, and compatibility. | +| `apps/api/test/oauth-openapi.e2e.spec.ts` | Cover security schemes and OR alternatives. | +| `.env.example` | No change. OAuth requires no new environment value. | +| `apps/api/src/config/env.validation.ts` | No change. OAuth requires no new environment value. | +| `turbo.json` | No change. OAuth requires no new environment value. | +| `docs/api.md` | Document the final authentication architecture. | +| `docs/environment.md` | Document final environment decisions. | +| `docs/exposed-api.md` | Link the implemented bearer flow and scopes. | + +No new environment value is required for the recommended first-party client. + +Use `API_URL` for issuer and API resource origins. + +Use `APP_URL` for login and consent page origins. + +Add no per-package `.env` file. + +## 9. Verification plan + +### 9.1 Unit tests + +Test request-principal resolution for each credential type. + +Test anonymous requests. + +Test duplicate credential rejection. + +Test malformed bearer headers. + +Test invalid signatures. + +Test incorrect issuers. + +Test incorrect audiences. + +Test expired tokens. + +Test future `nbf` values. + +Test disabled clients. + +Test deleted users. + +Test missing read scopes. + +Test missing write scopes. + +Test standards-compatible authentication errors. + +### 9.2 OAuth end-to-end tests + +Test authorization-server metadata. + +Test OpenID configuration metadata. + +Test JWKS publication. + +Test authorization with PKCE `S256`. + +Test rejection without PKCE. + +Test rejection for a wrong verifier. + +Test rejection for a wrong redirect URI. + +Test authorization-code reuse rejection. + +Test ID token issuer, audience, nonce, and expiry. + +Test read access with `crm.read`. + +Test mutation rejection without `crm.write`. + +Test mutation access with `crm.write`. + +Test refresh rotation. + +Test bounded refresh-response reuse. + +Test refresh reuse detection outside the interval. + +Test token revocation. + +Test end-session behavior. + +Test client disablement. + +### 9.3 Compatibility tests + +Run every existing authentication test. + +Confirm browser session authentication. + +Confirm API-key authentication. + +Confirm session-only API-key management. + +Confirm public sign-in options. + +Confirm upstream SSO sign-in. + +Confirm fixed workspace membership. + +Confirm member, administrator, and owner permissions. + +Confirm public tRPC procedures remain public. + +Confirm native controller protection. + +### 9.4 Documentation tests + +Validate both OpenAPI documents. + +Confirm each security requirement uses OR alternatives. + +Generate the Dart REST client. + +Compile the generated Dart client. + +Compare documented discovery endpoints with live responses. + +Confirm every published endpoint is reachable externally. + +### 9.5 Required commands + +Use repository scripts when available. + +Run these checks from the repository root: + +```bash +bun run test +bun run lint +bun run build +git diff --check +``` + +Run the focused API authentication suite before the full suite. + +Use the repository database commands from `docs/setup.md`. + +Do not run production migrations from a development checkout. + +## 10. Acceptance criteria + +The change is complete only after every criterion passes. + +- The official Flutter client uses Authorization Code Flow with PKCE. +- The Flutter package contains no client secret. +- The authorization server publishes valid OAuth metadata. +- The authorization server publishes valid OIDC metadata. +- The server publishes a valid JWKS document. +- The API accepts valid CompCRM bearer access tokens. +- The API rejects upstream provider tokens. +- The API validates issuer, audience, signature, expiry, and activation. +- OAuth queries require `crm.read`. +- OAuth mutations require `crm.write`. +- Current workspace roles still control every protected action. +- Session-only routes reject OAuth and API-key credentials. +- Requests with multiple credential types fail. +- Browser sessions continue to work. +- Existing API keys continue to work. +- OpenAPI describes cookie, API-key, and bearer alternatives. +- Refresh tokens rotate successfully. +- Logout deletes local tokens and attempts server revocation. +- Logs contain no tokens, codes, headers, bodies, or query strings. +- Existing authentication and SSO tests pass. +- New OAuth end-to-end tests pass. +- The generated Dart REST client compiles. +- `git diff --check` passes. + +## 11. Non-goals + +This release does not add multi-tenancy. + +It does not add organization selection. + +It does not add tenant headers. + +It does not expose upstream identity-provider tokens. + +It does not replace browser session cookies. + +It does not replace existing API keys. + +It does not add client-credentials grants. + +It does not add dynamic client registration. + +It does not add token exchange. + +It does not add DPoP. + +It does not encode current roles in long-lived token claims. + +It does not move domain authorization into the authentication package. + +## 12. Delivery phases + +### 12.1 Phase A: dependency and schema foundation — complete + +Align Better Auth packages. + +Add the provider configuration. + +Generate and migrate the database schema. + +Reconcile the official mobile client at startup. + +### 12.2 Phase B: API bearer support — complete + +Add the request-principal resolver. + +Update tRPC and controller authentication. + +Add OAuth scope enforcement. + +Add OpenAPI bearer alternatives. + +Verify existing clients remain compatible. + +### 12.3 Phase C: authorization user experience — complete + +Verify discovery routing. + +Add the custom-client consent page. + +Add exact first-party redirect URIs. + +Reconcile the official Flutter client. + +### 12.4 Phase D: Flutter release — separate application work + +Implement system-browser sign-in. + +Implement secure token storage. + +Implement serialized refresh behavior. + +Implement logout and revocation. + +Run mobile platform security tests. + +### 12.5 Phase E: controlled enablement — pending deployment + +Enable the official OAuth client. + +Release to internal testers first. + +Monitor authorization and refresh failure rates. + +Expand the release after stable results. + +## 13. Operational metrics + +Track authorization requests by bounded outcome. + +Track token exchanges by bounded outcome. + +Track refresh attempts by bounded outcome. + +Track invalid audience and invalid issuer counts. + +Track insufficient-scope counts. + +Track client disablement events. + +Track signing-key rotation events. + +Do not attach tokens or authorization codes to metrics. + +Alert on repeated refresh reuse detection. + +Alert on sudden invalid-signature increases. + +Alert on sustained token endpoint failures. + +## 14. References + +- [CompCRM exposed API](./exposed-api.md) +- [CompCRM API architecture](./api.md) +- [CompCRM environment rules](./environment.md) +- [CompCRM design rules](./design.md) +- [Better Auth OAuth Provider](https://better-auth.com/docs/plugins/oauth-provider) +- [Better Auth 1.7 upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide) +- [Flutter AppAuth](https://pub.dev/packages/flutter_appauth) +- [Flutter Secure Storage](https://pub.dev/packages/flutter_secure_storage) +- [OpenAPI Generator Dart Dio](https://openapi-generator.tech/docs/generators/dart-dio/) +- [OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252) +- [OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414) +- [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700) +- [Proof Key for Code Exchange](https://www.rfc-editor.org/rfc/rfc7636) + +## 15. Final recommendation + +CompCRM needs server changes before Flutter can use proper OIDC. + +The recommended solution uses Better Auth as the CompCRM authorization server. + +It issues CompCRM tokens through Authorization Code Flow with PKCE. + +It validates those tokens through one typed API principal. + +It combines OAuth scopes with current workspace role checks. + +It preserves browser cookies and integration API keys. + +This design adds mobile authentication without changing CRM tenancy or business authorization. diff --git a/docs/setup.md b/docs/setup.md index 55c5a24af..efad930eb 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -113,6 +113,18 @@ builds, and the pages that touch them fail. Test schema changes locally, where worse: every preview applied its own migrations to the production database, so on 2026-08-07 the live schema ran six migrations ahead of the live code all day. +### Better Auth 1.7 account identities + +Migration `20260830221000_better_auth_account_identity` adds issuer-scoped account identities. +Back up the `account` and `user` tables before production deployment. +The migration preserves provider-scoped identities and checks for collisions. +Microsoft changes its account subject from `sub` to `oid` in Better Auth 1.7. +The migration reads `oid` from each stored Microsoft ID token. +The migration stops when a Microsoft row lacks that trusted mapping. +Repair that row from a verified Entra export before retrying deployment. +Do not infer the mapping from email addresses. +The migration removes account rows whose deleted SSO provider cannot supply an issuer. + ### `migrate deploy` is not proof the schema is right The build follows the deploy with `prisma migrate diff --exit-code` against diff --git a/packages/auth/package.json b/packages/auth/package.json index ea671ffcd..cd1605f15 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -11,19 +11,22 @@ "./workspace": "./src/workspace.ts" }, "scripts": { - "auth:generate": "better-auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y", + "auth:generate": "auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y", "check-types": "tsc --noEmit", + "oauth:reconcile-client": "bun scripts/reconcile-oauth-client.ts", + "oauth:register-client": "bun scripts/register-oauth-client.ts", "lint": "biome check .", "test": "bun test", "clean": "rm -rf .turbo node_modules" }, "dependencies": { - "@better-auth/api-key": "1.6.25", - "@better-auth/sso": "1.6.25", + "@better-auth/api-key": "1.7.2", + "@better-auth/oauth-provider": "1.7.2", + "@better-auth/sso": "1.7.2", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/validation": "workspace:*", - "better-auth": "^1.6.25", + "better-auth": "1.7.2", "zod": "^4.4.3" }, "peerDependencies": { @@ -35,10 +38,10 @@ } }, "devDependencies": { - "@better-auth/cli": "^1.4.22", "@crm/typescript-config": "workspace:*", "@types/node": "^24.10.1", "@types/react": "^19.2.18", + "auth": "1.7.2", "react": "^19.2.8", "typescript": "5.9.2" } diff --git a/packages/auth/scripts/reconcile-oauth-client.ts b/packages/auth/scripts/reconcile-oauth-client.ts new file mode 100644 index 000000000..1a0dd8ebe --- /dev/null +++ b/packages/auth/scripts/reconcile-oauth-client.ts @@ -0,0 +1,7 @@ +import { db } from "@crm/db"; +import { ensureOfficialOAuthClient } from "../src/oauth-client"; + +await ensureOfficialOAuthClient(); +await db.$disconnect(); + +process.stdout.write("Official OAuth client reconciled.\n"); diff --git a/packages/auth/scripts/register-oauth-client.ts b/packages/auth/scripts/register-oauth-client.ts new file mode 100644 index 000000000..b1f8382ec --- /dev/null +++ b/packages/auth/scripts/register-oauth-client.ts @@ -0,0 +1,153 @@ +import { db } from "@crm/db"; +import { z } from "zod"; +import { auth } from "../src/auth"; +import { OAUTH, oauthClientFields } from "../src/oauth-config"; + +function parseOptions(args: string[]) { + const values = new Map(); + const allowed = new Set([ + "--client-id", + "--name", + "--redirect-uri", + "--post-logout-redirect-uri", + ]); + + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key || !allowed.has(key) || !value) throw usageError(); + const entries = values.get(key) ?? []; + entries.push(value); + values.set(key, entries); + } + + return z + .object({ + clientId: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[A-Za-z0-9._~-]+$/), + name: z.string().trim().min(1).max(255), + redirectUris: z.array(redirectUri).min(1), + postLogoutRedirectUris: z.array(redirectUri), + }) + .parse({ + clientId: singleValue(values, "--client-id"), + name: singleValue(values, "--name"), + redirectUris: values.get("--redirect-uri") ?? [], + postLogoutRedirectUris: values.get("--post-logout-redirect-uri") ?? [], + }); +} + +const redirectUri = z.string().superRefine((value, context) => { + let uri: URL; + + try { + uri = new URL(value); + } catch { + context.addIssue({ code: "custom", message: "Redirect URI is invalid." }); + return; + } + + if (uri.hash) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains a fragment.", + }); + } + + if (value.includes("*")) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains a wildcard.", + }); + } + + if (uri.username || uri.password) { + context.addIssue({ + code: "custom", + message: "Redirect URI contains user information.", + }); + } + + if (uri.protocol === "https:" && uri.hostname) return; + + if ( + uri.protocol === "http:" && + ["127.0.0.1", "[::1]", "localhost"].includes(uri.hostname) + ) { + return; + } + + if (uri.protocol.slice(0, -1).includes(".") && !uri.host) return; + + context.addIssue({ + code: "custom", + message: + "Redirect URI must use HTTPS, loopback HTTP, or a reverse-domain private scheme.", + }); +}); + +function singleValue(values: Map, key: string) { + const entries = values.get(key); + if (entries?.length !== 1) throw usageError(); + return entries[0]; +} + +function usageError() { + return new Error( + "Usage: bun run oauth:register-client --client-id --name --redirect-uri [--redirect-uri ] [--post-logout-redirect-uri ]", + ); +} + +const options = parseOptions(process.argv.slice(2)); + +await auth.$context; + +if (options.clientId === OAUTH.officialClient.id) { + throw new Error( + "The official OAuth client is managed by the reconciliation command.", + ); +} + +const existing = await db.oauthClient.findUnique({ + where: { clientId: options.clientId }, + select: { clientId: true }, +}); + +if (existing) + throw new Error(`OAuth client ${options.clientId} already exists.`); + +const now = new Date(); + +await db.$transaction(async (transaction) => { + await transaction.oauthClient.create({ + data: { + id: options.clientId, + ...oauthClientFields({ + clientId: options.clientId, + name: options.name, + redirectUris: options.redirectUris, + postLogoutRedirectUris: options.postLogoutRedirectUris, + skipConsent: false, + }), + createdAt: now, + updatedAt: now, + }, + }); + + await transaction.oauthClientResource.create({ + data: { + id: `oauth-client-resource:${options.clientId}`, + clientId: options.clientId, + resourceId: OAUTH.resource, + createdAt: now, + }, + }); +}); + +await db.$disconnect(); + +process.stdout.write(`Registered OAuth client ${options.clientId}.\n`); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index c59c98254..cc88c77fc 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,4 +1,5 @@ import { apiKey } from "@better-auth/api-key"; +import { oauthProvider } from "@better-auth/oauth-provider"; import { sso } from "@better-auth/sso"; import { db } from "@crm/db"; import { schemas } from "@crm/validation"; @@ -6,10 +7,12 @@ import { type BetterAuthOptions, betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; +import { jwt } from "better-auth/plugins/jwt"; import { organization } from "better-auth/plugins/organization"; import { API_KEY_EXPIRATION, API_KEY_HEADER, API_KEY_PREFIX } from "./api-keys"; import { AUTH_COOKIE_PREFIX } from "./cookies"; import { env } from "./env"; +import { OAUTH, OAUTH_SCOPES } from "./oauth-config"; import { ensureWorkspaceMembership } from "./organization"; import { GOOGLE_PROVIDER_ID, @@ -32,7 +35,7 @@ import { const socialProviders: NonNullable = {}; const slackOAuth = env.slack; const slackRedirectUri = new URL( - "/api/auth/oauth2/callback/slack", + "/api/auth/callback/slack", env.apiUrl, ).toString(); @@ -72,6 +75,7 @@ if (env.microsoft) { export const auth = betterAuth({ appName: "CRM", baseURL: env.apiUrl, + disabledPaths: ["/token"], database: prismaAdapter(db, { provider: "postgresql", @@ -122,6 +126,43 @@ export const auth = betterAuth({ }, plugins: [ + jwt({ + disableSettingJwtHeader: true, + jwt: { + issuer: OAUTH.issuer, + audience: OAUTH.resource, + expirationTime: `${OAUTH.accessTokenTtlSeconds}s`, + }, + }), + oauthProvider({ + loginPage: OAUTH.loginPage, + consentPage: OAUTH.consentPage, + scopes: [...OAUTH_SCOPES], + resources: [ + { + identifier: OAUTH.resource, + name: "CompCRM API", + accessTokenTtl: OAUTH.accessTokenTtlSeconds, + refreshTokenTtl: OAUTH.refreshTokenTtlSeconds, + allowedScopes: [...OAUTH_SCOPES], + }, + ], + resourceSeedMode: "overwrite", + cachedResources: new Set([OAUTH.resource]), + enforcePerClientResources: true, + clientRegistrationDefaultResources: [OAUTH.resource], + cachedTrustedClients: new Set([OAUTH.officialClient.id]), + accessTokenExpiresIn: OAUTH.accessTokenTtlSeconds, + idTokenExpiresIn: OAUTH.idTokenTtlSeconds, + refreshTokenExpiresIn: OAUTH.refreshTokenTtlSeconds, + refreshTokenReuseInterval: OAUTH.refreshTokenReuseIntervalSeconds, + codeExpiresIn: OAUTH.authorizationCodeTtlSeconds, + grantTypes: ["authorization_code", "refresh_token"], + allowDynamicClientRegistration: false, + allowUnauthenticatedClientRegistration: false, + clientPrivileges: () => false, + resourcePrivileges: () => false, + }), ...(slackOAuth ? [ genericOAuth({ diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 980a37b67..29f3ccc10 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,11 +1,11 @@ import { apiKeyClient } from "@better-auth/api-key/client"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { ssoClient } from "@better-auth/sso/client"; -import { genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: globalThis.window?.location.origin, - plugins: [ssoClient(), genericOAuthClient(), apiKeyClient()], + plugins: [ssoClient(), apiKeyClient(), oauthProviderClient()], }); export const { getSession, signIn, signOut, useSession } = authClient; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 31fbcbaad..ba86066b4 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -13,6 +13,23 @@ export { isMicrosoftConfigured, isSlackConfigured, } from "./env"; +export { ensureOfficialOAuthClient } from "./oauth-client"; +export { + isOAuthScope, + OAUTH, + OAUTH_SCOPES, + type OAuthScope, + oauthClientFields, +} from "./oauth-config"; +export { + getProtectedResourceMetadata, + verifyAccessTokenRequest, +} from "./oauth-resource"; +export { + bearerChallenge, + oauthScopeFailure, + requiredCrmScope, +} from "./oauth-scope"; export { canChangeRole, canManageConnections, diff --git a/packages/auth/src/oauth-client.ts b/packages/auth/src/oauth-client.ts new file mode 100644 index 000000000..4fc16183e --- /dev/null +++ b/packages/auth/src/oauth-client.ts @@ -0,0 +1,48 @@ +import { db } from "@crm/db"; +import { auth } from "./auth"; +import { OAUTH, oauthClientFields } from "./oauth-config"; + +const OFFICIAL_CLIENT_RESOURCE_ID = "compcrm-flutter-resource"; + +export async function ensureOfficialOAuthClient(): Promise { + await auth.$context; + const now = new Date(); + const clientId = OAUTH.officialClient.id; + const clientFields = oauthClientFields({ + clientId, + name: OAUTH.officialClient.name, + redirectUris: OAUTH.officialClient.redirectUris, + postLogoutRedirectUris: OAUTH.officialClient.postLogoutRedirectUris, + skipConsent: true, + }); + + await db.$transaction(async (transaction) => { + await transaction.oauthClient.upsert({ + where: { clientId }, + create: { + id: clientId, + ...clientFields, + createdAt: now, + updatedAt: now, + }, + update: { + ...clientFields, + updatedAt: now, + }, + }); + + await transaction.oauthClientResource.upsert({ + where: { id: OFFICIAL_CLIENT_RESOURCE_ID }, + create: { + id: OFFICIAL_CLIENT_RESOURCE_ID, + clientId, + resourceId: OAUTH.resource, + createdAt: now, + }, + update: { + clientId, + resourceId: OAUTH.resource, + }, + }); + }); +} diff --git a/packages/auth/src/oauth-config.ts b/packages/auth/src/oauth-config.ts new file mode 100644 index 000000000..157fcd8d1 --- /dev/null +++ b/packages/auth/src/oauth-config.ts @@ -0,0 +1,69 @@ +import { DAY_SECONDS } from "./api-keys"; +import { apiUrl, appUrl } from "./env"; + +const MINUTE_SECONDS = 60; + +export const OAUTH = { + issuer: new URL("/api/auth", apiUrl).toString(), + resource: new URL("/api", apiUrl).toString(), + loginPage: new URL("/sign-in", appUrl).toString(), + consentPage: new URL("/oauth/consent", appUrl).toString(), + accessTokenTtlSeconds: 10 * MINUTE_SECONDS, + idTokenTtlSeconds: 10 * MINUTE_SECONDS, + authorizationCodeTtlSeconds: 10 * MINUTE_SECONDS, + refreshTokenTtlSeconds: 30 * DAY_SECONDS, + refreshTokenReuseIntervalSeconds: 30, + scopes: { + identity: ["openid", "profile", "email", "offline_access"], + crm: { + read: "crm.read", + write: "crm.write", + }, + }, + officialClient: { + id: "compcrm-flutter", + name: "CompCRM for Flutter", + redirectUris: ["ai.trycrm.app:/oauth/callback"], + postLogoutRedirectUris: ["ai.trycrm.app:/oauth/logout"], + }, +} as const; + +export const OAUTH_SCOPES = [ + ...OAUTH.scopes.identity, + OAUTH.scopes.crm.read, + OAUTH.scopes.crm.write, +] as const; + +export type OAuthScope = (typeof OAUTH_SCOPES)[number]; + +export function isOAuthScope(value: string): value is OAuthScope { + return (OAUTH_SCOPES as readonly string[]).includes(value); +} + +export function oauthClientFields(input: { + clientId: string; + name: string; + redirectUris: readonly string[]; + postLogoutRedirectUris: readonly string[]; + skipConsent: boolean; +}) { + return { + clientId: input.clientId, + clientSecret: null, + disabled: false, + skipConsent: input.skipConsent, + enableEndSession: true, + scopes: [...OAUTH_SCOPES], + clientCredentialsScopes: [], + name: input.name, + contacts: [], + redirectUris: [...input.redirectUris], + postLogoutRedirectUris: [...input.postLogoutRedirectUris], + tokenEndpointAuthMethod: "none", + applicationType: "native", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + requirePKCE: true, + dpopBoundAccessTokens: false, + }; +} diff --git a/packages/auth/src/oauth-resource.ts b/packages/auth/src/oauth-resource.ts new file mode 100644 index 000000000..3611b76d6 --- /dev/null +++ b/packages/auth/src/oauth-resource.ts @@ -0,0 +1,45 @@ +import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; +import { APIError } from "better-auth/api"; +import type { ResourceRequestInput } from "better-auth/oauth2"; +import { verifyJwsAccessToken } from "better-auth/oauth2"; +import { auth } from "./auth"; +import { OAUTH } from "./oauth-config"; + +const oauthResource = oauthProviderResourceClient(auth).getActions(); +const jwksCacheKey = {}; + +type VerifyAccessTokenRequestOptions = { + verifyOptions?: { + issuer?: string | string[]; + audience?: string | string[]; + }; +}; + +export const getProtectedResourceMetadata = + oauthResource.getProtectedResourceMetadata; + +export async function verifyAccessTokenRequest( + request: Request | ResourceRequestInput, + opts?: VerifyAccessTokenRequestOptions, +) { + const authorization = + request instanceof Request + ? request.headers.get("authorization") + : request.authorizationHeader; + const match = authorization?.match(/^Bearer\s+(.+)$/i); + if (!match?.[1]) { + throw new APIError("UNAUTHORIZED", { + message: "A bearer access token is required.", + }); + } + + return verifyJwsAccessToken(match[1], { + jwksFetch: () => auth.api.getJwks(), + jwksCacheKey, + verifyOptions: { + issuer: OAUTH.issuer, + audience: OAUTH.resource, + ...opts?.verifyOptions, + }, + }); +} diff --git a/packages/auth/src/oauth-scope.ts b/packages/auth/src/oauth-scope.ts new file mode 100644 index 000000000..3fbb8366f --- /dev/null +++ b/packages/auth/src/oauth-scope.ts @@ -0,0 +1,22 @@ +import { OAUTH } from "./oauth-config"; + +export function requiredCrmScope(write: boolean): string { + return write ? OAUTH.scopes.crm.write : OAUTH.scopes.crm.read; +} + +export function bearerChallenge(error?: string, scope?: string): string { + const errorPart = error ? `, error="${error}"` : ""; + const scopePart = scope ? `, scope="${scope}"` : ""; + return `Bearer realm="compcrm"${errorPart}${scopePart}`; +} + +export function oauthScopeFailure( + scopes: ReadonlySet, + requiredScope: string, +): { challenge: string; message: string } | null { + if (scopes.has(requiredScope)) return null; + return { + challenge: bearerChallenge("insufficient_scope", requiredScope), + message: `The token requires ${requiredScope}.`, + }; +} diff --git a/packages/auth/src/slack-connect.ts b/packages/auth/src/slack-connect.ts index f8fa9a692..5952a10a4 100644 --- a/packages/auth/src/slack-connect.ts +++ b/packages/auth/src/slack-connect.ts @@ -18,16 +18,25 @@ const CONNECT_MANAGER_ROLES = WORKSPACE_ROLES.filter((role) => canManageConnections(role), ); -const SLACK_CONNECT_START_PATHS = ["/oauth2/link", "/sign-in/oauth2"]; -const OAUTH_CALLBACK_PATH = "/oauth2/callback"; +const SLACK_CONNECT_START_PATH = "/link-social"; +const OAUTH_CALLBACK_PATH = "/callback"; -const connectStartBody = z.object({ providerId: z.string() }); -const callbackParams = z.object({ providerId: z.string() }); +const connectStartBody = z.object({ provider: z.string() }); +const callbackParams = z.object({ id: z.string() }); +const callbackQuery = z.object({ state: z.string() }); +const oauthState = z.object({ + link: z + .object({ + email: z.string(), + userId: z.string(), + }) + .optional(), +}); export const slackConnectGuard = createAuthMiddleware(async (ctx) => { const guarded = startsSlackConnect(ctx.path, ctx.body) || - completesSlackConnect(ctx.path, ctx.params); + (await completesSlackConnect(ctx.path, ctx.params, ctx.query)); if (!guarded) return; const session = await getSessionFromCtx(ctx, { disableCookieCache: true }); @@ -62,14 +71,36 @@ export const slackConnectGuard = createAuthMiddleware(async (ctx) => { } }); -function startsSlackConnect(path: string, body: JsonValue): boolean { - if (!SLACK_CONNECT_START_PATHS.includes(path)) return false; +function startsSlackConnect( + path: string, + body: JsonValue | undefined, +): boolean { + if (path !== SLACK_CONNECT_START_PATH) return false; const parsed = connectStartBody.safeParse(body); - return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID; + return parsed.success && parsed.data.provider === SLACK_PROVIDER_ID; } -function completesSlackConnect(path: string, params: JsonValue): boolean { +async function completesSlackConnect( + path: string, + params: JsonValue | undefined, + query: JsonValue | undefined, +): Promise { if (!path.startsWith(OAUTH_CALLBACK_PATH)) return false; - const parsed = callbackParams.safeParse(params); - return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID; + const parsedParams = callbackParams.safeParse(params); + if (!parsedParams.success || parsedParams.data.id !== SLACK_PROVIDER_ID) { + return false; + } + const parsedQuery = callbackQuery.safeParse(query); + if (!parsedQuery.success) return false; + const verification = await db.verification.findFirst({ + where: { identifier: parsedQuery.data.state }, + select: { value: true }, + }); + if (!verification) return false; + try { + const parsedState = oauthState.safeParse(JSON.parse(verification.value)); + return parsedState.success && parsedState.data.link !== undefined; + } catch { + return false; + } } diff --git a/packages/auth/test/oauth-client-fields.spec.ts b/packages/auth/test/oauth-client-fields.spec.ts new file mode 100644 index 000000000..a3e374802 --- /dev/null +++ b/packages/auth/test/oauth-client-fields.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; + +process.env.API_URL = "https://crm.example.test"; +process.env.APP_URL = "https://crm.example.app"; + +const { oauthClientFields, OAUTH_SCOPES } = await import("../src/oauth-config"); + +describe("oauthClientFields", () => { + const fields = oauthClientFields({ + clientId: "custom-client", + name: "Custom client", + redirectUris: ["https://app.example/callback"], + postLogoutRedirectUris: ["https://app.example/logout"], + skipConsent: false, + }); + + it("identifies the client by its clientId", () => { + expect(fields.clientId).toBe("custom-client"); + }); + + it("grants every declared scope", () => { + expect(fields.scopes).toEqual([...OAUTH_SCOPES]); + }); + + it("demands PKCE for the native flow", () => { + expect(fields.requirePKCE).toBe(true); + expect(fields.tokenEndpointAuthMethod).toBe("none"); + }); + + it("supports only the authorization-code grant", () => { + expect(fields.grantTypes).toEqual(["authorization_code", "refresh_token"]); + expect(fields.responseTypes).toEqual(["code"]); + }); +}); diff --git a/packages/auth/test/oauth-scope.spec.ts b/packages/auth/test/oauth-scope.spec.ts new file mode 100644 index 000000000..fcc7cb8d3 --- /dev/null +++ b/packages/auth/test/oauth-scope.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; + +process.env.API_URL = "https://crm.example.test"; +process.env.APP_URL = "https://crm.example.app"; + +const { bearerChallenge, oauthScopeFailure, requiredCrmScope } = await import( + "../src/oauth-scope" +); + +describe("requiredCrmScope", () => { + it("returns the read scope for reads", () => { + expect(requiredCrmScope(false)).toBe("crm.read"); + }); + + it("returns the write scope for writes", () => { + expect(requiredCrmScope(true)).toBe("crm.write"); + }); +}); + +describe("bearerChallenge", () => { + it("names only the realm when there is no error", () => { + expect(bearerChallenge()).toBe('Bearer realm="compcrm"'); + }); + + it("adds the error code when given one", () => { + expect(bearerChallenge("invalid_token")).toBe( + 'Bearer realm="compcrm", error="invalid_token"', + ); + }); + + it("adds the scope alongside the error", () => { + expect(bearerChallenge("insufficient_scope", "crm.write")).toBe( + 'Bearer realm="compcrm", error="insufficient_scope", scope="crm.write"', + ); + }); +}); + +describe("oauthScopeFailure", () => { + it("returns null when the scope is granted", () => { + expect(oauthScopeFailure(new Set(["crm.read"]), "crm.read")).toBeNull(); + }); + + it("returns the challenge and message when the scope is missing", () => { + const failure = oauthScopeFailure(new Set(["crm.read"]), "crm.write"); + + expect(failure).toEqual({ + challenge: + 'Bearer realm="compcrm", error="insufficient_scope", scope="crm.write"', + message: "The token requires crm.write.", + }); + }); +}); diff --git a/packages/auth/test/slack-connect.integration.spec.ts b/packages/auth/test/slack-connect.integration.spec.ts index c5b7e9207..cd18b0944 100644 --- a/packages/auth/test/slack-connect.integration.spec.ts +++ b/packages/auth/test/slack-connect.integration.spec.ts @@ -8,9 +8,9 @@ import { } from "bun:test"; import { db } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; -import { type BetterAuthPlugin, betterAuth } from "better-auth"; +import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; -import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { applySetCookies } from "better-auth/cookies"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import * as z from "zod"; @@ -29,37 +29,34 @@ const SESSION_MS = 7 * 24 * 60 * 60 * 1000; const BASE_URL = "http://localhost:3001"; const JSON_HEADERS = { "content-type": "application/json" }; -const reached = { reached: true }; - -const probe = { - id: "slack-connect-probe", - endpoints: { - link: createAuthEndpoint("/oauth2/link", { method: "POST" }, async (ctx) => - ctx.json(reached), - ), - signIn: createAuthEndpoint( - "/sign-in/oauth2", - { method: "POST" }, - async (ctx) => ctx.json(reached), - ), - callback: createAuthEndpoint( - "/oauth2/callback/:providerId", - { method: "GET" }, - async (ctx) => ctx.json(reached), - ), +const provider = (providerId: string) => ({ + providerId, + accountIssuer: `https://${providerId}.example.test`, + authorizationUrl: `https://${providerId}.example.test/authorize`, + tokenUrl: `https://${providerId}.example.test/token`, + userInfoUrl: `https://${providerId}.example.test/userinfo`, + clientId: `${providerId}-client`, + clientSecret: `${providerId}-secret`, + getToken: async () => { + throw new APIError("BAD_REQUEST", { message: "Token exchange reached." }); }, -} satisfies BetterAuthPlugin; +}); const guarded = betterAuth({ baseURL: BASE_URL, secret: "slack-connect-spec-secret", database: prismaAdapter(db, { provider: "postgresql" }), emailAndPassword: { enabled: false }, + account: { skipStateCookieCheck: true }, hooks: { before: slackConnectGuard }, - plugins: [probe], + plugins: [ + genericOAuth({ + config: [provider(SLACK_PROVIDER_ID), provider(GOOGLE_PROVIDER_ID)], + }), + ], }); -const arrival = z.object({ reached: z.literal(true) }); +const authorization = z.object({ url: z.string().url() }); const refusal = z.object({ message: z.string() }); type Snapshot = { @@ -143,30 +140,65 @@ const startConnect = ( new Request(`${BASE_URL}/api/auth${path}`, { method: "POST", headers: cookie ? { ...JSON_HEADERS, cookie } : JSON_HEADERS, - body: JSON.stringify({ providerId, callbackURL: "/" }), + body: JSON.stringify({ provider: providerId, callbackURL: "/" }), }), ); -const linkSlack = (cookie?: string) => startConnect("/oauth2/link", cookie); +const linkSlack = (cookie?: string) => startConnect("/link-social", cookie); -const completeConnect = ( +const completeCallback = ( + state: string, cookie?: string, providerId: string = SLACK_PROVIDER_ID, ) => guarded.handler( new Request( - `${BASE_URL}/api/auth/oauth2/callback/${providerId}?code=test-code&state=test-state`, + `${BASE_URL}/api/auth/callback/${providerId}?code=test-code&state=${state}`, { headers: cookie ? { cookie } : undefined }, ), ); +const linkTransaction = async ( + cookie: string, + providerId: string = SLACK_PROVIDER_ID, +): Promise<{ state: string; cookie: string }> => { + const state = `slack-connect-state-${crypto.randomUUID()}`; + await db.verification.create({ + data: { + id: state, + identifier: state, + value: JSON.stringify({ + callbackURL: "/", + codeVerifier: "slack-connect-code-verifier", + expiresAt: Date.now() + 60_000, + link: { email: `${providerId}@example.test`, userId: providerId }, + }), + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + return { state, cookie }; +}; + +const completeLink = async (startCookie: string, keepCallbackCookie = true) => { + const transaction = await linkTransaction(startCookie); + return completeCallback( + transaction.state, + keepCallbackCookie ? transaction.cookie : undefined, + ); +}; + const messageOf = async (response: Response): Promise => refusal.parse(await response.json()).message; const arrived = async (response: Response): Promise => - arrival.safeParse(await response.json()).success; + authorization.safeParse(await response.json()).success; const clear = async () => { + await db.verification.deleteMany({ + where: { identifier: { startsWith: "slack-connect-state-" } }, + }); await db.member.deleteMany({ where: { organizationId: WORKSPACE_ID } }); await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); await db.user.deleteMany({ where: { email: { endsWith: EMAIL_SUFFIX } } }); @@ -221,56 +253,73 @@ afterAll(async () => { } }); -describe("the Slack callback that writes the connection", () => { - it("turns away a browser with no session", async () => { - const response = await completeConnect(); +describe("Slack linking callback authorization", () => { + it("turns away a linking browser after its session ends", async () => { + const cookie = await seat("lead", "admin"); + const response = await completeLink(cookie, false); expect(response.status).toBe(401); expect(await messageOf(response)).toContain("Sign in to the CRM"); }); - it("turns away a member", async () => { + it("turns away an admin who became a member", async () => { + const cookie = await seat("lead", "admin"); + const transaction = await linkTransaction(cookie); + await db.member.update({ + where: { id: idOf("lead-member") }, + data: { role: "member" }, + }); await seat("owner", "owner"); - const response = await completeConnect(await seat("rep", "member")); + const response = await completeCallback( + transaction.state, + transaction.cookie, + ); expect(response.status).toBe(403); expect(await messageOf(response)).toContain("Only an owner or an admin"); }); - it("turns away someone signed in who is not in this workspace", async () => { - await seat("owner", "owner"); - const response = await completeConnect(await seat("stranger", null)); + it("turns away an admin removed from the workspace", async () => { + const cookie = await seat("lead", "admin"); + const transaction = await linkTransaction(cookie); + await db.member.delete({ where: { id: idOf("lead-member") } }); + const response = await completeCallback( + transaction.state, + transaction.cookie, + ); expect(response.status).toBe(403); expect(await messageOf(response)).toContain("member of this workspace"); }); - it("lets an admin finish", async () => { - const response = await completeConnect(await seat("lead", "admin")); + it("lets an admin reach token exchange", async () => { + const cookie = await seat("lead", "admin"); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); - it("lets an owner finish", async () => { - const response = await completeConnect(await seat("founder", "owner")); + it("lets an owner reach token exchange", async () => { + const cookie = await seat("founder", "owner"); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); - it("lets a member finish when the workspace has no owner and no admin", async () => { + it("lets a member reach token exchange without a workspace manager", async () => { const cookie = await seat("rep", "member"); await seat("other", "member"); - const response = await completeConnect(cookie); + const response = await completeLink(cookie); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); }); -describe("the two paths that start a Slack connection", () => { +describe("Slack connection starts", () => { it("turns away a member who asks to link Slack", async () => { await seat("owner", "owner"); const response = await linkSlack(await seat("rep", "member")); @@ -278,14 +327,12 @@ describe("the two paths that start a Slack connection", () => { expect(response.status).toBe(403); }); - it("turns away a member who asks to sign in with Slack", async () => { + it("lets a browser with no session ask to sign in with Slack", async () => { await seat("owner", "owner"); - const response = await startConnect( - "/sign-in/oauth2", - await seat("rep", "member"), - ); + const response = await startConnect("/sign-in/social"); - expect(response.status).toBe(403); + expect(response.status).toBe(200); + expect(await arrived(response)).toBe(true); }); it("turns away a browser with no session", async () => { @@ -307,7 +354,7 @@ describe("every provider that is not Slack", () => { it("lets a member link Google", async () => { await seat("owner", "owner"); const response = await startConnect( - "/oauth2/link", + "/link-social", await seat("rep", "member"), GOOGLE_PROVIDER_ID, ); @@ -317,23 +364,34 @@ describe("every provider that is not Slack", () => { }); it("lets the Google callback through with no session at all", async () => { - const response = await completeConnect(undefined, GOOGLE_PROVIDER_ID); + const response = await completeCallback( + "test-state", + undefined, + GOOGLE_PROVIDER_ID, + ); - expect(response.status).toBe(200); - expect(await arrived(response)).toBe(true); + expect(response.status).toBe(302); }); -}); -describe("the paths the guard has to know about", () => { - it("is every path the generic OAuth plugin mounts", () => { - const paths = Object.values(genericOAuth({ config: [] }).endpoints) - .map((endpoint) => endpoint.path) - .sort(); - - expect(paths).toEqual([ - "/oauth2/callback/:providerId", - "/oauth2/link", - "/sign-in/oauth2", - ]); + it("lets a Slack sign-in callback through with no session", async () => { + const state = `slack-connect-state-${crypto.randomUUID()}`; + await db.verification.create({ + data: { + id: state, + identifier: state, + value: JSON.stringify({ + callbackURL: "/", + codeVerifier: "slack-sign-in-code-verifier", + expiresAt: Date.now() + 60_000, + }), + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const response = await completeCallback(state); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("error=invalid_code"); }); }); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 3c44722d2..954d8582e 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -7,6 +7,6 @@ "declaration": false, "declarationMap": false }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["scripts/**/*.ts", "src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] } diff --git a/packages/db/README.md b/packages/db/README.md index e4be5ff76..1aefb0bcf 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -65,7 +65,7 @@ generated model map. needs no `transpilePackages` entry. Non-bundler consumers need a TypeScript runtime — the NestJS API runs on Bun for exactly this reason. - **Auth models are generated.** `User`, `Session`, `Account`, `Verification` - and `RateLimit` come from `@better-auth/cli`. Do not hand-edit them — change + and `RateLimit` come from `auth`. Do not hand-edit them — change the Better Auth config in `@crm/auth` and re-run `bun run auth:generate`. The generator is additive: it adds models and fields a plugin needs but never removes the ones a dropped plugin left behind, so removing a plugin means diff --git a/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql b/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql new file mode 100644 index 000000000..3b44eb6ed --- /dev/null +++ b/packages/db/prisma/migrations/20260829113915_add_oauth_provider/migration.sql @@ -0,0 +1,206 @@ +CREATE TABLE "jwks" ( + "id" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, + "privateKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3), + "alg" TEXT, + "crv" TEXT, + + CONSTRAINT "jwks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClient" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT, + "clientDiscoveryId" TEXT, + "disabled" BOOLEAN DEFAULT false, + "skipConsent" BOOLEAN, + "enableEndSession" BOOLEAN, + "subjectType" TEXT, + "scopes" TEXT[] NOT NULL, + "clientCredentialsScopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "userId" TEXT, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + "name" TEXT, + "uri" TEXT, + "icon" TEXT, + "contacts" TEXT[] NOT NULL, + "tos" TEXT, + "policy" TEXT, + "softwareId" TEXT, + "softwareVersion" TEXT, + "softwareStatement" TEXT, + "redirectUris" TEXT[] NOT NULL, + "postLogoutRedirectUris" TEXT[] NOT NULL, + "backchannelLogoutUri" TEXT, + "backchannelLogoutSessionRequired" BOOLEAN, + "tokenEndpointAuthMethod" TEXT, + "applicationType" TEXT, + "jwks" TEXT, + "jwksUri" TEXT, + "grantTypes" TEXT[] NOT NULL, + "responseTypes" TEXT[] NOT NULL, + "requirePKCE" BOOLEAN, + "dpopBoundAccessTokens" BOOLEAN DEFAULT false, + "referenceId" TEXT, + "metadata" JSONB, + + CONSTRAINT "oauthClient_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthResource" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "name" TEXT NOT NULL, + "accessTokenTtl" INTEGER, + "refreshTokenTtl" INTEGER, + "signingAlgorithm" TEXT, + "signingKeyId" TEXT, + "allowedScopes" TEXT[] NOT NULL, + "customClaims" JSONB, + "dpopBoundAccessTokensRequired" BOOLEAN DEFAULT false, + "disabled" BOOLEAN DEFAULT false, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + "policyVersion" INTEGER DEFAULT 1, + "metadata" JSONB, + + CONSTRAINT "oauthResource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClientResource" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3), + + CONSTRAINT "oauthClientResource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthRefreshToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "sessionId" TEXT, + "userId" TEXT NOT NULL, + "referenceId" TEXT, + "authorizationCodeId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3), + "revoked" TIMESTAMP(3), + "rotatedAt" TIMESTAMP(3), + "rotationReplayResponse" TEXT, + "rotationReplayExpiresAt" TIMESTAMP(3), + "authTime" TIMESTAMP(3), + "confirmation" JSONB, + "scopes" TEXT[] NOT NULL, + + CONSTRAINT "oauthRefreshToken_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthAccessToken" ( + "id" TEXT NOT NULL, + "token" TEXT, + "clientId" TEXT NOT NULL, + "sessionId" TEXT, + "userId" TEXT, + "referenceId" TEXT, + "authorizationCodeId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "refreshId" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3), + "revoked" TIMESTAMP(3), + "confirmation" JSONB, + "scopes" TEXT[] NOT NULL, + + CONSTRAINT "oauthAccessToken_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthConsent" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT, + "referenceId" TEXT, + "resources" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "requestedUserInfoClaims" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "scopes" TEXT[] NOT NULL, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "oauthConsent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "oauthClientAssertion" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauthClientAssertion_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "oauthClient_userId_idx" ON "oauthClient"("userId"); + +CREATE UNIQUE INDEX "oauthClient_clientId_key" ON "oauthClient"("clientId"); + +CREATE UNIQUE INDEX "oauthResource_identifier_key" ON "oauthResource"("identifier"); + +CREATE INDEX "oauthClientResource_clientId_idx" ON "oauthClientResource"("clientId"); + +CREATE INDEX "oauthClientResource_resourceId_idx" ON "oauthClientResource"("resourceId"); + +CREATE INDEX "oauthRefreshToken_clientId_idx" ON "oauthRefreshToken"("clientId"); + +CREATE INDEX "oauthRefreshToken_sessionId_idx" ON "oauthRefreshToken"("sessionId"); + +CREATE INDEX "oauthRefreshToken_userId_idx" ON "oauthRefreshToken"("userId"); + +CREATE INDEX "oauthRefreshToken_authorizationCodeId_idx" ON "oauthRefreshToken"("authorizationCodeId"); + +CREATE UNIQUE INDEX "oauthRefreshToken_token_key" ON "oauthRefreshToken"("token"); + +CREATE INDEX "oauthAccessToken_clientId_idx" ON "oauthAccessToken"("clientId"); + +CREATE INDEX "oauthAccessToken_sessionId_idx" ON "oauthAccessToken"("sessionId"); + +CREATE INDEX "oauthAccessToken_userId_idx" ON "oauthAccessToken"("userId"); + +CREATE INDEX "oauthAccessToken_authorizationCodeId_idx" ON "oauthAccessToken"("authorizationCodeId"); + +CREATE INDEX "oauthAccessToken_refreshId_idx" ON "oauthAccessToken"("refreshId"); + +CREATE UNIQUE INDEX "oauthAccessToken_token_key" ON "oauthAccessToken"("token"); + +CREATE INDEX "oauthConsent_clientId_idx" ON "oauthConsent"("clientId"); + +CREATE INDEX "oauthConsent_userId_idx" ON "oauthConsent"("userId"); + +ALTER TABLE "oauthClient" ADD CONSTRAINT "oauthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthClientResource" ADD CONSTRAINT "oauthClientResource_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "oauthResource"("identifier") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "session"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "oauthRefreshToken" ADD CONSTRAINT "oauthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "session"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthAccessToken" ADD CONSTRAINT "oauthAccessToken_refreshId_fkey" FOREIGN KEY ("refreshId") REFERENCES "oauthRefreshToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthConsent" ADD CONSTRAINT "oauthConsent_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "oauthConsent" ADD CONSTRAINT "oauthConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql b/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql new file mode 100644 index 000000000..524048ced --- /dev/null +++ b/packages/db/prisma/migrations/20260830221000_better_auth_account_identity/migration.sql @@ -0,0 +1,107 @@ +CREATE FUNCTION "better_auth_jwt_payload"(token TEXT) RETURNS JSONB +LANGUAGE plpgsql IMMUTABLE STRICT AS $$ +DECLARE + payload TEXT; +BEGIN + payload := translate(split_part(token, '.', 2), '-_', '+/'); + payload := payload || repeat('=', (4 - length(payload) % 4) % 4); + RETURN convert_from(decode(payload, 'base64'), 'UTF8')::JSONB; +EXCEPTION WHEN OTHERS THEN + RETURN NULL; +END; +$$; + +ALTER TABLE "account" ADD COLUMN "issuer" TEXT; + +UPDATE "account" +SET "issuer" = 'local:credential', + "accountId" = "userId" +WHERE "providerId" = 'credential'; + +UPDATE "account" +SET "issuer" = 'https://accounts.google.com' +WHERE "providerId" = 'google'; + +UPDATE "account" +SET "issuer" = 'local:oauth:slack' +WHERE "providerId" = 'slack'; + +WITH "microsoftIdentity" AS MATERIALIZED ( + SELECT "id", "better_auth_jwt_payload"("idToken") AS "payload" + FROM "account" + WHERE "providerId" = 'microsoft' + AND "idToken" IS NOT NULL +) +UPDATE "account" AS account +SET "issuer" = identity."payload"->>'iss', + "accountId" = identity."payload"->>'oid' +FROM "microsoftIdentity" AS identity +WHERE account."id" = identity."id" + AND jsonb_typeof(identity."payload"->'iss') = 'string' + AND length(identity."payload"->>'iss') > 0 + AND jsonb_typeof(identity."payload"->'oid') = 'string' + AND length(identity."payload"->>'oid') > 0; + +UPDATE "account" AS account +SET "issuer" = provider."issuer" +FROM "ssoProvider" AS provider +WHERE account."providerId" = provider."providerId" + AND account."issuer" IS NULL + AND length(provider."issuer") > 0; + +DELETE FROM "account" AS account +WHERE account."issuer" IS NULL + AND account."providerId" NOT IN ('credential', 'google', 'microsoft', 'slack') + AND NOT EXISTS ( + SELECT 1 + FROM "ssoProvider" AS provider + WHERE provider."providerId" = account."providerId" + ); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "account" + WHERE "issuer" IS NULL OR length("issuer") = 0 + ) THEN + RAISE EXCEPTION 'Better Auth account issuer backfill is incomplete'; + END IF; + IF EXISTS ( + SELECT 1 FROM "account" + WHERE "providerId" = 'microsoft' + AND ( + "idToken" IS NULL + OR "issuer" IS DISTINCT FROM "better_auth_jwt_payload"("idToken")->>'iss' + OR "accountId" IS DISTINCT FROM "better_auth_jwt_payload"("idToken")->>'oid' + ) + ) THEN + RAISE EXCEPTION 'Microsoft account identity needs a verified oid mapping before Better Auth 1.7'; + END IF; + IF EXISTS ( + SELECT 1 + FROM "account" + GROUP BY "issuer", "accountId" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Better Auth account identity backfill found duplicate issuer and accountId pairs'; + END IF; + IF EXISTS ( + SELECT 1 + FROM "account" + GROUP BY "userId", "providerId" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Mailbox account identity backfill found duplicate userId and providerId pairs'; + END IF; +END; +$$; + +ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL; + +CREATE UNIQUE INDEX "account_issuer_accountId_uidx" +ON "account"("issuer", "accountId"); + +CREATE UNIQUE INDEX "account_userId_providerId_uidx" +ON "account"("userId", "providerId"); + +DROP FUNCTION "better_auth_jwt_payload"(TEXT); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b6e584481..156a95266 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -57,6 +57,11 @@ model User { apiKeys Apikey[] pushTokens PushToken[] + oauthclients OauthClient[] + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] + oauthconsents OauthConsent[] + @@unique([email]) @@map("user") } @@ -143,6 +148,8 @@ model Session { activeOrganizationId String? activeTeamId String? impersonatedBy String? + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] @@unique([token]) @@index([userId]) @@ -151,6 +158,7 @@ model Session { model Account { id String @id + issuer String accountId String providerId String userId String @@ -165,6 +173,8 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") + @@unique([userId, providerId], map: "account_userId_providerId_uidx") @@index([userId]) @@map("account") } @@ -191,6 +201,189 @@ model RateLimit { @@map("rateLimit") } +model Jwks { + id String @id + publicKey String + privateKey String + createdAt DateTime + expiresAt DateTime? + alg String? + crv String? + + @@map("jwks") +} + +model OauthClient { + id String @id + clientId String + clientSecret String? + clientDiscoveryId String? + disabled Boolean? @default(false) + skipConsent Boolean? + enableEndSession Boolean? + subjectType String? + scopes String[] + clientCredentialsScopes String[] @default([]) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime? + updatedAt DateTime? + name String? + uri String? + icon String? + contacts String[] + tos String? + policy String? + softwareId String? + softwareVersion String? + softwareStatement String? + redirectUris String[] + postLogoutRedirectUris String[] + backchannelLogoutUri String? + backchannelLogoutSessionRequired Boolean? + tokenEndpointAuthMethod String? + applicationType String? + jwks String? + jwksUri String? + grantTypes String[] + responseTypes String[] + requirePKCE Boolean? + dpopBoundAccessTokens Boolean? @default(false) + referenceId String? + metadata Json? + oauthclientresources OauthClientResource[] + oauthrefreshtokens OauthRefreshToken[] + oauthaccesstokens OauthAccessToken[] + oauthconsents OauthConsent[] + + @@unique([clientId]) + @@index([userId]) + @@map("oauthClient") +} + +model OauthResource { + id String @id + identifier String + name String + accessTokenTtl Int? + refreshTokenTtl Int? + signingAlgorithm String? + signingKeyId String? + allowedScopes String[] + customClaims Json? + dpopBoundAccessTokensRequired Boolean? @default(false) + disabled Boolean? @default(false) + createdAt DateTime? + updatedAt DateTime? + policyVersion Int? @default(1) + metadata Json? + oauthclientresources OauthClientResource[] + + @@unique([identifier]) + @@map("oauthResource") +} + +model OauthClientResource { + id String @id + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + resourceId String + oauthresource OauthResource @relation(fields: [resourceId], references: [identifier], onDelete: Cascade) + metadata Json? + createdAt DateTime? + + @@index([clientId]) + @@index([resourceId]) + @@map("oauthClientResource") +} + +model OauthRefreshToken { + id String @id + token String + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + expiresAt DateTime? + createdAt DateTime? + revoked DateTime? + rotatedAt DateTime? + rotationReplayResponse String? + rotationReplayExpiresAt DateTime? + authTime DateTime? + confirmation Json? + scopes String[] + oauthaccesstokens OauthAccessToken[] + + @@unique([token]) + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([authorizationCodeId]) + @@map("oauthRefreshToken") +} + +model OauthAccessToken { + id String @id + token String? + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + authorizationCodeId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + refreshId String? + oauthrefreshtoken OauthRefreshToken? @relation(fields: [refreshId], references: [id], onDelete: Cascade) + expiresAt DateTime? + createdAt DateTime? + revoked DateTime? + confirmation Json? + scopes String[] + + @@unique([token]) + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([authorizationCodeId]) + @@index([refreshId]) + @@map("oauthAccessToken") +} + +model OauthConsent { + id String @id + clientId String + oauthclient OauthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? + resources String[] @default([]) + requestedUserInfoClaims String[] @default([]) + scopes String[] + createdAt DateTime? + updatedAt DateTime? + + @@index([clientId]) + @@index([userId]) + @@map("oauthConsent") +} + +model OauthClientAssertion { + id String @id + expiresAt DateTime + + @@map("oauthClientAssertion") +} + enum DealStage { DEMO_BOOKED QUALIFIED_TO_BUY @@ -529,31 +722,31 @@ model AgentTask { } model XmppAgentTask { - id String @id - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - requestId String - callerJid String + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requestId String + callerJid String notificationJid String - targetJid String - tool String - apiVersion String - manifestHash String - fingerprint String - arguments Json - state XmppAgentTaskState @default(ACCEPTED) - revision Int @default(0) - progress Json? - result Json? - error Json? - summary String? - eveSessionId String? - ownerId String? - leaseUntil DateTime? - deadline DateTime? - retainUntil DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + targetJid String + tool String + apiVersion String + manifestHash String + fingerprint String + arguments Json + state XmppAgentTaskState @default(ACCEPTED) + revision Int @default(0) + progress Json? + result Json? + error Json? + summary String? + eveSessionId String? + ownerId String? + leaseUntil DateTime? + deadline DateTime? + retainUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, callerJid, targetJid, requestId]) @@index([organizationId, state, updatedAt]) @@ -1609,15 +1802,15 @@ model TelemetryCounter { } model Organization { - id String @id - name String - slug String - logo String? - createdAt DateTime - metadata String? - website String? - members Member[] - invitations Invitation[] + id String @id + name String + slug String + logo String? + createdAt DateTime + metadata String? + website String? + members Member[] + invitations Invitation[] xmppAgentTasks XmppAgentTask[] @@unique([slug]) From 4dbc909d83199433e4f5d778a5ae12af3096e7db Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sun, 6 Sep 2026 11:29:41 -0500 Subject: [PATCH 07/27] fix: complete JobSteward production Google OAuth setup (#7) * fix: preserve app build environment * fix(auth): allow mixed Google sign-in domains * feat(app): add public legal pages --- apps/app/app/(legal)/layout.tsx | 27 +++ apps/app/app/(legal)/privacy/page.tsx | 160 ++++++++++++++++++ apps/app/app/(legal)/terms/page.tsx | 132 +++++++++++++++ apps/app/components/landing/hero.tsx | 2 +- .../app/components/landing/landing-footer.tsx | 11 +- apps/app/proxy.ts | 3 + apps/app/test/onboarding-gate.spec.ts | 14 ++ apps/app/turbo.json | 2 +- packages/auth/src/auth.ts | 3 +- packages/auth/src/workspace.ts | 6 + packages/auth/test/workspace.spec.ts | 29 ++++ packages/env/test/root.spec.ts | 10 ++ 12 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 apps/app/app/(legal)/layout.tsx create mode 100644 apps/app/app/(legal)/privacy/page.tsx create mode 100644 apps/app/app/(legal)/terms/page.tsx create mode 100644 packages/auth/test/workspace.spec.ts diff --git a/apps/app/app/(legal)/layout.tsx b/apps/app/app/(legal)/layout.tsx new file mode 100644 index 000000000..25a3e1218 --- /dev/null +++ b/apps/app/app/(legal)/layout.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { LandingNav } from "@/components/landing/landing-nav"; + +export default function LegalLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+ {children} +
+
+ +
+
+ ); +} diff --git a/apps/app/app/(legal)/privacy/page.tsx b/apps/app/app/(legal)/privacy/page.tsx new file mode 100644 index 000000000..09521afcd --- /dev/null +++ b/apps/app/app/(legal)/privacy/page.tsx @@ -0,0 +1,160 @@ +import { Link } from "@crm/ui/components/link"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Privacy Policy", + description: "How JobSteward accesses, uses, stores, and shares user data.", +}; + +const sectionClass = "space-y-3"; +const headingClass = "font-semibold text-xl tracking-tight"; +const listClass = "list-disc space-y-2 pl-6 text-muted-foreground"; + +export default function PrivacyPage() { + return ( +
+
+

+ JobSteward +

+

+ Privacy Policy +

+

Effective September 6, 2026

+
+ +
+

Who we are

+

+ JobSteward is an agentic customer relationship management service + operated by Majestic Labs. This policy explains how JobSteward handles + personal information and Google user data. +

+
+ +
+

Information we collect

+
    +
  • + Account information, such as your name, email address, profile + image, and authentication identifiers. +
  • +
  • + CRM information that you or your workspace adds, such as contacts, + companies, deals, notes, settings, and agent instructions. +
  • +
  • + Service information, such as session, security, error, and usage + records needed to operate and protect JobSteward. +
  • +
+
+ +
+

Google user data

+

+ When you connect a Google account, JobSteward requests read-only + access to Gmail and Google Calendar. Depending on the access you + grant, this can include email messages, threads, message metadata and + settings, and calendar names, events, times, attendees, descriptions, + locations, and conference links. JobSteward also receives your basic + Google profile information for sign-in. +

+

+ JobSteward uses this data to authenticate you, sync email and calendar + activity into your CRM, match activity to contacts and companies, and + provide the search and assistant features that your workspace + requests. JobSteward does not send email, change calendars, or act + through your Google account with these read-only permissions. +

+
+ +
+

How we use information

+
    +
  • Provide, maintain, and secure JobSteward.
  • +
  • + Show CRM records and connected account activity to your workspace. +
  • +
  • + Run sync, search, automation, and assistant features that users + request. +
  • +
  • Respond to support requests and comply with applicable law.
  • +
+

+ When a workspace user requests an AI feature, JobSteward can send the + content needed for that request to the AI service selected for the + workspace. JobSteward does not use Google user data for advertising, + credit decisions, or to train its own general-purpose AI models. +

+
+ +
+

How we share information

+

+ We share information only with authorized workspace members, service + providers that help us host and operate JobSteward, and authorities + when required by law or needed to protect the service. A service + provider can use information only to perform services for us. We do + not sell personal information or Google user data. +

+

+ Humans do not read Google user data unless you ask us to inspect + specific data for support, access is necessary for security, or access + is required by law. +

+
+ +
+

Google API Limited Use

+

+ JobSteward's use and transfer of information received from Google + APIs follows the{" "} + + Google API Services User Data Policy + + , including the Limited Use requirements. +

+
+ +
+

Storage and security

+

+ JobSteward stores account credentials and synced CRM data in its + service database. We use HTTPS, access controls, and managed + infrastructure to protect data. No method of storage or transmission + is completely secure. +

+
+ +
+

Retention and deletion

+

+ We retain information while it is needed to provide JobSteward, meet + legal duties, resolve disputes, and protect the service. You can + revoke Google access from JobSteward's connection settings or + your Google Account. Revocation stops future access. You can + separately delete the synced Gmail and Calendar data from + JobSteward's connection settings. +

+

+ To request account or personal data deletion, email{" "} + support@jobsteward.ai + . +

+
+ +
+

Changes and contact

+

+ We can update this policy as JobSteward changes. We will update the + effective date and give additional notice when a material change + requires it. Send privacy questions to{" "} + support@jobsteward.ai + . +

+
+
+ ); +} diff --git a/apps/app/app/(legal)/terms/page.tsx b/apps/app/app/(legal)/terms/page.tsx new file mode 100644 index 000000000..f873ae774 --- /dev/null +++ b/apps/app/app/(legal)/terms/page.tsx @@ -0,0 +1,132 @@ +import { Link } from "@crm/ui/components/link"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Terms of Service", + description: "Terms for using the JobSteward service.", +}; + +const sectionClass = "space-y-3"; +const headingClass = "font-semibold text-xl tracking-tight"; +const listClass = "list-disc space-y-2 pl-6 text-muted-foreground"; + +export default function TermsPage() { + return ( +
+
+

+ JobSteward +

+

+ Terms of Service +

+

Effective September 6, 2026

+
+ +
+

Agreement

+

+ These terms govern your use of JobSteward, an agentic customer + relationship management service operated by Majestic Labs. By using + JobSteward, you agree to these terms. A separate written agreement + takes priority if it conflicts with these terms. +

+
+ +
+

Accounts and access

+
    +
  • You must provide accurate account information.
  • +
  • You are responsible for activity through your account.
  • +
  • + You must protect your account and tell us about unauthorized access. +
  • +
  • + You must have authority to add data and connect third-party + accounts. +
  • +
+
+ +
+

Connected services

+

+ You can authorize JobSteward to read data from services such as Google + Gmail and Google Calendar. Your use of those services remains subject + to the provider's terms. You can revoke access at any time. Some + JobSteward features will stop working after you revoke access. +

+
+ +
+

Your data

+

+ You keep your rights to the information that you provide. You give + Majestic Labs permission to host, process, copy, and transmit that + information only as needed to provide, secure, and support JobSteward. + You are responsible for the accuracy and legality of your data and for + giving required notices to other people whose information you add. +

+
+ +
+

Acceptable use

+

You must not:

+
    +
  • + Use JobSteward to break a law or another person's rights. +
  • +
  • Access an account, workspace, or data without permission.
  • +
  • Interfere with the service or bypass its security controls.
  • +
  • + Upload malware or use JobSteward to send abusive or deceptive + content. +
  • +
  • Resell the service unless Majestic Labs agrees in writing.
  • +
+
+ +
+

Service changes

+

+ We can change, suspend, or discontinue a feature. We aim to keep + JobSteward available, but we do not promise uninterrupted or + error-free operation. We can limit or suspend access when needed to + protect users, the service, or other people. +

+
+ +
+

Warranty and liability

+

+ JobSteward is provided as available. To the extent permitted by law, + Majestic Labs disclaims implied warranties and is not liable for + indirect, incidental, special, consequential, or punitive damages, or + for lost profits, revenue, data, or business opportunities. These + limits do not apply where the law does not allow them. +

+
+ +
+

Ending use

+

+ You can stop using JobSteward at any time. To request account + deletion, email{" "} + support@jobsteward.ai + . Terms that must continue by their nature, including ownership, + warranty, and liability terms, continue after access ends. +

+
+ +
+

Changes and contact

+

+ We can update these terms. We will update the effective date and give + additional notice when required. Send questions about these terms to{" "} + support@jobsteward.ai + . +

+
+
+ ); +} diff --git a/apps/app/components/landing/hero.tsx b/apps/app/components/landing/hero.tsx index 3fd11ef67..e89a16c27 100644 --- a/apps/app/components/landing/hero.tsx +++ b/apps/app/components/landing/hero.tsx @@ -6,7 +6,7 @@ export function Hero() {

- The CRM built for Agents + JobSteward, the CRM built for agents

diff --git a/apps/app/components/landing/landing-footer.tsx b/apps/app/components/landing/landing-footer.tsx index 8858eb405..ce94ee424 100644 --- a/apps/app/components/landing/landing-footer.tsx +++ b/apps/app/components/landing/landing-footer.tsx @@ -10,7 +10,7 @@ export function LandingFooter() {

- The open source agentic CRM. + JobSteward is an agentic CRM.

@@ -47,6 +47,15 @@ export function LandingFooter() {

+ +

All systems normal diff --git a/apps/app/proxy.ts b/apps/app/proxy.ts index ff2e7ab89..53478334e 100644 --- a/apps/app/proxy.ts +++ b/apps/app/proxy.ts @@ -14,6 +14,8 @@ const LANDING_PATH = "/"; const SIGN_IN_PATH = "/sign-in"; +const LEGAL_PATHS = ["/privacy", "/terms"]; + const UNGATED = ["/grant-access", "/eve", "/oauth"]; const ANONYMOUS = ["/t"]; @@ -24,6 +26,7 @@ export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; if (pathname === SIGN_IN_PATH) return NextResponse.next(); + if (LEGAL_PATHS.includes(pathname)) return NextResponse.next(); if (isAnonymous(pathname)) return NextResponse.next(); diff --git a/apps/app/test/onboarding-gate.spec.ts b/apps/app/test/onboarding-gate.spec.ts index 0480f1429..eea33f38c 100644 --- a/apps/app/test/onboarding-gate.spec.ts +++ b/apps/app/test/onboarding-gate.spec.ts @@ -174,6 +174,20 @@ describe("proxy", () => { expect(redirectedTo(await proxy(request("/sign-in")))).toBeNull(); }); + it("keeps the legal pages public for every visitor", async () => { + marketing(undefined); + stub(async () => { + throw new Error("the legal pages must not call the auth gate"); + }); + + for (const path of ["/privacy", "/terms"]) { + expect(redirectedTo(await proxy(request(path)))).toBeNull(); + expect( + redirectedTo(await proxy(request(path, [SESSION_COOKIE]))), + ).toBeNull(); + } + }); + it("never aims a redirect at the sign-in page itself", async () => { marketing(undefined); setup({ onboarded: false, configured: false }); diff --git a/apps/app/turbo.json b/apps/app/turbo.json index cea0ca0ed..3051a65cd 100644 --- a/apps/app/turbo.json +++ b/apps/app/turbo.json @@ -6,7 +6,7 @@ "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**"], - "env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"], + "env": ["$TURBO_EXTENDS$", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"], "passThroughEnv": [ "AGENT_BRIDGE_SECRET", "AGENT_URL", diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index cc88c77fc..a4c840ab2 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -27,6 +27,7 @@ import { rememberSlackInstall, replaceSlackConnection } from "./slack-grant"; import { SLACK_REQUESTED_SCOPES, SLACK_USER_SCOPES } from "./slack-scopes"; import { queueSlackInventorySync } from "./slack-sync"; import { + googleHostedDomain, hasSignInAllowList, isWorkspaceEmail, primaryWorkspaceDomain, @@ -48,7 +49,7 @@ if (env.google) { accessType: "offline", }; - const hostedDomain = primaryWorkspaceDomain(); + const hostedDomain = googleHostedDomain(); if (hostedDomain) google.hd = hostedDomain; socialProviders.google = google; diff --git a/packages/auth/src/workspace.ts b/packages/auth/src/workspace.ts index d94bc1c20..d464da136 100644 --- a/packages/auth/src/workspace.ts +++ b/packages/auth/src/workspace.ts @@ -36,6 +36,12 @@ export function primaryWorkspaceDomain(): string | undefined { return allowList().domains[0]; } +export function googleHostedDomain(): string | undefined { + const { domains, addresses } = allowList(); + if (domains.length !== 1 || addresses.length > 0) return undefined; + return domains[0]; +} + export function hasSignInAllowList(): boolean { const { domains, addresses } = allowList(); return domains.length > 0 || addresses.length > 0; diff --git a/packages/auth/test/workspace.spec.ts b/packages/auth/test/workspace.spec.ts new file mode 100644 index 000000000..d047c111e --- /dev/null +++ b/packages/auth/test/workspace.spec.ts @@ -0,0 +1,29 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { googleHostedDomain } from "../src/workspace"; + +const originalAllowList = process.env.ALLOWED_SIGN_IN; + +afterAll(() => { + if (originalAllowList === undefined) { + delete process.env.ALLOWED_SIGN_IN; + return; + } + process.env.ALLOWED_SIGN_IN = originalAllowList; +}); + +describe("googleHostedDomain", () => { + it("uses the only allowed domain", () => { + process.env.ALLOWED_SIGN_IN = "majesticlabs.dev"; + expect(googleHostedDomain()).toBe("majesticlabs.dev"); + }); + + it("does not restrict Google when an address is allowed", () => { + process.env.ALLOWED_SIGN_IN = "david@paluy.org, majesticlabs.dev"; + expect(googleHostedDomain()).toBeUndefined(); + }); + + it("does not restrict Google when multiple domains are allowed", () => { + process.env.ALLOWED_SIGN_IN = "majesticlabs.dev, altertx.com"; + expect(googleHostedDomain()).toBeUndefined(); + }); +}); diff --git a/packages/env/test/root.spec.ts b/packages/env/test/root.spec.ts index 460903046..092e7b76c 100644 --- a/packages/env/test/root.spec.ts +++ b/packages/env/test/root.spec.ts @@ -80,3 +80,13 @@ describe("the committed .env.example", () => { } }); }); + +describe("the app build environment", () => { + it("inherits the root build variables", () => { + const config = JSON.parse( + readFileSync(join(repoRoot, "apps", "app", "turbo.json"), "utf8"), + ); + + expect(config.tasks.build.env).toContain("$TURBO_EXTENDS$"); + }); +}); From 99012ad74c84f2f30bf8134da1230541f612cf99 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sun, 6 Sep 2026 11:50:28 -0500 Subject: [PATCH 08/27] fix: make Context research configuration optional (#1) --- apps/agent/test/capabilities.spec.ts | 28 +++++++ .../(landing)/onboarding/onboarding-form.tsx | 2 +- .../(landing)/onboarding/research/page.tsx | 20 +---- .../onboarding/research/research-form.tsx | 79 ------------------- apps/app/lib/onboarding.ts | 14 ---- apps/app/proxy.ts | 15 +--- apps/app/test/onboarding-form.spec.tsx | 44 +++++++++++ apps/app/test/onboarding-gate.spec.ts | 49 +++--------- docs/api.md | 22 ++---- docs/environment.md | 8 +- 10 files changed, 100 insertions(+), 181 deletions(-) delete mode 100644 apps/app/app/(landing)/onboarding/research/research-form.tsx create mode 100644 apps/app/test/onboarding-form.spec.tsx diff --git a/apps/agent/test/capabilities.spec.ts b/apps/agent/test/capabilities.spec.ts index db3dd6cf1..4706c6b4b 100644 --- a/apps/agent/test/capabilities.spec.ts +++ b/apps/agent/test/capabilities.spec.ts @@ -106,6 +106,34 @@ describe("reading a person comes from the same Context key", () => { }); }); +describe("optional Context research", () => { + it("restores company and person research when a key is configured", () => { + process.env.PERPLEXITY_API_KEY = "pplx-test"; + process.env.BLOB_READ_WRITE_TOKEN = "blob-test"; + + const missing = capabilitiesFrom(null); + const configured = capabilitiesFrom("ctx-test"); + + for (const id of [CONTEXT_DEV, CONTEXT_DEV_PEOPLE]) { + expect(missing.find((capability) => capability.id === id)?.enabled).toBe( + false, + ); + expect( + configured.find((capability) => capability.id === id)?.enabled, + ).toBe(true); + } + + for (const id of KEYS) { + expect(missing.find((capability) => capability.id === id)?.enabled).toBe( + true, + ); + expect( + configured.find((capability) => capability.id === id)?.enabled, + ).toBe(true); + } + }); +}); + describe("the unavailable result", () => { it("says retrying will not help", () => { const result = unavailable(CONTEXT_DEV_SOURCE); diff --git a/apps/app/app/(landing)/onboarding/onboarding-form.tsx b/apps/app/app/(landing)/onboarding/onboarding-form.tsx index 7f813dbb5..1e61e68c5 100644 --- a/apps/app/app/(landing)/onboarding/onboarding-form.tsx +++ b/apps/app/app/(landing)/onboarding/onboarding-form.tsx @@ -37,7 +37,7 @@ export function OnboardingForm({ placeholder }: { placeholder: string }) { trpc.workspace.update.mutationOptions({ onSuccess: () => { router.refresh(); - router.replace("/onboarding/research"); + router.replace("/"); }, onError: (error) => toast.error(error.message), }), diff --git a/apps/app/app/(landing)/onboarding/research/page.tsx b/apps/app/app/(landing)/onboarding/research/page.tsx index 4fc83d2d7..951cdd012 100644 --- a/apps/app/app/(landing)/onboarding/research/page.tsx +++ b/apps/app/app/(landing)/onboarding/research/page.tsx @@ -1,25 +1,9 @@ -import type { Metadata } from "next"; -import { AuthHeading, AuthShell } from "@/components/auth-shell"; +import { redirect } from "next/navigation"; import { requireMailboxAccess } from "@/lib/session"; -import { ResearchForm } from "./research-form"; - -export const metadata: Metadata = { - title: "Research key", -}; export const instant = false; export default async function ResearchKeyPage() { await requireMailboxAccess(); - - return ( - - - - - - ); + redirect("/"); } diff --git a/apps/app/app/(landing)/onboarding/research/research-form.tsx b/apps/app/app/(landing)/onboarding/research/research-form.tsx deleted file mode 100644 index 30887d2eb..000000000 --- a/apps/app/app/(landing)/onboarding/research/research-form.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings"; -import { Button } from "@crm/ui/components/button"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@crm/ui/components/field"; -import { Input } from "@crm/ui/components/input"; -import { Spinner } from "@crm/ui/components/spinner"; -import { useMutation } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; -import { useId } from "react"; -import { toast } from "sonner"; -import { useTRPC } from "@/lib/trpc/client"; - -export function ResearchForm() { - const trpc = useTRPC(); - const router = useRouter(); - - const keyId = useId(); - - const save = useMutation( - trpc.settings.setResearchKey.mutationOptions({ - onSuccess: () => { - router.refresh(); - router.replace("/"); - }, - onError: (error) => toast.error(error.message), - }), - ); - - return ( -

{ - event.preventDefault(); - const form = new FormData(event.currentTarget); - save.mutate({ apiKey: String(form.get("apiKey") ?? "").trim() }); - }} - className="flex flex-col gap-6" - > - - - Context API key - - - Don't have a Context API key?{" "} - - Sign up here - - - - - - -
- ); -} diff --git a/apps/app/lib/onboarding.ts b/apps/app/lib/onboarding.ts index ea8273789..74fae9531 100644 --- a/apps/app/lib/onboarding.ts +++ b/apps/app/lib/onboarding.ts @@ -22,10 +22,6 @@ const workspaceAnswer = z }) .catch({ onboarded: null, canRename: null, slug: null }); -const researchKeyAnswer = z - .object({ configured: z.boolean().nullable().catch(null) }) - .catch({ configured: null }); - async function read(request: NextRequest, procedure: string) { const cookie = request.headers.get("cookie"); @@ -67,13 +63,3 @@ export async function readWorkspaceGate( slug, }; } - -export async function readResearchGate(request: NextRequest): Promise { - const { configured } = researchKeyAnswer.parse( - await read(request, "settings.researchKey"), - ); - - if (configured === null) return "unknown"; - - return configured ? "settled" : "required"; -} diff --git a/apps/app/proxy.ts b/apps/app/proxy.ts index 53478334e..3cde31314 100644 --- a/apps/app/proxy.ts +++ b/apps/app/proxy.ts @@ -5,7 +5,6 @@ import { isMarketing } from "@/lib/env"; import { ONBOARDING_PATH, RESEARCH_PATH, - readResearchGate, readWorkspaceGate, } from "@/lib/onboarding"; import { workspaceUrl } from "@/lib/workspace-url"; @@ -40,19 +39,11 @@ export async function proxy(request: NextRequest) { if (isUngated(pathname)) return NextResponse.next(); - // Both answers, every time, and concurrently — so the gate costs one round - // trip rather than two, and neither answer can be stale. - const [workspace, research] = await Promise.all([ - readWorkspaceGate(request), - readResearchGate(request), - ]); + const workspace = await readWorkspaceGate(request); if (workspace.gate === "required") return sendTo(ONBOARDING_PATH, request); - if (research === "required") return sendTo(RESEARCH_PATH, request); - - const settled = workspace.gate === "settled" && research === "settled"; - - if (!settled || !workspace.slug) return NextResponse.next(); + if (workspace.gate !== "settled" || !workspace.slug) + return NextResponse.next(); return sendTo(appPath(pathname, workspace.slug), request); } diff --git a/apps/app/test/onboarding-form.spec.tsx b/apps/app/test/onboarding-form.spec.tsx new file mode 100644 index 000000000..a752e26ec --- /dev/null +++ b/apps/app/test/onboarding-form.spec.tsx @@ -0,0 +1,44 @@ +import { afterEach, expect, it, mock, spyOn } from "bun:test"; +import * as queries from "@tanstack/react-query"; +import * as navigation from "next/navigation"; +import { renderToStaticMarkup } from "react-dom/server"; +import { OnboardingForm } from "../app/(landing)/onboarding/onboarding-form"; +import * as client from "../lib/trpc/client"; + +afterEach(() => mock.restore()); + +it("enters the application after saving workspace onboarding", () => { + const replace = mock(); + const refresh = mock(); + let onSuccess: (() => void) | undefined; + + spyOn(navigation, "useRouter").mockReturnValue({ + bfcacheId: "test", + replace, + refresh, + push: mock(), + back: mock(), + forward: mock(), + prefetch: mock(), + }); + spyOn(client, "useTRPC").mockReturnValue({ + workspace: { + update: { + mutationOptions: (options: { onSuccess: () => void }) => { + onSuccess = options.onSuccess; + return options; + }, + }, + }, + } as unknown as ReturnType); + spyOn(queries, "useMutation").mockReturnValue({ + isPending: false, + } as ReturnType); + + renderToStaticMarkup(); + expect(onSuccess).toBeDefined(); + onSuccess?.(); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(replace).toHaveBeenCalledWith("/"); +}); diff --git a/apps/app/test/onboarding-gate.spec.ts b/apps/app/test/onboarding-gate.spec.ts index eea33f38c..82d738881 100644 --- a/apps/app/test/onboarding-gate.spec.ts +++ b/apps/app/test/onboarding-gate.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { AUTH_COOKIE_PREFIX } from "@crm/auth/cookies"; import { NextRequest } from "next/server"; -import { readResearchGate, readWorkspaceGate } from "../lib/onboarding"; +import { readWorkspaceGate } from "../lib/onboarding"; import { proxy } from "../proxy"; const SESSION_COOKIE = `${AUTH_COOKIE_PREFIX}.session_token=abc.def`; @@ -52,7 +52,6 @@ const researchKey = (configured: boolean) => ({ result: { data: { configured, hint: configured ? "••••9876" : null } }, }); -/** Answers both gate procedures, counting the calls to each. */ function setup({ onboarded = true, canRename = true, @@ -131,30 +130,6 @@ describe("readWorkspaceGate", () => { }); }); -describe("readResearchGate", () => { - it("is settled once a key is saved, and required until then", async () => { - answerWith(researchKey(true)); - expect(await readResearchGate(request("/", [SESSION_COOKIE]))).toBe( - "settled", - ); - - answerWith(researchKey(false)); - expect(await readResearchGate(request("/", [SESSION_COOKIE]))).toBe( - "required", - ); - }); - - it("is unknown rather than required when the API cannot be read", async () => { - stub(async () => { - throw new Error("connect ECONNREFUSED"); - }); - - expect(await readResearchGate(request("/", [SESSION_COOKIE]))).toBe( - "unknown", - ); - }); -}); - describe("proxy", () => { it("shows a stranger the landing page and nothing behind it", async () => { marketing("true"); @@ -251,11 +226,11 @@ describe("proxy", () => { const first = await proxy(request(`/${SLUG}/companies`, [SESSION_COOKIE])); expect([...first.cookies.getAll()]).toHaveLength(0); - expect(calls).toEqual({ workspace: 1, research: 1 }); + expect(calls).toEqual({ workspace: 1, research: 0 }); await proxy(request(`/${SLUG}/companies`, [SESSION_COOKIE])); - expect(calls).toEqual({ workspace: 2, research: 2 }); + expect(calls).toEqual({ workspace: 2, research: 0 }); }); it("notices when the answer changes underneath it", async () => { @@ -266,8 +241,6 @@ describe("proxy", () => { ), ).toBeNull(); - // A reset database, a removed key: the browser is carrying nothing that - // could keep saying the gate was satisfied. setup({ onboarded: false }); expect( redirectedTo( @@ -369,28 +342,28 @@ describe("the slug the app is served under", () => { }); }); -describe("the research key gate", () => { - it("sends an onboarded rep with no key to the key form", async () => { +describe("optional research configuration", () => { + it("lets an onboarded rep enter the CRM without a key", async () => { setup({ configured: false }); expect( redirectedTo( await proxy(request(`/${SLUG}/companies`, [SESSION_COOKIE])), ), - ).toBe("/onboarding/research"); + ).toBeNull(); }); - it("lets that form render rather than looping onto itself", async () => { + it("redirects the old research URL into the CRM", async () => { setup({ configured: false }); expect( redirectedTo( await proxy(request("/onboarding/research", [SESSION_COOKIE])), ), - ).toBeNull(); + ).toBe(`/${SLUG}`); }); - it("asks the first question first when both are outstanding", async () => { + it("requires workspace onboarding without a research key", async () => { setup({ onboarded: false, configured: false }); expect( @@ -400,11 +373,11 @@ describe("the research key gate", () => { ).toBe("/onboarding"); }); - it("sends them on to the key once the workspace is named", async () => { + it("enters the CRM after workspace onboarding without a key", async () => { setup({ onboarded: true, configured: false }); expect( redirectedTo(await proxy(request("/onboarding", [SESSION_COOKIE]))), - ).toBe("/onboarding/research"); + ).toBe(`/${SLUG}`); }); }); diff --git a/docs/api.md b/docs/api.md index 915a19b53..29257ddcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -67,20 +67,14 @@ here, what do we sell. ### Gates in `proxy.ts` -Onboarding, then `/onboarding/research` for the Context key. Asked server-side every -request. - -- **`getSessionCookie()` decides signed-in**; pages still resolve the real session via - `requireMailboxAccess()`. -- **Nothing is cached in a cookie** — both facts revert on a database reset while a - year-long marker insists the gate passed. Cache in the API if cost ever matters. -- **Both reads run concurrently**, but order decides which is *asked* — the research - read is never made while onboarding is open. -- **An unreachable API fails open** (`unknown` lets the request through). -- **`/sign-in`, `/grant-access`, `/eve`, `/oauth` are ungated.** `/sign-in` is the only path a - stranger may read; `/` joins it only when `IS_MARKETING` is set. -- **There is no way past the key gate but to answer** — Skip stranded installs, every - later company sitting `PENDING` with nothing saying so. +Workspace onboarding is required. Context configuration is optional in Settings → General. + +- `getSessionCookie()` checks for a session cookie. Pages validate the session through `requireMailboxAccess()`. +- The proxy reads workspace state on every request. It does not read research credentials. +- An unreachable API returns `unknown` and lets the request through. +- `/sign-in`, `/grant-access`, `/eve`, and `/oauth` remain ungated. +- After workspace onboarding, users enter the CRM without a Context key. +- The old `/onboarding/research` URL redirects into the CRM. ### The name is also the URL diff --git a/docs/environment.md b/docs/environment.md index e95fb55cd..dd790cc94 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -122,17 +122,15 @@ single place that knows what is set. because the API and the seed write pictures too. The Next.js app is deliberately excluded — recognising our URL for the image optimizer needs no token. -### The Context key is asked for, not configured +### The Context key is optional **`CONTEXT_DEV_API_KEY` is not a variable here and must not become one.** The key lives -in `AppSetting`, is asked for at `/onboarding/research`, and changes on Settings → -General — an admin who cannot redeploy cannot set a variable. +in `AppSetting`. Users add or replace it in Settings → General without a deployment. - **It buys two places to look, not one.** Company brand data by domain, and a person read back from a LinkedIn URL already on their record. Both capabilities in `agent/lib/capabilities.ts` turn on and off with this one key. -- **An install that had the variable is asked again**: no migration, no fallback, and - **the gate cannot be dismissed**. +- **CRM access does not require a Context key.** Missing credentials disable only Context company and person research. - **Nothing is lost while waiting.** A keyless `brand` task settles `SKIPPED` *before* anything marks the row `RUNNING`, and `settle` only overwrites `RUNNING` — so the company stays `PENDING`, which the sweep re-queues From 6a6a73904585d3539e2feb7d6cc0a2bc48dc3983 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sun, 6 Sep 2026 13:36:11 -0500 Subject: [PATCH 09/27] fix(app): label Kaneo agent tools --- apps/app/lib/agent-transcript.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index 2898c35bb..7f965c9e4 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -83,6 +83,12 @@ const VERBS: ToolVerbs = { set_field_value: "Filled in a custom field", manage_fields: "Changed what the CRM tracks", archive_field: "Asked to retire a field", + project_list: "Reviewed the project list", + task_list: "Reviewed the project tasks", + task_read: "Read a project task", + task_create: "Created a project task", + task_update: "Updated a project task", + task_comment: "Commented on a project task", load_skill: "Read its instructions for this", web_search: "Searched the web", From 9a3dcdc89383487cb10b117bec98b7466cc374f4 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sun, 6 Sep 2026 20:16:08 -0500 Subject: [PATCH 10/27] fix(auth): recover from OAuth startup connection resets (#3) --- bun.lock | 3 + package.json | 5 +- packages/auth/test/oauth-startup.spec.ts | 78 +++++++++++++++++++ .../@better-auth%2Foauth-provider@1.7.2.patch | 18 +++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/auth/test/oauth-startup.spec.ts create mode 100644 patches/@better-auth%2Foauth-provider@1.7.2.patch diff --git a/bun.lock b/bun.lock index 00c12bb47..4e39d6699 100644 --- a/bun.lock +++ b/bun.lock @@ -312,6 +312,9 @@ "trustedDependencies": [ "sharp", ], + "patchedDependencies": { + "@better-auth/oauth-provider@1.7.2": "patches/@better-auth%2Foauth-provider@1.7.2.patch", + }, "packages": { "@agent-xmpp/core": ["@agent-xmpp/core@workspace:packages/agent-xmpp/core"], diff --git a/package.json b/package.json index 1d678b6cf..ee716f343 100644 --- a/package.json +++ b/package.json @@ -48,5 +48,8 @@ "apps/*", "packages/*", "packages/agent-xmpp/*" - ] + ], + "patchedDependencies": { + "@better-auth/oauth-provider@1.7.2": "patches/@better-auth%2Foauth-provider@1.7.2.patch" + } } diff --git a/packages/auth/test/oauth-startup.spec.ts b/packages/auth/test/oauth-startup.spec.ts new file mode 100644 index 000000000..dce67e197 --- /dev/null +++ b/packages/auth/test/oauth-startup.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; + +function startup(errorCode: string, persistent = false) { + const resource = "https://api.example.test"; + const memory = memoryAdapter({ + oauthResource: [], + oauthClient: [], + oauthClientResource: [], + }); + let attempts = 0; + const auth = betterAuth({ + baseURL: "https://auth.example.test", + secret: "oauth-startup-test-secret-with-at-least-32-characters", + database: (options) => { + const adapter = memory(options); + return { + ...adapter, + findOne: async (input) => { + if (input.model === "oauthResource") { + attempts += 1; + if (attempts === 1 || persistent) { + throw Object.assign(new Error("Database unavailable"), { + code: errorCode, + }); + } + } + return adapter.findOne(input); + }, + }; + }, + plugins: [ + oauthProvider({ + loginPage: "/sign-in", + consentPage: "/consent", + disableJwtPlugin: true, + resources: [{ identifier: resource, name: "Test API" }], + resourceSeedMode: "overwrite", + clientRegistrationDefaultResources: [resource], + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + }), + ], + }); + const register = () => + auth.api.registerOAuthClient({ + body: { + client_name: "Startup regression", + redirect_uris: ["https://client.example.test/callback"], + token_endpoint_auth_method: "none", + }, + }); + return { auth, register, attempts: () => attempts }; +} + +describe("OAuth startup database recovery", () => { + it("serves sessions and retries resource setup after a connection reset", async () => { + const { auth, register, attempts } = startup("ECONNRESET"); + expect(await auth.api.getSession({ headers: new Headers() })).toBeNull(); + expect(await register()).toHaveProperty("client_id"); + expect(attempts()).toBeGreaterThan(1); + }); + + it("keeps resource requests blocked while the database remains unavailable", async () => { + const { auth, register, attempts } = startup("ECONNRESET", true); + await auth.$context; + await expect(register()).rejects.toThrow("Database unavailable"); + await expect(register()).rejects.toThrow("Database unavailable"); + expect(attempts()).toBe(3); + }); + + it("preserves initialization failures for schema errors", async () => { + const { auth } = startup("P2022"); + await expect(auth.$context).rejects.toThrow("Database unavailable"); + }); +}); diff --git a/patches/@better-auth%2Foauth-provider@1.7.2.patch b/patches/@better-auth%2Foauth-provider@1.7.2.patch new file mode 100644 index 000000000..cf432aec4 --- /dev/null +++ b/patches/@better-auth%2Foauth-provider@1.7.2.patch @@ -0,0 +1,18 @@ +diff --git a/dist/authorize-BmTe2VYG.mjs b/dist/authorize-BmTe2VYG.mjs +index 42c943dd45aa3d297a14edcc056a14e7ddd54680..df37111109156da9dc5b0d1d4dfb4587577f4eed 100644 +--- a/dist/authorize-BmTe2VYG.mjs ++++ b/dist/authorize-BmTe2VYG.mjs +@@ -4404,7 +4404,12 @@ const oauthProvider = (options) => { + onRequest: handleIssuerMetadataRequest, + init: async (ctx) => { + if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage"); +- await seedResources(ctx, opts); ++ try { ++ await seedResources(ctx, opts); ++ } catch (error) { ++ if (error?.code !== "ECONNRESET") throw error; ++ logger.warn("oauth-provider: database connection reset during startup; resource setup will retry on access."); ++ } + logEnforcePerClientResourcesResolution(opts); + if (!opts.disableJwtPlugin) { + const jwtPluginOptions = getJwtPlugin(ctx)?.options; From c19d15c71ea903ae75587fd95f169d8f0964af24 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Tue, 8 Sep 2026 21:02:12 -0500 Subject: [PATCH 11/27] feat: add customer and project asset storage (#4) * feat: add customer and project asset storage * fix: validate system message before upload replay * docs: set JobSteward pull request repository * refactor: keep asset code files within 200 lines --- .env.example | 8 + AGENTS.md | 3 +- apps/api/package.json | 2 + apps/api/src/app.module.ts | 2 + apps/api/src/assets/asset-access.service.ts | 124 +++++ apps/api/src/assets/asset-actor.ts | 8 + apps/api/src/assets/asset-catalog.service.ts | 125 +++++ apps/api/src/assets/asset-config.ts | 29 ++ apps/api/src/assets/asset-error.ts | 25 + apps/api/src/assets/asset-files.service.ts | 104 ++++ apps/api/src/assets/asset-mutation.service.ts | 104 ++++ apps/api/src/assets/asset-openapi.ts | 83 +++ apps/api/src/assets/asset-purge.ts | 73 +++ apps/api/src/assets/asset-request.ts | 19 + apps/api/src/assets/asset-responses.ts | 53 ++ apps/api/src/assets/asset-rest.ts | 187 +++++++ apps/api/src/assets/asset-storage-client.ts | 92 ++++ apps/api/src/assets/asset-storage-errors.ts | 56 ++ apps/api/src/assets/asset-storage-objects.ts | 101 ++++ apps/api/src/assets/asset-storage-signing.ts | 74 +++ .../src/assets/asset-storage-validation.ts | 39 ++ apps/api/src/assets/asset-storage.service.ts | 135 +++++ .../src/assets/asset-upload-create.service.ts | 163 ++++++ .../src/assets/asset-upload-grants.service.ts | 95 ++++ .../src/assets/asset-upload-source.service.ts | 79 +++ apps/api/src/assets/asset-uploads.service.ts | 158 ++++++ apps/api/src/assets/asset-worker-delete.ts | 56 ++ apps/api/src/assets/asset-worker-errors.ts | 3 + apps/api/src/assets/asset-worker-finalize.ts | 163 ++++++ apps/api/src/assets/asset-worker-lease.ts | 85 +++ apps/api/src/assets/asset-worker-retry.ts | 97 ++++ apps/api/src/assets/asset-worker-sweep.ts | 35 ++ .../api/src/assets/asset-worker.controller.ts | 48 ++ apps/api/src/assets/asset-worker.service.ts | 70 +++ apps/api/src/assets/assets.contracts.ts | 190 +++++++ apps/api/src/assets/assets.module.ts | 20 + apps/api/src/assets/assets.router.ts | 192 +++++++ apps/api/src/assets/assets.service.ts | 103 ++++ apps/api/src/config/env.validation.ts | 16 + apps/api/src/create-app.ts | 15 +- apps/api/src/deals/deals.service.ts | 2 + apps/api/src/generated/server.ts | 43 ++ .../middlewares/domain-error.middleware.ts | 7 + .../asset-purge-automatic.integration.spec.ts | 81 +++ apps/api/test/asset-purge.fixture.ts | 148 ++++++ apps/api/test/asset-purge.integration.spec.ts | 120 +++++ apps/api/test/asset-storage-errors.spec.ts | 122 +++++ apps/api/test/asset-storage.fixture.ts | 86 ++++ apps/api/test/asset-storage.spec.ts | 122 +++++ .../assets-core-abort.integration.spec.ts | 141 +++++ .../assets-core-deletion.integration.spec.ts | 198 +++++++ .../assets-core-legacy.integration.spec.ts | 133 +++++ .../assets-core-mailbox.integration.spec.ts | 159 ++++++ .../assets-core-project.integration.spec.ts | 191 +++++++ .../assets-core-sources.integration.spec.ts | 140 +++++ .../assets-core-worker.integration.spec.ts | 167 ++++++ apps/api/test/assets-core.fixture.ts | 172 +++++++ apps/api/test/assets-core.integration.spec.ts | 166 ++++++ apps/api/test/assets-core.storage.fixture.ts | 86 ++++ .../api/test/assets-http-contract.e2e.spec.ts | 83 +++ apps/api/test/assets-http-flow.e2e.spec.ts | 100 ++++ .../test/assets-http-lifecycle.e2e.spec.ts | 139 +++++ apps/api/test/assets-http.e2e.spec.ts | 125 +++++ apps/api/test/assets-http.fixture.ts | 126 +++++ apps/api/turbo.json | 8 + apps/api/vercel.json | 4 + bun.lock | 54 ++ docs/api.md | 12 + docs/asset-api-contract.md | 482 ++++++++++++++++++ docs/assets-storage-operations.md | 61 +++ docs/environment.md | 9 + .../migration.sql | 148 ++++++ packages/db/prisma/schema.prisma | 165 +++++- turbo.json | 4 + 74 files changed, 6828 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/assets/asset-access.service.ts create mode 100644 apps/api/src/assets/asset-actor.ts create mode 100644 apps/api/src/assets/asset-catalog.service.ts create mode 100644 apps/api/src/assets/asset-config.ts create mode 100644 apps/api/src/assets/asset-error.ts create mode 100644 apps/api/src/assets/asset-files.service.ts create mode 100644 apps/api/src/assets/asset-mutation.service.ts create mode 100644 apps/api/src/assets/asset-openapi.ts create mode 100644 apps/api/src/assets/asset-purge.ts create mode 100644 apps/api/src/assets/asset-request.ts create mode 100644 apps/api/src/assets/asset-responses.ts create mode 100644 apps/api/src/assets/asset-rest.ts create mode 100644 apps/api/src/assets/asset-storage-client.ts create mode 100644 apps/api/src/assets/asset-storage-errors.ts create mode 100644 apps/api/src/assets/asset-storage-objects.ts create mode 100644 apps/api/src/assets/asset-storage-signing.ts create mode 100644 apps/api/src/assets/asset-storage-validation.ts create mode 100644 apps/api/src/assets/asset-storage.service.ts create mode 100644 apps/api/src/assets/asset-upload-create.service.ts create mode 100644 apps/api/src/assets/asset-upload-grants.service.ts create mode 100644 apps/api/src/assets/asset-upload-source.service.ts create mode 100644 apps/api/src/assets/asset-uploads.service.ts create mode 100644 apps/api/src/assets/asset-worker-delete.ts create mode 100644 apps/api/src/assets/asset-worker-errors.ts create mode 100644 apps/api/src/assets/asset-worker-finalize.ts create mode 100644 apps/api/src/assets/asset-worker-lease.ts create mode 100644 apps/api/src/assets/asset-worker-retry.ts create mode 100644 apps/api/src/assets/asset-worker-sweep.ts create mode 100644 apps/api/src/assets/asset-worker.controller.ts create mode 100644 apps/api/src/assets/asset-worker.service.ts create mode 100644 apps/api/src/assets/assets.contracts.ts create mode 100644 apps/api/src/assets/assets.module.ts create mode 100644 apps/api/src/assets/assets.router.ts create mode 100644 apps/api/src/assets/assets.service.ts create mode 100644 apps/api/test/asset-purge-automatic.integration.spec.ts create mode 100644 apps/api/test/asset-purge.fixture.ts create mode 100644 apps/api/test/asset-purge.integration.spec.ts create mode 100644 apps/api/test/asset-storage-errors.spec.ts create mode 100644 apps/api/test/asset-storage.fixture.ts create mode 100644 apps/api/test/asset-storage.spec.ts create mode 100644 apps/api/test/assets-core-abort.integration.spec.ts create mode 100644 apps/api/test/assets-core-deletion.integration.spec.ts create mode 100644 apps/api/test/assets-core-legacy.integration.spec.ts create mode 100644 apps/api/test/assets-core-mailbox.integration.spec.ts create mode 100644 apps/api/test/assets-core-project.integration.spec.ts create mode 100644 apps/api/test/assets-core-sources.integration.spec.ts create mode 100644 apps/api/test/assets-core-worker.integration.spec.ts create mode 100644 apps/api/test/assets-core.fixture.ts create mode 100644 apps/api/test/assets-core.integration.spec.ts create mode 100644 apps/api/test/assets-core.storage.fixture.ts create mode 100644 apps/api/test/assets-http-contract.e2e.spec.ts create mode 100644 apps/api/test/assets-http-flow.e2e.spec.ts create mode 100644 apps/api/test/assets-http-lifecycle.e2e.spec.ts create mode 100644 apps/api/test/assets-http.e2e.spec.ts create mode 100644 apps/api/test/assets-http.fixture.ts create mode 100644 docs/asset-api-contract.md create mode 100644 docs/assets-storage-operations.md create mode 100644 packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql diff --git a/.env.example b/.env.example index 8dec3d5ae..4e794fedb 100644 --- a/.env.example +++ b/.env.example @@ -187,6 +187,14 @@ GOOGLE_CLIENT_SECRET="" # https://vercel.com/docs/ai-gateway # AI_GATEWAY_API_KEY="" +# Cloudflare R2 storage. Leave all four values empty to disable asset uploads. +# Create an R2 API token with object read and write access for the private bucket. +# The API signs direct browser and mobile transfers. It does not expose these keys. +# R2_ACCOUNT_ID="" +# R2_ACCESS_KEY_ID="" +# R2_SECRET_ACCESS_KEY="" +# R2_BUCKET="" + # ── Optional: operations ───────────────────────────────────────────────────── diff --git a/AGENTS.md b/AGENTS.md index 82f333d2a..8f561f342 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ rules and skills you read. ## Always true +- Keep each code file at 200 lines or fewer. Split larger files by responsibility. - **Never add code comments.** Not to new code, not to code you edit. - **No coauthoring commits.** No `Co-Authored-By` trailer, ever. - **Intelligence lives in `apps/agent`, never in the API.** No vendor client, no @@ -260,7 +261,7 @@ IDs to commits or pull requests. Use conventional commit messages and pull request titles. -Open pull requests against `master` in `romanbsd/compcrm` unless the user states +Open pull requests against `master` in `jobsteward/compcrm` unless the user states another repository or base branch. This rule overrides repository and base branch defaults in other project documents and skills. diff --git a/apps/api/package.json b/apps/api/package.json index 9ccbf28dc..e67dbab49 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,8 @@ "clean": "rm -rf .turbo dist node_modules src/generated" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1127.0", + "@aws-sdk/s3-request-presigner": "^3.1127.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 14264797d..0e1f452dc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { ActivitiesModule } from "./activities/activities.module"; import { AgentModule } from "./agent/agent.module"; import { ApiKeysModule } from "./api-keys/api-keys.module"; import { ArchiveModule } from "./archive/archive.module"; +import { AssetsModule } from "./assets/assets.module"; import { AuthModule } from "./auth/auth.module"; import { BackfillModule } from "./backfill/backfill.module"; import { AppCacheModule } from "./cache/cache.module"; @@ -83,6 +84,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; TelemetryModule, TrackingModule, ArchiveModule, + AssetsModule, SavedViewsModule, PushTokensModule, ], diff --git a/apps/api/src/assets/asset-access.service.ts b/apps/api/src/assets/asset-access.service.ts new file mode 100644 index 000000000..c1da9593f --- /dev/null +++ b/apps/api/src/assets/asset-access.service.ts @@ -0,0 +1,124 @@ +import type { Prisma } from "@crm/db"; +import type { AssetActor } from "./asset-actor"; +import { AssetError } from "./asset-error"; + +export function missing(): never { + throw new AssetError( + 404, + "RESOURCE_NOT_FOUND", + "The record does not exist or is inaccessible.", + ); +} + +export async function findAssetProject( + tx: Prisma.TransactionClient, + actor: AssetActor, + projectId: string, + lock = false, +) { + if (lock) + await tx.$queryRaw`SELECT "id" FROM "deal" WHERE "id" = ${projectId} FOR UPDATE`; + const project = await tx.deal.findUnique({ where: { id: projectId } }); + if (!project) missing(); + if (actor.type === "USER") { + if ( + !(await tx.user.findUnique({ + where: { id: actor.userId }, + select: { id: true }, + })) + ) + missing(); + } else { + const message = await tx.emailMessage.findUnique({ + where: { id: actor.messageId }, + include: { thread: true }, + }); + if (!message || message.syncedByUserId !== actor.mailboxOwnerId) missing(); + const mailboxSources: string[] = []; + if (message.gmailMessageId) mailboxSources.push("gmail"); + if (message.outlookMessageId) mailboxSources.push("outlook"); + if ( + !(await tx.mailboxSync.findFirst({ + where: { + userId: actor.mailboxOwnerId, + source: { in: mailboxSources }, + }, + select: { id: true }, + })) + ) + missing(); + if ( + message.thread.companyId !== null && + message.thread.companyId !== project.companyId + ) { + throw new AssetError( + 409, + "PROJECT_MISMATCH", + "The email belongs to another customer.", + ); + } + } + return project; +} + +export function requireActiveProject(project: { archivedAt: Date | null }) { + if (project.archivedAt) + throw new AssetError( + 409, + "PROJECT_ARCHIVED", + "Restore the project before uploading files.", + ); +} + +export async function findAssetUpload( + tx: Prisma.TransactionClient, + projectId: string, + uploadId: string, + actor: AssetActor, +) { + const upload = await tx.assetUpload.findFirst({ + where: { id: uploadId, projectId }, + }); + if (!upload) missing(); + if ( + actor.type === "SYSTEM" && + (upload.emailMessageId !== actor.messageId || + upload.mailboxOwnerId !== actor.mailboxOwnerId) + ) + missing(); + return upload; +} + +export async function findProjectAsset( + tx: Prisma.TransactionClient, + projectId: string, + assetId: string, + actor: AssetActor, +) { + const asset = await tx.artifact.findFirst({ + where: { id: assetId, dealId: projectId }, + include: { deal: { select: { companyId: true } } }, + }); + if (!asset) missing(); + if (actor.type === "SYSTEM" && asset.emailMessageId !== actor.messageId) + missing(); + return asset; +} + +export async function validateUploadActivity( + tx: Prisma.TransactionClient, + projectId: string, + activityId?: string | null, +) { + if (!activityId) return; + const activity = await tx.activity.findUnique({ + where: { id: activityId }, + }); + if (!activity) missing(); + if (activity.type !== "MEETING" || activity.dealId !== projectId) + throw new AssetError( + 409, + "PROJECT_MISMATCH", + "The meeting belongs to another project.", + ); +} diff --git a/apps/api/src/assets/asset-actor.ts b/apps/api/src/assets/asset-actor.ts new file mode 100644 index 000000000..00aea0c2c --- /dev/null +++ b/apps/api/src/assets/asset-actor.ts @@ -0,0 +1,8 @@ +export type AssetActor = + | { type: "USER"; userId: string } + | { type: "SYSTEM"; mailboxOwnerId: string; messageId: string }; +export function assetActorKey(actor: AssetActor) { + return actor.type === "USER" + ? `user:${actor.userId}` + : `mailbox:${actor.mailboxOwnerId}`; +} diff --git a/apps/api/src/assets/asset-catalog.service.ts b/apps/api/src/assets/asset-catalog.service.ts new file mode 100644 index 000000000..afc6da021 --- /dev/null +++ b/apps/api/src/assets/asset-catalog.service.ts @@ -0,0 +1,125 @@ +import type { Db, Prisma } from "@crm/db"; +import { + findAssetProject, + findProjectAsset, + missing, +} from "./asset-access.service"; +import type { AssetActor } from "./asset-actor"; +import { AssetError } from "./asset-error"; +import { assetResponse } from "./asset-responses"; +import { + type AssetListInput, + assetListInput, + customerAssetListInput, +} from "./assets.contracts"; + +export class AssetCatalog { + constructor(private readonly db: Db) {} + + async listCustomerAssets( + actor: AssetActor, + customerId: string, + raw: AssetListInput, + ) { + const input = customerAssetListInput.parse(raw); + return this.db.$transaction(async (tx) => { + if ( + !(await tx.company.findUnique({ + where: { id: customerId }, + select: { id: true }, + })) + ) + missing(); + if (actor.type === "SYSTEM") missing(); + if ( + !(await tx.user.findUnique({ + where: { id: actor.userId }, + select: { id: true }, + })) + ) + missing(); + if (input.projectId) { + const project = await findAssetProject(tx, actor, input.projectId); + if (project.companyId !== customerId) + throw new AssetError( + 409, + "PROJECT_MISMATCH", + "The project belongs to another customer.", + ); + } + return this.list( + tx, + { deal: { companyId: customerId }, dealId: input.projectId }, + input, + ); + }); + } + + async listProjectAssets( + actor: AssetActor, + projectId: string, + raw: AssetListInput, + ) { + const input = assetListInput.parse(raw); + return this.db.$transaction(async (tx) => { + await findAssetProject(tx, actor, projectId); + return this.list( + tx, + { + dealId: projectId, + emailMessageId: actor.type === "SYSTEM" ? actor.messageId : undefined, + }, + input, + ); + }); + } + + private async list( + tx: Prisma.TransactionClient, + parent: Prisma.ArtifactWhereInput, + input: AssetListInput, + ) { + const where: Prisma.ArtifactWhereInput = { + ...parent, + status: { in: ["READY", "UNVERIFIED"] }, + activityId: input.activityId, + kind: input.kind, + source: input.source, + }; + const total = await tx.artifact.count({ where }); + const offset = (input.page - 1) * input.pageSize; + if (offset >= total) + return { + items: [], + page: input.page, + pageSize: input.pageSize, + total, + hasNextPage: false, + }; + const items = await tx.artifact.findMany({ + where, + include: { deal: { select: { companyId: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + skip: offset, + take: input.pageSize, + }); + return { + items: items.map(assetResponse), + page: input.page, + pageSize: input.pageSize, + total, + hasNextPage: input.page * input.pageSize < total, + }; + } + + async getAsset(actor: AssetActor, projectId: string, assetId: string) { + return this.db.$transaction(async (tx) => { + await findAssetProject(tx, actor, projectId); + return { + asset: assetResponse( + await findProjectAsset(tx, projectId, assetId, actor), + ), + }; + }); + } +} diff --git a/apps/api/src/assets/asset-config.ts b/apps/api/src/assets/asset-config.ts new file mode 100644 index 000000000..aefc7a9a5 --- /dev/null +++ b/apps/api/src/assets/asset-config.ts @@ -0,0 +1,29 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +export const ASSETS = { + uploadUrlMs: 15 * MINUTE_MS, + downloadUrlMs: 15 * MINUTE_MS, + intentMs: 24 * HOUR_MS, + replayMs: 24 * HOUR_MS, + temporaryRetentionMs: 7 * DAY_MS, + maxPresignSeconds: (7 * DAY_MS) / SECOND_MS, + reservationLimit: 20, + maxSingleUploadBytes: 5 * 1024 ** 3 - 5 * 1024 ** 2, + network: { + connectionTimeoutMs: 5 * SECOND_MS, + requestTimeoutMs: 30 * SECOND_MS, + }, + worker: { + leaseMs: 2 * MINUTE_MS, + heartbeatMs: 30 * SECOND_MS, + batchSize: 20, + deadlineMs: 50 * SECOND_MS, + maxFinalizeAttempts: 5, + finalizeDeadlineMs: 24 * HOUR_MS, + retryBaseMs: 5 * SECOND_MS, + retryMaxMs: HOUR_MS, + }, +} as const; diff --git a/apps/api/src/assets/asset-error.ts b/apps/api/src/assets/asset-error.ts new file mode 100644 index 000000000..23c112731 --- /dev/null +++ b/apps/api/src/assets/asset-error.ts @@ -0,0 +1,25 @@ +import { HttpException } from "@nestjs/common"; +import { z } from "zod"; + +const assetErrorDetails = z.object({ + state: z.string().optional(), + maxBytes: z.number().optional(), + maxSeconds: z.number().optional(), + fields: z + .array(z.object({ field: z.string(), message: z.string() })) + .optional(), +}); +export type AssetErrorDetails = z.infer; + +export class AssetError extends HttpException { + constructor( + status: number, + readonly code: string, + message: string, + readonly details?: AssetErrorDetails, + readonly retryable = false, + ) { + super({ code, message, details, retryable }, status); + this.name = "AssetError"; + } +} diff --git a/apps/api/src/assets/asset-files.service.ts b/apps/api/src/assets/asset-files.service.ts new file mode 100644 index 000000000..b6b5b832e --- /dev/null +++ b/apps/api/src/assets/asset-files.service.ts @@ -0,0 +1,104 @@ +import type { Db } from "@crm/db"; +import { findAssetProject, findProjectAsset } from "./asset-access.service"; +import type { AssetActor } from "./asset-actor"; +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; +import type { AssetMutations } from "./asset-mutation.service"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; +import type { AssetStorageService } from "./asset-storage.service"; +import { requireAssetStorage } from "./asset-upload-grants.service"; +import { assetDeletionSchema, assetDownloadSchema } from "./assets.contracts"; + +export class AssetFiles { + constructor( + private readonly db: Db, + private readonly storage: AssetStorageService, + private readonly mutations: AssetMutations, + ) {} + + async downloadAsset(actor: AssetActor, projectId: string, assetId: string) { + return this.db.$transaction(async (tx) => { + await findAssetProject(tx, actor, projectId, true); + const asset = await findProjectAsset(tx, projectId, assetId, actor); + if ( + asset.status !== "READY" || + !asset.storageBucket || + asset.sizeBytes === null + ) + throw new AssetError( + 409, + "ASSET_NOT_READY", + "The asset is not available for download.", + { state: asset.status }, + ); + requireAssetStorage(this.storage); + const expiresAt = new Date(Date.now() + ASSETS.downloadUrlMs); + let url: string; + try { + url = await this.storage.presignGet( + asset.storageBucket, + asset.storageKey, + asset.fileName, + asset.contentType, + expiresAt, + ); + } catch (error) { + if (error instanceof AssetError) throw error; + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "File storage is temporarily unavailable.", + undefined, + true, + ); + } + return assetDownloadSchema.parse({ + assetId, + url, + method: "GET", + headers: {}, + expiresAt: expiresAt.toISOString(), + fileName: asset.fileName, + contentType: asset.contentType, + sizeBytes: Number(asset.sizeBytes), + }); + }); + } + + async deleteAsset( + actor: AssetActor, + projectId: string, + assetId: string, + key: string, + ) { + return this.mutations.run( + actor, + projectId, + "DELETE_ASSET", + `/projects/${projectId}/assets/${assetId}`, + key, + {}, + assetDeletionSchema, + async (tx) => { + const asset = await findProjectAsset(tx, projectId, assetId, actor); + if (asset.status === "DELETED") return { assetId, status: "DELETED" }; + await tx.artifact.update({ + where: { id: assetId }, + data: { status: "DELETING" }, + }); + await tx.assetEmailSource.updateMany({ + where: { assetId }, + data: { deletedAt: new Date() }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: asset.storageBucket, + objectKey: asset.storageKey, + artifactId: assetId, + }); + return { assetId, status: "DELETING" }; + }, + { assetId }, + ); + } +} diff --git a/apps/api/src/assets/asset-mutation.service.ts b/apps/api/src/assets/asset-mutation.service.ts new file mode 100644 index 000000000..c883ae6a1 --- /dev/null +++ b/apps/api/src/assets/asset-mutation.service.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import type { Db, Prisma } from "@crm/db"; +import type { z } from "zod"; +import { + findAssetProject, + findAssetUpload, + findProjectAsset, + missing, +} from "./asset-access.service"; +import { type AssetActor, assetActorKey } from "./asset-actor"; +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; + +export function hashAssetRequest(value: Prisma.InputJsonValue) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +export class AssetMutations { + constructor(private readonly db: Db) {} + + async run( + actor: AssetActor, + projectId: string, + operation: string, + path: string, + key: string, + input: Prisma.InputJsonValue, + schema: z.ZodType, + action: ( + tx: Prisma.TransactionClient, + project: Awaited>, + ) => Promise, + target?: { + uploadId?: string; + assetId?: string; + activityId?: string | null; + emailMessageId?: string; + }, + ) { + if (!/^[\x20-\x7e]{1,128}$/.test(key ?? "")) + throw new AssetError( + 400, + "VALIDATION_ERROR", + "A valid Idempotency-Key is required.", + ); + const actorKey = assetActorKey(actor); + const requestHash = hashAssetRequest(input); + return this.db.$transaction( + async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${actorKey}, 0))`; + const project = await findAssetProject(tx, actor, projectId, true); + if (target?.uploadId) + await findAssetUpload(tx, projectId, target.uploadId, actor); + if (target?.assetId) + await findProjectAsset(tx, projectId, target.assetId, actor); + if ( + target?.activityId && + !(await tx.activity.findUnique({ + where: { id: target.activityId }, + select: { id: true }, + })) + ) + missing(); + if ( + target?.emailMessageId && + !(await tx.emailMessage.findUnique({ + where: { id: target.emailMessageId }, + select: { id: true }, + })) + ) + missing(); + const identity = { actorKey, operation, path, idempotencyKey: key }; + const prior = await tx.assetApiRequest.findUnique({ + where: { actorKey_operation_path_idempotencyKey: identity }, + }); + if (prior && prior.expiresAt > new Date()) { + if (prior.requestHash !== requestHash) + throw new AssetError( + 409, + "IDEMPOTENCY_CONFLICT", + "The idempotency key has different request data.", + ); + return schema.parse(prior.responseBody); + } + const response = schema.parse(await action(tx, project)); + const data = { + requestHash, + responseStatus: 200, + responseBody: JSON.parse( + JSON.stringify(response), + ) as Prisma.InputJsonValue, + expiresAt: new Date(Date.now() + ASSETS.replayMs), + }; + await tx.assetApiRequest.upsert({ + where: { actorKey_operation_path_idempotencyKey: identity }, + create: { ...identity, ...data }, + update: data, + }); + return response; + }, + { timeout: ASSETS.worker.leaseMs }, + ); + } +} diff --git a/apps/api/src/assets/asset-openapi.ts b/apps/api/src/assets/asset-openapi.ts new file mode 100644 index 000000000..d87ecffd8 --- /dev/null +++ b/apps/api/src/assets/asset-openapi.ts @@ -0,0 +1,83 @@ +import type { OpenAPIObject } from "trpc-to-openapi"; +import { z } from "zod"; +import { type RestMethod, restMeta } from "../trpc/openapi"; +import { assetErrorEnvelopeSchema } from "./assets.contracts"; + +const requestHeaders = z.object({ + "X-Request-Id": z.string().max(128).optional(), +}); +const mutationHeaders = requestHeaders.extend({ + "Idempotency-Key": z + .string() + .min(1) + .max(128) + .regex(/^[\x20-\x7e]+$/), +}); + +export function assetRestMeta(method: RestMethod, path: `/${string}`) { + const meta = restMeta(method, path, ["Assets"]); + if (meta.openapi) { + meta.openapi.requestHeaders = + method === "GET" ? requestHeaders : mutationHeaders; + meta.openapi.responseHeaders = z.object({ + "Cache-Control": z.literal("private, no-store"), + "X-Request-Id": z.string(), + }); + meta.openapi.errorResponses = [400, 401, 403, 404, 409, 413, 429, 500, 503]; + } + return meta; +} + +export const assetRoutes = { + createUpload: assetRestMeta("POST", "/v1/projects/{projectId}/asset-uploads"), + getUpload: assetRestMeta( + "GET", + "/v1/projects/{projectId}/asset-uploads/{uploadId}", + ), + renewUpload: assetRestMeta( + "POST", + "/v1/projects/{projectId}/asset-uploads/{uploadId}/url", + ), + confirmUpload: assetRestMeta( + "POST", + "/v1/projects/{projectId}/asset-uploads/{uploadId}/confirm", + ), + cancelUpload: assetRestMeta( + "DELETE", + "/v1/projects/{projectId}/asset-uploads/{uploadId}", + ), + listCustomerAssets: assetRestMeta("GET", "/v1/customers/{customerId}/assets"), + listProjectAssets: assetRestMeta("GET", "/v1/projects/{projectId}/assets"), + getAsset: assetRestMeta("GET", "/v1/projects/{projectId}/assets/{assetId}"), + downloadAsset: assetRestMeta( + "GET", + "/v1/projects/{projectId}/assets/{assetId}/download", + ), + deleteAsset: assetRestMeta( + "DELETE", + "/v1/projects/{projectId}/assets/{assetId}", + ), +}; + +export function describeAssetErrors(document: OpenAPIObject): void { + for (const [path, methods] of Object.entries(document.paths ?? {})) { + if ( + !/^\/v1\/(?:projects|customers)\/\{[^}]+\}\/(?:assets|asset-uploads)(?:\/|$)/.test( + path, + ) + ) + continue; + for (const method of ["get", "post", "delete"] as const) { + const responses = methods[method]?.responses; + if (!responses) continue; + for (const [status, response] of Object.entries(responses)) { + if (Number(status) < 400 || "$ref" in response) continue; + response.content = { + "application/json": { + schema: z.toJSONSchema(assetErrorEnvelopeSchema), + }, + }; + } + } + } +} diff --git a/apps/api/src/assets/asset-purge.ts b/apps/api/src/assets/asset-purge.ts new file mode 100644 index 000000000..d5fae056b --- /dev/null +++ b/apps/api/src/assets/asset-purge.ts @@ -0,0 +1,73 @@ +import type { Prisma } from "@crm/db"; + +export async function enqueueAssetObjectDeletion( + tx: Prisma.TransactionClient, + input: { + projectId: string; + bucket: string | null; + objectKey: string; + uploadId?: string; + artifactId?: string; + temporary?: boolean; + }, +) { + const operationKey = input.temporary + ? `temporary:${input.uploadId}` + : input.artifactId + ? `artifact:${input.artifactId}` + : `orphan:${input.uploadId}`; + return tx.assetStorageJob.upsert({ + where: { operationKey }, + create: { + ...input, + operationKey, + operation: "DELETE_OBJECT", + nextAttemptAt: new Date(), + }, + update: {}, + }); +} + +export async function enqueueProjectAssetPurge( + tx: Prisma.TransactionClient, + projectId: string, +) { + await tx.$queryRaw`SELECT "id" FROM "deal" WHERE "id" = ${projectId} FOR UPDATE`; + const artifacts = await tx.artifact.findMany({ + where: { dealId: projectId }, + }); + const uploads = await tx.assetUpload.findMany({ where: { projectId } }); + for (const asset of artifacts) { + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: asset.storageBucket, + objectKey: asset.storageKey, + artifactId: asset.id, + }); + } + for (const upload of uploads) { + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId: upload.id, + temporary: true, + }); + if (!upload.assetId) { + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: upload.bucket, + objectKey: upload.finalKey, + uploadId: upload.id, + }); + } + } + await tx.assetUpload.updateMany({ + where: { projectId, status: { in: ["PENDING", "FINALIZING"] } }, + data: { status: "CANCELED", completedAt: new Date() }, + }); + await tx.assetEmailSource.updateMany({ + where: { projectId, deletedAt: null }, + data: { deletedAt: new Date() }, + }); +} diff --git a/apps/api/src/assets/asset-request.ts b/apps/api/src/assets/asset-request.ts new file mode 100644 index 000000000..4bfb95ebb --- /dev/null +++ b/apps/api/src/assets/asset-request.ts @@ -0,0 +1,19 @@ +import type { AuthedTrpcContext } from "../trpc/context.types"; +import type { AssetActor } from "./asset-actor"; +import { AssetError } from "./asset-error"; + +export function assetUser(ctx: AuthedTrpcContext): AssetActor { + return { type: "USER", userId: ctx.user.id }; +} + +export function idempotencyKey(ctx: AuthedTrpcContext): string { + const key = ctx.req?.header("Idempotency-Key"); + if (!key || !/^[\x20-\x7e]{1,128}$/.test(key)) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "A valid Idempotency-Key header is required.", + ); + } + return key; +} diff --git a/apps/api/src/assets/asset-responses.ts b/apps/api/src/assets/asset-responses.ts new file mode 100644 index 000000000..0a6d98284 --- /dev/null +++ b/apps/api/src/assets/asset-responses.ts @@ -0,0 +1,53 @@ +import type { + ArtifactModel as Artifact, + AssetUploadModel as AssetUpload, +} from "@crm/db"; + +export function uploadResponse(upload: AssetUpload) { + return { + id: upload.id, + customerId: upload.customerId, + projectId: upload.projectId, + status: upload.status, + expiresAt: upload.expiresAt.toISOString(), + assetId: upload.assetId, + failure: upload.failureCode + ? { + code: upload.failureCode, + message: upload.failureMessage ?? "File finalization failed.", + } + : null, + }; +} + +export function assetResponse( + asset: Artifact & { deal: { companyId: string } }, +) { + return { + id: asset.id, + customerId: asset.deal.companyId, + projectId: asset.dealId, + activityId: asset.activityId, + fileName: asset.fileName, + contentType: asset.contentType, + sizeBytes: asset.sizeBytes === null ? null : Number(asset.sizeBytes), + kind: asset.kind, + source: asset.source, + emailSource: + asset.emailMessageId && asset.emailAttachmentId + ? { + messageId: asset.emailMessageId, + attachmentId: asset.emailAttachmentId, + } + : null, + uploadedById: asset.uploadedById, + durationMilliseconds: + asset.durationMilliseconds === null + ? null + : Number(asset.durationMilliseconds), + capturedAt: asset.capturedAt?.toISOString() ?? null, + createdAt: asset.createdAt.toISOString(), + status: asset.status, + deletedAt: asset.deletedAt?.toISOString() ?? null, + }; +} diff --git a/apps/api/src/assets/asset-rest.ts b/apps/api/src/assets/asset-rest.ts new file mode 100644 index 000000000..91ad5d492 --- /dev/null +++ b/apps/api/src/assets/asset-rest.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import type { TRPCError } from "@trpc/server"; +import type { Request, Response } from "express"; +import { z } from "zod"; +import { AssetError } from "./asset-error"; +import { assetErrorEnvelopeSchema } from "./assets.contracts"; + +const domainFailure = assetErrorEnvelopeSchema.shape.error + .omit({ requestId: true }) + .partial({ retryable: true }); +const rawMutationNumbers = z.object({ + sizeBytes: z.number().optional(), + durationMilliseconds: z.number().nullable().optional(), +}); +type AssetFailure = { + status: number; + body: { + error: z.infer & { + requestId: string; + retryable: boolean; + }; + }; +}; +type AssetRequestState = { requestId: string; failure?: AssetFailure }; +const requests = new WeakMap(); + +export function prepareAssetRestResponse(req: Request, res: Response): void { + if ( + !/^\/v1\/(?:projects|customers)\/[^/]+\/(?:asset-uploads|assets)(?:\/|$)/.test( + req.path, + ) + ) + return; + const supplied = req.header("X-Request-Id"); + const requestId = + supplied && /^[A-Za-z0-9._:-]{1,128}$/.test(supplied) + ? supplied + : randomUUID(); + const state: AssetRequestState = { requestId }; + requests.set(req, state); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Request-Id", requestId); + res.end = new Proxy(res.end, { + apply(end, response, args) { + requests.delete(req); + if (!state.failure) return Reflect.apply(end, response, args); + res.statusCode = state.failure.status; + res.removeHeader("Content-Length"); + if (state.failure.status === 429) res.setHeader("Retry-After", "60"); + return Reflect.apply(end, response, [JSON.stringify(state.failure.body)]); + }, + }); +} + +export function validateAssetRestRequest(req: Request): void { + if (!requests.has(req)) return; + const url = new URL(req.originalUrl, "http://localhost"); + const forbiddenQuery = ["customerId", "uploadId", "assetId"]; + if (!/^\/rest\/v1\/customers\/[^/]+\/assets\/?$/.test(url.pathname)) + forbiddenQuery.push("projectId"); + if (forbiddenQuery.some((key) => url.searchParams.has(key))) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "Path identifiers cannot appear in the query.", + ); + } + if (req.method === "POST") { + if (url.search) + throw new AssetError( + 400, + "VALIDATION_ERROR", + "Upload mutations do not accept query parameters.", + ); + const body = z.record(z.string(), z.json()).safeParse(req.body); + if ( + !body.success || + ["projectId", "uploadId", "assetId", "customerId"].some( + (key) => key in body.data, + ) + ) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "Send a JSON object without path identifiers.", + ); + } + if (!rawMutationNumbers.safeParse(body.data).success) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "Byte counts and durations must use JSON numbers.", + ); + } + } + if ( + (req.method === "DELETE" || req.method === "GET") && + (req.headers["transfer-encoding"] || + Number(req.header("content-length") ?? 0) > 0) + ) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "This operation does not accept a request body.", + ); + } +} + +export function recordAssetRestError(req: Request, error: TRPCError): void { + const state = requests.get(req); + if (!state) return; + const cause = error.cause; + if (cause instanceof AssetError) { + const parsed = domainFailure.safeParse(cause.getResponse()); + if (parsed.success) { + state.failure = { + status: cause.getStatus(), + body: { + error: { + ...parsed.data, + requestId: state.requestId, + retryable: parsed.data.retryable ?? false, + }, + }, + }; + return; + } + } + const failure = fallbackFailure(error); + state.failure = { + status: failure.status, + body: { + error: { + code: failure.code, + message: failure.message, + requestId: state.requestId, + retryable: false, + }, + }, + }; + if (error.code === "BAD_REQUEST" && cause instanceof z.ZodError) { + state.failure.body.error.details = { + fields: cause.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }; + } +} + +function fallbackFailure(error: TRPCError) { + switch (error.code) { + case "UNAUTHORIZED": + return { + status: 401, + code: "AUTH_REQUIRED", + message: "Authentication is required.", + }; + case "FORBIDDEN": + return { + status: 403, + code: "FORBIDDEN", + message: "This operation is not permitted.", + }; + case "NOT_FOUND": + return { + status: 404, + code: "RESOURCE_NOT_FOUND", + message: "The resource was not found.", + }; + case "BAD_REQUEST": + case "PARSE_ERROR": + case "UNSUPPORTED_MEDIA_TYPE": + case "PAYLOAD_TOO_LARGE": + return { + status: 400, + code: "VALIDATION_ERROR", + message: "The request metadata is invalid.", + }; + default: + return { + status: 500, + code: "INTERNAL_ERROR", + message: "The operation failed. Check its status before retrying.", + }; + } +} diff --git a/apps/api/src/assets/asset-storage-client.ts b/apps/api/src/assets/asset-storage-client.ts new file mode 100644 index 000000000..824f5d37a --- /dev/null +++ b/apps/api/src/assets/asset-storage-client.ts @@ -0,0 +1,92 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigService } from "@nestjs/config"; +import type { EnvironmentVariables } from "../config/env.validation"; +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; + +export type R2Config = { + accountId?: string; + accessKeyId?: string; + secretAccessKey?: string; + bucket?: string; +}; + +export function readR2Config( + config?: ConfigService, +): R2Config { + return { + accountId: readConfigValue(config, "R2_ACCOUNT_ID"), + accessKeyId: readConfigValue(config, "R2_ACCESS_KEY_ID"), + secretAccessKey: readConfigValue(config, "R2_SECRET_ACCESS_KEY"), + bucket: readConfigValue(config, "R2_BUCKET"), + }; +} + +export function isR2Configured(r2: R2Config): boolean { + return Boolean( + r2.accountId && r2.accessKeyId && r2.secretAccessKey && r2.bucket, + ); +} + +export function createR2Client(r2: R2Config): S3Client | null { + if (!isR2Configured(r2)) return null; + + return new S3Client({ + region: "auto", + endpoint: `https://${r2.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: r2.accessKeyId as string, + secretAccessKey: r2.secretAccessKey as string, + }, + maxAttempts: 1, + requestChecksumCalculation: "WHEN_REQUIRED", + responseChecksumValidation: "WHEN_REQUIRED", + requestHandler: { + connectionTimeout: ASSETS.network.connectionTimeoutMs, + requestTimeout: ASSETS.network.requestTimeoutMs, + throwOnRequestTimeout: true, + }, + }); +} + +export function requireStorageClient( + client: S3Client | null, + configured: boolean, +): S3Client { + if (!configured || !client) { + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "Object storage is not configured.", + undefined, + false, + ); + } + + return client; +} + +export function requireBucketName(bucket: string): void { + if (!bucket.trim()) { + throw new AssetError( + 400, + "VALIDATION_ERROR", + "A storage bucket is required.", + undefined, + false, + ); + } +} + +function readConfigValue( + config: ConfigService | undefined, + key: keyof Pick< + EnvironmentVariables, + "R2_ACCOUNT_ID" | "R2_ACCESS_KEY_ID" | "R2_SECRET_ACCESS_KEY" | "R2_BUCKET" + >, +): string | undefined { + const configured = config?.get(key); + const value = configured ?? process.env[key]; + const normalized = value?.trim(); + return normalized || undefined; +} diff --git a/apps/api/src/assets/asset-storage-errors.ts b/apps/api/src/assets/asset-storage-errors.ts new file mode 100644 index 000000000..828d449ae --- /dev/null +++ b/apps/api/src/assets/asset-storage-errors.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { AssetError } from "./asset-error"; + +const s3ErrorSchema = z.object({ + $metadata: z + .object({ httpStatusCode: z.number().int().optional() }) + .optional(), + Code: z.string().optional(), + code: z.string().optional(), + name: z.string().optional(), + statusCode: z.number().int().optional(), +}); + +type S3Error = z.infer; + +export function parseS3Error(cause: unknown): S3Error { + const parsed = s3ErrorSchema.safeParse(cause); + return parsed.success ? parsed.data : {}; +} + +export function isNotFound(error: S3Error): boolean { + const status = statusCode(error); + const name = errorName(error); + return ( + status === 404 || + name === "NotFound" || + name === "NoSuchKey" || + name === "NoSuchObject" + ); +} + +export function isPreconditionFailure(error: S3Error): boolean { + const status = statusCode(error); + const name = errorName(error); + return status === 412 || name === "PreconditionFailed"; +} + +export function storageError(error: S3Error): AssetError { + const status = statusCode(error); + const retryable = status === undefined || status >= 500 || status === 429; + return new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "Object storage request failed.", + undefined, + retryable, + ); +} + +function statusCode(error: S3Error): number | undefined { + return error.$metadata?.httpStatusCode ?? error.statusCode; +} + +function errorName(error: S3Error): string | undefined { + return error.name ?? error.Code ?? error.code; +} diff --git a/apps/api/src/assets/asset-storage-objects.ts b/apps/api/src/assets/asset-storage-objects.ts new file mode 100644 index 000000000..a433e3664 --- /dev/null +++ b/apps/api/src/assets/asset-storage-objects.ts @@ -0,0 +1,101 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { AssetError } from "./asset-error"; +import { + isNotFound, + isPreconditionFailure, + parseS3Error, + storageError, +} from "./asset-storage-errors"; + +export type AssetStorageHead = { + sizeBytes: number; + etag: string; + contentType: string | null; +}; + +export async function headObject( + client: S3Client, + bucket: string, + key: string, + signal?: AbortSignal, +): Promise { + try { + const response = await client.send( + new HeadObjectCommand({ Bucket: bucket, Key: key }), + { abortSignal: signal }, + ); + if (response.ContentLength === undefined || !response.ETag) { + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "Object storage returned incomplete metadata.", + undefined, + false, + ); + } + + return { + sizeBytes: response.ContentLength, + etag: response.ETag, + contentType: response.ContentType ?? null, + }; + } catch (error) { + if (error instanceof AssetError) throw error; + const parsedError = parseS3Error(error); + if (isNotFound(parsedError)) return null; + throw storageError(parsedError); + } +} + +export async function copyObject( + client: S3Client, + bucket: string, + sourceKey: string, + finalKey: string, + sourceEtag: string, + signal?: AbortSignal, +): Promise { + try { + await client.send( + new CopyObjectCommand({ + Bucket: bucket, + Key: finalKey, + CopySource: `${bucket}/${sourceKey}`, + CopySourceIfMatch: sourceEtag, + }), + { abortSignal: signal }, + ); + } catch (error) { + const parsedError = parseS3Error(error); + if (isPreconditionFailure(parsedError)) { + throw new AssetError( + 409, + "SOURCE_ETAG_MISMATCH", + "The source object changed before finalization.", + undefined, + false, + ); + } + throw storageError(parsedError); + } +} + +export async function deleteObject( + client: S3Client, + bucket: string, + key: string, + signal?: AbortSignal, +): Promise { + try { + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }), { + abortSignal: signal, + }); + } catch (error) { + throw storageError(parseS3Error(error)); + } +} diff --git a/apps/api/src/assets/asset-storage-signing.ts b/apps/api/src/assets/asset-storage-signing.ts new file mode 100644 index 000000000..e4e01c0dc --- /dev/null +++ b/apps/api/src/assets/asset-storage-signing.ts @@ -0,0 +1,74 @@ +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { parseS3Error, storageError } from "./asset-storage-errors"; + +const DEFAULT_CONTENT_TYPE = "application/octet-stream"; + +export async function presignPutObject( + client: S3Client, + bucket: string, + key: string, + contentType: string, + sizeBytes: number, + expiresIn: number, +): Promise { + try { + return await getSignedUrl( + client, + new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType || DEFAULT_CONTENT_TYPE, + ContentLength: sizeBytes, + }), + { + expiresIn, + signableHeaders: new Set(["content-length", "content-type"]), + }, + ); + } catch (error) { + throw storageError(parseS3Error(error)); + } +} + +export async function presignGetObject( + client: S3Client, + bucket: string, + key: string, + fileName: string, + contentType: string, + expiresIn: number, +): Promise { + try { + return await getSignedUrl( + client, + new GetObjectCommand({ + Bucket: bucket, + Key: key, + ResponseContentDisposition: contentDisposition(fileName), + ResponseContentType: contentType || DEFAULT_CONTENT_TYPE, + }), + { expiresIn }, + ); + } catch (error) { + throw storageError(parseS3Error(error)); + } +} + +function contentDisposition(fileName: string): string { + const fallback = + fileName + .normalize("NFKD") + .replace(/[^\x20-\x7e]/g, "_") + .replace(/[\\"]/g, "_") || "download"; + const encoded = encodeURIComponent(fileName).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); + + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/apps/api/src/assets/asset-storage-validation.ts b/apps/api/src/assets/asset-storage-validation.ts new file mode 100644 index 000000000..92aa1f26e --- /dev/null +++ b/apps/api/src/assets/asset-storage-validation.ts @@ -0,0 +1,39 @@ +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; + +export function validateUploadSize(sizeBytes: number): void { + if ( + !Number.isSafeInteger(sizeBytes) || + sizeBytes < 0 || + sizeBytes > ASSETS.maxSingleUploadBytes + ) { + throw new AssetError( + 413, + "UPLOAD_TOO_LARGE", + "The file exceeds the single-upload limit.", + { maxBytes: ASSETS.maxSingleUploadBytes }, + false, + ); + } +} + +export function expiresInSeconds(expiresAt: Date): number { + const remainingMs = expiresAt.getTime() - Date.now(); + const seconds = Math.ceil(remainingMs / 1_000); + + if ( + !Number.isFinite(seconds) || + seconds < 1 || + seconds > ASSETS.maxPresignSeconds + ) { + throw new AssetError( + 400, + "INVALID_EXPIRY", + "The storage grant expiry is invalid.", + { maxSeconds: ASSETS.maxPresignSeconds }, + false, + ); + } + + return seconds; +} diff --git a/apps/api/src/assets/asset-storage.service.ts b/apps/api/src/assets/asset-storage.service.ts new file mode 100644 index 000000000..7074d4f4f --- /dev/null +++ b/apps/api/src/assets/asset-storage.service.ts @@ -0,0 +1,135 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { Injectable, Optional } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { EnvironmentVariables } from "../config/env.validation"; +import { AssetError } from "./asset-error"; +import { + createR2Client, + isR2Configured, + type R2Config, + readR2Config, + requireBucketName, + requireStorageClient, +} from "./asset-storage-client"; +import { + type AssetStorageHead, + copyObject, + deleteObject, + headObject, +} from "./asset-storage-objects"; +import { presignGetObject, presignPutObject } from "./asset-storage-signing"; +import { + expiresInSeconds, + validateUploadSize, +} from "./asset-storage-validation"; + +export type { AssetStorageHead } from "./asset-storage-objects"; + +@Injectable() +export class AssetStorageService { + private readonly r2: R2Config; + private readonly client: S3Client | null; + + constructor( + @Optional() config?: ConfigService, + @Optional() client?: S3Client, + ) { + this.r2 = readR2Config(config); + this.client = client ?? createR2Client(this.r2); + } + + configured(): boolean { + return isR2Configured(this.r2); + } + + bucket(): string { + if (!this.r2.bucket) { + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "Object storage is not configured.", + undefined, + false, + ); + } + + return this.r2.bucket; + } + + async presignPut( + bucket: string, + key: string, + contentType: string, + sizeBytes: number, + expiresAt: Date, + ): Promise { + validateUploadSize(sizeBytes); + const expiresIn = expiresInSeconds(expiresAt); + const client = this.requireClient(); + requireBucketName(bucket); + return presignPutObject( + client, + bucket, + key, + contentType, + sizeBytes, + expiresIn, + ); + } + + async presignGet( + bucket: string, + key: string, + fileName: string, + contentType: string, + expiresAt: Date, + ): Promise { + const expiresIn = expiresInSeconds(expiresAt); + const client = this.requireClient(); + requireBucketName(bucket); + return presignGetObject( + client, + bucket, + key, + fileName, + contentType, + expiresIn, + ); + } + + async head( + bucket: string, + key: string, + signal?: AbortSignal, + ): Promise { + const client = this.requireClient(); + requireBucketName(bucket); + return headObject(client, bucket, key, signal); + } + + async copy( + bucket: string, + sourceKey: string, + finalKey: string, + sourceEtag: string, + signal?: AbortSignal, + ): Promise { + const client = this.requireClient(); + requireBucketName(bucket); + return copyObject(client, bucket, sourceKey, finalKey, sourceEtag, signal); + } + + async delete( + bucket: string, + key: string, + signal?: AbortSignal, + ): Promise { + const client = this.requireClient(); + requireBucketName(bucket); + return deleteObject(client, bucket, key, signal); + } + + private requireClient(): S3Client { + return requireStorageClient(this.client, this.configured()); + } +} diff --git a/apps/api/src/assets/asset-upload-create.service.ts b/apps/api/src/assets/asset-upload-create.service.ts new file mode 100644 index 000000000..ce4522ec8 --- /dev/null +++ b/apps/api/src/assets/asset-upload-create.service.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { + missing, + requireActiveProject, + validateUploadActivity, +} from "./asset-access.service"; +import { type AssetActor, assetActorKey } from "./asset-actor"; +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; +import { AssetMutations, hashAssetRequest } from "./asset-mutation.service"; +import type { AssetStorageService } from "./asset-storage.service"; +import { + renewUploadGrant, + requireAssetStorage, + uploadGrant, +} from "./asset-upload-grants.service"; +import { resolveUploadSource } from "./asset-upload-source.service"; +import { + type CreateUploadInput, + createUploadInput, + uploadGrantSchema, +} from "./assets.contracts"; + +export class AssetUploadCreation { + constructor( + private readonly mutations: AssetMutations, + private readonly storage: AssetStorageService, + ) {} + + async createUpload( + actor: AssetActor, + projectId: string, + raw: CreateUploadInput, + key: string, + ) { + const parsed = createUploadInput.safeParse(raw); + if (!parsed.success) + throw new AssetError( + 400, + "VALIDATION_ERROR", + "Upload metadata is invalid.", + ); + const input = parsed.data; + if ( + actor.type === "SYSTEM" && + (input.source !== "EMAIL_ATTACHMENT" || + input.emailSource?.messageId !== actor.messageId) + ) + missing(); + const metadata = { + fileName: input.fileName, + contentType: input.contentType, + sizeBytes: input.sizeBytes, + kind: input.kind, + source: input.source, + activityId: input.activityId ?? null, + durationMilliseconds: input.durationMilliseconds ?? null, + capturedAt: input.capturedAt + ? new Date(input.capturedAt).toISOString() + : null, + emailSource: input.emailSource ?? null, + }; + return this.mutations.run( + actor, + projectId, + "CREATE_UPLOAD", + `/projects/${projectId}/asset-uploads`, + key, + metadata, + uploadGrantSchema, + async (tx, project) => { + requireActiveProject(project); + if (input.sizeBytes > ASSETS.maxSingleUploadBytes) + throw new AssetError( + 413, + "UPLOAD_TOO_LARGE", + "The file exceeds the single-upload limit.", + { maxBytes: ASSETS.maxSingleUploadBytes }, + ); + await validateUploadActivity(tx, projectId, input.activityId); + const metadataHash = hashAssetRequest(metadata); + const { mailboxOwnerId, existing } = await resolveUploadSource( + tx, + projectId, + project, + input, + metadataHash, + ); + if (existing) + return existing.status === "PENDING" + ? renewUploadGrant(this.storage, tx, existing) + : uploadGrant(this.storage, existing); + requireAssetStorage(this.storage); + const actorKey = assetActorKey(actor); + const count = await tx.assetUpload.count({ + where: { actorKey, reservationReleasedAt: null }, + }); + if (count >= ASSETS.reservationLimit) + throw new AssetError( + 429, + "UPLOAD_CAPACITY_EXCEEDED", + "Temporary upload capacity is full.", + undefined, + true, + ); + const id = randomUUID(); + const expiresAt = new Date(Date.now() + ASSETS.intentMs); + const grantExpiresAt = new Date( + Math.min(Date.now() + ASSETS.uploadUrlMs, expiresAt.getTime()), + ); + const upload = await tx.assetUpload.create({ + data: { + id, + projectId, + customerId: project.companyId, + actorKey, + uploadedById: actor.type === "USER" ? actor.userId : null, + mailboxOwnerId, + fileName: input.fileName, + contentType: input.contentType, + sizeBytes: BigInt(input.sizeBytes), + kind: input.kind, + source: input.source, + activityId: input.activityId, + durationMilliseconds: + input.durationMilliseconds == null + ? null + : BigInt(input.durationMilliseconds), + capturedAt: input.capturedAt ? new Date(input.capturedAt) : null, + emailMessageId: input.emailSource?.messageId, + emailAttachmentId: input.emailSource?.attachmentId, + metadataHash, + bucket: this.storage.bucket(), + temporaryKey: `temporary/${id}`, + finalKey: `assets/${randomUUID()}`, + expiresAt, + grantExpiresAt, + reservationUntil: new Date( + grantExpiresAt.getTime() + ASSETS.temporaryRetentionMs, + ), + }, + }); + if (input.emailSource) + await tx.assetEmailSource.upsert({ + where: { messageId_attachmentId: input.emailSource }, + create: { + ...input.emailSource, + projectId, + uploadId: id, + metadataHash, + mailboxOwnerId, + }, + update: { uploadId: id }, + }); + return uploadGrant(this.storage, upload); + }, + { + activityId: input.activityId, + emailMessageId: input.emailSource?.messageId, + }, + ); + } +} diff --git a/apps/api/src/assets/asset-upload-grants.service.ts b/apps/api/src/assets/asset-upload-grants.service.ts new file mode 100644 index 000000000..4daf5193b --- /dev/null +++ b/apps/api/src/assets/asset-upload-grants.service.ts @@ -0,0 +1,95 @@ +import type { AssetUploadModel as AssetUpload, Prisma } from "@crm/db"; +import { ASSETS } from "./asset-config"; +import { AssetError } from "./asset-error"; +import { uploadResponse } from "./asset-responses"; +import type { AssetStorageService } from "./asset-storage.service"; +import { uploadGrantSchema } from "./assets.contracts"; + +export function requireAssetStorage(storage: AssetStorageService) { + if (!storage.configured()) + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "File storage is not configured.", + ); +} + +export function requireUploadState(upload: AssetUpload, allowed: string[]) { + const state = + upload.status === "PENDING" && upload.expiresAt <= new Date() + ? "EXPIRED" + : upload.status; + if (!allowed.includes(state)) + throw new AssetError( + 409, + "UPLOAD_STATE_CONFLICT", + "The upload does not permit this action.", + { state }, + ); +} + +export async function uploadGrant( + storage: AssetStorageService, + upload: AssetUpload, +) { + if (upload.status !== "PENDING") + return uploadGrantSchema.parse({ + upload: uploadResponse(upload), + transfer: null, + }); + requireAssetStorage(storage); + let url: string; + try { + url = await storage.presignPut( + upload.bucket, + upload.temporaryKey, + upload.contentType, + Number(upload.sizeBytes), + upload.grantExpiresAt, + ); + } catch (error) { + if (error instanceof AssetError) throw error; + throw new AssetError( + 503, + "STORAGE_UNAVAILABLE", + "File storage is temporarily unavailable.", + undefined, + true, + ); + } + return uploadGrantSchema.parse({ + upload: uploadResponse(upload), + transfer: { + method: "PUT", + url, + headers: { + "Content-Type": upload.contentType, + "Content-Length": upload.sizeBytes.toString(), + }, + expiresAt: upload.grantExpiresAt.toISOString(), + maxBytes: ASSETS.maxSingleUploadBytes, + }, + }); +} + +export async function renewUploadGrant( + storage: AssetStorageService, + tx: Prisma.TransactionClient, + upload: AssetUpload, +) { + const grantExpiresAt = new Date( + Math.min(Date.now() + ASSETS.uploadUrlMs, upload.expiresAt.getTime()), + ); + return uploadGrant( + storage, + await tx.assetUpload.update({ + where: { id: upload.id }, + data: { + grantExpiresAt, + reservationUntil: new Date( + grantExpiresAt.getTime() + ASSETS.temporaryRetentionMs, + ), + }, + }), + ); +} diff --git a/apps/api/src/assets/asset-upload-source.service.ts b/apps/api/src/assets/asset-upload-source.service.ts new file mode 100644 index 000000000..f508be50a --- /dev/null +++ b/apps/api/src/assets/asset-upload-source.service.ts @@ -0,0 +1,79 @@ +import type { Prisma } from "@crm/db"; +import { missing } from "./asset-access.service"; +import { AssetError } from "./asset-error"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; +import type { CreateUploadInput } from "./assets.contracts"; + +export async function resolveUploadSource( + tx: Prisma.TransactionClient, + projectId: string, + project: { companyId: string }, + input: CreateUploadInput, + metadataHash: string, +) { + let mailboxOwnerId: string | null = null; + if (input.emailSource) { + const message = await tx.emailMessage.findUnique({ + where: { id: input.emailSource.messageId }, + include: { thread: true }, + }); + if (!message) missing(); + if ( + message.thread.companyId && + message.thread.companyId !== project.companyId + ) + throw new AssetError( + 409, + "PROJECT_MISMATCH", + "The email belongs to another customer.", + ); + mailboxOwnerId = message.syncedByUserId; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`source:${input.emailSource.messageId}:${input.emailSource.attachmentId}`}, 0))`; + const source = await tx.assetEmailSource.findUnique({ + where: { messageId_attachmentId: input.emailSource }, + }); + if (source) { + if (source.projectId !== projectId) + throw new AssetError( + 409, + "PROJECT_MISMATCH", + "The attachment belongs to another project.", + ); + if (source.deletedAt) + throw new AssetError( + 409, + "SOURCE_DELETED", + "The source attachment was deleted.", + ); + if (source.metadataHash !== metadataHash) + throw new AssetError( + 409, + "SOURCE_CONFLICT", + "The source attachment has different metadata.", + ); + const existing = await tx.assetUpload.findUnique({ + where: { id: source.uploadId }, + }); + if ( + existing && + ["PENDING", "FINALIZING", "READY"].includes(existing.status) && + !(existing.status === "PENDING" && existing.expiresAt <= new Date()) + ) + return { mailboxOwnerId, existing }; + if (existing?.status === "PENDING") { + await tx.assetUpload.update({ + where: { id: existing.id }, + data: { status: "EXPIRED", completedAt: new Date() }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: existing.bucket, + objectKey: existing.temporaryKey, + uploadId: existing.id, + temporary: true, + }); + } + } + } + return { mailboxOwnerId, existing: null }; +} diff --git a/apps/api/src/assets/asset-uploads.service.ts b/apps/api/src/assets/asset-uploads.service.ts new file mode 100644 index 000000000..80ff2aa4d --- /dev/null +++ b/apps/api/src/assets/asset-uploads.service.ts @@ -0,0 +1,158 @@ +import type { Db } from "@crm/db"; +import { + findAssetProject, + findAssetUpload, + requireActiveProject, +} from "./asset-access.service"; +import type { AssetActor } from "./asset-actor"; +import type { AssetMutations } from "./asset-mutation.service"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; +import { uploadResponse } from "./asset-responses"; +import type { AssetStorageService } from "./asset-storage.service"; +import { + renewUploadGrant, + requireAssetStorage, + requireUploadState, +} from "./asset-upload-grants.service"; +import { + uploadCancellationSchema, + uploadConfirmationSchema, + uploadGrantSchema, +} from "./assets.contracts"; + +export class AssetUploads { + constructor( + private readonly db: Db, + private readonly storage: AssetStorageService, + private readonly mutations: AssetMutations, + ) {} + + async getUpload(actor: AssetActor, projectId: string, uploadId: string) { + return this.db.$transaction(async (tx) => { + await findAssetProject(tx, actor, projectId, true); + let upload = await findAssetUpload(tx, projectId, uploadId, actor); + if (upload.status === "PENDING" && upload.expiresAt <= new Date()) { + upload = await tx.assetUpload.update({ + where: { id: uploadId }, + data: { status: "EXPIRED", completedAt: new Date() }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId, + temporary: true, + }); + } + return { + upload: uploadResponse(upload), + pollAfterSeconds: upload.status === "FINALIZING" ? 3 : null, + }; + }); + } + + async renewUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.mutations.run( + actor, + projectId, + "RENEW_UPLOAD", + `/projects/${projectId}/asset-uploads/${uploadId}/url`, + key, + {}, + uploadGrantSchema, + async (tx, project) => { + requireActiveProject(project); + const upload = await findAssetUpload(tx, projectId, uploadId, actor); + requireUploadState(upload, ["PENDING"]); + return renewUploadGrant(this.storage, tx, upload); + }, + { uploadId }, + ); + } + + async confirmUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.mutations.run( + actor, + projectId, + "CONFIRM_UPLOAD", + `/projects/${projectId}/asset-uploads/${uploadId}/confirm`, + key, + {}, + uploadConfirmationSchema, + async (tx, project) => { + requireActiveProject(project); + const upload = await findAssetUpload(tx, projectId, uploadId, actor); + requireUploadState(upload, ["PENDING", "FINALIZING", "READY"]); + if (upload.status === "PENDING") { + requireAssetStorage(this.storage); + await tx.assetUpload.update({ + where: { id: uploadId }, + data: { status: "FINALIZING", confirmedAt: new Date() }, + }); + await tx.assetStorageJob.create({ + data: { + operationKey: `finalize:${uploadId}`, + operation: "FINALIZE_UPLOAD", + nextAttemptAt: new Date(), + projectId, + uploadId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + finalKey: upload.finalKey, + }, + }); + } + return { + uploadId, + statusUrl: `/rest/v1/projects/${encodeURIComponent(projectId)}/asset-uploads/${encodeURIComponent(uploadId)}`, + }; + }, + { uploadId }, + ); + } + + async cancelUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.mutations.run( + actor, + projectId, + "CANCEL_UPLOAD", + `/projects/${projectId}/asset-uploads/${uploadId}`, + key, + {}, + uploadCancellationSchema, + async (tx) => { + const upload = await findAssetUpload(tx, projectId, uploadId, actor); + requireUploadState(upload, ["PENDING", "FAILED", "CANCELED"]); + if (upload.status !== "CANCELED") + await tx.assetUpload.update({ + where: { id: uploadId }, + data: { status: "CANCELED", completedAt: new Date() }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId, + temporary: true, + }); + return { uploadId, status: "CANCELED" }; + }, + { uploadId }, + ); + } +} diff --git a/apps/api/src/assets/asset-worker-delete.ts b/apps/api/src/assets/asset-worker-delete.ts new file mode 100644 index 000000000..12d74c811 --- /dev/null +++ b/apps/api/src/assets/asset-worker-delete.ts @@ -0,0 +1,56 @@ +import type { AssetStorageJobModel as AssetStorageJob, Db } from "@crm/db"; +import { ASSETS } from "./asset-config"; +import { AssetStorageService } from "./asset-storage.service"; +import { + completeAssetStorageJob, + withOwnedAssetStorageJob, +} from "./asset-worker-lease"; + +export async function removeAssetStorageJob( + db: Db, + storage: AssetStorageService, + job: AssetStorageJob, + signal: AbortSignal, +) { + signal.throwIfAborted(); + if (!job.bucket) + throw new Error("Storage location requires operator resolution."); + await storage.delete(job.bucket, job.objectKey, signal); + if (await storage.head(job.bucket, job.objectKey, signal)) + throw new Error("Object deletion remains incomplete."); + await withOwnedAssetStorageJob(db, job, async (tx) => { + if (job.temporary && job.uploadId) { + const upload = await tx.assetUpload.findUnique({ + where: { id: job.uploadId }, + }); + if (upload && upload.reservationUntil > new Date()) { + await tx.assetStorageJob.update({ + where: { id: job.id }, + data: { + state: "PENDING", + nextAttemptAt: new Date( + Math.min( + Date.now() + ASSETS.worker.retryMaxMs, + upload.reservationUntil.getTime(), + ), + ), + leaseUntil: null, + leaseToken: null, + lastError: null, + }, + }); + return; + } + await tx.assetUpload.updateMany({ + where: { id: job.uploadId, reservationReleasedAt: null }, + data: { reservationReleasedAt: new Date() }, + }); + } + if (job.artifactId) + await tx.artifact.updateMany({ + where: { id: job.artifactId, status: "DELETING" }, + data: { status: "DELETED", deletedAt: new Date() }, + }); + await completeAssetStorageJob(tx, job); + }); +} diff --git a/apps/api/src/assets/asset-worker-errors.ts b/apps/api/src/assets/asset-worker-errors.ts new file mode 100644 index 000000000..9bc02c9c9 --- /dev/null +++ b/apps/api/src/assets/asset-worker-errors.ts @@ -0,0 +1,3 @@ +export class LeaseLost extends Error {} +export class VerificationFailed extends Error {} +export class FinalizationExpired extends Error {} diff --git a/apps/api/src/assets/asset-worker-finalize.ts b/apps/api/src/assets/asset-worker-finalize.ts new file mode 100644 index 000000000..07465f929 --- /dev/null +++ b/apps/api/src/assets/asset-worker-finalize.ts @@ -0,0 +1,163 @@ +import type { + AssetStorageJobModel as AssetStorageJob, + Db, + Prisma, +} from "@crm/db"; +import { ASSETS } from "./asset-config"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; +import { AssetStorageService } from "./asset-storage.service"; +import { + FinalizationExpired, + LeaseLost, + VerificationFailed, +} from "./asset-worker-errors"; +import { + completeAssetStorageJob, + withOwnedAssetStorageJob, +} from "./asset-worker-lease"; + +type Tx = Prisma.TransactionClient; + +async function abandonAssetStorageJob(tx: Tx, job: AssetStorageJob) { + if (job.finalKey) { + const deletion = await enqueueAssetObjectDeletion(tx, { + projectId: job.projectId, + bucket: job.bucket, + objectKey: job.finalKey, + uploadId: job.uploadId ?? undefined, + }); + if (deletion.state === "COMPLETE") + await tx.assetStorageJob.update({ + where: { id: deletion.id }, + data: { state: "PENDING", nextAttemptAt: new Date() }, + }); + } + await completeAssetStorageJob(tx, job); +} + +export async function finalizeAssetStorageJob( + db: Db, + storage: AssetStorageService, + job: AssetStorageJob, + signal: AbortSignal, +) { + signal.throwIfAborted(); + const upload = await withOwnedAssetStorageJob(db, job, async (tx) => { + const upload = job.uploadId + ? await tx.assetUpload.findUnique({ where: { id: job.uploadId } }) + : null; + const project = await tx.deal.findUnique({ + where: { id: job.projectId }, + select: { id: true }, + }); + if (upload?.status === "READY") { + await completeAssetStorageJob(tx, job); + return null; + } + if (!upload || !project || upload.status !== "FINALIZING") { + await abandonAssetStorageJob(tx, job); + return null; + } + return upload; + }); + if (!upload) return; + if ( + !upload.confirmedAt || + Date.now() - upload.confirmedAt.getTime() >= + ASSETS.worker.finalizeDeadlineMs + ) + throw new FinalizationExpired(); + let sourceEtag = upload.sourceEtag; + if (!sourceEtag) { + const source = await storage.head( + upload.bucket, + upload.temporaryKey, + signal, + ); + if (!source || source.sizeBytes !== Number(upload.sizeBytes)) + throw new VerificationFailed(); + sourceEtag = source.etag; + await withOwnedAssetStorageJob(db, job, async (tx) => { + const current = await tx.assetUpload.findUnique({ + where: { id: upload.id }, + }); + if (current?.status !== "FINALIZING") throw new LeaseLost(); + await tx.assetUpload.update({ + where: { id: upload.id }, + data: { sourceEtag }, + }); + }); + } + let final = await storage.head(upload.bucket, upload.finalKey, signal); + if (!final) { + await withOwnedAssetStorageJob(db, job, async (tx) => { + const current = await tx.assetUpload.findUnique({ + where: { id: upload.id }, + }); + if (current?.status !== "FINALIZING") throw new LeaseLost(); + }); + await storage.copy( + upload.bucket, + upload.temporaryKey, + upload.finalKey, + sourceEtag, + signal, + ); + final = await storage.head(upload.bucket, upload.finalKey, signal); + } + if ( + !final || + final.sizeBytes !== Number(upload.sizeBytes) || + final.etag !== sourceEtag + ) + throw new VerificationFailed(); + await withOwnedAssetStorageJob(db, job, async (tx) => { + const current = await tx.assetUpload.findUnique({ + where: { id: upload.id }, + }); + const project = await tx.deal.findUnique({ + where: { id: upload.projectId }, + select: { id: true }, + }); + if (!project || !current || current.status !== "FINALIZING") { + await abandonAssetStorageJob(tx, job); + return; + } + const asset = await tx.artifact.create({ + data: { + dealId: upload.projectId, + type: upload.kind, + fileName: upload.fileName, + storageBucket: upload.bucket, + storageKey: upload.finalKey, + kind: upload.kind, + contentType: upload.contentType, + sizeBytes: upload.sizeBytes, + source: upload.source, + activityId: upload.activityId, + uploadedById: upload.uploadedById, + durationMilliseconds: upload.durationMilliseconds, + capturedAt: upload.capturedAt, + emailMessageId: upload.emailMessageId, + emailAttachmentId: upload.emailAttachmentId, + status: "READY", + }, + }); + await tx.assetUpload.update({ + where: { id: upload.id }, + data: { status: "READY", assetId: asset.id, completedAt: new Date() }, + }); + await tx.assetEmailSource.updateMany({ + where: { uploadId: upload.id }, + data: { assetId: asset.id }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId: upload.projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId: upload.id, + temporary: true, + }); + await completeAssetStorageJob(tx, job); + }); +} diff --git a/apps/api/src/assets/asset-worker-lease.ts b/apps/api/src/assets/asset-worker-lease.ts new file mode 100644 index 000000000..32b15a54d --- /dev/null +++ b/apps/api/src/assets/asset-worker-lease.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import type { + AssetStorageJobModel as AssetStorageJob, + Db, + Prisma, +} from "@crm/db"; +import { ASSETS } from "./asset-config"; +import { LeaseLost } from "./asset-worker-errors"; + +type Tx = Prisma.TransactionClient; + +export async function assertAssetStorageJobLease(tx: Tx, job: AssetStorageJob) { + const rows = await tx.$queryRaw< + Array<{ id: string }> + >`SELECT "id" FROM "assetStorageJob" WHERE "id" = ${job.id} AND "leaseToken" = ${job.leaseToken} AND "leaseUntil" > (NOW() AT TIME ZONE 'UTC') AND "state" = 'RUNNING' FOR UPDATE`; + if (!rows.length) throw new LeaseLost(); +} + +export async function withOwnedAssetStorageJob( + db: Db, + job: AssetStorageJob, + action: (tx: Tx) => Promise, +) { + return db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "deal" WHERE "id" = ${job.projectId} FOR UPDATE`; + await assertAssetStorageJobLease(tx, job); + return action(tx); + }); +} + +export async function completeAssetStorageJob(tx: Tx, job: AssetStorageJob) { + await tx.assetStorageJob.update({ + where: { id: job.id }, + data: { + state: "COMPLETE", + leaseUntil: null, + leaseToken: null, + lastError: null, + nextAttemptAt: new Date(Date.now() + ASSETS.worker.retryMaxMs), + }, + }); +} + +export async function claimAssetStorageJob(db: Db) { + const leaseToken = randomUUID(); + return db.$transaction(async (tx) => { + const rows = await tx.$queryRaw< + Array<{ id: string }> + >`SELECT "id" FROM "assetStorageJob" WHERE ("state" = 'PENDING' AND "nextAttemptAt" <= (NOW() AT TIME ZONE 'UTC')) OR ("state" = 'RUNNING' AND "leaseUntil" <= (NOW() AT TIME ZONE 'UTC')) OR ("state" = 'COMPLETE' AND "operation" = 'DELETE_OBJECT' AND "nextAttemptAt" <= (NOW() AT TIME ZONE 'UTC')) ORDER BY "nextAttemptAt", "id" FOR UPDATE SKIP LOCKED LIMIT 1`; + if (!rows[0]) return null; + return tx.assetStorageJob.update({ + where: { id: rows[0].id }, + data: { + state: "RUNNING", + leaseToken, + leaseUntil: new Date(Date.now() + ASSETS.worker.leaseMs), + }, + }); + }); +} + +export function startAssetStorageJobHeartbeat(db: Db, job: AssetStorageJob) { + let heartbeatPending = false; + const heartbeat = setInterval(() => { + if (heartbeatPending) return; + heartbeatPending = true; + void db.assetStorageJob + .updateMany({ + where: { + id: job.id, + state: "RUNNING", + leaseToken: job.leaseToken, + leaseUntil: { gt: new Date() }, + }, + data: { + leaseUntil: new Date(Date.now() + ASSETS.worker.leaseMs), + }, + }) + .catch(() => {}) + .finally(() => { + heartbeatPending = false; + }); + }, ASSETS.worker.heartbeatMs); + return () => clearInterval(heartbeat); +} diff --git a/apps/api/src/assets/asset-worker-retry.ts b/apps/api/src/assets/asset-worker-retry.ts new file mode 100644 index 000000000..d701ef84c --- /dev/null +++ b/apps/api/src/assets/asset-worker-retry.ts @@ -0,0 +1,97 @@ +import type { AssetStorageJobModel as AssetStorageJob, Db } from "@crm/db"; +import { Logger } from "@nestjs/common"; +import { ASSETS } from "./asset-config"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; +import { LeaseLost } from "./asset-worker-errors"; +import { withOwnedAssetStorageJob } from "./asset-worker-lease"; + +export async function retryAssetStorageJob( + db: Db, + job: AssetStorageJob, + verification: boolean, + logger: Logger, + interrupted: boolean, +) { + try { + await withOwnedAssetStorageJob(db, job, async (tx) => { + const current = await tx.assetStorageJob.findUniqueOrThrow({ + where: { id: job.id }, + }); + const attempts = current.attempts + (interrupted ? 0 : 1); + const upload = job.uploadId + ? await tx.assetUpload.findUnique({ where: { id: job.uploadId } }) + : null; + const terminal = + job.operation === "FINALIZE_UPLOAD" && + (verification || + attempts >= ASSETS.worker.maxFinalizeAttempts || + !upload?.confirmedAt || + Date.now() - upload.confirmedAt.getTime() >= + ASSETS.worker.finalizeDeadlineMs); + const message = interrupted + ? "Invocation deadline reached. A retry is scheduled." + : verification + ? "Stored bytes do not match the upload intent." + : job.bucket + ? "Storage operation failed. A retry is scheduled." + : "Storage location requires operator resolution."; + if (terminal) { + if (upload?.status === "FINALIZING") + await tx.assetUpload.update({ + where: { id: upload.id }, + data: { + status: "FAILED", + failureCode: verification + ? "UPLOAD_VERIFICATION_FAILED" + : "UPLOAD_FINALIZATION_FAILED", + failureMessage: verification + ? message + : "File finalization failed.", + completedAt: new Date(), + }, + }); + if (upload) { + await enqueueAssetObjectDeletion(tx, { + projectId: upload.projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId: upload.id, + temporary: true, + }); + await enqueueAssetObjectDeletion(tx, { + projectId: upload.projectId, + bucket: upload.bucket, + objectKey: upload.finalKey, + uploadId: upload.id, + }); + } + } + await tx.assetStorageJob.update({ + where: { id: job.id }, + data: { + state: terminal ? "COMPLETE" : "PENDING", + attempts, + lastError: message, + leaseUntil: null, + leaseToken: null, + nextAttemptAt: new Date( + Date.now() + + Math.min( + ASSETS.worker.retryMaxMs, + ASSETS.worker.retryBaseMs * + 2 ** Math.max(0, Math.min(attempts - 1, 30)), + ), + ), + }, + }); + logger.warn({ + message, + jobId: job.id, + operation: job.operation, + attempts, + }); + }); + } catch (error) { + if (!(error instanceof LeaseLost)) throw error; + } +} diff --git a/apps/api/src/assets/asset-worker-sweep.ts b/apps/api/src/assets/asset-worker-sweep.ts new file mode 100644 index 000000000..7bd3d9cd4 --- /dev/null +++ b/apps/api/src/assets/asset-worker-sweep.ts @@ -0,0 +1,35 @@ +import type { Db } from "@crm/db"; +import { ASSETS } from "./asset-config"; +import { enqueueAssetObjectDeletion } from "./asset-purge"; + +export async function sweepExpiredAssetUploads(db: Db, signal: AbortSignal) { + if (signal.aborted) return; + const expired = await db.assetUpload.findMany({ + where: { status: "PENDING", expiresAt: { lte: new Date() } }, + take: ASSETS.worker.batchSize, + select: { id: true, projectId: true }, + }); + for (const candidate of expired) { + if (signal.aborted) break; + await db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "deal" WHERE "id" = ${candidate.projectId} FOR UPDATE`; + const upload = await tx.assetUpload.findUnique({ + where: { id: candidate.id }, + }); + if (upload?.status !== "PENDING" || upload.expiresAt > new Date()) return; + await tx.assetUpload.update({ + where: { id: upload.id }, + data: { status: "EXPIRED", completedAt: new Date() }, + }); + await enqueueAssetObjectDeletion(tx, { + projectId: upload.projectId, + bucket: upload.bucket, + objectKey: upload.temporaryKey, + uploadId: upload.id, + temporary: true, + }); + }); + } + if (signal.aborted) return; + await db.$executeRaw`DELETE FROM "assetApiRequest" WHERE "id" IN (SELECT "id" FROM "assetApiRequest" WHERE "expiresAt" <= (NOW() AT TIME ZONE 'UTC') LIMIT ${ASSETS.worker.batchSize})`; +} diff --git a/apps/api/src/assets/asset-worker.controller.ts b/apps/api/src/assets/asset-worker.controller.ts new file mode 100644 index 000000000..23283cac6 --- /dev/null +++ b/apps/api/src/assets/asset-worker.controller.ts @@ -0,0 +1,48 @@ +import { timingSafeEqual } from "node:crypto"; +import { + Controller, + ForbiddenException, + Get, + Headers, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; +import type { EnvironmentVariables } from "../config/env.validation"; +import { AssetWorkerService } from "./asset-worker.service"; + +@ApiTags("Internal Assets") +@Controller("internal/assets") +export class AssetWorkerController { + constructor( + private readonly worker: AssetWorkerService, + private readonly config: ConfigService, + ) {} + + @Get("process") + @AllowAnonymous() + @ApiHeader({ + name: "authorization", + required: true, + description: "Bearer CRON_SECRET", + }) + @ApiOperation({ + summary: "Process durable asset finalization and deletion jobs", + }) + process(@Headers("authorization") authorization?: string) { + const secret = this.config.get("CRON_SECRET", { infer: true }); + if (!secret) + throw new ServiceUnavailableException( + "Asset processing is not configured.", + ); + const supplied = Buffer.from(authorization ?? ""); + const expected = Buffer.from(`Bearer ${secret}`); + if ( + supplied.length !== expected.length || + !timingSafeEqual(supplied, expected) + ) + throw new ForbiddenException(); + return this.worker.process(); + } +} diff --git a/apps/api/src/assets/asset-worker.service.ts b/apps/api/src/assets/asset-worker.service.ts new file mode 100644 index 000000000..78889d380 --- /dev/null +++ b/apps/api/src/assets/asset-worker.service.ts @@ -0,0 +1,70 @@ +import type { Db } from "@crm/db"; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { ASSETS } from "./asset-config"; +import { AssetStorageService } from "./asset-storage.service"; +import { removeAssetStorageJob } from "./asset-worker-delete"; +import { LeaseLost, VerificationFailed } from "./asset-worker-errors"; +import { finalizeAssetStorageJob } from "./asset-worker-finalize"; +import { + claimAssetStorageJob, + startAssetStorageJobHeartbeat, +} from "./asset-worker-lease"; +import { retryAssetStorageJob } from "./asset-worker-retry"; +import { sweepExpiredAssetUploads } from "./asset-worker-sweep"; + +@Injectable() +export class AssetWorkerService { + private readonly logger = new Logger(AssetWorkerService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly storage: AssetStorageService, + ) {} + + async process(externalSignal?: AbortSignal) { + const deadline = Date.now() + ASSETS.worker.deadlineMs; + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + ASSETS.worker.deadlineMs, + ); + const signal = externalSignal + ? AbortSignal.any([controller.signal, externalSignal]) + : controller.signal; + try { + await sweepExpiredAssetUploads(this.db, signal); + if (!this.storage.configured()) return { processed: 0 }; + let processed = 0; + while ( + processed < ASSETS.worker.batchSize && + Date.now() < deadline && + !signal.aborted + ) { + const job = await claimAssetStorageJob(this.db); + if (!job) break; + const stopHeartbeat = startAssetStorageJobHeartbeat(this.db, job); + try { + if (job.operation === "FINALIZE_UPLOAD") + await finalizeAssetStorageJob(this.db, this.storage, job, signal); + else await removeAssetStorageJob(this.db, this.storage, job, signal); + } catch (error) { + if (!(error instanceof LeaseLost)) + await retryAssetStorageJob( + this.db, + job, + error instanceof VerificationFailed, + this.logger, + signal.aborted, + ); + } finally { + stopHeartbeat(); + } + processed++; + } + return { processed }; + } finally { + clearTimeout(timer); + } + } +} diff --git a/apps/api/src/assets/assets.contracts.ts b/apps/api/src/assets/assets.contracts.ts new file mode 100644 index 000000000..c62c66567 --- /dev/null +++ b/apps/api/src/assets/assets.contracts.ts @@ -0,0 +1,190 @@ +import { z } from "zod"; + +export const assetId = z.string().min(1).max(128); +export const assetSource = z.enum([ + "MANUAL", + "MOBILE_RECORDING", + "EMAIL_ATTACHMENT", +]); +export const emailSource = z.strictObject({ + messageId: z.string().min(1).max(255), + attachmentId: z.string().min(1).max(255), +}); +export const createUploadInput = z + .strictObject({ + fileName: z + .string() + .min(1) + .max(255) + .regex(/^[^/\\\p{Cc}]+$/u), + contentType: z + .string() + .min(1) + .max(255) + .regex(/^[^\p{Cc}]+$/u) + .default("application/octet-stream"), + sizeBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + kind: z.string().min(1).max(64).default("file"), + source: assetSource, + activityId: assetId.nullable().optional(), + durationMilliseconds: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .nullable() + .optional(), + capturedAt: z.iso.datetime({ offset: true }).nullable().optional(), + emailSource: emailSource.nullable().optional(), + }) + .superRefine((input, context) => { + if ((input.source === "EMAIL_ATTACHMENT") !== (input.emailSource != null)) { + context.addIssue({ + code: "custom", + path: ["emailSource"], + message: + "Email attachments require an email source. Other sources cannot include one.", + }); + } + }); +export type CreateUploadInput = z.infer; +export const projectUploadCreateInput = createUploadInput.safeExtend({ + projectId: assetId, +}); +export const projectUploadInput = z.strictObject({ + projectId: assetId, + uploadId: assetId, +}); +export const projectAssetInput = z.strictObject({ + projectId: assetId, + assetId, +}); + +export const assetListInput = z.strictObject({ + page: z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(25), + activityId: assetId.optional(), + kind: z.string().min(1).max(64).optional(), + source: assetSource.optional(), +}); +export const customerAssetListInput = assetListInput.extend({ + projectId: assetId.optional(), +}); +export type AssetListInput = z.infer; +export const projectAssetListInput = assetListInput.extend({ + projectId: assetId, +}); +export const customerAssetListArgs = customerAssetListInput.extend({ + customerId: assetId, +}); + +export const uploadSchema = z.object({ + id: assetId, + customerId: assetId, + projectId: assetId, + status: z.enum([ + "PENDING", + "FINALIZING", + "READY", + "FAILED", + "CANCELED", + "EXPIRED", + ]), + expiresAt: z.iso.datetime(), + assetId: assetId.nullable(), + failure: z + .object({ + code: z.enum([ + "UPLOAD_VERIFICATION_FAILED", + "UPLOAD_FINALIZATION_FAILED", + ]), + message: z.string(), + }) + .nullable(), +}); +export const uploadGrantSchema = z.object({ + upload: uploadSchema, + transfer: z + .object({ + method: z.literal("PUT"), + url: z.url(), + headers: z.object({ + "Content-Type": z.string(), + "Content-Length": z.string(), + }), + expiresAt: z.iso.datetime(), + maxBytes: z.number().int(), + }) + .nullable(), +}); +export const uploadStateSchema = z.object({ + upload: uploadSchema, + pollAfterSeconds: z.number().int().nullable(), +}); +export const uploadConfirmationSchema = z.object({ + uploadId: assetId, + statusUrl: z.string(), +}); +export const uploadCancellationSchema = z.object({ + uploadId: assetId, + status: z.literal("CANCELED"), +}); + +export const assetSchema = z.object({ + id: assetId, + customerId: assetId, + projectId: assetId, + activityId: assetId.nullable(), + fileName: z.string(), + contentType: z.string(), + sizeBytes: z.number().int().nonnegative().nullable(), + kind: z.string(), + source: assetSource.nullable(), + emailSource: emailSource.nullable(), + uploadedById: assetId.nullable(), + durationMilliseconds: z.number().int().nonnegative().nullable(), + capturedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), + status: z.enum(["UNVERIFIED", "READY", "DELETING", "DELETED"]), + deletedAt: z.iso.datetime().nullable(), +}); +export const assetDetailSchema = z.object({ asset: assetSchema }); +export const assetListSchema = z.object({ + items: z.array(assetSchema), + page: z.number().int(), + pageSize: z.number().int(), + total: z.number().int(), + hasNextPage: z.boolean(), +}); +export const assetDownloadSchema = z.object({ + assetId, + url: z.url(), + method: z.literal("GET"), + headers: z.object({}), + expiresAt: z.iso.datetime(), + fileName: z.string(), + contentType: z.string(), + sizeBytes: z.number().int().nonnegative(), +}); +export const assetDeletionSchema = z.object({ + assetId, + status: z.enum(["DELETING", "DELETED"]), +}); + +export const assetErrorEnvelopeSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + requestId: z.string(), + retryable: z.boolean(), + details: z + .object({ + state: z.string().optional(), + maxBytes: z.number().optional(), + fields: z + .array(z.object({ field: z.string(), message: z.string() })) + .optional(), + }) + .optional(), + }), +}); diff --git a/apps/api/src/assets/assets.module.ts b/apps/api/src/assets/assets.module.ts new file mode 100644 index 000000000..4058bacf5 --- /dev/null +++ b/apps/api/src/assets/assets.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { AssetStorageService } from "./asset-storage.service"; +import { AssetWorkerController } from "./asset-worker.controller"; +import { AssetWorkerService } from "./asset-worker.service"; +import { AssetsRouter } from "./assets.router"; +import { AssetsService } from "./assets.service"; + +@Module({ + imports: [TrpcModule], + controllers: [AssetWorkerController], + providers: [ + AssetsService, + AssetsRouter, + AssetStorageService, + AssetWorkerService, + ], + exports: [AssetsService], +}) +export class AssetsModule {} diff --git a/apps/api/src/assets/assets.router.ts b/apps/api/src/assets/assets.router.ts new file mode 100644 index 000000000..7b500f6e3 --- /dev/null +++ b/apps/api/src/assets/assets.router.ts @@ -0,0 +1,192 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { assetRoutes } from "./asset-openapi"; +import { assetUser, idempotencyKey } from "./asset-request"; +import { + assetDeletionSchema, + assetDetailSchema, + assetDownloadSchema, + assetListSchema, + customerAssetListArgs, + projectAssetInput, + projectAssetListInput, + projectUploadCreateInput, + projectUploadInput, + uploadCancellationSchema, + uploadConfirmationSchema, + uploadGrantSchema, + uploadStateSchema, +} from "./assets.contracts"; +import { AssetsService } from "./assets.service"; + +@Router({ alias: "assets" }) +@UseMiddlewares(AuthMiddleware) +export class AssetsRouter { + constructor(@Inject(AssetsService) private readonly assets: AssetsService) {} + + @Mutation({ + input: projectUploadCreateInput, + output: uploadGrantSchema, + meta: assetRoutes.createUpload, + }) + createUpload( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + const { projectId, ...body } = input; + return this.assets.createUpload( + assetUser(ctx), + projectId, + body, + idempotencyKey(ctx), + ); + } + + @Query({ + input: projectUploadInput, + output: uploadStateSchema, + meta: assetRoutes.getUpload, + }) + getUpload( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.getUpload( + assetUser(ctx), + input.projectId, + input.uploadId, + ); + } + + @Mutation({ + input: projectUploadInput, + output: uploadGrantSchema, + meta: assetRoutes.renewUpload, + }) + renewUpload( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.renewUpload( + assetUser(ctx), + input.projectId, + input.uploadId, + idempotencyKey(ctx), + ); + } + + @Mutation({ + input: projectUploadInput, + output: uploadConfirmationSchema, + meta: assetRoutes.confirmUpload, + }) + confirmUpload( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.confirmUpload( + assetUser(ctx), + input.projectId, + input.uploadId, + idempotencyKey(ctx), + ); + } + + @Mutation({ + input: projectUploadInput, + output: uploadCancellationSchema, + meta: assetRoutes.cancelUpload, + }) + cancelUpload( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.cancelUpload( + assetUser(ctx), + input.projectId, + input.uploadId, + idempotencyKey(ctx), + ); + } + + @Query({ + input: customerAssetListArgs, + output: assetListSchema, + meta: assetRoutes.listCustomerAssets, + }) + listCustomerAssets( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + const { customerId, ...query } = input; + return this.assets.listCustomerAssets(assetUser(ctx), customerId, query); + } + + @Query({ + input: projectAssetListInput, + output: assetListSchema, + meta: assetRoutes.listProjectAssets, + }) + listProjectAssets( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + const { projectId, ...query } = input; + return this.assets.listProjectAssets(assetUser(ctx), projectId, query); + } + + @Query({ + input: projectAssetInput, + output: assetDetailSchema, + meta: assetRoutes.getAsset, + }) + getAsset( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.getAsset(assetUser(ctx), input.projectId, input.assetId); + } + + @Query({ + input: projectAssetInput, + output: assetDownloadSchema, + meta: assetRoutes.downloadAsset, + }) + downloadAsset( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.downloadAsset( + assetUser(ctx), + input.projectId, + input.assetId, + ); + } + + @Mutation({ + input: projectAssetInput, + output: assetDeletionSchema, + meta: assetRoutes.deleteAsset, + }) + deleteAsset( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.assets.deleteAsset( + assetUser(ctx), + input.projectId, + input.assetId, + idempotencyKey(ctx), + ); + } +} diff --git a/apps/api/src/assets/assets.service.ts b/apps/api/src/assets/assets.service.ts new file mode 100644 index 000000000..1b118ff15 --- /dev/null +++ b/apps/api/src/assets/assets.service.ts @@ -0,0 +1,103 @@ +import type { Db } from "@crm/db"; +import { Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { type AssetActor, assetActorKey } from "./asset-actor"; +import { AssetCatalog } from "./asset-catalog.service"; +import { AssetFiles } from "./asset-files.service"; +import { AssetMutations } from "./asset-mutation.service"; +import { uploadResponse } from "./asset-responses"; +import { AssetStorageService } from "./asset-storage.service"; +import { AssetUploadCreation } from "./asset-upload-create.service"; +import { AssetUploads } from "./asset-uploads.service"; +import type { AssetListInput, CreateUploadInput } from "./assets.contracts"; + +export { type AssetActor, assetActorKey, uploadResponse }; + +@Injectable() +export class AssetsService { + private readonly creation: AssetUploadCreation; + private readonly uploads: AssetUploads; + private readonly catalog: AssetCatalog; + private readonly files: AssetFiles; + + constructor(@InjectDatabase() db: Db, storage: AssetStorageService) { + const mutations = new AssetMutations(db); + this.creation = new AssetUploadCreation(mutations, storage); + this.uploads = new AssetUploads(db, storage, mutations); + this.catalog = new AssetCatalog(db); + this.files = new AssetFiles(db, storage, mutations); + } + + async createUpload( + actor: AssetActor, + projectId: string, + raw: CreateUploadInput, + key: string, + ) { + return this.creation.createUpload(actor, projectId, raw, key); + } + + async getUpload(actor: AssetActor, projectId: string, uploadId: string) { + return this.uploads.getUpload(actor, projectId, uploadId); + } + + async renewUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.uploads.renewUpload(actor, projectId, uploadId, key); + } + + async confirmUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.uploads.confirmUpload(actor, projectId, uploadId, key); + } + + async cancelUpload( + actor: AssetActor, + projectId: string, + uploadId: string, + key: string, + ) { + return this.uploads.cancelUpload(actor, projectId, uploadId, key); + } + + async listCustomerAssets( + actor: AssetActor, + customerId: string, + raw: AssetListInput, + ) { + return this.catalog.listCustomerAssets(actor, customerId, raw); + } + + async listProjectAssets( + actor: AssetActor, + projectId: string, + raw: AssetListInput, + ) { + return this.catalog.listProjectAssets(actor, projectId, raw); + } + + async getAsset(actor: AssetActor, projectId: string, assetId: string) { + return this.catalog.getAsset(actor, projectId, assetId); + } + + async downloadAsset(actor: AssetActor, projectId: string, assetId: string) { + return this.files.downloadAsset(actor, projectId, assetId); + } + + async deleteAsset( + actor: AssetActor, + projectId: string, + assetId: string, + key: string, + ) { + return this.files.deleteAsset(actor, projectId, assetId, key); + } +} diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c2..694949075 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -109,6 +109,22 @@ export class EnvironmentVariables { @IsString() BLOB_READ_WRITE_TOKEN?: string; + @IsOptional() + @IsString() + R2_ACCOUNT_ID?: string; + + @IsOptional() + @IsString() + R2_ACCESS_KEY_ID?: string; + + @IsOptional() + @IsString() + R2_SECRET_ACCESS_KEY?: string; + + @IsOptional() + @IsString() + R2_BUCKET?: string; + @IsOptional() @IsUrl( { require_tld: false, require_protocol: true }, diff --git a/apps/api/src/create-app.ts b/apps/api/src/create-app.ts index 8739410ce..16f89fafa 100644 --- a/apps/api/src/create-app.ts +++ b/apps/api/src/create-app.ts @@ -14,6 +14,12 @@ import { generateOpenApiDocument, } from "trpc-to-openapi"; import { AppModule } from "./app.module"; +import { describeAssetErrors } from "./assets/asset-openapi"; +import { + prepareAssetRestResponse, + recordAssetRestError, + validateAssetRestRequest, +} from "./assets/asset-rest"; import { RequestPrincipalService } from "./auth/request-principal.service"; import { ContextLogger } from "./logging/context-logger"; import { REST_BRIDGE_PATH } from "./trpc/openapi"; @@ -44,6 +50,7 @@ export async function createApp(): Promise { next(); return; } + prepareAssetRestResponse(req, res); void restBridge(req, res); }, ); @@ -82,6 +89,7 @@ export async function createApp(): Promise { oauth: oauthSecurityScheme, }, }); + describeAssetErrors(trpcDocument); const swaggerConfig = new DocumentBuilder() .setTitle("CRM API") @@ -138,7 +146,12 @@ export async function createApp(): Promise { restBridge = createOpenApiExpressMiddleware({ router: appRouter, - createContext: ({ req }) => createBaseTrpcContext(req, principals), + createContext: async ({ req }) => { + const context = await createBaseTrpcContext(req, principals); + validateAssetRestRequest(req); + return context; + }, + onError: ({ req, error }) => recordAssetRestError(req, error), }); return app; diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index be49b3903..cd282cf4b 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -21,6 +21,7 @@ import { } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; import { ARCHIVE } from "../archive/archive-config"; +import { enqueueProjectAssetPurge } from "../assets/asset-purge"; import { ActivityStampService, type StampTargets, @@ -462,6 +463,7 @@ export class DealsService { const targets = await this.stamp.targetsOf({ dealId: id }, tx); await tx.agentTask.deleteMany({ where: { dealId: id } }); + await enqueueProjectAssetPurge(tx, id); const deal = await tx.deal.delete({ where: { id }, diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..50cfaceb8 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -16,6 +16,7 @@ const publicProcedure = t.procedure; import { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput } from "../activities/activities.contracts"; import { agentListOutput, agentReviseInput, agentReviseOutput, agentIdInput, agentFilesOutput, agentSaveFileInput, agentSaveFileOutput, agentByIdOutput, agentHistoryInput, agentHistoryOutput, agentActivityOutput, agentUpdateInput, agentUpdateOutput, agentDeployInput, agentDeployOutput, agentPauseOutput, agentResumeOutput, agentArchiveOutput, agentRestoreOutput, agentRemoveOutput, agentRunNowInput, agentRunNowOutput, agentRetryRunInput, agentRetryRunOutput, agentCancelRunInput, agentCancelRunOutput } from "../agent/agents.contracts"; import { apiKeyListInput, apiKeyListOutput, createApiKeyInput, createApiKeyOutput, revokeApiKeyInput, revokeApiKeyOutput } from "../api-keys/api-keys.contracts"; +import { projectUploadCreateInput, uploadGrantSchema, projectUploadInput, uploadStateSchema, uploadConfirmationSchema, uploadCancellationSchema, customerAssetListArgs, assetListSchema, projectAssetListInput, projectAssetInput, assetDetailSchema, assetDownloadSchema, assetDeletionSchema } from "../assets/assets.contracts"; import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput } from "../companies/companies.contracts"; import { contactListInput, contactListOutput, contactIdInput, contactByIdOutput, contactCreateInput, contactBasicOutput, contactUpdateArgs, contactNameOutput, contactEnrichOutput, contactBulkOwnerInput, bulkResultOutput, contactBulkCompanyInput, contactBulkInput, factDecisionInput, decideFactOutput } from "../contacts/contacts.contracts"; import { conversationListInput, conversationListOutput, builderListOutput, builderResourceSearchInput, builderResourcesOutput, conversationIdInput, builderConversationDetailOutput, conversationEventsInput, conversationEventsOutput, conversationSaveInput, conversationIdOutput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, builderResponseRatingOutput, conversationShareStatusOutput, conversationShareTokenOutput, sharedConversationInput, sharedConversationOutput } from "../conversations/conversations.contracts"; @@ -140,6 +141,48 @@ const appRouter = t.router({ .output(revokeApiKeyOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), + assets: t.router({ + createUpload: publicProcedure + .input(projectUploadCreateInput) + .output(uploadGrantSchema) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + getUpload: publicProcedure + .input(projectUploadInput) + .output(uploadStateSchema) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + renewUpload: publicProcedure + .input(projectUploadInput) + .output(uploadGrantSchema) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + confirmUpload: publicProcedure + .input(projectUploadInput) + .output(uploadConfirmationSchema) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + cancelUpload: publicProcedure + .input(projectUploadInput) + .output(uploadCancellationSchema) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + listCustomerAssets: publicProcedure + .input(customerAssetListArgs) + .output(assetListSchema) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + listProjectAssets: publicProcedure + .input(projectAssetListInput) + .output(assetListSchema) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + getAsset: publicProcedure + .input(projectAssetInput) + .output(assetDetailSchema) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + downloadAsset: publicProcedure + .input(projectAssetInput) + .output(assetDownloadSchema) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + deleteAsset: publicProcedure + .input(projectAssetInput) + .output(assetDeletionSchema) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), companies: t.router({ list: publicProcedure .input(companyListInput) diff --git a/apps/api/src/trpc/middlewares/domain-error.middleware.ts b/apps/api/src/trpc/middlewares/domain-error.middleware.ts index 2b7bf7fb5..54352a84b 100644 --- a/apps/api/src/trpc/middlewares/domain-error.middleware.ts +++ b/apps/api/src/trpc/middlewares/domain-error.middleware.ts @@ -13,6 +13,8 @@ type TrpcErrorCode = | "NOT_FOUND" | "CONFLICT" | "TOO_MANY_REQUESTS" + | "PAYLOAD_TOO_LARGE" + | "SERVICE_UNAVAILABLE" | "INTERNAL_SERVER_ERROR"; function statusToTrpcCode(status: number): TrpcErrorCode { @@ -29,6 +31,10 @@ function statusToTrpcCode(status: number): TrpcErrorCode { return "CONFLICT"; case 429: return "TOO_MANY_REQUESTS"; + case 413: + return "PAYLOAD_TOO_LARGE"; + case 503: + return "SERVICE_UNAVAILABLE"; default: return "INTERNAL_SERVER_ERROR"; } @@ -50,6 +56,7 @@ export class DomainErrorMiddleware implements TRPCMiddleware { throw new TRPCError({ code: statusToTrpcCode(cause.getStatus()), message: cause.message, + cause, }); } diff --git a/apps/api/test/asset-purge-automatic.integration.spec.ts b/apps/api/test/asset-purge-automatic.integration.spec.ts new file mode 100644 index 000000000..acf947072 --- /dev/null +++ b/apps/api/test/asset-purge-automatic.integration.spec.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { + AssetPurgeFixture, + createAssetPurgeFixture, +} from "./asset-purge.fixture"; + +let fixture: AssetPurgeFixture; +let deals: AssetPurgeFixture["deals"]; +let project: AssetPurgeFixture["project"]; +let assetFixture: AssetPurgeFixture["assetFixture"]; + +describe("asset storage cleanup during automatic deal purge", () => { + beforeAll(async () => { + fixture = createAssetPurgeFixture(); + await fixture.setup(); + deals = fixture.deals; + project = fixture.project; + assetFixture = fixture.assetFixture; + }); + + afterAll(async () => { + await fixture.clean(); + }); + it("keeps automatic purge jobs after the deal cascade", async () => { + const before = new Date("2026-09-01T00:00:00.000Z"); + const deleted = await project( + "automatic-deleted", + new Date("2026-08-01T00:00:00.000Z"), + ); + const survivor = await project("automatic-survivor"); + const deletedAssets = await assetFixture( + deleted.id, + deleted.companyId, + "automatic-deleted", + ); + const survivorAssets = await assetFixture( + survivor.id, + survivor.companyId, + "automatic-survivor", + ); + + expect(await deals.purgeExpired(before)).toMatchObject({ + requested: 1, + succeeded: 1, + skipped: 0, + failed: 0, + }); + + expect(await db.deal.findUnique({ where: { id: deleted.id } })).toBeNull(); + expect( + await db.assetStorageJob.findMany({ + where: { projectId: deleted.id }, + select: { artifactId: true, uploadId: true, objectKey: true }, + }), + ).toHaveLength(3); + expect( + await db.assetUpload.findUnique({ + where: { id: deletedAssets.upload.id }, + select: { status: true }, + }), + ).toEqual({ status: "CANCELED" }); + + expect( + await db.deal.findUnique({ where: { id: survivor.id } }), + ).not.toBeNull(); + expect( + await db.assetStorageJob.count({ where: { projectId: survivor.id } }), + ).toBe(0); + expect( + await db.artifact.findUnique({ + where: { id: survivorAssets.artifact.id }, + }), + ).not.toBeNull(); + expect( + await db.assetUpload.findUnique({ + where: { id: survivorAssets.upload.id }, + }), + ).not.toBeNull(); + }); +}); diff --git a/apps/api/test/asset-purge.fixture.ts b/apps/api/test/asset-purge.fixture.ts new file mode 100644 index 000000000..9d352055c --- /dev/null +++ b/apps/api/test/asset-purge.fixture.ts @@ -0,0 +1,148 @@ +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { ConversionService } from "../src/currency/conversion.service"; +import { DealsService } from "../src/deals/deals.service"; +import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +export const purgeKeys = [ + "explicit-deleted", + "explicit-survivor", + "automatic-deleted", + "automatic-survivor", +] as const; +export type PurgeKey = (typeof purgeKeys)[number]; + +export function createAssetPurgeFixture() { + const suffix = `${process.env.TEST_RUN_ID ?? "asset-purge-spec"}-${randomUUID()}`; + const ownerId = `asset-purge-owner-${suffix}`; + const domains = purgeKeys.map((key) => `${key}-${suffix}.test`); + const agent = { + withCrmEvents: withDiscardedCrmEvents, + } as unknown as AgentTriggerService; + const deals = new DealsService( + db, + agent, + new ActivityStampService(db), + new ConversionService(db), + new FieldsService(db, { fieldBackfill: async () => undefined } as never), + ); + + async function clean() { + const companies = await db.company.findMany({ + where: { domain: { in: domains } }, + select: { id: true }, + }); + const companyIds = companies.map((company) => company.id); + const projectIds = purgeKeys.map( + (key) => `asset-purge-project-${key}-${suffix}`, + ); + + await db.assetStorageJob.deleteMany({ + where: { projectId: { in: projectIds } }, + }); + await db.assetEmailSource.deleteMany({ + where: { projectId: { in: projectIds } }, + }); + await db.assetUpload.deleteMany({ + where: { projectId: { in: projectIds } }, + }); + await db.artifact.deleteMany({ where: { dealId: { in: projectIds } } }); + await db.agentTask.deleteMany({ where: { dealId: { in: projectIds } } }); + await db.deal.deleteMany({ where: { id: { in: projectIds } } }); + await db.company.deleteMany({ where: { id: { in: companyIds } } }); + await db.user.deleteMany({ where: { id: ownerId } }); + } + + async function project(key: PurgeKey, archivedAt: Date | null = null) { + const company = await db.company.create({ + data: { + id: `asset-purge-company-${key}-${suffix}`, + name: `Asset purge ${key} ${suffix}`, + domain: `${key}-${suffix}.test`, + }, + select: { id: true }, + }); + + return db.deal.create({ + data: { + id: `asset-purge-project-${key}-${suffix}`, + name: `Asset purge project ${key} ${suffix}`, + companyId: company.id, + ownerId, + archivedAt, + }, + select: { id: true, companyId: true }, + }); + } + + async function assetFixture( + projectId: string, + companyId: string, + key: string, + ) { + const artifact = await db.artifact.create({ + data: { + id: `asset-purge-artifact-${key}-${suffix}`, + dealId: projectId, + type: "file", + fileName: `${key}.txt`, + storageKey: `projects/${projectId}/${key}.txt`, + storageBucket: "crm-assets", + }, + select: { id: true, storageKey: true }, + }); + + const now = Date.now(); + const upload = await db.assetUpload.create({ + data: { + id: `asset-purge-upload-${key}-${suffix}`, + projectId, + customerId: companyId, + actorKey: `user:${ownerId}`, + fileName: `${key}-upload.txt`, + contentType: "text/plain", + sizeBytes: 32n, + kind: "file", + source: "MANUAL", + metadataHash: `metadata-${key}-${suffix}`, + bucket: "crm-assets", + temporaryKey: `temporary/${projectId}/${key}.txt`, + finalKey: `projects/${projectId}/${key}-upload.txt`, + expiresAt: new Date(now + 60 * 60_000), + grantExpiresAt: new Date(now + 60 * 60_000), + reservationUntil: new Date(now + 60 * 60_000), + }, + select: { id: true, temporaryKey: true, finalKey: true }, + }); + + return { artifact, upload }; + } + + async function setup() { + await clean(); + await db.user.create({ + data: { + id: ownerId, + name: "Asset purge owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + } + + return { + suffix, + ownerId, + keys: purgeKeys, + deals, + clean, + setup, + project, + assetFixture, + }; +} + +export type AssetPurgeFixture = ReturnType; diff --git a/apps/api/test/asset-purge.integration.spec.ts b/apps/api/test/asset-purge.integration.spec.ts new file mode 100644 index 000000000..d677b2609 --- /dev/null +++ b/apps/api/test/asset-purge.integration.spec.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { + AssetPurgeFixture, + createAssetPurgeFixture, +} from "./asset-purge.fixture"; + +let fixture: AssetPurgeFixture; +let deals: AssetPurgeFixture["deals"]; +let project: AssetPurgeFixture["project"]; +let assetFixture: AssetPurgeFixture["assetFixture"]; +let suffix: AssetPurgeFixture["suffix"]; + +describe("asset storage cleanup during explicit deal purge", () => { + beforeAll(async () => { + fixture = createAssetPurgeFixture(); + await fixture.setup(); + deals = fixture.deals; + project = fixture.project; + assetFixture = fixture.assetFixture; + suffix = fixture.suffix; + }); + + afterAll(async () => { + await fixture.clean(); + }); + it("keeps explicit purge jobs and isolates another project", async () => { + const deleted = await project("explicit-deleted"); + const survivor = await project("explicit-survivor"); + const deletedAssets = await assetFixture( + deleted.id, + deleted.companyId, + "explicit-deleted", + ); + const survivorAssets = await assetFixture( + survivor.id, + survivor.companyId, + "explicit-survivor", + ); + + await expect(deals.purge(deleted.id)).resolves.toEqual({ + id: deleted.id, + name: `Asset purge project explicit-deleted ${suffix}`, + }); + + const jobs = await db.assetStorageJob.findMany({ + where: { projectId: deleted.id }, + orderBy: { operationKey: "asc" }, + select: { + operationKey: true, + projectId: true, + uploadId: true, + artifactId: true, + bucket: true, + objectKey: true, + temporary: true, + }, + }); + + expect(jobs).toEqual([ + { + operationKey: `artifact:${deletedAssets.artifact.id}`, + projectId: deleted.id, + uploadId: null, + artifactId: deletedAssets.artifact.id, + bucket: "crm-assets", + objectKey: deletedAssets.artifact.storageKey, + temporary: false, + }, + { + operationKey: `orphan:${deletedAssets.upload.id}`, + projectId: deleted.id, + uploadId: deletedAssets.upload.id, + artifactId: null, + bucket: "crm-assets", + objectKey: deletedAssets.upload.finalKey, + temporary: false, + }, + { + operationKey: `temporary:${deletedAssets.upload.id}`, + projectId: deleted.id, + uploadId: deletedAssets.upload.id, + artifactId: null, + bucket: "crm-assets", + objectKey: deletedAssets.upload.temporaryKey, + temporary: true, + }, + ]); + + expect(await db.deal.findUnique({ where: { id: deleted.id } })).toBeNull(); + expect( + await db.artifact.findUnique({ + where: { id: deletedAssets.artifact.id }, + }), + ).toBeNull(); + expect( + await db.assetUpload.findUnique({ + where: { id: deletedAssets.upload.id }, + select: { status: true }, + }), + ).toEqual({ status: "CANCELED" }); + + expect( + await db.deal.findUnique({ where: { id: survivor.id } }), + ).not.toBeNull(); + expect( + await db.assetStorageJob.count({ where: { projectId: survivor.id } }), + ).toBe(0); + expect( + await db.artifact.findUnique({ + where: { id: survivorAssets.artifact.id }, + }), + ).not.toBeNull(); + expect( + await db.assetUpload.findUnique({ + where: { id: survivorAssets.upload.id }, + }), + ).not.toBeNull(); + }); +}); diff --git a/apps/api/test/asset-storage-errors.spec.ts b/apps/api/test/asset-storage-errors.spec.ts new file mode 100644 index 000000000..52e7bf3d8 --- /dev/null +++ b/apps/api/test/asset-storage-errors.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { ASSETS } from "../src/assets/asset-config"; +import { AssetStorageService } from "../src/assets/asset-storage.service"; +import type { CapturedRequest } from "./asset-storage.fixture"; +import { testClient, withR2Environment } from "./asset-storage.fixture"; + +let restore: (() => void) | undefined; + +describe("AssetStorageService error behavior", () => { + beforeEach(() => { + restore = withR2Environment(); + }); + + afterEach(() => { + restore?.(); + }); + + it("returns null when HEAD reports a missing object", async () => { + const service = new AssetStorageService( + undefined, + testClient([], [], { statusCode: 404, headers: {} }), + ); + + expect(await service.head("assets", "temporary/missing")).toBeNull(); + }); + + it("turns a changed source ETag into a state conflict", async () => { + const service = new AssetStorageService( + undefined, + testClient([], [], { + statusCode: 412, + headers: {}, + body: new TextEncoder().encode("secret provider details"), + }), + ); + + const promise = service.copy( + "assets", + "temporary/upload-1", + "final/upload-1", + '"old"', + ); + await expect(promise).rejects.toMatchObject({ + status: 409, + code: "SOURCE_ETAG_MISMATCH", + }); + await expect(promise).rejects.not.toThrow("secret provider details"); + }); + + it("redacts provider errors and keeps transient errors retryable", async () => { + const service = new AssetStorageService( + undefined, + testClient( + [], + [], + undefined, + Object.assign(new Error("secret-key"), { + $metadata: { httpStatusCode: 503 }, + }), + ), + ); + + const error = await service + .delete("assets", "final/upload-1") + .catch((value) => value); + expect(error).toMatchObject({ + status: 503, + code: "STORAGE_UNAVAILABLE", + retryable: true, + }); + expect(error.message).toBe("Object storage request failed."); + expect(error.message).not.toContain("secret-key"); + }); + + it("passes an aborted request signal and sanitizes the provider error", async () => { + const requests: CapturedRequest[] = []; + const controller = new AbortController(); + const service = new AssetStorageService( + undefined, + testClient( + requests, + [], + undefined, + Object.assign(new Error("secret provider details"), { + name: "AbortError", + }), + ), + ); + + controller.abort(); + const error = await service + .delete("assets", "temporary/upload-1", controller.signal) + .catch((value) => value); + + expect(requests[0]?.abortSignal).toBe(controller.signal); + expect(error).toMatchObject({ + status: 503, + code: "STORAGE_UNAVAILABLE", + retryable: true, + }); + expect(error.message).toBe("Object storage request failed."); + expect(error.message).not.toContain("secret provider details"); + }); + + it("rejects a PUT above the single-request R2 limit", async () => { + const service = new AssetStorageService(); + + await expect( + service.presignPut( + "assets", + "temporary/upload-1", + "application/octet-stream", + ASSETS.maxSingleUploadBytes + 1, + new Date(Date.now() + 60_000), + ), + ).rejects.toMatchObject({ + status: 413, + code: "UPLOAD_TOO_LARGE", + details: { maxBytes: 5363466240 }, + }); + }); +}); diff --git a/apps/api/test/asset-storage.fixture.ts b/apps/api/test/asset-storage.fixture.ts new file mode 100644 index 000000000..4ed8cb791 --- /dev/null +++ b/apps/api/test/asset-storage.fixture.ts @@ -0,0 +1,86 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +export const r2Keys = [ + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET", +] as const; + +export type ResponseSpec = { + statusCode: number; + headers?: Record; + body?: Uint8Array; +}; + +export type CapturedRequest = { + method: string; + path: string; + headers: Record; + abortSignal?: AbortSignal; +}; + +export function withR2Environment() { + const previous: Partial> = + {}; + for (const key of r2Keys) { + previous[key] = process.env[key]; + process.env[key] = + key === "R2_ACCOUNT_ID" + ? "account-id" + : key === "R2_ACCESS_KEY_ID" + ? "access-key" + : key === "R2_SECRET_ACCESS_KEY" + ? "secret-key" + : "assets"; + } + return () => { + for (const key of r2Keys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + +export function testClient( + requests: CapturedRequest[], + responses: ResponseSpec[], + defaultResponse?: ResponseSpec, + failure?: Error, +): S3Client { + const requestHandler = { + handle: async ( + request: CapturedRequest, + options: { abortSignal?: AbortSignal }, + ) => { + requests.push({ + method: request.method, + path: request.path, + headers: request.headers, + abortSignal: options.abortSignal, + }); + if (failure) throw failure; + const response = responses.shift() ?? + defaultResponse ?? { statusCode: 204 }; + return { + response: { + statusCode: response.statusCode, + headers: response.headers ?? {}, + body: response.body ?? new Uint8Array(), + }, + }; + }, + }; + + return new S3Client({ + region: "auto", + endpoint: "http://r2.test", + forcePathStyle: true, + credentials: { accessKeyId: "access-key", secretAccessKey: "secret-key" }, + maxAttempts: 1, + requestChecksumCalculation: "WHEN_REQUIRED", + responseChecksumValidation: "WHEN_REQUIRED", + requestHandler: requestHandler as never, + }); +} diff --git a/apps/api/test/asset-storage.spec.ts b/apps/api/test/asset-storage.spec.ts new file mode 100644 index 000000000..514b544ec --- /dev/null +++ b/apps/api/test/asset-storage.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { AssetError } from "../src/assets/asset-error"; +import { AssetStorageService } from "../src/assets/asset-storage.service"; +import type { CapturedRequest, ResponseSpec } from "./asset-storage.fixture"; +import { r2Keys, testClient, withR2Environment } from "./asset-storage.fixture"; + +let restore: (() => void) | undefined; + +describe("AssetStorageService request and signing behavior", () => { + beforeEach(() => { + restore = withR2Environment(); + }); + + afterEach(() => { + restore?.(); + }); + + it("starts without R2 and reports a sanitized unavailable error", () => { + for (const key of r2Keys) delete process.env[key]; + const service = new AssetStorageService(); + + expect(service.configured()).toBe(false); + expect(() => service.bucket()).toThrow(AssetError); + try { + service.bucket(); + } catch (error) { + expect(error).toMatchObject({ + status: 503, + code: "STORAGE_UNAVAILABLE", + retryable: false, + }); + expect((error as Error).message).not.toContain("secret-key"); + } + }); + + it("signs PUT content type and content length without an R2 checksum", async () => { + const service = new AssetStorageService(); + const url = await service.presignPut( + "assets", + "temporary/upload-1", + "audio/mpeg", + 1234, + new Date(Date.now() + 60_000), + ); + const parsed = new URL(url); + + expect(parsed.searchParams.get("X-Amz-SignedHeaders")).toContain( + "content-length", + ); + expect(parsed.searchParams.get("X-Amz-SignedHeaders")).toContain( + "content-type", + ); + expect(url).not.toContain("checksum"); + }); + + it("signs private GET attachment disposition and response type", async () => { + const service = new AssetStorageService(); + const url = await service.presignGet( + "assets", + "final/upload-1", + "réunion 1.mp3", + "audio/mpeg", + new Date(Date.now() + 60_000), + ); + const parsed = new URL(url); + + expect(parsed.searchParams.get("response-content-type")).toBe("audio/mpeg"); + expect(parsed.searchParams.get("response-content-disposition")).toContain( + "attachment", + ); + expect(parsed.searchParams.get("response-content-disposition")).toContain( + "filename*=UTF-8''r%C3%A9union%201.mp3", + ); + }); + + it("intercepts HEAD, conditional COPY, and DELETE requests", async () => { + const requests: CapturedRequest[] = []; + const responses: ResponseSpec[] = [ + { + statusCode: 200, + headers: { + "content-length": "42", + etag: '"source-etag"', + "content-type": "audio/mpeg", + }, + }, + { + statusCode: 200, + body: new TextEncoder().encode(""), + }, + { statusCode: 204 }, + ]; + const service = new AssetStorageService( + undefined, + testClient(requests, responses), + ); + + expect(await service.head("assets", "temporary/upload-1")).toEqual({ + sizeBytes: 42, + etag: '"source-etag"', + contentType: "audio/mpeg", + }); + await service.copy( + "assets", + "temporary/upload-1", + "final/upload-1", + '"source-etag"', + ); + await service.delete("assets", "temporary/upload-1"); + + expect(requests).toHaveLength(3); + expect(requests[0]?.method).toBe("HEAD"); + expect(requests[1]?.method).toBe("PUT"); + expect(requests[1]?.headers["x-amz-copy-source"]).toContain( + "assets/temporary/upload-1", + ); + expect(requests[1]?.headers["x-amz-copy-source-if-match"]).toBe( + '"source-etag"', + ); + expect(requests[2]?.method).toBe("DELETE"); + }); +}); diff --git a/apps/api/test/assets-core-abort.integration.spec.ts b/apps/api/test/assets-core-abort.integration.spec.ts new file mode 100644 index 000000000..d7ddd4215 --- /dev/null +++ b/apps/api/test/assets-core-abort.integration.spec.ts @@ -0,0 +1,141 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let storage: AssetsCoreFixture["storage"]; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let projectId: AssetsCoreFixture["projectId"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let due: AssetsCoreFixture["due"]; + +describe("asset worker deadlines", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + storage = fixture.storage; + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + projectId = fixture.projectId; + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + due = fixture.due.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("aborts in-flight storage work at the invocation boundary and durably defers it", async () => { + const created = await create(); + await put(created.upload.id); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + const controller = new AbortController(); + storage.copyHook = async (signal) => { + if (!signal) throw new Error("The worker did not pass an abort signal."); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + setTimeout(() => controller.abort(), 5); + }); + }; + const start = Date.now(); + await worker.process(controller.signal); + expect(Date.now() - start).toBeLessThan(1_000); + const job = await db.assetStorageJob.findUniqueOrThrow({ + where: { operationKey: `finalize:${created.upload.id}` }, + }); + expect(job).toMatchObject({ + state: "PENDING", + attempts: 0, + leaseToken: null, + }); + expect(job.lastError).toContain("Invocation deadline"); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("FINALIZING"); + storage.copyHook = null; + await due(); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("READY"); + }); + it("aborts the initial source metadata request and defers finalization", async () => { + const created = await create(); + await put(created.upload.id); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + const controller = new AbortController(); + storage.headHook = async (signal) => { + if (!signal) + throw new Error("The worker did not pass an abort signal to HEAD."); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + setTimeout(() => controller.abort(), 5); + }); + }; + const start = Date.now(); + await worker.process(controller.signal); + expect(Date.now() - start).toBeLessThan(1_000); + expect(storage.copyCount).toBe(0); + const job = await db.assetStorageJob.findUniqueOrThrow({ + where: { operationKey: `finalize:${created.upload.id}` }, + }); + expect(job).toMatchObject({ + state: "PENDING", + attempts: 0, + leaseToken: null, + }); + expect(job.lastError).toContain("Invocation deadline"); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("FINALIZING"); + storage.headHook = null; + await due(); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("READY"); + }); +}); diff --git a/apps/api/test/assets-core-deletion.integration.spec.ts b/apps/api/test/assets-core-deletion.integration.spec.ts new file mode 100644 index 000000000..ca15d2137 --- /dev/null +++ b/apps/api/test/assets-core-deletion.integration.spec.ts @@ -0,0 +1,198 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { AssetError } from "../src/assets/asset-error"; +import { enqueueProjectAssetPurge } from "../src/assets/asset-purge"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let storage: AssetsCoreFixture["storage"]; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let projectId: AssetsCoreFixture["projectId"]; +let otherProjectId: AssetsCoreFixture["otherProjectId"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let ready: AssetsCoreFixture["ready"]; +let due: AssetsCoreFixture["due"]; + +describe("asset deletion and stale work", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + storage = fixture.storage; + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + projectId = fixture.projectId; + otherProjectId = fixture.otherProjectId; + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + ready = fixture.ready.bind(fixture); + due = fixture.due.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("hides deleting assets and retries object deletion without duplicate jobs", async () => { + const result = await ready(); + storage.deleteFailures = 1; + expect( + ( + await service.deleteAsset( + actor, + projectId, + result.assetId, + randomUUID(), + ) + ).status, + ).toBe("DELETING"); + await service.deleteAsset(actor, projectId, result.assetId, randomUUID()); + await expect( + service.downloadAsset(actor, projectId, result.assetId), + ).rejects.toMatchObject({ code: "ASSET_NOT_READY" }); + expect( + ( + await service.listProjectAssets(actor, projectId, { + page: 1, + pageSize: 25, + }) + ).total, + ).toBe(0); + await worker.process(); + expect( + (await service.getAsset(actor, projectId, result.assetId)).asset.status, + ).toBe("DELETING"); + await due(); + await worker.process(); + expect( + (await service.getAsset(actor, projectId, result.assetId)).asset, + ).toMatchObject({ status: "DELETED", deletedAt: expect.any(String) }); + expect( + await db.assetStorageJob.count({ where: { artifactId: result.assetId } }), + ).toBe(1); + }); + it("preserves unknown legacy storage references during deletion", async () => { + const legacy = await db.artifact.create({ + data: { + dealId: projectId, + type: "legacy", + fileName: "old.pdf", + storageKey: "unknown-original-location", + }, + }); + const detail = await service.getAsset(actor, projectId, legacy.id); + expect(detail.asset).toMatchObject({ + status: "UNVERIFIED", + source: null, + sizeBytes: null, + uploadedById: null, + }); + await expect( + service.downloadAsset(actor, projectId, legacy.id), + ).rejects.toMatchObject({ code: "ASSET_NOT_READY" }); + await service.deleteAsset(actor, projectId, legacy.id, randomUUID()); + await worker.process(); + expect( + (await service.getAsset(actor, projectId, legacy.id)).asset.status, + ).toBe("DELETING"); + const job = await db.assetStorageJob.findFirstOrThrow({ + where: { artifactId: legacy.id }, + }); + expect(job.bucket).toBeNull(); + expect(job.objectKey).toBe("unknown-original-location"); + expect(job.lastError).toContain("operator resolution"); + }); + it("purges a project during conditional copy and removes its orphan final object", async () => { + const created = await create(); + const upload = await put(created.upload.id); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + storage.copyHook = async () => { + await db.$transaction(async (tx) => { + await enqueueProjectAssetPurge(tx, projectId); + await tx.deal.delete({ where: { id: projectId } }); + }); + }; + await worker.process(); + expect(await db.artifact.count({ where: { dealId: projectId } })).toBe(0); + expect(storage.objects.has(upload.finalKey)).toBe(false); + expect( + await db.deal.findUnique({ where: { id: otherProjectId } }), + ).not.toBeNull(); + await expect( + service.getUpload(actor, projectId, created.upload.id), + ).rejects.toBeInstanceOf(AssetError); + }); + it("reconciles final objects from a stale copy after deletion already completes", async () => { + const created = await create(); + const upload = await put(created.upload.id); + await service.confirmUpload(actor, projectId, upload.id, randomUUID()); + let release: (() => void) | undefined; + let copying: (() => void) | undefined; + const started = new Promise((resolve) => { + copying = resolve; + }); + const delayed = new Promise((resolve) => { + release = resolve; + }); + storage.copyHook = async () => { + storage.copyHook = null; + copying?.(); + await delayed; + }; + const staleWorker = worker.process(); + await started; + await db.assetStorageJob.update({ + where: { operationKey: `finalize:${upload.id}` }, + data: { leaseUntil: new Date(0) }, + }); + await worker.process(); + const complete = await service.getUpload(actor, projectId, upload.id); + expect(complete.upload.status).toBe("READY"); + await service.deleteAsset( + actor, + projectId, + complete.upload.assetId as string, + randomUUID(), + ); + await worker.process(); + expect(storage.objects.has(upload.finalKey)).toBe(false); + storage.put(upload.temporaryKey); + release?.(); + await staleWorker; + expect(storage.objects.has(upload.finalKey)).toBe(true); + await db.assetStorageJob.updateMany({ + where: { artifactId: complete.upload.assetId }, + data: { nextAttemptAt: new Date(0) }, + }); + await worker.process(); + expect(storage.objects.has(upload.finalKey)).toBe(false); + }); +}); diff --git a/apps/api/test/assets-core-legacy.integration.spec.ts b/apps/api/test/assets-core-legacy.integration.spec.ts new file mode 100644 index 000000000..f8787e619 --- /dev/null +++ b/apps/api/test/assets-core-legacy.integration.spec.ts @@ -0,0 +1,133 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let storage: AssetsCoreFixture["storage"]; +let service: AssetsCoreFixture["service"]; +let actor: AssetsCoreFixture["actor"]; +let projectId: AssetsCoreFixture["projectId"]; +let create: AssetsCoreFixture["create"]; +let ready: AssetsCoreFixture["ready"]; + +describe("asset legacy compatibility", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + storage = fixture.storage; + service = fixture.service; + actor = fixture.actor; + projectId = fixture.projectId; + create = fixture.create.bind(fixture); + ready = fixture.ready.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("migrates legacy artifacts without inventing object locations or source metadata", async () => { + const schema = `asset_migration_${randomUUID().replaceAll("-", "")}`; + const migration = await Bun.file( + new URL( + "../../../packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql", + import.meta.url, + ), + ).text(); + await db.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`); + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${schema}"`); + await tx.$executeRaw`CREATE TABLE "artifact" ("id" TEXT PRIMARY KEY, "dealId" TEXT NOT NULL, "type" TEXT NOT NULL, "fileName" TEXT NOT NULL, "storageKey" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL)`; + await tx.$executeRaw`INSERT INTO "artifact" VALUES ('known', 'project-a', 'photo', 'photo.jpg', 'existing/key', '2025-01-01'), ('unknown', 'project-b', ${"x".repeat(65)}, 'unknown.bin', 'unresolved/key', '2025-02-01'), ('missing', 'project-c', '', 'missing', '', '2025-03-01')`; + for (const statement of migration + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean)) + await tx.$executeRawUnsafe(statement); + const rows = await tx.$queryRaw< + Array<{ + id: string; + dealId: string; + kind: string; + storageKey: string; + storageBucket: string | null; + sizeBytes: bigint | null; + source: string | null; + status: string; + }> + >`SELECT "id", "dealId", "kind", "storageKey", "storageBucket", "sizeBytes", "source", "status" FROM "artifact" ORDER BY "id"`; + expect(rows).toEqual([ + { + id: "known", + dealId: "project-a", + kind: "photo", + storageKey: "existing/key", + storageBucket: null, + sizeBytes: null, + source: null, + status: "UNVERIFIED", + }, + { + id: "missing", + dealId: "project-c", + kind: "file", + storageKey: "", + storageBucket: null, + sizeBytes: null, + source: null, + status: "UNVERIFIED", + }, + { + id: "unknown", + dealId: "project-b", + kind: "file", + storageKey: "unresolved/key", + storageBucket: null, + sizeBytes: null, + source: null, + status: "UNVERIFIED", + }, + ]); + await tx.$executeRawUnsafe(`DROP SCHEMA "${schema}" CASCADE`); + }); + }); + it("keeps metadata reads available without storage configuration", async () => { + const result = await ready(); + storage.enabled = false; + await expect(create()).rejects.toMatchObject({ + code: "STORAGE_UNAVAILABLE", + retryable: false, + }); + expect( + (await service.getAsset(actor, projectId, result.assetId)).asset.status, + ).toBe("READY"); + expect( + ( + await service.listProjectAssets(actor, projectId, { + page: 1, + pageSize: 25, + }) + ).total, + ).toBe(1); + }); +}); diff --git a/apps/api/test/assets-core-mailbox.integration.spec.ts b/apps/api/test/assets-core-mailbox.integration.spec.ts new file mode 100644 index 000000000..98de7d1ef --- /dev/null +++ b/apps/api/test/assets-core-mailbox.integration.spec.ts @@ -0,0 +1,159 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import type { AssetActor } from "../src/assets/assets.service"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let service: AssetsCoreFixture["service"]; +let userId: AssetsCoreFixture["userId"]; +let projectId: AssetsCoreFixture["projectId"]; +let metadata: AssetsCoreFixture["metadata"]; +let email: AssetsCoreFixture["email"]; + +describe("asset mailbox actors", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + service = fixture.service; + userId = fixture.userId; + projectId = fixture.projectId; + metadata = fixture.metadata.bind(fixture); + email = fixture.email.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("binds system upload operations to their verified email source", async () => { + const firstSource = await email(); + const secondSource = await email(); + await db.mailboxSync.create({ data: { userId, source: "gmail" } }); + const firstActor: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: firstSource.messageId, + }; + const secondActor: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: secondSource.messageId, + }; + const created = await service.createUpload( + secondActor, + projectId, + metadata({ source: "EMAIL_ATTACHMENT", emailSource: secondSource }), + randomUUID(), + ); + await expect( + service.getUpload(firstActor, projectId, created.upload.id), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await expect( + service.renewUpload( + firstActor, + projectId, + created.upload.id, + randomUUID(), + ), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await expect( + service.confirmUpload( + firstActor, + projectId, + created.upload.id, + randomUUID(), + ), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await db.emailMessage.delete({ where: { id: secondSource.messageId } }); + await expect( + service.renewUpload( + secondActor, + projectId, + created.upload.id, + randomUUID(), + ), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + }); + it("rejects a system creation replay from a different message actor", async () => { + const firstSource = await email(); + const secondSource = await email(); + await db.mailboxSync.create({ data: { userId, source: "gmail" } }); + const firstActor: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: firstSource.messageId, + }; + const secondActor: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: secondSource.messageId, + }; + const key = randomUUID(); + const input = metadata({ + source: "EMAIL_ATTACHMENT", + emailSource: secondSource, + }); + const created = await service.createUpload( + secondActor, + projectId, + input, + key, + ); + await expect( + service.createUpload(firstActor, projectId, input, key), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + expect( + await service.createUpload(secondActor, projectId, input, key), + ).toEqual(created); + expect(await db.assetUpload.count({ where: { projectId } })).toBe(1); + }); + it("rejects a revoked source mailbox even when another provider remains connected", async () => { + const emailSource = await email(); + await db.mailboxSync.createMany({ + data: [ + { userId, source: "gmail" }, + { userId, source: "outlook" }, + ], + }); + const system: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: emailSource.messageId, + }; + const key = randomUUID(); + const input = metadata({ source: "EMAIL_ATTACHMENT", emailSource }); + const created = await service.createUpload(system, projectId, input, key); + await db.mailboxSync.delete({ + where: { userId_source: { userId, source: "gmail" } }, + }); + await expect( + service.createUpload(system, projectId, input, key), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await expect( + service.renewUpload(system, projectId, created.upload.id, randomUUID()), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + expect( + await db.mailboxSync.count({ where: { userId, source: "outlook" } }), + ).toBe(1); + }); +}); diff --git a/apps/api/test/assets-core-project.integration.spec.ts b/apps/api/test/assets-core-project.integration.spec.ts new file mode 100644 index 000000000..f51e8dff1 --- /dev/null +++ b/apps/api/test/assets-core-project.integration.spec.ts @@ -0,0 +1,191 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let userId: AssetsCoreFixture["userId"]; +let companyId: AssetsCoreFixture["companyId"]; +let projectId: AssetsCoreFixture["projectId"]; +let otherProjectId: AssetsCoreFixture["otherProjectId"]; +let metadata: AssetsCoreFixture["metadata"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let ready: AssetsCoreFixture["ready"]; + +describe("asset project operations", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + userId = fixture.userId; + companyId = fixture.companyId; + projectId = fixture.projectId; + otherProjectId = fixture.otherProjectId; + metadata = fixture.metadata.bind(fixture); + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + ready = fixture.ready.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("renews one upload without extending intent expiry and records expired state", async () => { + const created = await create(); + const renewal = await service.renewUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + expect(renewal.upload.id).toBe(created.upload.id); + expect(renewal.upload.expiresAt).toBe(created.upload.expiresAt); + await db.assetUpload.update({ + where: { id: created.upload.id }, + data: { expiresAt: new Date(0) }, + }); + await expect( + service.renewUpload(actor, projectId, created.upload.id, randomUUID()), + ).rejects.toMatchObject({ + code: "UPLOAD_STATE_CONFLICT", + details: { state: "EXPIRED" }, + }); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("EXPIRED"); + expect( + await db.assetStorageJob.count({ + where: { uploadId: created.upload.id, operation: "DELETE_OBJECT" }, + }), + ).toBe(1); + }); + it("rejects new work on archived projects but finishes accepted work", async () => { + const created = await create(); + await put(created.upload.id); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + await db.deal.update({ + where: { id: projectId }, + data: { archivedAt: new Date() }, + }); + await expect(create()).rejects.toMatchObject({ code: "PROJECT_ARCHIVED" }); + await expect( + service.renewUpload(actor, projectId, created.upload.id, randomUUID()), + ).rejects.toMatchObject({ code: "PROJECT_ARCHIVED" }); + await expect( + service.confirmUpload(actor, projectId, created.upload.id, randomUUID()), + ).rejects.toMatchObject({ code: "PROJECT_ARCHIVED" }); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("READY"); + }); + it("keeps files within their project and lists both customer projects", async () => { + const first = await ready(); + const other = await service.createUpload( + actor, + otherProjectId, + metadata(), + randomUUID(), + ); + await put(other.upload.id); + await service.confirmUpload( + actor, + otherProjectId, + other.upload.id, + randomUUID(), + ); + await worker.process(); + const listing = await service.listCustomerAssets(actor, companyId, { + page: 1, + pageSize: 1, + }); + expect(listing.total).toBe(2); + expect(listing.hasNextPage).toBe(true); + expect( + ( + await service.listProjectAssets(actor, projectId, { + page: 1, + pageSize: 25, + }) + ).total, + ).toBe(1); + await expect( + service.getAsset(actor, otherProjectId, first.assetId), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await expect( + service.getUpload(actor, otherProjectId, first.uploadId), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + }); + it("returns an empty high page without overflowing the database offset", async () => { + await ready(); + expect( + await service.listProjectAssets(actor, projectId, { + page: Number.MAX_SAFE_INTEGER, + pageSize: 100, + }), + ).toEqual({ + items: [], + page: Number.MAX_SAFE_INTEGER, + pageSize: 100, + total: 1, + hasNextPage: false, + }); + }); + it("requires an existing meeting on the exact project", async () => { + const wrong = await db.activity.create({ + data: { type: "NOTE", dealId: projectId, createdById: userId }, + }); + await expect(create({ activityId: wrong.id })).rejects.toMatchObject({ + code: "PROJECT_MISMATCH", + }); + const meeting = await db.activity.create({ + data: { type: "MEETING", dealId: otherProjectId, createdById: userId }, + }); + await expect(create({ activityId: meeting.id })).rejects.toMatchObject({ + code: "PROJECT_MISMATCH", + }); + await expect(create({ activityId: "absent" })).rejects.toMatchObject({ + code: "RESOURCE_NOT_FOUND", + }); + await db.activity.update({ + where: { id: meeting.id }, + data: { dealId: projectId }, + }); + expect((await create({ activityId: meeting.id })).upload.status).toBe( + "PENDING", + ); + }); +}); diff --git a/apps/api/test/assets-core-sources.integration.spec.ts b/apps/api/test/assets-core-sources.integration.spec.ts new file mode 100644 index 000000000..1874241dd --- /dev/null +++ b/apps/api/test/assets-core-sources.integration.spec.ts @@ -0,0 +1,140 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import type { AssetActor } from "../src/assets/assets.service"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let userId: AssetsCoreFixture["userId"]; +let projectId: AssetsCoreFixture["projectId"]; +let otherProjectId: AssetsCoreFixture["otherProjectId"]; +let metadata: AssetsCoreFixture["metadata"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let ready: AssetsCoreFixture["ready"]; +let email: AssetsCoreFixture["email"]; + +describe("asset email sources", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + userId = fixture.userId; + projectId = fixture.projectId; + otherProjectId = fixture.otherProjectId; + metadata = fixture.metadata.bind(fixture); + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + ready = fixture.ready.bind(fixture); + email = fixture.email.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("deduplicates email occurrences, preserves the project binding, and replaces canceled attempts", async () => { + const emailSource = await email(); + const input = { source: "EMAIL_ATTACHMENT" as const, emailSource }; + const first = await create(input); + const duplicate = await create(input); + expect(duplicate.upload.id).toBe(first.upload.id); + await expect(create({ ...input, sizeBytes: 9 })).rejects.toMatchObject({ + code: "SOURCE_CONFLICT", + }); + await expect( + service.createUpload( + actor, + otherProjectId, + metadata(input), + randomUUID(), + ), + ).rejects.toMatchObject({ code: "PROJECT_MISMATCH" }); + await service.cancelUpload(actor, projectId, first.upload.id, randomUUID()); + const replacement = await create(input); + expect(replacement.upload.id).not.toBe(first.upload.id); + const distinct = await create({ + ...input, + emailSource: { ...emailSource, attachmentId: "gmail-part:2" }, + }); + expect(distinct.upload.id).not.toBe(replacement.upload.id); + expect(await db.assetEmailSource.count({ where: { projectId } })).toBe(2); + }); + it("preserves email deletion markers after artifact-row removal", async () => { + const emailSource = await email(); + const result = await ready({ source: "EMAIL_ATTACHMENT", emailSource }); + const duplicate = await create({ source: "EMAIL_ATTACHMENT", emailSource }); + expect(duplicate.transfer).toBeNull(); + expect(duplicate.upload.assetId).toBe(result.assetId); + await service.deleteAsset(actor, projectId, result.assetId, randomUUID()); + await worker.process(); + await db.artifact.delete({ where: { id: result.assetId } }); + await expect( + create({ source: "EMAIL_ATTACHMENT", emailSource }), + ).rejects.toMatchObject({ code: "SOURCE_DELETED" }); + }); + it("verifies the system mailbox actor and stores null uploader attribution", async () => { + const emailSource = await email(); + const system: AssetActor = { + type: "SYSTEM", + mailboxOwnerId: userId, + messageId: emailSource.messageId, + }; + await expect( + service.createUpload( + system, + projectId, + metadata({ source: "EMAIL_ATTACHMENT", emailSource }), + randomUUID(), + ), + ).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + await db.mailboxSync.create({ data: { userId, source: "gmail" } }); + const result = await service.createUpload( + system, + projectId, + metadata({ source: "EMAIL_ATTACHMENT", emailSource }), + randomUUID(), + ); + await put(result.upload.id); + await service.confirmUpload( + system, + projectId, + result.upload.id, + randomUUID(), + ); + await worker.process(); + const upload = await db.assetUpload.findUniqueOrThrow({ + where: { id: result.upload.id }, + }); + expect(upload.uploadedById).toBeNull(); + expect(upload.mailboxOwnerId).toBe(userId); + expect( + (await service.getAsset(system, projectId, upload.assetId as string)) + .asset.uploadedById, + ).toBeNull(); + }); +}); diff --git a/apps/api/test/assets-core-worker.integration.spec.ts b/apps/api/test/assets-core-worker.integration.spec.ts new file mode 100644 index 000000000..4b00fdf88 --- /dev/null +++ b/apps/api/test/assets-core-worker.integration.spec.ts @@ -0,0 +1,167 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { ASSETS } from "../src/assets/asset-config"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let storage: AssetsCoreFixture["storage"]; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let projectId: AssetsCoreFixture["projectId"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let due: AssetsCoreFixture["due"]; + +describe("asset finalization workers", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + storage = fixture.storage; + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + projectId = fixture.projectId; + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + due = fixture.due.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("reclaims an expired worker lease and refuses state writes from its previous owner", async () => { + const created = await create(); + await put(created.upload.id); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + await db.assetStorageJob.update({ + where: { operationKey: `finalize:${created.upload.id}` }, + data: { + state: "RUNNING", + leaseToken: "dead-worker", + leaseUntil: new Date(0), + }, + }); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("READY"); + const failed = await create(); + await put(failed.upload.id); + await service.confirmUpload( + actor, + projectId, + failed.upload.id, + randomUUID(), + ); + storage.copyHook = async () => { + await db.assetStorageJob.update({ + where: { operationKey: `finalize:${failed.upload.id}` }, + data: { + leaseUntil: new Date(Date.now() + ASSETS.worker.leaseMs), + leaseToken: "replacement-worker", + }, + }); + }; + await worker.process(); + expect( + (await service.getUpload(actor, projectId, failed.upload.id)).upload + .status, + ).toBe("FINALIZING"); + storage.copyHook = null; + await db.assetStorageJob.update({ + where: { operationKey: `finalize:${failed.upload.id}` }, + data: { leaseUntil: new Date(0) }, + }); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, failed.upload.id)).upload + .status, + ).toBe("READY"); + }); + it("fails verification for missing and mismatched bytes", async () => { + for (const size of [null, 3]) { + const created = await create(); + if (size !== null) await put(created.upload.id, size); + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload, + ).toMatchObject({ + status: "FAILED", + failure: { code: "UPLOAD_VERIFICATION_FAILED" }, + }); + } + expect(await db.artifact.count({ where: { dealId: projectId } })).toBe(0); + }); + it("stops finalization after five failed attempts", async () => { + const created = await create(); + await put(created.upload.id); + storage.copyFailures = 8; + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + for (let attempt = 0; attempt < 5; attempt++) { + await due(); + await worker.process(); + } + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload, + ).toMatchObject({ + status: "FAILED", + failure: { code: "UPLOAD_FINALIZATION_FAILED" }, + }); + expect(storage.copyCount).toBe(5); + }); + it("serializes cancellation and confirmation", async () => { + const created = await create(); + await put(created.upload.id); + const results = await Promise.allSettled([ + service.cancelUpload(actor, projectId, created.upload.id, randomUUID()), + service.confirmUpload(actor, projectId, created.upload.id, randomUUID()), + ]); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + await worker.process(); + const status = ( + await service.getUpload(actor, projectId, created.upload.id) + ).upload.status; + expect(["CANCELED", "READY"]).toContain(status); + }); +}); diff --git a/apps/api/test/assets-core.fixture.ts b/apps/api/test/assets-core.fixture.ts new file mode 100644 index 000000000..57d37d146 --- /dev/null +++ b/apps/api/test/assets-core.fixture.ts @@ -0,0 +1,172 @@ +import { expect } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { AssetWorkerService } from "../src/assets/asset-worker.service"; +import { + type CreateUploadInput, + createUploadInput, +} from "../src/assets/assets.contracts"; +import { type AssetActor, AssetsService } from "../src/assets/assets.service"; +import { MemoryStorage } from "./assets-core.storage.fixture"; + +export class AssetsCoreFixture { + readonly run = `assets-core-${randomUUID()}`; + readonly projectIds: string[] = []; + readonly threadIds: string[] = []; + readonly storage = new MemoryStorage(); + readonly service = new AssetsService(db, this.storage); + readonly worker = new AssetWorkerService(db, this.storage); + actor!: AssetActor; + userId!: string; + companyId!: string; + projectId!: string; + otherProjectId!: string; + + async setup() { + const user = await db.user.create({ + data: { + id: randomUUID(), + name: this.run, + email: `${randomUUID()}@assets.test`, + }, + }); + this.userId = user.id; + this.actor = { type: "USER", userId: this.userId }; + const company = await db.company.create({ + data: { name: this.run, domain: `${randomUUID()}.assets.test` }, + }); + this.companyId = company.id; + const project = await db.deal.create({ + data: { + name: "Kitchen", + companyId: this.companyId, + ownerId: this.userId, + }, + }); + this.projectId = project.id; + const other = await db.deal.create({ + data: { + name: "Bathroom", + companyId: this.companyId, + ownerId: this.userId, + }, + }); + this.otherProjectId = other.id; + this.projectIds.push(this.projectId, this.otherProjectId); + } + + async cleanup() { + if (!this.projectIds.length) return; + await db.assetStorageJob.deleteMany({ + where: { projectId: { in: this.projectIds } }, + }); + await db.assetEmailSource.deleteMany({ + where: { projectId: { in: this.projectIds } }, + }); + await db.assetUpload.deleteMany({ + where: { projectId: { in: this.projectIds } }, + }); + await db.assetApiRequest.deleteMany({ + where: { + actorKey: { in: [`user:${this.userId}`, `mailbox:${this.userId}`] }, + }, + }); + await db.deal.deleteMany({ where: { id: { in: this.projectIds } } }); + await db.emailThread.deleteMany({ where: { id: { in: this.threadIds } } }); + await db.company.delete({ where: { id: this.companyId } }); + await db.user.delete({ where: { id: this.userId } }); + } + + metadata(input: Partial = {}) { + return createUploadInput.parse({ + fileName: "file.custom", + sizeBytes: 4, + source: "MANUAL", + ...input, + }); + } + + async create(input: Partial = {}, key = randomUUID()) { + return this.service.createUpload( + this.actor, + this.projectId, + this.metadata(input), + key, + ); + } + + async put(uploadId: string, size = 4) { + const upload = await db.assetUpload.findUniqueOrThrow({ + where: { id: uploadId }, + }); + this.storage.put(upload.temporaryKey, size); + return upload; + } + + async ready(input: Partial = {}) { + const created = await this.create(input); + await this.put(created.upload.id, input.sizeBytes ?? 4); + await this.service.confirmUpload( + this.actor, + this.projectId, + created.upload.id, + randomUUID(), + ); + await this.worker.process(); + const state = await this.service.getUpload( + this.actor, + this.projectId, + created.upload.id, + ); + expect(state.upload.status).toBe("READY"); + return { + uploadId: created.upload.id, + assetId: state.upload.assetId as string, + }; + } + + async email(attachmentId = "gmail-part:1") { + const thread = await db.emailThread.create({ + data: { + rootMessageId: randomUUID(), + companyId: this.companyId, + firstMessageAt: new Date(), + lastMessageAt: new Date(), + }, + }); + this.threadIds.push(thread.id); + const message = await db.emailMessage.create({ + data: { + threadId: thread.id, + rfcMessageId: randomUUID(), + syncedByUserId: this.userId, + gmailMessageId: randomUUID(), + direction: "INBOUND", + fromEmail: "sender@example.test", + recipients: [], + sentAt: new Date(), + }, + }); + return { messageId: message.id, attachmentId }; + } + + async due() { + await db.assetStorageJob.updateMany({ + where: { projectId: { in: this.projectIds }, state: "PENDING" }, + data: { nextAttemptAt: new Date(0) }, + }); + } +} + +export async function assertLocalTestDatabase() { + const url = new URL(process.env.DATABASE_URL ?? ""); + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + !url.pathname.endsWith("_test") + ) + throw new Error("Assets integration tests require a local test database."); + const rows = await db.$queryRaw>` + SELECT current_database() AS name + `; + expect(rows[0]?.name).toBe(url.pathname.slice(1)); +} diff --git a/apps/api/test/assets-core.integration.spec.ts b/apps/api/test/assets-core.integration.spec.ts new file mode 100644 index 000000000..3e98000e1 --- /dev/null +++ b/apps/api/test/assets-core.integration.spec.ts @@ -0,0 +1,166 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { db } from "@crm/db"; +import { ASSETS } from "../src/assets/asset-config"; +import { + AssetsCoreFixture, + assertLocalTestDatabase, +} from "./assets-core.fixture"; + +let fixture: AssetsCoreFixture; +let storage: AssetsCoreFixture["storage"]; +let service: AssetsCoreFixture["service"]; +let worker: AssetsCoreFixture["worker"]; +let actor: AssetsCoreFixture["actor"]; +let userId: AssetsCoreFixture["userId"]; +let projectId: AssetsCoreFixture["projectId"]; +let create: AssetsCoreFixture["create"]; +let put: AssetsCoreFixture["put"]; +let ready: AssetsCoreFixture["ready"]; +let due: AssetsCoreFixture["due"]; + +describe("asset state and storage transactions", () => { + beforeAll(async () => { + await assertLocalTestDatabase(); + }); + + beforeEach(async () => { + fixture = new AssetsCoreFixture(); + await fixture.setup(); + storage = fixture.storage; + service = fixture.service; + worker = fixture.worker; + actor = fixture.actor; + userId = fixture.userId; + projectId = fixture.projectId; + create = fixture.create.bind(fixture); + put = fixture.put.bind(fixture); + ready = fixture.ready.bind(fixture); + due = fixture.due.bind(fixture); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it("serializes concurrent creation and rejects a changed retry body", async () => { + const key = randomUUID(); + const results = await Promise.all( + Array.from({ length: 5 }, () => create({}, key)), + ); + expect(new Set(results.map((result) => result.upload.id)).size).toBe(1); + expect(await db.assetUpload.count({ where: { projectId } })).toBe(1); + await expect(create({ fileName: "other" }, key)).rejects.toMatchObject({ + code: "IDEMPOTENCY_CONFLICT", + }); + await db.deal.delete({ where: { id: projectId } }); + await expect(create({}, key)).rejects.toMatchObject({ + code: "RESOURCE_NOT_FOUND", + }); + }); + it("accepts arbitrary formats and zero bytes without a duration cap", async () => { + const { assetId } = await ready({ + fileName: "empty.unknown", + sizeBytes: 0, + contentType: "arbitrary/x-format", + durationMilliseconds: 7_200_000, + }); + const detail = await service.getAsset(actor, projectId, assetId); + expect(detail.asset).toMatchObject({ + sizeBytes: 0, + contentType: "arbitrary/x-format", + durationMilliseconds: 7_200_000, + uploadedById: userId, + source: "MANUAL", + }); + expect(detail.asset).not.toHaveProperty("storageKey"); + expect( + (await service.downloadAsset(actor, projectId, assetId)).method, + ).toBe("GET"); + }); + it("enforces the exact upload-size boundary", async () => { + expect( + (await create({ sizeBytes: ASSETS.maxSingleUploadBytes })).transfer + ?.maxBytes, + ).toBe(5_363_466_240); + await expect( + create({ sizeBytes: ASSETS.maxSingleUploadBytes + 1 }), + ).rejects.toMatchObject({ code: "UPLOAD_TOO_LARGE" }); + expect(await db.assetUpload.count({ where: { projectId } })).toBe(1); + }); + it("retains reservations after cancellation and releases after late-object reconciliation", async () => { + const uploads = await Promise.all( + Array.from({ length: ASSETS.reservationLimit }, () => create()), + ); + for (const upload of uploads) + await service.cancelUpload( + actor, + projectId, + upload.upload.id, + randomUUID(), + ); + await worker.process(); + await expect(create()).rejects.toMatchObject({ + code: "UPLOAD_CAPACITY_EXCEEDED", + retryable: true, + }); + const firstUpload = uploads[0]; + if (!firstUpload) throw new Error("The reservation fixture is empty."); + const first = await put(firstUpload.upload.id); + await db.assetUpload.updateMany({ + where: { projectId }, + data: { reservationUntil: new Date(0) }, + }); + await due(); + await worker.process(); + expect(storage.objects.has(first.temporaryKey)).toBe(false); + expect((await create()).upload.status).toBe("PENDING"); + }); + it("keeps the final object unchanged after an old PUT grant is reused", async () => { + const result = await ready(); + const upload = await db.assetUpload.findUniqueOrThrow({ + where: { id: result.uploadId }, + }); + storage.put(upload.temporaryKey, 88, '"replacement"'); + await service.confirmUpload(actor, projectId, upload.id, randomUUID()); + await worker.process(); + expect(storage.objects.get(upload.finalKey)?.sizeBytes).toBe(4); + expect(storage.copyCount).toBe(1); + expect(await db.artifact.count({ where: { dealId: projectId } })).toBe(1); + }); + it("reconciles a copy timeout without copying or inserting twice", async () => { + const created = await create(); + await put(created.upload.id); + storage.copyTimeout = true; + await service.confirmUpload( + actor, + projectId, + created.upload.id, + randomUUID(), + ); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("FINALIZING"); + await due(); + await worker.process(); + expect( + (await service.getUpload(actor, projectId, created.upload.id)).upload + .status, + ).toBe("READY"); + expect(storage.copyCount).toBe(1); + }); +}); diff --git a/apps/api/test/assets-core.storage.fixture.ts b/apps/api/test/assets-core.storage.fixture.ts new file mode 100644 index 000000000..da4d2f8a5 --- /dev/null +++ b/apps/api/test/assets-core.storage.fixture.ts @@ -0,0 +1,86 @@ +import { ConfigService } from "@nestjs/config"; +import type { AssetStorageHead } from "../src/assets/asset-storage.service"; +import { AssetStorageService } from "../src/assets/asset-storage.service"; + +export class MemoryStorage extends AssetStorageService { + objects = new Map(); + copyCount = 0; + deleteFailures = 0; + copyFailures = 0; + copyTimeout = false; + enabled = true; + copyHook: ((signal?: AbortSignal) => Promise) | null = null; + headHook: ((signal?: AbortSignal) => Promise) | null = null; + constructor() { + super( + new ConfigService({ + R2_ACCOUNT_ID: "test", + R2_ACCESS_KEY_ID: "test", + R2_SECRET_ACCESS_KEY: "test", + R2_BUCKET: "test", + }), + ); + } + override configured() { + return this.enabled !== false; + } + override bucket() { + return "test"; + } + override async presignPut( + _bucket: string, + key: string, + _type: string, + _size: number, + expires: Date, + ) { + return `https://storage.test/${key}?expires=${expires.getTime()}`; + } + override async presignGet(_bucket: string, key: string) { + return `https://storage.test/${key}`; + } + override async head(_bucket: string, key: string, signal?: AbortSignal) { + signal?.throwIfAborted(); + if (this.headHook) await this.headHook(signal); + signal?.throwIfAborted(); + return this.objects.get(key) ?? null; + } + override async copy( + _bucket: string, + sourceKey: string, + finalKey: string, + sourceEtag: string, + signal?: AbortSignal, + ) { + signal?.throwIfAborted(); + this.copyCount++; + if (this.copyFailures > 0) { + this.copyFailures--; + throw new Error("Transient copy failure"); + } + if (this.copyHook) await this.copyHook(signal); + signal?.throwIfAborted(); + const source = this.objects.get(sourceKey); + if (!source || source.etag !== sourceEtag) + throw new Error("Precondition failed"); + this.objects.set(finalKey, { ...source }); + if (this.copyTimeout) { + this.copyTimeout = false; + throw new Error("Unknown copy result"); + } + } + override async delete(_bucket: string, key: string) { + if (this.deleteFailures > 0) { + this.deleteFailures--; + throw new Error("Transient delete failure"); + } + this.objects.delete(key); + } + put(key: string, sizeBytes = 4, etag = '"source-1"') { + this.objects.set(key, { + sizeBytes, + etag, + contentType: "arbitrary/example", + }); + } +} diff --git a/apps/api/test/assets-http-contract.e2e.spec.ts b/apps/api/test/assets-http-contract.e2e.spec.ts new file mode 100644 index 000000000..4f789e23a --- /dev/null +++ b/apps/api/test/assets-http-contract.e2e.spec.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { AssetsHttpFixture } from "./assets-http.fixture"; + +let fixture: AssetsHttpFixture; +let app: AssetsHttpFixture["app"]; +let userId: AssetsHttpFixture["userId"]; +let base: AssetsHttpFixture["base"]; +let metadata: AssetsHttpFixture["metadata"]; + +describe("Asset HTTP contract compatibility", () => { + beforeAll(async () => { + fixture = new AssetsHttpFixture(); + await fixture.setup(); + app = fixture.app; + userId = fixture.userId; + base = fixture.base; + metadata = fixture.metadata; + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("cancels an upload and preserves its durable status", async () => { + const created = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(200); + const uploadId = created.body.upload.id; + const canceled = await request(app.getHttpServer()) + .delete(`${base}/asset-uploads/${uploadId}`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .expect(200); + expect(canceled.body).toEqual({ uploadId, status: "CANCELED" }); + const confirmation = await request(app.getHttpServer()) + .post(`${base}/asset-uploads/${uploadId}/confirm`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send({}) + .expect(409); + expect(confirmation.body.error.details.state).toBe("CANCELED"); + }); + + it("publishes ten asset operations and leaves old REST error formatting unchanged", async () => { + const document = await request(app.getHttpServer()) + .get("/openapi.json") + .expect(200); + const operations = Object.entries(document.body.paths) + .filter(([path]) => path.startsWith("/v1/")) + .flatMap(([, methods]) => + Object.keys( + methods as { get?: object; post?: object; delete?: object }, + ).filter((method) => ["get", "post", "delete"].includes(method)), + ); + expect(operations).toHaveLength(10); + const createOperation = + document.body.paths["/v1/projects/{projectId}/asset-uploads"].post; + expect(createOperation.parameters).toContainEqual( + expect.objectContaining({ + name: "Idempotency-Key", + in: "header", + required: true, + }), + ); + expect( + createOperation.responses["413"].content["application/json"].schema + .properties.error.required, + ).toContain("requestId"); + const legacy = await request(app.getHttpServer()) + .get("/rest/companies/missing") + .expect(401); + expect(legacy.body.code).toBe("UNAUTHORIZED"); + expect(legacy.body).not.toHaveProperty("error"); + await request(app.getHttpServer()) + .get("/internal/assets/process") + .expect(process.env.CRON_SECRET ? 403 : 503); + }); +}); diff --git a/apps/api/test/assets-http-flow.e2e.spec.ts b/apps/api/test/assets-http-flow.e2e.spec.ts new file mode 100644 index 000000000..8cf44a5bb --- /dev/null +++ b/apps/api/test/assets-http-flow.e2e.spec.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { AssetError } from "../src/assets/asset-error"; +import { AssetStorageService } from "../src/assets/asset-storage.service"; +import { AssetsService } from "../src/assets/assets.service"; +import { AssetsHttpFixture } from "./assets-http.fixture"; + +let fixture: AssetsHttpFixture; +let app: AssetsHttpFixture["app"]; +let db: AssetsHttpFixture["db"]; +let userId: AssetsHttpFixture["userId"]; +let projectId: AssetsHttpFixture["projectId"]; +let base: AssetsHttpFixture["base"]; +let metadata: AssetsHttpFixture["metadata"]; + +describe("Asset HTTP error handling", () => { + beforeAll(async () => { + fixture = new AssetsHttpFixture(); + await fixture.setup(); + app = fixture.app; + db = fixture.db; + userId = fixture.userId; + projectId = fixture.projectId; + base = fixture.base; + metadata = fixture.metadata; + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("returns 413 and its exact byte limit without creating an upload", async () => { + const before = await db.assetUpload.count({ where: { projectId } }); + const response = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send({ ...metadata, sizeBytes: 5363466241 }) + .expect(413); + expect(response.body.error).toMatchObject({ + code: "UPLOAD_TOO_LARGE", + retryable: false, + details: { maxBytes: 5363466240 }, + }); + expect(await db.assetUpload.count({ where: { projectId } })).toBe(before); + }); + + it("returns storage and capacity errors without exposing internal data", async () => { + const unavailable = spyOn( + app.get(AssetStorageService), + "configured", + ).mockReturnValue(false); + const response = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(503); + expect(response.body.error).toMatchObject({ + code: "STORAGE_UNAVAILABLE", + retryable: false, + }); + unavailable.mockReturnValue(true); + const create = spyOn( + app.get(AssetsService), + "createUpload", + ).mockRejectedValueOnce( + new AssetError( + 429, + "UPLOAD_CAPACITY_EXCEEDED", + "Temporary upload capacity is full.", + undefined, + true, + ), + ); + const capacity = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(429); + expect(capacity.body.error).toMatchObject({ + code: "UPLOAD_CAPACITY_EXCEEDED", + retryable: true, + }); + expect(capacity.headers["retry-after"]).toBe("60"); + create.mockRejectedValueOnce(new Error("secret provider credential")); + const failure = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(500); + expect(JSON.stringify(failure.body)).not.toContain( + "secret provider credential", + ); + create.mockRestore(); + }); +}); diff --git a/apps/api/test/assets-http-lifecycle.e2e.spec.ts b/apps/api/test/assets-http-lifecycle.e2e.spec.ts new file mode 100644 index 000000000..ac54c1d0f --- /dev/null +++ b/apps/api/test/assets-http-lifecycle.e2e.spec.ts @@ -0,0 +1,139 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { AssetWorkerService } from "../src/assets/asset-worker.service"; +import { AssetsHttpFixture } from "./assets-http.fixture"; + +let fixture: AssetsHttpFixture; +let app: AssetsHttpFixture["app"]; +let db: AssetsHttpFixture["db"]; +let userId: AssetsHttpFixture["userId"]; +let projectId: AssetsHttpFixture["projectId"]; +let otherProjectId: AssetsHttpFixture["otherProjectId"]; +let customerId: AssetsHttpFixture["customerId"]; +let base: AssetsHttpFixture["base"]; +let metadata: AssetsHttpFixture["metadata"]; +let objects: AssetsHttpFixture["objects"]; + +describe("Asset HTTP upload lifecycle", () => { + beforeAll(async () => { + fixture = new AssetsHttpFixture(); + await fixture.setup(); + app = fixture.app; + db = fixture.db; + userId = fixture.userId; + projectId = fixture.projectId; + otherProjectId = fixture.otherProjectId; + customerId = fixture.customerId; + base = fixture.base; + metadata = fixture.metadata; + objects = fixture.objects; + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("completes the upload, renewal, confirmation, listing, download, and deletion flow", async () => { + const key = randomUUID(); + const create = () => + request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", key) + .send(metadata); + const first = await create().expect(200); + const replay = await create().expect(200); + expect(replay.body).toEqual(first.body); + const uploadId = first.body.upload.id; + expect(first.body.transfer.headers).toEqual({ + "Content-Type": "application/octet-stream", + "Content-Length": "0", + }); + await request(app.getHttpServer()) + .get(`/rest/v1/projects/${otherProjectId}/asset-uploads/${uploadId}`) + .set("x-asset-test-user", userId) + .expect(404); + await request(app.getHttpServer()) + .post(`${base}/asset-uploads/${uploadId}/url`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send({}) + .expect(200); + const stored = await db.assetUpload.findUniqueOrThrow({ + where: { id: uploadId }, + }); + objects.set(stored.temporaryKey, { + sizeBytes: 0, + etag: '"empty"', + contentType: "application/octet-stream", + }); + const confirmed = await request(app.getHttpServer()) + .post(`${base}/asset-uploads/${uploadId}/confirm`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send({}) + .expect(200); + expect(confirmed.body.statusUrl).toBe(`${base}/asset-uploads/${uploadId}`); + const processed = await app.get(AssetWorkerService).process(); + expect(processed.processed).toBeGreaterThan(0); + expect( + await db.assetStorageJob.findFirst({ + where: { uploadId, operation: "FINALIZE_UPLOAD" }, + select: { state: true, attempts: true, lastError: true }, + }), + ).toMatchObject({ state: "COMPLETE", lastError: null }); + const state = await request(app.getHttpServer()) + .get(confirmed.body.statusUrl) + .set("x-asset-test-user", userId) + .expect(200); + expect(state.body.upload.status).toBe("READY"); + const assetId = state.body.upload.assetId; + const detail = await request(app.getHttpServer()) + .get(`${base}/assets/${assetId}`) + .set("x-asset-test-user", userId) + .expect(200); + expect(detail.body.asset).toMatchObject({ + id: assetId, + projectId, + customerId, + sizeBytes: 0, + source: "MANUAL", + uploadedById: userId, + }); + expect(detail.body.asset).not.toHaveProperty("storageKey"); + for (const path of [ + `${base}/assets`, + `/rest/v1/customers/${customerId}/assets?projectId=${projectId}`, + ]) { + const listed = await request(app.getHttpServer()) + .get(path) + .set("x-asset-test-user", userId) + .expect(200); + expect( + listed.body.items.map((item: { id: string }) => item.id), + ).toContain(assetId); + } + await request(app.getHttpServer()) + .get(`${base}/assets/${assetId}/download`) + .set("x-asset-test-user", userId) + .expect(200); + await request(app.getHttpServer()) + .delete(`${base}/assets/${assetId}`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .expect(200); + await request(app.getHttpServer()) + .get(`${base}/assets/${assetId}/download`) + .set("x-asset-test-user", userId) + .expect(409); + await app.get(AssetWorkerService).process(); + const deleted = await request(app.getHttpServer()) + .get(`${base}/assets/${assetId}`) + .set("x-asset-test-user", userId) + .expect(200); + expect(deleted.body.asset.status).toBe("DELETED"); + expect(deleted.body.asset.deletedAt).not.toBeNull(); + expect(objects.has(stored.finalKey)).toBe(false); + }); +}); diff --git a/apps/api/test/assets-http.e2e.spec.ts b/apps/api/test/assets-http.e2e.spec.ts new file mode 100644 index 000000000..f79d15a7b --- /dev/null +++ b/apps/api/test/assets-http.e2e.spec.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { AssetsHttpFixture } from "./assets-http.fixture"; + +let fixture: AssetsHttpFixture; +let app: AssetsHttpFixture["app"]; +let userId: AssetsHttpFixture["userId"]; +let otherProjectId: AssetsHttpFixture["otherProjectId"]; +let customerId: AssetsHttpFixture["customerId"]; +let base: AssetsHttpFixture["base"]; +let metadata: AssetsHttpFixture["metadata"]; + +describe("Asset HTTP authentication and validation", () => { + beforeAll(async () => { + fixture = new AssetsHttpFixture(); + await fixture.setup(); + app = fixture.app; + userId = fixture.userId; + otherProjectId = fixture.otherProjectId; + customerId = fixture.customerId; + base = fixture.base; + metadata = fixture.metadata; + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("protects all ten endpoints and returns the versioned error envelope", async () => { + const endpoints = [ + ["post", `${base}/asset-uploads`], + ["get", `${base}/asset-uploads/upload`], + ["post", `${base}/asset-uploads/upload/url`], + ["post", `${base}/asset-uploads/upload/confirm`], + ["delete", `${base}/asset-uploads/upload`], + ["get", `/rest/v1/customers/${customerId}/assets`], + ["get", `${base}/assets`], + ["get", `${base}/assets/asset`], + ["get", `${base}/assets/asset/download`], + ["delete", `${base}/assets/asset`], + ] as const; + for (const [method, path] of endpoints) { + const call = request(app.getHttpServer()) + [method](path) + .set("X-Request-Id", "asset-request-test"); + if (method === "post") + call.send(path.endsWith("asset-uploads") ? metadata : {}); + const response = await call.expect(401); + expect(response.body).toEqual({ + error: { + code: "AUTH_REQUIRED", + message: "Authentication is required.", + requestId: "asset-request-test", + retryable: false, + }, + }); + expect(response.headers["cache-control"]).toBe("private, no-store"); + expect(response.headers["x-request-id"]).toBe("asset-request-test"); + } + }); + + it("enforces read and write OAuth scopes", async () => { + const deniedWrite = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("x-asset-test-scope", "crm.read") + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(403); + expect(deniedWrite.body.error.code).toBe("FORBIDDEN"); + expect(deniedWrite.headers["www-authenticate"]).toContain("crm.write"); + const deniedRead = await request(app.getHttpServer()) + .get(`${base}/assets`) + .set("x-asset-test-user", userId) + .set("x-asset-test-scope", "crm.write") + .expect(403); + expect(deniedRead.body.error.code).toBe("FORBIDDEN"); + }); + + it("rejects unknown metadata, numeric strings, path shadowing, and missing keys", async () => { + for (const body of [ + { ...metadata, extra: true }, + { ...metadata, sizeBytes: "0" }, + { ...metadata, projectId: otherProjectId }, + { ...metadata, fileName: "../file" }, + ]) { + const response = await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(body) + .expect(400); + expect(response.body.error.code).toBe("VALIDATION_ERROR"); + } + await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .send(metadata) + .expect(400); + for (const query of [ + "unknown=1", + `projectId=${otherProjectId}`, + "page=1.5", + ]) { + const response = await request(app.getHttpServer()) + .get(`${base}/assets?${query}`) + .set("x-asset-test-user", userId) + .expect(400); + expect(response.body.error.code).toBe("VALIDATION_ERROR"); + } + await request(app.getHttpServer()) + .post(`${base}/asset-uploads?extra=1`) + .set("x-asset-test-user", userId) + .set("Idempotency-Key", randomUUID()) + .send(metadata) + .expect(400); + await request(app.getHttpServer()) + .post(`${base}/asset-uploads`) + .set("x-asset-test-user", userId) + .set("Content-Type", "application/json") + .send("{") + .expect(400); + }); +}); diff --git a/apps/api/test/assets-http.fixture.ts b/apps/api/test/assets-http.fixture.ts new file mode 100644 index 000000000..f87c301ae --- /dev/null +++ b/apps/api/test/assets-http.fixture.ts @@ -0,0 +1,126 @@ +import { spyOn } from "bun:test"; +import { randomUUID } from "node:crypto"; +import type { Db } from "@crm/db"; +import type { INestApplication } from "@nestjs/common"; +import { AssetStorageService } from "../src/assets/asset-storage.service"; +import type { RequestPrincipal } from "../src/auth/request-principal"; +import { RequestPrincipalService } from "../src/auth/request-principal.service"; + +export class AssetsHttpFixture { + readonly prefix = `asset-http-${randomUUID()}`; + readonly projectId = `${this.prefix}-project`; + readonly otherProjectId = `${this.prefix}-other-project`; + readonly customerId = `${this.prefix}-customer`; + readonly userId = `${this.prefix}-user`; + readonly base = `/rest/v1/projects/${this.projectId}`; + readonly metadata = { + fileName: "evidence.unusual", + sizeBytes: 0, + source: "MANUAL", + }; + readonly objects = new Map< + string, + { sizeBytes: number; etag: string; contentType: string | null } + >(); + app!: INestApplication; + db!: Db; + principal!: RequestPrincipal; + private restores: Array<() => void> = []; + + async setup() { + const testUrl = process.env.TEST_DATABASE_URL; + if (!testUrl || !new URL(testUrl).pathname.endsWith("_test")) + throw new Error("A disposable TEST_DATABASE_URL is required."); + process.env.DATABASE_URL = testUrl; + ({ db: this.db } = await import("@crm/db")); + const user = await this.db.user.create({ + data: { + id: this.userId, + email: `${this.prefix}@example.com`, + name: "Asset HTTP test", + emailVerified: true, + }, + }); + await this.db.company.create({ + data: { id: this.customerId, name: "Asset HTTP test" }, + }); + await this.db.deal.createMany({ + data: [this.projectId, this.otherProjectId].map((id) => ({ + id, + name: id, + companyId: this.customerId, + ownerId: this.userId, + })), + }); + this.principal = { + credentialKind: "oauth", + user, + clientId: "asset-test-client", + scopes: new Set(["crm.read", "crm.write"]), + session: null, + expiresAt: null, + }; + const { createApp } = await import("../src/create-app"); + this.app = await createApp(); + const resolve = spyOn( + this.app.get(RequestPrincipalService), + "resolve", + ).mockImplementation(async (req) => { + if (req.header("x-asset-test-user") !== this.userId) return null; + const scope = req.header("x-asset-test-scope"); + return scope + ? { ...this.principal, scopes: new Set([scope]) } + : this.principal; + }); + const storage = this.app.get(AssetStorageService); + const mocks = [ + resolve, + spyOn(storage, "configured").mockReturnValue(true), + spyOn(storage, "bucket").mockReturnValue("asset-tests"), + spyOn(storage, "presignPut").mockImplementation( + async (_bucket, key) => `https://storage.invalid/${key}?signed=put`, + ), + spyOn(storage, "presignGet").mockImplementation( + async (_bucket, key) => `https://storage.invalid/${key}?signed=get`, + ), + spyOn(storage, "head").mockImplementation( + async (_bucket, key) => this.objects.get(key) ?? null, + ), + spyOn(storage, "copy").mockImplementation( + async (_bucket, source, target, etag) => { + const object = this.objects.get(source); + if (!object || object.etag !== etag) + throw new Error("Source changed."); + this.objects.set(target, { ...object }); + }, + ), + spyOn(storage, "delete").mockImplementation(async (_bucket, key) => { + this.objects.delete(key); + }), + ]; + this.restores = mocks.map((mock) => () => mock.mockRestore()); + } + + async cleanup() { + for (const restore of this.restores) restore(); + if (this.app) await this.app.close(); + if (!this.db) return; + await this.db.assetStorageJob.deleteMany({ + where: { projectId: { in: [this.projectId, this.otherProjectId] } }, + }); + await this.db.assetApiRequest.deleteMany({ + where: { actorKey: `user:${this.userId}` }, + }); + await this.db.assetEmailSource.deleteMany({ + where: { projectId: { in: [this.projectId, this.otherProjectId] } }, + }); + await this.db.assetUpload.deleteMany({ + where: { projectId: { in: [this.projectId, this.otherProjectId] } }, + }); + await this.db.deal.deleteMany({ + where: { id: { in: [this.projectId, this.otherProjectId] } }, + }); + await this.db.company.delete({ where: { id: this.customerId } }); + await this.db.user.delete({ where: { id: this.userId } }); + } +} diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 04699fe51..84e4232c7 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -31,6 +31,10 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET", "PORT", "REDIS_URL" ] @@ -49,6 +53,10 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET", "PORT", "REDIS_URL" ] diff --git a/apps/api/vercel.json b/apps/api/vercel.json index ffce1ccc0..8f0fc48b3 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -20,6 +20,10 @@ { "path": "/internal/archive/prune", "schedule": "0 5 * * *" + }, + { + "path": "/internal/assets/process", + "schedule": "* * * * *" } ] } diff --git a/bun.lock b/bun.lock index 4e39d6699..96dafcfa1 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,8 @@ "name": "api", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.1127.0", + "@aws-sdk/s3-request-presigner": "^3.1127.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", @@ -334,6 +336,44 @@ "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.29", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A=="], + + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1127.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.29", "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/middleware-sdk-s3": "^3.972.75", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vl3WBaE2fMqfnEhyqulKT6ZIPNsCR2caCps92tyt1yGfLVlDXScSXBDXf4m3E2ejXBb4pplX0EdwBukJyBXtBg=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -1068,6 +1108,18 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1434,6 +1486,8 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/docs/api.md b/docs/api.md index 29257ddcc..b90af5eb8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -157,6 +157,18 @@ The root well-known routes expose authorization-server and protected-resource me ## The OpenAPI document is built at runtime, not committed +Customer and project files use the [asset API contract](./asset-api-contract.md). +`assets.*` supplies ten versioned REST operations under `/rest/v1`. +The routes use the existing principal and OAuth scopes. +The versioned asset routes return a structured error envelope and disable response caching. +The other REST routes retain their existing response format. + +`AssetStorageJob` holds deterministic file finalization and deletion work. +`GET /internal/assets/process` uses `CRON_SECRET` and processes bounded batches. +Project purge enqueues cleanup before database cascades remove file records. +Successful deletion retains a cleanup record for delayed writes and stale workers. +See [asset storage operations](./assets-storage-operations.md) before enabling R2. + `GET /openapi.json` serves one document: Nest's own controllers plus a REST bridge under `/rest` generated from every tRPC procedure. Swagger UI renders it at `/`. `createApp` builds both halves and merges them, so nothing is generated at build diff --git a/docs/asset-api-contract.md b/docs/asset-api-contract.md new file mode 100644 index 000000000..edd008d6b --- /dev/null +++ b/docs/asset-api-contract.md @@ -0,0 +1,482 @@ +--- +title: Customer and project asset API +status: implemented-pending-r2-verification +updated: 2026-09-07 +--- + +# Customer and project asset API + +This contract defines the implemented customer and project asset API. Production configuration and real R2 checks remain release requirements. +See [storage operations](./assets-storage-operations.md) for configuration and release checks. +Transcription, mobile implementation, mailbox fetching, customer matching, and project creation remain outside this API. + +## Common contract + +Base URL: `https://api.jobsteward.ai/rest/v1`. +All paths below are relative to this base. All CRM requests and responses use JSON. +Returned `statusUrl` values are origin-relative. Resolve them against `https://api.jobsteward.ai`, without adding `/rest/v1` again. +The API transfers metadata only. Callers transfer original file bytes directly to private R2 storage. +Any file format is accepted. No filename extension or media-type allowlist applies. + +Public names are `customerId`, `projectId`, and `assetId`. +They map to `Company.id`, `Deal.id`, and `Artifact.id`. +Each asset has one required project. Resolve its customer through `Deal.companyId`. +The caller cannot set `customerId`, `storageKey`, uploader identity, or a storage URL in a mutation body. +Never select a customer's newest project automatically. + +Use existing CRM authentication and principal resolution. OAuth callers send `Authorization: Bearer `. +GET operations require `crm.read`. POST and DELETE operations require `crm.write`. +Existing session and API-key callers retain their current admission rules. Never embed a shared API key in mobile clients. +Check record access on every operation, including retries. Missing or inaccessible records return `404 RESOURCE_NOT_FOUND`. +Use the [OAuth implementation guide](./oauth-oidc-crm-implementation.md) for OAuth configuration. + +IDs are opaque nonempty strings, at most 128 characters. Timestamps use RFC 3339 UTC. +Optional input fields can be omitted. Nullable fields accept null. Responses include documented nullable fields as null. +Reject unknown mutation fields to catch misspelled identifiers. +Clients ignore unknown response fields for additive compatibility. +Return `Cache-Control: private, no-store` and `X-Request-Id` on all asset API responses. +Accept an optional client `X-Request-Id`; generate one when absent or invalid. + +All successful operations return `200 OK` with the defined JSON body, matching the existing REST bridge's default status. +Success on a state-changing request means the durable state change is accepted. Inspect status for background completion. + +## Endpoints + +| ID | Method | Path | Purpose | +| --- | --- | --- | --- | +| E1 | POST | `/projects/{projectId}/asset-uploads` | Create an upload intent. | +| E2 | GET | `/projects/{projectId}/asset-uploads/{uploadId}` | Read durable upload state. | +| E3 | POST | `/projects/{projectId}/asset-uploads/{uploadId}/url` | Renew a pending upload URL. | +| E4 | POST | `/projects/{projectId}/asset-uploads/{uploadId}/confirm` | Start file verification and finalization. | +| E5 | DELETE | `/projects/{projectId}/asset-uploads/{uploadId}` | Cancel a pending or failed upload. | +| E6 | GET | `/customers/{customerId}/assets` | List assets across the customer's projects. | +| E7 | GET | `/projects/{projectId}/assets` | List one project's assets. | +| E8 | GET | `/projects/{projectId}/assets/{assetId}` | Read asset metadata and deletion state. | +| E9 | GET | `/projects/{projectId}/assets/{assetId}/download` | Obtain temporary file access. | +| E10 | DELETE | `/projects/{projectId}/assets/{assetId}` | Delete one asset and its stored file. | + +E1, E3, E4, E5, and E10 require `Idempotency-Key`. + +## E1: Create an upload + +Request example: + +```json +{ + "fileName": "bathroom-visit.mp3", + "contentType": "audio/mpeg", + "sizeBytes": 57600000, + "kind": "meeting_recording", + "source": "MOBILE_RECORDING", + "activityId": null, + "durationMilliseconds": 3600000, + "capturedAt": "2026-07-15T15:00:00Z", + "emailSource": null +} +``` + +| Field | Type | Rule | +| --- | --- | --- | +| `fileName` | string | Required display name, 1–255 characters. Reject path separators and control characters. Never use it as an object key. | +| `contentType` | string | Optional, at most 255 characters. Default `application/octet-stream`. Treat it as caller metadata, not verified content. | +| `sizeBytes` | integer | Required, zero or greater, within the selected upload method's byte limit. Empty files are valid assets. | +| `kind` | string | Optional descriptive label, 1–64 characters. Default `file`. Examples include `meeting_notes`, `evidence`, and `reference`. No fixed category list. | +| `source` | enum | Required: `MANUAL`, `MOBILE_RECORDING`, or `EMAIL_ATTACHMENT`. These values describe origin, not accepted formats. | +| `activityId` | string or null | Optional `Activity.id`. Its type must be `MEETING` and its non-null `dealId` must equal the project. | +| `durationMilliseconds` | integer or null | Optional nonnegative duration. No one-hour duration cap. | +| `capturedAt` | timestamp or null | Optional capture time. Separate from the server upload time. | +| `emailSource` | object or null | Required only for `EMAIL_ATTACHMENT`. Fields are `messageId` and `attachmentId`, each a nonempty string up to 255 characters. | + +`emailSource.messageId` is the internal CRM `EmailMessage.id`. +`attachmentId` identifies one attachment occurrence within that message. Intake computes it before calling the asset service. +For Gmail, use `gmail-part:`. Google defines `MessagePart.partId` as immutable within its message. +For a raw MIME importer, use `mime-part:`, with the zero-based nested part path from the immutable message. +Persist this identity with the email source record. Retries reuse it, including attachments whose bytes match another attachment. +Do not use a download token, filename, or byte hash alone as attachment identity. +Other intake adapters must supply a persisted occurrence identity before importing through this API. +Source: [Gmail message-part identity](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages#MessagePart). + +Reject non-null `emailSource` for other sources. Verify access to the referenced email before creating an intent. +The existing thread association is `EmailMessage.thread → EmailThread.activity → Activity.dealId`. +Treat that thread project as an intake hint, not the destination of every attachment in later messages. +The caller supplies the exact project. A non-null `EmailThread.companyId` must match that project's customer. +An unbound thread requires an explicit project selected by the authorized caller or resolved by intake. +The first import records an attachment-level project binding in `AssetEmailSource`. +Later imports of that same attachment must use its recorded project or return `409 PROJECT_MISMATCH`. +Never change the thread association during an asset upload. + +An accessible non-meeting activity, null activity project, or different activity project returns `409 PROJECT_MISMATCH`. +An inaccessible activity returns `404 RESOURCE_NOT_FOUND`. +E1 and E3 reject archived projects with `409 PROJECT_ARCHIVED`; restore the project before starting or renewing uploads. +E4 also rejects an archived project before accepting finalization. E2 and E5 remain available. +Work accepted before archiving can finish, subject to the existing project-purge checks. + +### Import attribution + +User-authenticated API calls record the principal's user ID in `uploadedById`, including manually imported email files. +Scheduled email intake calls the shared asset service in-process with a trusted `SYSTEM` actor, not a forged user token. +The service verifies the stored mailbox owner and resolved project context. Request bodies cannot select a system actor. +System imports set `uploadedById: null`. Preserve the source message and mailbox owner in the internal audit record. +`CRON_SECRET` protects internal scheduler routes only. It does not authorize E1 through E10. +Public callers and internal intake use the same validation, source deduplication, and upload-state service. + +The API stores expected metadata and a generated temporary object key before returning the grant. +The intent expires 24 hours after creation. URL renewal does not extend that deadline. + +Response example: + +```json +{ + "upload": { + "id": "upload_123", + "customerId": "company_alice", + "projectId": "deal_bathroom_2026", + "status": "PENDING", + "expiresAt": "2026-09-08T15:00:00Z", + "assetId": null, + "failure": null + }, + "transfer": { + "method": "PUT", + "url": "https://example.r2.cloudflarestorage.com/private/temporary-object?signature=example", + "headers": { "Content-Type": "audio/mpeg", "Content-Length": "57600000" }, + "expiresAt": "2026-09-07T15:15:00Z", + "maxBytes": 5363466240 + } +} +``` + +The example URL is illustrative and cannot authorize a request. +`transfer` is nullable: it is null when email deduplication resolves to a finalized upload. +Send raw bytes with every returned header. Do not send the CRM bearer token to R2. +Bind the expected `Content-Length` into the PUT signature. HTTP clients can supply the equivalent header automatically. +Verify rejection of shorter, longer, and unknown-length bodies against real R2 before release. +Signed length does not replace final object existence, byte-count, and copy checks. +The caller confirms through E4 after R2 returns a successful PUT response. + +V1 uses single-request PUT uploads. The maximum is 5 GiB minus 5 MiB, or `5363466240` bytes. +This follows the current R2 upload-limit footnote. It is a transport limit, not a restriction on file formats. +Requests above that limit return `413 UPLOAD_TOO_LARGE` before an intent is created. +There is no recording-duration restriction or separate photo/document cap. +Multipart upload is outside this V1 contract. Verify the exact byte boundary against R2 before release. +Source: [Cloudflare R2 limits](https://developers.cloudflare.com/r2/platform/limits/). + +## E2: Read upload state + +Return `{"upload": , "pollAfterSeconds": }`. +The `Upload` schema is the object shown in E1. Every nullable field is present. +For `FINALIZING`, return `pollAfterSeconds: 3`. Otherwise return null. +The failure value is null or `{"code": "UPLOAD_VERIFICATION_FAILED", "message": "Stored size differs from declared size."}`. +Messages contain no provider response, private object key, or credentials. +Status does not claim upload progress. A completed R2 PUT remains `PENDING` until E4 is accepted. + +| State | Meaning | Allowed action | +| --- | --- | --- | +| `PENDING` | Intent accepts file transfer before expiry. | Renew URL, confirm, or cancel. | +| `FINALIZING` | Durable backend work verifies and finalizes the file. | Poll E2. | +| `READY` | Exactly one confirmed artifact exists. `assetId` is non-null. | Read the artifact. | +| `FAILED` | Verification or finalization ends with a terminal failure. | Start a replacement upload or cancel. | +| `CANCELED` | The caller cancels the intent. Temporary cleanup is scheduled. | Start a new upload. | +| `EXPIRED` | The pending intent reaches its deadline. Temporary cleanup is scheduled. | Start a new upload. | + +States are monotonic except internal retries within `FINALIZING`. +An intent accepted for finalization before expiry can finish after its deadline. +Store terminal upload status for at least seven days after completion or expiry, then permit `404` after cleanup. +Access to an upload also requires access to its current project. Deleted projects return `404`. + +## E3: Renew the upload URL + +Request body: `{}`. Return the same E1 response shape. +Issue a fresh URL for the same temporary key only while status is `PENDING` and the intent remains valid. +The URL expires after 15 minutes or at intent expiry, whichever comes first. +This is grant validity, not a claimed maximum transfer duration. +Test a slow R2 PUT that starts before URL expiry and finishes after expiry, plus a reconnect after expiry. +R2 behavior remains unverified. Do not infer a 15-minute transfer cutoff or add size-based expiry without that evidence. +Use a new idempotency key for each logical renewal. Retries of that renewal reuse its key. +No new upload record, artifact, project binding, or metadata is created. + +## E4: Confirm the upload + +Request body: `{}`. +Return `{"uploadId": "upload_123", "statusUrl": "/rest/v1/projects/deal_bathroom_2026/asset-uploads/upload_123"}`. +Acceptance atomically changes `PENDING` to `FINALIZING` and records durable finalization work. +Repeat confirmation for `FINALIZING` or `READY` returns the same acknowledgement without another job or artifact. +Confirming `FAILED`, `CANCELED`, or `EXPIRED` returns `409 UPLOAD_STATE_CONFLICT` with `details.state` set to that state. + +Finalization performs these operations: + +1. Recheck that the project exists and no project purge or upload cancellation supersedes the work. +2. Read the object's byte count and ETag. Missing or mismatched bytes cause a terminal verification failure. +3. Copy the checked object into a generated final key, conditioned on the observed source ETag. +4. Verify the final object, then commit one artifact and `READY` state through a database transaction. +5. Schedule temporary-object cleanup. Preserve the final key for download grants only. + +The final key never receives a client PUT grant. Reusing the upload URL cannot overwrite the confirmed artifact. +A timeout has an unknown result. Reconcile the final object and database state before another copy or insert. +An ETag is an object identity check, not a promised SHA-256 checksum. +R2 and PostgreSQL do not share a transaction. Use one stable final key, durable work, and retry-safe reconciliation. +Source: [R2 conditional copy support](https://developers.cloudflare.com/r2/api/s3/api/). + +Confirmation stores an asset only. It does not start transcription or claim a transcription status. + +## E5: Cancel the upload + +No request body. Return `{"uploadId": "upload_123", "status": "CANCELED"}`. +Cancel `PENDING` or `FAILED`; repeated cancellation returns the same result. +`FINALIZING` and `READY` return `409 UPLOAD_STATE_CONFLICT`. Delete the resulting asset through E10 after finalization. +`EXPIRED` returns `409 UPLOAD_STATE_CONFLICT` with `details.state: "EXPIRED"`. +Serialize confirmation and cancellation so only one transition wins. +Cancellation blocks confirmation immediately. An issued R2 PUT grant remains usable until its expiry. +Attempt immediate temporary-object deletion through durable cleanup work. +The temporary-prefix lifecycle policy also removes late writes made through an already issued grant. +Set temporary-object expiry to seven days. Keep final objects outside that prefix. +This is eventual cleanup, not immediate revocation or a guaranteed deletion deadline. +Verify late-write cleanup and ensure finalization finishes or fails before its temporary object reaches lifecycle expiry. +Source: [R2 lifecycle behavior](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). + +## E8: Asset schema and detail + +E8 returns `{"asset": }`. E6 and E7 return arrays of the same object. + +```json +{ + "id": "artifact_123", + "customerId": "company_alice", + "projectId": "deal_bathroom_2026", + "activityId": null, + "fileName": "bathroom-visit.mp3", + "contentType": "audio/mpeg", + "sizeBytes": 57600000, + "kind": "meeting_recording", + "source": "MOBILE_RECORDING", + "emailSource": null, + "uploadedById": "user_gc", + "durationMilliseconds": 3600000, + "capturedAt": "2026-07-15T15:00:00Z", + "createdAt": "2026-09-07T15:03:00Z", + "status": "READY", + "deletedAt": null +} +``` + +Metadata follows E1 field types, with the migration exceptions below. `createdAt` is the artifact's original creation time. +`uploadedById` is a user ID or null for system imports and unknown historical attribution. +`status` is `UNVERIFIED`, `READY`, `DELETING`, or `DELETED`. +`UNVERIFIED` applies only to pre-existing artifacts awaiting storage inventory. New uploads become artifacts only after verification. +`deletedAt` is non-null only after physical object deletion succeeds. +Do not expose storage keys, access URLs, or transcripts in the asset schema. +The same object key never belongs to two project artifacts. +This API does not move assets between projects or update their source metadata. + +### Existing artifact rows + +Inventory existing rows and storage locations before deployment. Skip the backfill when no rows exist. +Preserve each existing ID, project, filename, storage key, and creation time. +Do not assume an existing key points to the new R2 bucket. + +| Field | Existing-row mapping | +| --- | --- | +| `kind` | Use existing `type` when it fits the kind schema; otherwise use `file`. Preserve original `type` internally. | +| `source`, `uploadedById` | Null unless existing evidence establishes the source or user. Null is response-only for `source`. | +| `sizeBytes` | Null until a storage metadata check supplies the actual byte count. Zero means a verified empty file. | +| `contentType` | Storage metadata when available; otherwise `application/octet-stream`. | +| `activityId`, `emailSource`, `durationMilliseconds`, `capturedAt` | Null unless supported by existing data. | +| `status` | `UNVERIFIED` until the stored object and its location are verified; then `READY`. | + +Expose `UNVERIFIED` metadata in lists and E8. E9 returns `409 ASSET_NOT_READY` until verification succeeds. +E10 can accept deletion, but never reports `DELETED` until the storage location is resolved and deletion is confirmed. +A missing or unknown object location requires operator resolution during migration. Never invent readiness or discard its reference. + +## E6 and E7: List assets + +| Query parameter | Rule | +| --- | --- | +| `page` | Integer, minimum 1. Default 1. | +| `pageSize` | Integer, 1–100. Default 25. | +| `projectId` | Optional on E6 only. Must belong to the path customer. | +| `activityId` | Optional exact activity filter. | +| `kind` | Optional exact kind filter. | +| `source` | Optional source enum filter. | + +Return `{"items": [], "page": 1, "pageSize": 25, "total": 1, "hasNextPage": false}`. +Sort by `createdAt DESC, id DESC` after applying customer, project, and access filters. +`total` counts filtered `READY` and `UNVERIFIED` artifacts. `hasNextPage` equals `page * pageSize < total`. +Pending uploads and deletion states do not appear in lists. Use E2 or E8 for their status. +Archived projects retain files only until permanent purge, including automatic purge after the configured archive-retention period. +E6 includes accessible archived-project artifacts until purge. E7 supports an archived project's ID until purge. +Pagination is a live listing, not a snapshot. Concurrent inserts can shift page boundaries. +Unknown query parameters return `400 VALIDATION_ERROR`. + +## E9: Download or play a recording + +Query parameters: none. Return a signed GET URL only for an authorized `READY` asset. + +```json +{ + "assetId": "artifact_123", + "url": "https://example.r2.cloudflarestorage.com/private/final-object?signature=example", + "method": "GET", + "headers": {}, + "expiresAt": "2026-09-07T15:30:00Z", + "fileName": "bathroom-visit.mp3", + "contentType": "audio/mpeg", + "sizeBytes": 57600000 +} +``` + +URL lifetime is 15 minutes. The caller requests a fresh URL when needed. +Default storage responses use `Content-Disposition: attachment` with an encoded filename. +Native playback uses the same URL and HTTP Range requests. No recording-specific download endpoint is needed. +Reported media type does not authorize active-content rendering in a browser. +Unknown formats remain downloadable. Format support in a viewer is outside this API. +Presigned URLs expose their object path and grant temporary access. Never log them or claim that their paths are secret. +Use the S3 API domain; no public bucket or public custom domain is required. +Sources: [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), +[R2 range requests](https://developers.cloudflare.com/r2/api/s3/api/). + +## E10: Delete an asset + +No request body. Return `{"assetId": "artifact_123", "status": "DELETING"}` when cleanup is pending. +Return the same shape with `DELETED` after cleanup completes. +Mark the artifact `DELETING` and store durable object-deletion work in one database transaction. +Immediately hide it from lists and refuse new download grants. Existing grants stop working after physical deletion. +An accepted deletion does not claim immediate removal from R2 or from previously downloaded client files. +E8 reports `DELETING` until R2 deletion succeeds, then `DELETED` with `deletedAt`. +Retain the deletion record for at least seven days after completion. After that, GET and DELETE can return `404`. +Repeated deletion never schedules duplicate work. A transient R2 error keeps `DELETING` and triggers a server retry. + +Permanent project purge uses this cleanup mechanism for every project artifact and pending upload. +Store all object references durably before existing database cascades remove their rows. +Prevent concurrent upload finalization from creating an artifact after project purge. +Finalization and deletion workers reconcile copied objects left by concurrent operations. +No files from another project are removed. Archiving does not delete files immediately. +The existing daily `/internal/archive/prune` job calls `deals.purgeExpired` after the configured retention period. +That automatic purge must enqueue the same storage cleanup as explicit project deletion, before database cascades run. +Use the existing archive-retention setting. Do not add a separate asset-retention setting for archived projects. +Keep the existing rule that a company with projects cannot be purged until those projects are purged. + +## Idempotency and email deduplication + +`Idempotency-Key` is a nonempty ASCII string, at most 128 characters. A UUID is recommended. +Scope it to principal identity, operation, and canonical path. Store a normalized request hash. +For E1, this key is the client creation-request identifier. No second client upload identifier is required. +The returned server `uploadId` identifies the upload across later operations and outlives the response cache. +Renewal, confirmation, cancellation, and deletion each use their own operation key. Never use one key for the whole upload workflow. +Same key and request replay the first successful response for at least 24 hours. +Different input with the same key returns `409 IDEMPOTENCY_CONFLICT`. +Concurrent attempts produce one durable action. Transient failures before durable acceptance are retryable with the same key. +Perform authentication and current record authorization before response replay. +A replayed URL can be expired. Obtain a fresh grant through E3 with a new renewal key. +State transitions remain idempotent beyond response retention. E2 and E8 provide current status after response replay. + +Email imports use `AssetEmailSource`, unique on `(messageId, attachmentId)`, with its immutable destination `projectId`. +It references the current attempt or confirmed artifact and persists independently of the 24-hour response cache. +A repeated source with matching metadata returns the existing upload and no duplicate asset. +A repeated source with different expected metadata returns `409 SOURCE_CONFLICT`. +For `READY`, return the existing `assetId` with `transfer: null`; for `FINALIZING`, return `transfer: null` and poll E2. +After an expired, failed, or canceled attempt, a new key can create one replacement intent without parallel active duplicates. +Deleting an artifact suppresses automatic reimport of that source identity. Reimport is outside V1. +Keep the source-deletion marker while its project and source email exist, even after the asset deletion record expires. +An E1 request for a deleted email source returns `409 SOURCE_DELETED`, without a transfer URL. +Manual reuploads with new idempotency keys are separate assets, even when filenames match. + +## Errors + +The versioned asset routes use the existing proposed mobile error envelope: + +```json +{ + "error": { + "code": "UPLOAD_TOO_LARGE", + "message": "The file exceeds the single-upload limit.", + "requestId": "request_123", + "retryable": false, + "details": { "maxBytes": 5363466240 } + } +} +``` + +`details` is optional. Validation details use `fields: [{"field": "sizeBytes", "message": "Must be an integer."}]`. +Do not return provider payloads, private email text, stack traces, object keys, or credentials in errors. + +| HTTP | Code | Meaning | +| --- | --- | --- | +| 400 | `VALIDATION_ERROR` | Invalid schema, unknown field, or missing idempotency key. | +| 401 | `AUTH_REQUIRED` | Missing, expired, or invalid authentication. | +| 403 | `FORBIDDEN` | Authenticated caller lacks the operation permission or OAuth scope. | +| 404 | `RESOURCE_NOT_FOUND` | Record is absent or inaccessible. | +| 409 | `IDEMPOTENCY_CONFLICT`, `SOURCE_CONFLICT` | Retry identity is reused with different input. | +| 409 | `SOURCE_DELETED` | The email source belongs to an artifact that is deleting or deleted. | +| 409 | `PROJECT_MISMATCH` | Accessible activity, email binding, or project belongs to a different requested parent. | +| 409 | `UPLOAD_STATE_CONFLICT`, `ASSET_NOT_READY` | Operation is unavailable in the current state. Include `details.state`. | +| 409 | `PROJECT_ARCHIVED` | Restore the project before creating, renewing, or confirming an upload. | +| 413 | `UPLOAD_TOO_LARGE` | File exceeds the supported upload method. Include `details.maxBytes`. | +| 429 | `UPLOAD_CAPACITY_EXCEEDED` | Caller has 20 outstanding temporary-object reservations. Retry after `Retry-After: 60`. | +| 503 | `STORAGE_UNAVAILABLE` | R2 configuration is absent or storage is temporarily unavailable. | +| 500 | `INTERNAL_ERROR` | Unexpected backend failure. Reconcile state before retrying a mutation. | + +Only 429 and transient 503 errors set `retryable: true`; state errors require the stated recovery action. +An expired upload uses HTTP 409 and `details.state: "EXPIRED"`. No asset route requires HTTP 410. +Unconfigured storage returns 503 with `retryable: false`. Artifact listing and deletion-state reads remain available. +Finalization errors appear through E2 as `FAILED`, with `UPLOAD_VERIFICATION_FAILED` or `UPLOAD_FINALIZATION_FAILED`. +Successful E2 polling remains HTTP 200 even when the stored upload has failed. +Direct R2 errors use R2's response format, not this JSON envelope. +There is no `415 UNSUPPORTED_AUDIO_TYPE` response in this asset API. + +## Durable backend work + +`AssetStorageJob` stores deterministic file work in PostgreSQL. +Use two operations: `FINALIZE_UPLOAD` and `DELETE_OBJECT`. +Store a unique operation key, upload reference, bucket and object keys, state, attempts, next-attempt time, and last error. +Jobs also carry `leaseUntil` and a lease token. Cleanup references survive project and artifact deletion without cascade. +Retain deletion jobs after successful removal and reconcile their keys hourly. A stalled worker or delayed PUT can recreate an object after an earlier deletion. +The next reconciliation deletes that object again. `DELETED` records the last verified physical removal. Cleanup records remain durable after the public deletion record retention period. +E4 inserts finalization work in the same transaction as `FINALIZING`. +E5, E10, and project purge insert cleanup work in their state-change transactions. + +`GET /internal/assets/process` uses `CRON_SECRET` and the existing internal cron pattern. +Schedule it once per minute in the API deployment. It processes bounded batches within the invocation deadline. +Claim due jobs with `FOR UPDATE SKIP LOCKED`. Renew leases during active work and reject state writes from expired lease holders. +After a crash, another invocation reclaims an expired lease and reconciles R2 state before repeating the operation. +Finalization makes at most five failed attempts within 24 hours of confirmation, then records `FAILED` and schedules cleanup. +Deletion retries continue with delay capped at one hour. Persistent failure remains visible in job state and operational logs. +Delay transient retries with exponential backoff. Store every next-attempt time; never depend on an in-process timer. +Process-local promises and agent prompts do not own these jobs. Existing deterministic worker code supplies patterns, not an asset implementation. + +`AssetApiRequest` stores results, unique on actor, operation, canonical path, and idempotency key. +Store the request hash, response status and body, and expiry. Commit accepted state changes and their replay record atomically. +Use transaction locking for simultaneous requests. Uncommitted or transiently failed attempts must remain retryable. +Delete expired replay records through the same bounded internal sweep. Keep upload and source identities independently. + +### Temporary-upload capacity + +Permit at most 20 outstanding temporary-object reservations per actor. Enforce the count transactionally when E1 creates an intent. +An existing-intent replay or E3 renewal consumes no additional reservation. +Count pending, finalizing, and retained temporary objects, including canceled, expired, failed, and ready uploads awaiting temporary cleanup. +Cancellation alone does not release a reservation. The current implementation retains it for seven days after the latest grant expires and verifies object removal before release. +This retention interval is a conservative cleanup policy. It is not evidence that R2 terminates all in-flight PUT requests within seven days. +Temporary deletion jobs continue reconciliation after reservation release. Late objects remain covered by that work and the temporary-prefix lifecycle policy. +The provider transfer-duration boundary remains a release check. Do not describe the reservation count as a hard bound on in-flight transfers or stored bytes. +Use the authenticated user as the public actor. Scheduled email intake uses a server-selected mailbox actor key. +Keep reservation records independently of project deletion until cleanup completes. +This is a retained-reservation limit. Twenty uploads within the retention interval can block new intents until cleanup releases capacity. +It is not a general request-rate limiter or a guaranteed storage-byte budget. +R2 quota failures and operational storage metrics remain separate from this API capacity error. + +## Implementation boundaries and acceptance + +Use a shared asset service and typed schemas. Keep public REST paths under `/rest/v1`. +The repository currently builds REST routes from tRPC metadata and publishes OpenAPI at `/openapi.json`. +Asset operations use that mechanism. Regenerate router types after changing their schemas. Do not commit generated OpenAPI. +The error middleware maps HTTP 413 and 503 to `PAYLOAD_TOO_LARGE` and `SERVICE_UNAVAILABLE` and preserves the domain error. +The asset response adapter supplies the defined JSON envelope for versioned asset routes. Unversioned routes retain their existing format. +The generated OpenAPI document describes asset request headers and the same error envelope. State conflicts use HTTP 409. +Keep finalization and deletion durable outside the request lifetime. API success cannot depend on process-local background promises. +Use the storage jobs and authenticated cron route defined above. This storage feature does not start intelligence or transcription work. + +Verify every endpoint's authorization, schemas, status codes, idempotency, and actual R2 behavior. +Include arbitrary formats, empty files, signed-length enforcement, one-hour recordings, expiry during transfer, and reconnect after expiry. +Test worker crash recovery, idempotency, capacity after cancellation, late lifecycle cleanup, and conditional-copy races. +Test archived-project rejection, automatic retention purge, explicit purge races, system attribution, and existing-row migration. +Prove exact byte-limit handling. Configure production only after target verification and implementation authorization. +The complete backend flow is E1, R2 PUT, E4, E2 until READY, E8/E9, and E10 with deletion-status verification. diff --git a/docs/assets-storage-operations.md b/docs/assets-storage-operations.md new file mode 100644 index 000000000..5feb7880a --- /dev/null +++ b/docs/assets-storage-operations.md @@ -0,0 +1,61 @@ +# Asset storage operations + +CompCRM uses a private Cloudflare R2 bucket for customer and project assets. The API signs short-lived direct transfers. The API never returns R2 credentials to a client. + +Set these variables together in the root `.env`: + +| Variable | Value | +| --- | --- | +| `R2_ACCOUNT_ID` | Cloudflare account ID that owns the bucket. | +| `R2_ACCESS_KEY_ID` | R2 API token access key with object read and write access. | +| `R2_SECRET_ACCESS_KEY` | Secret for the R2 API token. | +| `R2_BUCKET` | Private bucket name. | + +All four variables are optional. When one value is missing, the API starts without asset storage. Upload and download grants return `503 STORAGE_UNAVAILABLE`. Metadata lists and deletion-state reads remain available. Do not place these values in a package-local `.env` file. + +Create the bucket in Cloudflare R2. Keep public access disabled. Use one bucket for the deployment and keep temporary objects under the `temporary/` prefix. Final object keys stay outside this prefix. + +Configure an R2 lifecycle rule for `temporary/` with a seven-day expiration. The rule is a backstop for canceled uploads, expired grants, failed finalization, and writes that arrive after a grant expires. Application cleanup still attempts deletion immediately and records the result. Lifecycle deletion is eventual. See [R2 object lifecycles](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). + +Configure bucket CORS for the application origins that perform direct transfers. A starting policy is: + +```json +[ + { + "AllowedOrigins": ["https://app.jobsteward.ai"], + "AllowedMethods": ["PUT", "GET", "HEAD"], + "AllowedHeaders": ["Content-Type", "Content-Length"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] +``` + +Replace the origin with every approved web origin. Add a local origin only for local development. Do not use `*` for a production bucket. + +The upload flow creates an intent, sends the original bytes to the signed PUT URL, confirms the upload, and polls until `READY`. The signed PUT includes `Content-Type` and `Content-Length`. The finalization worker reads the source ETag and uses conditional copy before it creates an artifact. Downloads use a signed GET with `Content-Disposition: attachment`. + +The worker retains deletion records and checks deleted keys again each hour. A late PUT or stalled COPY can recreate an object after an earlier deletion. Reconciliation removes that object again. Keep cleanup records after project purge and after the asset reports `DELETED`. + +Temporary uploads consume one of 20 reservations per actor. Cleanup retains reservations for seven days after the latest grant expires. This interval is a cleanup policy, not a verified R2 transfer deadline. Twenty uploads in that interval can block new intents. Temporary cleanup continues after reservation release. Verify the provider's transfer-duration behavior before production use. + +Existing artifacts keep `UNVERIFIED` status and unknown storage locations. Inventory their original storage before deployment. Confirm the bucket, key, byte count, and media type before marking a row `READY`. Deleting an unresolved artifact creates durable work with an unknown bucket. Resolve that job's bucket from verified storage evidence. Never assign the new bucket solely because its key matches. + +## Release checks against real R2 + +Run these checks after credentials and the bucket are configured. Fake HTTP tests prove request construction. They do not prove R2 behavior. + +1. Upload an empty file and a file at `5363466240` bytes. Confirm each object size and ETag. +2. Reject `5363466241` bytes before creating an upload intent. +3. Send a PUT with the signed content type and length. Repeat with a different content type, a shorter body, a longer body, and an unknown-length body. Confirm the rejected cases do not become ready assets. +4. Start a PUT before the 15-minute grant expiry and finish it after expiry. Record whether R2 accepts or rejects the transfer. This behavior is unknown until this test runs. Do not claim that the URL lifetime limits transfer duration. +5. Reconnect after grant expiry. Confirm a new grant uses the same temporary object and the old grant is not reused. +6. Change the temporary object's ETag before finalization. Confirm conditional copy fails and no final artifact is created. +7. Confirm the final key cannot be written with the upload grant. Confirm GET grants use attachment disposition and honor range requests. +8. Cancel an upload with an existing grant. Confirm immediate cleanup is attempted, the reservation remains until cleanup resolves, and the lifecycle rule removes late temporary writes. +9. Run the scheduled cleanup and inspect retry state after a transient R2 failure. Confirm deletion remains visible until R2 confirms removal. +10. Verify the bucket is private and that an unauthenticated request cannot read a final object. + +Record the date, bucket, region `auto`, SDK version, test object keys, and R2 responses in the release evidence. Remove test objects after verification. Do not include credentials or signed URLs in the evidence. + +Use the [R2 presigned URL documentation](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), [R2 S3 API compatibility](https://developers.cloudflare.com/r2/api/s3/api/), and [R2 upload limits](https://developers.cloudflare.com/r2/platform/limits/) as the release references. diff --git a/docs/environment.md b/docs/environment.md index dd790cc94..b1ee2e81f 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -96,6 +96,15 @@ list fails closed.** Parsed on demand. `packages/auth/src/workspace.ts`. ## Typed, validated env +### Optional asset storage + +`R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_BUCKET` configure private file storage. +All four values must be present to issue upload or download grants. +Missing values disable storage transfers without preventing API startup. +Metadata lists and deletion-state reads remain available. +The existing `CRON_SECRET` also protects the asset storage worker. +See [asset storage operations](./assets-storage-operations.md) for bucket settings and required R2 checks. + `apps/api/src/config/env.validation.ts` runs via `ConfigModule.forRoot({ validate })`, and lists every variable the API reads and nothing else. diff --git a/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql b/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql new file mode 100644 index 000000000..17cfb2ffb --- /dev/null +++ b/packages/db/prisma/migrations/20260907160000_customer_project_assets/migration.sql @@ -0,0 +1,148 @@ +CREATE TYPE "AssetSource" AS ENUM ('MANUAL', 'MOBILE_RECORDING', 'EMAIL_ATTACHMENT'); + +CREATE TYPE "AssetStatus" AS ENUM ('UNVERIFIED', 'READY', 'DELETING', 'DELETED'); + +CREATE TYPE "AssetUploadStatus" AS ENUM ('PENDING', 'FINALIZING', 'READY', 'FAILED', 'CANCELED', 'EXPIRED'); + +CREATE TYPE "AssetJobOperation" AS ENUM ('FINALIZE_UPLOAD', 'DELETE_OBJECT'); + +CREATE TYPE "AssetJobState" AS ENUM ('PENDING', 'RUNNING', 'COMPLETE'); + +ALTER TABLE "artifact" ADD COLUMN "activityId" TEXT, +ADD COLUMN "capturedAt" TIMESTAMP(3), +ADD COLUMN "contentType" TEXT NOT NULL DEFAULT 'application/octet-stream', +ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "durationMilliseconds" BIGINT, +ADD COLUMN "emailAttachmentId" TEXT, +ADD COLUMN "emailMessageId" TEXT, +ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'file', +ADD COLUMN "sizeBytes" BIGINT, +ADD COLUMN "source" "AssetSource", +ADD COLUMN "status" "AssetStatus" NOT NULL DEFAULT 'UNVERIFIED', +ADD COLUMN "storageBucket" TEXT, +ADD COLUMN "uploadedById" TEXT; + +CREATE TABLE "assetUpload" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "actorKey" TEXT NOT NULL, + "uploadedById" TEXT, + "mailboxOwnerId" TEXT, + "fileName" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "kind" TEXT NOT NULL, + "source" "AssetSource" NOT NULL, + "activityId" TEXT, + "durationMilliseconds" BIGINT, + "capturedAt" TIMESTAMP(3), + "emailMessageId" TEXT, + "emailAttachmentId" TEXT, + "metadataHash" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "temporaryKey" TEXT NOT NULL, + "finalKey" TEXT NOT NULL, + "sourceEtag" TEXT, + "status" "AssetUploadStatus" NOT NULL DEFAULT 'PENDING', + "assetId" TEXT, + "failureCode" TEXT, + "failureMessage" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "grantExpiresAt" TIMESTAMP(3) NOT NULL, + "reservationUntil" TIMESTAMP(3) NOT NULL, + "reservationReleasedAt" TIMESTAMP(3), + "confirmedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetUpload_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetEmailSource" ( + "id" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "attachmentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "metadataHash" TEXT NOT NULL, + "uploadId" TEXT NOT NULL, + "assetId" TEXT, + "deletedAt" TIMESTAMP(3), + "mailboxOwnerId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetEmailSource_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetStorageJob" ( + "id" TEXT NOT NULL, + "operationKey" TEXT NOT NULL, + "operation" "AssetJobOperation" NOT NULL, + "projectId" TEXT NOT NULL, + "uploadId" TEXT, + "artifactId" TEXT, + "bucket" TEXT, + "objectKey" TEXT NOT NULL, + "finalKey" TEXT, + "temporary" BOOLEAN NOT NULL DEFAULT false, + "state" "AssetJobState" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "leaseUntil" TIMESTAMP(3), + "leaseToken" TEXT, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "assetStorageJob_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "assetApiRequest" ( + "id" TEXT NOT NULL, + "actorKey" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "path" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "responseStatus" INTEGER NOT NULL, + "responseBody" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "assetApiRequest_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "assetUpload_temporaryKey_key" ON "assetUpload"("temporaryKey"); + +CREATE UNIQUE INDEX "assetUpload_finalKey_key" ON "assetUpload"("finalKey"); + +CREATE UNIQUE INDEX "assetUpload_assetId_key" ON "assetUpload"("assetId"); + +CREATE INDEX "assetUpload_actorKey_reservationReleasedAt_idx" ON "assetUpload"("actorKey", "reservationReleasedAt"); + +CREATE INDEX "assetUpload_projectId_idx" ON "assetUpload"("projectId"); + +CREATE INDEX "assetUpload_status_expiresAt_idx" ON "assetUpload"("status", "expiresAt"); + +CREATE INDEX "assetEmailSource_projectId_idx" ON "assetEmailSource"("projectId"); + +CREATE UNIQUE INDEX "assetEmailSource_messageId_attachmentId_key" ON "assetEmailSource"("messageId", "attachmentId"); + +CREATE UNIQUE INDEX "assetStorageJob_operationKey_key" ON "assetStorageJob"("operationKey"); + +CREATE INDEX "assetStorageJob_state_nextAttemptAt_leaseUntil_idx" ON "assetStorageJob"("state", "nextAttemptAt", "leaseUntil"); + +CREATE INDEX "assetStorageJob_projectId_idx" ON "assetStorageJob"("projectId"); + +CREATE INDEX "assetApiRequest_expiresAt_idx" ON "assetApiRequest"("expiresAt"); + +CREATE UNIQUE INDEX "assetApiRequest_actorKey_operation_path_idempotencyKey_key" ON "assetApiRequest"("actorKey", "operation", "path", "idempotencyKey"); + +CREATE INDEX "artifact_dealId_status_createdAt_id_idx" ON "artifact"("dealId", "status", "createdAt", "id"); + +CREATE UNIQUE INDEX "artifact_storageBucket_storageKey_key" ON "artifact"("storageBucket", "storageKey"); + + +UPDATE "artifact" SET "kind" = CASE WHEN length("type") BETWEEN 1 AND 64 THEN "type" ELSE 'file' END; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 156a95266..5fa83c37c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1263,19 +1263,168 @@ model DealContact { @@map("dealContact") } -model Artifact { - id String @id @default(cuid()) - dealId String - deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) - type String - fileName String - storageKey String - createdAt DateTime @default(now()) +enum AssetSource { + MANUAL + MOBILE_RECORDING + EMAIL_ATTACHMENT +} + +enum AssetStatus { + UNVERIFIED + READY + DELETING + DELETED +} + +enum AssetUploadStatus { + PENDING + FINALIZING + READY + FAILED + CANCELED + EXPIRED +} +enum AssetJobOperation { + FINALIZE_UPLOAD + DELETE_OBJECT +} + +enum AssetJobState { + PENDING + RUNNING + COMPLETE +} + +model Artifact { + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + type String + fileName String + storageKey String + storageBucket String? + kind String @default("file") + contentType String @default("application/octet-stream") + sizeBytes BigInt? + source AssetSource? + activityId String? + uploadedById String? + durationMilliseconds BigInt? + capturedAt DateTime? + emailMessageId String? + emailAttachmentId String? + status AssetStatus @default(UNVERIFIED) + deletedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([storageBucket, storageKey]) @@index([dealId, createdAt]) + @@index([dealId, status, createdAt, id]) @@map("artifact") } +model AssetUpload { + id String @id @default(cuid()) + projectId String + customerId String + actorKey String + uploadedById String? + mailboxOwnerId String? + fileName String + contentType String + sizeBytes BigInt + kind String + source AssetSource + activityId String? + durationMilliseconds BigInt? + capturedAt DateTime? + emailMessageId String? + emailAttachmentId String? + metadataHash String + bucket String + temporaryKey String @unique + finalKey String @unique + sourceEtag String? + status AssetUploadStatus @default(PENDING) + assetId String? @unique + failureCode String? + failureMessage String? + expiresAt DateTime + grantExpiresAt DateTime + reservationUntil DateTime + reservationReleasedAt DateTime? + confirmedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([actorKey, reservationReleasedAt]) + @@index([projectId]) + @@index([status, expiresAt]) + @@map("assetUpload") +} + +model AssetEmailSource { + id String @id @default(cuid()) + messageId String + attachmentId String + projectId String + metadataHash String + uploadId String + assetId String? + deletedAt DateTime? + mailboxOwnerId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([messageId, attachmentId]) + @@index([projectId]) + @@map("assetEmailSource") +} + +model AssetStorageJob { + id String @id @default(cuid()) + operationKey String @unique + operation AssetJobOperation + projectId String + uploadId String? + artifactId String? + bucket String? + objectKey String + finalKey String? + temporary Boolean @default(false) + state AssetJobState @default(PENDING) + attempts Int @default(0) + nextAttemptAt DateTime @default(now()) + leaseUntil DateTime? + leaseToken String? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([state, nextAttemptAt, leaseUntil]) + @@index([projectId]) + @@map("assetStorageJob") +} + +model AssetApiRequest { + id String @id @default(cuid()) + actorKey String + operation String + path String + idempotencyKey String + requestHash String + responseStatus Int + responseBody Json + expiresAt DateTime + createdAt DateTime @default(now()) + + @@unique([actorKey, operation, path, idempotencyKey]) + @@index([expiresAt]) + @@map("assetApiRequest") +} + model Document { id String @id @default(cuid()) dealId String diff --git a/turbo.json b/turbo.json index 65aae934d..2d4ab7fae 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,10 @@ "PERPLEXITY_API_KEY", "GITHUB_TOKEN", "BLOB_READ_WRITE_TOKEN", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET", "AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN", "AGENT_URL", From 3198acc714cbbdeedc261749dc62103b40ab72bb Mon Sep 17 00:00:00 2001 From: David Paluy Date: Tue, 8 Sep 2026 22:00:48 -0500 Subject: [PATCH 12/27] fix tests --- packages/db/scripts/test-db-create.ts | 127 ++++++++++++++++++++ packages/db/scripts/test-db-url.ts | 32 +++++ packages/db/scripts/test-db.ts | 164 +------------------------- 3 files changed, 165 insertions(+), 158 deletions(-) create mode 100644 packages/db/scripts/test-db-create.ts create mode 100644 packages/db/scripts/test-db-url.ts diff --git a/packages/db/scripts/test-db-create.ts b/packages/db/scripts/test-db-create.ts new file mode 100644 index 000000000..43ad0e9ce --- /dev/null +++ b/packages/db/scripts/test-db-create.ts @@ -0,0 +1,127 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import pg from "pg"; + +import { fail } from "./test-db-url"; + +const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); +const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); + +export async function createTestDatabase( + target: string, + database: string, + forced: boolean, +): Promise { + const maintenance = new URL(target); + maintenance.pathname = "/postgres"; + maintenance.search = ""; + + const client = new pg.Client({ connectionString: maintenance.toString() }); + + try { + await client.connect(); + } catch (error) { + fail([ + `Could not reach the server at ${new URL(target).host}.`, + "Is Postgres running? docker compose up -d", + "", + error instanceof Error ? error.message : String(error), + ]); + } + + try { + const existing = await client.query( + "SELECT 1 FROM pg_database WHERE datname = $1", + [database], + ); + + if (existing.rowCount) { + const reason = forced + ? "you asked for --reset" + : await stale(target, database); + + if (!reason) { + console.log(` ${database} already exists`); + return; + } + + console.log(` rebuilding ${database}: ${reason}`); + await drop(client, database); + } + + await client.query(`CREATE DATABASE "${database}"`); + console.log(` created ${database}`); + } finally { + await client.end(); + } +} + +async function drop(client: pg.Client, database: string): Promise { + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database], + ); + await client.query(`DROP DATABASE IF EXISTS "${database}"`); +} + +async function stale(target: string, database: string): Promise { + const applied = await appliedMigrations(target); + + if (applied === null) return null; + + const onDisk = new Set( + existsSync(MIGRATIONS) + ? readdirSync(MIGRATIONS, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [], + ); + + const foreign = applied.filter((migration) => !onDisk.has(migration)); + + if (foreign.length > 0) { + return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; + } + + return drifted(target) ? `${database} no longer matches schema.prisma` : null; +} + +async function appliedMigrations(target: string): Promise { + const client = new pg.Client({ connectionString: target }); + + try { + await client.connect(); + } catch { + return null; + } + + try { + const rows = await client.query<{ migration_name: string }>( + `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, + ); + return rows.rows.map((row) => row.migration_name); + } catch { + return null; + } finally { + await client.end(); + } +} + +function drifted(target: string): boolean { + const result = spawnSync( + "prisma", + [ + "migrate", + "diff", + "--from-config-datasource", + "--to-schema", + SCHEMA, + "--exit-code", + ], + { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, + ); + + return result.status === 2; +} diff --git a/packages/db/scripts/test-db-url.ts b/packages/db/scripts/test-db-url.ts new file mode 100644 index 000000000..026ca277f --- /dev/null +++ b/packages/db/scripts/test-db-url.ts @@ -0,0 +1,32 @@ +export function resolveTestDatabaseUrl(): string | null { + const explicit = process.env.TEST_DATABASE_URL; + if (explicit) return explicit; + + const live = process.env.DATABASE_URL; + if (!live) return null; + + try { + const parsed = new URL(live); + const database = parsed.pathname.replace(/^\//, ""); + if (!database) return null; + + parsed.pathname = `/${database.endsWith("_test") ? database : `${database}_test`}`; + + return parsed.toString(); + } catch { + return null; + } +} + +export function databaseName(value: string): string { + try { + return new URL(value).pathname.replace(/^\//, ""); + } catch { + return value; + } +} + +export function fail(lines: string[]): never { + console.error(["", ...lines.map((line) => ` ${line}`), ""].join("\n")); + process.exit(1); +} diff --git a/packages/db/scripts/test-db.ts b/packages/db/scripts/test-db.ts index 7deb36e5f..9d8e5c633 100644 --- a/packages/db/scripts/test-db.ts +++ b/packages/db/scripts/test-db.ts @@ -1,12 +1,11 @@ +import "@crm/env/load"; + import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import pg from "pg"; -const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); -const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); +import { createTestDatabase } from "./test-db-create"; +import { databaseName, fail, resolveTestDatabaseUrl } from "./test-db-url"; -const url = resolve(); +const url = resolveTestDatabaseUrl(); if (!url) { fail([ @@ -25,7 +24,7 @@ if (!name.endsWith("_test")) { ]); } -await create(url, name, process.argv.includes("--reset")); +await createTestDatabase(url, name, process.argv.includes("--reset")); migrate(url); if (!process.env.TEST_DATABASE_URL) { @@ -40,124 +39,6 @@ if (!process.env.TEST_DATABASE_URL) { ); } -async function create( - target: string, - database: string, - forced: boolean, -): Promise { - const maintenance = new URL(target); - maintenance.pathname = "/postgres"; - maintenance.search = ""; - - const client = new pg.Client({ connectionString: maintenance.toString() }); - - try { - await client.connect(); - } catch (error) { - fail([ - `Could not reach the server at ${new URL(target).host}.`, - "Is Postgres running? docker compose up -d", - "", - error instanceof Error ? error.message : String(error), - ]); - } - - try { - const existing = await client.query( - "SELECT 1 FROM pg_database WHERE datname = $1", - [database], - ); - - if (existing.rowCount) { - const reason = forced - ? "you asked for --reset" - : await stale(target, database); - - if (!reason) { - console.log(` ${database} already exists`); - return; - } - - console.log(` rebuilding ${database}: ${reason}`); - await drop(client, database); - } - - await client.query(`CREATE DATABASE "${database}"`); - console.log(` created ${database}`); - } finally { - await client.end(); - } -} - -async function drop(client: pg.Client, database: string): Promise { - await client.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ); - await client.query(`DROP DATABASE IF EXISTS "${database}"`); -} - -async function stale(target: string, database: string): Promise { - const applied = await appliedMigrations(target); - - if (applied === null) return null; - - const onDisk = new Set( - existsSync(MIGRATIONS) - ? readdirSync(MIGRATIONS, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : [], - ); - - const foreign = applied.filter((migration) => !onDisk.has(migration)); - - if (foreign.length > 0) { - return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; - } - - return drifted(target) ? `${database} no longer matches schema.prisma` : null; -} - -async function appliedMigrations(target: string): Promise { - const client = new pg.Client({ connectionString: target }); - - try { - await client.connect(); - } catch { - return null; - } - - try { - const rows = await client.query<{ migration_name: string }>( - `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, - ); - return rows.rows.map((row) => row.migration_name); - } catch { - return null; - } finally { - await client.end(); - } -} - -function drifted(target: string): boolean { - const result = spawnSync( - "prisma", - [ - "migrate", - "diff", - "--from-config-datasource", - "--to-schema", - SCHEMA, - "--exit-code", - ], - { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, - ); - - return result.status === 2; -} - function migrate(target: string): void { const result = spawnSync("prisma", ["migrate", "deploy"], { stdio: "inherit", @@ -177,36 +58,3 @@ function migrate(target: string): void { if (result.status !== 0) process.exit(result.status ?? 1); } - -function resolve(): string | null { - const explicit = process.env.TEST_DATABASE_URL; - if (explicit) return explicit; - - const live = process.env.DATABASE_URL; - if (!live) return null; - - try { - const parsed = new URL(live); - const database = parsed.pathname.replace(/^\//, ""); - if (!database) return null; - - parsed.pathname = `/${database.endsWith("_test") ? database : `${database}_test`}`; - - return parsed.toString(); - } catch { - return null; - } -} - -function databaseName(value: string): string { - try { - return new URL(value).pathname.replace(/^\//, ""); - } catch { - return value; - } -} - -function fail(lines: string[]): never { - console.error(["", ...lines.map((line) => ` ${line}`), ""].join("\n")); - process.exit(1); -} From 18580809d38565a6e4b16fa74a613c583a57df28 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Wed, 9 Sep 2026 13:14:32 +0300 Subject: [PATCH 13/27] chore: support bun 1.4.x Raise the declared toolchain from bun 1.3.12 to 1.4.2 in packageManager and devEngines. The lockfile format is unchanged; bun 1.4.2 reads and writes it identically and a frozen install resolves cleanly. --- .github/workflows/ci.yml | 12 +++++++++++- apps/agent/turbo.json | 2 +- apps/api/turbo.json | 2 +- apps/app/turbo.json | 2 +- bun.lock | 16 ++++++++-------- package.json | 6 +++--- packages/auth/turbo.json | 2 +- packages/db/turbo.json | 2 +- turbo.json | 2 +- 9 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf7714763..36aeaa820 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] push: - branches: [main, release] + branches: [master] concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -50,6 +50,8 @@ jobs: steps: - uses: actions/checkout@v5 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: @@ -57,6 +59,14 @@ jobs: - run: bun install --frozen-lockfile + - name: Install kaneo dependencies + run: cd vendor/kaneo && bun install + + - name: Build kaneo packages + run: | + cd vendor/kaneo/packages/email && bun run build + cd ../permissions && bun run build + - name: Apply migrations run: bun run db:deploy diff --git a/apps/agent/turbo.json b/apps/agent/turbo.json index 6432b1236..da5033412 100644 --- a/apps/agent/turbo.json +++ b/apps/agent/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "build": { diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 84e4232c7..734740578 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "build": { diff --git a/apps/app/turbo.json b/apps/app/turbo.json index 3051a65cd..28c97853e 100644 --- a/apps/app/turbo.json +++ b/apps/app/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "build": { diff --git a/bun.lock b/bun.lock index 96dafcfa1..720682be6 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "drizzle-orm": "^0.45.2", "knip": "6.32.2", "oxlint": "1.78.0", - "turbo": "^2.10.8", + "turbo": "^2.10.12", "typescript": "5.9.2", }, }, @@ -1188,17 +1188,17 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.10.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-po+7rfJfUnFXjWlcoN2RwhErgzCdRtBc1T26vYPcywHlggmCQiQe1uWaE4j+BibI2uY9/2pDoFzMN0rmSaPFOw=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+zB2btDJ00lnPRuqOvpVvgl4x34k/djZQGZTTCfjn7JgNCl8QFY5Njo5+dqkY1g/+9gbbsnAvWm9CmJg9ebcXA=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="], - "@turbo/linux-64": ["@turbo/linux-64@2.10.8", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-K1dxqiVisyN7cViVsfQLs6xscQbYuI8aO2nbUhFURDACgEDfZRdP/b4CCxeosBJpcMfhYyiibWqJorCnvz9kKg=="], + "@turbo/linux-64": ["@turbo/linux-64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.8", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-Gi77ibVnrE1fEmvr+/wBD/yvRqhwp/RQuCp2+//lv1U1wNFFyVg0V7Wj8FG9FXPFAw5QHReo8rxc9+wBSDZjzA=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA=="], - "@turbo/windows-64": ["@turbo/windows-64@2.10.8", "", { "os": "win32", "cpu": "x64" }, "sha512-znnLO1haJPYTHoKMKwlAvlkjRiYbbhBzME6wIGaMd+fwir23U6jVd1ecaTWWi1fbnRVqxMfgDBKseQ/hLKb83g=="], + "@turbo/windows-64": ["@turbo/windows-64@2.10.12", "", { "os": "win32", "cpu": "x64" }, "sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-VN30vh3b3Czh2WzYHNTfF1FE0YMZ5aHsLO8dBMGHJewA6792wX6iJR8ZxlzFW6WdOu0gEAKIvlYhfyT81Wkm4Q=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -2786,7 +2786,7 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.10.8", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.8", "@turbo/darwin-arm64": "2.10.8", "@turbo/linux-64": "2.10.8", "@turbo/linux-arm64": "2.10.8", "@turbo/windows-64": "2.10.8", "@turbo/windows-arm64": "2.10.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg=="], + "turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="], "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], diff --git a/package.json b/package.json index ee716f343..77515e8c4 100644 --- a/package.json +++ b/package.json @@ -31,17 +31,17 @@ "drizzle-orm": "^0.45.2", "knip": "6.32.2", "oxlint": "1.78.0", - "turbo": "^2.10.8", + "turbo": "^2.10.12", "typescript": "5.9.2" }, "engines": { "node": ">=22" }, - "packageManager": "bun@1.3.12", + "packageManager": "bun@1.4.2", "devEngines": { "packageManager": { "name": "bun", - "version": "1.3.12" + "version": "1.4.2" } }, "workspaces": [ diff --git a/packages/auth/turbo.json b/packages/auth/turbo.json index 7160b71b9..db3cf95d4 100644 --- a/packages/auth/turbo.json +++ b/packages/auth/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "auth:generate": { diff --git a/packages/db/turbo.json b/packages/db/turbo.json index 76cf20afe..6211c5c43 100644 --- a/packages/db/turbo.json +++ b/packages/db/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "extends": ["//"], "tasks": { "build": { diff --git a/turbo.json b/turbo.json index 2d4ab7fae..ee963ebbc 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.dev/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", "ui": "tui", "globalEnv": ["NODE_ENV"], "globalPassThroughEnv": [ From 94317fe0a98a7fcd06ccc639a5b752716781aa8a Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Wed, 9 Sep 2026 13:43:41 +0300 Subject: [PATCH 14/27] test: make brand retirement timing timezone-safe --- apps/agent/test/keyless-brand.integration.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index d6487b6b9..f7ba1db25 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -51,11 +51,10 @@ const subjectOf = (companyId: string) => ({ }); async function retiredSubjectOf(companyId: string) { - await db.$executeRaw` - UPDATE "company" - SET "updatedAt" = NOW() - INTERVAL '1 second' - WHERE id = ${companyId} - `; + await db.company.update({ + where: { id: companyId }, + data: { updatedAt: new Date(Date.now() - 1_000) }, + }); const row = await db.agentTask.create({ data: { From 88408547d1c3bae49cc6868fc40eeacee80aa1a3 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Wed, 9 Sep 2026 16:42:07 +0300 Subject: [PATCH 15/27] chore(deps): upgrade all dependencies to latest versions (#4) * chore(deps): upgrade all dependencies to latest versions Upgrades eve, ai, next, react, typescript, prisma, better-auth, biome, oxlint, turbo, and the remaining manifests to their latest versions. Fixes incompatibilities introduced by the upgrades: - eve 0.52.3: ClientSessions.attach() replaces session(), ChannelFrom/ChannelSource replace SendFn, respond() is a separate channel method. - @pierre/diffs 1.4.1: new Editor/FileOptions generics. - typescript 7 (tsgo): explicit node types in agent-xmpp tsconfigs. - better-auth 1.7.3: regenerated the oauth-provider patch. - prisma 7 capped at 7.10.0 (8.x requires a config-file migration). * refactor: dedupe builder dispatch parsing and agent model fallback Extract shared builder input-response predicate and delivery-part builder so custom-agent-dispatch parses the submission JSON once instead of up to three times. Add defaultAgentModelResult() to replace five duplicated fallback object literals across agent entrypoints. Fix indentation and non-null assertions flagged by biome in packages/agent-xmpp/core/src/schema.ts. Add @better-auth/core to packages/auth dependencies. Co-Authored-By: Claude Sonnet 5 * fix: satisfy anti-slop lint on defaultAgentModelResult Drop the explicit return type annotation so TypeScript keeps the literal-typed inference from DEFAULT_AGENT_MODEL instead of widening it to string/number. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- apps/agent/agent/agent.ts | 21 +- apps/agent/agent/channels/crm.ts | 64 +- apps/agent/agent/channels/xmpp.ts | 4 +- apps/agent/agent/hooks/activity.ts | 2 + apps/agent/agent/lib/approval.ts | 2 +- apps/agent/agent/lib/custom-agent-dispatch.ts | 150 +- apps/agent/agent/schedules/dispatch.ts | 19 +- .../agent/subagents/agent_builder/agent.ts | 21 +- .../agent/subagents/agent_runner/agent.ts | 20 +- apps/agent/package.json | 18 +- apps/agent/src/export-tools/eve-adapter.ts | 27 +- .../src/export-tools/export-tools.test.ts | 42 +- apps/agent/src/export-tools/types.ts | 7 +- apps/agent/test/custom-agent-runtime.spec.ts | 53 +- .../durable-agent-runtime.integration.spec.ts | 61 +- apps/api/package.json | 42 +- .../agent-builder/agent-builder-chat.tsx | 2 +- .../components/agent-builder/agent-code.tsx | 29 +- apps/app/components/crm/agent-panel.tsx | 4 +- apps/app/lib/agent-session.ts | 18 +- apps/app/package.json | 40 +- apps/app/test/agent-session.spec.ts | 39 +- bun.lock | 1421 ++++++----------- package.json | 12 +- packages/agent-xmpp/core/package.json | 8 +- packages/agent-xmpp/core/src/schema.ts | 22 +- packages/agent-xmpp/core/tsconfig.json | 19 +- packages/agent-xmpp/gateway/package.json | 4 +- packages/agent-xmpp/gateway/tsconfig.json | 19 +- packages/agent-xmpp/protocol/package.json | 6 +- packages/agent-xmpp/protocol/tsconfig.json | 19 +- packages/auth/package.json | 19 +- packages/db/package.json | 14 +- packages/db/src/settings.ts | 7 + packages/env/package.json | 4 +- packages/kaneo-domain/package.json | 4 +- packages/telemetry/package.json | 6 +- packages/ui/package.json | 40 +- packages/validation/package.json | 6 +- ...@better-auth%2Foauth-provider@1.7.3.patch} | 8 +- 40 files changed, 993 insertions(+), 1330 deletions(-) rename patches/{@better-auth%2Foauth-provider@1.7.2.patch => @better-auth%2Foauth-provider@1.7.3.patch} (76%) diff --git a/apps/agent/agent/agent.ts b/apps/agent/agent/agent.ts index 56756af59..f7adb3f11 100644 --- a/apps/agent/agent/agent.ts +++ b/apps/agent/agent/agent.ts @@ -1,8 +1,8 @@ import "@crm/env/load"; -import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; +import { defaultAgentModelResult } from "@crm/db/settings"; import { onTelemetryProblem, syncVersion } from "@crm/telemetry"; -import { defineAgent, defineDynamic } from "eve"; +import { type DefinedAgent, defineAgent, defineDynamic } from "eve"; import { logCapabilities } from "./lib/capabilities"; import { selectedModel } from "./lib/model"; @@ -12,10 +12,19 @@ onTelemetryProblem((message) => console.debug(`[telemetry] ${message}`)); void syncVersion(); -export default defineAgent({ +const agent: DefinedAgent = defineAgent({ model: defineDynamic({ - fallback: DEFAULT_AGENT_MODEL.id, - events: { "session.started": () => selectedModel() }, + events: { + "session.started": async () => { + const selected = await selectedModel(); + return selected + ? { + model: selected.model, + modelContextWindowTokens: selected.modelContextWindowTokens, + } + : defaultAgentModelResult(); + }, + }, }), limits: { maxInputTokensPerSession: 500_000, @@ -23,3 +32,5 @@ export default defineAgent({ sessionTimeoutMs: 30 * 24 * 60 * 60 * 1000, }, }); + +export default agent; diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index aff3af458..6862602aa 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -131,7 +131,7 @@ export default defineChannel({ ); }), - POST("/internal/crm/dispatch", async (request, { send, waitUntil }) => { + POST("/internal/crm/dispatch", async (request, { from, waitUntil }) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); } @@ -140,12 +140,11 @@ export default defineChannel({ (async () => { await reconcileStaleTasks(); await drainAll((task) => - send(brief(task), { + from(taskToken(task.id)).send(brief(task), { auth: taskAuth(task), - continuationToken: taskToken(task.id), }), ); - await drainAgentRuns(send); + await drainAgentRuns(from); })(), ); @@ -154,29 +153,29 @@ export default defineChannel({ POST( "/internal/crm/builder-dispatch", - async (request, { send, waitUntil }) => { + async (request, { from, waitUntil }) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); } - waitUntil(drainBuilder(send)); + waitUntil(drainBuilder(from)); return new Response(null, { status: 202 }); }, ), POST( "/internal/crm/agent-dispatch", - async (request, { send, waitUntil }) => { + async (request, { from, waitUntil }) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); } - waitUntil(drainAgentRuns(send)); + waitUntil(drainAgentRuns(from)); return new Response(null, { status: 202 }); }, ), - POST("/internal/crm/cancel-run", async (request, { cancel }) => { + POST("/internal/crm/cancel-run", async (request, { from }) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); } @@ -188,9 +187,7 @@ export default defineChannel({ return Response.json({ error: "No run id was sent." }, { status: 400 }); } - return Response.json( - await cancel({ continuationToken: runToken(runId) }), - ); + return Response.json(await from(runToken(runId)).cancel()); }), POST("/internal/crm/slack/create-channel", async (request) => { @@ -243,13 +240,13 @@ export default defineChannel({ async "input.requested"(data, channel, ctx) { await persistBuilderInputRequest( data, - channel.continuationToken, + channel.continuation?.token, attribute(ctx, "conversationId"), ); }, async "message.completed"(data, channel) { - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (!conversationId || !data.message?.trim()) return; await import("@crm/db").then(({ db }) => @@ -265,9 +262,9 @@ export default defineChannel({ }, async "session.waiting"(_data, channel) { - if (await closeTask(channel.continuationToken, "ran")) return; + if (await closeTask(channel.continuation?.token, "ran")) return; - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (!conversationId) return; await import("@crm/db").then(({ db }) => @@ -279,7 +276,7 @@ export default defineChannel({ }, async "turn.failed"(data, channel) { - const taskId = taskFromToken(channel.continuationToken); + const taskId = taskFromToken(channel.continuation?.token); const reason = eveTurnFailure.parse(data).message ?? "The agent turn failed."; @@ -289,7 +286,7 @@ export default defineChannel({ return; } - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); await db.agentConversation.updateMany({ @@ -302,14 +299,14 @@ export default defineChannel({ return; } - const runId = runIdFromToken(channel.continuationToken); + const runId = runIdFromToken(channel.continuation?.token); if (runId) await failRun(runId, "TURN_FAILED", reason); }, async "session.completed"(_data, channel) { - if (await closeTask(channel.continuationToken, "ran")) return; + if (await closeTask(channel.continuation?.token, "ran")) return; - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); await db.agentConversation.updateMany({ @@ -319,7 +316,7 @@ export default defineChannel({ return; } - const runId = runIdFromToken(channel.continuationToken); + const runId = runIdFromToken(channel.continuation?.token); if (!runId) return; const { db } = await import("@crm/db"); @@ -346,7 +343,7 @@ export default defineChannel({ async "turn.cancelled"(_data, channel) { if ( await closeTask( - channel.continuationToken, + channel.continuation?.token, "stopped", EnrichmentStatus.SKIPPED, ) @@ -354,7 +351,7 @@ export default defineChannel({ return; } - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); await db.agentConversation.updateMany({ @@ -367,7 +364,7 @@ export default defineChannel({ return; } - const runId = runIdFromToken(channel.continuationToken); + const runId = runIdFromToken(channel.continuation?.token); if (runId) { await cancelRun( runId, @@ -378,7 +375,7 @@ export default defineChannel({ }, async "session.failed"(data, channel) { - const conversationId = builderIdFromToken(channel.continuationToken); + const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); await db.agentConversation.updateMany({ @@ -393,29 +390,28 @@ export default defineChannel({ return; } - const runId = runIdFromToken(channel.continuationToken); + const runId = runIdFromToken(channel.continuation?.token); if (runId) await failRun(runId, data.code, data.message); }, }, - async receive(input, { send }) { + async receive(input, { from }) { const target = receiveTarget.parse(input.target); if (target.builderSubmissionId) { assertInternalDispatchAuth(input.auth); - return dispatchBuilderSubmission(target.builderSubmissionId, send); + return dispatchBuilderSubmission(target.builderSubmissionId, from); } if (target.runId) { assertInternalDispatchAuth(input.auth); - return dispatchAgentRun(target.runId, send); + return dispatchAgentRun(target.runId, from); } - return send(input.message, { - auth: input.auth, - continuationToken: target.taskId + return from( + target.taskId ? taskToken(target.taskId) : `crm:adhoc:${crypto.randomUUID()}`, - }); + ).send(input.message, { auth: input.auth }); }, }); diff --git a/apps/agent/agent/channels/xmpp.ts b/apps/agent/agent/channels/xmpp.ts index 637b09692..fdf038092 100644 --- a/apps/agent/agent/channels/xmpp.ts +++ b/apps/agent/agent/channels/xmpp.ts @@ -31,7 +31,7 @@ export default defineChannel({ if (!authorized(request)) return denied(); return Response.json({ tools: exportToolManifest() }); }), - POST("/internal/xmpp/export-tools/invoke", async (request, { send }) => { + POST("/internal/xmpp/export-tools/invoke", async (request, { from }) => { if (!authorized(request)) return denied(); const parsed = exportInvocationRequestSchema.safeParse( await request.json(), @@ -58,7 +58,7 @@ export default defineChannel({ ), ); }; - const eveSend = createEveExportSend(send, invocation, request.signal); + const eveSend = createEveExportSend(from, invocation, request.signal); void executeExportTool(parsed.data.operation, parsed.data.arguments, { abortSignal: request.signal, invocation, diff --git a/apps/agent/agent/hooks/activity.ts b/apps/agent/agent/hooks/activity.ts index 2e661bd15..d87aa1619 100644 --- a/apps/agent/agent/hooks/activity.ts +++ b/apps/agent/agent/hooks/activity.ts @@ -42,6 +42,8 @@ function requestName(action: ActionRequest): string { return `subagent ${action.subagentName}`; case "remote-agent-call": return `remote ${action.remoteAgentName}`; + case "workflow-tool-call": + return `workflow ${action.toolName}`; case "load-skill": return "load_skill"; } diff --git a/apps/agent/agent/lib/approval.ts b/apps/agent/agent/lib/approval.ts index 04f9707c1..8935f1f04 100644 --- a/apps/agent/agent/lib/approval.ts +++ b/apps/agent/agent/lib/approval.ts @@ -1,4 +1,4 @@ -import type { Approval } from "eve/tools"; +import type { Approval } from "eve/tools/approval"; import { APP_AUTH } from "./app-auth"; export function isAutomated(session: { diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index e62ba967d..894ed6c66 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -3,7 +3,8 @@ import { CRM_EVENT_CATALOG } from "@crm/db/crm-events"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { crmEventTask } from "@crm/validation/agent-events"; import { readAgentTriggerConfig } from "@crm/validation/agent-manifest"; -import type { SendFn } from "eve/channels"; +import type { UserContent } from "ai"; +import type { ChannelFrom } from "eve/channels"; import { z } from "zod"; import { DISPATCH } from "./dispatch-config"; import { DEPENDENCY_UNAVAILABLE, runDependencyFailure } from "./run-preflight"; @@ -20,7 +21,7 @@ const MAX_BUILDER_ATTEMPTS = DISPATCH.builder.maxAttempts; const BUILDER_LEASE_MS = DISPATCH.builder.leaseMs; const RUN_DELIVERY_LEASE_MS = DISPATCH.run.deliveryLeaseMs; -type BuilderMessageParts = Extract[0], readonly unknown[]>; +type BuilderMessageParts = Extract; const trimmedText = z.string().trim().catch(""); @@ -46,6 +47,15 @@ const builderSubmissionMessage = z inputResponse: { requestId: "", optionId: "", text: "" }, }); +type BuilderInputResponse = z.infer; +type BuilderSubmissionMessage = z.infer; + +function isCreateAgentInput(inputResponse: BuilderInputResponse): boolean { + return Boolean( + inputResponse.requestId && (inputResponse.optionId || inputResponse.text), + ); +} + export async function pendingBuilderSubmissionIds(): Promise { await recoverBuilderSubmissions(); const rows = await db.agentConversationSubmission.findMany({ @@ -71,15 +81,15 @@ export async function pendingBuilderSubmissionIds(): Promise { .slice(0, BUILDER_BATCH); } -export async function drainBuilder(send: SendFn): Promise { +export async function drainBuilder(from: ChannelFrom): Promise { const ids = await pendingBuilderSubmissionIds(); - await Promise.all(ids.map((id) => dispatchBuilderSubmission(id, send))); + await Promise.all(ids.map((id) => dispatchBuilderSubmission(id, from))); return ids.length; } export async function dispatchBuilderSubmission( submissionId: string, - send: SendFn, + from: ChannelFrom, ) { const submission = await db.$transaction(async (tx) => { const seed = await tx.agentConversationSubmission.findUnique({ @@ -157,33 +167,37 @@ export async function dispatchBuilderSubmission( const conversationId = submission.conversation.id; try { - const session = await send( - builderDeliveryMessage( - submission.id, - submission.message, - submission.attachments, - ), - { - auth: { - authenticator: "crm-builder", - principalType: "user", - principalId: submission.conversation.userId, - attributes: { - purpose: "builder", - commandType: builderCommandType( - submission.commandType, - submission.message, - ), - needsTitle: submission.conversation.title ? "false" : "true", - conversationId, - userId: submission.conversation.userId, - submissionId: submission.id, - }, - }, - continuationToken: builderToken(conversationId), - title: submission.conversation.title ?? "Agent builder", + const message = builderSubmissionMessage.parse(submission.message); + const { inputResponse } = message; + const { requestId, optionId, text: responseText } = inputResponse; + const isCreateAgent = isCreateAgentInput(inputResponse); + const auth = { + authenticator: "crm-builder", + principalType: "user", + principalId: submission.conversation.userId, + attributes: { + purpose: "builder", + commandType: isCreateAgent ? "CREATE_AGENT" : submission.commandType, + needsTitle: submission.conversation.title ? "false" : "true", + conversationId, + userId: submission.conversation.userId, + submissionId: submission.id, }, - ); + }; + const session = isCreateAgent + ? await from(builderToken(conversationId)).respond( + [ + { + requestId, + ...(optionId ? { optionId } : { text: responseText }), + }, + ], + { auth }, + ) + : await from(builderToken(conversationId)).send( + builderDeliveryParts(message, submission.id, submission.attachments), + { auth, title: submission.conversation.title ?? "Agent builder" }, + ); await db.$transaction(async (tx) => { const conversation = await lockBuilderConversation(tx, conversationId); @@ -424,7 +438,7 @@ export async function pendingAgentRunIds(): Promise { return runnable; } -export async function drainAgentRuns(send: SendFn): Promise { +export async function drainAgentRuns(from: ChannelFrom): Promise { await queueDueAgentRuns(); let dispatched = 0; @@ -434,7 +448,7 @@ export async function drainAgentRuns(send: SendFn): Promise { const outcomes = await Promise.all( ids.map((id) => - dispatchAgentRun(id, send).then( + dispatchAgentRun(id, from).then( () => true, (error) => { console.error( @@ -453,7 +467,7 @@ export async function drainAgentRuns(send: SendFn): Promise { return dispatched; } -export async function dispatchAgentRun(runId: string, send: SendFn) { +export async function dispatchAgentRun(runId: string, from: ChannelFrom) { const run = await db.agentRun.findUnique({ where: { id: runId }, select: { @@ -513,23 +527,25 @@ export async function dispatchAgentRun(runId: string, send: SendFn) { const principalId = run.initiatedById ?? run.agent.createdById; try { - const session = await send(`Execute deployed agent run ${run.id}.`, { - auth: { - authenticator: run.initiatedById ? "crm-user" : "crm-schedule", - principalType: run.initiatedById ? "user" : "runtime", - principalId, - attributes: { - purpose: "team-agent", - runId: run.id, - agentId: run.agentId, - versionId: run.versionId, - userId: principalId, + const session = await from(runToken(run.id)).send( + `Execute deployed agent run ${run.id}.`, + { + auth: { + authenticator: run.initiatedById ? "crm-user" : "crm-schedule", + principalType: run.initiatedById ? "user" : "runtime", + principalId, + attributes: { + purpose: "team-agent", + runId: run.id, + agentId: run.agentId, + versionId: run.versionId, + userId: principalId, + }, }, + title: `${run.agent.name} run`, + mode: "task", }, - continuationToken: runToken(run.id), - title: `${run.agent.name} run`, - mode: "task", - }); + ); await db.agentRun.updateMany({ where: { id: run.id, status: "RUNNING" }, @@ -831,24 +847,11 @@ async function recoverAgentRuns() { } } -export function builderDeliveryMessage( +function builderDeliveryParts( + message: BuilderSubmissionMessage, submissionId: string, - value: Prisma.JsonValue, attachments: readonly BuilderDeliveryAttachment[] = [], -): Parameters[0] { - const message = builderSubmissionMessage.parse(value); - const { requestId, optionId, text: responseText } = message.inputResponse; - if (requestId && (optionId || responseText)) { - return { - inputResponses: [ - { - requestId, - ...(optionId ? { optionId } : { text: responseText }), - }, - ], - }; - } - +): string | UserContent { const labels = message.resources .map((resource) => resource.label) .filter(Boolean); @@ -876,13 +879,24 @@ export function builderDeliveryMessage( return parts; } +export function builderDeliveryMessage( + submissionId: string, + value: Prisma.JsonValue, + attachments: readonly BuilderDeliveryAttachment[] = [], +): string | UserContent { + return builderDeliveryParts( + builderSubmissionMessage.parse(value), + submissionId, + attachments, + ); +} + export function builderCommandType( commandType: string, value: Prisma.JsonValue, ): string { - const { requestId, optionId, text } = - builderSubmissionMessage.parse(value).inputResponse; - return requestId && (optionId || text) ? "CREATE_AGENT" : commandType; + const { inputResponse } = builderSubmissionMessage.parse(value); + return isCreateAgentInput(inputResponse) ? "CREATE_AGENT" : commandType; } type BuilderDeliveryAttachment = { diff --git a/apps/agent/agent/schedules/dispatch.ts b/apps/agent/agent/schedules/dispatch.ts index 1aa080fe6..9f954bbea 100644 --- a/apps/agent/agent/schedules/dispatch.ts +++ b/apps/agent/agent/schedules/dispatch.ts @@ -11,7 +11,7 @@ import { reconcileStaleTasks } from "../lib/stale-tasks"; export default defineSchedule({ cron: "* * * * *", - async run({ receive, waitUntil, appAuth }) { + async run({ to, waitUntil, appAuth }) { waitUntil( Promise.all([ sweepBlankFacts(), @@ -19,9 +19,7 @@ export default defineSchedule({ (async () => { await reconcileStaleTasks(); await drainAll((task) => - receive(crm, { - message: brief(task), - target: { taskId: task.id }, + to(crm, { taskId: task.id }).send(brief(task), { auth: taskAuth(task, appAuth), }), ); @@ -33,16 +31,13 @@ export default defineSchedule({ await Promise.all([ ...builderIds.map((builderSubmissionId) => - receive(crm, { - message: "Continue a queued private agent-builder chat.", - target: { builderSubmissionId }, - auth: appAuth, - }), + to(crm, { builderSubmissionId }).send( + "Continue a queued private agent-builder chat.", + { auth: appAuth }, + ), ), ...runIds.map((runId) => - receive(crm, { - message: "Execute a queued deployed agent run.", - target: { runId }, + to(crm, { runId }).send("Execute a queued deployed agent run.", { auth: appAuth, }), ), diff --git a/apps/agent/agent/subagents/agent_builder/agent.ts b/apps/agent/agent/subagents/agent_builder/agent.ts index 4318264f9..aa5ae8ef5 100644 --- a/apps/agent/agent/subagents/agent_builder/agent.ts +++ b/apps/agent/agent/subagents/agent_builder/agent.ts @@ -1,14 +1,23 @@ -import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; -import { defineAgent, defineDynamic } from "eve"; +import { defaultAgentModelResult } from "@crm/db/settings"; +import { type DefinedAgent, defineAgent, defineDynamic } from "eve"; import { z } from "zod"; import { selectedModel } from "../../lib/model"; -export default defineAgent({ +const agent: DefinedAgent = defineAgent({ description: "Turn one private CRM builder-chat request into a validated, reviewable team-agent version without deploying it.", model: defineDynamic({ - fallback: DEFAULT_AGENT_MODEL.id, - events: { "session.started": () => selectedModel() }, + events: { + "session.started": async () => { + const selected = await selectedModel(); + return selected + ? { + model: selected.model, + modelContextWindowTokens: selected.modelContextWindowTokens, + } + : defaultAgentModelResult(); + }, + }, }), outputSchema: z.object({ status: z.literal("draft_ready"), @@ -22,3 +31,5 @@ export default defineAgent({ sessionTimeoutMs: 24 * 60 * 60 * 1000, }, }); + +export default agent; diff --git a/apps/agent/agent/subagents/agent_runner/agent.ts b/apps/agent/agent/subagents/agent_runner/agent.ts index 1d8be7751..bc9de815c 100644 --- a/apps/agent/agent/subagents/agent_runner/agent.ts +++ b/apps/agent/agent/subagents/agent_runner/agent.ts @@ -1,20 +1,22 @@ import { db } from "@crm/db"; -import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; -import { defineAgent, defineDynamic } from "eve"; +import { defaultAgentModelResult } from "@crm/db/settings"; +import { type DefinedAgent, defineAgent, defineDynamic } from "eve"; import { z } from "zod"; import { attribute, purposeOf } from "../../lib/session-purpose"; -export default defineAgent({ +const agent: DefinedAgent = defineAgent({ description: "Execute one immutable deployed CRM agent version and persist its result and every side effect.", model: defineDynamic({ - fallback: DEFAULT_AGENT_MODEL.id, events: { "session.started": async (_event, ctx) => { - if (purposeOf(ctx) !== "team-agent") return null; + if (purposeOf(ctx) !== "team-agent") { + return defaultAgentModelResult(); + } const runId = attribute(ctx, "runId"); - if (!runId) return null; - + if (!runId) { + return defaultAgentModelResult(); + } const run = await db.agentRun.findUnique({ where: { id: runId }, select: { @@ -28,7 +30,7 @@ export default defineAgent({ model: run.version.modelId, modelContextWindowTokens: run.version.modelContextWindowTokens, } - : null; + : defaultAgentModelResult(); }, }, }), @@ -42,3 +44,5 @@ export default defineAgent({ sessionTimeoutMs: 24 * 60 * 60 * 1000, }, }); + +export default agent; diff --git a/apps/agent/package.json b/apps/agent/package.json index fdf8b0ccb..13c03a135 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -27,19 +27,19 @@ "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", - "context.dev": "2.10.0", - "eve": "^0.29.4", - "ai": "7.0.47", + "context.dev": "2.14.0", + "eve": "^0.52.3", + "ai": "7.0.94", "ulid": "3.0.2", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/bun": "^1.3.14", - "@types/node": "^24.0.0", + "@types/bun": "^1.4.2", + "@types/node": "^26.5.0", "@xmpp/client": "0.14.0", - "just-bash": "^3.2.0", - "microsandbox": "^0.6.8", - "typescript": "^5.9.2" + "just-bash": "^3.4.2", + "microsandbox": "^0.6.17", + "typescript": "^7.0.2" } } diff --git a/apps/agent/src/export-tools/eve-adapter.ts b/apps/agent/src/export-tools/eve-adapter.ts index cf6bb2c97..37deb4e8c 100644 --- a/apps/agent/src/export-tools/eve-adapter.ts +++ b/apps/agent/src/export-tools/eve-adapter.ts @@ -1,4 +1,4 @@ -import type { SendFn, SendPayload, Session } from "eve/channels"; +import type { ChannelFrom, Session } from "eve/channels"; import { ExportAgentRunError, ExportCancelledError } from "./errors"; import { toJsonSchema, validateSchema } from "./schema"; @@ -12,23 +12,13 @@ import { type ExportInvocation, exportJsonValueSchema } from "./wire"; type ExportSession = Pick; export function createEveExportSend( - send: SendFn, + from: ChannelFrom, invocation: ExportInvocation, abortSignal: AbortSignal, ): (request: ExportAgentRequest) => Promise> { return async (request: ExportAgentRequest) => { if (abortSignal.aborted) throw new ExportCancelledError(); - const payload: SendPayload = { - message: request.message, - context: - request.clientContext === undefined - ? undefined - : [JSON.stringify(request.clientContext)], - outputSchema: request.outputSchema - ? toJsonSchema(request.outputSchema) - : undefined, - }; - const options = { + const session = await from(invocation.requestId).send(request.message, { auth: { authenticator: "xmpp-agent-gateway", principalType: "agent", @@ -38,11 +28,16 @@ export function createEveExportSend( operation: invocation.operation, }, }, - continuationToken: invocation.requestId, + context: + request.clientContext === undefined + ? undefined + : [JSON.stringify(request.clientContext)], + outputSchema: request.outputSchema + ? toJsonSchema(request.outputSchema) + : undefined, mode: request.taskMode === false ? "conversation" : "task", title: request.title, - } as const; - const session = await send(payload, options); + }); let cancellation: ReturnType | undefined; const cancel = () => (cancellation ??= session.cancel().catch(() => ({ diff --git a/apps/agent/src/export-tools/export-tools.test.ts b/apps/agent/src/export-tools/export-tools.test.ts index 23f40b006..59b7af851 100644 --- a/apps/agent/src/export-tools/export-tools.test.ts +++ b/apps/agent/src/export-tools/export-tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import type { SendFn } from "eve/channels"; +import type { ChannelFrom, Session } from "eve/channels"; import handleCrmRequest from "../exports/handle_crm_request"; import { ExportCancelledError, ExportToolValidationError } from "./errors"; @@ -113,7 +113,7 @@ describe("export tools", () => { collectAgentResult( { id: "ses_1", - cancel: async () => ({ status: "accepted" }), + cancel: async () => ({ sessionId: "ses_1", status: "accepted" }), getEventStream: async () => stream, }, handleCrmRequest.outputSchema, @@ -128,21 +128,29 @@ describe("export tools", () => { it("cancels when the request aborts while Eve accepts the send", async () => { const controller = new AbortController(); let cancellations = 0; - const send: SendFn = async () => { - controller.abort(); - return { - id: "ses_1", - continuationToken: "xmpp:req_1", - cancel: async () => { - cancellations++; - return { status: "accepted" }; - }, - getEventStream: async () => new ReadableStream(), - getStreamTailIndex: async () => -1, - }; - }; + const session = { + id: "ses_1", + cancel: async () => { + cancellations++; + return { sessionId: "ses_1", status: "accepted" }; + }, + getEventStream: async () => new ReadableStream(), + getStreamTailIndex: async () => -1, + } as Session; + + const from: ChannelFrom = () => ({ + send: async () => { + controller.abort(); + return session; + }, + respond: async () => session, + cancel: async () => ({ sessionId: "ses_1", status: "accepted" }), + compact: async () => ({ sessionId: "ses_1", status: "accepted" }), + clear: async () => ({ sessionId: "ses_1", status: "accepted" }), + reset: async () => ({ status: "no_active_session" }), + }); const run = createEveExportSend( - send, + from, { requestId: "req_1", operation: "ping" }, controller.signal, ); @@ -163,7 +171,7 @@ describe("export tools", () => { await expect( collectAgentResult({ id: "ses_1", - cancel: async () => ({ status: "accepted" }), + cancel: async () => ({ sessionId: "ses_1", status: "accepted" }), getEventStream: async () => stream, }), ).rejects.toBeInstanceOf(ExportCancelledError); diff --git a/apps/agent/src/export-tools/types.ts b/apps/agent/src/export-tools/types.ts index b9e857445..29309a195 100644 --- a/apps/agent/src/export-tools/types.ts +++ b/apps/agent/src/export-tools/types.ts @@ -1,4 +1,5 @@ -import type { SendPayload } from "eve/channels"; +import type { UserContent } from "ai"; +import type { ChannelSendOptions } from "eve/channels"; import type { ExportInvocation, @@ -34,7 +35,7 @@ export interface ExportToolAnnotations { } export interface ExportAgentRequest { - readonly message: NonNullable; + readonly message: string | UserContent; readonly outputSchema?: StandardSchemaV1; readonly title?: string; readonly taskMode?: boolean; @@ -65,7 +66,7 @@ export interface ExportToolDefinition { export type AnyExportToolDefinition = ExportToolDefinition; -export type JsonSchema = NonNullable; +export type JsonSchema = NonNullable; export interface ExportToolManifestEntry { readonly name: string; diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index 330d2a581..490e898d5 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -81,36 +81,37 @@ describe("builder delivery messages", () => { }); it("routes a selected answer back to the parked Eve input request", () => { - expect( - builderDeliveryMessage("submission-1", { - text: "Use a CRM task instead", - inputResponse: { - requestId: "question-1", - optionId: "crm-task", - }, - }), - ).toEqual({ - inputResponses: [{ requestId: "question-1", optionId: "crm-task" }], - }); + const value = { + text: "Use a CRM task instead", + inputResponse: { + requestId: "question-1", + optionId: "crm-task", + }, + }; + expect(builderCommandType("CHAT", value)).toBe("CREATE_AGENT"); + expect(builderDeliveryMessage("submission-1", value)).toEqual([ + { + type: "text", + text: "Submission id: submission-1\n\nUse a CRM task instead", + }, + ]); }); it("routes a written answer back to the parked Eve input request", () => { - expect( - builderDeliveryMessage("submission-1", { + const value = { + text: "Use the private renewals channel", + inputResponse: { + requestId: "question-2", text: "Use the private renewals channel", - inputResponse: { - requestId: "question-2", - text: "Use the private renewals channel", - }, - }), - ).toEqual({ - inputResponses: [ - { - requestId: "question-2", - text: "Use the private renewals channel", - }, - ], - }); + }, + }; + expect(builderCommandType("CHAT", value)).toBe("CREATE_AGENT"); + expect(builderDeliveryMessage("submission-1", value)).toEqual([ + { + type: "text", + text: "Submission id: submission-1\n\nUse the private renewals channel", + }, + ]); }); it("delivers persisted attachment bytes with model-visible metadata", () => { diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index d7dd10523..2847b6a6a 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; -import type { SendFn } from "eve/channels"; +import type { UserContent } from "ai"; +import type { ChannelFrom, ChannelSendOptions, Session } from "eve/channels"; import { z } from "zod"; import audit from "../agent/hooks/audit"; import { @@ -22,6 +23,24 @@ import { const attachmentBytes = z.object({ data: z.instanceof(Uint8Array) }); +const channelFrom = + ( + send: ( + message: string | UserContent, + options: ChannelSendOptions, + ) => Promise, + ): ChannelFrom => + () => ({ + send, + respond: async () => { + throw new Error("unused"); + }, + cancel: async () => ({ sessionId: "", status: "accepted" }), + compact: async () => ({ sessionId: "", status: "accepted" }), + clear: async () => ({ sessionId: "", status: "accepted" }), + reset: async () => ({ status: "no_active_session" }), + }); + const suffix = crypto.randomUUID(); const userId = `durable-runtime-user-${suffix}`; const domain = `durable-${suffix}.example.test`; @@ -324,14 +343,14 @@ describe("durable custom-agent runtime", () => { const run = await createRun("QUEUED", null); let deliveries = 0; const sessionId = `durable-session-${suffix}-agent-dispatch`; - const send = (async () => { + const from = channelFrom(async () => { deliveries += 1; - return { id: sessionId }; - }) as unknown as SendFn; + return { id: sessionId } as Session; + }); const attempts = await Promise.allSettled([ - dispatchAgentRun(run.id, send), - dispatchAgentRun(run.id, send), + dispatchAgentRun(run.id, from), + dispatchAgentRun(run.id, from), ]); const persisted = await db.agentRun.findUniqueOrThrow({ where: { id: run.id }, @@ -353,14 +372,14 @@ describe("durable custom-agent runtime", () => { createRun("QUEUED", null), ]); const deliveries: string[] = []; - const send = (async (message: string) => { + const from = channelFrom(async (message: string) => { deliveries.push(message); - return { id: `durable-session-${suffix}-serialized` }; - }) as unknown as SendFn; + return { id: `durable-session-${suffix}-serialized` } as Session; + }); const results = await Promise.allSettled([ - dispatchAgentRun(first.id, send), - dispatchAgentRun(second.id, send), + dispatchAgentRun(first.id, from), + dispatchAgentRun(second.id, from), ]); expect( @@ -449,18 +468,18 @@ describe("durable custom-agent runtime", () => { const started = Promise.withResolvers(); const release = Promise.withResolvers(); let deliveries = 0; - const send = (async () => { + const from = channelFrom(async () => { deliveries += 1; started.resolve(); await release.promise; - return { id: `durable-session-${suffix}-builder-dispatch` }; - }) as unknown as SendFn; + return { id: `durable-session-${suffix}-builder-dispatch` } as Session; + }); - const firstDispatch = dispatchBuilderSubmission(first.id, send); + const firstDispatch = dispatchBuilderSubmission(first.id, from); await started.promise; let secondError: Error | null = null; try { - await dispatchBuilderSubmission(second.id, send); + await dispatchBuilderSubmission(second.id, from); } catch (error) { secondError = error as Error; } @@ -518,15 +537,15 @@ describe("durable custom-agent runtime", () => { select: { id: true, submissions: { select: { id: true } } }, }); builderConversationIds.push(conversation.id); - let delivered: Parameters[0] | null = null; - const send = (async (input: Parameters[0]) => { + let delivered: UserContent | null = null; + const from = channelFrom(async (input: UserContent) => { delivered = input; - return { id: `durable-session-${suffix}-attachment` }; - }) as unknown as SendFn; + return { id: `durable-session-${suffix}-attachment` } as Session; + }); await dispatchBuilderSubmission( conversation.submissions[0]?.id ?? "", - send, + from, ); const parts = Array.isArray(delivered) ? delivered : []; expect(parts).toHaveLength(2); diff --git a/apps/api/package.json b/apps/api/package.json index e67dbab49..01c38aa69 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,44 +23,44 @@ "clean": "rm -rf .turbo dist node_modules src/generated" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1127.0", - "@aws-sdk/s3-request-presigner": "^3.1127.0", + "@aws-sdk/client-s3": "^3.1128.0", + "@aws-sdk/s3-request-presigner": "^3.1128.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", "@keyv/redis": "^5.1.6", - "@nestjs/cache-manager": "^3.1.3", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/cache-manager": "^12.0.0", + "@nestjs/common": "^11.2.3", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^11.2.3", + "@nestjs/platform-express": "^11.2.3", "@nestjs/swagger": "^11.4.7", - "@thallesp/nestjs-better-auth": "^2.7.0", + "@thallesp/nestjs-better-auth": "^2.8.0", "@trpc/server": "^11.18.0", - "@vercel/blob": "^2.6.1", - "better-auth": "1.7.2", + "@vercel/blob": "^2.8.0", + "better-auth": "1.7.3", "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "context.dev": "^2.7.0", + "context.dev": "^2.14.0", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", + "rxjs": "^7.8.2", "trpc-to-openapi": "^3.3.0", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@nestjs/testing": "^11.0.1", - "@types/bun": "^1.3.14", - "@types/express": "^5.0.0", - "@types/node": "^24.0.0", - "@types/supertest": "^7.0.0", - "concurrently": "^9.1.0", - "supertest": "^7.0.0", - "typescript": "^5.9.2" + "@nestjs/testing": "^11.2.3", + "@types/bun": "^1.4.2", + "@types/express": "^5.0.6", + "@types/node": "^26.5.0", + "@types/supertest": "^7.2.1", + "concurrently": "^10.0.5", + "supertest": "^7.2.2", + "typescript": "^7.0.2" } } diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index b2e3626ea..8165c6d8f 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -588,7 +588,7 @@ function BuilderEventFollower({ }); const follow = async () => { - const session = client.session({ sessionId, streamIndex: 0 }); + const session = client.sessions.attach(sessionId, { streamIndex: 0 }); const snapshot = await session.snapshot({ signal: controller.signal }); if (controller.signal.aborted) return; onSnapshot(snapshot.events); diff --git a/apps/app/components/agent-builder/agent-code.tsx b/apps/app/components/agent-builder/agent-code.tsx index a41f1617a..5b128cf25 100644 --- a/apps/app/components/agent-builder/agent-code.tsx +++ b/apps/app/components/agent-builder/agent-code.tsx @@ -8,13 +8,19 @@ import { parseDiffFromFile, } from "@pierre/diffs"; import { Editor, type EditorOptions } from "@pierre/diffs/edit"; -import { EditProvider, File, FileDiff, Virtualizer } from "@pierre/diffs/react"; +import { + type EditorFactory, + EditProvider, + File, + FileDiff, + Virtualizer, +} from "@pierre/diffs/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; -const FILE_OPTIONS: FileOptions = { +const FILE_OPTIONS: FileOptions = { theme: { dark: "pierre-dark-soft", light: "pierre-light-soft" }, stickyHeader: true, }; @@ -30,9 +36,11 @@ const VIRTUALIZER_STYLE = { overflow: "auto", } as const; -function createEditor(options: EditorOptions) { - return new Editor(options); -} +const createEditor: EditorFactory = ( + editorType, + options, + editStateKey, +) => new Editor(editorType, options, editStateKey); export function AgentCode({ agentId, @@ -50,7 +58,7 @@ export function AgentCode({ const [changed, setChanged] = useState([]); const [saving, setSaving] = useState(false); const draft = useRef(new Map()); - const editorRef = useRef | null>(null); + const editorRef = useRef | null>(null); const code = useQuery(trpc.agents.files.queryOptions({ id: agentId })); const files = code.data?.files ?? []; @@ -98,16 +106,17 @@ export function AgentCode({ toast.success("Saved."); }, [agentId, queryClient, save, trpc]); - const editorOptions = useMemo>( + const editorOptions = useMemo>( () => ({ persistState: true, onAttach(editor) { editorRef.current = editor; }, - onChange(next) { - draft.current.set(next.name, next.contents); + onChange(event) { + const file = event.file; + draft.current.set(file.name, file.contents); setChanged((paths) => - paths.includes(next.name) ? paths : [...paths, next.name], + paths.includes(file.name) ? paths : [...paths, file.name], ); }, }), diff --git a/apps/app/components/crm/agent-panel.tsx b/apps/app/components/crm/agent-panel.tsx index 61990ef01..1df9c32e9 100644 --- a/apps/app/components/crm/agent-panel.tsx +++ b/apps/app/components/crm/agent-panel.tsx @@ -231,7 +231,7 @@ function Thread({ if (!message.trim() || locked) return; opening.current ||= message.trim(); setDraft(""); - void agent.send({ message: message.trim() }); + void agent.send(message.trim()); }; return ( @@ -289,7 +289,7 @@ function Thread({ key={question.requestId} question={question} pending={busy} - onSubmit={(response) => agent.send({ inputResponses: [response] })} + onSubmit={(response) => agent.respond([response])} /> ) : (
{ try { - const snapshot = await new Client({ headers, host: "" }) - .session({ sessionId, streamIndex: 0 }) + const snapshot = await new Client({ headers, host: "" }).sessions + .attach(sessionId, { streamIndex: 0 }) .snapshot({ signal }); return { - status: classify(snapshot.session, snapshot.events), + status: classify(snapshot.events), session: snapshot.session, events: snapshot.events, } as Thread; @@ -54,13 +54,11 @@ export function offlineThread(events: readonly MessageStreamEvent[]): Thread { } export function classify( - session: SessionState, events: readonly MessageStreamEvent[], now: number = Date.now(), ): "ready" | "working" | "ended" { - if (session.continuationToken) return "ready"; - const last = events.at(-1); + if (last?.type === "session.waiting") return "ready"; if (!last) return "ended"; if (last.type === "session.completed" || last.type === "session.failed") { diff --git a/apps/app/package.json b/apps/app/package.json index af84e9ab2..fd82a6bd9 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -12,41 +12,41 @@ "lint": "biome check ." }, "dependencies": { - "@carbon/icons-react": "^11.82.0", + "@carbon/icons-react": "^11.88.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/ui": "workspace:*", "@crm/validation": "workspace:*", - "@pierre/diffs": "^1.2.12", - "@pierre/trees": "^1.0.0-beta.5", - "@tanstack/react-query": "^5.101.2", + "@pierre/diffs": "^1.4.1", + "@pierre/trees": "^1.0.0-beta.6", + "@tanstack/react-query": "^5.102.8", "@trpc/client": "^11.18.0", "@trpc/server": "^11.18.0", "@trpc/tanstack-react-query": "^11.18.0", "api": "workspace:*", - "better-auth": "1.7.2", - "eve": "^0.29.4", - "next": "16.3.0", + "better-auth": "1.7.3", + "eve": "^0.52.3", + "next": "16.3.4", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "posthog-js": "^1.413.2", - "react": "19.2.4", - "react-dom": "19.2.4", - "sonner": "^2.0.7", - "zod": "^4.4.3" + "nuqs": "^2.10.1", + "posthog-js": "^1.428.8", + "react": "19.2.8", + "react-dom": "19.2.8", + "sonner": "^2.0.8", + "zod": "^4.5.4" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@tanstack/react-query-devtools": "^5.101.2", - "@types/bun": "^1.3.14", - "@types/node": "^20", - "@types/react": "^19", + "@tailwindcss/postcss": "^4.3.3", + "@tanstack/react-query-devtools": "^5.102.8", + "@types/bun": "^1.4.2", + "@types/node": "^26.5.0", + "@types/react": "^19.2.18", "@types/react-dom": "^19", - "tailwindcss": "^4", - "typescript": "^5" + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2" }, "ignoreScripts": [ "sharp", diff --git a/apps/app/test/agent-session.spec.ts b/apps/app/test/agent-session.spec.ts index 7e00682ce..d39610e10 100644 --- a/apps/app/test/agent-session.spec.ts +++ b/apps/app/test/agent-session.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; -import type { MessageStreamEvent, SessionState } from "eve/client"; +import type { ClientSessionState, MessageStreamEvent } from "eve/client"; import { recordCopy, recordFilter, recordHeader } from "../lib/agent-record"; import { classify, composerState, eventsOf } from "../lib/agent-session"; @@ -12,49 +12,41 @@ const event = ( ): MessageStreamEvent => ({ type, data: {}, meta: { id: `evt_${type}`, at } }) as MessageStreamEvent; -const parked: SessionState = { - sessionId: "wrun_1", - continuationToken: "eve:live", - streamIndex: 3, -}; - -const unparked: SessionState = { sessionId: "wrun_1", streamIndex: 3 }; +const state: ClientSessionState = { sessionId: "wrun_1", streamIndex: 3 }; describe("classify", () => { - it("trusts the token over any reading of the events", () => { - expect(classify(parked, [event("message.appended")], NOW)).toBe("ready"); + it("treats a parked session as ready", () => { + expect(classify([event("session.waiting")], NOW)).toBe("ready"); }); it("knows a terminal session cannot be continued", () => { - expect(classify(unparked, [event("session.completed")], NOW)).toBe("ended"); - expect(classify(unparked, [event("session.failed")], NOW)).toBe("ended"); + expect(classify([event("session.completed")], NOW)).toBe("ended"); + expect(classify([event("session.failed")], NOW)).toBe("ended"); }); it("reads a turn still emitting as working", () => { const recent = event("message.appended", "2026-08-01T11:59:30.000Z"); - expect(classify(unparked, [recent], NOW)).toBe("working"); + expect(classify([recent], NOW)).toBe("working"); }); it("retires a turn that stopped mid-sentence", () => { const stalled = event("message.appended", "2026-08-01T11:50:00.000Z"); - expect(classify(unparked, [stalled], NOW)).toBe("ended"); + expect(classify([stalled], NOW)).toBe("ended"); }); it("does not retire a live turn for want of a timestamp", () => { const undated = { type: "step.started", data: {}, meta: { id: "x" } }; - expect(classify(unparked, [undated as MessageStreamEvent], NOW)).toBe( - "working", - ); + expect(classify([undated as MessageStreamEvent], NOW)).toBe("working"); }); }); describe("the composer", () => { it("takes input on a parked thread, and on one not started yet", () => { expect( - composerState({ status: "ready", session: parked, events: [] }, false), + composerState({ status: "ready", session: state, events: [] }, false), ).toEqual({ locked: false, ended: false }); expect(composerState({ status: "new" }, false)).toEqual({ locked: false, @@ -68,20 +60,17 @@ describe("the composer", () => { it("holds input while a turn is in flight, from either side", () => { expect( - composerState({ status: "ready", session: parked, events: [] }, true) + composerState({ status: "ready", session: state, events: [] }, true) .locked, ).toBe(true); expect( - composerState( - { status: "working", session: unparked, events: [] }, - false, - ), + composerState({ status: "working", session: state, events: [] }, false), ).toEqual({ locked: true, ended: false }); }); it("says an ended thread is ended rather than merely busy", () => { expect( - composerState({ status: "ended", session: unparked, events: [] }, false), + composerState({ status: "ended", session: state, events: [] }, false), ).toEqual({ locked: true, ended: true }); }); @@ -98,7 +87,7 @@ describe("eventsOf", () => { const events = [event("message.completed")]; expect(eventsOf({ status: "offline", events })).toEqual(events); - expect(eventsOf({ status: "ended", session: unparked, events })).toEqual( + expect(eventsOf({ status: "ended", session: state, events })).toEqual( events, ); expect(eventsOf({ status: "new" })).toEqual([]); diff --git a/bun.lock b/bun.lock index 720682be6..3f0e254f8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "crm", "devDependencies": { - "@biomejs/biome": "^2.4.10", - "@oxlint/plugins": "1.78.0", + "@biomejs/biome": "^2.5.12", + "@oxlint/plugins": "1.82.0", "@paralleldrive/cuid2": "^3.3.0", "drizzle-orm": "^0.45.2", - "knip": "6.32.2", - "oxlint": "1.78.0", + "knip": "6.35.1", + "oxlint": "1.82.0", "turbo": "^2.10.12", - "typescript": "5.9.2", + "typescript": "7.0.2", }, }, "apps/agent": { @@ -26,106 +26,106 @@ "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", - "ai": "7.0.47", - "context.dev": "2.10.0", - "eve": "^0.29.4", + "ai": "7.0.94", + "context.dev": "2.14.0", + "eve": "^0.52.3", "ulid": "3.0.2", - "zod": "^4.4.3", + "zod": "^4.5.4", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/bun": "^1.3.14", - "@types/node": "^24.0.0", + "@types/bun": "^1.4.2", + "@types/node": "^26.5.0", "@xmpp/client": "0.14.0", - "just-bash": "^3.2.0", - "microsandbox": "^0.6.8", - "typescript": "^5.9.2", + "just-bash": "^3.4.2", + "microsandbox": "^0.6.17", + "typescript": "^7.0.2", }, }, "apps/api": { "name": "api", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-s3": "^3.1127.0", - "@aws-sdk/s3-request-presigner": "^3.1127.0", + "@aws-sdk/client-s3": "^3.1128.0", + "@aws-sdk/s3-request-presigner": "^3.1128.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", "@keyv/redis": "^5.1.6", - "@nestjs/cache-manager": "^3.1.3", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/cache-manager": "^12.0.0", + "@nestjs/common": "^11.2.3", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^11.2.3", + "@nestjs/platform-express": "^11.2.3", "@nestjs/swagger": "^11.4.7", - "@thallesp/nestjs-better-auth": "^2.7.0", + "@thallesp/nestjs-better-auth": "^2.8.0", "@trpc/server": "^11.18.0", - "@vercel/blob": "^2.6.1", - "better-auth": "1.7.2", + "@vercel/blob": "^2.8.0", + "better-auth": "1.7.3", "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "context.dev": "^2.7.0", + "context.dev": "^2.14.0", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", + "rxjs": "^7.8.2", "trpc-to-openapi": "^3.3.0", - "zod": "^4.4.3", + "zod": "^4.5.4", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@nestjs/testing": "^11.0.1", - "@types/bun": "^1.3.14", - "@types/express": "^5.0.0", - "@types/node": "^24.0.0", - "@types/supertest": "^7.0.0", - "concurrently": "^9.1.0", - "supertest": "^7.0.0", - "typescript": "^5.9.2", + "@nestjs/testing": "^11.2.3", + "@types/bun": "^1.4.2", + "@types/express": "^5.0.6", + "@types/node": "^26.5.0", + "@types/supertest": "^7.2.1", + "concurrently": "^10.0.5", + "supertest": "^7.2.2", + "typescript": "^7.0.2", }, }, "apps/app": { "name": "app", "version": "0.1.0", "dependencies": { - "@carbon/icons-react": "^11.82.0", + "@carbon/icons-react": "^11.88.0", "@crm/auth": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/ui": "workspace:*", "@crm/validation": "workspace:*", - "@pierre/diffs": "^1.2.12", - "@pierre/trees": "^1.0.0-beta.5", - "@tanstack/react-query": "^5.101.2", + "@pierre/diffs": "^1.4.1", + "@pierre/trees": "^1.0.0-beta.6", + "@tanstack/react-query": "^5.102.8", "@trpc/client": "^11.18.0", "@trpc/server": "^11.18.0", "@trpc/tanstack-react-query": "^11.18.0", "api": "workspace:*", - "better-auth": "1.7.2", - "eve": "^0.29.4", - "next": "16.3.0", + "better-auth": "1.7.3", + "eve": "^0.52.3", + "next": "16.3.4", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "posthog-js": "^1.413.2", - "react": "19.2.4", - "react-dom": "19.2.4", - "sonner": "^2.0.7", - "zod": "^4.4.3", + "nuqs": "^2.10.1", + "posthog-js": "^1.428.8", + "react": "19.2.8", + "react-dom": "19.2.8", + "sonner": "^2.0.8", + "zod": "^4.5.4", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@tanstack/react-query-devtools": "^5.101.2", - "@types/bun": "^1.3.14", - "@types/node": "^20", - "@types/react": "^19", + "@tailwindcss/postcss": "^4.3.3", + "@tanstack/react-query-devtools": "^5.102.8", + "@types/bun": "^1.4.2", + "@types/node": "^26.5.0", + "@types/react": "^19.2.18", "@types/react-dom": "^19", - "tailwindcss": "^4", - "typescript": "^5", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", }, }, "packages/agent-xmpp/core": { @@ -133,12 +133,12 @@ "version": "0.1.0", "dependencies": { "@agent-xmpp/protocol": "workspace:*", - "ajv": "8.17.1", + "ajv": "8.20.0", }, "devDependencies": { - "@types/node": "^22.10.0", - "tsx": "4.23.12", - "typescript": "^5.7.0", + "@types/node": "^26.5.0", + "tsx": "4.23.13", + "typescript": "^7.0.2", }, }, "packages/agent-xmpp/gateway": { @@ -152,45 +152,46 @@ "ulid": "3.0.2", }, "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0", + "@types/node": "^26.5.0", + "typescript": "^7.0.2", }, }, "packages/agent-xmpp/protocol": { "name": "@agent-xmpp/protocol", "version": "0.1.0", "dependencies": { - "idn-hostname": "15.1.10", + "idn-hostname": "17.0.3", "precis-wasm": "0.1.0", }, "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0", + "@types/node": "^26.5.0", + "typescript": "^7.0.2", }, }, "packages/auth": { "name": "@crm/auth", "version": "0.0.0", "dependencies": { - "@better-auth/api-key": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/sso": "1.7.2", + "@better-auth/api-key": "1.7.3", + "@better-auth/core": "1.7.3", + "@better-auth/oauth-provider": "1.7.3", + "@better-auth/sso": "1.7.3", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/validation": "workspace:*", - "better-auth": "1.7.2", - "zod": "^4.4.3", + "better-auth": "1.7.3", + "zod": "^4.5.4", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", - "auth": "1.7.2", + "auth": "1.7.3", "react": "^19.2.8", - "typescript": "5.9.2", + "typescript": "7.0.2", }, "peerDependencies": { - "react": "^19.2.0", + "react": "^19.2.8", }, "optionalPeers": [ "react", @@ -201,16 +202,16 @@ "version": "0.0.0", "dependencies": { "@crm/env": "workspace:*", - "@prisma/adapter-pg": "^7.9.1", - "@prisma/client": "^7.9.1", - "@vercel/blob": "^2.6.1", + "@prisma/adapter-pg": "^7.10.0", + "@prisma/client": "^7.10.0", + "@vercel/blob": "^2.8.0", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "pg": "^8.22.0", - "prisma": "^7.9.1", - "typescript": "5.9.2", + "@types/node": "^26.5.0", + "pg": "^8.23.0", + "prisma": "^7.10.0", + "typescript": "7.0.2", }, }, "packages/env": { @@ -218,8 +219,8 @@ "version": "0.0.0", "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2", + "@types/node": "^26.5.0", + "typescript": "7.0.2", }, }, "packages/kaneo-domain": { @@ -228,9 +229,9 @@ "devDependencies": { "@crm/typescript-config": "workspace:*", "@paralleldrive/cuid2": "^3.3.0", - "@types/node": "^24.10.1", + "@types/node": "^26.5.0", "drizzle-orm": "^0.45.2", - "typescript": "5.9.2", + "typescript": "7.0.2", }, }, "packages/telemetry": { @@ -239,12 +240,12 @@ "dependencies": { "@crm/db": "workspace:*", "@crm/env": "workspace:*", - "posthog-node": "^5.48.0", + "posthog-node": "^5.51.8", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2", + "@types/node": "^26.5.0", + "typescript": "7.0.2", }, }, "packages/typescript-config": { @@ -255,46 +256,46 @@ "name": "@crm/ui", "version": "0.0.0", "dependencies": { - "@carbon/icons-react": "^11.82.0", + "@carbon/icons-react": "^11.88.0", "@crm/db": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@shadcn/react": "^0.3.0", + "@shadcn/react": "^0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "lucide-react": "^1.28.0", - "motion": "^12.42.2", + "lucide-react": "^1.43.0", + "motion": "^13.2.0", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "radix-ui": "^1.6.0", + "nuqs": "^2.10.1", + "radix-ui": "^1.6.7", "react-day-picker": "^10.0.1", - "recharts": "3.8.0", - "sonner": "^2.0.7", - "streamdown": "^2.5.0", + "recharts": "3.10.1", + "sonner": "^2.0.8", + "streamdown": "^2.6.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@types/node": "^24.10.1", + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19", - "next": "16.2.12", - "react": "19.2.4", - "react-dom": "19.2.4", - "shadcn": "^4.16.1", - "tailwindcss": "^4", - "typescript": "5.9.2", + "next": "16.3.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "shadcn": "^4.21.0", + "tailwindcss": "^4.3.3", + "typescript": "7.0.2", }, "peerDependencies": { - "next": "^16.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "next": "^16.3.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", }, }, "packages/validation": { @@ -302,12 +303,12 @@ "version": "0.0.0", "dependencies": { "@crm/db": "workspace:*", - "zod": "^4.4.3", + "zod": "^4.5.4", }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2", + "@types/node": "^26.5.0", + "typescript": "7.0.2", }, }, }, @@ -315,7 +316,7 @@ "sharp", ], "patchedDependencies": { - "@better-auth/oauth-provider@1.7.2": "patches/@better-auth%2Foauth-provider@1.7.2.patch", + "@better-auth/oauth-provider@1.7.3": "patches/@better-auth%2Foauth-provider@1.7.3.patch", }, "packages": { "@agent-xmpp/core": ["@agent-xmpp/core@workspace:packages/agent-xmpp/core"], @@ -324,21 +325,19 @@ "@agent-xmpp/protocol": ["@agent-xmpp/protocol@workspace:packages/agent-xmpp/protocol"], - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N1P6bdW/aC5rxLeuGYgx3X4el3DoZy8UWlky+g+AeIZSmxaEi/AToHJL4cmZ6nCPHk1byqJWwC+PaOZG0hK0dw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.76", "", { "dependencies": { "@ai-sdk/provider": "4.0.11", "@ai-sdk/provider-utils": "5.0.37", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hWNtiveILLtkXm1t5tNmzeII8p+j/mzxlL2OtPAgU/0h4ZInkIVILX8Svx4SzI5U9d80B59gCPaKRAhb7jTtAw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.11", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-A/Cyjma9RLTLsF9xv/2OSaw9eyFhOIBqzoeOVZhp+xKZD8wv5opN+qyIa83NE4Jug2SjQ/Kaf08w1JX6CESR/w=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.18", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UBNCrkxS5llgN2/RXLBRkjCFTIuL6YB1Goq5c+yRecnznhtX4XPjTwLHz2hrsTltTVZ0rqVegtnwFXdPBkjDHA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.37", "", { "dependencies": { "@ai-sdk/provider": "4.0.11", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WzLQPWtpLpunKBanq/TX8+rbBz7JdKpl8Io4nl/g+bLvePbxMlMuxLrxT8JzquxJmgWKbW4nfFrQ5mnX4A8mpw=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.29", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A=="], - "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1127.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.29", "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/middleware-sdk-s3": "^3.972.75", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1128.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.29", "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/middleware-sdk-s3": "^3.972.75", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-tYEB4058LdhTiSS7sCVVSpqSAdjIc1jTaf1dDPoIRJbR/XI5A2ZOuCiV1Aebo/pria/pUJBOT0WjdZVIwaZtDA=="], "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], @@ -362,7 +361,7 @@ "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], - "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vl3WBaE2fMqfnEhyqulKT6ZIPNsCR2caCps92tyt1yGfLVlDXScSXBDXf4m3E2ejXBb4pplX0EdwBukJyBXtBg=="], + "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1128.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0wTgUw97lUKx94e4liC53V5z9giwqMaPsFjbXXy8qLC9Q/AaxUySmri+NFkHaOAd908sGsZ6ERMyFO4WMvYxLQ=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], @@ -380,7 +379,7 @@ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], @@ -412,7 +411,7 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], @@ -436,61 +435,59 @@ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@better-auth/api-key": ["@better-auth/api-key@1.7.2", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-jih45wZaQ83lVYdChSnasxb8iax8p7AYetZ/XiImA9Yuag+cqmLBsaLPNvds5aZV18hLpmvzVkuzf7H/k1X88g=="], + "@better-auth/api-key": ["@better-auth/api-key@1.7.3", "", { "dependencies": { "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.3", "better-call": "1.4.0" } }, "sha512-SY5IBK4YcxnQdyK6RAxSpze6Zfi2Emtp9c4BFl/FT0U7ZyBnKARKHFC1csf8kvtrbaMIIWUvcM9eeQW6OKUqRQ=="], - "@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="], + "@better-auth/core": ["@better-auth/core@1.7.3", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-JdP7lOkyE83jgjn7RilJj1XvZ7n2JjRsErKJuaXchjyuNo6cf1iVd3GtbhAtiUyJJkWdk8yL+LaUBKT80H0zLA=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-S+nQRlxbUhkR43LrSv8c98ZvOvmv3nrtOnHkiZXkdDkr60PWp7maC2cqgzZ2C9exCC1a+4TugJlx2jRL+r+/9A=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-UIsyJMIrjUnT+yTaS6dkCxYYmtPwxFHxwSJ8+CLty2II5w9BewlDxDA0/QzhoL/InYCPxQ5Y6xIgLHZG1dhwRA=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2" } }, "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2" } }, "sha512-WdLANFY/QWC3G351RCzxU+Y9YlW+BQ1oG9NwBTSOUWQw5rZ87ws+weU8tvAMO7sQ4C9gKIlkOKBKUKXrSt91Tw=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-YL9m01tNogmFmRvOWJ46M9WwE6HirCXHDell29mtsQB/Qs1TPNLrgj7ybGMMGhQuyj6S218+8Wbls8m9s+i8RQ=="], - "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.2", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-td7FnUz3lLKXFXN+0RbZe3ygaHqxpRqDG+gxbfSZbztbVfG7vuZtR4ba3uccsodAw7anchhIg2xwVP1/zlcxcw=="], + "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.3", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.3", "better-call": "1.4.0" } }, "sha512-Eoajj68F1ETmp4NBM98tgj1Uiip4cZiFqFsijAWIvOEQTejr/67PWK1/92N8PiNdLf03ezUWU538W93rd9fyIw=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-TJ/DhlU7oLzrC626/1wfYA1Pl+lVsXa/zXBZ8d7Rlc2YO5fGd5fsAYJdRrYMyA51iZEo+rWLXMhZ0cez1BamsQ=="], - "@better-auth/sso": ["@better-auth/sso@1.7.2", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-8tmkAGdcVu8Tr/+LPfSRgs/5a8YE1uU8OeaN5mAzsSdMWR4iwZdwQlJJmU8f9qZyIpNL/B6mIfDc5vaVUy1ECQ=="], + "@better-auth/sso": ["@better-auth/sso@1.7.3", "", { "dependencies": { "@xmldom/xmldom": "^0.9.12", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.3", "better-call": "1.4.0" } }, "sha512-inxETRuOfyy8m0uEJSfi9uhScp4RkQ7ZYI+GRj+Jtz6ml/p5wTzNOjYZsG43TroUTDfrElF6CYQON5/Xk1XdmA=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-aixgHbJhGvS8PRczX/LR3murYyBnIvOGmJw37ZZBMg6ZtLR/UBJAkufbDi1HYn5THSuTJ3D5DtY85Ahh/ABQtw=="], "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], - "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], - "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], - "@carbon/icon-helpers": ["@carbon/icon-helpers@10.79.0", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.0" } }, "sha512-sg5bL9Y/Vns4W7iNRaIMoNc/FtuzJE8G5VvCbC8xcrrNoZmfhhiPFHyU8CFhrf19HJdAxOvXShIBxmm3glLAYw=="], + "@carbon/icon-helpers": ["@carbon/icon-helpers@10.82.0", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.0" } }, "sha512-HwyUn3iOzV7ahnf2oBaDL4fui7FBbTDBf+0N+sFfs+x17PFD3WkTz4BRefnWjlfLyKmuQTUwRuoOAVBSN1qY0Q=="], - "@carbon/icons-react": ["@carbon/icons-react@11.85.0", "", { "dependencies": { "@carbon/icon-helpers": "^10.79.0", "@ibm/telemetry-js": "^1.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16" } }, "sha512-+fRNYyqR8aCZRSgMA8sJ+GVfCmS0pK8FaDl9rQDuy4ZqYldZ4Yj8K3YIxmcSLlvTxMmNtGAxoa80xlhXROYDQg=="], + "@carbon/icons-react": ["@carbon/icons-react@11.88.0", "", { "dependencies": { "@carbon/icon-helpers": "^10.82.0", "@ibm/telemetry-js": "^1.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16" } }, "sha512-RvlzTmAxIJ/ujFB1vsb7IWKAhJvaBh9ims5qBQUW/OTV8Rf9If/FHFiSMyMoK7JdQB1yfn+0RQaB7t1wqZ2wag=="], "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], @@ -502,9 +499,9 @@ "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], - "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + "@clack/core": ["@clack/core@1.5.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw=="], - "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@clack/prompts": ["@clack/prompts@1.8.0", "", { "dependencies": { "@clack/core": "1.5.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA=="], "@crm/auth": ["@crm/auth@workspace:packages/auth"], @@ -612,67 +609,63 @@ "@hapi/bourne": ["@hapi/bourne@3.0.0", "", {}, "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="], - "@hono/node-server": ["@hono/node-server@2.0.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg=="], + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - - "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], @@ -690,7 +683,7 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -700,8 +693,6 @@ "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], - "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -712,45 +703,45 @@ "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.16.0", "", { "dependencies": { "chevrotain": "^12.0.0", "lilconfig": "^2.1.0" } }, "sha512-a9ELYNIflEQP38tSu6gnUgSAWgXjuhMvC52868K5sWgyRmYsjiJMWTUmm8iy7hZT5EN2ZKVydMTFP2q3/+6ccg=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], - "@nestjs/cache-manager": ["@nestjs/cache-manager@3.1.3", "", { "peerDependencies": { "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", "cache-manager": ">=6", "keyv": ">=5", "rxjs": "^7.8.1" } }, "sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ=="], + "@nestjs/cache-manager": ["@nestjs/cache-manager@12.0.0", "", { "peerDependencies": { "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "cache-manager": ">=6", "keyv": ">=5", "rxjs": "^7.8.1" } }, "sha512-kYn5rgDZXZ6aIS7MxSfx0AwjRtJpq++Uac4ceWNckkYIkvnflv8hWgorhPXoVmLpTGiwrQ6G2gJh6MzsmoTqHg=="], - "@nestjs/common": ["@nestjs/common@11.1.28", "", { "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": ">=0.4.1", "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ=="], + "@nestjs/common": ["@nestjs/common@11.2.3", "", { "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": ">=0.4.1", "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA=="], - "@nestjs/config": ["@nestjs/config@4.0.4", "", { "dependencies": { "dotenv": "17.4.1", "dotenv-expand": "12.0.3", "lodash": "4.18.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "rxjs": "^7.1.0" } }, "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow=="], + "@nestjs/config": ["@nestjs/config@12.0.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "es-toolkit": "1.51.0" }, "peerDependencies": { "@nestjs/common": "^11.0.0 || ^12.0.0", "rxjs": "^7.1.0" } }, "sha512-pG5+fFfCo3cdgVkLJLvvhUGfxEjwzv9hIAQ1aJVHvHlHVniDWfwX5pR/utgoAkJzSikv2oTR1BmIF03FXdi42Q=="], - "@nestjs/core": ["@nestjs/core@11.1.28", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ=="], + "@nestjs/core": ["@nestjs/core@11.2.3", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg=="], "@nestjs/mapped-types": ["@nestjs/mapped-types@2.1.1", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A=="], - "@nestjs/platform-express": ["@nestjs/platform-express@11.1.28", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0" } }, "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA=="], + "@nestjs/platform-express": ["@nestjs/platform-express@11.2.3", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0" } }, "sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg=="], "@nestjs/swagger": ["@nestjs/swagger@11.4.7", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.1", "js-yaml": "5.3.0", "lodash": "4.18.1", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.32.13" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator"] }, "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ=="], - "@nestjs/testing": ["@nestjs/testing@11.1.28", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express"] }, "sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ=="], + "@nestjs/testing": ["@nestjs/testing@11.2.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express"] }, "sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ=="], - "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="], + "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw=="], - "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], @@ -762,45 +753,45 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.143.0", "", { "os": "android", "cpu": "arm" }, "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.148.0", "", { "os": "android", "cpu": "arm" }, "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.143.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.148.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.143.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.148.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.143.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.148.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.143.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.148.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.143.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.148.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.143.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.148.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.143.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.148.0", "", { "os": "none", "cpu": "arm64" }, "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.143.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.148.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.143.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.148.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.148.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ=="], - "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + "@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], @@ -840,49 +831,49 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], - "@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="], + "@oxlint/plugins": ["@oxlint/plugins@1.82.0", "", {}, "sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], - "@pierre/diffs": ["@pierre/diffs@1.3.4", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-6Dt48jQIL+H54QyNB943Oqn5p4uRw7NpiXLSEapnaYgYvTmSHNPAUCDfg6AcRC+W02z0IYJnJJvcs3lsNp7gCw=="], + "@pierre/diffs": ["@pierre/diffs@1.4.1", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-rzvY9FeeYdGtVcErjNsW6tnrjpMb0DvGtgCFNuMoYT8wufE+gO1ZXAzKAc2VPRBS3Rl5HELEQ38WDlis4qZHkQ=="], "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], @@ -890,31 +881,31 @@ "@pierre/trees": ["@pierre/trees@1.0.0-beta.6", "", { "dependencies": { "@pierre/theming": "1.0.0", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw=="], - "@posthog/browser-common": ["@posthog/browser-common@0.4.0", "", { "dependencies": { "@posthog/core": "^1.46.8", "@posthog/types": "^1.402.0" } }, "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ=="], + "@posthog/browser-common": ["@posthog/browser-common@0.8.2", "", { "dependencies": { "@posthog/core": "^1.51.0", "@posthog/types": "^1.409.1" } }, "sha512-8g7+ijrx8bfWmpDjmP07e0ZmdRnJoFqZ6PCcMQvfBTUrr422GRXvQWpQn7yD028zIJHIIn/rUTipKgUC5UkWpw=="], - "@posthog/core": ["@posthog/core@1.46.8", "", { "dependencies": { "@posthog/types": "^1.401.1" } }, "sha512-WQTSwlFhsWk09xngTimHiTyhFU8QZHFZEGSm+6brY3acwYdvTLcTxpIhgl/qOCgn/XoqlCFTZqU1ldWLxNntfA=="], + "@posthog/core": ["@posthog/core@1.51.2", "", { "dependencies": { "@posthog/types": "^1.409.2" } }, "sha512-z3fPR/RdOgTYWdHQnZZm81CCgljDxsrMsSz72Jpd2vJAtycsRYKOlMXdP8+55yM3WYeVU1wOyF9BldWsIABr+A=="], - "@posthog/types": ["@posthog/types@1.402.1", "", {}, "sha512-4PZ9wMYI8m8AqJuZ9YR1IAHGVtSnYbBVBgTevrKpzZbcSe/OUwhEV2Ks//rJh/L8eMTU8R5DrD4D/hlrOwaAiQ=="], + "@posthog/types": ["@posthog/types@1.409.2", "", {}, "sha512-hZ4EXZ1+BstMaxUkmAEg3qvgMR/S00Xb+wEwY/Tx2Dr9dBgiBWrERxaq2UwICh/Fh/vVXpKZT/0SkRtO8JKQ2A=="], - "@prisma/adapter-pg": ["@prisma/adapter-pg@7.9.1", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.9.1", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw=="], + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.10.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw=="], - "@prisma/client": ["@prisma/client@7.9.1", "", { "dependencies": { "@prisma/client-runtime-utils": "7.9.1" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q=="], + "@prisma/client": ["@prisma/client@7.10.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q=="], - "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.9.1", "", {}, "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg=="], + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.10.0", "", {}, "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw=="], - "@prisma/config": ["@prisma/config@7.9.1", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA=="], + "@prisma/config": ["@prisma/config@7.10.0", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA=="], - "@prisma/debug": ["@prisma/debug@7.9.1", "", {}, "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ=="], + "@prisma/debug": ["@prisma/debug@7.10.0", "", {}, "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g=="], "@prisma/dev": ["@prisma/dev@0.24.17", "", { "dependencies": { "@electric-sql/pglite": "0.4.3", "@electric-sql/pglite-socket": "0.1.3", "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.11", "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA=="], - "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ=="], + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA=="], - "@prisma/engines": ["@prisma/engines@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1", "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "@prisma/fetch-engine": "7.9.1", "@prisma/get-platform": "7.9.1" } }, "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg=="], + "@prisma/engines": ["@prisma/engines@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/fetch-engine": "7.10.0", "@prisma/get-platform": "7.10.0" } }, "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ=="], - "@prisma/engines-version": ["@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "", {}, "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA=="], + "@prisma/engines-version": ["@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "", {}, "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw=="], - "@prisma/fetch-engine": ["@prisma/fetch-engine@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1", "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "@prisma/get-platform": "7.9.1" } }, "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w=="], + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/get-platform": "7.10.0" } }, "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g=="], "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], @@ -946,7 +937,7 @@ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], @@ -990,7 +981,7 @@ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="], @@ -1048,35 +1039,35 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.8", "", { "os": "android", "cpu": "arm" }, "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.8", "", { "os": "android", "cpu": "arm64" }, "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.8", "", { "os": "linux", "cpu": "arm" }, "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.1", "", { "os": "none", "cpu": "arm64" }, "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.1", "", { "dependencies": { "@emnapi/core": "2.0.0-alpha.3", "@emnapi/runtime": "2.0.0-alpha.3", "@napi-rs/wasm-runtime": "^1.2.0" } }, "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.8", "", { "os": "none", "cpu": "arm64" }, "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -1086,23 +1077,23 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@shadcn/react": ["@shadcn/react@0.3.0", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-iKN0NuYe850VDHlxEfvppFrpa7CMV3zArTHusXlaSmeFeQhohGbIqclv6WwPTs/44I8EkXQpcuRt6sXEVKkWqQ=="], + "@shadcn/react": ["@shadcn/react@0.3.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-2gOR0HDMtWeRsCZfNDaU0YDFdgH3zsDQ6lz67Fv/y/qjY9y+R8kJqAn6q56phqv7/zHi0wqURjntrNO9zL7vnQ=="], - "@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="], + "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], - "@shikijs/langs": ["@shikijs/langs@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2" } }, "sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg=="], + "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], - "@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="], + "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], - "@shikijs/themes": ["@shikijs/themes@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2" } }, "sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g=="], + "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], - "@shikijs/transformers": ["@shikijs/transformers@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2" } }, "sha512-d81PJ9KkR1tVP95FH/9296HTtDo0mh76wv10u9T1YmsZq/UcXgt0OLdBszfUQ1i+umkRMCjDnFbFZU7/tCODTQ=="], + "@shikijs/transformers": ["@shikijs/transformers@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/types": "4.4.3" } }, "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw=="], - "@shikijs/types": ["@shikijs/types@4.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ=="], + "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1124,17 +1115,17 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@superradcompany/microsandbox-darwin-arm64": ["@superradcompany/microsandbox-darwin-arm64@0.6.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SbPo2mb5sEWY7Sry9lxlh762oX5G4RI4Xq2i6U9covTe+4MIFznNSCPIntoCcpzVwt4VNIukvEdyTsI9kITJLw=="], + "@superradcompany/microsandbox-darwin-arm64": ["@superradcompany/microsandbox-darwin-arm64@0.6.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H+h+lNGlPWpt9qQ9k/MOFAApLFloA22zJAzormM1CSISkyaLBromH4bt9XlCP3nhG3/oHuYnO7FqhKZN+klVgA=="], - "@superradcompany/microsandbox-linux-arm64-gnu": ["@superradcompany/microsandbox-linux-arm64-gnu@0.6.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMgS/pE6N2LTbdGYMNiSzfxlsD0F1fhVuCFuIULxGfGyK+zmrYHgTgV0K6n9UQY+PjseOvNn//T7FJQ7SwyJ3g=="], + "@superradcompany/microsandbox-linux-arm64-gnu": ["@superradcompany/microsandbox-linux-arm64-gnu@0.6.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-j7YfBbVKDpF1fC38FPI0Ut1iThpUf0U4+I4IA+lBTyxo3ptux0b2ouY0CDSB+okrWTCe/kxgxUhgOKc9V3gOPA=="], - "@superradcompany/microsandbox-linux-x64-gnu": ["@superradcompany/microsandbox-linux-x64-gnu@0.6.8", "", { "os": "linux", "cpu": "x64" }, "sha512-JCYgBsCf00bX2RA7YhdFdNOQHsWJFXYM3WJi1ONx3jLdUpiMKAVRZxCSPJe/UrFoRiqfj49AmgIBNtvs+2Ns9g=="], + "@superradcompany/microsandbox-linux-x64-gnu": ["@superradcompany/microsandbox-linux-x64-gnu@0.6.17", "", { "os": "linux", "cpu": "x64" }, "sha512-o9IXJNWi1Ml9VMD0IAbLdUTeujbmY39z5reuez03Svxrqy8AlGGJoNR6Nh+oAm5p0Ytke8BChI9OlYcSFFCPHw=="], - "@superradcompany/microsandbox-win32-arm64-msvc": ["@superradcompany/microsandbox-win32-arm64-msvc@0.6.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-cWXaHBleVZbTddVYiUSuChNbHWhzTnNkgBqhisSDcFa1FBa0g5sBzTBlcC0RAseMzs4F+6YqLbD040QOe/6deQ=="], + "@superradcompany/microsandbox-win32-arm64-msvc": ["@superradcompany/microsandbox-win32-arm64-msvc@0.6.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-M2wdSTTEBRKJXpYX3P0+5tQJep17HY1p7UTFLxFsiWH+xEJ6z5RvYb1U2i5BvNVvyymkfBbh5rnyBSv8vnyDcg=="], - "@superradcompany/microsandbox-win32-x64-msvc": ["@superradcompany/microsandbox-win32-x64-msvc@0.6.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Asfn+8+CplKh05MyJIoRJzLCl1Z05B/n6Lc6+tpfFKmHjMkQw976L67AqqBQq4AxfkakBcpZYVHZQqUDxl5Fow=="], + "@superradcompany/microsandbox-win32-x64-msvc": ["@superradcompany/microsandbox-win32-x64-msvc@0.6.17", "", { "os": "win32", "cpu": "x64" }, "sha512-1Acpg7zb7o+R1BRV2pWFyMAKTqm7YvMJFPMjAA6H69jAyPTVBdJw3TVCLiN2RR5lHLvV2EdHmx/awl9X5Oj8/g=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -1166,15 +1157,15 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], - "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.4", "", {}, "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.102.8", "", {}, "sha512-ZgeMKuF5d/zOE+tgWm3cSbD6Zcbr6IugVsnbHzUcSismqLZDSUPBKM6ILUxExgwe6rPAOox2x5bA5T+PSOQG0Q=="], - "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="], + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.102.8", "", { "dependencies": { "@tanstack/query-devtools": "5.102.8" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" } }, "sha512-QKb7A44BZOU7nxsGA4gFN1fofjYovar5O0T83Ff4Y+2eRq09RGFrAzzutVdF/6/emfSaMDohp6e759BjB3fxEw=="], - "@thallesp/nestjs-better-auth": ["@thallesp/nestjs-better-auth@2.7.0", "", { "peerDependencies": { "@nestjs/common": "^11.1.6", "@nestjs/core": "^11.1.6", "@nestjs/graphql": "^13.1.0", "@nestjs/websockets": "^11.1.6", "better-auth": ">=1.5.0 <2.0.0", "express": "^5.1.0", "graphql": "^16.11.0", "qs": "^6.14.0", "typescript": "^5.9.2 || ^6.0.0" }, "optionalPeers": ["@nestjs/graphql", "@nestjs/websockets", "express", "graphql", "qs"] }, "sha512-Grq74scQ4AdEjrAr9mE8780u9N4fn+X/qz7DTHa/K37g+RPw+R3Yde4leRz/UIqsLx0o8p1B5wRGPnM65Q+ZsQ=="], + "@thallesp/nestjs-better-auth": ["@thallesp/nestjs-better-auth@2.8.0", "", { "peerDependencies": { "@nestjs/common": "^11.1.6 || ^12.0.0", "@nestjs/core": "^11.1.6 || ^12.0.0", "@nestjs/graphql": "^13.1.0 || ^14.0.0", "@nestjs/websockets": "^11.1.6 || ^12.0.0", "better-auth": ">=1.5.0 <2.0.0", "express": "^5.1.0", "graphql": "^16.11.0", "qs": "^6.14.0", "typescript": "^5.9.2 || ^6.0.0" }, "optionalPeers": ["@nestjs/graphql", "@nestjs/websockets", "express", "graphql", "qs"] }, "sha512-B5K8Nlr0BH/tFJAPJAPefC09gKSSFofgmSTQx6XFeWDgPcG6awTh9iRFlv9it0skYgcCD2fXr53ChgXjLse0pw=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], @@ -1204,74 +1195,38 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], - - "@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], - - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], - - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], - - "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], - - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], - - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], - "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], - - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - - "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], - - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], - "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], - - "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -1280,7 +1235,7 @@ "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], - "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.2", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="], + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.3", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], @@ -1288,7 +1243,7 @@ "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + "@types/lodash": ["@types/lodash@4.17.25", "", {}, "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -1296,9 +1251,9 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], - "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -1326,17 +1281,55 @@ "@types/validator": ["@types/validator@13.15.10", "", {}, "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - "@vercel/blob": ["@vercel/blob@2.6.1", "", { "dependencies": { "@vercel/oidc": "^3.6.1", "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-KTJytw85j1XQBxjN5d6UXI7fIWNQe1jotn4nWN+0hePqLs+Qi1B3jHdQcSKFGF0m2rsy9uhPT6GOXMtHe3qNzg=="], + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - "@vercel/cli-config": ["@vercel/cli-config@0.2.1", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA=="], + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - "@vercel/cli-exec": ["@vercel/cli-exec@1.0.0", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug=="], + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - "@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="], + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "@vercel/blob": ["@vercel/blob@2.8.0", "", { "dependencies": { "@vercel/oidc": "^3.6.1", "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ=="], + + "@vercel/cli-config": ["@vercel/cli-config@0.2.5", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-WniFxgznIPZ3h1USCLxArTyvR2a3/lvZW5mSSkuI2r8rQWEzUV8qnKXJEOFrVnVWe2YDxeCVMgZoxIH4kHA92Q=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="], + + "@vercel/oidc": ["@vercel/oidc@3.8.6", "", { "dependencies": { "@vercel/cli-config": "0.2.5", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-0gAPrFB1eVCLBVDDG1PUxs+G5OS97260GK2TEcUB2TeEeWM+17P4U9xC7ASp6f9rv/pu8tBceH5cmRV0MpUhGA=="], "@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="], @@ -1426,17 +1419,17 @@ "agent": ["agent@workspace:apps/agent"], - "ai": ["ai@7.0.47", "", { "dependencies": { "@ai-sdk/gateway": "4.0.36", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-e0MpNtufu6JmcmwMUTgM1smCRkP5z014iPaKgnCm7kmMsTSIZbOJN2vglhPq0WIj/6x7ewVi6W0FyIR0pjaE3Q=="], + "ai": ["ai@7.0.94", "", { "dependencies": { "@ai-sdk/gateway": "4.0.76", "@ai-sdk/provider": "4.0.11", "@ai-sdk/provider-utils": "5.0.37" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N70qu4y53SjhA+kHYQCGLNTXLsztV+Rh28l2JDuEzALY5EgNWloYvUDjssL2Pi1Ie2nILcgWShLRgTpU1HxGHg=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], @@ -1454,7 +1447,7 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-types": ["ast-types@0.16.3", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA=="], "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], @@ -1462,7 +1455,7 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], - "auth": ["auth@1.7.2", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@better-auth/core": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@clack/prompts": "^1.6.0", "@mrleebo/prisma-ast": "^0.16.0", "better-auth": "1.7.2", "c12": "^4.0.0-beta.5", "chalk": "^5.6.2", "commander": "^15.0.0", "dotenv": "^17.3.1", "get-tsconfig": "^4.14.0", "jiti": "^2.7.0", "open": "^11.0.0", "prettier": "^3.8.1", "prompts": "^2.4.2", "semver": "^7.8.4", "yocto-spinner": "^1.2.0", "zod": "^4.3.6" }, "bin": { "better-auth": "./dist/index.mjs", "auth": "./dist/index.mjs" } }, "sha512-1c/FD5L2FkWzZXpbIV72X8gxXW0FepmGUBv0l3bn50SGMNOxfNGfU4k9yWonhM0r5i+l/39rTYtNPIS8Q0anUA=="], + "auth": ["auth@1.7.3", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@better-auth/core": "1.7.3", "@better-auth/telemetry": "1.7.3", "@better-auth/utils": "0.4.2", "@clack/prompts": "^1.6.0", "@mrleebo/prisma-ast": "^0.16.0", "better-auth": "1.7.3", "c12": "^4.0.0-beta.5", "chalk": "^5.6.2", "commander": "^15.0.0", "dotenv": "^17.3.1", "get-tsconfig": "^4.14.0", "jiti": "^2.7.0", "open": "^11.0.0", "prettier": "^3.8.1", "prompts": "^2.4.2", "semver": "^7.8.4", "yocto-spinner": "^1.2.0", "zod": "^4.5.4" }, "bin": { "auth": "./dist/index.mjs", "better-auth": "./dist/index.mjs" } }, "sha512-95a2C+wg/UNRy9wW9rbA95KBN0MsWev3S/txmIIgyqSQtZ+Hbqz14F9sFKZ9qMsZjFEjaEQ2QS0mqmbtISIpPw=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -1472,9 +1465,9 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], - "better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="], + "better-auth": ["better-auth@1.7.3", "", { "dependencies": { "@better-auth/core": "1.7.3", "@better-auth/drizzle-adapter": "1.7.3", "@better-auth/kysely-adapter": "1.7.3", "@better-auth/memory-adapter": "1.7.3", "@better-auth/mongo-adapter": "1.7.3", "@better-auth/prisma-adapter": "1.7.3", "@better-auth/telemetry": "1.7.3", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-8xGp68JQ+l36kniDEgP8bP99TLi1GdEv0NTEUBkyqnYnus/cgFUZseUfqUHKzr2BAsg2O6aD88I9f0U68shanQ=="], "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], @@ -1492,13 +1485,13 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1506,7 +1499,7 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "c12": ["c12@4.0.0-beta.5", "", { "dependencies": { "confbox": "^0.2.4", "defu": "^6.1.7", "exsolve": "^1.0.8", "pathe": "^2.0.3", "pkg-types": "^2.3.1", "rc9": "^3.0.1" }, "peerDependencies": { "chokidar": "^5", "dotenv": "*", "giget": "*", "jiti": "*", "magicast": "*" }, "optionalPeers": ["chokidar", "dotenv", "giget", "jiti", "magicast"] }, "sha512-yWGCPCQGJeFq4R0mFg5HOhC3Rg+B0PCdM+ldXWUhughoGgeeq8/tjRmXh4/lmhKWyhf+KOFxB/JMXf0Yv1Fd5A=="], + "c12": ["c12@4.0.0-rc.1", "", { "dependencies": { "confbox": "^0.3.1", "defu": "^6.1.7", "exsolve": "^1.1.1", "pathe": "^2.0.3", "pkg-types": "^2.3.2", "rc9": "^3.1.0" }, "peerDependencies": { "chokidar": "^5", "dotenv": "*", "giget": ">=3.1.0", "jiti": "*", "magicast": "*" }, "optionalPeers": ["chokidar", "dotenv", "giget", "jiti", "magicast"] }, "sha512-08UYGAVLTLqgiCzWNMNdBHdiWKzhIVwcapySI11mFTBacL5bP0dj1Vr7IcwOH0wcUTgMzm1VCh+by922viKWig=="], "cache-manager": ["cache-manager@7.2.9", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "keyv": "^5.6.0" } }, "sha512-d4vceEyYe95gPxEyQchlEOH9vJlkNRW8G6gzFzzMTxJK9PahYMhC9chrEqgZN0HulROjgw3IzmWVNk7Q7ytiGw=="], @@ -1516,7 +1509,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1550,7 +1543,7 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1558,14 +1551,12 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], + "co-body": ["co-body@6.2.0", "", { "dependencies": { "@hapi/bourne": "^3.0.0", "inflation": "^2.0.0", "qs": "^6.5.2", "raw-body": "^2.3.3", "type-is": "^1.6.16" } }, "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -1576,11 +1567,11 @@ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], - "concurrently": ["concurrently@9.2.4", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA=="], + "concurrently": ["concurrently@10.0.5", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg=="], "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], - "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -1588,7 +1579,7 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "context.dev": ["context.dev@2.10.0", "", {}, "sha512-hTymKw2SrPXWt+1JSPceQcbIgnX32PdSzR9qvAMXWK5qwDTB/kJE1YK5IIXkBUUzZG2rT5QQqwYAGFooYQ8Yow=="], + "context.dev": ["context.dev@2.14.0", "", {}, "sha512-cx1kwP5ecCw6FPC3TV8t+JVv+xfpEDmBfs51aQIXoT2UerncizgoMyuRM/EKHp8n6ITe9YEeHJdBPyNBgHav5A=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1604,76 +1595,34 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="], + "crossws": ["crossws@0.4.12", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], - - "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], - - "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], - - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], - - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], @@ -1682,17 +1631,9 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], - - "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], + "db0": ["db0@0.4.1", "", {}, "sha512-6RBY/bSn42UrqATwsiULj2uYFyEykB3XeA/NLIVQeHNXlYV6D4Idxx3/aa6k5Y45Dwzx7sygeLotnqHWSeURYA=="], "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], @@ -1712,7 +1653,7 @@ "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -1742,13 +1683,13 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], + "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "dotenv-expand": ["dotenv-expand@13.0.0", "", { "dependencies": { "dotenv": "^17.4.2" } }, "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], @@ -1758,7 +1699,7 @@ "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.399", "", {}, "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.425", "", {}, "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g=="], "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], @@ -1778,7 +1719,7 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="], + "env-runner": ["env-runner@0.2.1", "", { "dependencies": { "crossws": "^0.4.12", "exsolve": "^1.1.1", "httpxy": "^0.5.5", "srvx": "^1.0.0" }, "bin": { "env-runner": "./dist/cli.mjs" } }, "sha512-2iDP2DfheAMMAKeXBggEuFmpSq+1Xs6wQoHba1g65pZFXlC3VHD1O6/tgVzS6GQLv+P2koPKZPKgKhXkigNBdg=="], "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], @@ -1792,7 +1733,7 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -1808,7 +1749,7 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eve": ["eve@0.29.4", "", { "dependencies": { "nitro": "3.0.260610-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.38", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-EwOmL37l+Iuu7Umno7at3flFpMs4AtT9cg1J4dt7EZ8ZdxaCvGXwvKGwiulMkG8oXkMQ5CNhxMi2ruHJwrtpwQ=="], + "eve": ["eve@0.52.3", "", { "dependencies": { "nitro": "3.0.260903-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.82", "braintrust": "^3.0.0", "just-bash": "^3.1.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-Ii/fs18oBX7mVoywtAy8Pmi0sHRWVx8/IR0Uzhic3e/WkcFLTmr07ewfaH3VJ1h5Y9SpeUni2WfBE5Yd83mv0A=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -1816,7 +1757,7 @@ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], @@ -1824,7 +1765,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.6.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA=="], + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], @@ -1846,15 +1787,15 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], - "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], - "fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="], + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -1878,13 +1819,13 @@ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "formatly": ["formatly@0.7.0", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg=="], "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - "framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "framer-motion": ["framer-motion@13.2.0", "", { "dependencies": { "motion-dom": "^13.2.0", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -1918,7 +1859,7 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], @@ -1936,10 +1877,6 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1968,7 +1905,7 @@ "helmet": ["helmet@8.3.0", "", {}, "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w=="], - "hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="], + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], @@ -1986,18 +1923,16 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - "idn-hostname": ["idn-hostname@15.1.10", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-/mSXWRhVasTJ7Z4z18523rTA6CmStYN29yDt+oXi9fe1/M0SO2Un1BgUr3v28aAZG5hWicyUtIamC/juXt3nZQ=="], + "idn-hostname": ["idn-hostname@17.0.3", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-jah2u3Omm11YP1ORFVTPLrlrBdRT4Wc7xWPTg1rMRquSmEhOqkNpM15bIwDb9ugtq2S5tLe5b+gLHXQeZ3RiMw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "inflation": ["inflation@2.1.0", "", {}, "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -2008,7 +1943,7 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -2028,8 +1963,6 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], @@ -2058,7 +1991,7 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], @@ -2068,7 +2001,7 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "jose": ["jose@6.2.5", "", {}, "sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -2088,25 +2021,19 @@ "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "just-bash": ["just-bash@3.2.0", "", { "dependencies": { "diff": "^8.0.2", "fast-xml-parser": "^5.7.3", "file-type": "^21.2.0", "ini": "^6.0.0", "minimatch": "^10.1.1", "modern-tar": "^0.7.3", "papaparse": "^5.5.3", "quickjs-emscripten": "^0.32.0", "re2js": "^1.2.1", "seek-bzip": "^2.0.0", "smol-toml": "^1.6.0", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", "undici": "^7.25.0", "yaml": "^2.8.2" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.0.3" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-hRTLLWBXCKuosjaNFJR7uPYBza+T2vjG3NdPBz4wxlctlnxwrbLjtLQf6RtSKmy/jzNJ6/URCtvxrXjy6yFGeQ=="], - - "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "just-bash": ["just-bash@3.4.2", "", { "dependencies": { "diff": "^8.0.4", "fast-xml-parser": "^5.10.1", "file-type": "^21.3.4", "ini": "^6.0.0", "minimatch": "^10.2.6", "modern-tar": "^0.7.7", "papaparse": "^5.6.0", "quickjs-emscripten": "^0.32.0", "re2js": "^1.3.3", "seek-bzip": "^2.0.0", "smol-toml": "^1.8.0", "sprintf-js": "^1.1.3", "sql.js": "^1.14.1", "turndown": "^7.2.4", "undici": "^7.29.0", "yaml": "^2.9.0" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.2.0" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-T0Vpy7YRgCjxJdqG3tkxn0ZnIDLJvVwb8hH4L+6NVdp+Te27jQxjxnszW9ODjEKbWxWujj83rP5S0GQxCSufgg=="], "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="], + "knip": ["knip@6.35.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.148.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g=="], "koa-compose": ["koa-compose@4.1.0", "", {}, "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="], - "kysely": ["kysely@0.29.4", "", {}, "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA=="], - - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], - "libphonenumber-js": ["libphonenumber-js@1.13.10", "", {}, "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw=="], + "libphonenumber-js": ["libphonenumber-js@1.13.12", "", {}, "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -2142,8 +2069,6 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -2154,13 +2079,13 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "ltx": ["ltx@3.1.2", "", {}, "sha512-tFSKojN92FqNK6eRTmKK/ROUTUYVWKAxgohz523TPhF1G3nR3DXQS/I7/705rEPrDSloKDgMdRlh0qgMFQoVYw=="], - "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], + "lucide-react": ["lucide-react@1.43.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2208,8 +2133,6 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], - "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -2270,7 +2193,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "microsandbox": ["microsandbox@0.6.8", "", { "optionalDependencies": { "@superradcompany/microsandbox-darwin-arm64": "0.6.8", "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.8", "@superradcompany/microsandbox-linux-x64-gnu": "0.6.8", "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.8", "@superradcompany/microsandbox-win32-x64-msvc": "0.6.8" }, "bin": { "microsandbox": "bin/microsandbox.cjs", "msb": "bin/microsandbox.cjs" } }, "sha512-N9sqwUFjeRisBJPyu3uV0f+BFsWQmrxogGoZw0jXuLY9+ey0cgPTzQBLhjKyCbe6rq8joGtefvyS3J4vEW7DBQ=="], + "microsandbox": ["microsandbox@0.6.17", "", { "optionalDependencies": { "@superradcompany/microsandbox-darwin-arm64": "0.6.17", "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.17", "@superradcompany/microsandbox-linux-x64-gnu": "0.6.17", "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.17", "@superradcompany/microsandbox-win32-x64-msvc": "0.6.17" }, "bin": { "microsandbox": "bin/microsandbox.cjs", "msb": "bin/microsandbox.cjs" } }, "sha512-1JpOtboSgnv2qGzwIRF2GwiMH+79RnY/I6QNriA9WCqsmoSWh5sYoeRNpAPnuUhxm9GYztWNvFST+aZ3+p8iYg=="], "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], @@ -2292,11 +2215,11 @@ "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], - "motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], + "motion": ["motion@13.2.0", "", { "dependencies": { "framer-motion": "^13.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ=="], - "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], + "motion-dom": ["motion-dom@13.2.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ=="], - "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "motion-utils": ["motion-utils@13.0.0", "", {}, "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2306,27 +2229,27 @@ "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], - "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - "nanostores": ["nanostores@1.4.2", "", {}, "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g=="], + "nanostores": ["nanostores@1.5.3", "", {}, "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "nestjs-trpc": ["nestjs-trpc@2.13.0", "", { "dependencies": { "lodash": "^4.17.21", "tslib": "^2.5.0" }, "peerDependencies": { "@nestjs/common": "^9.3.8 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.3.8 || ^10.0.0 || ^11.0.0", "@trpc/server": "^11.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0", "rxjs": "7.8.1", "zod": "^3.14.0 || ^4.0.0" }, "bin": { "nestjs-trpc": "bin/nestjs-trpc.js" } }, "sha512-kip+4dI6vnoe/wDDtCz9ITu9WJ80dHSnSQHJR5B0WNf5FIqB9Haz69msXwV/QI8GpHsBaU5Vs9nJC1dGJkroQw=="], - "next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="], + "next": ["next@16.3.4", "", { "dependencies": { "@next/env": "16.3.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.4", "@next/swc-darwin-x64": "16.3.4", "@next/swc-linux-arm64-gnu": "16.3.4", "@next/swc-linux-arm64-musl": "16.3.4", "@next/swc-linux-x64-gnu": "16.3.4", "@next/swc-linux-x64-musl": "16.3.4", "@next/swc-win32-arm64-msvc": "16.3.4", "@next/swc-win32-x64-msvc": "16.3.4", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - "nf3": ["nf3@0.3.23", "", {}, "sha512-RWVLAWozmVD3AaDmaU3qMGB3v+yNlH5d9qqStI4e/WLlNQVnJ4YErGDbYCIrGFyrHdbF6I6Baf0Ae6c7tFYmSg=="], + "nf3": ["nf3@0.3.24", "", {}, "sha512-HxLK4bo+5jNsEETZp4w3tJblHOA9MCBY14IN9nZJJV8JDxt9yNIYxuBuLMjTAR5GFa3HL61+8VQDUrXv3/C8fw=="], - "nitro": ["nitro@3.0.260610-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.6", "db0": "^0.3.4", "env-runner": "^0.1.12", "h3": "2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.5", "ofetch": "2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.1.0", "srvx": "^0.11.16", "unenv": "2.0.0-rc.24", "unstorage": "2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.3.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.61.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q=="], + "nitro": ["nitro@3.0.260903-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.12", "db0": "^0.4.1", "env-runner": "^0.2.1", "h3": "^2.0.1-rc.31", "hookable": "^6.1.1", "nf3": "^0.3.24", "ocache": "^0.3.0", "rolldown": "^1.2.7", "rou3": "^0.9.2", "srvx": "^1.0.3", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.10" }, "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-54gANPi62O8rfMvepiJUVuIzEIinYfpeHbebywlLxvVTgIYXDwSvpaU9Id+0sJOBjBx0wC1/CVYXJkH7SY+l0g=="], - "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], + "node-abi": ["node-abi@3.96.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg=="], - "node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="], + "node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], @@ -2334,13 +2257,13 @@ "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], - "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - "nuqs": ["nuqs@2.9.4", "", { "dependencies": { "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7 || ^8", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-lsz3NyCOKmuNAyW052i9RWqcTntoYb2Qm6FxSWnkTDwOJnGS6fzpXDAp0VcwTevw3xgnWebYpDr9rm6+o4DHbw=="], + "nuqs": ["nuqs@2.10.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7 || ^8", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-7lPZrPJVOsD0VvfQSKtodYBBPGPLsLzkFU8ueYIap9yMMJvSw6UC12p8luA5NW7aKe7AbPHKxcxb3OluH6iAJA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -2348,11 +2271,9 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], - "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], + "ocache": ["ocache@0.3.0", "", {}, "sha512-RS/9P0zeBb0gDJmadGERLH8lZNXK7xbufTDhclkXGvFGTsj0G4M5/cLk+mizcERH29mLXNnocfB5wjcK70wGJg=="], - "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], - - "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -2364,7 +2285,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "open": ["open@11.0.2", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q=="], "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], @@ -2372,11 +2293,11 @@ "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], - "oxc-parser": ["oxc-parser@0.143.0", "", { "dependencies": { "@oxc-project/types": "^0.143.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.143.0", "@oxc-parser/binding-android-arm64": "0.143.0", "@oxc-parser/binding-darwin-arm64": "0.143.0", "@oxc-parser/binding-darwin-x64": "0.143.0", "@oxc-parser/binding-freebsd-x64": "0.143.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", "@oxc-parser/binding-linux-arm64-musl": "0.143.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-musl": "0.143.0", "@oxc-parser/binding-openharmony-arm64": "0.143.0", "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA=="], + "oxc-parser": ["oxc-parser@0.148.0", "", { "dependencies": { "@oxc-project/types": "^0.148.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.148.0", "@oxc-parser/binding-android-arm64": "0.148.0", "@oxc-parser/binding-darwin-arm64": "0.148.0", "@oxc-parser/binding-darwin-x64": "0.148.0", "@oxc-parser/binding-freebsd-x64": "0.148.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.148.0", "@oxc-parser/binding-linux-arm64-gnu": "0.148.0", "@oxc-parser/binding-linux-arm64-musl": "0.148.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-musl": "0.148.0", "@oxc-parser/binding-linux-s390x-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-musl": "0.148.0", "@oxc-parser/binding-openharmony-arm64": "0.148.0", "@oxc-parser/binding-win32-arm64-msvc": "0.148.0", "@oxc-parser/binding-win32-ia32-msvc": "0.148.0", "@oxc-parser/binding-win32-x64-msvc": "0.148.0" } }, "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg=="], "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], - "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], + "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -2386,7 +2307,7 @@ "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], - "papaparse": ["papaparse@5.5.4", "", {}, "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ=="], + "papaparse": ["papaparse@5.7.0", "", {}, "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -2402,8 +2323,6 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], @@ -2416,7 +2335,7 @@ "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], - "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], @@ -2426,7 +2345,7 @@ "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], - "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], @@ -2434,21 +2353,17 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], - - "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - - "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], @@ -2460,11 +2375,11 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "posthog-js": ["posthog-js@1.413.2", "", { "dependencies": { "@posthog/browser-common": "^0.4.0", "@posthog/core": "^1.46.8", "@posthog/types": "^1.402.1", "core-js": "^3.49.0", "dompurify": "^3.4.12", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0", "web-vitals-soft-navs": "npm:web-vitals@6.0.0" } }, "sha512-LddTVMdYFd0jB+p5h3ZHcho9mWNjgKn9QySCQt74iISKrmC3b7KzW2Q7W7aOF6m/NwK7NubIHqLujCER64gSRw=="], + "posthog-js": ["posthog-js@1.428.8", "", { "dependencies": { "@posthog/browser-common": "^0.8.2", "@posthog/core": "^1.51.1", "@posthog/types": "^1.409.2", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^6.2.1", "web-vitals-soft-navs": "npm:web-vitals@6.2.1" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-ZQoJGcZ3TNirjv1DZWm+eQE4eVU5dFWN+aK7DSHoj1vBXhMrQ+mwC8862Yw7FNl9TLyUiIMYPE2m47DQXb9jDg=="], - "posthog-node": ["posthog-node@5.48.0", "", { "dependencies": { "@posthog/core": "^1.46.8" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-YIH82XV24aa1nnVph1ndiW/vBN2QDIKlrHNEJcmAYutMGeoGC4WeEefg7O5aD3dDcO5dzociy8sVwXq4tNDYsQ=="], + "posthog-node": ["posthog-node@5.51.8", "", { "dependencies": { "@posthog/core": "^1.51.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-TCqgAYbACwb/D3VI2S861ugD+FavvowQfak8eMwS/wATS+g0Wj+YpsABrBEKi+ZP3Y5SJDcoc7bIH49rlGo1Ww=="], - "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], @@ -2476,9 +2391,9 @@ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], - "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], - "prisma": ["prisma@7.9.1", "", { "dependencies": { "@prisma/config": "7.9.1", "@prisma/dev": "0.24.17", "@prisma/engines": "7.9.1", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg=="], + "prisma": ["prisma@7.10.0", "", { "dependencies": { "@prisma/config": "7.10.0", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.0", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -2496,7 +2411,7 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -2516,7 +2431,7 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], + "rc9": ["rc9@3.1.0", "", { "dependencies": { "defu": "^6.1.7", "destr": "^2.0.5" } }, "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q=="], "re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="], @@ -2524,7 +2439,7 @@ "react-day-picker": ["react-day-picker@10.0.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], @@ -2538,11 +2453,11 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], - "recast": ["recast@0.23.19", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w=="], + "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], - "recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], + "recharts": ["recharts@3.10.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA=="], "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], @@ -2572,13 +2487,11 @@ "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], - "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "remend": ["remend@1.3.1", "", {}, "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -2594,20 +2507,16 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="], + "rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -2642,9 +2551,9 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.16.1", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-XLFzfNNIUPlUlyheFEzj0H4Vnhi9nI0nl3Nfgg8HYXW1FkUVhVT1X+mgmOUW8aWL5SeG0A+yJIV5fm3Hr9MVkQ=="], + "shadcn": ["shadcn@4.21.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "cn": "^0.2.4", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "socks": "^2.8.8", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-UU2mFNusW8C5rvadKdH69vERYZqUlOOlXBcf0MYhYLdTGP6DPti7X4qovCu+RTfCqsAgq/T+YfE0Vnttxh9aiw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2652,7 +2561,7 @@ "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], - "shiki": ["shiki@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/engine-javascript": "4.4.2", "@shikijs/engine-oniguruma": "4.4.2", "@shikijs/langs": "4.4.2", "@shikijs/themes": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA=="], + "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], @@ -2670,9 +2579,13 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "smol-toml": ["smol-toml@1.7.1", "", {}, "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], - "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2684,11 +2597,11 @@ "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], + "sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="], "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], - "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "srvx": ["srvx@1.0.4", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-eZmYaxUfZSo7/8m8UdsHRJNmTLSCTPPom9d7a60vMmuVeZvwhbvflRR+00/CxTCjMOFa5+H8tgBeNDVZ+w7AAQ=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -2696,7 +2609,7 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="], + "streamdown": ["streamdown@2.6.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.1", "tailwind-merge": "^3.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-nQZVUn4GvB2R5SAlDNph20iKZeW+RM4gv6G1H3ACypEnmQf8PgTHZ/1Ta2OACRwQ2coK7aW5AeIAa7Ls5zk+RA=="], "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], @@ -2716,7 +2629,7 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], @@ -2726,17 +2639,15 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], - "superagent": ["superagent@10.3.0", "", { "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "swagger-ui-dist": ["swagger-ui-dist@5.32.13", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw=="], - "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], + "systeminformation": ["systeminformation@5.33.10", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-/NXbMVASt2UbSVgmWto4aBTIAeuYY7zOELpZybmNxqpsrbo5uYHpuEPf5nkyrPng0uG5YOb+Yv6s0FyKY4mDQg=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2752,13 +2663,11 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], + "tldts": ["tldts@7.4.12", "", { "dependencies": { "tldts-core": "^7.4.12" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA=="], - "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], + "tldts-core": ["tldts-core@7.4.12", "", {}, "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2774,15 +2683,13 @@ "trpc-to-openapi": ["trpc-to-openapi@3.3.0", "", { "dependencies": { "co-body": "6.2.0", "h3": "^1.15.5", "openapi3-ts": "4.4.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "peerDependencies": { "@trpc/server": "^11.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^5.4.4" } }, "sha512-PNMXq0K37Ott5ysNACYN1XLaM5xZGNJrx1QiQVTlk4hmgJomoyiAKhVrpwrK02JW3joSQmHYBOZ7pK6Hdioekw=="], - "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], - "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -2796,7 +2703,7 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -2806,13 +2713,13 @@ "ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="], - "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], + "unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -2834,9 +2741,9 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], + "unstorage": ["unstorage@2.0.0-alpha.10", "", {}, "sha512-6h1veZp8gnp4dGllf0tDijoXxDYymJu251IpKKkrSVH+QHL6tXFbwG85KM+CBHSgpkkgtPuIiZ9oLCLP5+1Evw=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -2846,8 +2753,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], @@ -2870,17 +2775,17 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + "web-vitals": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="], - "web-vitals-soft-navs": ["web-vitals@6.0.0", "", {}, "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA=="], + "web-vitals-soft-navs": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], @@ -2904,9 +2809,9 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], @@ -2914,7 +2819,7 @@ "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], @@ -2922,17 +2827,11 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@agent-xmpp/core/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@agent-xmpp/gateway/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@agent-xmpp/protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@ai-sdk/provider-utils/undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], @@ -2942,43 +2841,17 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@crm/auth/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/db/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/env/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/kaneo-domain/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/telemetry/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/ui/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@crm/ui/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "@crm/validation/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "@dotenvx/dotenvx/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "@dotenvx/dotenvx/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@dotenvx/dotenvx/undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], - - "@mermaid-js/parser/@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], - - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "@nestjs/config/dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], + "@nestjs/config/es-toolkit": ["es-toolkit@1.51.0", "", {}, "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw=="], "@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -2986,159 +2859,17 @@ "@prisma/config/c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], - "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="], - "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="], "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], - "@prisma/streams-local/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@prisma/streams-local/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "@prisma/studio-core/@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], - "@radix-ui/react-accordion/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-checkbox/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-collapsible/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-form/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-hover-card/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-menubar/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-navigation-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-one-time-password-field/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-password-toggle-field/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-radio-group/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-select/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-slider/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-switch/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-toast/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@reduxjs/toolkit/immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], - - "@reduxjs/toolkit/reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], - - "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], - - "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="], + "@reduxjs/toolkit/reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -3152,86 +2883,46 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/body-parser/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/connect/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/express-serve-static-core/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/pg/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/send/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/serve-static/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "@types/superagent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@vercel/oidc/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], - "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], - - "agent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@visx/vendor/@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], - "agent/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@visx/vendor/@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@visx/vendor/@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], - "api/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@visx/vendor/@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], - "api/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@visx/vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], - "app/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@visx/vendor/@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], - "app/next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="], - - "app/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], - "app/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@visx/vendor/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "bun-types/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "co-body/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "co-body/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], - "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "cosmiconfig/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "cosmiconfig/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], - - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - - "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], - - "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], - "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "eve/undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], @@ -3242,23 +2933,21 @@ "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], - "just-bash/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "just-bash/undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "nestjs-trpc/rxjs": ["rxjs@7.8.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], - "nitro/h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="], + "nitro/h3": ["h3@2.0.1-rc.31", "", { "dependencies": { "rou3": "^0.9.2", "srvx": "^1.0.2" }, "peerDependencies": { "crossws": "^0.4.12", "ocache": ">=0.3.0" }, "optionalPeers": ["crossws", "ocache"], "bin": { "h3": "./bin/h3.mjs" } }, "sha512-AG7qZzF99a0BSYoXT3evJgIhwSIUKow7R+hwkLRZS06WJ9nbSb7QXUDgs1ByBjp6svTvl++N/GKA7I9YPz9cQQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -3278,54 +2967,32 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "radix-ui/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "rolldown/@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="], - "samlify/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "samlify/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], "seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "shadcn/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "shadcn/undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], - "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "@crm/auth/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/db/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/env/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/kaneo-domain/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/telemetry/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/ui/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@crm/validation/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3344,29 +3011,13 @@ "@dotenvx/dotenvx/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - - "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@prisma/config/c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - - "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], - - "@types/body-parser/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@types/connect/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@types/pg/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@types/send/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@types/serve-static/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "@types/superagent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -3380,37 +3031,9 @@ "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "agent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "api/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "app/next/@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], - - "app/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw=="], - - "app/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw=="], - - "app/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg=="], - - "app/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q=="], - - "app/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw=="], - - "app/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g=="], - - "app/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw=="], - - "app/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA=="], - - "app/next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "@visx/vendor/@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - "app/next/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@visx/vendor/@types/d3-scale/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], "co-body/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -3418,16 +3041,8 @@ "co-body/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "concurrently/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], - - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -3438,70 +3053,16 @@ "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "nitro/h3/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], - - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "app/next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], - - "app/next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], - - "app/next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], - - "app/next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], - - "app/next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], - - "app/next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], - - "app/next/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], - - "app/next/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], - - "app/next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], - - "app/next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], - - "app/next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], - - "app/next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], - - "app/next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], - - "app/next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], - - "app/next/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], - - "app/next/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], - - "app/next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], - - "app/next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], - - "app/next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], - - "app/next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], - - "app/next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], - - "app/next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], - - "app/next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], - "co-body/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], } } diff --git a/package.json b/package.json index 77515e8c4..526743aee 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,14 @@ "db:test": "bun run --filter=@crm/db db:test" }, "devDependencies": { - "@biomejs/biome": "^2.4.10", - "@oxlint/plugins": "1.78.0", + "@biomejs/biome": "^2.5.12", + "@oxlint/plugins": "1.82.0", "@paralleldrive/cuid2": "^3.3.0", "drizzle-orm": "^0.45.2", - "knip": "6.32.2", - "oxlint": "1.78.0", + "knip": "6.35.1", + "oxlint": "1.82.0", "turbo": "^2.10.12", - "typescript": "5.9.2" + "typescript": "7.0.2" }, "engines": { "node": ">=22" @@ -50,6 +50,6 @@ "packages/agent-xmpp/*" ], "patchedDependencies": { - "@better-auth/oauth-provider@1.7.2": "patches/@better-auth%2Foauth-provider@1.7.2.patch" + "@better-auth/oauth-provider@1.7.3": "patches/@better-auth%2Foauth-provider@1.7.3.patch" } } diff --git a/packages/agent-xmpp/core/package.json b/packages/agent-xmpp/core/package.json index 189b578c4..58fe05f49 100644 --- a/packages/agent-xmpp/core/package.json +++ b/packages/agent-xmpp/core/package.json @@ -18,11 +18,11 @@ }, "dependencies": { "@agent-xmpp/protocol": "workspace:*", - "ajv": "8.17.1" + "ajv": "8.20.0" }, "devDependencies": { - "@types/node": "^22.10.0", - "tsx": "4.23.12", - "typescript": "^5.7.0" + "@types/node": "^26.5.0", + "tsx": "4.23.13", + "typescript": "^7.0.2" } } diff --git a/packages/agent-xmpp/core/src/schema.ts b/packages/agent-xmpp/core/src/schema.ts index 7be084e80..22cbe0467 100644 --- a/packages/agent-xmpp/core/src/schema.ts +++ b/packages/agent-xmpp/core/src/schema.ts @@ -135,7 +135,8 @@ export function assertJsonValueBounded( const visited = new WeakSet(); let members = 0; while (pending.length > 0) { - const current = pending.pop()!; + const current = pending.pop(); + if (!current) break; if (current.depth > DEFAULT_JSON_LIMITS.maxDepth) { throw new SchemaResourceLimitError( `${label} exceeds JSON depth ${DEFAULT_JSON_LIMITS.maxDepth}`, @@ -249,7 +250,7 @@ function isPublicProfileHttpsUri(value: string): boolean { ) { return false; } - const authority = value.slice("https://".length).split(/[/?]/, 1)[0]!; + const authority = value.slice("https://".length).split(/[/?]/, 1)[0] ?? ""; if (authority.endsWith(":")) return false; const uri = new URL(value); return ( @@ -349,8 +350,8 @@ export async function closeSchemaWorkers(): Promise { } while (validationQueue.length) validationQueue - .shift()! - .reject(new Error("schema validator worker closed")); + .shift() + ?.reject(new Error("schema validator worker closed")); } function ensureSchemaWorkers(): void { @@ -394,7 +395,12 @@ function createSchemaWorker(consecutiveFailures: number): SchemaWorkerSlot { worker.on("message", (response: WorkerResponse) => settleWorker(slot, response), ); - worker.on("error", (error) => replaceWorker(slot, error)); + worker.on("error", (error) => + replaceWorker( + slot, + error instanceof Error ? error : new Error(String(error)), + ), + ); worker.on("exit", (code) => { if (schemaWorkers.includes(slot) && code !== 0) { replaceWorker( @@ -460,7 +466,7 @@ function replaceWorker(slot: SchemaWorkerSlot, error: Error): void { } function rejectValidationQueue(error: Error): void { - while (validationQueue.length) validationQueue.shift()!.reject(error); + while (validationQueue.length) validationQueue.shift()?.reject(error); } export function preflightSchema(schema: JsonSchema, label: string): void { @@ -481,7 +487,9 @@ function assertSchemaComplexity(schema: JsonSchema, label: string): void { ]; let nodes = 0; while (pending.length > 0) { - const { value, depth } = pending.pop()!; + const current = pending.pop(); + if (!current) break; + const { value, depth } = current; if (++nodes > SCHEMA_MAX_NODES) { throw new SchemaResourceLimitError( `${label} exceeds ${SCHEMA_MAX_NODES} nodes`, diff --git a/packages/agent-xmpp/core/tsconfig.json b/packages/agent-xmpp/core/tsconfig.json index cb2ac4fc1..292130012 100644 --- a/packages/agent-xmpp/core/tsconfig.json +++ b/packages/agent-xmpp/core/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "outDir": "dist", "rootDir": "src", "strict": true, @@ -11,8 +13,17 @@ "skipLibCheck": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "types": [ + "node" + ] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts" + ] } diff --git a/packages/agent-xmpp/gateway/package.json b/packages/agent-xmpp/gateway/package.json index 303e3ca15..75cb6c6fe 100644 --- a/packages/agent-xmpp/gateway/package.json +++ b/packages/agent-xmpp/gateway/package.json @@ -24,7 +24,7 @@ "ulid": "3.0.2" }, "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0" + "@types/node": "^26.5.0", + "typescript": "^7.0.2" } } diff --git a/packages/agent-xmpp/gateway/tsconfig.json b/packages/agent-xmpp/gateway/tsconfig.json index f1a44108c..d457dfe30 100644 --- a/packages/agent-xmpp/gateway/tsconfig.json +++ b/packages/agent-xmpp/gateway/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -11,8 +13,17 @@ "skipLibCheck": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "types": [ + "node" + ] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts" + ] } diff --git a/packages/agent-xmpp/protocol/package.json b/packages/agent-xmpp/protocol/package.json index cdac241ea..71099d4bb 100644 --- a/packages/agent-xmpp/protocol/package.json +++ b/packages/agent-xmpp/protocol/package.json @@ -19,11 +19,11 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0" + "@types/node": "^26.5.0", + "typescript": "^7.0.2" }, "dependencies": { - "idn-hostname": "15.1.10", + "idn-hostname": "17.0.3", "precis-wasm": "0.1.0" } } diff --git a/packages/agent-xmpp/protocol/tsconfig.json b/packages/agent-xmpp/protocol/tsconfig.json index f1a44108c..d457dfe30 100644 --- a/packages/agent-xmpp/protocol/tsconfig.json +++ b/packages/agent-xmpp/protocol/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -11,8 +13,17 @@ "skipLibCheck": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "types": [ + "node" + ] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts" + ] } diff --git a/packages/auth/package.json b/packages/auth/package.json index cd1605f15..a1950ebe8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -20,17 +20,18 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { - "@better-auth/api-key": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/sso": "1.7.2", + "@better-auth/api-key": "1.7.3", + "@better-auth/core": "1.7.3", + "@better-auth/oauth-provider": "1.7.3", + "@better-auth/sso": "1.7.3", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/validation": "workspace:*", - "better-auth": "1.7.2", - "zod": "^4.4.3" + "better-auth": "1.7.3", + "zod": "^4.5.4" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.8" }, "peerDependenciesMeta": { "react": { @@ -39,10 +40,10 @@ }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", - "auth": "1.7.2", + "auth": "1.7.3", "react": "^19.2.8", - "typescript": "5.9.2" + "typescript": "7.0.2" } } diff --git a/packages/db/package.json b/packages/db/package.json index 4935913b1..984340dce 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -45,15 +45,15 @@ }, "dependencies": { "@crm/env": "workspace:*", - "@prisma/adapter-pg": "^7.9.1", - "@prisma/client": "^7.9.1", - "@vercel/blob": "^2.6.1" + "@prisma/adapter-pg": "^7.10.0", + "@prisma/client": "^7.10.0", + "@vercel/blob": "^2.8.0" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "pg": "^8.22.0", - "prisma": "^7.9.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "pg": "^8.23.0", + "prisma": "^7.10.0", + "typescript": "7.0.2" } } diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 2959bf05b..3287b7be1 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -12,6 +12,13 @@ export const DEFAULT_AGENT_MODEL = { contextWindowTokens: 1_000_000, } as const; +export function defaultAgentModelResult() { + return { + model: DEFAULT_AGENT_MODEL.id, + modelContextWindowTokens: DEFAULT_AGENT_MODEL.contextWindowTokens, + }; +} + export interface AgentModelSetting { id: string; contextWindowTokens: number; diff --git a/packages/env/package.json b/packages/env/package.json index 9bf9c77f0..9347e63de 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/packages/kaneo-domain/package.json b/packages/kaneo-domain/package.json index 7af09fd11..977b56079 100644 --- a/packages/kaneo-domain/package.json +++ b/packages/kaneo-domain/package.json @@ -17,8 +17,8 @@ "devDependencies": { "@crm/typescript-config": "workspace:*", "@paralleldrive/cuid2": "^3.3.0", - "@types/node": "^24.10.1", + "@types/node": "^26.5.0", "drizzle-orm": "^0.45.2", - "typescript": "5.9.2" + "typescript": "7.0.2" } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 149906cf8..b1f60809f 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -18,11 +18,11 @@ "dependencies": { "@crm/db": "workspace:*", "@crm/env": "workspace:*", - "posthog-node": "^5.48.0" + "posthog-node": "^5.51.8" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 504419a28..5e63847a6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,45 +17,45 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { - "@carbon/icons-react": "^11.82.0", + "@carbon/icons-react": "^11.88.0", "@crm/db": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@shadcn/react": "^0.3.0", + "@shadcn/react": "^0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "lucide-react": "^1.28.0", - "motion": "^12.42.2", + "lucide-react": "^1.43.0", + "motion": "^13.2.0", "next-themes": "^0.4.6", - "nuqs": "^2.8.9", - "radix-ui": "^1.6.0", + "nuqs": "^2.10.1", + "radix-ui": "^1.6.7", "react-day-picker": "^10.0.1", - "recharts": "3.8.0", - "sonner": "^2.0.7", - "streamdown": "^2.5.0", + "recharts": "3.10.1", + "sonner": "^2.0.8", + "streamdown": "^2.6.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "peerDependencies": { - "next": "^16.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "next": "^16.3.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@types/node": "^24.10.1", + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19", - "next": "16.2.12", - "react": "19.2.4", - "react-dom": "19.2.4", - "shadcn": "^4.16.1", - "tailwindcss": "^4", - "typescript": "5.9.2" + "next": "16.3.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "shadcn": "^4.21.0", + "tailwindcss": "^4.3.3", + "typescript": "7.0.2" } } diff --git a/packages/validation/package.json b/packages/validation/package.json index d6fb2c36a..b2e9c861f 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -24,11 +24,11 @@ }, "dependencies": { "@crm/db": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@crm/typescript-config": "workspace:*", - "@types/node": "^24.10.1", - "typescript": "5.9.2" + "@types/node": "^26.5.0", + "typescript": "7.0.2" } } diff --git a/patches/@better-auth%2Foauth-provider@1.7.2.patch b/patches/@better-auth%2Foauth-provider@1.7.3.patch similarity index 76% rename from patches/@better-auth%2Foauth-provider@1.7.2.patch rename to patches/@better-auth%2Foauth-provider@1.7.3.patch index cf432aec4..2a1ac4a20 100644 --- a/patches/@better-auth%2Foauth-provider@1.7.2.patch +++ b/patches/@better-auth%2Foauth-provider@1.7.3.patch @@ -1,7 +1,7 @@ -diff --git a/dist/authorize-BmTe2VYG.mjs b/dist/authorize-BmTe2VYG.mjs -index 42c943dd45aa3d297a14edcc056a14e7ddd54680..df37111109156da9dc5b0d1d4dfb4587577f4eed 100644 ---- a/dist/authorize-BmTe2VYG.mjs -+++ b/dist/authorize-BmTe2VYG.mjs +diff --git a/dist/authorize-9whjxVLJ.mjs b/dist/authorize-9whjxVLJ.mjs +index 6c1d5c51c..47c085e76 100644 +--- a/dist/authorize-9whjxVLJ.mjs ++++ b/dist/authorize-9whjxVLJ.mjs @@ -4404,7 +4404,12 @@ const oauthProvider = (options) => { onRequest: handleIssuerMetadataRequest, init: async (ctx) => { From 44188b90afe835ea7739cc75cd84cccebd9020d2 Mon Sep 17 00:00:00 2001 From: Roman Shterenzon Date: Thu, 10 Sep 2026 00:28:49 +0300 Subject: [PATCH 16/27] feat: add native multi-tenancy (#3) * feat: add native multi-tenancy * fix: tenant-scope customer assets * update ci.yml * fix: declare @better-auth/core as direct dependency in @crm/auth slack-grant.ts imports @better-auth/core/context but the package was never listed as a dependency, relying on phantom hoisting. Multiple @better-auth/core versions in the tree made resolution nondeterministic and CI hoisted a version without the symlink, failing check-types with TS2307. Co-Authored-By: Claude Sonnet 5 * fix: sort test imports to satisfy biome lint Co-Authored-By: Claude Sonnet 5 * fix: sort imports and format per biome across rebase diff Co-Authored-By: Claude Sonnet 5 * remove workflows * fix lint * fix: satisfy anti-slop lint boundary-parsing rules Fixes the lint:slop CI failure. Replaces inline Record and typeof-based narrowing with named types and instanceof/Zod parsing at real I/O boundaries. Moves api-key-principal and adds active-organization-claim parsers into packages/validation, matching the repo's parse-at-boundary convention. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * fix: give real-worker schema test more headroom on CI The "starts source workers with the declared loader" test spins up a real worker_threads worker (tsx-loaded), unlike the mocked worker in schema-worker-lineage.test.ts. Its default 500ms budget is tuned for production safety, not CI cold-start latency, so a loaded CI runner timed it out at 502ms. Passes an explicit 5s timeout for this one integration-style test only; production default is unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * fix: raise both bun's per-test timeout and the worker budget Previous fix only bumped validateJsonBounded's internal timeoutMs to 5000ms, missing that bun:test's own default per-test timeout is also 5000ms. Both fired at once, so bun killed the test before the internal promise could even reject on its own terms. Raise the test timeout to 20s and the internal budget to 15s, giving real headroom on a resource-constrained CI runner. Verified against a Linux bun 1.4.2 container. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * fix: stop spawning schema workers with a Node tsx loader under bun Root cause of the hanging schema.test.ts: createSchemaWorker passed execArgv: ["--import", "tsx"] when spawning the source-mode worker. Everything in this repo runs under bun, not Node, and bun already runs .ts worker files natively without any loader hook. Verified with a raw Worker spawn that bun runs the .ts file fine with no execArgv at all. On Linux x64 (the GitHub Actions runner) that unnecessary flag combination silently hung the worker forever - neither 5s nor 15s budgets helped because the worker never came online, not because it was slow. Confirmed against a Linux bun 1.4.2 container before and after. Removes the now-dead tsx devDependency and the execArgv option, and reverts the test back to the default timeout now that the real fix lands. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * diag: surface the real worker failure reason in CI logs Two prior fixes (longer timeouts, removing execArgv) did not resolve this test's failure on the GitHub Actions Linux x64 runner, and it is not reproducible on macOS ARM64 or a native Linux ARM64 bun container (x64 emulation via QEMU crashes bun itself with a memory-exhaustion assertion, unrelated to the real bug, so it gives no signal). Every prior CI failure only showed "promise rejected" with no reason. Logs worker error/exit events to stderr in schema.ts (previously silent) and logs the actual rejection in the test before failing, so the next CI run tells us what is actually happening instead of us guessing again. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * diag: trace schema worker startup step by step Prior diagnostic showed no error/exit event ever fires for the real schema.test.ts worker on CI - it just silently times out, meaning the worker either never starts, or starts and hangs somewhere before registering the message handler, with a code-0 exit event we were not logging. Switches schema-worker.ts to dynamic imports with a log after each step (isMainThread/parentPort, ajv import, protocol import, Ajv construction, handler registration, message receipt), adds uncaughtException/unhandledRejection handlers, and logs the worker's "online" event and every exit code (not just nonzero) on the parent side. Confirmed locally the full sequence logs and the test passes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX * fix: stop leaking a mock.module Worker across test files Root cause, confirmed with a minimal local repro: bun 1.4.2's mock.restore() does not actually undo mock.module("node:worker_threads", ...) for other test files sharing the same bun test process. Once schema-worker-lineage.test.ts mocked node:worker_threads to return a FakeWorker, schema.test.ts's plain "./schema.js" import of the real Worker silently resolved to that FakeWorker instead - a class that never emits online/message/error, so every validateJsonBounded() call just sat there until our own internal timeout fired. That is why every prior fix (longer timeouts, dropping the tsx execArgv) failed identically: the worker was never real, so no amount of waiting or loader changes could help. It only reproduced on the GitHub Actions runner because import/module-cache ordering between the two test files happened to differ there; a standalone repro (two files, one mocking node:worker_threads and restoring, the other freshly importing it after) reproduces the same leak locally. Fixes this at the root instead of routing around it: schema.ts now constructs its worker through an overridable factory (setWorkerFactory), so schema-worker-lineage.test.ts can inject its FakeWorker with a plain reassignment scoped to its own "./schema.ts?worker-lineage" module instance, never touching bun's global module registry. Removes mock.module/mock.restore entirely from that test. Reverts the temporary diagnostic logging from the last two commits now that the cause is known. Verified 5x locally and 3x in a Linux bun 1.4.2 container. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013nRJf8FVnwdjmSwgBt7tuX --------- Co-authored-by: Claude Sonnet 5 --- .env.example | 9 +- .github/workflows/auto-pr.yml | 62 -- .github/workflows/ci.yml | 15 +- .github/workflows/pr-base.yml | 52 -- .github/workflows/pr-title.yml | 132 ---- .github/workflows/release.yml | 126 --- apps/agent/agent/channels/crm.ts | 25 +- apps/agent/agent/hooks/audit.ts | 57 +- apps/agent/agent/hooks/telemetry.ts | 23 +- apps/agent/agent/instructions/task.ts | 34 +- apps/agent/agent/lib/accounts.ts | 3 +- apps/agent/agent/lib/blank-facts.ts | 110 ++- apps/agent/agent/lib/brand.ts | 16 +- apps/agent/agent/lib/builder-input.ts | 12 +- apps/agent/agent/lib/builder-runtime.ts | 283 ++++--- apps/agent/agent/lib/capabilities.ts | 4 +- apps/agent/agent/lib/conversation-title.ts | 2 +- apps/agent/agent/lib/crm.ts | 3 +- apps/agent/agent/lib/custom-agent-dispatch.ts | 429 ++++++---- apps/agent/agent/lib/dispatch-config.ts | 1 + apps/agent/agent/lib/dispatch.ts | 52 +- apps/agent/agent/lib/enrichment.ts | 11 +- apps/agent/agent/lib/facts.ts | 313 ++++---- apps/agent/agent/lib/fields.ts | 244 +++--- apps/agent/agent/lib/lookup.ts | 3 +- apps/agent/agent/lib/model.ts | 4 +- apps/agent/agent/lib/portrait.ts | 8 +- apps/agent/agent/lib/preamble.ts | 2 +- apps/agent/agent/lib/run-preflight.ts | 2 +- apps/agent/agent/lib/run-runtime.ts | 296 +++---- apps/agent/agent/lib/run-state.ts | 3 +- apps/agent/agent/lib/session-purpose.ts | 9 + apps/agent/agent/lib/slack-connection.ts | 12 +- apps/agent/agent/lib/slack-join-task.ts | 3 +- apps/agent/agent/lib/slack-membership.ts | 19 +- apps/agent/agent/lib/slack-people.ts | 107 +-- apps/agent/agent/lib/stale-tasks.ts | 206 +++-- apps/agent/agent/lib/tasks.ts | 271 +++++-- apps/agent/agent/lib/workspace.ts | 4 +- .../agent_builder/tools/inspect_context.ts | 6 +- .../agent_builder/tools/save_agent_draft.ts | 6 +- .../agent_builder/tools/write_agent_file.ts | 6 +- .../agent/subagents/agent_runner/agent.ts | 24 +- apps/agent/agent/tools/archive_field.ts | 5 +- apps/agent/agent/tools/enrich_company.ts | 43 +- apps/agent/agent/tools/fetch_contact_photo.ts | 27 +- .../agent/agent/tools/find_contact_socials.ts | 50 +- .../agent/tools/get_contact_work_history.ts | 49 +- .../agent/agent/tools/get_linkedin_profile.ts | 59 +- apps/agent/agent/tools/identify_contact.ts | 41 +- apps/agent/agent/tools/list_deals.ts | 5 +- apps/agent/agent/tools/list_fields.ts | 5 +- .../agent/tools/list_outstanding_work.ts | 7 +- apps/agent/agent/tools/manage_fields.ts | 51 +- .../agent/agent/tools/read_company_history.ts | 28 +- apps/agent/agent/tools/read_crm_history.ts | 36 +- apps/agent/agent/tools/read_deal_history.ts | 13 +- apps/agent/agent/tools/record_fact.ts | 41 +- apps/agent/agent/tools/record_job_change.ts | 108 +-- apps/agent/agent/tools/research_company.ts | 141 ++-- apps/agent/agent/tools/research_person.ts | 41 +- .../agent/tools/resolve_linkedin_profile.ts | 66 +- apps/agent/agent/tools/schedule_recheck.ts | 26 +- apps/agent/agent/tools/search_crm.ts | 7 +- apps/agent/agent/tools/set_chat_title.ts | 16 +- apps/agent/agent/tools/set_contact_socials.ts | 136 ++-- apps/agent/agent/tools/set_field_value.ts | 11 +- apps/agent/agent/tools/write_brief.ts | 45 +- .../agent/tools/write_workspace_profile.ts | 76 +- apps/agent/evals/agent-builder.eval.ts | 2 + apps/agent/package.json | 2 +- apps/agent/test/accounts.integration.spec.ts | 36 +- .../test/audit-tenant.integration.spec.ts | 82 ++ .../blank-facts-tenant.integration.spec.ts | 136 ++++ .../test/blank-facts.integration.spec.ts | 17 +- .../builder-input-tenant.integration.spec.ts | 128 +++ ...builder-runtime-tenant.integration.spec.ts | 211 +++++ .../test/builder-runtime.integration.spec.ts | 81 +- .../agent/test/close-task.integration.spec.ts | 26 +- apps/agent/test/custom-agent-runtime.spec.ts | 11 +- apps/agent/test/dispatch-health.spec.ts | 3 +- .../test/dispatch-tenant.integration.spec.ts | 182 +++++ apps/agent/test/drain.spec.ts | 18 +- .../durable-agent-runtime.integration.spec.ts | 72 +- .../agent/test/enrichment.integration.spec.ts | 38 +- apps/agent/test/facts.integration.spec.ts | 28 +- apps/agent/test/fields.integration.spec.ts | 127 +++ .../test/keyless-brand.integration.spec.ts | 59 +- apps/agent/test/lanes.integration.spec.ts | 14 +- apps/agent/test/lookup.integration.spec.ts | 26 +- apps/agent/test/model.integration.spec.ts | 65 +- apps/agent/test/preamble.integration.spec.ts | 61 +- .../test/slack-connection.integration.spec.ts | 75 ++ .../test/slack-membership.integration.spec.ts | 73 +- .../test/slack-people.integration.spec.ts | 202 ++++- .../test/stale-tasks.integration.spec.ts | 197 +++-- apps/agent/test/tasks.integration.spec.ts | 153 +++- apps/api/src/activities/activities.service.ts | 7 +- apps/api/src/agent/agent-access.service.ts | 17 +- .../src/agent/agent-definitions.service.ts | 17 +- apps/api/src/agent/agent-queue.service.ts | 7 +- apps/api/src/agent/agent-runs.service.ts | 199 ++--- apps/api/src/agent/agent-trigger.service.ts | 15 +- apps/api/src/api-keys/api-keys.router.ts | 7 +- apps/api/src/api-keys/api-keys.service.ts | 24 +- apps/api/src/app.module.ts | 1 + apps/api/src/assets/asset-catalog.service.ts | 7 +- apps/api/src/assets/asset-files.service.ts | 3 +- apps/api/src/assets/asset-mutation.service.ts | 4 +- apps/api/src/assets/asset-uploads.service.ts | 3 +- apps/api/src/assets/asset-worker-lease.ts | 12 +- apps/api/src/assets/asset-worker-sweep.ts | 21 +- apps/api/src/assets/asset-worker.service.ts | 79 +- apps/api/src/assets/assets.service.ts | 4 +- .../api/src/auth/request-principal.service.ts | 110 ++- apps/api/src/auth/request-principal.ts | 1 + apps/api/src/backfill/backfill.service.ts | 40 +- apps/api/src/backfill/image-mirror.service.ts | 19 +- apps/api/src/companies/companies.service.ts | 10 +- apps/api/src/companies/favicon.service.ts | 6 +- apps/api/src/contacts/contacts.service.ts | 20 +- .../conversation-sharing.service.ts | 18 +- .../conversations/conversations.service.ts | 168 ++-- apps/api/src/crm/activity-stamp.service.ts | 94 ++- apps/api/src/crm/enrichment-log.service.ts | 13 +- apps/api/src/currency/conversion.service.ts | 51 +- apps/api/src/currency/currency.service.ts | 15 +- apps/api/src/dashboard/dashboard.service.ts | 7 +- apps/api/src/database/database.constants.ts | 2 + apps/api/src/database/database.module.ts | 14 +- apps/api/src/deals/deals.service.ts | 18 +- apps/api/src/enrichment/enrichment.service.ts | 7 +- apps/api/src/fields/fields.service.ts | 40 +- apps/api/src/generated/server.ts | 4 + apps/api/src/google/calendar-sync.service.ts | 6 +- apps/api/src/google/conversation.service.ts | 17 +- apps/api/src/google/gmail-sync.service.ts | 6 +- .../src/google/google-connection.service.ts | 52 +- .../src/logging/request-logger.middleware.ts | 12 +- apps/api/src/mailbox/mailbox-match.service.ts | 21 +- apps/api/src/mailbox/sync-state.service.ts | 23 +- apps/api/src/mailbox/thread-writer.service.ts | 8 +- .../microsoft/microsoft-connection.service.ts | 24 +- .../src/saved-views/saved-views.service.ts | 7 +- apps/api/src/search/search.service.ts | 6 +- apps/api/src/settings/settings.service.ts | 6 +- .../api/src/slack/slack-connection.service.ts | 97 +-- apps/api/src/sso/sso.service.ts | 24 +- apps/api/src/sync/mailbox-sync.service.ts | 5 +- apps/api/src/telemetry/rollup.service.ts | 730 ++++++++++++------ .../src/tracking/tracking-config.service.ts | 76 +- .../src/tracking/tracking-counter.service.ts | 49 +- .../src/tracking/tracking-filing.service.ts | 16 +- .../src/tracking/tracking-ingest.service.ts | 6 +- .../src/tracking/tracking-retention.config.ts | 6 + .../tracking/tracking-retention.service.ts | 110 +++ .../src/tracking/tracking-rollup.service.ts | 40 +- .../tracking/tracking-site-locator.service.ts | 20 + apps/api/src/tracking/tracking.controller.ts | 70 +- apps/api/src/tracking/tracking.module.ts | 4 + apps/api/src/tracking/tracking.service.ts | 138 ++-- apps/api/src/trpc/context.types.ts | 7 + .../src/trpc/middlewares/auth.middleware.ts | 62 +- apps/api/src/trpc/trpc.module.ts | 8 +- apps/api/src/workspace/workspace.router.ts | 25 +- apps/api/src/workspace/workspace.service.ts | 73 +- apps/api/test/agent-delete.spec.ts | 262 ++++--- apps/api/test/agent-events.spec.ts | 143 ++-- apps/api/test/agent-lifecycle.spec.ts | 547 +++++++------ apps/api/test/agent-queue.spec.ts | 81 +- apps/api/test/agent-runs.spec.ts | 292 ++++--- apps/api/test/agent-trigger.stub.ts | 5 +- .../asset-purge-automatic.integration.spec.ts | 109 +-- apps/api/test/asset-purge.fixture.ts | 174 +++-- apps/api/test/asset-purge.integration.spec.ts | 179 ++--- .../assets-core-abort.integration.spec.ts | 4 +- .../assets-core-deletion.integration.spec.ts | 6 +- .../assets-core-legacy.integration.spec.ts | 4 +- .../assets-core-mailbox.integration.spec.ts | 8 +- .../assets-core-project.integration.spec.ts | 4 +- .../assets-core-sources.integration.spec.ts | 4 +- .../assets-core-worker.integration.spec.ts | 4 +- apps/api/test/assets-core.fixture.ts | 91 ++- apps/api/test/assets-core.integration.spec.ts | 4 +- apps/api/test/assets-http-flow.e2e.spec.ts | 14 +- .../test/assets-http-lifecycle.e2e.spec.ts | 22 +- apps/api/test/assets-http.fixture.ts | 64 +- apps/api/test/assets-tenant.fixture.ts | 50 ++ apps/api/test/auth-middleware.spec.ts | 112 +++ apps/api/test/auth.e2e.spec.ts | 31 +- apps/api/test/backfill-auto-scope.spec.ts | 72 ++ apps/api/test/bulk.spec.ts | 147 ++-- apps/api/test/company-requested.spec.ts | 87 ++- apps/api/test/contacts-tenant-scope.spec.ts | 92 +++ apps/api/test/conversation-sharing.spec.ts | 175 ++--- apps/api/test/conversations.spec.ts | 173 +++-- .../test/currency-totals.integration.spec.ts | 227 +++--- apps/api/test/database-module.spec.ts | 84 ++ apps/api/test/deal-contacts.spec.ts | 51 +- apps/api/test/enrichment-queue.spec.ts | 118 +-- apps/api/test/fields.spec.ts | 134 ++-- apps/api/test/mailbox-purge.spec.ts | 91 ++- apps/api/test/mailbox-sync-tick.spec.ts | 16 + apps/api/test/mailbox-thread-writer.spec.ts | 105 +-- ...multi-tenant-agent-queue-isolation.spec.ts | 73 ++ .../multi-tenant-crm-isolation.e2e.spec.ts | 180 +++++ ...lti-tenant-mailbox-attribution.e2e.spec.ts | 223 ++++++ .../multi-tenant-slack-connection.e2e.spec.ts | 180 +++++ apps/api/test/record-delete.spec.ts | 259 ++++--- .../request-principal.integration.spec.ts | 72 ++ apps/api/test/slack-connection.spec.ts | 47 +- apps/api/test/sso.spec.ts | 140 +++- .../test/telemetry-rollup.integration.spec.ts | 189 +++++ .../test/tracking-config-tenant-scope.spec.ts | 75 ++ .../test/tracking-filing.integration.spec.ts | 135 ++-- .../test/tracking-ingest.integration.spec.ts | 118 +-- apps/api/test/tracking-public-tenant.spec.ts | 128 +++ .../tracking-retention.integration.spec.ts | 274 +++++++ .../tracking-site-locator.integration.spec.ts | 64 ++ .../test/tracking-sources.integration.spec.ts | 168 ++++ apps/api/test/workspace-gate.spec.ts | 64 ++ apps/api/test/workspace.fixture.ts | 27 + apps/app/app/(app)/[slug]/forbidden.tsx | 30 + apps/app/app/(app)/[slug]/layout.tsx | 42 +- .../app/(landing)/no-organization/page.tsx | 26 + .../no-organization/sign-out-button.tsx | 17 + apps/app/app/(landing)/oauth/consent/page.tsx | 16 +- apps/app/app/(landing)/onboarding/page.tsx | 3 +- apps/app/app/eve/v1/[...path]/route.ts | 11 +- apps/app/components/app-header.tsx | 12 +- apps/app/components/org-switcher.tsx | 64 ++ apps/app/lib/agent-bridge.ts | 3 + apps/app/lib/agent-transcript.ts | 1 - apps/app/lib/app-path.ts | 37 + apps/app/lib/onboarding.ts | 27 +- apps/app/lib/session.ts | 4 +- apps/app/lib/tenant-gate.ts | 55 ++ apps/app/next.config.ts | 4 + apps/app/proxy.ts | 43 +- apps/app/test/agent-bridge.spec.ts | 16 +- apps/app/test/onboarding-gate.spec.ts | 58 +- apps/app/test/tenant-gate.spec.ts | 99 +++ apps/app/test/workspace-label.spec.ts | 3 +- bun.lock | 88 +-- docker-compose.yml | 1 + docs/agent.md | 12 +- docs/api.md | 61 +- docs/connections.md | 19 +- docs/environment.md | 25 +- docs/setup.md | 8 + docs/telemetry.md | 7 +- docs/tracking.md | 14 +- package.json | 1 + packages/agent-xmpp/core/package.json | 1 - .../core/src/schema-worker-lineage.test.ts | 16 +- packages/agent-xmpp/core/src/schema.ts | 19 +- packages/auth/src/auth.ts | 61 +- packages/auth/src/client.ts | 8 +- packages/auth/src/index.ts | 12 +- packages/auth/src/oauth-config.ts | 5 +- packages/auth/src/organization.ts | 125 +-- packages/auth/src/slack-connect.ts | 11 +- packages/auth/src/slack-grant.ts | 153 ++-- packages/auth/src/sso-tenant-context.ts | 128 +++ .../test/oauth-tenant.integration.spec.ts | 165 ++++ .../test/organization.integration.spec.ts | 151 ++-- ...on-active-organization.integration.spec.ts | 67 ++ .../test/slack-connect.integration.spec.ts | 46 +- .../auth/test/slack-grant.integration.spec.ts | 360 +++++++++ .../sso-tenant-context.integration.spec.ts | 178 +++++ packages/auth/test/sso.spec.ts | 11 +- packages/db/docker/init-runtime-role.sql | 5 + packages/db/package.json | 8 + .../migration.sql | 330 ++++++++ .../migration.sql | 79 ++ packages/db/prisma/schema.prisma | 292 ++++++- packages/db/prisma/seed.ts | 120 ++- packages/db/scripts/provision-organization.ts | 134 ++++ packages/db/scripts/rehearse-migration.ts | 220 ++++++ packages/db/src/fields.ts | 6 +- packages/db/src/idempotency.ts | 9 +- .../db/src/pool.test.ts | 2 +- .../agent/lib => packages/db/src}/pool.ts | 0 packages/db/src/row-level-security.test.ts | 125 +++ packages/db/src/settings.test.ts | 107 +++ packages/db/src/settings.ts | 113 +-- packages/db/src/slack-inventory.test.ts | 61 ++ packages/db/src/slack-inventory.ts | 63 +- .../db/src/sso-provider-tenant-scope.test.ts | 89 +++ packages/db/src/tenant-context.test.ts | 92 +++ packages/db/src/tenant-context.ts | 53 ++ .../db/src/tenant-policy-foundation.test.ts | 143 ++++ packages/db/src/tenant-scope.test.ts | 493 ++++++++++++ packages/db/src/tenant-scope.ts | 155 ++++ packages/db/src/tenants.ts | 57 ++ packages/db/src/test-support.ts | 56 ++ packages/db/src/tracking-tenant-scope.test.ts | 44 ++ packages/db/src/tracking.ts | 4 +- packages/db/src/workspace.test.ts | 119 +++ packages/db/src/workspace.ts | 19 +- packages/db/tsconfig.json | 2 +- packages/telemetry/src/allowlist.ts | 1 - packages/validation/package.json | 5 +- .../src/active-organization-claim.ts | 8 + packages/validation/src/api-key-principal.ts | 13 + packages/validation/src/index.ts | 3 + packages/validation/src/slack.ts | 1 + packages/validation/src/workspace-gate.ts | 10 + .../validation/test/api-key-principal.spec.ts | 15 + packages/validation/test/parse.spec.ts | 14 + 310 files changed, 15311 insertions(+), 5390 deletions(-) delete mode 100644 .github/workflows/auto-pr.yml delete mode 100644 .github/workflows/pr-base.yml delete mode 100644 .github/workflows/pr-title.yml delete mode 100644 .github/workflows/release.yml create mode 100644 apps/agent/test/audit-tenant.integration.spec.ts create mode 100644 apps/agent/test/blank-facts-tenant.integration.spec.ts create mode 100644 apps/agent/test/builder-input-tenant.integration.spec.ts create mode 100644 apps/agent/test/builder-runtime-tenant.integration.spec.ts create mode 100644 apps/agent/test/dispatch-tenant.integration.spec.ts create mode 100644 apps/agent/test/fields.integration.spec.ts create mode 100644 apps/agent/test/slack-connection.integration.spec.ts create mode 100644 apps/api/src/tracking/tracking-retention.config.ts create mode 100644 apps/api/src/tracking/tracking-retention.service.ts create mode 100644 apps/api/src/tracking/tracking-site-locator.service.ts create mode 100644 apps/api/test/assets-tenant.fixture.ts create mode 100644 apps/api/test/auth-middleware.spec.ts create mode 100644 apps/api/test/backfill-auto-scope.spec.ts create mode 100644 apps/api/test/contacts-tenant-scope.spec.ts create mode 100644 apps/api/test/database-module.spec.ts create mode 100644 apps/api/test/multi-tenant-agent-queue-isolation.spec.ts create mode 100644 apps/api/test/multi-tenant-crm-isolation.e2e.spec.ts create mode 100644 apps/api/test/multi-tenant-mailbox-attribution.e2e.spec.ts create mode 100644 apps/api/test/multi-tenant-slack-connection.e2e.spec.ts create mode 100644 apps/api/test/request-principal.integration.spec.ts create mode 100644 apps/api/test/telemetry-rollup.integration.spec.ts create mode 100644 apps/api/test/tracking-config-tenant-scope.spec.ts create mode 100644 apps/api/test/tracking-public-tenant.spec.ts create mode 100644 apps/api/test/tracking-retention.integration.spec.ts create mode 100644 apps/api/test/tracking-site-locator.integration.spec.ts create mode 100644 apps/api/test/tracking-sources.integration.spec.ts create mode 100644 apps/api/test/workspace-gate.spec.ts create mode 100644 apps/api/test/workspace.fixture.ts create mode 100644 apps/app/app/(app)/[slug]/forbidden.tsx create mode 100644 apps/app/app/(landing)/no-organization/page.tsx create mode 100644 apps/app/app/(landing)/no-organization/sign-out-button.tsx create mode 100644 apps/app/components/org-switcher.tsx create mode 100644 apps/app/lib/app-path.ts create mode 100644 apps/app/lib/tenant-gate.ts create mode 100644 apps/app/test/tenant-gate.spec.ts create mode 100644 packages/auth/src/sso-tenant-context.ts create mode 100644 packages/auth/test/oauth-tenant.integration.spec.ts create mode 100644 packages/auth/test/session-active-organization.integration.spec.ts create mode 100644 packages/auth/test/slack-grant.integration.spec.ts create mode 100644 packages/auth/test/sso-tenant-context.integration.spec.ts create mode 100644 packages/db/docker/init-runtime-role.sql create mode 100644 packages/db/prisma/migrations/20260905120000_multi_tenant_schema/migration.sql create mode 100644 packages/db/prisma/migrations/20260909120000_tenant_scope_assets/migration.sql create mode 100644 packages/db/scripts/provision-organization.ts create mode 100644 packages/db/scripts/rehearse-migration.ts rename apps/agent/test/pool.spec.ts => packages/db/src/pool.test.ts (97%) rename {apps/agent/agent/lib => packages/db/src}/pool.ts (100%) create mode 100644 packages/db/src/row-level-security.test.ts create mode 100644 packages/db/src/settings.test.ts create mode 100644 packages/db/src/slack-inventory.test.ts create mode 100644 packages/db/src/sso-provider-tenant-scope.test.ts create mode 100644 packages/db/src/tenant-context.test.ts create mode 100644 packages/db/src/tenant-context.ts create mode 100644 packages/db/src/tenant-policy-foundation.test.ts create mode 100644 packages/db/src/tenant-scope.test.ts create mode 100644 packages/db/src/tenant-scope.ts create mode 100644 packages/db/src/tenants.ts create mode 100644 packages/db/src/test-support.ts create mode 100644 packages/db/src/tracking-tenant-scope.test.ts create mode 100644 packages/db/src/workspace.test.ts create mode 100644 packages/validation/src/active-organization-claim.ts create mode 100644 packages/validation/src/api-key-principal.ts create mode 100644 packages/validation/src/workspace-gate.ts create mode 100644 packages/validation/test/api-key-principal.spec.ts diff --git a/.env.example b/.env.example index 4e794fedb..9286b14d6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Postgres -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm?schema=public" +DATABASE_URL="postgresql://crm:crm@localhost:5432/crm?schema=public" # A direct, unpooled connection to that same database, used by # `prisma migrate deploy` when the API is built on Vercel. Set it only if @@ -11,7 +11,12 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm?schema=public" # and refuses to run without this — these are real integration tests, they write # and delete rows, and the pre-push hook runs them. The name has to end in # `_test`. `bun run db:test` creates it and applies the migrations. -TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm_test?schema=public" +TEST_DATABASE_URL="postgresql://crm:crm@localhost:5432/crm_test?schema=public" + +# Postgres connection for a BYPASSRLS role. Only the database migration +# rehearsal uses this role to audit every tenant. Normal app processes must +# never use this value. +AUDIT_DATABASE_URL="" # Generate your own: openssl rand -base64 32 BETTER_AUTH_SECRET="" diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml deleted file mode 100644 index bbfcd8ec6..000000000 --- a/.github/workflows/auto-pr.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Auto PR - -on: - push: - branches-ignore: - - main - - release - - "release-please--**" - - "gh-readonly-queue/**" - - "dependabot/**" - -concurrency: - group: auto-pr-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - open: - name: Open a pull request into main - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Open the pull request - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BRANCH: ${{ github.ref_name }} - run: | - set -euo pipefail - - existing=$(gh pr list --head "$BRANCH" --state all --limit 1 --json number,state --jq '.[0] | select(.) | "#\(.number) (\(.state))"') - if [ -n "$existing" ]; then - echo "\`$BRANCH\` already has pull request $existing — leaving it alone." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - if [ "$(git rev-list --count "origin/main..origin/$BRANCH")" = "0" ]; then - echo "\`$BRANCH\` has nothing \`main\` does not already have." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - title=$(.github/scripts/pr-title.sh generate "origin/main" "origin/$BRANCH" "$BRANCH") - - body=$(cat < - EOF - ) - - gh pr create --base main --head "$BRANCH" --title "$title" --body "$body" - - echo "Opened a pull request for \`$BRANCH\` titled \`$title\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36aeaa820..09695ece5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,12 @@ env: jobs: check: name: check-types, lint, test - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 20 services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -39,8 +39,8 @@ jobs: --health-retries 20 env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm_test?schema=public - TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm_test?schema=public + DATABASE_URL: postgresql://crm:crm@localhost:5432/crm_test?schema=public + TEST_DATABASE_URL: postgresql://crm:crm@localhost:5432/crm_test?schema=public ALLOWED_SIGN_IN: example.com BETTER_AUTH_SECRET: ci-only-secret-regenerate-for-any-real-deployment API_URL: http://localhost:3001 @@ -57,6 +57,13 @@ jobs: with: bun-version-file: package.json + - name: Create runtime database role + env: + PGPASSWORD: postgres + run: | + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE crm WITH LOGIN PASSWORD 'crm' NOSUPERUSER CREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS" + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "ALTER DATABASE crm_test OWNER TO crm" + - run: bun install --frozen-lockfile - name: Install kaneo dependencies diff --git a/.github/workflows/pr-base.yml b/.github/workflows/pr-base.yml deleted file mode 100644 index 6f62f04b5..000000000 --- a/.github/workflows/pr-base.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: PR base - -on: - pull_request_target: - types: [opened, reopened] - branches: [release] - -concurrency: - group: pr-base-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - retarget: - name: Retarget onto main - if: >- - github.event.pull_request.head.ref != 'main' && - !startsWith(github.event.pull_request.head.ref, 'release-please--') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Move the pull request to main - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - gh pr edit "$NUMBER" --base main - - echo "Retargeted #$NUMBER from \`release\` onto \`main\`." >> "$GITHUB_STEP_SUMMARY" - - - uses: marocchino/sticky-pull-request-comment@v2 - continue-on-error: true - with: - header: pr-base - message: | - **Retargeted this onto `main`.** - - `release` is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to `main`, and reach `release` when a release is cut. - - Nothing is wrong with your branch. If the diff now shows commits that are already on `main`, rebase and force-push: - - ```sh - git fetch origin main - git rebase origin/main - git push --force-with-lease - ``` diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index c289adbe8..000000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: PR title - -on: - pull_request: - types: [opened, edited, reopened, ready_for_review, synchronize] - branches: [main] - -concurrency: - group: pr-title-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - conventional: - name: conventional commit - if: >- - github.event.pull_request.draft == false && - !startsWith(github.event.pull_request.head.ref, 'release-please--') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Write the title from the diff - id: autotitle - env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - TITLE: ${{ github.event.pull_request.title }} - BODY: ${{ github.event.pull_request.body }} - NUMBER: ${{ github.event.pull_request.number }} - BRANCH: ${{ github.event.pull_request.head.ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - body=$(printf '%s' "$BODY" | tr -d '\r') - - if ! .github/scripts/pr-title.sh check "$TITLE"; then - reason="it is not a release note" - elif ! .github/scripts/pr-title.sh sufficient "$BASE_SHA" "$HEAD_SHA" "$TITLE"; then - reason="it releases less than the commits on the branch do" - else - echo "\`$TITLE\` still covers the diff — leaving it alone." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - title=$(.github/scripts/pr-title.sh generate "$BASE_SHA" "$HEAD_SHA" "$BRANCH") - - if [ "$title" = "$TITLE" ]; then - echo "\`$TITLE\` still describes the diff." >> "$GITHUB_STEP_SUMMARY" - echo "written=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - body=$(printf '%s\n\n\n' \ - "$(printf '%s\n' "$body" | grep -v '^