Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,53 @@
|
||||
# @toolctrl/worker
|
||||
|
||||
Opt-in BullMQ worker runtime for queue-backed jobs.
|
||||
|
||||
The worker is disabled by default and is not imported by the web runtime. Running it directly without `WORKER_ENABLED=true` exits immediately.
|
||||
|
||||
## Local execution
|
||||
|
||||
Check worker readiness and Redis configuration without starting processors:
|
||||
|
||||
```sh
|
||||
pnpm --filter @toolctrl/worker health
|
||||
```
|
||||
|
||||
Run the disabled-by-default worker locally:
|
||||
|
||||
```sh
|
||||
pnpm --filter @toolctrl/worker dev
|
||||
```
|
||||
|
||||
Start the enabled worker against a local Redis instance and database:
|
||||
|
||||
```sh
|
||||
WORKER_ENABLED=true WORKER_QUEUE_GROUP=general BULLMQ_REDIS_URL=redis://localhost:6379/0 DATABASE_URL=postgresql://localhost/app pnpm --filter @toolctrl/worker dev
|
||||
```
|
||||
|
||||
The worker stays disabled unless `WORKER_ENABLED=true`. When enabled, `DATABASE_URL` is required, and Redis is resolved from `BULLMQ_REDIS_URL` first; `REDIS_URL` is fallback only when `BULLMQ_REDIS_URL` is absent for deployments that already provide a generic Redis binding.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Build the worker image and start a disabled smoke container with local Redis:
|
||||
|
||||
```sh
|
||||
docker compose up redis worker-general
|
||||
```
|
||||
|
||||
The compose topology builds the Dockerfile `worker` target for five grouped services: `worker-general`, `worker-mail`, `worker-import`, `worker-webhook`, and `worker-plugin`. Each service leaves processors disabled by default and wires Redis with `BULLMQ_REDIS_URL=redis://redis:6379/0` using the compose service name. Enable real processors only with a valid database URL and the group you want to exercise, for example with `WORKER_ENABLED=true DATABASE_URL=postgresql://... docker compose up worker-mail` or an override file.
|
||||
|
||||
Manual E2E note: real queued job processing requires a running Redis/container smoke test outside Auto-Mode; Auto-Mode verification for this task is limited to static configuration and documentation checks.
|
||||
|
||||
## Current processors
|
||||
|
||||
The worker has concrete handler paths for mail, webhooks, notifications, maintenance, storage scans, exports, imports, and guarded AI/browser job descriptors. Some queues remain disabled unless feature flags and provider services are configured.
|
||||
|
||||
- `toolctrl.email`
|
||||
- `toolctrl.notifications`
|
||||
- `toolctrl.maintenance`
|
||||
- `toolctrl.ai-chatbot`
|
||||
- `toolctrl.browser-automation`
|
||||
- `toolctrl.file-scan`
|
||||
- `toolctrl.exports`
|
||||
- `toolctrl.imports`
|
||||
- `toolctrl.plugin-invoke`
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@toolctrl/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./src/otel.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"dev": "tsx src/index.ts",
|
||||
"health": "node dist/health.js",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1045.0",
|
||||
"@aws-sdk/lib-storage": "3.1045.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||
"@opentelemetry/core": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/node": "^10.53.1",
|
||||
"@toolctrl/config": "workspace:*",
|
||||
"@toolctrl/db": "workspace:*",
|
||||
"@toolctrl/email-sequences": "workspace:*",
|
||||
"@toolctrl/email-templates": "workspace:*",
|
||||
"@toolctrl/entitlements": "workspace:*",
|
||||
"@toolctrl/error-reporter": "workspace:*",
|
||||
"@toolctrl/exports": "workspace:*",
|
||||
"@toolctrl/form-widget": "workspace:*",
|
||||
"@toolctrl/foundation": "workspace:*",
|
||||
"@toolctrl/imports": "workspace:*",
|
||||
"@toolctrl/logger": "workspace:*",
|
||||
"@toolctrl/mail": "workspace:*",
|
||||
"@toolctrl/marketing-outbound": "workspace:*",
|
||||
"@toolctrl/metrics": "workspace:*",
|
||||
"@toolctrl/observability": "workspace:*",
|
||||
"@toolctrl/plugin-runtime": "workspace:*",
|
||||
"@toolctrl/queue": "workspace:*",
|
||||
"@toolctrl/search": "workspace:*",
|
||||
"@toolctrl/shared-store": "workspace:*",
|
||||
"@toolctrl/storage": "workspace:*",
|
||||
"bullmq": "^5.76.5",
|
||||
"clamscan": "^2.4.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"ioredis": "5.10.1",
|
||||
"meilisearch": "0.45.0",
|
||||
"pdf-to-png-converter": "^4.1.1",
|
||||
"pino": "^9.0.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"semver": "^7.7.3",
|
||||
"sharp": "^0.35.2",
|
||||
"stripe": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
const sourcemap = process.env.SOURCEMAP === "true" || process.env.BUILD_SOURCEMAPS === "true";
|
||||
|
||||
const externalBarePackagesPlugin = {
|
||||
name: "external-bare-packages",
|
||||
setup(builder) {
|
||||
builder.onResolve({ filter: /^[^./]|^\.[^./]|^\.\.[^/]/ }, (args) => {
|
||||
if (args.path === "@toolctrl/queue/runtime" || args.path.startsWith("@toolctrl/queue/runtime/")) {
|
||||
return { path: args.path, external: true };
|
||||
}
|
||||
|
||||
if (args.path.startsWith("@toolctrl/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/index.ts", "src/health.ts", "src/jobs/plugin-sandbox-worker.ts"],
|
||||
outdir: "dist",
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
banner: {
|
||||
js: 'import { createRequire as __toolctrlCreateRequire } from "node:module"; const require = __toolctrlCreateRequire(import.meta.url);',
|
||||
},
|
||||
logLevel: "warning",
|
||||
plugins: [externalBarePackagesPlugin],
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createMetricsRegistry, createDefaultMetrics, type MetricsRegistry, type DefaultMetrics } from "@toolctrl/metrics";
|
||||
import { createWorkerAdminHandler, type WorkerStatusReport } from "./admin-server.js";
|
||||
|
||||
let registry: MetricsRegistry;
|
||||
let metrics: DefaultMetrics;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createMetricsRegistry({ serviceName: "worker-test", collectDefaultProcessMetrics: false });
|
||||
metrics = createDefaultMetrics(registry);
|
||||
metrics.queueJobsActive.labels({ queue: "email" }).set(1);
|
||||
});
|
||||
|
||||
function fakeReqRes(opts: { url: string; authorization?: string }) {
|
||||
const writes: string[] = [];
|
||||
const headers: Record<string, string | number | string[]> = {};
|
||||
const req = {
|
||||
url: opts.url,
|
||||
headers: opts.authorization ? { authorization: opts.authorization } : {},
|
||||
} as unknown as Parameters<ReturnType<typeof createWorkerAdminHandler>>[0];
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
setHeader: (k: string, v: string | number | string[]) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
},
|
||||
end: (chunk?: string) => {
|
||||
if (chunk !== undefined) writes.push(chunk);
|
||||
},
|
||||
} as unknown as Parameters<ReturnType<typeof createWorkerAdminHandler>>[1];
|
||||
return { req, res, writes, headers };
|
||||
}
|
||||
|
||||
function okStatus(): WorkerStatusReport {
|
||||
return {
|
||||
status: "ok",
|
||||
service: "worker",
|
||||
checks: { runtime: "ok" },
|
||||
version: "abc",
|
||||
uptime_seconds: 1,
|
||||
region: "eu-central-1",
|
||||
};
|
||||
}
|
||||
|
||||
const TOKEN = "x".repeat(64);
|
||||
|
||||
describe("createWorkerAdminHandler", () => {
|
||||
it("returns 200 + JSON on /status", async () => {
|
||||
const handler = createWorkerAdminHandler({
|
||||
enabled: true,
|
||||
token: TOKEN,
|
||||
registry,
|
||||
buildStatus: () => okStatus(),
|
||||
});
|
||||
const { req, res, writes } = fakeReqRes({ url: "/status" });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(200);
|
||||
expect(JSON.parse(writes[0]!)).toMatchObject({ status: "ok", service: "worker" });
|
||||
});
|
||||
|
||||
it("returns 503 when status report is down", async () => {
|
||||
const handler = createWorkerAdminHandler({
|
||||
enabled: true,
|
||||
token: TOKEN,
|
||||
registry,
|
||||
buildStatus: () => ({ ...okStatus(), status: "down", checks: { runtime: "down" } }),
|
||||
});
|
||||
const { req, res } = fakeReqRes({ url: "/status" });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it("returns 404 on /metrics when disabled", async () => {
|
||||
const handler = createWorkerAdminHandler({ enabled: false, token: TOKEN, registry, buildStatus: () => okStatus() });
|
||||
const { req, res } = fakeReqRes({ url: "/metrics" });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 401 on /metrics without bearer token", async () => {
|
||||
const handler = createWorkerAdminHandler({ enabled: true, token: TOKEN, registry, buildStatus: () => okStatus() });
|
||||
const { req, res } = fakeReqRes({ url: "/metrics" });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 + prom text on /metrics with valid token", async () => {
|
||||
const handler = createWorkerAdminHandler({ enabled: true, token: TOKEN, registry, buildStatus: () => okStatus() });
|
||||
const { req, res, writes, headers } = fakeReqRes({ url: "/metrics", authorization: `Bearer ${TOKEN}` });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(200);
|
||||
expect(headers["content-type"]).toContain("text/plain");
|
||||
expect(writes.join("")).toContain("queue_jobs_active");
|
||||
});
|
||||
|
||||
it("returns 404 on unknown route", async () => {
|
||||
const handler = createWorkerAdminHandler({ enabled: true, token: TOKEN, registry, buildStatus: () => okStatus() });
|
||||
const { req, res } = fakeReqRes({ url: "/health" });
|
||||
await handler(req, res);
|
||||
expect((res as unknown as { statusCode: number }).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Tiny HTTP admin server for the worker process.
|
||||
*
|
||||
* Exposes `/status` (public, JSON, 200/503) and `/metrics` (token-protected,
|
||||
* Prom text-format) so Prometheus + Uptime monitors can scrape the worker
|
||||
* the same way they scrape the API. The worker has no Hono app today and we
|
||||
* don't want to pull in one just for two endpoints — node's built-in
|
||||
* `http.createServer` is plenty.
|
||||
*
|
||||
* The server only listens when `METRICS_ENABLED=true`. Otherwise this module
|
||||
* is a no-op so dev / Sentry-less staging environments keep a single open
|
||||
* file descriptor budget.
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { isMetricsEnabled, validateMetricsTokenAuth, type MetricsRegistry } from "@toolctrl/metrics";
|
||||
|
||||
export type WorkerStatusCheckOutcome = "ok" | "down" | "disabled";
|
||||
|
||||
export type WorkerStatusReport = {
|
||||
status: "ok" | "down";
|
||||
service: "worker";
|
||||
checks: {
|
||||
runtime: WorkerStatusCheckOutcome;
|
||||
outboxDispatcher?: WorkerStatusCheckOutcome;
|
||||
workflowTrigger?: WorkerStatusCheckOutcome;
|
||||
pluginHookFanout?: WorkerStatusCheckOutcome;
|
||||
transactionalMailOutbox?: WorkerStatusCheckOutcome;
|
||||
emailSequences?: WorkerStatusCheckOutcome;
|
||||
};
|
||||
// Resolved WORKFLOW_TRIGGER_EXECUTOR_MODE for this worker. Only present when the
|
||||
// workflow-trigger subscriber runs on this worker (i.e. the check is not "disabled"),
|
||||
// so operators can see whether queued workflows execute over the HTTP bridge, the
|
||||
// native runner, or the hybrid fallback path.
|
||||
workflowTriggerExecutorMode?: "http" | "native" | "hybrid";
|
||||
version: string;
|
||||
uptime_seconds: number;
|
||||
region: string;
|
||||
};
|
||||
|
||||
export type WorkerAdminServerOptions = {
|
||||
enabled: boolean;
|
||||
token: string | undefined;
|
||||
registry: MetricsRegistry;
|
||||
port: number;
|
||||
host?: string;
|
||||
logger?: { error?: (fields: Record<string, unknown>, msg: string) => void; info?: (fields: Record<string, unknown>, msg: string) => void };
|
||||
buildStatus: () => Promise<WorkerStatusReport> | WorkerStatusReport;
|
||||
};
|
||||
|
||||
export type WorkerAdminServerHandle = {
|
||||
close: () => Promise<void>;
|
||||
port: number;
|
||||
};
|
||||
|
||||
// Health-server (apps/worker/src/health-server.ts) defaults to 9100. Admin
|
||||
// metrics-server must default to a different port to avoid EADDRINUSE in the
|
||||
// container — both servers bind to 0.0.0.0 inside the same process.
|
||||
export const DEFAULT_WORKER_ADMIN_PORT = 9101;
|
||||
|
||||
export function createWorkerAdminHandler(deps: Omit<WorkerAdminServerOptions, "port" | "host">) {
|
||||
return async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/status" || url === "/status/") {
|
||||
await respondStatus(res, deps);
|
||||
return;
|
||||
}
|
||||
if (url === "/metrics" || url === "/metrics/") {
|
||||
await respondMetrics(req, res, deps);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: "not_found", message: "Endpoint not found." } }));
|
||||
};
|
||||
}
|
||||
|
||||
export function startWorkerAdminServer(options: WorkerAdminServerOptions): WorkerAdminServerHandle | undefined {
|
||||
if (!options.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = createWorkerAdminHandler({
|
||||
enabled: options.enabled,
|
||||
token: options.token,
|
||||
registry: options.registry,
|
||||
buildStatus: options.buildStatus,
|
||||
...(options.logger ? { logger: options.logger } : {}),
|
||||
});
|
||||
const server: Server = createServer((req, res) => {
|
||||
void handler(req, res).catch((error: unknown) => {
|
||||
options.logger?.error?.({ error: serialiseError(error) }, "worker admin server handler failed");
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: "internal", message: "Internal server error." } }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(options.port, options.host ?? "0.0.0.0");
|
||||
options.logger?.info?.({ port: options.port, host: options.host ?? "0.0.0.0" }, "worker admin server listening");
|
||||
return {
|
||||
port: options.port,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function respondStatus(res: ServerResponse, deps: Omit<WorkerAdminServerOptions, "port" | "host">): Promise<void> {
|
||||
let report: WorkerStatusReport;
|
||||
try {
|
||||
report = await deps.buildStatus();
|
||||
} catch (error) {
|
||||
deps.logger?.error?.({ error: serialiseError(error) }, "worker admin status build failed");
|
||||
report = {
|
||||
status: "down",
|
||||
service: "worker",
|
||||
checks: { runtime: "down" },
|
||||
version: process.env.APP_REVISION?.trim() || "unknown",
|
||||
uptime_seconds: Math.round(process.uptime()),
|
||||
region: process.env.APP_REGION?.trim() || "unknown",
|
||||
};
|
||||
}
|
||||
res.statusCode = report.status === "ok" ? 200 : 503;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(report));
|
||||
}
|
||||
|
||||
async function respondMetrics(req: IncomingMessage, res: ServerResponse, deps: Omit<WorkerAdminServerOptions, "port" | "host">): Promise<void> {
|
||||
if (!deps.enabled) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: "not_found", message: "Endpoint not enabled." } }));
|
||||
return;
|
||||
}
|
||||
const auth = validateMetricsTokenAuth({
|
||||
metricsEnabled: true,
|
||||
configuredToken: deps.token,
|
||||
authorizationHeader: pickAuthorizationHeader(req),
|
||||
});
|
||||
if (!auth.ok) {
|
||||
if (auth.status === 500) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: "token_misconfigured", message: "Metrics token not configured." } }));
|
||||
return;
|
||||
}
|
||||
if (auth.status === 403) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: "not_found", message: "Endpoint not enabled." } }));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 401;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: { code: auth.code, message: "Authentication required." } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await deps.registry.metrics();
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", deps.registry.contentType);
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function pickAuthorizationHeader(req: IncomingMessage): string | undefined {
|
||||
const raw = req.headers.authorization;
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0];
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function serialiseError(error: unknown): { name: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { name: error.name, message: error.message.slice(0, 200) };
|
||||
}
|
||||
return { name: "UnknownError", message: String(error).slice(0, 200) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `METRICS_ENABLED` for the worker. Re-exported so callers don't import
|
||||
* the env helper from a different package than the admin server.
|
||||
*/
|
||||
export const isWorkerMetricsEnabled = isMetricsEnabled;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DEFAULT_GROUP_POOL_SIZE, createWorkerConnection } from "./connection.js";
|
||||
|
||||
const REDIS_URL = "redis://user:pass@redis.example.com:6380/3";
|
||||
|
||||
describe("createWorkerConnection", () => {
|
||||
test("uses BULLMQ_REDIS_URL when present and tags the connection with group + pid", () => {
|
||||
const result = createWorkerConnection("mail", {
|
||||
env: { BULLMQ_REDIS_URL: REDIS_URL },
|
||||
pid: 9999,
|
||||
});
|
||||
|
||||
expect(result.host).toBe("redis.example.com");
|
||||
expect(result.port).toBe(6380);
|
||||
expect(result.db).toBe(3);
|
||||
expect(result.connectionName).toBe("worker-mail-9999");
|
||||
expect(result.pool).toBe(DEFAULT_GROUP_POOL_SIZE.mail);
|
||||
});
|
||||
|
||||
test("falls back to REDIS_URL when BULLMQ_REDIS_URL is missing", () => {
|
||||
const result = createWorkerConnection("general", {
|
||||
env: { REDIS_URL },
|
||||
pid: 1,
|
||||
});
|
||||
|
||||
expect(result.host).toBe("redis.example.com");
|
||||
expect(result.connectionName).toBe("worker-general-1");
|
||||
});
|
||||
|
||||
test("rejects missing Redis URL with a non-leaking message", () => {
|
||||
expect(() => createWorkerConnection("import", { env: {} })).toThrow(
|
||||
"Worker Redis URL is required: set BULLMQ_REDIS_URL or REDIS_URL."
|
||||
);
|
||||
});
|
||||
|
||||
test("honours WORKER_REDIS_POOL_SIZE override", () => {
|
||||
const result = createWorkerConnection("webhook", {
|
||||
env: { BULLMQ_REDIS_URL: REDIS_URL, WORKER_REDIS_POOL_SIZE: "16" },
|
||||
pid: 42,
|
||||
});
|
||||
|
||||
expect(result.pool).toBe(16);
|
||||
});
|
||||
|
||||
test("ignores zero/negative/non-numeric pool overrides", () => {
|
||||
for (const bogus of ["0", "-3", "abc", ""]) {
|
||||
const result = createWorkerConnection("webhook", {
|
||||
env: { BULLMQ_REDIS_URL: REDIS_URL, WORKER_REDIS_POOL_SIZE: bogus },
|
||||
pid: 1,
|
||||
});
|
||||
expect(result.pool).toBe(DEFAULT_GROUP_POOL_SIZE.webhook);
|
||||
}
|
||||
});
|
||||
|
||||
test("defaults differ between webhook (8) and import (2)", () => {
|
||||
const webhook = createWorkerConnection("webhook", { env: { BULLMQ_REDIS_URL: REDIS_URL }, pid: 1 });
|
||||
const importer = createWorkerConnection("import", { env: { BULLMQ_REDIS_URL: REDIS_URL }, pid: 1 });
|
||||
expect(webhook.pool).toBe(8);
|
||||
expect(importer.pool).toBe(2);
|
||||
});
|
||||
|
||||
test("connectionName uses runtime pid when not supplied", () => {
|
||||
const result = createWorkerConnection("general", { env: { BULLMQ_REDIS_URL: REDIS_URL } });
|
||||
expect(result.connectionName).toMatch(/^worker-general-\d+$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import { parseRedisUrl, type ConnectionOptions, type WorkerQueueGroup } from "@toolctrl/queue";
|
||||
|
||||
/**
|
||||
* Per-group default Redis connection pool size. BullMQ uses ioredis under
|
||||
* the hood — `family`/`tls`/pool tuning happen at ioredis level. We expose
|
||||
* `pool` as an opt-in field BullMQ does not consume so the value can be
|
||||
* read by integrations (custom factories, ioredis-cluster wrappers); the
|
||||
* default fields stay 1:1 compatible with BullMQ's `WorkerOptions.connection`.
|
||||
*
|
||||
* Rationale per group:
|
||||
* - general : 4 — mixed I/O, modest connection demand
|
||||
* - mail : 4 — SMTP/HTTP, moderate
|
||||
* - import : 2 — long-running but low connection turnover
|
||||
* - webhook : 8 — high parallelism (concurrency 20+) needs more sockets
|
||||
* - plugin : 4 — sandbox calls are short-lived; Redis is mostly BullMQ
|
||||
* command traffic plus sliding-window rate limits
|
||||
*/
|
||||
export const DEFAULT_GROUP_POOL_SIZE: Readonly<Record<WorkerQueueGroup, number>> = {
|
||||
general: 4,
|
||||
mail: 4,
|
||||
import: 2,
|
||||
webhook: 8,
|
||||
plugin: 4,
|
||||
};
|
||||
|
||||
const POOL_ENV_VAR = "WORKER_REDIS_POOL_SIZE";
|
||||
|
||||
export type WorkerConnectionOptions = ConnectionOptions & {
|
||||
/**
|
||||
* Human-readable name BullMQ/ioredis tags the connection with. Visible in
|
||||
* `CLIENT LIST` so operators can attribute Redis traffic to a worker
|
||||
* group + PID. Format: `worker-${group}-${pid}`.
|
||||
*/
|
||||
connectionName: string;
|
||||
/**
|
||||
* Advisory pool size (not consumed by BullMQ directly). Read by callers
|
||||
* that wrap BullMQ in a custom connection factory.
|
||||
*/
|
||||
pool: number;
|
||||
};
|
||||
|
||||
export type CreateWorkerConnectionOptions = {
|
||||
env?: EnvRecord;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the BullMQ connection options for a single worker process.
|
||||
* Resolves the Redis URL from BULLMQ_REDIS_URL/REDIS_URL via the queue
|
||||
* package's shared parser, then layers on a connection name + pool size
|
||||
* derived from the group.
|
||||
*
|
||||
* Throws if neither BULLMQ_REDIS_URL nor REDIS_URL is configured — a
|
||||
* worker without a Redis target can't function and should fail loud.
|
||||
*/
|
||||
export function createWorkerConnection(group: WorkerQueueGroup, options: CreateWorkerConnectionOptions = {}): WorkerConnectionOptions {
|
||||
const env = options.env ?? (process.env as EnvRecord);
|
||||
const pid = options.pid ?? process.pid;
|
||||
const redisUrl = pickRedisUrl(env);
|
||||
if (!redisUrl) {
|
||||
throw new Error("Worker Redis URL is required: set BULLMQ_REDIS_URL or REDIS_URL.");
|
||||
}
|
||||
const base = parseRedisUrl(redisUrl.name, redisUrl.value);
|
||||
return {
|
||||
...base,
|
||||
connectionName: `worker-${group}-${pid}`,
|
||||
pool: resolvePoolSize(group, env),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePoolSize(group: WorkerQueueGroup, env: EnvRecord): number {
|
||||
const raw = env[POOL_ENV_VAR];
|
||||
if (typeof raw === "string" && raw.length > 0) {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return DEFAULT_GROUP_POOL_SIZE[group];
|
||||
}
|
||||
|
||||
function pickRedisUrl(env: EnvRecord): { name: "BULLMQ_REDIS_URL" | "REDIS_URL"; value: string } | undefined {
|
||||
const bullmq = trim(env.BULLMQ_REDIS_URL);
|
||||
if (bullmq) return { name: "BULLMQ_REDIS_URL", value: bullmq };
|
||||
const redis = trim(env.REDIS_URL);
|
||||
if (redis) return { name: "REDIS_URL", value: redis };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trim(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// AK-P4a — worker-side tenant RLS adoption, mirroring R-P2b's web helper
|
||||
// (apps/web/app/utils/db/.server/tenantRls.server.ts). The worker cannot import
|
||||
// the web helper (it is bound to the web `db` singleton), so it gets its own thin
|
||||
// wrapper that takes the Prisma client as a parameter (the worker uses
|
||||
// getDbClient() from @toolctrl/db/client.server).
|
||||
//
|
||||
// Sets the tenant context TRANSACTION-LOCALLY (set_config(..., true) == SET LOCAL,
|
||||
// PgBouncer transaction-pooling safe) and runs the queries inside that same
|
||||
// transaction, so the RLS policies (USING/WITH CHECK on
|
||||
// current_setting('app.tenant_id')) bind. Without the context an RLS-enabled
|
||||
// table returns no rows for the restricted app role.
|
||||
//
|
||||
// The helper is generic over the transaction client so callers can keep narrow,
|
||||
// string-typed seam interfaces (decoupled from the generated Prisma enum types,
|
||||
// which keeps the dynamic-import jobs building before native libs are installed).
|
||||
// The only requirement is that the tx exposes `$executeRaw` (a real Prisma
|
||||
// TransactionClient does).
|
||||
|
||||
/** Minimal surface the helper needs on the transaction client to set the GUC. */
|
||||
export interface RlsTransactionExecutor {
|
||||
$executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>;
|
||||
}
|
||||
|
||||
/** A client whose `$transaction` yields a tx of type `Tx`. */
|
||||
export interface TransactionCapableClient<Tx> {
|
||||
$transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export async function withTenantRLS<Tx extends RlsTransactionExecutor, T>(
|
||||
client: TransactionCapableClient<Tx>,
|
||||
tenantId: string,
|
||||
callback: (tx: Tx) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!tenantId) {
|
||||
throw new Error("withTenantRLS requires a non-empty tenantId");
|
||||
}
|
||||
return client.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
|
||||
return callback(tx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
import {
|
||||
DEFAULT_GROUP_CONCURRENCY,
|
||||
InvalidWorkerQueueGroupError,
|
||||
bootstrapWorkerGroup,
|
||||
resolveGroupConcurrency,
|
||||
resolveQueueNamesForGroup,
|
||||
resolveWorkerQueueGroup,
|
||||
} from "./group-runtime.js";
|
||||
|
||||
describe("resolveWorkerQueueGroup", () => {
|
||||
test("returns the validated group when env is set", () => {
|
||||
expect(resolveWorkerQueueGroup({ WORKER_QUEUE_GROUP: "mail" })).toBe("mail");
|
||||
});
|
||||
|
||||
test("throws when env is missing", () => {
|
||||
expect(() => resolveWorkerQueueGroup({})).toThrow(InvalidWorkerQueueGroupError);
|
||||
});
|
||||
|
||||
test("throws when env is unknown", () => {
|
||||
expect(() => resolveWorkerQueueGroup({ WORKER_QUEUE_GROUP: "all" })).toThrow(InvalidWorkerQueueGroupError);
|
||||
});
|
||||
|
||||
test("error message lists every valid group", () => {
|
||||
try {
|
||||
resolveWorkerQueueGroup({ WORKER_QUEUE_GROUP: "bogus" });
|
||||
throw new Error("should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(InvalidWorkerQueueGroupError);
|
||||
const message = (error as Error).message;
|
||||
for (const group of ["general", "mail", "import", "webhook", "plugin"]) {
|
||||
expect(message).toContain(group);
|
||||
}
|
||||
expect(message).toContain("docs/ops/worker-queues.md");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQueueNamesForGroup", () => {
|
||||
test("mail group resolves to email queue", () => {
|
||||
expect(resolveQueueNamesForGroup("mail")).toEqual([queueNames.email]);
|
||||
});
|
||||
|
||||
test("general group includes notifications + maintenance + fileScan", () => {
|
||||
expect(resolveQueueNamesForGroup("general")).toEqual([queueNames.notifications, queueNames.maintenance, queueNames.fileScan]);
|
||||
});
|
||||
|
||||
test("import group includes imports + exports", () => {
|
||||
expect(resolveQueueNamesForGroup("import")).toEqual([queueNames.imports, queueNames.exports]);
|
||||
});
|
||||
|
||||
test("webhook group covers AI + browser automation", () => {
|
||||
expect(resolveQueueNamesForGroup("webhook")).toEqual([queueNames.aiChatbot, queueNames.browserAutomation]);
|
||||
});
|
||||
|
||||
test("plugin group resolves to plugin-invoke queue", () => {
|
||||
expect(resolveQueueNamesForGroup("plugin")).toEqual([queueNames.pluginInvoke]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupConcurrency", () => {
|
||||
test("falls back to documented defaults when env is unset", () => {
|
||||
expect(resolveGroupConcurrency("general", {})).toBe(DEFAULT_GROUP_CONCURRENCY.general);
|
||||
expect(resolveGroupConcurrency("mail", {})).toBe(DEFAULT_GROUP_CONCURRENCY.mail);
|
||||
expect(resolveGroupConcurrency("import", {})).toBe(DEFAULT_GROUP_CONCURRENCY.import);
|
||||
expect(resolveGroupConcurrency("webhook", {})).toBe(DEFAULT_GROUP_CONCURRENCY.webhook);
|
||||
expect(resolveGroupConcurrency("plugin", {})).toBe(DEFAULT_GROUP_CONCURRENCY.plugin);
|
||||
});
|
||||
|
||||
test("honours WORKER_CONCURRENCY_PLUGIN override", () => {
|
||||
expect(resolveGroupConcurrency("plugin", { WORKER_CONCURRENCY_PLUGIN: "12" })).toBe(12);
|
||||
});
|
||||
|
||||
test("honours explicit WORKER_CONCURRENCY_<GROUP> override", () => {
|
||||
expect(resolveGroupConcurrency("webhook", { WORKER_CONCURRENCY_WEBHOOK: "40" })).toBe(40);
|
||||
expect(resolveGroupConcurrency("mail", { WORKER_CONCURRENCY_MAIL: "8" })).toBe(8);
|
||||
});
|
||||
|
||||
test("ignores non-numeric or zero/negative overrides and uses default", () => {
|
||||
expect(resolveGroupConcurrency("mail", { WORKER_CONCURRENCY_MAIL: "abc" })).toBe(DEFAULT_GROUP_CONCURRENCY.mail);
|
||||
expect(resolveGroupConcurrency("mail", { WORKER_CONCURRENCY_MAIL: "0" })).toBe(DEFAULT_GROUP_CONCURRENCY.mail);
|
||||
expect(resolveGroupConcurrency("mail", { WORKER_CONCURRENCY_MAIL: "-5" })).toBe(DEFAULT_GROUP_CONCURRENCY.mail);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapWorkerGroup", () => {
|
||||
test("returns a fully-resolved record for valid env", () => {
|
||||
expect(
|
||||
bootstrapWorkerGroup({ WORKER_QUEUE_GROUP: "import", WORKER_CONCURRENCY_IMPORT: "2" })
|
||||
).toEqual({
|
||||
group: "import",
|
||||
queueNames: [queueNames.imports, queueNames.exports],
|
||||
concurrency: 2,
|
||||
concurrencyEnvVar: "WORKER_CONCURRENCY_IMPORT",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid group", () => {
|
||||
expect(() => bootstrapWorkerGroup({ WORKER_QUEUE_GROUP: "" })).toThrow(InvalidWorkerQueueGroupError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import {
|
||||
WORKER_QUEUE_GROUP_NAMES,
|
||||
getQueueNamesForGroup,
|
||||
isWorkerQueueGroup,
|
||||
type QueueName,
|
||||
type WorkerQueueGroup,
|
||||
} from "@toolctrl/queue";
|
||||
|
||||
/**
|
||||
* Default per-group concurrency. These are deliberate — they reflect the
|
||||
* workload profile of each group, not a guess:
|
||||
* - general : 10 (mixed I/O, short jobs)
|
||||
* - mail : 5 (SMTP/API throughput-bound)
|
||||
* - import : 1 (CPU/memory-bound long-running)
|
||||
* - webhook : 20 (I/O-bound external HTTP)
|
||||
* - plugin : 5 (WASM-sandboxed third-party code; tight memory cap)
|
||||
*
|
||||
* Operators override via `WORKER_CONCURRENCY_<GROUP>` (uppercase, e.g.
|
||||
* `WORKER_CONCURRENCY_WEBHOOK=40`).
|
||||
*/
|
||||
export const DEFAULT_GROUP_CONCURRENCY: Readonly<Record<WorkerQueueGroup, number>> = {
|
||||
general: 10,
|
||||
mail: 5,
|
||||
import: 1,
|
||||
webhook: 20,
|
||||
plugin: 5,
|
||||
};
|
||||
|
||||
const CONCURRENCY_ENV_VAR: Readonly<Record<WorkerQueueGroup, string>> = {
|
||||
general: "WORKER_CONCURRENCY_GENERAL",
|
||||
mail: "WORKER_CONCURRENCY_MAIL",
|
||||
import: "WORKER_CONCURRENCY_IMPORT",
|
||||
webhook: "WORKER_CONCURRENCY_WEBHOOK",
|
||||
plugin: "WORKER_CONCURRENCY_PLUGIN",
|
||||
};
|
||||
|
||||
export class InvalidWorkerQueueGroupError extends Error {
|
||||
override readonly name = "InvalidWorkerQueueGroupError";
|
||||
constructor(value: string) {
|
||||
super(
|
||||
`WORKER_QUEUE_GROUP must be one of [${WORKER_QUEUE_GROUP_NAMES.join(", ")}]; received ${JSON.stringify(value)}. ` +
|
||||
"See docs/ops/worker-queues.md."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `WORKER_QUEUE_GROUP` from the given env and returns the validated
|
||||
* group. Throws `InvalidWorkerQueueGroupError` (a thin subclass of Error)
|
||||
* if the value is missing or unknown — caller is expected to log the
|
||||
* message and `process.exit(1)`.
|
||||
*/
|
||||
export function resolveWorkerQueueGroup(env: EnvRecord = process.env as EnvRecord): WorkerQueueGroup {
|
||||
const raw = env.WORKER_QUEUE_GROUP;
|
||||
if (typeof raw !== "string" || raw.length === 0) {
|
||||
throw new InvalidWorkerQueueGroupError(raw === undefined ? "" : String(raw));
|
||||
}
|
||||
if (!isWorkerQueueGroup(raw)) {
|
||||
throw new InvalidWorkerQueueGroupError(raw);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the BullMQ queue names this worker process should subscribe to,
|
||||
* given its group. Wrapper over `getQueueNamesForGroup` kept as a separate
|
||||
* module-level helper so callers don't have to import from the queue
|
||||
* package directly when they already depend on the group runtime.
|
||||
*/
|
||||
export function resolveQueueNamesForGroup(group: WorkerQueueGroup): readonly QueueName[] {
|
||||
return getQueueNamesForGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `WORKER_CONCURRENCY_<GROUP>` from env (e.g. `WORKER_CONCURRENCY_MAIL`).
|
||||
* Falls back to the documented default for the group. Non-numeric or non-
|
||||
* positive overrides fall back to the default rather than throwing — a
|
||||
* misconfigured env var should not knock the worker offline.
|
||||
*/
|
||||
export function resolveGroupConcurrency(group: WorkerQueueGroup, env: EnvRecord = process.env as EnvRecord): number {
|
||||
const raw = env[CONCURRENCY_ENV_VAR[group]];
|
||||
if (typeof raw === "string" && raw.length > 0) {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return DEFAULT_GROUP_CONCURRENCY[group];
|
||||
}
|
||||
|
||||
export type WorkerGroupResolution = {
|
||||
group: WorkerQueueGroup;
|
||||
queueNames: readonly QueueName[];
|
||||
concurrency: number;
|
||||
concurrencyEnvVar: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One-shot bootstrap helper for the worker entry point. Validates env and
|
||||
* returns the group + its queues + the resolved concurrency in a single
|
||||
* record — that record is enough for `startWorkerRuntime` to register the
|
||||
* right BullMQ workers without re-reading env.
|
||||
*/
|
||||
export function bootstrapWorkerGroup(env: EnvRecord = process.env as EnvRecord): WorkerGroupResolution {
|
||||
const group = resolveWorkerQueueGroup(env);
|
||||
return {
|
||||
group,
|
||||
queueNames: resolveQueueNamesForGroup(group),
|
||||
concurrency: resolveGroupConcurrency(group, env),
|
||||
concurrencyEnvVar: CONCURRENCY_ENV_VAR[group],
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,167 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createWorkerHealthHandler } from "./health-server.js";
|
||||
|
||||
function createMockResponse() {
|
||||
const captured = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "" as string,
|
||||
ended: false,
|
||||
headersSent: false,
|
||||
};
|
||||
const res = {
|
||||
get statusCode() {
|
||||
return captured.statusCode;
|
||||
},
|
||||
set statusCode(code: number) {
|
||||
captured.statusCode = code;
|
||||
},
|
||||
get headersSent() {
|
||||
return captured.headersSent;
|
||||
},
|
||||
setHeader(name: string, value: string) {
|
||||
captured.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body?: string) {
|
||||
captured.headersSent = true;
|
||||
if (body !== undefined) captured.body = body;
|
||||
captured.ended = true;
|
||||
},
|
||||
} as unknown as ServerResponse;
|
||||
return { res, captured };
|
||||
}
|
||||
|
||||
function createMockReq(url: string): IncomingMessage {
|
||||
return { url } as IncomingMessage;
|
||||
}
|
||||
|
||||
describe("worker health handler", () => {
|
||||
test("/healthz returns 200 alive without invoking probes", async () => {
|
||||
const redisProbe = vi.fn();
|
||||
const databaseProbe = vi.fn();
|
||||
const handler = createWorkerHealthHandler({ redisProbe, databaseProbe, workerGroup: "general" });
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/healthz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(JSON.parse(captured.body)).toEqual({ status: "alive", workerGroup: "general" });
|
||||
expect(redisProbe).not.toHaveBeenCalled();
|
||||
expect(databaseProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("/readyz returns 200 when both probes succeed", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: true } as const),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
workerGroup: "mail",
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/readyz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const body = JSON.parse(captured.body);
|
||||
expect(body.status).toBe("ready");
|
||||
expect(body.workerGroup).toBe("mail");
|
||||
expect(body.checks).toEqual({ redis: "ok", database: "ok" });
|
||||
});
|
||||
|
||||
test("/readyz returns 503 when redis probe fails", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: false, error: "ECONNREFUSED" } as const),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/readyz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(503);
|
||||
const body = JSON.parse(captured.body);
|
||||
expect(body.status).toBe("not_ready");
|
||||
expect(body.checks.redis).toBe("error");
|
||||
expect(body.checks.database).toBe("ok");
|
||||
});
|
||||
|
||||
test("/readyz returns 503 when database probe throws", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: true } as const),
|
||||
databaseProbe: async () => {
|
||||
throw new Error("pg disconnected");
|
||||
},
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/readyz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(JSON.parse(captured.body).checks.database).toBe("error");
|
||||
});
|
||||
|
||||
test("/readyz includes required runtime probes in readiness status", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: true } as const),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
readinessProbes: {
|
||||
outboxDispatcher: async () => ({ ok: true } as const),
|
||||
workflowTrigger: async () => ({ ok: false, error: "subscriber disabled" } as const),
|
||||
},
|
||||
workerGroup: "general",
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/readyz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(JSON.parse(captured.body)).toMatchObject({
|
||||
status: "not_ready",
|
||||
workerGroup: "general",
|
||||
checks: {
|
||||
redis: "ok",
|
||||
database: "ok",
|
||||
outboxDispatcher: "ok",
|
||||
workflowTrigger: "error",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("/readyz times out a hanging probe", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: () => new Promise(() => undefined),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
probeTimeoutMs: 20,
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/readyz"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(JSON.parse(captured.body).checks.redis).toBe("error");
|
||||
});
|
||||
|
||||
test("unknown routes return 404 JSON", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: true } as const),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/wat"), res);
|
||||
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(JSON.parse(captured.body).error.code).toBe("not_found");
|
||||
});
|
||||
|
||||
test("/healthz omits workerGroup when none is configured", async () => {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: async () => ({ ok: true } as const),
|
||||
databaseProbe: async () => ({ ok: true } as const),
|
||||
});
|
||||
const { res, captured } = createMockResponse();
|
||||
|
||||
await handler(createMockReq("/healthz"), res);
|
||||
|
||||
expect(JSON.parse(captured.body)).toEqual({ status: "alive" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Per-worker HTTP health server.
|
||||
*
|
||||
* Exposes two Kubernetes/Coolify-style probes on a dedicated port so a
|
||||
* platform can verify the worker process is alive (`/healthz`) and that
|
||||
* its critical dependencies are reachable (`/readyz`).
|
||||
*
|
||||
* Separate from `admin-server.ts` (which serves /status and /metrics
|
||||
* gated by METRICS_ENABLED + a bearer token). Health probes must work
|
||||
* even when metrics are off and even before a bearer is provisioned.
|
||||
*
|
||||
* - `/healthz` — process liveness. Returns 200 as long as the event
|
||||
* loop is responsive. No external dependency calls.
|
||||
* - `/readyz` — readiness. Issues a BullMQ Redis ping and a Prisma
|
||||
* `SELECT 1` to confirm both backends are reachable.
|
||||
* Returns 200 only when every probe succeeds; 503
|
||||
* otherwise. Each check is bounded by a timeout so a
|
||||
* hung dependency cannot stall the probe.
|
||||
*
|
||||
* The handler is intentionally framework-free — node's built-in
|
||||
* `http.createServer` keeps the bundle size and startup cost minimal.
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { WorkerQueueGroup } from "@toolctrl/queue";
|
||||
|
||||
export const DEFAULT_WORKER_HEALTH_PORT = 9100;
|
||||
|
||||
export type HealthProbeResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export type HealthProbe = () => Promise<HealthProbeResult> | HealthProbeResult;
|
||||
|
||||
export type ReadyResponseBody = {
|
||||
status: "ready" | "not_ready";
|
||||
workerGroup?: WorkerQueueGroup;
|
||||
checks: Record<string, "ok" | "error">;
|
||||
};
|
||||
|
||||
export type WorkerHealthServerOptions = {
|
||||
port?: number;
|
||||
host?: string;
|
||||
workerGroup?: WorkerQueueGroup;
|
||||
redisProbe: HealthProbe;
|
||||
databaseProbe: HealthProbe;
|
||||
readinessProbes?: Record<string, HealthProbe>;
|
||||
probeTimeoutMs?: number;
|
||||
logger?: {
|
||||
error?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
info?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkerHealthServerHandle = {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Pure handler factory — exposed so tests can hit the request handler
|
||||
* directly without binding a TCP port.
|
||||
*/
|
||||
export function createWorkerHealthHandler(deps: Omit<WorkerHealthServerOptions, "port" | "host" | "logger">) {
|
||||
const timeoutMs = deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
return async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/healthz" || url === "/healthz/") {
|
||||
respondJson(res, 200, { status: "alive", ...(deps.workerGroup ? { workerGroup: deps.workerGroup } : {}) });
|
||||
return;
|
||||
}
|
||||
if (url === "/readyz" || url === "/readyz/") {
|
||||
const additionalProbes = Object.entries(deps.readinessProbes ?? {});
|
||||
const [redis, database, ...additionalResults] = await Promise.all([
|
||||
runWithTimeout(deps.redisProbe, timeoutMs, "redis"),
|
||||
runWithTimeout(deps.databaseProbe, timeoutMs, "database"),
|
||||
...additionalProbes.map(([label, probe]) => runWithTimeout(probe, timeoutMs, label)),
|
||||
]);
|
||||
const checks: ReadyResponseBody["checks"] = {
|
||||
redis: redis.ok ? "ok" : "error",
|
||||
database: database.ok ? "ok" : "error",
|
||||
};
|
||||
for (const [index, [label]] of additionalProbes.entries()) {
|
||||
checks[label] = additionalResults[index]?.ok ? "ok" : "error";
|
||||
}
|
||||
const allOk = redis.ok && database.ok && additionalResults.every((result) => result.ok);
|
||||
respondJson(res, allOk ? 200 : 503, {
|
||||
status: allOk ? "ready" : "not_ready",
|
||||
...(deps.workerGroup ? { workerGroup: deps.workerGroup } : {}),
|
||||
checks,
|
||||
});
|
||||
return;
|
||||
}
|
||||
respondJson(res, 404, { error: { code: "not_found", message: "Endpoint not found." } });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the health handler to a TCP port and returns a handle whose
|
||||
* `close()` shuts the server cleanly during SIGTERM/SIGINT.
|
||||
*/
|
||||
export function startWorkerHealthServer(options: WorkerHealthServerOptions): WorkerHealthServerHandle {
|
||||
const handler = createWorkerHealthHandler({
|
||||
redisProbe: options.redisProbe,
|
||||
databaseProbe: options.databaseProbe,
|
||||
...(options.readinessProbes ? { readinessProbes: options.readinessProbes } : {}),
|
||||
...(options.workerGroup ? { workerGroup: options.workerGroup } : {}),
|
||||
...(options.probeTimeoutMs !== undefined ? { probeTimeoutMs: options.probeTimeoutMs } : {}),
|
||||
});
|
||||
|
||||
const port = options.port ?? DEFAULT_WORKER_HEALTH_PORT;
|
||||
const host = options.host ?? "0.0.0.0";
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
void handler(req, res).catch((error: unknown) => {
|
||||
options.logger?.error?.({ error: serialiseError(error) }, "worker health server handler failed");
|
||||
if (!res.headersSent) {
|
||||
respondJson(res, 500, { error: { code: "internal", message: "Internal server error." } });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host);
|
||||
options.logger?.info?.({ port, host, workerGroup: options.workerGroup }, "worker health server listening");
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWithTimeout(probe: HealthProbe, timeoutMs: number, label: string): Promise<HealthProbeResult> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
Promise.resolve().then(() => probe()),
|
||||
new Promise<HealthProbeResult>((resolve) =>
|
||||
setTimeout(() => resolve({ ok: false, error: `${label} probe timed out after ${timeoutMs}ms` }), timeoutMs)
|
||||
),
|
||||
]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { ok: false, error: serialiseError(error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function respondJson(res: ServerResponse, statusCode: number, body: unknown): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function serialiseError(error: unknown): { name: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { name: error.name, message: error.message.slice(0, 200) };
|
||||
}
|
||||
return { name: "UnknownError", message: String(error).slice(0, 200) };
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
import { buildWorkerHealthReport, runWorkerHealthCli } from "./health.js";
|
||||
|
||||
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "../package.json");
|
||||
const mailjetEnv = {
|
||||
MAILJET_API_KEY: "fixture-mailjet-key",
|
||||
MAILJET_SECRET_KEY: "fixture-mailjet-secret",
|
||||
MAILJET_FROM_EMAIL: "sender@example.com",
|
||||
};
|
||||
|
||||
function readPackageScripts(): Record<string, string> {
|
||||
return JSON.parse(readFileSync(packageJsonPath, "utf8")).scripts;
|
||||
}
|
||||
|
||||
function createIo() {
|
||||
let output = "";
|
||||
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write(chunk: string) {
|
||||
output += chunk;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
readOutput: () => output,
|
||||
};
|
||||
}
|
||||
|
||||
describe("worker health", () => {
|
||||
test("production health script runs compiled Node output without tsx", () => {
|
||||
const scripts = readPackageScripts();
|
||||
|
||||
expect(scripts.health).toBe("node dist/health.js");
|
||||
expect(scripts.health).not.toContain("tsx");
|
||||
});
|
||||
|
||||
test("reports disabled without Redis as a successful config-only health state and skips DB", async () => {
|
||||
const databaseProbe = vi.fn();
|
||||
const report = await buildWorkerHealthReport({ WORKER_ENABLED: "false" }, { databaseProbe });
|
||||
|
||||
expect(report).toEqual({
|
||||
databaseConfigured: false,
|
||||
databaseStatus: "skipped",
|
||||
errors: [],
|
||||
importConfigured: false,
|
||||
importStatus: "skipped",
|
||||
mailjetConfigured: false,
|
||||
mailjetStatus: "skipped",
|
||||
pluginRuntimeConfigured: false,
|
||||
pluginRuntimeStatus: "skipped",
|
||||
queues: [queueNames.email, queueNames.notifications],
|
||||
redisConfigured: false,
|
||||
redisParseStatus: "not_configured",
|
||||
redisSource: "none",
|
||||
redisStatus: "skipped",
|
||||
revision: "unknown",
|
||||
status: "disabled",
|
||||
storageConfigured: false,
|
||||
storageStatus: "skipped",
|
||||
workerEnabled: false,
|
||||
workerGroup: null,
|
||||
});
|
||||
expect(databaseProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports ready with BULLMQ Redis and ready DB configured", async () => {
|
||||
const databaseProbe = vi.fn();
|
||||
const redisProbe = vi.fn();
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://user:db-secret@localhost:5432/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe, redisProbe }
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
databaseConfigured: true,
|
||||
databaseStatus: "ready",
|
||||
errors: [],
|
||||
redisConfigured: true,
|
||||
redisParseStatus: "valid",
|
||||
redisSource: "BULLMQ_REDIS_URL",
|
||||
redisStatus: "ready",
|
||||
status: "ready",
|
||||
importConfigured: false,
|
||||
importStatus: "skipped",
|
||||
mailjetConfigured: true,
|
||||
mailjetStatus: "ready",
|
||||
storageConfigured: false,
|
||||
storageStatus: "skipped",
|
||||
workerEnabled: true,
|
||||
workerGroup: "general",
|
||||
});
|
||||
expect(report.queues).toEqual([queueNames.notifications, queueNames.maintenance, queueNames.fileScan]);
|
||||
expect(redisProbe).toHaveBeenCalledWith("redis://localhost:6379/0");
|
||||
expect(databaseProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("probes Redis and database readiness concurrently", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseRedis: (() => void) | undefined;
|
||||
let databaseStartedBeforeRedisFinished = false;
|
||||
const redisProbe = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
events.push("redis:start");
|
||||
releaseRedis = () => {
|
||||
events.push("redis:end");
|
||||
resolve();
|
||||
};
|
||||
})
|
||||
);
|
||||
const databaseProbe = vi.fn(async () => {
|
||||
databaseStartedBeforeRedisFinished = !events.includes("redis:end");
|
||||
events.push("db:start");
|
||||
releaseRedis?.();
|
||||
events.push("db:end");
|
||||
});
|
||||
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://user:db-secret@localhost:5432/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe, redisProbe }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(databaseStartedBeforeRedisFinished).toBe(true);
|
||||
expect(events).toEqual(["redis:start", "db:start", "redis:end", "db:end"]);
|
||||
});
|
||||
|
||||
test("includes the injected image revision without probing dependencies", async () => {
|
||||
const report = await buildWorkerHealthReport({ APP_REVISION: "abc1234", WORKER_ENABLED: "false" }, { databaseProbe: vi.fn() });
|
||||
|
||||
expect(report.revision).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("does not block webhook-capable worker readiness when Mailjet delivery config is absent", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "webhook",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.mailjetConfigured).toBe(false);
|
||||
expect(report.mailjetStatus).toBe("skipped");
|
||||
expect(report.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports queue-group-scoped queues for the mail worker", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
...mailjetEnv,
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.workerGroup).toBe("mail");
|
||||
expect(report.queues).toEqual([queueNames.email]);
|
||||
});
|
||||
|
||||
test("reports general required-mode gaps as redacted health diagnostics", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"OUTBOX_DISPATCHER_ENABLED:missing-required-general-worker:Required when WORKER_QUEUE_GROUP=general and OUTBOX_DISPATCHER_REQUIRED=true.",
|
||||
"WORKFLOW_TRIGGER_OUTBOX_ENABLED:missing-required-general-worker:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true.",
|
||||
"API_ACCESS_TOKEN:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true with http or hybrid executor mode.",
|
||||
"WORKFLOW_TRIGGER_EXECUTOR_URL:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true with http or hybrid executor mode.",
|
||||
])
|
||||
);
|
||||
expect(JSON.stringify(report)).not.toContain("postgresql://localhost/app");
|
||||
});
|
||||
|
||||
test("does not accept the legacy workflow executor token without the web API token", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_EXECUTOR_TOKEN: "legacy-worker-only",
|
||||
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.com",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.errors).toContain(
|
||||
"API_ACCESS_TOKEN:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true with http or hybrid executor mode."
|
||||
);
|
||||
});
|
||||
|
||||
test("reports workflow required-mode outbox dispatcher enablement gaps explicitly", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
API_ACCESS_TOKEN: "redacted-test-token",
|
||||
APP_URL: "https://app.example.com",
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.errors).toEqual([
|
||||
"OUTBOX_DISPATCHER_ENABLED:missing-workflow-trigger-dispatcher:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true.",
|
||||
]);
|
||||
expect(JSON.stringify(report)).not.toContain("redacted-test-token");
|
||||
expect(JSON.stringify(report)).not.toContain("https://app.example.com");
|
||||
});
|
||||
|
||||
test("reports explicitly enabled workflow outbox bridge gaps even when not required", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "false",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "false",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.errors).toEqual([
|
||||
"API_ACCESS_TOKEN:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_ENABLED=true with http or hybrid executor mode.",
|
||||
"WORKFLOW_TRIGGER_EXECUTOR_URL:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_ENABLED=true with http or hybrid executor mode.",
|
||||
]);
|
||||
expect(JSON.stringify(report)).not.toContain("postgresql://localhost/app");
|
||||
});
|
||||
|
||||
test("accepts general required-mode workflow fanout when native executor mode is explicit", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_EXECUTOR_MODE: "native",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("accepts general required-mode workflow fanout when native executor mode has whitespace", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_EXECUTOR_MODE: " native ",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports invalid workflow executor mode before required-mode bootstrap fails later", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
API_ACCESS_TOKEN: "redacted-test-token",
|
||||
APP_URL: "https://app.example.com",
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_EXECUTOR_MODE: "sidecar",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.errors).toEqual([
|
||||
"WORKFLOW_TRIGGER_EXECUTOR_MODE:invalid-workflow-executor-mode:Expected http, native, or hybrid when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true.",
|
||||
]);
|
||||
expect(JSON.stringify(report)).not.toContain("redacted-test-token");
|
||||
expect(JSON.stringify(report)).not.toContain("https://app.example.com");
|
||||
});
|
||||
|
||||
test("accepts general required-mode workflow fanout with signed HTTP bridge config", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
API_ACCESS_TOKEN: "redacted-test-token",
|
||||
APP_URL: "https://app.example.com",
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
OUTBOX_DISPATCHER_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
|
||||
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(JSON.stringify(report)).not.toContain("redacted-test-token");
|
||||
expect(JSON.stringify(report)).not.toContain("https://app.example.com");
|
||||
});
|
||||
|
||||
test("reports production plugin runtime gaps on the general worker", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "false",
|
||||
PLUGIN_CAPABILITY_USE_ENABLED: "false",
|
||||
PLUGIN_HOOK_FANOUT_ENABLED: "false",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.pluginRuntimeConfigured).toBe(true);
|
||||
expect(report.pluginRuntimeStatus).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PLUGIN_TRUSTED_PUBLIC_KEYS:missing-plugin-trust-root:Required when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true.",
|
||||
"OUTBOX_DISPATCHER_ENABLED:missing-plugin-runtime-dispatcher:Required on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true.",
|
||||
"PLUGIN_HOOK_FANOUT_ENABLED:disabled-plugin-runtime-fanout:Must not be false on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true.",
|
||||
"PLUGIN_CAPABILITY_USE_ENABLED:disabled-plugin-capability-use:Must not be false on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true.",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("reports production plugin runtime ready when trust root and general subscribers are configured", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
OUTBOX_DISPATCHER_ENABLED: "true",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
PLUGIN_TRUSTED_PUBLIC_KEYS: "aa".repeat(32),
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.pluginRuntimeConfigured).toBe(true);
|
||||
expect(report.pluginRuntimeStatus).toBe("ready");
|
||||
expect(report.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports plugin worker production runtime trust-root readiness", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "plugin",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.pluginRuntimeConfigured).toBe(true);
|
||||
expect(report.pluginRuntimeStatus).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(["PLUGIN_TRUSTED_PUBLIC_KEYS:missing-plugin-trust-root:Required when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true."]);
|
||||
});
|
||||
|
||||
test("reports required mail worker misconfigured when Mailjet env is absent", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.mailjetConfigured).toBe(false);
|
||||
expect(report.mailjetStatus).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MAILJET_API_KEY:missing-mail-provider-config:Required when WORKER_QUEUE_GROUP=mail and TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true.",
|
||||
"MAILJET_SECRET_KEY:missing-mail-provider-config:Required when WORKER_QUEUE_GROUP=mail and TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true.",
|
||||
"MAILJET_FROM_EMAIL:missing-mail-provider-config:Required when WORKER_QUEUE_GROUP=mail and TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true.",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("reports misconfigured and skips DB probe when enabled worker lacks DATABASE_URL", async () => {
|
||||
const databaseProbe = vi.fn();
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe, redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.databaseConfigured).toBe(false);
|
||||
expect(report.databaseStatus).toBe("skipped");
|
||||
expect(report.errors).toEqual(expect.arrayContaining(["DATABASE_URL:missing-required:DATABASE_URL is required when WORKER_ENABLED is true."]));
|
||||
expect(databaseProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports misconfigured and skips dependency probes when enabled worker lacks WORKER_QUEUE_GROUP", async () => {
|
||||
const databaseProbe = vi.fn();
|
||||
const redisProbe = vi.fn();
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
},
|
||||
{ databaseProbe, redisProbe }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.workerEnabled).toBe(true);
|
||||
expect(report.workerGroup).toBe(null);
|
||||
expect(report.errors).toEqual(expect.arrayContaining(["WORKER_QUEUE_GROUP:missing-required:WORKER_QUEUE_GROUP is required when WORKER_ENABLED is true."]));
|
||||
expect(databaseProbe).not.toHaveBeenCalled();
|
||||
expect(redisProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports the maintenance queue when export retention processing is enabled", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
EXPORT_RETENTION_QUEUE_ENABLED: "true",
|
||||
...mailjetEnv,
|
||||
STORAGE_ACCESS_KEY_ID: "access-key",
|
||||
STORAGE_BUCKET: "exports",
|
||||
STORAGE_ENDPOINT: "https://storage.example.com",
|
||||
STORAGE_REGION: "us-east-1",
|
||||
STORAGE_SECRET_ACCESS_KEY: "secret-key",
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.queues).toEqual([queueNames.notifications, queueNames.maintenance, queueNames.fileScan]);
|
||||
expect(report.storageStatus).toBe("ready");
|
||||
});
|
||||
|
||||
test("keeps the import worker group ready but idle until import runtime gates are enabled", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "import",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.queues).toEqual([]);
|
||||
expect(report.importStatus).toBe("skipped");
|
||||
expect(report.errors).not.toEqual(expect.arrayContaining([expect.stringMatching(/missing-import/u)]));
|
||||
});
|
||||
|
||||
test("reports misconfigured when import worker group is partially enabled", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
IMPORT_QUEUE_ENABLED: "true",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "import",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.queues).toEqual([]);
|
||||
expect(report.importStatus).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"IMPORT_QUEUE_RUNTIME_READY:missing-import-runtime-gate:Required with IMPORT_QUEUE_ENABLED=true after import, export, and scan services are wired.",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("reports misconfigured when export retention is enabled without storage delete config", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
EXPORT_RETENTION_QUEUE_ENABLED: "true",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report.status).toBe("misconfigured");
|
||||
expect(report.storageStatus).toBe("misconfigured");
|
||||
expect(report.errors).toEqual(expect.arrayContaining(["STORAGE_ENDPOINT:missing-storage-config:Required when EXPORT_RETENTION_QUEUE_ENABLED=true."]));
|
||||
});
|
||||
|
||||
test("falls back to REDIS_URL when BULLMQ_REDIS_URL is absent", async () => {
|
||||
const report = await buildWorkerHealthReport(
|
||||
{
|
||||
DATABASE_URL: "postgresql://localhost/app",
|
||||
REDIS_URL: "rediss://localhost:6380/1",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
databaseStatus: "ready",
|
||||
errors: [],
|
||||
redisConfigured: true,
|
||||
redisParseStatus: "valid",
|
||||
redisSource: "REDIS_URL",
|
||||
redisStatus: "ready",
|
||||
status: "ready",
|
||||
workerEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns non-zero for enabled worker with invalid Redis protocol and skips DB", async () => {
|
||||
const databaseProbe = vi.fn();
|
||||
const { io, readOutput } = createIo();
|
||||
const secretRedisUrl = "http://user:redis-secret@localhost:6379";
|
||||
const exitCode = await runWorkerHealthCli(
|
||||
{
|
||||
BULLMQ_REDIS_URL: secretRedisUrl,
|
||||
DATABASE_URL: "postgresql://db-secret@localhost/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
io,
|
||||
{ databaseProbe }
|
||||
);
|
||||
const output = readOutput();
|
||||
const report = JSON.parse(output);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(report).toMatchObject({
|
||||
databaseConfigured: true,
|
||||
databaseStatus: "skipped",
|
||||
redisConfigured: false,
|
||||
redisParseStatus: "invalid",
|
||||
redisSource: "BULLMQ_REDIS_URL",
|
||||
redisStatus: "skipped",
|
||||
status: "misconfigured",
|
||||
workerEnabled: true,
|
||||
});
|
||||
expect(report.errors).toEqual(["BULLMQ_REDIS_URL:invalid-redis-protocol:Configured Redis URL protocol must be redis: or rediss:."]);
|
||||
expect(databaseProbe).not.toHaveBeenCalled();
|
||||
expect(output).not.toContain(secretRedisUrl);
|
||||
expect(output).not.toContain("redis-secret");
|
||||
expect(output).not.toContain("db-secret");
|
||||
});
|
||||
|
||||
test("returns non-zero with redacted DB code when DB readiness fails", async () => {
|
||||
const { io, readOutput } = createIo();
|
||||
const exitCode = await runWorkerHealthCli(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://user:database-secret@localhost:5432/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
io,
|
||||
{
|
||||
databaseProbe: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("postgresql://user:database-secret@localhost:5432/app failed with token abc123"), {
|
||||
code: "P1001",
|
||||
stack: "secret stack trace",
|
||||
})
|
||||
),
|
||||
redisProbe: vi.fn(),
|
||||
}
|
||||
);
|
||||
const output = readOutput();
|
||||
const report = JSON.parse(output);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(report).toMatchObject({
|
||||
databaseConfigured: true,
|
||||
databaseErrorCode: "P1001",
|
||||
databaseStatus: "misconfigured",
|
||||
status: "misconfigured",
|
||||
});
|
||||
expect(output).not.toContain("database-secret");
|
||||
expect(output).not.toContain("abc123");
|
||||
expect(output).not.toContain("postgresql://user:database-secret@localhost:5432/app");
|
||||
expect(output).not.toContain("secret stack trace");
|
||||
});
|
||||
|
||||
test("returns non-zero with redacted Redis code when Redis readiness fails", async () => {
|
||||
const { io, readOutput } = createIo();
|
||||
const exitCode = await runWorkerHealthCli(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://user:redis-secret@localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://user:db-secret@localhost:5432/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
io,
|
||||
{
|
||||
databaseProbe: vi.fn(),
|
||||
redisProbe: vi.fn().mockRejectedValue(Object.assign(new Error("redis://user:redis-secret@localhost failed"), { code: "ECONNREFUSED" })),
|
||||
}
|
||||
);
|
||||
const output = readOutput();
|
||||
const report = JSON.parse(output);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(report).toMatchObject({
|
||||
redisErrorCode: "ECONNREFUSED",
|
||||
redisStatus: "misconfigured",
|
||||
status: "misconfigured",
|
||||
});
|
||||
expect(output).not.toContain("redis-secret");
|
||||
expect(output).not.toContain("db-secret");
|
||||
});
|
||||
|
||||
test("does not leak password-bearing Redis URLs in JSON output", async () => {
|
||||
const { io, readOutput } = createIo();
|
||||
const exitCode = await runWorkerHealthCli(
|
||||
{
|
||||
BULLMQ_REDIS_URL: "redis://user:super-secret@localhost:6379/0",
|
||||
DATABASE_URL: "postgresql://user:db-secret@localhost:5432/app",
|
||||
...mailjetEnv,
|
||||
WORKER_ENABLED: "true",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
},
|
||||
io,
|
||||
{ databaseProbe: vi.fn(), redisProbe: vi.fn() }
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(readOutput()).not.toContain("super-secret");
|
||||
expect(readOutput()).not.toContain("db-secret");
|
||||
expect(readOutput()).not.toContain("redis://user:super-secret@localhost:6379/0");
|
||||
expect(JSON.parse(readOutput())).toMatchObject({ databaseStatus: "ready", redisSource: "BULLMQ_REDIS_URL", redisStatus: "ready", status: "ready" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { loadWorkerConfig, type EnvRecord, type WorkerConfigDiagnostic, type WorkerConfigRedisParseStatus } from "@toolctrl/config";
|
||||
import { getDbClient } from "@toolctrl/db/client.server";
|
||||
import { isWorkerQueueGroup, parseRedisUrl, type QueueName, type WorkerQueueGroup } from "@toolctrl/queue";
|
||||
import { Queue } from "bullmq";
|
||||
import { getWorkerQueueNames } from "./queues.js";
|
||||
import { getMissingWorkerStorageEnv } from "./storage.js";
|
||||
|
||||
export type WorkerHealthEnv = EnvRecord;
|
||||
|
||||
export type WorkerHealthStatus = "disabled" | "ready" | "misconfigured";
|
||||
export type WorkerHealthRedisSource = "BULLMQ_REDIS_URL" | "REDIS_URL" | "none";
|
||||
|
||||
export type WorkerHealthDatabaseStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthRedisStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthStorageStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthImportStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthMailStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthPluginRuntimeStatus = "skipped" | "ready" | "misconfigured";
|
||||
export type WorkerHealthRequiredModeStatus = "skipped" | "ready" | "misconfigured";
|
||||
|
||||
export type WorkerHealthReport = {
|
||||
status: WorkerHealthStatus;
|
||||
revision: string;
|
||||
workerEnabled: boolean;
|
||||
workerGroup: WorkerQueueGroup | null;
|
||||
redisConfigured: boolean;
|
||||
redisSource: WorkerHealthRedisSource;
|
||||
redisParseStatus: "not_configured" | "valid" | "invalid";
|
||||
redisStatus: WorkerHealthRedisStatus;
|
||||
redisErrorCode?: string;
|
||||
databaseConfigured: boolean;
|
||||
databaseStatus: WorkerHealthDatabaseStatus;
|
||||
databaseErrorCode?: string;
|
||||
storageConfigured: boolean;
|
||||
storageStatus: WorkerHealthStorageStatus;
|
||||
mailjetConfigured: boolean;
|
||||
mailjetStatus: WorkerHealthMailStatus;
|
||||
importConfigured: boolean;
|
||||
importStatus: WorkerHealthImportStatus;
|
||||
pluginRuntimeConfigured: boolean;
|
||||
pluginRuntimeStatus: WorkerHealthPluginRuntimeStatus;
|
||||
queues: readonly QueueName[];
|
||||
errors: readonly string[];
|
||||
};
|
||||
|
||||
export type WorkerHealthCliIo = {
|
||||
stderr?: Pick<NodeJS.WriteStream, "write">;
|
||||
stdout: Pick<NodeJS.WriteStream, "write">;
|
||||
};
|
||||
|
||||
export type WorkerDatabaseProbe = () => Promise<void> | void;
|
||||
export type WorkerRedisProbe = (redisUrl: string) => Promise<void> | void;
|
||||
|
||||
export type WorkerHealthDependencies = {
|
||||
databaseProbe?: WorkerDatabaseProbe;
|
||||
redisProbe?: WorkerRedisProbe;
|
||||
};
|
||||
|
||||
export async function buildWorkerHealthReport(
|
||||
env: WorkerHealthEnv = process.env as WorkerHealthEnv,
|
||||
dependencies: WorkerHealthDependencies = {}
|
||||
): Promise<WorkerHealthReport> {
|
||||
const result = loadWorkerConfig(env);
|
||||
const { config } = result;
|
||||
const shouldProbeDatabase = config.enabled && result.ok;
|
||||
const shouldProbeRedis = config.enabled && result.ok;
|
||||
const databaseConfigured = Boolean(env.DATABASE_URL);
|
||||
const [redisReadiness, databaseReadiness] = await Promise.all([
|
||||
checkRedisReadiness(shouldProbeRedis, config.redis.url, dependencies.redisProbe),
|
||||
checkDatabaseReadiness(shouldProbeDatabase, dependencies.databaseProbe),
|
||||
]);
|
||||
const storageReadiness = checkStorageReadiness(config.enabled && env.EXPORT_RETENTION_QUEUE_ENABLED === "true", env);
|
||||
const mailReadiness = checkMailReadiness(config.enabled, config.mailjet.configured, isMailProviderRequired(env), env);
|
||||
const importReadiness = checkImportReadiness(config.enabled && result.ok && getWorkerGroup(env) === "import", env);
|
||||
const pluginRuntimeReadiness = checkPluginRuntimeReadiness(config.enabled && result.ok, getWorkerGroup(env), env);
|
||||
const requiredModeReadiness = checkRequiredGeneralWorkerReadiness(config.enabled && result.ok && getWorkerGroup(env) === "general", env);
|
||||
const status = toWorkerHealthStatus(
|
||||
config.enabled,
|
||||
result.ok,
|
||||
redisReadiness.status,
|
||||
databaseReadiness.status,
|
||||
storageReadiness.status,
|
||||
mailReadiness.status,
|
||||
importReadiness.status,
|
||||
pluginRuntimeReadiness.status,
|
||||
requiredModeReadiness.status
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
revision: env.APP_REVISION || "unknown",
|
||||
workerEnabled: config.enabled,
|
||||
workerGroup: getWorkerGroup(env),
|
||||
redisConfigured: config.redis.configured,
|
||||
redisSource: config.redis.source,
|
||||
redisParseStatus: toHealthRedisParseStatus(config.redis.parseStatus),
|
||||
redisStatus: redisReadiness.status,
|
||||
...(redisReadiness.errorCode ? { redisErrorCode: redisReadiness.errorCode } : {}),
|
||||
databaseConfigured,
|
||||
databaseStatus: databaseReadiness.status,
|
||||
...(databaseReadiness.errorCode ? { databaseErrorCode: databaseReadiness.errorCode } : {}),
|
||||
storageConfigured: storageReadiness.configured,
|
||||
storageStatus: storageReadiness.status,
|
||||
mailjetConfigured: mailReadiness.configured,
|
||||
mailjetStatus: mailReadiness.status,
|
||||
importConfigured: importReadiness.configured,
|
||||
importStatus: importReadiness.status,
|
||||
pluginRuntimeConfigured: pluginRuntimeReadiness.configured,
|
||||
pluginRuntimeStatus: pluginRuntimeReadiness.status,
|
||||
queues: getWorkerQueueNames(env),
|
||||
errors: [
|
||||
...result.diagnostics.map(formatWorkerConfigDiagnostic),
|
||||
...storageReadiness.errors,
|
||||
...mailReadiness.errors,
|
||||
...importReadiness.errors,
|
||||
...pluginRuntimeReadiness.errors,
|
||||
...requiredModeReadiness.errors,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkerGroup(env: WorkerHealthEnv): WorkerQueueGroup | null {
|
||||
const group = env.WORKER_QUEUE_GROUP;
|
||||
return typeof group === "string" && isWorkerQueueGroup(group) ? group : null;
|
||||
}
|
||||
|
||||
export async function runWorkerHealthCli(
|
||||
env: WorkerHealthEnv = process.env as WorkerHealthEnv,
|
||||
io: WorkerHealthCliIo = process,
|
||||
dependencies: WorkerHealthDependencies = {}
|
||||
): Promise<number> {
|
||||
const report = await buildWorkerHealthReport(env, dependencies);
|
||||
io.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
return report.status === "misconfigured" ? 1 : 0;
|
||||
}
|
||||
|
||||
function toWorkerHealthStatus(
|
||||
workerEnabled: boolean,
|
||||
redisReady: boolean,
|
||||
redisStatus: WorkerHealthRedisStatus,
|
||||
databaseStatus: WorkerHealthDatabaseStatus,
|
||||
storageStatus: WorkerHealthStorageStatus,
|
||||
mailStatus: WorkerHealthMailStatus,
|
||||
importStatus: WorkerHealthImportStatus,
|
||||
pluginRuntimeStatus: WorkerHealthPluginRuntimeStatus,
|
||||
requiredModeStatus: WorkerHealthRequiredModeStatus
|
||||
): WorkerHealthStatus {
|
||||
if (!workerEnabled) {
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
if (
|
||||
!redisReady ||
|
||||
redisStatus === "misconfigured" ||
|
||||
databaseStatus === "misconfigured" ||
|
||||
storageStatus === "misconfigured" ||
|
||||
mailStatus === "misconfigured" ||
|
||||
importStatus === "misconfigured" ||
|
||||
pluginRuntimeStatus === "misconfigured" ||
|
||||
requiredModeStatus === "misconfigured"
|
||||
) {
|
||||
return "misconfigured";
|
||||
}
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function checkPluginRuntimeReadiness(
|
||||
shouldCheck: boolean,
|
||||
workerGroup: WorkerQueueGroup | null,
|
||||
env: WorkerHealthEnv
|
||||
): { configured: boolean; errors: string[]; status: WorkerHealthPluginRuntimeStatus } {
|
||||
const productionRuntimeEnabled = env.PLUGIN_RUNTIME_PRODUCTION_ENABLED === "true";
|
||||
if (!shouldCheck || !productionRuntimeEnabled || (workerGroup !== "general" && workerGroup !== "plugin")) {
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
if (!hasTrimmedValue(env.PLUGIN_TRUSTED_PUBLIC_KEYS)) {
|
||||
errors.push("PLUGIN_TRUSTED_PUBLIC_KEYS:missing-plugin-trust-root:Required when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true.");
|
||||
}
|
||||
|
||||
if (workerGroup === "general") {
|
||||
if (!hasTrimmedValue(env.BULLMQ_REDIS_URL) && !hasTrimmedValue(env.REDIS_URL)) {
|
||||
errors.push(
|
||||
"BULLMQ_REDIS_URL:missing-plugin-runtime-redis:Required for plugin hook fanout and capability-use subscribers when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true."
|
||||
);
|
||||
}
|
||||
if (env.OUTBOX_DISPATCHER_ENABLED !== "true") {
|
||||
errors.push(
|
||||
"OUTBOX_DISPATCHER_ENABLED:missing-plugin-runtime-dispatcher:Required on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true."
|
||||
);
|
||||
}
|
||||
if (env.PLUGIN_HOOK_FANOUT_ENABLED === "false") {
|
||||
errors.push(
|
||||
"PLUGIN_HOOK_FANOUT_ENABLED:disabled-plugin-runtime-fanout:Must not be false on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true."
|
||||
);
|
||||
}
|
||||
if (env.PLUGIN_CAPABILITY_USE_ENABLED === "false") {
|
||||
errors.push(
|
||||
"PLUGIN_CAPABILITY_USE_ENABLED:disabled-plugin-capability-use:Must not be false on WORKER_QUEUE_GROUP=general when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { configured: true, errors, status: errors.length > 0 ? "misconfigured" : "ready" };
|
||||
}
|
||||
|
||||
function isMailProviderRequired(env: WorkerHealthEnv): boolean {
|
||||
return env.WORKER_QUEUE_GROUP === "mail" && env.TRANSACTIONAL_MAIL_OUTBOX_REQUIRED === "true";
|
||||
}
|
||||
|
||||
function checkRequiredGeneralWorkerReadiness(shouldCheck: boolean, env: WorkerHealthEnv): { errors: string[]; status: WorkerHealthRequiredModeStatus } {
|
||||
if (!shouldCheck) {
|
||||
return { errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (env.OUTBOX_DISPATCHER_REQUIRED === "true" && env.OUTBOX_DISPATCHER_ENABLED !== "true") {
|
||||
errors.push("OUTBOX_DISPATCHER_ENABLED:missing-required-general-worker:Required when WORKER_QUEUE_GROUP=general and OUTBOX_DISPATCHER_REQUIRED=true.");
|
||||
}
|
||||
|
||||
const workflowTriggerRequired = env.WORKFLOW_TRIGGER_OUTBOX_REQUIRED === "true";
|
||||
const workflowTriggerEnabled = env.WORKFLOW_TRIGGER_OUTBOX_ENABLED === "true";
|
||||
const workflowTriggerModeLabel = workflowTriggerRequired ? "WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true" : "WORKFLOW_TRIGGER_OUTBOX_ENABLED=true";
|
||||
|
||||
if (workflowTriggerRequired || workflowTriggerEnabled) {
|
||||
if (env.OUTBOX_DISPATCHER_ENABLED !== "true" && env.OUTBOX_DISPATCHER_REQUIRED !== "true") {
|
||||
errors.push(`OUTBOX_DISPATCHER_ENABLED:missing-workflow-trigger-dispatcher:Required when WORKER_QUEUE_GROUP=general and ${workflowTriggerModeLabel}.`);
|
||||
}
|
||||
|
||||
if (workflowTriggerRequired && !workflowTriggerEnabled) {
|
||||
errors.push(
|
||||
"WORKFLOW_TRIGGER_OUTBOX_ENABLED:missing-required-general-worker:Required when WORKER_QUEUE_GROUP=general and WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true."
|
||||
);
|
||||
}
|
||||
|
||||
const executorMode = parseWorkflowTriggerExecutorModeForHealth(env.WORKFLOW_TRIGGER_EXECUTOR_MODE);
|
||||
if (!executorMode) {
|
||||
errors.push(
|
||||
`WORKFLOW_TRIGGER_EXECUTOR_MODE:invalid-workflow-executor-mode:Expected http, native, or hybrid when WORKER_QUEUE_GROUP=general and ${workflowTriggerModeLabel}.`
|
||||
);
|
||||
} else if (workflowTriggerExecutorNeedsHttpBridge(executorMode)) {
|
||||
if (!hasTrimmedValue(env.API_ACCESS_TOKEN)) {
|
||||
errors.push(
|
||||
`API_ACCESS_TOKEN:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and ${workflowTriggerModeLabel} with http or hybrid executor mode.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasTrimmedValue(env.WORKFLOW_TRIGGER_EXECUTOR_URL) && !hasTrimmedValue(env.APP_URL) && !hasTrimmedValue(env.WEB_ORIGIN)) {
|
||||
errors.push(
|
||||
`WORKFLOW_TRIGGER_EXECUTOR_URL:missing-workflow-executor-bridge:Required when WORKER_QUEUE_GROUP=general and ${workflowTriggerModeLabel} with http or hybrid executor mode.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, status: errors.length > 0 ? "misconfigured" : "ready" };
|
||||
}
|
||||
|
||||
function parseWorkflowTriggerExecutorModeForHealth(value: unknown): "http" | "native" | "hybrid" | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return "http";
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "http" || normalized === "native" || normalized === "hybrid" ? normalized : null;
|
||||
}
|
||||
|
||||
function workflowTriggerExecutorNeedsHttpBridge(mode: "http" | "native" | "hybrid"): boolean {
|
||||
return mode !== "native";
|
||||
}
|
||||
|
||||
function hasTrimmedValue(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function checkMailReadiness(
|
||||
shouldCheck: boolean,
|
||||
configured: boolean,
|
||||
required: boolean,
|
||||
env: WorkerHealthEnv
|
||||
): { configured: boolean; errors: string[]; status: WorkerHealthMailStatus } {
|
||||
if (!shouldCheck) {
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
if (required) {
|
||||
const missing = ["MAILJET_API_KEY", "MAILJET_SECRET_KEY", "MAILJET_FROM_EMAIL"].filter((name) => !env[name]?.trim());
|
||||
return {
|
||||
configured: false,
|
||||
errors: missing.map(
|
||||
(name) => `${name}:missing-mail-provider-config:Required when WORKER_QUEUE_GROUP=mail and TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true.`
|
||||
),
|
||||
status: "misconfigured",
|
||||
};
|
||||
}
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
return { configured: true, errors: [], status: "ready" };
|
||||
}
|
||||
|
||||
function checkImportReadiness(shouldCheck: boolean, env: WorkerHealthEnv): { configured: boolean; errors: string[]; status: WorkerHealthImportStatus } {
|
||||
if (!shouldCheck) {
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
const importEnabled = env.IMPORT_QUEUE_ENABLED === "true";
|
||||
const runtimeReady = env.IMPORT_QUEUE_RUNTIME_READY === "true";
|
||||
if (!importEnabled && !runtimeReady) {
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
if (!importEnabled || !runtimeReady) {
|
||||
const errors: string[] = [];
|
||||
if (!importEnabled) {
|
||||
errors.push(
|
||||
"IMPORT_QUEUE_ENABLED:missing-import-runtime-gate:Required with IMPORT_QUEUE_RUNTIME_READY=true before WORKER_QUEUE_GROUP=import can consume toolctrl.imports/toolctrl.exports."
|
||||
);
|
||||
}
|
||||
if (!runtimeReady) {
|
||||
errors.push(
|
||||
"IMPORT_QUEUE_RUNTIME_READY:missing-import-runtime-gate:Required with IMPORT_QUEUE_ENABLED=true after import, export, and scan services are wired."
|
||||
);
|
||||
}
|
||||
return {
|
||||
configured: false,
|
||||
errors,
|
||||
status: "misconfigured",
|
||||
};
|
||||
}
|
||||
|
||||
return { configured: true, errors: [], status: "ready" };
|
||||
}
|
||||
|
||||
function checkStorageReadiness(shouldCheck: boolean, env: WorkerHealthEnv): { configured: boolean; errors: string[]; status: WorkerHealthStorageStatus } {
|
||||
if (!shouldCheck) {
|
||||
return { configured: false, errors: [], status: "skipped" };
|
||||
}
|
||||
|
||||
const missing = getMissingWorkerStorageEnv(env);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
configured: false,
|
||||
errors: missing.map((name) => `${name}:missing-storage-config:Required when EXPORT_RETENTION_QUEUE_ENABLED=true.`),
|
||||
status: "misconfigured",
|
||||
};
|
||||
}
|
||||
|
||||
return { configured: true, errors: [], status: "ready" };
|
||||
}
|
||||
|
||||
async function checkRedisReadiness(
|
||||
shouldProbe: boolean,
|
||||
redisUrl: string | undefined,
|
||||
redisProbe: WorkerRedisProbe = defaultRedisProbe
|
||||
): Promise<{ status: WorkerHealthRedisStatus; errorCode?: string }> {
|
||||
if (!shouldProbe || !redisUrl) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
try {
|
||||
await redisProbe(redisUrl);
|
||||
return { status: "ready" };
|
||||
} catch (error) {
|
||||
return { status: "misconfigured", errorCode: toRedactedDependencyErrorCode(error, "REDIS_READINESS_FAILED") };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabaseReadiness(
|
||||
shouldProbe: boolean,
|
||||
databaseProbe: WorkerDatabaseProbe = defaultDatabaseProbe
|
||||
): Promise<{ status: WorkerHealthDatabaseStatus; errorCode?: string }> {
|
||||
if (!shouldProbe) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
try {
|
||||
await databaseProbe();
|
||||
return { status: "ready" };
|
||||
} catch (error) {
|
||||
return { status: "misconfigured", errorCode: toRedactedDependencyErrorCode(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultDatabaseProbe(): Promise<void> {
|
||||
const db = getDbClient();
|
||||
|
||||
try {
|
||||
await db.$queryRaw`SELECT 1`;
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultRedisProbe(redisUrl: string): Promise<void> {
|
||||
const queue = new Queue("__toolctrl_worker_health__", {
|
||||
connection: parseRedisUrl(redisUrl),
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(queue.waitUntilReady(), 2_000);
|
||||
} finally {
|
||||
await queue.close();
|
||||
}
|
||||
}
|
||||
|
||||
function toHealthRedisParseStatus(status: WorkerConfigRedisParseStatus): WorkerHealthReport["redisParseStatus"] {
|
||||
if (status === "valid") {
|
||||
return "valid";
|
||||
}
|
||||
|
||||
if (status === "not-provided") {
|
||||
return "not_configured";
|
||||
}
|
||||
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function toRedactedDependencyErrorCode(error: unknown, fallback = "DATABASE_READINESS_FAILED"): string {
|
||||
if (typeof error === "object" && error !== null && "code" in error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (typeof code === "string" && /^[A-Z0-9_-]{1,64}$/i.test(code)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(Object.assign(new Error("Redis readiness timed out."), { code: "REDIS_TIMEOUT" })), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkerConfigDiagnostic(diagnostic: WorkerConfigDiagnostic): string {
|
||||
return `${diagnostic.name}:${diagnostic.code}:${diagnostic.message}`;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runWorkerHealthCli()
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch(() => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
bootstrapAuditEventPersist,
|
||||
type AuditEventPersistBootstrapDeps,
|
||||
type AuditEventPersistWorkerHandle,
|
||||
} from "./audit-event-persist-bootstrap.js";
|
||||
|
||||
type WorkerImpl = NonNullable<AuditEventPersistBootstrapDeps["createWorkerImpl"]>;
|
||||
|
||||
function fakeWorker(): AuditEventPersistWorkerHandle {
|
||||
return { name: "outbox.audit", async close() {} };
|
||||
}
|
||||
|
||||
describe("bootstrapAuditEventPersist", () => {
|
||||
it("disables itself when WORKER_QUEUE_GROUP is not general", () => {
|
||||
const result = bootstrapAuditEventPersist({ WORKER_QUEUE_GROUP: "mail" }, { runInTransaction: async (fn) => fn({} as never) });
|
||||
expect(result).toEqual({ status: "disabled", reason: "non-general worker group" });
|
||||
});
|
||||
|
||||
it("disables itself when AUDIT_EVENT_PERSIST_ENABLED=false", () => {
|
||||
const result = bootstrapAuditEventPersist(
|
||||
{ WORKER_QUEUE_GROUP: "general", AUDIT_EVENT_PERSIST_ENABLED: "false" },
|
||||
{ runInTransaction: async (fn) => fn({} as never) }
|
||||
);
|
||||
expect(result).toEqual({ status: "disabled", reason: "AUDIT_EVENT_PERSIST_ENABLED=false" });
|
||||
});
|
||||
|
||||
it("disables itself when no transaction runner is wired", () => {
|
||||
const result = bootstrapAuditEventPersist({ WORKER_QUEUE_GROUP: "general" }, {});
|
||||
expect(result.status).toBe("disabled");
|
||||
if (result.status === "disabled") {
|
||||
expect(result.reason).toContain("no transaction runner wired");
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribes a worker to outbox.audit with the default concurrency", () => {
|
||||
const inner = vi.fn(() => fakeWorker());
|
||||
const createWorkerImpl = inner as unknown as WorkerImpl;
|
||||
const result = bootstrapAuditEventPersist(
|
||||
{ WORKER_QUEUE_GROUP: "general" },
|
||||
{ runInTransaction: async (fn) => fn({} as never), createWorkerImpl }
|
||||
);
|
||||
expect(result.status).toBe("started");
|
||||
expect(inner).toHaveBeenCalledTimes(1);
|
||||
const args = inner.mock.calls[0] as unknown as [string, unknown, { concurrency?: number }];
|
||||
expect(args[0]).toBe("outbox.audit");
|
||||
expect(args[2]).toEqual({ concurrency: 5 });
|
||||
});
|
||||
|
||||
it("honours AUDIT_EVENT_PERSIST_CONCURRENCY override", () => {
|
||||
const inner = vi.fn(() => fakeWorker());
|
||||
const createWorkerImpl = inner as unknown as WorkerImpl;
|
||||
bootstrapAuditEventPersist(
|
||||
{ WORKER_QUEUE_GROUP: "general", AUDIT_EVENT_PERSIST_CONCURRENCY: "12" },
|
||||
{ runInTransaction: async (fn) => fn({} as never), createWorkerImpl }
|
||||
);
|
||||
const args = inner.mock.calls[0] as unknown as [string, unknown, { concurrency?: number }];
|
||||
expect(args[2]).toEqual({ concurrency: 12 });
|
||||
});
|
||||
|
||||
it("invokes the underlying processor when BullMQ delivers a job", async () => {
|
||||
let captured: ((job: { data: unknown }) => Promise<unknown>) | null = null;
|
||||
const inner = vi.fn((_queue: unknown, processor: unknown) => {
|
||||
captured = processor as (job: { data: unknown }) => Promise<unknown>;
|
||||
return fakeWorker();
|
||||
});
|
||||
const createWorkerImpl = inner as unknown as WorkerImpl;
|
||||
const runInTransaction = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
auditEvent: {
|
||||
findFirst: async () => null,
|
||||
create: async () => ({ id: "audit_1", eventHash: "h1" }),
|
||||
},
|
||||
})
|
||||
);
|
||||
bootstrapAuditEventPersist({ WORKER_QUEUE_GROUP: "general" }, { runInTransaction: runInTransaction as never, createWorkerImpl });
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
if (captured) {
|
||||
await (captured as (job: { data: unknown }) => Promise<unknown>)({
|
||||
data: {
|
||||
outboxEventId: "outbox_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "audit.event.create",
|
||||
payload: { action: "x.do", actorType: "system" },
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(runInTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Bootstrap helper for the audit-event outbox subscriber.
|
||||
*
|
||||
* Subscribes a BullMQ worker to the `outbox.audit` queue inside the
|
||||
* `general` worker group. Each delivered job carries an
|
||||
* `audit.event.create` outbox payload; the handler inserts the
|
||||
* AuditEvent with a freshly-computed chain hash inside a serializable
|
||||
* Prisma transaction (see runAuditEventPersistJob).
|
||||
*
|
||||
* Gating:
|
||||
* - WORKER_QUEUE_GROUP === "general" (the audit subscriber co-locates
|
||||
* with the outbox dispatcher to keep DB connections concentrated)
|
||||
* - AUDIT_EVENT_PERSIST_ENABLED !== "false" (escape hatch)
|
||||
*
|
||||
* Tests inject a fake `createWorkerImpl` to assert subscription
|
||||
* semantics without spinning up BullMQ.
|
||||
*/
|
||||
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
|
||||
import {
|
||||
AUDIT_EVENT_OUTBOX_QUEUE,
|
||||
runAuditEventPersistJob,
|
||||
type AuditEventPersistDependencies,
|
||||
type AuditEventPersistLogger,
|
||||
type AuditEventPersistTransactionRunner,
|
||||
} from "./audit-event-persist.js";
|
||||
|
||||
export type AuditEventPersistBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
AUDIT_EVENT_PERSIST_ENABLED?: string;
|
||||
AUDIT_EVENT_PERSIST_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface AuditEventPersistWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuditEventPersistBootstrapDeps {
|
||||
readonly logger?: AuditEventPersistLogger;
|
||||
/** Override the transaction runner. Production wires Prisma $transaction. */
|
||||
readonly runInTransaction?: AuditEventPersistTransactionRunner;
|
||||
/** BullMQ worker factory. Production builds a real Worker; tests fake it. */
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => AuditEventPersistWorkerHandle;
|
||||
}
|
||||
|
||||
export type AuditEventPersistBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: AuditEventPersistWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function bootstrapAuditEventPersist(
|
||||
env: AuditEventPersistBootstrapEnv,
|
||||
deps: AuditEventPersistBootstrapDeps = {}
|
||||
): AuditEventPersistBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.AUDIT_EVENT_PERSIST_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "AUDIT_EVENT_PERSIST_ENABLED=false" };
|
||||
}
|
||||
if (!deps.runInTransaction) {
|
||||
return { status: "disabled", reason: "no transaction runner wired (worker host must supply prisma.$transaction)" };
|
||||
}
|
||||
|
||||
const runInTransaction = deps.runInTransaction;
|
||||
const concurrency = parsePositiveInt(env.AUDIT_EVENT_PERSIST_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
|
||||
const processor = async (job: { data: unknown }): Promise<unknown> => {
|
||||
const handlerDeps: AuditEventPersistDependencies = {
|
||||
runInTransaction,
|
||||
...(logger ? { logger } : {}),
|
||||
};
|
||||
return runAuditEventPersistJob(job.data, handlerDeps);
|
||||
};
|
||||
|
||||
const createWorkerImpl = deps.createWorkerImpl ?? defaultCreateWorker;
|
||||
const handle = createWorkerImpl(AUDIT_EVENT_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.(
|
||||
{ component: "audit-event-persist", queue: AUDIT_EVENT_OUTBOX_QUEUE, concurrency },
|
||||
"audit-event-persist worker started"
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
|
||||
const defaultCreateWorker: NonNullable<AuditEventPersistBootstrapDeps["createWorkerImpl"]> = () => {
|
||||
throw new Error(
|
||||
"bootstrapAuditEventPersist requires deps.createWorkerImpl in this build; the worker entry must wire the BullMQ Worker constructor."
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { computeAuditHash } from "@toolctrl/db/audit-hash";
|
||||
import {
|
||||
AUDIT_EVENT_OUTBOX_QUEUE,
|
||||
AuditEventPersistJobError,
|
||||
parseAuditEventPersistJob,
|
||||
runAuditEventPersistJob,
|
||||
type AuditEventPersistTransactionRunner,
|
||||
type AuditEventPersistTxClient,
|
||||
} from "./audit-event-persist.js";
|
||||
|
||||
type PersistedRow = {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
action: string;
|
||||
eventHash: string;
|
||||
previousHash: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
function makeStore(): {
|
||||
rows: PersistedRow[];
|
||||
runInTransaction: AuditEventPersistTransactionRunner;
|
||||
} {
|
||||
const rows: PersistedRow[] = [];
|
||||
let idSeq = 0;
|
||||
let chainSeq = 0;
|
||||
const tx: AuditEventPersistTxClient = {
|
||||
auditEvent: {
|
||||
async findFirst({ where }) {
|
||||
const tenantRows = rows.filter((r) => r.tenantId === where.tenantId && r.eventHash !== null);
|
||||
if (tenantRows.length === 0) return null;
|
||||
// newest first
|
||||
tenantRows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return { eventHash: tenantRows[0]?.eventHash ?? null };
|
||||
},
|
||||
async create({ data }) {
|
||||
idSeq += 1;
|
||||
chainSeq += 1;
|
||||
const row: PersistedRow = {
|
||||
id: `audit_${idSeq}`,
|
||||
tenantId: data.tenantId,
|
||||
action: data.action,
|
||||
eventHash: data.eventHash,
|
||||
previousHash: data.previousHash,
|
||||
createdAt: new Date(2026, 4, 17, 0, 0, 0, chainSeq),
|
||||
};
|
||||
rows.push(row);
|
||||
return { id: row.id, eventHash: row.eventHash };
|
||||
},
|
||||
},
|
||||
};
|
||||
const runInTransaction: AuditEventPersistTransactionRunner = async <T>(fn: (tx: AuditEventPersistTxClient) => Promise<T>): Promise<T> => fn(tx);
|
||||
return { rows, runInTransaction };
|
||||
}
|
||||
|
||||
const baseJob = {
|
||||
outboxEventId: "outbox_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "audit.event.create",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorEmail: null,
|
||||
actorIp: null,
|
||||
actorUserAgent: null,
|
||||
action: "widget.created",
|
||||
targetType: "widget",
|
||||
targetId: "widget_1",
|
||||
requestId: null,
|
||||
sourceModule: "crm",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
regulatoryScope: ["soc2"],
|
||||
metadata: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("AUDIT_EVENT_OUTBOX_QUEUE", () => {
|
||||
it("matches the outbox audit queue name", () => {
|
||||
expect(AUDIT_EVENT_OUTBOX_QUEUE).toBe("outbox.audit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAuditEventPersistJob", () => {
|
||||
it("throws on missing data envelope", () => {
|
||||
expect(() => parseAuditEventPersistJob(null)).toThrowError(AuditEventPersistJobError);
|
||||
});
|
||||
|
||||
it("throws on wrong eventType", () => {
|
||||
expect(() => parseAuditEventPersistJob({ eventType: "marketing.lead.persist", payload: { action: "x" } })).toThrowError(AuditEventPersistJobError);
|
||||
});
|
||||
|
||||
it("throws on missing payload", () => {
|
||||
expect(() => parseAuditEventPersistJob({ eventType: "audit.event.create" })).toThrowError(AuditEventPersistJobError);
|
||||
});
|
||||
|
||||
it("throws on missing action", () => {
|
||||
expect(() =>
|
||||
parseAuditEventPersistJob({ eventType: "audit.event.create", payload: { actorType: "user" } })
|
||||
).toThrowError(AuditEventPersistJobError);
|
||||
});
|
||||
|
||||
it("normalizes payload through buildAuditEventCreateData", () => {
|
||||
const parsed = parseAuditEventPersistJob({
|
||||
eventType: "audit.event.create",
|
||||
tenantId: "tenant_1",
|
||||
payload: { action: "x.do", actorType: " ", regulatoryScope: ["SOC2"] },
|
||||
});
|
||||
expect(parsed.payload.actorType).toBe("system");
|
||||
expect(parsed.payload.regulatoryScope).toEqual(["soc2"]);
|
||||
expect(parsed.payload.outcome).toBe("success");
|
||||
expect(parsed.payload.dataSensitivity).toBe("internal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAuditEventPersistJob", () => {
|
||||
it("persists the event with prevHash=null on chain root", async () => {
|
||||
const { rows, runInTransaction } = makeStore();
|
||||
const result = await runAuditEventPersistJob(baseJob, { runInTransaction });
|
||||
expect(result).toEqual({ persisted: true, auditEventId: "audit_1", eventHash: expect.stringMatching(/^[0-9a-f]{64}$/) });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.previousHash).toBeNull();
|
||||
});
|
||||
|
||||
it("links the chain when a prior event exists for the same tenant", async () => {
|
||||
const { rows, runInTransaction } = makeStore();
|
||||
await runAuditEventPersistJob(baseJob, { runInTransaction });
|
||||
const second = await runAuditEventPersistJob(
|
||||
{ ...baseJob, payload: { ...baseJob.payload, action: "widget.updated" } },
|
||||
{ runInTransaction }
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]?.previousHash).toBe(rows[0]?.eventHash);
|
||||
expect(second.eventHash).toBe(rows[1]?.eventHash);
|
||||
});
|
||||
|
||||
it("isolates chains per tenant", async () => {
|
||||
const { rows, runInTransaction } = makeStore();
|
||||
await runAuditEventPersistJob(baseJob, { runInTransaction });
|
||||
await runAuditEventPersistJob(
|
||||
{ ...baseJob, tenantId: "tenant_2", payload: { ...baseJob.payload, tenantId: "tenant_2", action: "x.do" } },
|
||||
{ runInTransaction }
|
||||
);
|
||||
expect(rows[0]?.tenantId).toBe("tenant_1");
|
||||
expect(rows[1]?.tenantId).toBe("tenant_2");
|
||||
expect(rows[1]?.previousHash).toBeNull();
|
||||
});
|
||||
|
||||
it("computes an eventHash consistent with computeAuditHash", async () => {
|
||||
const { rows, runInTransaction } = makeStore();
|
||||
await runAuditEventPersistJob(baseJob, { runInTransaction, secret: "test-secret" });
|
||||
const recomputed = computeAuditHash({
|
||||
previousHash: null,
|
||||
payload: {
|
||||
id: "",
|
||||
createdAt: "",
|
||||
tenantId: "tenant_1",
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
actorEmail: null,
|
||||
actorIp: null,
|
||||
actorUserAgent: null,
|
||||
action: "widget.created",
|
||||
targetType: "widget",
|
||||
targetId: "widget_1",
|
||||
requestId: null,
|
||||
sourceModule: "crm",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
regulatoryScope: ["soc2"],
|
||||
metadata: null,
|
||||
},
|
||||
secret: "test-secret",
|
||||
});
|
||||
expect(rows[0]?.eventHash).toBe(recomputed);
|
||||
});
|
||||
|
||||
it("handles concurrent persists for the same tenant without breaking the chain when the tx runner serializes", async () => {
|
||||
// The production runner sets isolationLevel="Serializable". This
|
||||
// test simulates that by serializing the underlying mock store
|
||||
// through a single chained Promise. Each call sees the previously
|
||||
// committed row, so the chain stays consistent under parallel
|
||||
// dispatch.
|
||||
const { rows, runInTransaction: serialRunner } = makeStore();
|
||||
let gate: Promise<void> = Promise.resolve();
|
||||
const queuedRunner: AuditEventPersistTransactionRunner = async <T>(fn: (tx: AuditEventPersistTxClient) => Promise<T>): Promise<T> => {
|
||||
const prev = gate;
|
||||
let release!: () => void;
|
||||
gate = new Promise<void>((r) => (release = r));
|
||||
await prev;
|
||||
try {
|
||||
return await serialRunner(fn);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
const logger = { error: vi.fn() };
|
||||
await Promise.all([
|
||||
runAuditEventPersistJob(baseJob, { runInTransaction: queuedRunner, logger }),
|
||||
runAuditEventPersistJob(
|
||||
{ ...baseJob, payload: { ...baseJob.payload, action: "widget.updated" } },
|
||||
{ runInTransaction: queuedRunner, logger }
|
||||
),
|
||||
runAuditEventPersistJob(
|
||||
{ ...baseJob, payload: { ...baseJob.payload, action: "widget.deleted" } },
|
||||
{ runInTransaction: queuedRunner, logger }
|
||||
),
|
||||
]);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]?.previousHash).toBeNull();
|
||||
expect(rows[1]?.previousHash).toBe(rows[0]?.eventHash);
|
||||
expect(rows[2]?.previousHash).toBe(rows[1]?.eventHash);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Phase 10 Task #67 — Audit-event outbox handler.
|
||||
*
|
||||
* Subscribes to the `outbox.audit` BullMQ queue (populated by the
|
||||
* outbox dispatcher in apps/worker/src/outbox/dispatcher.ts) and
|
||||
* inserts the AuditEvent row into Postgres.
|
||||
*
|
||||
* The hash chain is computed inside a short Prisma transaction:
|
||||
* - SELECT the most-recent eventHash for the tenant (FOR UPDATE
|
||||
* equivalent via serializable isolation)
|
||||
* - compute the new event's eventHash
|
||||
* - INSERT the new row
|
||||
*
|
||||
* Running this under serializable isolation ensures concurrent persists
|
||||
* for the same tenant either commit in some order with a coherent
|
||||
* chain, or one of them retries on serialization-failure. The handler
|
||||
* is therefore safe to invoke from N parallel BullMQ workers without
|
||||
* corrupting the chain.
|
||||
*
|
||||
* Idempotency: the producer-side `publishAuditEventViaOutbox` writes
|
||||
* one outbox row per business-write. The dispatcher delivers each row
|
||||
* exactly once (claim+ack); on retry we may re-process the same
|
||||
* payload but the audit insert is a plain CREATE — duplicates simply
|
||||
* extend the chain by one extra row. The acceptable outcome here is
|
||||
* "at-least-once": a duplicated audit row is harmless evidence.
|
||||
*/
|
||||
|
||||
import { computeAuditHash, type AuditHashPayload } from "@toolctrl/db/audit-hash";
|
||||
import {
|
||||
buildAuditEventCreateData,
|
||||
type AuditEventCreateData,
|
||||
type AuditEventInput,
|
||||
} from "@toolctrl/db/audit-events.server";
|
||||
|
||||
export const AUDIT_EVENT_OUTBOX_QUEUE = "outbox.audit" as const;
|
||||
|
||||
export interface AuditEventPersistLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface AuditEventPersistTxClient {
|
||||
auditEvent: {
|
||||
findFirst(args: {
|
||||
where: { tenantId: string | null; eventHash: { not: null } };
|
||||
orderBy: { createdAt: "desc" };
|
||||
select: { eventHash: true };
|
||||
}): Promise<{ eventHash: string | null } | null>;
|
||||
create(args: {
|
||||
data: AuditEventCreateData & { previousHash: string | null; eventHash: string };
|
||||
}): Promise<{ id: string; eventHash: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditEventPersistTransactionRunner {
|
||||
/**
|
||||
* Run `fn` inside a Prisma transaction. Production wiring passes
|
||||
* `prisma.$transaction` with isolationLevel="Serializable" so that
|
||||
* the prevHash lookup + insert form an atomic chain link.
|
||||
*/
|
||||
<T>(fn: (tx: AuditEventPersistTxClient) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface AuditEventPersistDependencies {
|
||||
readonly runInTransaction: AuditEventPersistTransactionRunner;
|
||||
readonly logger?: AuditEventPersistLogger;
|
||||
/**
|
||||
* Override the hash-chain secret resolution for tests. Production
|
||||
* leaves this undefined so the helper reads `process.env.AUDIT_CHAIN_SECRET`.
|
||||
*/
|
||||
readonly secret?: string | null;
|
||||
}
|
||||
|
||||
export class AuditEventPersistJobError extends Error {
|
||||
override readonly name = "AuditEventPersistJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "INVALID_EVENT_TYPE" | "MISSING_ACTION";
|
||||
constructor(message: string, code: AuditEventPersistJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditEventPersistJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly eventType: string;
|
||||
readonly payload: AuditEventCreateData;
|
||||
}
|
||||
|
||||
export interface AuditEventPersistResult {
|
||||
readonly persisted: true;
|
||||
readonly auditEventId: string;
|
||||
readonly eventHash: string;
|
||||
}
|
||||
|
||||
export function parseAuditEventPersistJob(data: unknown): AuditEventPersistJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new AuditEventPersistJobError("audit-event persist job missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const eventType = typeof envelope.eventType === "string" ? envelope.eventType : "";
|
||||
if (eventType !== "audit.event.create") {
|
||||
throw new AuditEventPersistJobError(
|
||||
`audit-event persist job rejected eventType ${JSON.stringify(eventType)}`,
|
||||
"INVALID_EVENT_TYPE"
|
||||
);
|
||||
}
|
||||
const rawPayload = envelope.payload;
|
||||
if (!rawPayload || typeof rawPayload !== "object") {
|
||||
throw new AuditEventPersistJobError("audit-event persist job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const inner = rawPayload as Record<string, unknown>;
|
||||
const action = typeof inner.action === "string" ? inner.action.trim() : "";
|
||||
if (action.length === 0) {
|
||||
throw new AuditEventPersistJobError("audit-event persist job missing action", "MISSING_ACTION");
|
||||
}
|
||||
// Re-run normalization in case the producer emitted an older payload
|
||||
// shape — defensive. `buildAuditEventCreateData` is idempotent.
|
||||
const normalized = buildAuditEventCreateData(inner as unknown as AuditEventInput);
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId: typeof envelope.tenantId === "string" ? envelope.tenantId : null,
|
||||
eventType,
|
||||
payload: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function toHashPayload(data: AuditEventCreateData): AuditHashPayload {
|
||||
return {
|
||||
id: "",
|
||||
createdAt: "",
|
||||
tenantId: data.tenantId,
|
||||
actorType: data.actorType,
|
||||
actorId: data.actorId,
|
||||
actorEmail: data.actorEmail,
|
||||
actorIp: data.actorIp,
|
||||
actorUserAgent: data.actorUserAgent,
|
||||
action: data.action,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
requestId: data.requestId,
|
||||
sourceModule: data.sourceModule,
|
||||
outcome: data.outcome,
|
||||
dataSensitivity: data.dataSensitivity,
|
||||
regulatoryScope: data.regulatoryScope,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAuditEventPersistJob(
|
||||
data: unknown,
|
||||
deps: AuditEventPersistDependencies
|
||||
): Promise<AuditEventPersistResult> {
|
||||
const job = parseAuditEventPersistJob(data);
|
||||
const payload = job.payload;
|
||||
|
||||
const result = await deps.runInTransaction(async (tx) => {
|
||||
const prior = await tx.auditEvent.findFirst({
|
||||
where: { tenantId: payload.tenantId, eventHash: { not: null } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { eventHash: true },
|
||||
});
|
||||
const previousHash = prior?.eventHash ?? null;
|
||||
const eventHash = computeAuditHash({
|
||||
previousHash,
|
||||
payload: toHashPayload(payload),
|
||||
secret: deps.secret,
|
||||
});
|
||||
const inserted = await tx.auditEvent.create({
|
||||
data: { ...payload, previousHash, eventHash },
|
||||
});
|
||||
return { auditEventId: inserted.id, eventHash };
|
||||
});
|
||||
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
component: "audit-event-persist",
|
||||
tenantId: payload.tenantId,
|
||||
action: payload.action,
|
||||
auditEventId: result.auditEventId,
|
||||
outboxEventId: job.outboxEventId,
|
||||
},
|
||||
"audit-event persisted via outbox"
|
||||
);
|
||||
|
||||
return { persisted: true, auditEventId: result.auditEventId, eventHash: result.eventHash };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||
GDPR_AUDIT_RETENTION_DAYS,
|
||||
SOC2_AUDIT_RETENTION_DAYS,
|
||||
resolveAuditRetentionPolicy,
|
||||
runAuditRetentionJob,
|
||||
} from "./audit-retention.js";
|
||||
|
||||
describe("resolveAuditRetentionPolicy", () => {
|
||||
it("returns 7y for SOC2 + HIPAA tenants (HIPAA wins on label)", () => {
|
||||
expect(resolveAuditRetentionPolicy({ tenantId: "t1", regulatoryScopes: ["soc2"] })).toEqual({ retentionDays: SOC2_AUDIT_RETENTION_DAYS, source: "soc2" });
|
||||
expect(resolveAuditRetentionPolicy({ tenantId: "t1", regulatoryScopes: ["hipaa"] })).toEqual({ retentionDays: SOC2_AUDIT_RETENTION_DAYS, source: "hipaa" });
|
||||
expect(resolveAuditRetentionPolicy({ tenantId: "t1", regulatoryScopes: ["soc2", "hipaa"] })).toEqual({ retentionDays: SOC2_AUDIT_RETENTION_DAYS, source: "hipaa" });
|
||||
});
|
||||
|
||||
it("returns 6y for GDPR-only tenants", () => {
|
||||
expect(resolveAuditRetentionPolicy({ tenantId: "t1", regulatoryScopes: ["gdpr"] })).toEqual({ retentionDays: GDPR_AUDIT_RETENTION_DAYS, source: "gdpr" });
|
||||
});
|
||||
|
||||
it("returns 1y default for tenants with no regulatory scope", () => {
|
||||
expect(resolveAuditRetentionPolicy({ tenantId: "t1", regulatoryScopes: [] })).toEqual({ retentionDays: DEFAULT_AUDIT_RETENTION_DAYS, source: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAuditRetentionJob", () => {
|
||||
it("walks tenants, deletes rows, and emits a meta-audit for each non-empty sweep", async () => {
|
||||
const now = new Date("2026-05-17T00:00:00.000Z");
|
||||
const tenants = [
|
||||
{ tenantId: "t1", regulatoryScopes: ["soc2"] },
|
||||
{ tenantId: "t2", regulatoryScopes: [] },
|
||||
];
|
||||
const eventDeleter = vi.fn(async (tenantId: string, threshold: Date) => {
|
||||
void threshold;
|
||||
return tenantId === "t1" ? 42 : 0;
|
||||
});
|
||||
const auditWriter = vi.fn(async () => {});
|
||||
const result = await runAuditRetentionJob({
|
||||
now,
|
||||
tenantLister: async () => tenants,
|
||||
eventDeleter,
|
||||
auditWriter,
|
||||
});
|
||||
expect(result).toEqual({ inspectedTenants: 2, deletedRows: 42, failures: [] });
|
||||
expect(eventDeleter).toHaveBeenCalledTimes(2);
|
||||
// SOC2 -> 7y threshold
|
||||
const soc2Threshold = eventDeleter.mock.calls[0]?.[1];
|
||||
expect(soc2Threshold).toBeInstanceOf(Date);
|
||||
expect(soc2Threshold.toISOString()).toBe(new Date(now.getTime() - SOC2_AUDIT_RETENTION_DAYS * 86_400_000).toISOString());
|
||||
// Only t1 had deletions -> only one meta-audit
|
||||
expect(auditWriter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records failures without aborting other tenants", async () => {
|
||||
const now = new Date("2026-05-17T00:00:00.000Z");
|
||||
const tenants = [
|
||||
{ tenantId: "t-fail", regulatoryScopes: [] },
|
||||
{ tenantId: "t-ok", regulatoryScopes: [] },
|
||||
];
|
||||
const eventDeleter = vi.fn(async (tenantId: string) => {
|
||||
if (tenantId === "t-fail") throw new Error("boom");
|
||||
return 5;
|
||||
});
|
||||
const result = await runAuditRetentionJob({ now, tenantLister: async () => tenants, eventDeleter });
|
||||
expect(result.deletedRows).toBe(5);
|
||||
expect(result.failures).toEqual([{ tenantId: "t-fail", error: "boom" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Phase 10 Task #67 — Audit-Event retention worker.
|
||||
*
|
||||
* Walks every tenant on a daily cron, looks up the tenant's retention
|
||||
* window (plan-based default; configurable per-tenant via an override
|
||||
* JSON in a future task), and deletes AuditEvent rows older than the
|
||||
* window. Deletions are themselves audited as a system-action event so
|
||||
* the cleanup itself is traceable — see
|
||||
* docs/features/compliance-audit.md.
|
||||
*
|
||||
* Deliberately host-agnostic: the worker bootstrap (apps/worker/src/index.ts)
|
||||
* supplies the seams (tenantLister, eventDeleter, auditWriter) so this
|
||||
* file is trivially unit-testable without a real database.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 365; // baseline / "none" scope
|
||||
export const SOC2_AUDIT_RETENTION_DAYS = 7 * 365; // SOC2 + HIPAA: 7y
|
||||
export const GDPR_AUDIT_RETENTION_DAYS = 6 * 365; // GDPR: 6y
|
||||
|
||||
export type AuditRetentionPolicy = {
|
||||
/** Hard cap in days. Events older than this are pruned. */
|
||||
retentionDays: number;
|
||||
/** Free-form label for log lines (`gdpr` | `soc2` | `plan:enterprise` | …). */
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type AuditRetentionTenant = {
|
||||
tenantId: string;
|
||||
/** Highest regulatoryScope used by events for this tenant. */
|
||||
regulatoryScopes: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
export type AuditRetentionInput = {
|
||||
/** Reference "now" — defaults to `new Date()`. Injected for tests. */
|
||||
now?: Date;
|
||||
/** List tenants (each with the regulatoryScopes ever used by their events). */
|
||||
tenantLister: () => Promise<ReadonlyArray<AuditRetentionTenant>>;
|
||||
/** Delete events with createdAt < threshold for tenantId. Returns row count. */
|
||||
eventDeleter: (tenantId: string, threshold: Date) => Promise<number>;
|
||||
/** Optional audit writer for the meta-audit (cleanup itself). */
|
||||
auditWriter?: (input: { tenantId: string; deletedCount: number; threshold: Date; policy: AuditRetentionPolicy }) => Promise<void>;
|
||||
/** Logger seam. */
|
||||
logger?: { info: (fields: Record<string, unknown>, msg: string) => void; error?: (fields: Record<string, unknown>, msg: string) => void };
|
||||
/** Optional override for plan-resolved policy per tenant. */
|
||||
policyResolver?: (tenant: AuditRetentionTenant) => AuditRetentionPolicy;
|
||||
};
|
||||
|
||||
export function resolveAuditRetentionPolicy(tenant: AuditRetentionTenant): AuditRetentionPolicy {
|
||||
const scopes = new Set(tenant.regulatoryScopes.map((s) => s.toLowerCase()));
|
||||
if (scopes.has("soc2") || scopes.has("hipaa")) {
|
||||
return { retentionDays: SOC2_AUDIT_RETENTION_DAYS, source: scopes.has("hipaa") ? "hipaa" : "soc2" };
|
||||
}
|
||||
if (scopes.has("gdpr")) {
|
||||
return { retentionDays: GDPR_AUDIT_RETENTION_DAYS, source: "gdpr" };
|
||||
}
|
||||
return { retentionDays: DEFAULT_AUDIT_RETENTION_DAYS, source: "default" };
|
||||
}
|
||||
|
||||
export type AuditRetentionResult = {
|
||||
inspectedTenants: number;
|
||||
deletedRows: number;
|
||||
failures: Array<{ tenantId: string; error: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the retention sweep across every tenant. Caller (cron bootstrap)
|
||||
* decides scheduling — this function is a single pass.
|
||||
*/
|
||||
export async function runAuditRetentionJob(input: AuditRetentionInput): Promise<AuditRetentionResult> {
|
||||
const now = input.now ?? new Date();
|
||||
const tenants = await input.tenantLister();
|
||||
let deletedRows = 0;
|
||||
const failures: AuditRetentionResult["failures"] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
try {
|
||||
const policy = (input.policyResolver ?? resolveAuditRetentionPolicy)(tenant);
|
||||
const threshold = new Date(now.getTime() - policy.retentionDays * 86_400_000);
|
||||
const count = await input.eventDeleter(tenant.tenantId, threshold);
|
||||
deletedRows += count;
|
||||
input.logger?.info({ tenantId: tenant.tenantId, deletedCount: count, retentionDays: policy.retentionDays, policySource: policy.source }, "audit.retention.tenant.swept");
|
||||
if (count > 0 && input.auditWriter) {
|
||||
await input.auditWriter({ tenantId: tenant.tenantId, deletedCount: count, threshold, policy });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push({ tenantId: tenant.tenantId, error: message });
|
||||
input.logger?.error?.({ tenantId: tenant.tenantId, error: message }, "audit.retention.tenant.failed");
|
||||
}
|
||||
}
|
||||
|
||||
input.logger?.info({ inspected: tenants.length, deletedRows, failures: failures.length }, "audit.retention.complete");
|
||||
return { inspectedTenants: tenants.length, deletedRows, failures };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { USER_FK_MAP } from "@toolctrl/db/dsgvo/user-fk-map";
|
||||
|
||||
import { runDsgvoDeletion, type DsgvoDeletionDependencies, type DsgvoDeletionJobInput } from "./dsgvo-deletion.js";
|
||||
|
||||
function makeDeps(overrides?: Partial<DsgvoDeletionDependencies>): {
|
||||
deps: DsgvoDeletionDependencies;
|
||||
audits: Array<Parameters<DsgvoDeletionDependencies["writeAuditEvent"]>[0]>;
|
||||
hardDeletes: string[];
|
||||
pseudoTables: Array<{ model: string; column: string }>;
|
||||
tombstones: Array<{ model: string; column: string }>;
|
||||
hardDeleteUserCalls: string[];
|
||||
pseudonymizeUserCalls: string[];
|
||||
} {
|
||||
const audits: Array<Parameters<DsgvoDeletionDependencies["writeAuditEvent"]>[0]> = [];
|
||||
const hardDeletes: string[] = [];
|
||||
const pseudoTables: Array<{ model: string; column: string }> = [];
|
||||
const tombstones: Array<{ model: string; column: string }> = [];
|
||||
const hardDeleteUserCalls: string[] = [];
|
||||
const pseudonymizeUserCalls: string[] = [];
|
||||
|
||||
const deps: DsgvoDeletionDependencies = {
|
||||
tableClient: {
|
||||
hardDelete: vi.fn().mockImplementation(async (entry: { model: string; column: string }) => {
|
||||
hardDeletes.push(`${entry.model}.${entry.column}`);
|
||||
return 1;
|
||||
}),
|
||||
pseudonymize: vi.fn().mockImplementation(async (entry: { model: string; column: string }) => {
|
||||
pseudoTables.push({ model: entry.model, column: entry.column });
|
||||
return 2;
|
||||
}),
|
||||
retainTombstone: vi.fn().mockImplementation(async (entry: { model: string; column: string }) => {
|
||||
tombstones.push({ model: entry.model, column: entry.column });
|
||||
return 3;
|
||||
}),
|
||||
},
|
||||
userClient: {
|
||||
hardDeleteUser: vi.fn().mockImplementation(async (userId: string) => {
|
||||
hardDeleteUserCalls.push(userId);
|
||||
}),
|
||||
pseudonymizeUser: vi.fn().mockImplementation(async (userId: string) => {
|
||||
pseudonymizeUserCalls.push(userId);
|
||||
}),
|
||||
},
|
||||
writeAuditEvent: vi.fn().mockImplementation(async (input) => {
|
||||
audits.push(input);
|
||||
}),
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, audits, hardDeletes, pseudoTables, tombstones, hardDeleteUserCalls, pseudonymizeUserCalls };
|
||||
}
|
||||
|
||||
const baseJob: DsgvoDeletionJobInput = {
|
||||
userId: "u1",
|
||||
tenantId: "t1",
|
||||
requestId: "req-1",
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
mode: "delete",
|
||||
};
|
||||
|
||||
describe("runDsgvoDeletion", () => {
|
||||
it("applies hard-delete, pseudonymize and retain-tombstone strategies across the user-fk-map", async () => {
|
||||
const { deps, audits, hardDeletes, pseudoTables, tombstones, hardDeleteUserCalls } = makeDeps();
|
||||
const result = await runDsgvoDeletion(deps, baseJob);
|
||||
|
||||
const expectedHardDelete = USER_FK_MAP.filter((e) => e.strategy === "hard-delete").length;
|
||||
const expectedPseudo = USER_FK_MAP.filter((e) => e.strategy === "pseudonymize").length;
|
||||
const expectedTombstone = USER_FK_MAP.filter((e) => e.strategy === "retain-tombstone").length;
|
||||
|
||||
expect(hardDeletes).toHaveLength(expectedHardDelete);
|
||||
expect(pseudoTables).toHaveLength(expectedPseudo);
|
||||
expect(tombstones).toHaveLength(expectedTombstone);
|
||||
expect(hardDeleteUserCalls).toEqual(["u1"]);
|
||||
|
||||
expect(result.tables).toHaveLength(USER_FK_MAP.length);
|
||||
expect(result.userOutcome).toBe("deleted");
|
||||
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0]?.action).toBe("DELETE_USER");
|
||||
expect(audits[0]?.outcome).toBe("success");
|
||||
expect(audits[0]?.regulatoryScope).toEqual(["gdpr"]);
|
||||
});
|
||||
|
||||
it("pseudonymizes the user when mode=pseudonymize", async () => {
|
||||
const { deps, audits, pseudonymizeUserCalls, hardDeleteUserCalls } = makeDeps();
|
||||
const result = await runDsgvoDeletion(deps, { ...baseJob, mode: "pseudonymize", actorType: "admin", actorId: "admin-1", reason: "court order" });
|
||||
|
||||
expect(pseudonymizeUserCalls).toEqual(["u1"]);
|
||||
expect(hardDeleteUserCalls).toEqual([]);
|
||||
expect(result.userOutcome).toBe("pseudonymized");
|
||||
expect(audits[0]?.action).toBe("PSEUDONYMIZE_USER");
|
||||
expect(audits[0]?.metadata).toMatchObject({ mode: "pseudonymize", reason: "court order" });
|
||||
});
|
||||
|
||||
it("audits failure and rethrows when a table strategy errors mid-run", async () => {
|
||||
const { deps, audits } = makeDeps({
|
||||
tableClient: {
|
||||
hardDelete: vi.fn().mockRejectedValue(new Error("db lock")),
|
||||
pseudonymize: vi.fn().mockResolvedValue(0),
|
||||
retainTombstone: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
});
|
||||
await expect(runDsgvoDeletion(deps, baseJob)).rejects.toThrow(/db lock/);
|
||||
expect(audits.at(-1)?.outcome).toBe("failure");
|
||||
expect(audits.at(-1)?.metadata).toMatchObject({ error: expect.stringContaining("db lock") });
|
||||
});
|
||||
|
||||
it("never deletes the AuditEvent rows even when mode=delete (SOC2-CC6.1 audit-trail integrity)", async () => {
|
||||
const { deps, hardDeletes, pseudoTables } = makeDeps();
|
||||
await runDsgvoDeletion(deps, baseJob);
|
||||
expect(hardDeletes).not.toContain("AuditEvent.actorId");
|
||||
expect(pseudoTables.some((t) => t.model === "AuditEvent" && t.column === "actorId")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Phase 10 Task #68 — DSGVO deletion-cascade worker.
|
||||
*
|
||||
* Walks every entry in `USER_FK_MAP` and applies its declared strategy
|
||||
* to the rows that reference the deletion target:
|
||||
*
|
||||
* • hard-delete → `model.deleteMany({ where: { column: userId } })`.
|
||||
* • pseudonymize → `model.updateMany(..., { column: pseudonym.id })`
|
||||
* with a deterministic pseudonym (see
|
||||
* packages/db/src/dsgvo/pseudonymize.ts). When the
|
||||
* model also stores an email or other PII column,
|
||||
* caller-supplied `pseudonymizeRow` may rewrite it.
|
||||
* • retain-tombstone → `model.updateMany(..., { column: null })` —
|
||||
* the column is nullable per schema; the row
|
||||
* outlives the user but loses the reference.
|
||||
*
|
||||
* Once every entry is processed the canonical `User` row itself is
|
||||
* deleted (hard delete) unless `mode === 'pseudonymize'` in which case
|
||||
* the row is kept and the deterministic-pseudonym field set is written
|
||||
* via `buildUserPseudonymizationUpdate`.
|
||||
*
|
||||
* The worker is host-agnostic — the bootstrap supplies the table
|
||||
* client + audit writer so this file is fully unit-testable.
|
||||
*/
|
||||
|
||||
import { USER_FK_MAP, type UserFkMapEntry } from "@toolctrl/db/dsgvo/user-fk-map";
|
||||
import { buildUserPseudonymizationUpdate, pseudonymize } from "@toolctrl/db/dsgvo/pseudonymize";
|
||||
|
||||
export type DsgvoDeletionMode = "delete" | "pseudonymize";
|
||||
|
||||
export type DsgvoDeletionJobInput = {
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
requestId: string;
|
||||
actorType: "user" | "admin" | "system";
|
||||
actorId: string;
|
||||
mode: DsgvoDeletionMode;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type DsgvoDeletionTableResult = {
|
||||
model: string;
|
||||
column: string;
|
||||
strategy: UserFkMapEntry["strategy"];
|
||||
affectedRows: number;
|
||||
};
|
||||
|
||||
export type DsgvoDeletionResult = {
|
||||
userId: string;
|
||||
requestId: string;
|
||||
mode: DsgvoDeletionMode;
|
||||
tables: DsgvoDeletionTableResult[];
|
||||
userOutcome: "deleted" | "pseudonymized";
|
||||
};
|
||||
|
||||
export type DsgvoDeletionTableClient = {
|
||||
hardDelete: (entry: UserFkMapEntry, userId: string) => Promise<number>;
|
||||
pseudonymize: (entry: UserFkMapEntry, userId: string, pseudonymUserId: string) => Promise<number>;
|
||||
retainTombstone: (entry: UserFkMapEntry, userId: string) => Promise<number>;
|
||||
};
|
||||
|
||||
export type DsgvoDeletionUserClient = {
|
||||
hardDeleteUser: (userId: string) => Promise<void>;
|
||||
pseudonymizeUser: (userId: string, update: ReturnType<typeof buildUserPseudonymizationUpdate>) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DsgvoDeletionDependencies = {
|
||||
tableClient: DsgvoDeletionTableClient;
|
||||
userClient: DsgvoDeletionUserClient;
|
||||
writeAuditEvent: (input: {
|
||||
tenantId: string | null;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
regulatoryScope: string[];
|
||||
dataSensitivity: "pii";
|
||||
outcome: "success" | "failure";
|
||||
metadata: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
logger: { info: (fields: Record<string, unknown>, msg: string) => void; error: (fields: Record<string, unknown>, msg: string) => void };
|
||||
};
|
||||
|
||||
export async function runDsgvoDeletion(
|
||||
deps: DsgvoDeletionDependencies,
|
||||
job: DsgvoDeletionJobInput,
|
||||
): Promise<DsgvoDeletionResult> {
|
||||
const pseudo = pseudonymize(job.userId);
|
||||
const tables: DsgvoDeletionTableResult[] = [];
|
||||
|
||||
try {
|
||||
for (const entry of USER_FK_MAP) {
|
||||
let affectedRows = 0;
|
||||
switch (entry.strategy) {
|
||||
case "hard-delete":
|
||||
// In `mode=pseudonymize` we *still* hard-delete from join/RBAC
|
||||
// tables (TenantUser, UserRole, GroupUser): keeping these rows
|
||||
// would let the pseudonymized identity stay attached to active
|
||||
// workspaces, which contradicts the user's deletion intent. The
|
||||
// pseudonymization mode only protects retention-bound rows.
|
||||
affectedRows = await deps.tableClient.hardDelete(entry, job.userId);
|
||||
break;
|
||||
case "pseudonymize":
|
||||
affectedRows = await deps.tableClient.pseudonymize(entry, job.userId, pseudo.pseudoId);
|
||||
break;
|
||||
case "retain-tombstone":
|
||||
affectedRows = await deps.tableClient.retainTombstone(entry, job.userId);
|
||||
break;
|
||||
}
|
||||
tables.push({ model: entry.model, column: entry.column, strategy: entry.strategy, affectedRows });
|
||||
}
|
||||
|
||||
let userOutcome: "deleted" | "pseudonymized";
|
||||
if (job.mode === "pseudonymize") {
|
||||
await deps.userClient.pseudonymizeUser(job.userId, buildUserPseudonymizationUpdate(job.userId));
|
||||
userOutcome = "pseudonymized";
|
||||
} else {
|
||||
await deps.userClient.hardDeleteUser(job.userId);
|
||||
userOutcome = "deleted";
|
||||
}
|
||||
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: job.actorId,
|
||||
actorType: job.actorType,
|
||||
action: job.mode === "pseudonymize" ? "PSEUDONYMIZE_USER" : "DELETE_USER",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: "success",
|
||||
metadata: {
|
||||
requestId: job.requestId,
|
||||
mode: job.mode,
|
||||
tablesProcessed: tables.length,
|
||||
rowsAffected: tables.reduce((sum, t) => sum + t.affectedRows, 0),
|
||||
reason: job.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
deps.logger.info(
|
||||
{ userId: job.userId, requestId: job.requestId, mode: job.mode, tables: tables.length },
|
||||
"dsgvo-deletion.complete",
|
||||
);
|
||||
|
||||
return { userId: job.userId, requestId: job.requestId, mode: job.mode, tables, userOutcome };
|
||||
} catch (error) {
|
||||
deps.logger.error({ userId: job.userId, requestId: job.requestId, err: String(error) }, "dsgvo-deletion.failed");
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: job.actorId,
|
||||
actorType: job.actorType,
|
||||
action: job.mode === "pseudonymize" ? "PSEUDONYMIZE_USER" : "DELETE_USER",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: "failure",
|
||||
metadata: {
|
||||
requestId: job.requestId,
|
||||
mode: job.mode,
|
||||
tablesProcessed: tables.length,
|
||||
error: String(error),
|
||||
reason: job.reason ?? null,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
runDsgvoExport,
|
||||
type DsgvoExportDependencies,
|
||||
type DsgvoExportJobInput,
|
||||
type DsgvoExportSink,
|
||||
} from "./dsgvo-export.js";
|
||||
|
||||
function makeSink(): { sink: DsgvoExportSink; collected: () => string } {
|
||||
const writeStream = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
writeStream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
// Swallow stream-destroy errors so the "iterator throws" test does not
|
||||
// surface as an uncaught exception on the runner. The job rethrows the
|
||||
// original error via its own catch block — that's what the test asserts.
|
||||
writeStream.on("error", () => undefined);
|
||||
const completionPromise = new Promise<{ bucket: string; key: string; sizeBytes: number }>((resolve) => {
|
||||
writeStream.on("end", () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
resolve({ bucket: "test-bucket", key: "user-data/test.json", sizeBytes: buf.byteLength });
|
||||
});
|
||||
});
|
||||
return {
|
||||
sink: { writeStream, completionPromise },
|
||||
collected: () => Buffer.concat(chunks).toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: Partial<DsgvoExportDependencies>): {
|
||||
deps: DsgvoExportDependencies;
|
||||
audits: Array<Parameters<DsgvoExportDependencies["writeAuditEvent"]>[0]>;
|
||||
mails: Array<Parameters<DsgvoExportDependencies["sendExportReadyEmail"]>[0]>;
|
||||
collected: () => string;
|
||||
} {
|
||||
const { sink, collected } = makeSink();
|
||||
const audits: Array<Parameters<DsgvoExportDependencies["writeAuditEvent"]>[0]> = [];
|
||||
const mails: Array<Parameters<DsgvoExportDependencies["sendExportReadyEmail"]>[0]> = [];
|
||||
|
||||
const deps: DsgvoExportDependencies = {
|
||||
loadUser: vi.fn().mockResolvedValue({
|
||||
id: "u1",
|
||||
email: "alice@example.com",
|
||||
firstName: "Alice",
|
||||
lastName: "Anderson",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
tableSource: {
|
||||
async *iterate(_userId, entry) {
|
||||
// Return one row per "Log.userId" section so we can assert order.
|
||||
if (entry.model === "Log") {
|
||||
yield { id: "log1", message: "hello" };
|
||||
}
|
||||
},
|
||||
},
|
||||
openSink: () => sink,
|
||||
buildSignedUrl: vi.fn().mockResolvedValue({ url: "https://s3/signed", expiresAt: new Date("2026-02-01T00:00:00Z") }),
|
||||
writeAuditEvent: vi.fn().mockImplementation(async (input) => {
|
||||
audits.push(input);
|
||||
}),
|
||||
sendExportReadyEmail: vi.fn().mockImplementation(async (input) => {
|
||||
mails.push(input);
|
||||
}),
|
||||
buildObjectKey: () => ({ bucket: "test-bucket", key: "user-data/test.json" }),
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, audits, mails, collected };
|
||||
}
|
||||
|
||||
const baseJob: DsgvoExportJobInput = {
|
||||
userId: "u1",
|
||||
tenantId: "t1",
|
||||
requestId: "req-1",
|
||||
actorType: "user",
|
||||
actorId: "u1",
|
||||
};
|
||||
|
||||
describe("runDsgvoExport", () => {
|
||||
it("streams a JSON bundle, audits success, and sends the export-ready email", async () => {
|
||||
const { deps, audits, mails, collected } = makeDeps();
|
||||
const result = await runDsgvoExport(deps, baseJob);
|
||||
|
||||
expect(result.bucket).toBe("test-bucket");
|
||||
expect(result.key).toBe("user-data/test.json");
|
||||
expect(result.rowCount).toBeGreaterThan(0);
|
||||
expect(result.signedUrl).toBe("https://s3/signed");
|
||||
|
||||
const body = collected();
|
||||
const parsed = JSON.parse(body);
|
||||
expect(parsed.bundleVersion).toBe("1.0");
|
||||
expect(parsed.requestId).toBe("req-1");
|
||||
expect(parsed.user.email).toBe("alice@example.com");
|
||||
expect(parsed.sections["Log.userId"]).toHaveLength(1);
|
||||
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0]?.action).toBe("EXPORT_PERSONAL_DATA");
|
||||
expect(audits[0]?.outcome).toBe("success");
|
||||
expect(audits[0]?.regulatoryScope).toEqual(["gdpr"]);
|
||||
expect(audits[0]?.dataSensitivity).toBe("pii");
|
||||
|
||||
expect(mails).toHaveLength(1);
|
||||
expect(mails[0]?.toEmail).toBe("alice@example.com");
|
||||
expect(mails[0]?.signedUrl).toBe("https://s3/signed");
|
||||
});
|
||||
|
||||
it("audits a failure when the user cannot be loaded", async () => {
|
||||
const { deps, audits } = makeDeps({
|
||||
loadUser: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
await expect(runDsgvoExport(deps, baseJob)).rejects.toThrow(/not found/);
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0]?.outcome).toBe("failure");
|
||||
expect(audits[0]?.metadata).toMatchObject({ error: "user_not_found" });
|
||||
});
|
||||
|
||||
it("audits a failure and rethrows when a table iterator throws", async () => {
|
||||
const { deps, audits } = makeDeps({
|
||||
tableSource: {
|
||||
async *iterate() {
|
||||
throw new Error("db boom");
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(runDsgvoExport(deps, baseJob)).rejects.toThrow(/db boom/);
|
||||
expect(audits.at(-1)?.outcome).toBe("failure");
|
||||
});
|
||||
|
||||
it("forwards the actorType and reason into the audit metadata for admin-initiated exports", async () => {
|
||||
const { deps, audits } = makeDeps();
|
||||
await runDsgvoExport(deps, { ...baseJob, actorType: "admin", actorId: "admin-1", reason: "user requested via support ticket #123" });
|
||||
expect(audits[0]?.actorType).toBe("admin");
|
||||
expect(audits[0]?.metadata).toMatchObject({ reason: "user requested via support ticket #123" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Phase 10 Task #68 — DSGVO personal-data export worker.
|
||||
*
|
||||
* Aggregates every row that references `userId` across the
|
||||
* USER_FK_MAP (see packages/db/src/dsgvo/user-fk-map.ts) plus the
|
||||
* canonical `User` row, serialises the result as a single
|
||||
* UTF-8 JSON bundle, and streams it to S3 via the multipart sink
|
||||
* shared with Phase 03 #46.
|
||||
*
|
||||
* The worker is host-agnostic: the bootstrap (apps/worker/src/index.ts)
|
||||
* passes in the dependencies (table iterator, sink factory, audit
|
||||
* writer, signed-URL builder, mailer) so this module can be unit-tested
|
||||
* in isolation without S3 / Prisma / SES.
|
||||
*
|
||||
* Job lifecycle:
|
||||
* 1. Resolve the user (loadUser).
|
||||
* 2. For each entry in USER_FK_MAP with includeInExport=true, stream
|
||||
* its rows into the JSON bundle under a section keyed by model.
|
||||
* 3. Finalise the multipart upload, mint a signed URL with the
|
||||
* configured validity, send the "export ready" notification email,
|
||||
* and emit an AuditEvent with action=EXPORT_PERSONAL_DATA,
|
||||
* regulatoryScope=[gdpr], dataSensitivity=pii.
|
||||
*
|
||||
* Errors short-circuit the pipeline; the caller (BullMQ worker) is
|
||||
* responsible for retry semantics. The sink aborts its multipart upload
|
||||
* automatically on stream destruction so no orphan parts linger in S3.
|
||||
*/
|
||||
|
||||
import type { Writable } from "node:stream";
|
||||
|
||||
import { USER_FK_MAP, type UserFkMapEntry } from "@toolctrl/db/dsgvo/user-fk-map";
|
||||
|
||||
export type DsgvoExportJobInput = {
|
||||
userId: string;
|
||||
/** Tenant scope for the AuditEvent + S3 metadata. */
|
||||
tenantId: string | null;
|
||||
/** Idempotency token (mirrors User.deletionRequestId for repeats). */
|
||||
requestId: string;
|
||||
/** Initiator: 'user' (self-service) or 'admin' (operator-on-behalf). */
|
||||
actorType: "user" | "admin";
|
||||
/** Actor identity for the AuditEvent. */
|
||||
actorId: string;
|
||||
/** Optional admin reason (admin path only). */
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type DsgvoExportSection = {
|
||||
model: string;
|
||||
column: string;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export type DsgvoExportResult = {
|
||||
bucket: string;
|
||||
key: string;
|
||||
rowCount: number;
|
||||
sizeBytes: number;
|
||||
signedUrl: string;
|
||||
signedUrlExpiresAt: Date;
|
||||
sections: DsgvoExportSection[];
|
||||
};
|
||||
|
||||
export type DsgvoExportSink = {
|
||||
writeStream: Writable;
|
||||
completionPromise: Promise<{ bucket: string; key: string; sizeBytes: number }>;
|
||||
};
|
||||
|
||||
export type DsgvoExportTableSource = {
|
||||
/**
|
||||
* Async-iterate every row in `entry.model` filtered by
|
||||
* `${entry.column} = userId`. Implementations typically use a Prisma
|
||||
* cursor; the worker buffers nothing.
|
||||
*/
|
||||
iterate(userId: string, entry: UserFkMapEntry): AsyncIterable<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type DsgvoExportUserRecord = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: Date;
|
||||
phone?: string | null;
|
||||
avatar?: string | null;
|
||||
locale?: string | null;
|
||||
};
|
||||
|
||||
export type DsgvoExportDependencies = {
|
||||
/** Load the canonical User row (header section of the bundle). */
|
||||
loadUser: (userId: string) => Promise<DsgvoExportUserRecord | null>;
|
||||
/** Iterate every user-fk-map entry that opts into export. */
|
||||
tableSource: DsgvoExportTableSource;
|
||||
/** Open the streaming sink (typically createS3MultipartUploadSink). */
|
||||
openSink: (objectKey: string, contentType: string, metadata: Record<string, string>) => DsgvoExportSink;
|
||||
/** Mint a presigned URL pointing at the finished artifact. */
|
||||
buildSignedUrl: (input: { bucket: string; key: string }) => Promise<{ url: string; expiresAt: Date }>;
|
||||
/** Persist the AuditEvent (createAuditEvent). */
|
||||
writeAuditEvent: (input: {
|
||||
tenantId: string | null;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
regulatoryScope: string[];
|
||||
dataSensitivity: "pii";
|
||||
outcome: "success" | "failure";
|
||||
metadata: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
/** Notify the user that the export is ready. */
|
||||
sendExportReadyEmail: (input: {
|
||||
userId: string;
|
||||
toEmail: string;
|
||||
signedUrl: string;
|
||||
expiresAt: Date;
|
||||
sizeBytes: number;
|
||||
rowCount: number;
|
||||
}) => Promise<void>;
|
||||
/** Object-key builder — caller controls bucket + path layout. */
|
||||
buildObjectKey: (input: { userId: string; requestId: string }) => { bucket: string; key: string };
|
||||
/** Logger seam — no console.* in production code. */
|
||||
logger: { info: (fields: Record<string, unknown>, msg: string) => void; error: (fields: Record<string, unknown>, msg: string) => void };
|
||||
};
|
||||
|
||||
const EXPORT_CONTENT_TYPE = "application/json; charset=utf-8";
|
||||
const BUNDLE_VERSION = "1.0";
|
||||
|
||||
export async function runDsgvoExport(deps: DsgvoExportDependencies, job: DsgvoExportJobInput): Promise<DsgvoExportResult> {
|
||||
const user = await deps.loadUser(job.userId);
|
||||
if (!user) {
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: job.actorId,
|
||||
actorType: job.actorType,
|
||||
action: "EXPORT_PERSONAL_DATA",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: "failure",
|
||||
metadata: { requestId: job.requestId, error: "user_not_found", reason: job.reason ?? null },
|
||||
});
|
||||
throw new Error(`dsgvo-export: user ${job.userId} not found`);
|
||||
}
|
||||
|
||||
const exportEntries = USER_FK_MAP.filter((e) => e.includeInExport);
|
||||
const sections: DsgvoExportSection[] = [];
|
||||
let totalRows = 0;
|
||||
const { key } = deps.buildObjectKey({ userId: job.userId, requestId: job.requestId });
|
||||
const sink = deps.openSink(key, EXPORT_CONTENT_TYPE, {
|
||||
"x-tc-user-id": job.userId,
|
||||
"x-tc-request-id": job.requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
sink.writeStream.write(`{\n "bundleVersion": ${JSON.stringify(BUNDLE_VERSION)},\n`);
|
||||
sink.writeStream.write(` "exportedAt": ${JSON.stringify(new Date().toISOString())},\n`);
|
||||
sink.writeStream.write(` "requestId": ${JSON.stringify(job.requestId)},\n`);
|
||||
sink.writeStream.write(` "user": ${JSON.stringify(user)},\n`);
|
||||
sink.writeStream.write(` "sections": {\n`);
|
||||
|
||||
let firstSection = true;
|
||||
for (const entry of exportEntries) {
|
||||
if (!firstSection) sink.writeStream.write(",\n");
|
||||
firstSection = false;
|
||||
sink.writeStream.write(` ${JSON.stringify(`${entry.model}.${entry.column}`)}: [`);
|
||||
let rowCount = 0;
|
||||
let firstRow = true;
|
||||
for await (const row of deps.tableSource.iterate(job.userId, entry)) {
|
||||
if (!firstRow) sink.writeStream.write(",");
|
||||
firstRow = false;
|
||||
sink.writeStream.write(JSON.stringify(row));
|
||||
rowCount += 1;
|
||||
}
|
||||
sink.writeStream.write("]");
|
||||
sections.push({ model: entry.model, column: entry.column, rowCount });
|
||||
totalRows += rowCount;
|
||||
}
|
||||
|
||||
sink.writeStream.write("\n }\n}\n");
|
||||
sink.writeStream.end();
|
||||
const completion = await sink.completionPromise;
|
||||
|
||||
const { url, expiresAt } = await deps.buildSignedUrl({ bucket: completion.bucket, key: completion.key });
|
||||
|
||||
await deps.sendExportReadyEmail({
|
||||
userId: job.userId,
|
||||
toEmail: user.email,
|
||||
signedUrl: url,
|
||||
expiresAt,
|
||||
sizeBytes: completion.sizeBytes,
|
||||
rowCount: totalRows,
|
||||
});
|
||||
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: job.actorId,
|
||||
actorType: job.actorType,
|
||||
action: "EXPORT_PERSONAL_DATA",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: "success",
|
||||
metadata: {
|
||||
requestId: job.requestId,
|
||||
rowCount: totalRows,
|
||||
sizeBytes: completion.sizeBytes,
|
||||
sectionCount: sections.length,
|
||||
bucket: completion.bucket,
|
||||
key: completion.key,
|
||||
reason: job.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
deps.logger.info({ userId: job.userId, requestId: job.requestId, rowCount: totalRows }, "dsgvo-export.complete");
|
||||
|
||||
return {
|
||||
bucket: completion.bucket,
|
||||
key: completion.key,
|
||||
rowCount: totalRows,
|
||||
sizeBytes: completion.sizeBytes,
|
||||
signedUrl: url,
|
||||
signedUrlExpiresAt: expiresAt,
|
||||
sections,
|
||||
};
|
||||
} catch (error) {
|
||||
sink.writeStream.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
deps.logger.error({ userId: job.userId, requestId: job.requestId, err: String(error) }, "dsgvo-export.failed");
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: job.actorId,
|
||||
actorType: job.actorType,
|
||||
action: "EXPORT_PERSONAL_DATA",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: "failure",
|
||||
metadata: { requestId: job.requestId, error: String(error), reason: job.reason ?? null },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DSGVO_GRACE_CRON_EVERY_MS,
|
||||
DSGVO_GRACE_CRON_JOB_ID,
|
||||
registerDsgvoGraceCron,
|
||||
runDsgvoGraceCron,
|
||||
type DsgvoGraceCandidate,
|
||||
} from "./dsgvo-grace-cron.js";
|
||||
|
||||
function makeLogger() {
|
||||
return { info: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
describe("dsgvo-grace-cron", () => {
|
||||
it("dispatches one deletion per overdue user", async () => {
|
||||
const now = new Date("2026-06-17T00:00:00Z");
|
||||
const candidates: DsgvoGraceCandidate[] = [
|
||||
{ userId: "u1", tenantId: "t1", scheduledDeletionAt: new Date("2026-06-01T00:00:00Z"), deletionRequestId: "req-1" },
|
||||
{ userId: "u2", tenantId: null, scheduledDeletionAt: new Date("2026-06-10T00:00:00Z"), deletionRequestId: null },
|
||||
];
|
||||
const enqueue = vi.fn().mockResolvedValue({ jobId: "job-x" });
|
||||
const logger = makeLogger();
|
||||
const result = await runDsgvoGraceCron({
|
||||
listOverdueUsers: async () => candidates,
|
||||
enqueueDeletion: enqueue,
|
||||
now: () => now,
|
||||
logger,
|
||||
});
|
||||
expect(result.scanned).toBe(2);
|
||||
expect(result.dispatched).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||
expect(enqueue.mock.calls[0][0].requestId).toBe("req-1");
|
||||
expect(enqueue.mock.calls[0][0].mode).toBe("delete");
|
||||
expect(enqueue.mock.calls[0][0].actorType).toBe("system");
|
||||
expect(enqueue.mock.calls[0][0].reason).toBe("dsgvo-grace-period-elapsed");
|
||||
// u2 had no deletionRequestId — synthesize a grace-derived one.
|
||||
expect(enqueue.mock.calls[1][0].requestId.startsWith("grace-u2-")).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ userId: "u1" }), "dsgvo-grace-cron.dispatched");
|
||||
});
|
||||
|
||||
it("isolates per-candidate failures", async () => {
|
||||
const candidates: DsgvoGraceCandidate[] = [
|
||||
{ userId: "ok", tenantId: null, scheduledDeletionAt: new Date(), deletionRequestId: null },
|
||||
{ userId: "bad", tenantId: null, scheduledDeletionAt: new Date(), deletionRequestId: null },
|
||||
{ userId: "ok2", tenantId: null, scheduledDeletionAt: new Date(), deletionRequestId: null },
|
||||
];
|
||||
const enqueue = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ jobId: "j1" })
|
||||
.mockRejectedValueOnce(new Error("redis down"))
|
||||
.mockResolvedValueOnce({ jobId: "j3" });
|
||||
const logger = makeLogger();
|
||||
const result = await runDsgvoGraceCron({
|
||||
listOverdueUsers: async () => candidates,
|
||||
enqueueDeletion: enqueue,
|
||||
logger,
|
||||
});
|
||||
expect(result.scanned).toBe(3);
|
||||
expect(result.dispatched).toBe(2);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.failures[0]).toEqual({ userId: "bad", error: "redis down" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "bad", err: "redis down" }),
|
||||
"dsgvo-grace-cron.dispatch-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns zero when no candidates", async () => {
|
||||
const enqueue = vi.fn();
|
||||
const logger = makeLogger();
|
||||
const result = await runDsgvoGraceCron({
|
||||
listOverdueUsers: async () => [],
|
||||
enqueueDeletion: enqueue,
|
||||
logger,
|
||||
});
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.dispatched).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registerDsgvoGraceCron upserts a 24h scheduler with discriminator-shaped data", async () => {
|
||||
const upsert = vi.fn().mockResolvedValue(undefined);
|
||||
await registerDsgvoGraceCron({ upsertJobScheduler: upsert });
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
expect(upsert.mock.calls[0][0]).toBe(DSGVO_GRACE_CRON_JOB_ID);
|
||||
expect(upsert.mock.calls[0][1]).toEqual({ every: DSGVO_GRACE_CRON_EVERY_MS });
|
||||
// The scheduled-job template MUST carry the CleanupJob discriminator
|
||||
// so the maintenance handler can dispatch on payload.task. Without
|
||||
// this, every tick raised UnsupportedWorkerJobError in staging.
|
||||
expect(upsert.mock.calls[0][2]).toEqual({
|
||||
name: DSGVO_GRACE_CRON_JOB_ID,
|
||||
data: { kind: "cleanup.run", task: "dsgvo-grace-period-scan" },
|
||||
});
|
||||
expect(DSGVO_GRACE_CRON_EVERY_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Phase 10 Task #68 — DSGVO grace-period cron.
|
||||
*
|
||||
* Daily job that scans `User` rows with `pendingDeletion=true` and
|
||||
* `scheduledDeletionAt <= now()`, then dispatches a per-user
|
||||
* dsgvo-deletion job. The cron itself never deletes data — it merely
|
||||
* fans out work to the existing cascade worker (apps/worker/src/jobs/
|
||||
* dsgvo-deletion.ts) so all the audit + per-table-strategy invariants
|
||||
* stay in one place.
|
||||
*
|
||||
* Host-agnostic: callers supply the user-reader, the dispatcher, and
|
||||
* the audit/logger shims. This keeps the file fully unit-testable
|
||||
* without hauling in BullMQ or Prisma. The worker bootstrap wires it
|
||||
* to the real Prisma + BullMQ implementations.
|
||||
*
|
||||
* Cadence is once per 24h. We deliberately do not run more often:
|
||||
* - Deletion is irreversible past the grace window, so a slightly
|
||||
* delayed dispatch is acceptable (and indeed safer than a tight
|
||||
* loop).
|
||||
* - One row per overdue user per day is enough — re-runs are
|
||||
* idempotent because the dsgvo-deletion job clears the User row.
|
||||
*
|
||||
* Repeat-job registration is exported as `registerDsgvoGraceCron` and
|
||||
* called from `apps/worker/src/index.ts` on the `general` worker
|
||||
* group. We use BullMQ's `Queue.upsertJobScheduler` API so re-deploys
|
||||
* do not stack duplicate schedules.
|
||||
*/
|
||||
|
||||
export const DSGVO_GRACE_CRON_JOB_ID = "dsgvo-grace-period-scan";
|
||||
export const DSGVO_GRACE_CRON_EVERY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type DsgvoGraceCandidate = {
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
scheduledDeletionAt: Date;
|
||||
deletionRequestId: string | null;
|
||||
};
|
||||
|
||||
export type DsgvoGraceCronDependencies = {
|
||||
/** Returns every User row currently overdue (pendingDeletion=true AND scheduledDeletionAt<=now). */
|
||||
listOverdueUsers: (now: Date) => Promise<DsgvoGraceCandidate[]>;
|
||||
/**
|
||||
* Enqueue one dsgvo-deletion cascade for the given candidate. Resolves
|
||||
* with a job id for logging. May throw — failures are isolated per
|
||||
* candidate so one bad row does not block the rest.
|
||||
*/
|
||||
enqueueDeletion: (input: {
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
requestId: string;
|
||||
actorType: "system";
|
||||
actorId: string;
|
||||
mode: "delete";
|
||||
reason: string;
|
||||
}) => Promise<{ jobId?: string }>;
|
||||
/** Optional. Defaults to () => new Date(). */
|
||||
now?: () => Date;
|
||||
logger: {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
/** Stable actor id stamped on dispatched-cascade audit rows. Default "dsgvo-grace-cron". */
|
||||
systemActorId?: string;
|
||||
};
|
||||
|
||||
export type DsgvoGraceCronResult = {
|
||||
scanned: number;
|
||||
dispatched: number;
|
||||
failed: number;
|
||||
failures: Array<{ userId: string; error: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run one pass of the grace-period scan.
|
||||
*
|
||||
* Failure-isolation: if one candidate's dispatch throws, log + continue.
|
||||
* Returning a non-zero `failed` count gives the BullMQ wrapper a clear
|
||||
* signal to surface in metrics without blackholing the rest of the batch.
|
||||
*/
|
||||
export async function runDsgvoGraceCron(deps: DsgvoGraceCronDependencies): Promise<DsgvoGraceCronResult> {
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
const systemActorId = deps.systemActorId ?? "dsgvo-grace-cron";
|
||||
const candidates = await deps.listOverdueUsers(now);
|
||||
let dispatched = 0;
|
||||
const failures: Array<{ userId: string; error: string }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
// Reuse the deletion-request-id from the original user request so
|
||||
// the dsgvo-deletion cascade's AuditEvent.metadata.requestId
|
||||
// correlates back to the self-service POST that started it.
|
||||
const requestId = candidate.deletionRequestId ?? `grace-${candidate.userId}-${now.getTime()}`;
|
||||
const result = await deps.enqueueDeletion({
|
||||
userId: candidate.userId,
|
||||
tenantId: candidate.tenantId,
|
||||
requestId,
|
||||
actorType: "system",
|
||||
actorId: systemActorId,
|
||||
mode: "delete",
|
||||
reason: "dsgvo-grace-period-elapsed",
|
||||
});
|
||||
dispatched += 1;
|
||||
deps.logger.info(
|
||||
{ userId: candidate.userId, requestId, jobId: result.jobId ?? null, scheduledDeletionAt: candidate.scheduledDeletionAt.toISOString() },
|
||||
"dsgvo-grace-cron.dispatched",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
failures.push({ userId: candidate.userId, error: message });
|
||||
deps.logger.error({ userId: candidate.userId, err: message }, "dsgvo-grace-cron.dispatch-failed");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: candidates.length,
|
||||
dispatched,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for the worker bootstrap. Given a BullMQ-like queue with the
|
||||
* `upsertJobScheduler` API, install (or replace) the 24h repeat schedule.
|
||||
* Pinned to the queue's default job name + a stable scheduler key so a
|
||||
* second call is a no-op (idempotent on redeploys).
|
||||
*/
|
||||
export async function registerDsgvoGraceCron(queue: {
|
||||
upsertJobScheduler: (
|
||||
schedulerId: string,
|
||||
options: { every: number },
|
||||
template: { name: string; data?: Record<string, unknown> },
|
||||
) => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
// The job-template `data` must satisfy the maintenance CleanupJob
|
||||
// discriminator so the registry-level handler can route on
|
||||
// `payload.task` (see apps/worker/src/handlers.ts maintenance branch).
|
||||
// Prior shape `{ kind: "dsgvo-grace-cron" }` had no `task` field and
|
||||
// tripped UnsupportedWorkerJobError on every tick.
|
||||
await queue.upsertJobScheduler(
|
||||
DSGVO_GRACE_CRON_JOB_ID,
|
||||
{ every: DSGVO_GRACE_CRON_EVERY_MS },
|
||||
{ name: DSGVO_GRACE_CRON_JOB_ID, data: { kind: "cleanup.run", task: "dsgvo-grace-period-scan" } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { USER_FK_MAP } from "@toolctrl/db/dsgvo/user-fk-map";
|
||||
|
||||
import { runDsgvoVerify, type DsgvoVerifyDependencies } from "./dsgvo-verify.js";
|
||||
|
||||
function makeDeps(countRemainingImpl: (model: string, column: string) => number): {
|
||||
deps: DsgvoVerifyDependencies;
|
||||
audits: Array<Parameters<DsgvoVerifyDependencies["writeAuditEvent"]>[0]>;
|
||||
escalations: Array<Parameters<NonNullable<DsgvoVerifyDependencies["raiseEscalation"]>>[0]>;
|
||||
} {
|
||||
const audits: Array<Parameters<DsgvoVerifyDependencies["writeAuditEvent"]>[0]> = [];
|
||||
const escalations: Array<Parameters<NonNullable<DsgvoVerifyDependencies["raiseEscalation"]>>[0]> = [];
|
||||
const deps: DsgvoVerifyDependencies = {
|
||||
client: {
|
||||
countRemaining: vi.fn().mockImplementation(async (entry: { model: string; column: string }) =>
|
||||
countRemainingImpl(entry.model, entry.column),
|
||||
),
|
||||
},
|
||||
writeAuditEvent: vi.fn().mockImplementation(async (input) => {
|
||||
audits.push(input);
|
||||
}),
|
||||
raiseEscalation: vi.fn().mockImplementation(async (input) => {
|
||||
escalations.push(input);
|
||||
}),
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
};
|
||||
return { deps, audits, escalations };
|
||||
}
|
||||
|
||||
describe("runDsgvoVerify", () => {
|
||||
it("emits a success AuditEvent when every hard-delete table is empty", async () => {
|
||||
const { deps, audits, escalations } = makeDeps(() => 0);
|
||||
const result = await runDsgvoVerify(deps, { userId: "u1", tenantId: "t1", requestId: "req-1" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.failures).toHaveLength(0);
|
||||
expect(audits[0]?.outcome).toBe("success");
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only checks hard-delete tables — pseudonymized / tombstoned tables are skipped", async () => {
|
||||
const inspected = new Set<string>();
|
||||
const { deps } = makeDeps((model, column) => {
|
||||
inspected.add(`${model}.${column}`);
|
||||
return 0;
|
||||
});
|
||||
await runDsgvoVerify(deps, { userId: "u1", tenantId: null, requestId: "req-1" });
|
||||
const hardDeleteKeys = new Set(USER_FK_MAP.filter((e) => e.strategy === "hard-delete").map((e) => `${e.model}.${e.column}`));
|
||||
expect(inspected).toEqual(hardDeleteKeys);
|
||||
});
|
||||
|
||||
it("emits a failure AuditEvent and escalates when stragglers remain", async () => {
|
||||
const { deps, audits, escalations } = makeDeps((model) => (model === "TenantUser" ? 1 : 0));
|
||||
const result = await runDsgvoVerify(deps, { userId: "u1", tenantId: "t1", requestId: "req-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failures.some((f) => f.model === "TenantUser")).toBe(true);
|
||||
expect(audits[0]?.outcome).toBe("failure");
|
||||
expect(escalations).toHaveLength(1);
|
||||
expect(escalations[0]?.failures.some((f) => f.model === "TenantUser")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Phase 10 Task #68 — DSGVO deletion-verifier worker.
|
||||
*
|
||||
* Runs ~1 hour after the deletion cascade. For every USER_FK_MAP entry
|
||||
* with strategy=`hard-delete`, queries the table for any straggler row
|
||||
* still pointing at the deleted user. Tables in the `pseudonymize` and
|
||||
* `retain-tombstone` buckets are skipped — these intentionally keep
|
||||
* rows.
|
||||
*
|
||||
* A clean run emits an AuditEvent with outcome=success and metadata
|
||||
* `{ tablesChecked, straggler: 0 }`. A run with any leftover rows emits
|
||||
* an AuditEvent with outcome=failure and the per-table breakdown so the
|
||||
* operator can escalate the incident.
|
||||
*/
|
||||
|
||||
import { USER_FK_MAP, type UserFkMapEntry } from "@toolctrl/db/dsgvo/user-fk-map";
|
||||
|
||||
export type DsgvoVerifyJobInput = {
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export type DsgvoVerifyTableResult = {
|
||||
model: string;
|
||||
column: string;
|
||||
remainingRows: number;
|
||||
};
|
||||
|
||||
export type DsgvoVerifyResult = {
|
||||
userId: string;
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
tablesChecked: number;
|
||||
failures: DsgvoVerifyTableResult[];
|
||||
};
|
||||
|
||||
export type DsgvoVerifyClient = {
|
||||
countRemaining: (entry: UserFkMapEntry, userId: string) => Promise<number>;
|
||||
};
|
||||
|
||||
export type DsgvoVerifyDependencies = {
|
||||
client: DsgvoVerifyClient;
|
||||
writeAuditEvent: (input: {
|
||||
tenantId: string | null;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
regulatoryScope: string[];
|
||||
dataSensitivity: "pii";
|
||||
outcome: "success" | "failure";
|
||||
metadata: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
raiseEscalation?: (input: { userId: string; failures: DsgvoVerifyTableResult[] }) => Promise<void>;
|
||||
logger: { info: (fields: Record<string, unknown>, msg: string) => void; warn?: (fields: Record<string, unknown>, msg: string) => void };
|
||||
};
|
||||
|
||||
export async function runDsgvoVerify(
|
||||
deps: DsgvoVerifyDependencies,
|
||||
job: DsgvoVerifyJobInput,
|
||||
): Promise<DsgvoVerifyResult> {
|
||||
const hardDeleteEntries = USER_FK_MAP.filter((e) => e.strategy === "hard-delete");
|
||||
const failures: DsgvoVerifyTableResult[] = [];
|
||||
|
||||
for (const entry of hardDeleteEntries) {
|
||||
const remaining = await deps.client.countRemaining(entry, job.userId);
|
||||
if (remaining > 0) {
|
||||
failures.push({ model: entry.model, column: entry.column, remainingRows: remaining });
|
||||
}
|
||||
}
|
||||
|
||||
const ok = failures.length === 0;
|
||||
|
||||
await deps.writeAuditEvent({
|
||||
tenantId: job.tenantId,
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
action: "VERIFY_USER_DELETION",
|
||||
targetType: "User",
|
||||
targetId: job.userId,
|
||||
regulatoryScope: ["gdpr"],
|
||||
dataSensitivity: "pii",
|
||||
outcome: ok ? "success" : "failure",
|
||||
metadata: {
|
||||
requestId: job.requestId,
|
||||
tablesChecked: hardDeleteEntries.length,
|
||||
stragglerTables: failures,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
deps.logger.warn?.(
|
||||
{ userId: job.userId, requestId: job.requestId, failureCount: failures.length },
|
||||
"dsgvo-verify.stragglers-found",
|
||||
);
|
||||
if (deps.raiseEscalation) {
|
||||
await deps.raiseEscalation({ userId: job.userId, failures });
|
||||
}
|
||||
} else {
|
||||
deps.logger.info({ userId: job.userId, requestId: job.requestId }, "dsgvo-verify.clean");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: job.userId,
|
||||
requestId: job.requestId,
|
||||
ok,
|
||||
tablesChecked: hardDeleteEntries.length,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { bootstrapEmailSequenceWorkers } from "./email-sequence-bootstrap.js";
|
||||
import { EMAIL_SEQUENCE_TRIGGER_QUEUE } from "./email-sequence-trigger.js";
|
||||
import { EMAIL_SEQUENCE_QUEUE } from "./email-sequence-scheduler.js";
|
||||
|
||||
function fakeWorker() {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const calls: Array<{ queue: string; concurrency?: number }> = [];
|
||||
const handles: Array<{ name: string; close: typeof close }> = [];
|
||||
const createWorkerImpl = vi.fn((queueName: string, _processor: unknown, options: { concurrency?: number }) => {
|
||||
calls.push({ queue: queueName, concurrency: options.concurrency });
|
||||
const handle = { name: `fake-${queueName}`, close };
|
||||
handles.push(handle);
|
||||
return handle;
|
||||
});
|
||||
return { createWorkerImpl, calls, handles };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
});
|
||||
|
||||
describe("bootstrapEmailSequenceWorkers", () => {
|
||||
const triggerProcessor = async () => undefined;
|
||||
const schedulerProcessor = async () => undefined;
|
||||
|
||||
it("returns disabled when WORKER_QUEUE_GROUP is not mail", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "general" },
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
expect(result.status).toBe("disabled");
|
||||
if (result.status === "disabled") expect(result.reason).toMatch(/non-mail/);
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns disabled when MARKETING_SEQUENCES_ENABLED is not explicitly true", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const unset = bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "mail" },
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
expect(unset.status).toBe("disabled");
|
||||
|
||||
const result = bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "mail", MARKETING_SEQUENCES_ENABLED: "false" },
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts both workers when explicitly enabled on mail group", () => {
|
||||
const { createWorkerImpl, calls } = fakeWorker();
|
||||
const result = bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "mail", MARKETING_SEQUENCES_ENABLED: "true" },
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
expect(result.status).toBe("started");
|
||||
expect(calls.map((c) => c.queue).sort()).toEqual([EMAIL_SEQUENCE_QUEUE, EMAIL_SEQUENCE_TRIGGER_QUEUE].sort());
|
||||
});
|
||||
|
||||
it("honours concurrency env overrides", () => {
|
||||
const { createWorkerImpl, calls } = fakeWorker();
|
||||
bootstrapEmailSequenceWorkers(
|
||||
{
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
MARKETING_SEQUENCES_ENABLED: "true",
|
||||
EMAIL_SEQUENCE_TRIGGER_CONCURRENCY: "20",
|
||||
EMAIL_SEQUENCE_SCHEDULER_CONCURRENCY: "40",
|
||||
},
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
const trigger = calls.find((c) => c.queue === EMAIL_SEQUENCE_TRIGGER_QUEUE);
|
||||
const scheduler = calls.find((c) => c.queue === EMAIL_SEQUENCE_QUEUE);
|
||||
expect(trigger?.concurrency).toBe(20);
|
||||
expect(scheduler?.concurrency).toBe(40);
|
||||
});
|
||||
|
||||
it("defaults concurrency to documented values when env unset", () => {
|
||||
const { createWorkerImpl, calls } = fakeWorker();
|
||||
bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "mail", MARKETING_SEQUENCES_ENABLED: "true" },
|
||||
{ createWorkerImpl, triggerProcessor, schedulerProcessor }
|
||||
);
|
||||
expect(calls.find((c) => c.queue === EMAIL_SEQUENCE_TRIGGER_QUEUE)?.concurrency).toBe(5);
|
||||
expect(calls.find((c) => c.queue === EMAIL_SEQUENCE_QUEUE)?.concurrency).toBe(10);
|
||||
});
|
||||
|
||||
it("throws when createWorkerImpl is omitted", () => {
|
||||
expect(() =>
|
||||
bootstrapEmailSequenceWorkers(
|
||||
{ WORKER_QUEUE_GROUP: "mail", MARKETING_SEQUENCES_ENABLED: "true" },
|
||||
{ triggerProcessor, schedulerProcessor } as never
|
||||
)
|
||||
).toThrow(/createWorkerImpl/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — bootstrap helpers wiring
|
||||
* the BullMQ subscribers for the email-sequence trigger handler +
|
||||
* scheduler step-runner.
|
||||
*
|
||||
* Two BullMQ Workers are started inside the `mail` worker group:
|
||||
* - `outbox.marketing` — invokes the trigger handler. Outbox-dispatcher
|
||||
* publishes `marketing.lead.*` events here. We also accept bare
|
||||
* `lead.*` and `tenant.*` event-types when the publisher routes
|
||||
* them via a future fan-out.
|
||||
* - `email-sequence.step` — wakes up at the delayed-job's `runAt` and
|
||||
* invokes the step-runner.
|
||||
*
|
||||
* Both bootstrap helpers are gated on:
|
||||
* - WORKER_QUEUE_GROUP === "mail" (mail-bound outbound work)
|
||||
* - MARKETING_SEQUENCES_ENABLED === "true"
|
||||
*
|
||||
* Production wiring lives in `apps/worker/src/index.ts`; tests pass a
|
||||
* fake `createWorkerImpl` to assert subscription semantics without
|
||||
* spinning up BullMQ.
|
||||
*/
|
||||
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import { EMAIL_SEQUENCE_TRIGGER_QUEUE } from "./email-sequence-trigger.js";
|
||||
import { EMAIL_SEQUENCE_QUEUE } from "./email-sequence-scheduler.js";
|
||||
|
||||
export type EmailSequenceBootstrapEnv = EnvRecord & {
|
||||
MARKETING_SEQUENCES_ENABLED?: string;
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
EMAIL_SEQUENCE_TRIGGER_CONCURRENCY?: string;
|
||||
EMAIL_SEQUENCE_SCHEDULER_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface EmailSequenceWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface EmailSequenceBootstrapLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface EmailSequenceBootstrapDeps {
|
||||
readonly logger?: EmailSequenceBootstrapLogger;
|
||||
/** Factory that returns a BullMQ-like Worker handle. */
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => EmailSequenceWorkerHandle;
|
||||
/** Trigger-job processor. Production wires Prisma-backed deps. */
|
||||
readonly triggerProcessor: (job: { data: unknown }) => Promise<unknown>;
|
||||
/** Scheduler-job processor. Production wires Prisma-backed deps. */
|
||||
readonly schedulerProcessor: (job: { data: unknown }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export type EmailSequenceBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| {
|
||||
readonly status: "started";
|
||||
readonly triggerHandle: EmailSequenceWorkerHandle;
|
||||
readonly schedulerHandle: EmailSequenceWorkerHandle;
|
||||
};
|
||||
|
||||
const DEFAULT_TRIGGER_CONCURRENCY = 5;
|
||||
const DEFAULT_SCHEDULER_CONCURRENCY = 10;
|
||||
|
||||
export function bootstrapEmailSequenceWorkers(
|
||||
env: EmailSequenceBootstrapEnv,
|
||||
deps: EmailSequenceBootstrapDeps
|
||||
): EmailSequenceBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "mail") {
|
||||
return { status: "disabled", reason: "non-mail worker group" };
|
||||
}
|
||||
if (env.MARKETING_SEQUENCES_ENABLED !== "true") {
|
||||
logger?.info?.(
|
||||
{ component: "email-sequences", configured: env.MARKETING_SEQUENCES_ENABLED === "true" },
|
||||
"email-sequence workers disabled: MARKETING_SEQUENCES_ENABLED must be true"
|
||||
);
|
||||
return { status: "disabled", reason: "MARKETING_SEQUENCES_ENABLED must be true" };
|
||||
}
|
||||
if (!deps.createWorkerImpl) {
|
||||
throw new Error(
|
||||
"bootstrapEmailSequenceWorkers requires deps.createWorkerImpl; the worker entry must wire the BullMQ Worker constructor."
|
||||
);
|
||||
}
|
||||
|
||||
const triggerConcurrency = parsePositiveInt(env.EMAIL_SEQUENCE_TRIGGER_CONCURRENCY, DEFAULT_TRIGGER_CONCURRENCY);
|
||||
const schedulerConcurrency = parsePositiveInt(
|
||||
env.EMAIL_SEQUENCE_SCHEDULER_CONCURRENCY,
|
||||
DEFAULT_SCHEDULER_CONCURRENCY
|
||||
);
|
||||
|
||||
const triggerHandle = deps.createWorkerImpl(EMAIL_SEQUENCE_TRIGGER_QUEUE, deps.triggerProcessor, {
|
||||
concurrency: triggerConcurrency,
|
||||
});
|
||||
const schedulerHandle = deps.createWorkerImpl(EMAIL_SEQUENCE_QUEUE, deps.schedulerProcessor, {
|
||||
concurrency: schedulerConcurrency,
|
||||
});
|
||||
|
||||
logger?.info?.(
|
||||
{
|
||||
component: "email-sequences",
|
||||
triggerQueue: EMAIL_SEQUENCE_TRIGGER_QUEUE,
|
||||
schedulerQueue: EMAIL_SEQUENCE_QUEUE,
|
||||
triggerConcurrency,
|
||||
schedulerConcurrency,
|
||||
},
|
||||
"email-sequence workers started"
|
||||
);
|
||||
|
||||
return { status: "started", triggerHandle, schedulerHandle };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createPrismaSequenceLoader,
|
||||
createPrismaSequenceStore,
|
||||
type PrismaLikeForSequences,
|
||||
} from "./email-sequence-prisma-store.js";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
stepIndex: 0,
|
||||
delayHours: 0,
|
||||
template: "welcome",
|
||||
subject: "Welcome",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function createPrismaFake(overrides: Partial<PrismaLikeForSequences> = {}): PrismaLikeForSequences {
|
||||
return {
|
||||
emailSequence: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => null),
|
||||
...overrides.emailSequence,
|
||||
},
|
||||
emailSequenceRun: {
|
||||
create: vi.fn(async (args) => ({
|
||||
id: "run_1",
|
||||
sequenceId: String(args.data.sequenceId),
|
||||
leadId: args.data.leadId,
|
||||
tenantId: args.data.tenantId,
|
||||
triggerEventId: args.data.triggerEventId,
|
||||
status: String(args.data.status),
|
||||
startedAt: new Date("2026-05-27T06:00:00Z"),
|
||||
completedAt: null,
|
||||
stepStateJson: "stepStateJson" in args.data ? args.data.stepStateJson : null,
|
||||
})),
|
||||
findFirst: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({})),
|
||||
...overrides.emailSequenceRun,
|
||||
},
|
||||
emailSequenceStepDelivery: {
|
||||
create: vi.fn(async (args) => ({
|
||||
id: "delivery_1",
|
||||
runId: args.data.runId,
|
||||
stepIndex: args.data.stepIndex,
|
||||
scheduledAt: args.data.scheduledAt,
|
||||
status: args.data.status,
|
||||
})),
|
||||
updateMany: vi.fn(async () => ({})),
|
||||
...overrides.emailSequenceStepDelivery,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createPrismaSequenceLoader", () => {
|
||||
it("returns enabled sequence definitions parsed from Prisma rows", async () => {
|
||||
const prisma = createPrismaFake({
|
||||
emailSequence: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: "seq_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "Welcome",
|
||||
description: "First touch",
|
||||
triggerEvent: "lead.captured",
|
||||
stepsJson: STEPS,
|
||||
conditionsJson: [{ path: "lead.status", op: "eq", value: "new" }],
|
||||
isEnabled: true,
|
||||
},
|
||||
]),
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
});
|
||||
const loader = createPrismaSequenceLoader(prisma);
|
||||
|
||||
await expect(loader.listEnabledForEvent("lead.captured")).resolves.toEqual([
|
||||
{
|
||||
id: "seq_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "Welcome",
|
||||
description: "First touch",
|
||||
triggerEvent: "lead.captured",
|
||||
steps: STEPS,
|
||||
conditions: [{ path: "lead.status", op: "eq", value: "new" }],
|
||||
isEnabled: true,
|
||||
},
|
||||
]);
|
||||
expect(prisma.emailSequence.findMany).toHaveBeenCalledWith({
|
||||
where: { triggerEvent: "lead.captured", isEnabled: true },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
triggerEvent: true,
|
||||
stepsJson: true,
|
||||
conditionsJson: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPrismaSequenceStore", () => {
|
||||
it("persists remembered trigger context with newly created runs", async () => {
|
||||
const prisma = createPrismaFake();
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
|
||||
store.rememberTrigger({
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { lead: { status: "new" } },
|
||||
});
|
||||
|
||||
const run = await store.createRun({
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
triggerEventId: "evt_1",
|
||||
});
|
||||
|
||||
expect(run.stepStateJson).toEqual({
|
||||
trigger: {
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { lead: { status: "new" } },
|
||||
},
|
||||
});
|
||||
expect(prisma.emailSequenceRun.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
sequenceId: "seq_1",
|
||||
status: "running",
|
||||
stepStateJson: expect.objectContaining({
|
||||
trigger: expect.objectContaining({ email: "alice@example.com" }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds trigger context from the stored run state", async () => {
|
||||
const prisma = createPrismaFake({
|
||||
emailSequenceRun: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "run_1",
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-05-27T06:00:00Z"),
|
||||
completedAt: null,
|
||||
stepStateJson: {
|
||||
trigger: {
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { source: "form" },
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
|
||||
await expect(store.loadTriggerContext("run_1")).resolves.toEqual({
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { source: "form" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads steps from the related sequence row when the run query has no include", async () => {
|
||||
const prisma = createPrismaFake({
|
||||
emailSequence: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({
|
||||
stepsJson: STEPS,
|
||||
})),
|
||||
},
|
||||
emailSequenceRun: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "run_1",
|
||||
sequenceId: "seq_1",
|
||||
leadId: null,
|
||||
tenantId: null,
|
||||
triggerEventId: null,
|
||||
status: "running",
|
||||
startedAt: new Date("2026-05-27T06:00:00Z"),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
|
||||
await expect(store.loadSteps("run_1")).resolves.toEqual(STEPS);
|
||||
expect(prisma.emailSequence.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "seq_1" },
|
||||
select: { stepsJson: true },
|
||||
});
|
||||
expect(prisma.emailSequence.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks deliveries with only present delivery outcome fields", async () => {
|
||||
const prisma = createPrismaFake();
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
const sentAt = new Date("2026-05-27T06:10:00Z");
|
||||
|
||||
await store.markDelivery({
|
||||
runId: "run_1",
|
||||
stepIndex: 0,
|
||||
status: "sent",
|
||||
sentAt,
|
||||
providerMessageId: "msg_1",
|
||||
});
|
||||
|
||||
expect(prisma.emailSequenceStepDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: { runId: "run_1", stepIndex: 0 },
|
||||
data: {
|
||||
status: "sent",
|
||||
sentAt,
|
||||
providerMessageId: "msg_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — Prisma-backed adapter for
|
||||
* the email-sequence engine + step-runner storage surface.
|
||||
*
|
||||
* The pure-logic package `@toolctrl/email-sequences` accepts an
|
||||
* injected `StepRunnerStore` so this module is the production wiring:
|
||||
* EmailSequence + EmailSequenceRun + EmailSequenceStepDelivery rows
|
||||
* mapped to/from the engine types.
|
||||
*
|
||||
* Loaded lazily from `email-sequence-bootstrap.ts` so a misconfigured
|
||||
* Prisma client cannot break worker boot for other groups.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SequenceDefinition,
|
||||
SequenceRunRow,
|
||||
SequenceTriggerContext,
|
||||
StepRunnerStore,
|
||||
SequenceRunStatus,
|
||||
StepDeliveryStatus,
|
||||
SequenceStep,
|
||||
} from "@toolctrl/email-sequences";
|
||||
import { parseSequenceSteps } from "@toolctrl/email-sequences";
|
||||
|
||||
/**
|
||||
* Narrow Prisma surface used by the adapter. Kept structural so tests
|
||||
* can pass a fake without depending on the generated client. Methods
|
||||
* mirror the real client one-for-one.
|
||||
*/
|
||||
export interface PrismaLikeForSequences {
|
||||
emailSequence: {
|
||||
findMany(args: { where: Record<string, unknown>; select: EmailSequenceDefinitionSelect }): Promise<Array<{
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
stepsJson: unknown;
|
||||
conditionsJson: unknown;
|
||||
isEnabled: boolean;
|
||||
}>>;
|
||||
findUnique(args: { where: { id: string }; select: { stepsJson: true } }): Promise<{
|
||||
stepsJson: unknown;
|
||||
} | null>;
|
||||
};
|
||||
emailSequenceRun: {
|
||||
create(args: {
|
||||
data: {
|
||||
sequenceId: string;
|
||||
leadId: string | null;
|
||||
tenantId: string | null;
|
||||
triggerEventId: string | null;
|
||||
status: string;
|
||||
};
|
||||
}): Promise<{
|
||||
id: string;
|
||||
sequenceId: string;
|
||||
leadId: string | null;
|
||||
tenantId: string | null;
|
||||
triggerEventId: string | null;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
stepStateJson: unknown;
|
||||
}>;
|
||||
findFirst(args: { where: Record<string, unknown> }): Promise<{
|
||||
id: string;
|
||||
sequenceId: string;
|
||||
leadId: string | null;
|
||||
tenantId: string | null;
|
||||
triggerEventId: string | null;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
stepStateJson: unknown;
|
||||
sequence?: {
|
||||
stepsJson: unknown;
|
||||
conditionsJson: unknown;
|
||||
};
|
||||
} | null>;
|
||||
update(args: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
emailSequenceStepDelivery: {
|
||||
create(args: {
|
||||
data: { runId: string; stepIndex: number; scheduledAt: Date; status: string };
|
||||
}): Promise<{ id: string; runId: string; stepIndex: number; scheduledAt: Date; status: string }>;
|
||||
updateMany(args: {
|
||||
where: { runId: string; stepIndex: number };
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const emailSequenceDefinitionSelect = {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
triggerEvent: true,
|
||||
stepsJson: true,
|
||||
conditionsJson: true,
|
||||
isEnabled: true,
|
||||
} as const;
|
||||
|
||||
type EmailSequenceDefinitionSelect = typeof emailSequenceDefinitionSelect;
|
||||
|
||||
function rowToRun(row: {
|
||||
id: string;
|
||||
sequenceId: string;
|
||||
leadId: string | null;
|
||||
tenantId: string | null;
|
||||
triggerEventId: string | null;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
stepStateJson: unknown;
|
||||
}): SequenceRunRow {
|
||||
return {
|
||||
id: row.id,
|
||||
sequenceId: row.sequenceId,
|
||||
leadId: row.leadId,
|
||||
tenantId: row.tenantId,
|
||||
triggerEventId: row.triggerEventId,
|
||||
status: row.status as SequenceRunStatus,
|
||||
startedAt: row.startedAt,
|
||||
completedAt: row.completedAt,
|
||||
stepStateJson:
|
||||
row.stepStateJson && typeof row.stepStateJson === "object"
|
||||
? (row.stepStateJson as Record<string, unknown>)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToDefinition(row: {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
stepsJson: unknown;
|
||||
conditionsJson: unknown;
|
||||
isEnabled: boolean;
|
||||
}): SequenceDefinition {
|
||||
const steps = parseSequenceSteps(row.stepsJson);
|
||||
const conditions =
|
||||
row.conditionsJson && typeof row.conditionsJson === "object" && Array.isArray(row.conditionsJson)
|
||||
? (row.conditionsJson as SequenceDefinition["conditions"])
|
||||
: undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
triggerEvent: row.triggerEvent,
|
||||
steps,
|
||||
...(conditions ? { conditions } : {}),
|
||||
isEnabled: row.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger handler keeps the `SequenceTriggerContext.payload`
|
||||
* inside the run's stepStateJson so the step-runner can rebuild it
|
||||
* when the delayed job fires hours/days later.
|
||||
*/
|
||||
function readTriggerFromStepState(
|
||||
run: SequenceRunRow,
|
||||
fallbackEmail?: string
|
||||
): SequenceTriggerContext | null {
|
||||
const state = run.stepStateJson;
|
||||
if (!state || typeof state !== "object") return null;
|
||||
const trigger = (state as Record<string, unknown>)["trigger"];
|
||||
if (!trigger || typeof trigger !== "object") return null;
|
||||
const t = trigger as Record<string, unknown>;
|
||||
const email = typeof t["email"] === "string" ? (t["email"] as string) : fallbackEmail;
|
||||
if (!email) return null;
|
||||
const payload = t["payload"] && typeof t["payload"] === "object" ? (t["payload"] as Record<string, unknown>) : {};
|
||||
return {
|
||||
triggerEventId: run.triggerEventId,
|
||||
leadId: run.leadId,
|
||||
tenantId: run.tenantId,
|
||||
email,
|
||||
recipientName: typeof t["recipientName"] === "string" ? (t["recipientName"] as string) : null,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrismaSequenceLoader(prisma: PrismaLikeForSequences) {
|
||||
return {
|
||||
async listEnabledForEvent(eventType: string): Promise<readonly SequenceDefinition[]> {
|
||||
const rows = await prisma.emailSequence.findMany({
|
||||
where: { triggerEvent: eventType, isEnabled: true },
|
||||
select: emailSequenceDefinitionSelect,
|
||||
});
|
||||
return rows.map(rowToDefinition);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrismaSequenceStoreOptions {
|
||||
/** Persist the trigger context inside the run row so the step-runner can rebuild it. */
|
||||
attachTriggerContext?: SequenceTriggerContext | null;
|
||||
}
|
||||
|
||||
export function createPrismaSequenceStore(prisma: PrismaLikeForSequences): StepRunnerStore & {
|
||||
/** Cache the trigger context for subsequent step-runner invocations. */
|
||||
rememberTrigger(trigger: SequenceTriggerContext | null): void;
|
||||
} {
|
||||
let triggerCache: SequenceTriggerContext | null = null;
|
||||
|
||||
return {
|
||||
rememberTrigger(trigger) {
|
||||
triggerCache = trigger;
|
||||
},
|
||||
|
||||
async createRun(input) {
|
||||
const stepStateJson = triggerCache
|
||||
? ({ trigger: triggerCache } as unknown as Record<string, unknown>)
|
||||
: null;
|
||||
const row = await prisma.emailSequenceRun.create({
|
||||
data: {
|
||||
sequenceId: input.sequenceId,
|
||||
leadId: input.leadId,
|
||||
tenantId: input.tenantId,
|
||||
triggerEventId: input.triggerEventId,
|
||||
status: "running",
|
||||
...(stepStateJson ? { stepStateJson } : {}),
|
||||
} as never,
|
||||
});
|
||||
return rowToRun(row);
|
||||
},
|
||||
|
||||
async findRunByTrigger(sequenceId, triggerEventId) {
|
||||
if (!triggerEventId) return null;
|
||||
const row = await prisma.emailSequenceRun.findFirst({
|
||||
where: { sequenceId, triggerEventId },
|
||||
});
|
||||
return row ? rowToRun(row) : null;
|
||||
},
|
||||
|
||||
async updateRunStatus(runId, status, completedAt) {
|
||||
await prisma.emailSequenceRun.update({
|
||||
where: { id: runId },
|
||||
data: completedAt ? { status, completedAt } : { status },
|
||||
});
|
||||
},
|
||||
|
||||
async createDelivery(input) {
|
||||
const row = await prisma.emailSequenceStepDelivery.create({
|
||||
data: {
|
||||
runId: input.runId,
|
||||
stepIndex: input.stepIndex,
|
||||
scheduledAt: input.scheduledAt,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
runId: row.runId,
|
||||
stepIndex: row.stepIndex,
|
||||
status: row.status as StepDeliveryStatus,
|
||||
scheduledAt: row.scheduledAt,
|
||||
};
|
||||
},
|
||||
|
||||
async loadRun(runId) {
|
||||
const row = await prisma.emailSequenceRun.findFirst({ where: { id: runId } });
|
||||
return row ? rowToRun(row) : null;
|
||||
},
|
||||
|
||||
async loadSteps(runId): Promise<readonly SequenceStep[]> {
|
||||
const row = await prisma.emailSequenceRun.findFirst({
|
||||
where: { id: runId },
|
||||
});
|
||||
if (!row?.sequence) {
|
||||
// findFirst without include returns the run only; the bootstrap
|
||||
// wires a richer adapter when needed. Fall back to fetching the
|
||||
// sequence directly.
|
||||
const seqRun = row;
|
||||
if (!seqRun) return [];
|
||||
const seq = await prisma.emailSequence.findUnique({
|
||||
where: { id: seqRun.sequenceId },
|
||||
select: { stepsJson: true },
|
||||
});
|
||||
return seq ? parseSequenceSteps(seq.stepsJson) : [];
|
||||
}
|
||||
return parseSequenceSteps(row.sequence.stepsJson);
|
||||
},
|
||||
|
||||
async loadTriggerContext(runId) {
|
||||
const row = await prisma.emailSequenceRun.findFirst({ where: { id: runId } });
|
||||
if (!row) return null;
|
||||
const run = rowToRun(row);
|
||||
return readTriggerFromStepState(run);
|
||||
},
|
||||
|
||||
async markDelivery(args) {
|
||||
const data: Record<string, unknown> = { status: args.status };
|
||||
if (args.sentAt) data["sentAt"] = args.sentAt;
|
||||
if (args.bouncedAt) data["bouncedAt"] = args.bouncedAt;
|
||||
if (args.errorMessage) data["errorMessage"] = args.errorMessage;
|
||||
if (args.providerMessageId) data["providerMessageId"] = args.providerMessageId;
|
||||
await prisma.emailSequenceStepDelivery.updateMany({
|
||||
where: { runId: args.runId, stepIndex: args.stepIndex },
|
||||
data,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildEmailSequenceStepJobId,
|
||||
createEmailSequenceSchedulerDeps,
|
||||
createEmailSequenceTriggerDeps,
|
||||
} from "./email-sequence-runtime.js";
|
||||
import { EMAIL_SEQUENCE_STEP_JOB_NAME, runEmailSequenceStepJob } from "./email-sequence-scheduler.js";
|
||||
import type { SequenceRunRow, SequenceStep, SequenceTriggerContext } from "@toolctrl/email-sequences";
|
||||
import { verifyUnsubscribeToken } from "@toolctrl/email-sequences/unsubscribe-token";
|
||||
|
||||
const loader = { load: vi.fn() };
|
||||
const store = {
|
||||
save: vi.fn(),
|
||||
loadRun: vi.fn<(runId: string) => Promise<SequenceRunRow | null>>(async () => null),
|
||||
loadSteps: vi.fn<(runId: string) => Promise<readonly SequenceStep[]>>(async () => []),
|
||||
loadTriggerContext: vi.fn<(runId: string) => Promise<SequenceTriggerContext | null>>(async () => null),
|
||||
markDelivery: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
findRunByTrigger: vi.fn(async () => null),
|
||||
updateRunStatus: vi.fn(),
|
||||
createDelivery: vi.fn(),
|
||||
};
|
||||
const prisma = { emailSequence: {} };
|
||||
const TOKEN_SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const mailMocks = vi.hoisted(() => ({
|
||||
provider: {
|
||||
id: "mailjet" as const,
|
||||
send: vi.fn(),
|
||||
},
|
||||
createMailjetProviderFromEnv: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/db/client.server", () => ({
|
||||
getDbClient: vi.fn(() => prisma),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/mail", () => ({
|
||||
createMailjetProviderFromEnv: mailMocks.createMailjetProviderFromEnv,
|
||||
}));
|
||||
|
||||
vi.mock("./email-sequence-prisma-store.js", () => ({
|
||||
createPrismaSequenceLoader: vi.fn(() => loader),
|
||||
createPrismaSequenceStore: vi.fn(() => store),
|
||||
}));
|
||||
|
||||
function logger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("email sequence runtime deps", () => {
|
||||
beforeEach(() => {
|
||||
store.loadRun.mockResolvedValue(null);
|
||||
store.loadSteps.mockResolvedValue([]);
|
||||
store.loadTriggerContext.mockResolvedValue(null);
|
||||
mailMocks.provider.send.mockResolvedValue({ provider: "mailjet", status: "queued", messageId: "msg_1" });
|
||||
mailMocks.createMailjetProviderFromEnv.mockReturnValue(mailMocks.provider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.APP_URL;
|
||||
delete process.env.LEAD_CONVERSION_SECRET;
|
||||
delete process.env.MARKETING_TOKEN_SECRET;
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
delete process.env.MAILJET_API_KEY;
|
||||
delete process.env.MAILJET_FROM_EMAIL;
|
||||
delete process.env.MAILJET_FROM_NAME;
|
||||
});
|
||||
|
||||
it("builds trigger deps that enqueue delayed step jobs idempotently", async () => {
|
||||
const log = logger();
|
||||
const stepQueue = { add: vi.fn(async () => ({ id: "step_job_1" })) };
|
||||
const now = () => new Date("2026-01-01T00:00:00.000Z");
|
||||
const deps = await createEmailSequenceTriggerDeps({ logger: log, now, stepQueue });
|
||||
|
||||
expect(deps).toMatchObject({ loader, store });
|
||||
await deps?.queue.scheduleStep({ runId: "run_1", stepIndex: 2, runAt: new Date("2026-01-01T00:10:00.000Z") });
|
||||
expect(stepQueue.add).toHaveBeenCalledWith(
|
||||
EMAIL_SEQUENCE_STEP_JOB_NAME,
|
||||
{
|
||||
runId: "run_1",
|
||||
stepIndex: 2,
|
||||
scheduledFor: "2026-01-01T00:10:00.000Z",
|
||||
},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 1_000 },
|
||||
delay: 600_000,
|
||||
jobId: "email-sequence-step:run_1:2",
|
||||
removeOnComplete: false,
|
||||
removeOnFail: 1_000,
|
||||
}
|
||||
);
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
op: "sequence.queue.scheduleStep.enqueued",
|
||||
queue: "email-sequence.step",
|
||||
jobId: "step_job_1",
|
||||
runId: "run_1",
|
||||
stepIndex: 2,
|
||||
}),
|
||||
"email sequence step enqueued"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps scheduleStep fail-closed when Redis is not configured", async () => {
|
||||
const log = logger();
|
||||
const deps = await createEmailSequenceTriggerDeps({ logger: log, env: {} });
|
||||
|
||||
await deps?.queue.scheduleStep({ runId: "run_1", stepIndex: 0, runAt: new Date("2026-01-01T00:00:00.000Z") });
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
{ op: "sequence.queue.scheduleStep.missing_redis", runId: "run_1", stepIndex: 0 },
|
||||
"cannot schedule email sequence step: Redis URL is not configured"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds stable email-sequence step job ids", () => {
|
||||
expect(buildEmailSequenceStepJobId("run_1", 2)).toBe("email-sequence-step:run_1:2");
|
||||
});
|
||||
|
||||
it("builds scheduler deps that fail closed without mail configuration", async () => {
|
||||
const log = logger();
|
||||
const stepQueue = { add: vi.fn(async () => ({ id: "next_step_job" })) };
|
||||
const deps = await createEmailSequenceSchedulerDeps({
|
||||
logger: log,
|
||||
stepQueue,
|
||||
env: {
|
||||
APP_URL: "https://app.example.test/",
|
||||
MARKETING_TOKEN_SECRET: TOKEN_SECRET,
|
||||
},
|
||||
now: () => new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(deps).toMatchObject({ store });
|
||||
await deps?.queue.scheduleStep({ runId: "run_2", stepIndex: 1, runAt: new Date("2026-01-01T00:00:00.000Z") });
|
||||
expect(stepQueue.add).toHaveBeenCalledWith(
|
||||
EMAIL_SEQUENCE_STEP_JOB_NAME,
|
||||
expect.objectContaining({ runId: "run_2", stepIndex: 1 }),
|
||||
expect.objectContaining({ jobId: "email-sequence-step:run_2:1" })
|
||||
);
|
||||
const rendered = await deps!.renderTemplate(
|
||||
{
|
||||
stepIndex: 0,
|
||||
delayHours: 0,
|
||||
template: "welcome",
|
||||
subject: "Welcome {{trigger.recipientName}}",
|
||||
variables: { ctaUrl: "https://app.example.test/start", productName: "ToolCTRL Test" },
|
||||
},
|
||||
{
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "user@example.test",
|
||||
recipientName: "User",
|
||||
payload: {},
|
||||
},
|
||||
"run_render_1"
|
||||
);
|
||||
expect(rendered).toMatchObject({
|
||||
subject: "Welcome User",
|
||||
});
|
||||
expect(rendered.html).toContain("ToolCTRL Test");
|
||||
expect(rendered.html).toContain("https://app.example.test/start");
|
||||
expect(rendered.html).toContain("/unsubscribe/u1.");
|
||||
await expect(
|
||||
deps?.sendMail({
|
||||
to: "user@example.test",
|
||||
subject: "Subject",
|
||||
html: "<p>Hello</p>",
|
||||
text: "Hello",
|
||||
unsubscribeUrl: "https://app.example.test/unsubscribe/u1.token.sig",
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
errorTag: "email_sequence_mail_sender_unavailable",
|
||||
});
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
{ op: "sequence.step.sendMail.missing_config", recipientCount: 1 },
|
||||
"email sequence mail sender unavailable: MAILJET_API_KEY, MAILJET_SECRET_KEY and MAILJET_FROM_EMAIL are required"
|
||||
);
|
||||
const url = deps?.buildUnsubscribeUrl(
|
||||
{
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "user@example.test",
|
||||
recipientName: "User",
|
||||
payload: {},
|
||||
},
|
||||
"run_1"
|
||||
);
|
||||
expect(url?.startsWith("https://app.example.test/unsubscribe/u1.")).toBe(true);
|
||||
const token = url?.split("/unsubscribe/")[1];
|
||||
expect(verifyUnsubscribeToken(token, { secret: TOKEN_SECRET })).toMatchObject({
|
||||
runId: "run_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends sequence mail through @toolctrl/mail when Mailjet env is configured", async () => {
|
||||
const log = logger();
|
||||
const deps = await createEmailSequenceSchedulerDeps({
|
||||
logger: log,
|
||||
stepQueue: { add: vi.fn() },
|
||||
env: {
|
||||
APP_URL: "https://app.example.test",
|
||||
MARKETING_TOKEN_SECRET: TOKEN_SECRET,
|
||||
MAILJET_API_KEY: "mailer-key",
|
||||
MAILJET_SECRET_KEY: "mailer-secret",
|
||||
MAILJET_FROM_EMAIL: "sender@example.test",
|
||||
MAILJET_FROM_NAME: "Sender",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
deps?.sendMail({
|
||||
to: "user@example.test",
|
||||
subject: "Subject",
|
||||
html: "<p>Hello</p>",
|
||||
text: "Hello",
|
||||
unsubscribeUrl: "https://app.example.test/unsubscribe/u1.token.sig",
|
||||
listUnsubscribeMailto: "mailto:unsubscribe@example.test",
|
||||
})
|
||||
).resolves.toEqual({ ok: true, messageId: "msg_1" });
|
||||
expect(mailMocks.createMailjetProviderFromEnv).toHaveBeenCalledWith({
|
||||
APP_URL: "https://app.example.test",
|
||||
MARKETING_TOKEN_SECRET: TOKEN_SECRET,
|
||||
MAILJET_API_KEY: "mailer-key",
|
||||
MAILJET_SECRET_KEY: "mailer-secret",
|
||||
MAILJET_FROM_EMAIL: "sender@example.test",
|
||||
MAILJET_FROM_NAME: "Sender",
|
||||
});
|
||||
expect(mailMocks.provider.send).toHaveBeenCalledWith({
|
||||
to: ["user@example.test"],
|
||||
subject: "Subject",
|
||||
html: "<p>Hello</p>",
|
||||
text: "Hello",
|
||||
tags: ["email-sequence"],
|
||||
precedenceBulk: true,
|
||||
listUnsubscribe: "<https://app.example.test/unsubscribe/u1.token.sig>, <mailto:unsubscribe@example.test>",
|
||||
headers: [{ name: "List-Unsubscribe-Post", value: "List-Unsubscribe=One-Click" }],
|
||||
priorityOverride: "low",
|
||||
});
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
{ op: "sequence.step.sendMail.sent", provider: "mailjet", recipientCount: 1, messageId: "msg_1" },
|
||||
"email sequence mail accepted by provider"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps @toolctrl/mail provider failures to stable sequence error tags", async () => {
|
||||
const log = logger();
|
||||
mailMocks.provider.send.mockRejectedValue(Object.assign(new Error("provider rejected"), { statusCode: 429 }));
|
||||
const deps = await createEmailSequenceSchedulerDeps({
|
||||
logger: log,
|
||||
stepQueue: { add: vi.fn() },
|
||||
env: {
|
||||
MAILJET_API_KEY: "mailer-key",
|
||||
MAILJET_SECRET_KEY: "mailer-secret",
|
||||
MAILJET_FROM_EMAIL: "sender@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
deps?.sendMail({
|
||||
to: "user@example.test",
|
||||
subject: "Subject",
|
||||
html: "<p>Hello</p>",
|
||||
text: "Hello",
|
||||
unsubscribeUrl: "https://app.example.test/unsubscribe/u1.token.sig",
|
||||
})
|
||||
).resolves.toEqual({ ok: false, errorTag: "email_sequence_mail_provider_4xx" });
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
{
|
||||
op: "sequence.step.sendMail.failed",
|
||||
provider: "mailjet",
|
||||
recipientCount: 1,
|
||||
errorTag: "email_sequence_mail_provider_4xx",
|
||||
statusCode: 429,
|
||||
},
|
||||
"email sequence mail provider send failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps production step jobs fail-closed while mail delivery is unavailable", async () => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "true";
|
||||
const log = logger();
|
||||
const deps = await createEmailSequenceSchedulerDeps({
|
||||
logger: log,
|
||||
stepQueue: { add: vi.fn() },
|
||||
env: {
|
||||
APP_URL: "https://app.example.test",
|
||||
MARKETING_TOKEN_SECRET: TOKEN_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
store.loadRun.mockResolvedValue({
|
||||
id: "run_mail_unavailable",
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
});
|
||||
store.loadSteps.mockResolvedValue([{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" }]);
|
||||
store.loadTriggerContext.mockResolvedValue({
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
email: "user@example.test",
|
||||
recipientName: "User",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmailSequenceStepJob({ runId: "run_mail_unavailable", stepIndex: 0 }, deps!)
|
||||
).resolves.toEqual({ action: "failed", completed: false });
|
||||
expect(store.markDelivery).toHaveBeenCalledWith({
|
||||
runId: "run_mail_unavailable",
|
||||
stepIndex: 0,
|
||||
status: "failed",
|
||||
errorMessage: "email_sequence_mail_sender_unavailable",
|
||||
});
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ op: "sequence.step.sendMail.missing_config" }),
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — production runtime-deps
|
||||
* builder for the email-sequence trigger handler + scheduler.
|
||||
*
|
||||
* Returns the injected dependency bundles consumed by
|
||||
* `runSequenceTriggerJob` + `runEmailSequenceStepJob`. Loaded lazily
|
||||
* inside the BullMQ subscribers so a misconfigured optional adapter
|
||||
* (e.g. mail provider unavailable) cannot crash worker boot for other
|
||||
* groups.
|
||||
*
|
||||
* Returns `null` when the deps cannot be built — the BullMQ subscriber
|
||||
* treats that as a no-op so the queue drains cleanly while operators
|
||||
* fix the upstream config.
|
||||
*/
|
||||
|
||||
import { parseRedisUrl, type RedisConnectionEnv } from "@toolctrl/queue";
|
||||
import { resolvePath, type StepRunnerDeps, type SequenceEngineQueue } from "@toolctrl/email-sequences";
|
||||
import { buildUnsubscribeUrl as buildSignedUnsubscribeUrl } from "@toolctrl/email-sequences/unsubscribe-token";
|
||||
import type { MailProvider } from "@toolctrl/mail";
|
||||
import {
|
||||
GenericStepEmail,
|
||||
ReActivationEmail,
|
||||
renderEmail,
|
||||
TrialReminderEmail,
|
||||
WelcomeEmail,
|
||||
} from "@toolctrl/email-templates";
|
||||
import {
|
||||
EMAIL_SEQUENCE_QUEUE,
|
||||
EMAIL_SEQUENCE_STEP_JOB_NAME,
|
||||
type EmailSequenceStepJobData,
|
||||
} from "./email-sequence-scheduler.js";
|
||||
import type { SequenceTriggerDeps } from "./email-sequence-trigger.js";
|
||||
|
||||
export interface EmailSequenceRuntimeLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface EmailSequenceRuntimeOptions {
|
||||
readonly logger: EmailSequenceRuntimeLogger;
|
||||
readonly env?: EmailSequenceRuntimeEnv;
|
||||
readonly now?: () => Date;
|
||||
readonly stepQueue?: EmailSequenceStepQueue;
|
||||
readonly mailProvider?: EmailSequenceMailProvider;
|
||||
}
|
||||
|
||||
type EmailSequenceRuntimeEnv = RedisConnectionEnv & {
|
||||
APP_URL?: string;
|
||||
APP_NAME?: string;
|
||||
LEAD_CONVERSION_SECRET?: string;
|
||||
MARKETING_TOKEN_SECRET?: string;
|
||||
MAILJET_API_KEY?: string;
|
||||
MAILJET_SECRET_KEY?: string;
|
||||
MAILJET_FROM_EMAIL?: string;
|
||||
MAILJET_FROM_NAME?: string;
|
||||
};
|
||||
|
||||
type EmailSequenceMailProvider = Pick<MailProvider, "id" | "send">;
|
||||
|
||||
export interface EmailSequenceStepQueue {
|
||||
add(
|
||||
name: typeof EMAIL_SEQUENCE_STEP_JOB_NAME,
|
||||
data: EmailSequenceStepJobData,
|
||||
options: EmailSequenceStepQueueOptions
|
||||
): Promise<{ id?: string }>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
export type EmailSequenceStepQueueOptions = {
|
||||
attempts: number;
|
||||
backoff: { type: "exponential"; delay: number };
|
||||
delay: number;
|
||||
jobId: string;
|
||||
removeOnComplete: boolean | number;
|
||||
removeOnFail: boolean | number;
|
||||
};
|
||||
|
||||
const EMAIL_SEQUENCE_STEP_ATTEMPTS = 3;
|
||||
const EMAIL_SEQUENCE_STEP_BACKOFF_MS = 1_000;
|
||||
const EMAIL_SEQUENCE_STEP_REMOVE_ON_FAIL = 1_000;
|
||||
|
||||
/**
|
||||
* Build the trigger-handler dependency bundle. Returns null if Prisma
|
||||
* is not available in this build.
|
||||
*/
|
||||
export async function createEmailSequenceTriggerDeps(
|
||||
opts: EmailSequenceRuntimeOptions
|
||||
): Promise<SequenceTriggerDeps | null> {
|
||||
try {
|
||||
const [{ getDbClient }, { createPrismaSequenceLoader, createPrismaSequenceStore }] = await Promise.all([
|
||||
import("@toolctrl/db/client.server"),
|
||||
import("./email-sequence-prisma-store.js"),
|
||||
]);
|
||||
const prisma = getDbClient() as unknown as Parameters<typeof createPrismaSequenceLoader>[0];
|
||||
const loader = createPrismaSequenceLoader(prisma);
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
return {
|
||||
loader,
|
||||
store,
|
||||
queue: {
|
||||
scheduleStep: (args) => scheduleEmailSequenceStep(args, opts),
|
||||
},
|
||||
logger: opts.logger,
|
||||
};
|
||||
} catch (error) {
|
||||
opts.logger.error({ error }, "failed to build email-sequence trigger deps");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scheduler-handler dependency bundle. Returns null if the
|
||||
* Prisma client, mail sender or template renderer are unavailable.
|
||||
*/
|
||||
export async function createEmailSequenceSchedulerDeps(
|
||||
opts: EmailSequenceRuntimeOptions
|
||||
): Promise<StepRunnerDeps | null> {
|
||||
try {
|
||||
const [{ getDbClient }, { createPrismaSequenceStore }] = await Promise.all([
|
||||
import("@toolctrl/db/client.server"),
|
||||
import("./email-sequence-prisma-store.js"),
|
||||
]);
|
||||
const prisma = getDbClient() as unknown as Parameters<typeof createPrismaSequenceStore>[0];
|
||||
const store = createPrismaSequenceStore(prisma);
|
||||
const sendMail = await createEmailSequenceMailSender(opts);
|
||||
return {
|
||||
store,
|
||||
queue: {
|
||||
scheduleStep: (args) => scheduleEmailSequenceStep(args, opts),
|
||||
},
|
||||
logger: opts.logger,
|
||||
renderTemplate: (step, trigger, runId) => renderSequenceTemplate(step, trigger, runId, opts),
|
||||
sendMail,
|
||||
buildUnsubscribeUrl: (trigger, runId) => {
|
||||
const env = opts.env ?? process.env;
|
||||
const baseUrl = env.APP_URL?.trim() || "https://app.example.com";
|
||||
return buildSignedUnsubscribeUrl({
|
||||
baseUrl,
|
||||
runId,
|
||||
leadId: trigger.leadId,
|
||||
tenantId: trigger.tenantId,
|
||||
secret: env.MARKETING_TOKEN_SECRET ?? env.LEAD_CONVERSION_SECRET,
|
||||
now: opts.now,
|
||||
});
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
opts.logger.error({ error }, "failed to build email-sequence scheduler deps");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmailSequenceMailSender(opts: EmailSequenceRuntimeOptions): Promise<StepRunnerDeps["sendMail"]> {
|
||||
const provider = opts.mailProvider ?? (await createEmailSequenceMailProviderFromEnv(opts));
|
||||
if (!provider) {
|
||||
return async () => {
|
||||
opts.logger.error(
|
||||
{ op: "sequence.step.sendMail.missing_config", recipientCount: 1 },
|
||||
"email sequence mail sender unavailable: MAILJET_API_KEY, MAILJET_SECRET_KEY and MAILJET_FROM_EMAIL are required"
|
||||
);
|
||||
return { ok: false, errorTag: "email_sequence_mail_sender_unavailable" };
|
||||
};
|
||||
}
|
||||
|
||||
return async (input) => {
|
||||
try {
|
||||
const result = await provider.send({
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
html: input.html,
|
||||
text: input.text,
|
||||
tags: ["email-sequence"],
|
||||
precedenceBulk: true,
|
||||
listUnsubscribe: formatListUnsubscribe(input.unsubscribeUrl, input.listUnsubscribeMailto),
|
||||
headers: [{ name: "List-Unsubscribe-Post", value: "List-Unsubscribe=One-Click" }],
|
||||
priorityOverride: "low",
|
||||
});
|
||||
opts.logger.info?.(
|
||||
{ op: "sequence.step.sendMail.sent", provider: result.provider, recipientCount: 1, messageId: result.messageId },
|
||||
"email sequence mail accepted by provider"
|
||||
);
|
||||
return { ok: true, messageId: result.messageId };
|
||||
} catch (error) {
|
||||
const statusCode = readStatusCode(error);
|
||||
const errorTag = statusCode ? `email_sequence_mail_provider_${Math.floor(statusCode / 100)}xx` : "email_sequence_mail_provider_failed";
|
||||
opts.logger.error(
|
||||
{
|
||||
op: "sequence.step.sendMail.failed",
|
||||
provider: provider.id,
|
||||
recipientCount: 1,
|
||||
errorTag,
|
||||
...(statusCode ? { statusCode } : {}),
|
||||
},
|
||||
"email sequence mail provider send failed"
|
||||
);
|
||||
return { ok: false, errorTag };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createEmailSequenceMailProviderFromEnv(opts: EmailSequenceRuntimeOptions): Promise<EmailSequenceMailProvider | null> {
|
||||
const env = opts.env ?? process.env;
|
||||
if (!env.MAILJET_API_KEY?.trim() || !env.MAILJET_SECRET_KEY?.trim() || !env.MAILJET_FROM_EMAIL?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { createMailjetProviderFromEnv } = await import("@toolctrl/mail");
|
||||
return createMailjetProviderFromEnv(env);
|
||||
} catch (error) {
|
||||
opts.logger.error(
|
||||
{ op: "sequence.step.sendMail.provider_init_failed", error },
|
||||
"failed to initialize email sequence mail provider"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatListUnsubscribe(unsubscribeUrl: string, mailto: string | undefined): string {
|
||||
const urlHeader = `<${unsubscribeUrl}>`;
|
||||
return mailto ? `${urlHeader}, <${mailto}>` : urlHeader;
|
||||
}
|
||||
|
||||
function readStatusCode(error: unknown): number | undefined {
|
||||
return error && typeof error === "object" && typeof (error as { statusCode?: unknown }).statusCode === "number"
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function renderSequenceTemplate(
|
||||
step: Parameters<StepRunnerDeps["renderTemplate"]>[0],
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1],
|
||||
runId: string,
|
||||
opts: EmailSequenceRuntimeOptions
|
||||
) {
|
||||
const vars = step.variables ?? {};
|
||||
const env = opts.env ?? process.env;
|
||||
const appUrl = (env.APP_URL?.trim() || "https://app.example.com").replace(/\/+$/, "");
|
||||
const defaultProductName = env.APP_NAME?.trim() || "ToolCTRL";
|
||||
const productName = stringVariable(vars, trigger, "productName", defaultProductName) ?? defaultProductName;
|
||||
const recipientName = stringVariable(vars, trigger, "recipientName", trigger.recipientName ?? undefined);
|
||||
const unsubscribeUrl = buildSignedUnsubscribeUrl({
|
||||
baseUrl: appUrl,
|
||||
runId,
|
||||
leadId: trigger.leadId,
|
||||
tenantId: trigger.tenantId,
|
||||
secret: env.MARKETING_TOKEN_SECRET ?? env.LEAD_CONVERSION_SECRET,
|
||||
now: opts.now,
|
||||
});
|
||||
const subject = interpolateTemplate(step.subject, vars, trigger);
|
||||
|
||||
switch (step.template) {
|
||||
case "welcome": {
|
||||
const ctaUrl = stringVariable(vars, trigger, "ctaUrl", appUrl) ?? appUrl;
|
||||
const ctaLabel = stringVariable(vars, trigger, "ctaLabel");
|
||||
const senderName = stringVariable(vars, trigger, "senderName");
|
||||
const email = renderEmail(WelcomeEmail({
|
||||
productName,
|
||||
ctaUrl,
|
||||
unsubscribeUrl,
|
||||
...(recipientName ? { recipientName } : {}),
|
||||
...(ctaLabel ? { ctaLabel } : {}),
|
||||
...(senderName ? { senderName } : {}),
|
||||
}));
|
||||
return { ...email, subject };
|
||||
}
|
||||
case "trial-reminder": {
|
||||
const upgradeUrl = stringVariable(vars, trigger, "upgradeUrl", `${appUrl}/settings/subscription`);
|
||||
const email = renderEmail(TrialReminderEmail({
|
||||
productName,
|
||||
daysRemaining: numberVariable(vars, trigger, "daysRemaining", 3),
|
||||
upgradeUrl: upgradeUrl ?? `${appUrl}/settings/subscription`,
|
||||
unsubscribeUrl,
|
||||
...(recipientName ? { recipientName } : {}),
|
||||
}));
|
||||
return { ...email, subject };
|
||||
}
|
||||
case "re-activation": {
|
||||
const highlight = stringVariable(vars, trigger, "highlight");
|
||||
const returnUrl = stringVariable(vars, trigger, "returnUrl", appUrl);
|
||||
const email = renderEmail(ReActivationEmail({
|
||||
productName,
|
||||
returnUrl: returnUrl ?? appUrl,
|
||||
unsubscribeUrl,
|
||||
...(recipientName ? { recipientName } : {}),
|
||||
...(highlight ? { highlight } : {}),
|
||||
}));
|
||||
return { ...email, subject };
|
||||
}
|
||||
case "generic-step": {
|
||||
const headline = stringVariable(vars, trigger, "headline", subject) ?? subject;
|
||||
const ctaUrl = stringVariable(vars, trigger, "ctaUrl");
|
||||
const ctaLabel = stringVariable(vars, trigger, "ctaLabel");
|
||||
const email = renderEmail(GenericStepEmail({
|
||||
productName,
|
||||
headline,
|
||||
bodyParagraphs: stringArrayVariable(vars, trigger, "bodyParagraphs", [headline]),
|
||||
unsubscribeUrl,
|
||||
...(recipientName ? { recipientName } : {}),
|
||||
...(ctaUrl ? { ctaUrl } : {}),
|
||||
...(ctaLabel ? { ctaLabel } : {}),
|
||||
}));
|
||||
return { ...email, subject };
|
||||
}
|
||||
default:
|
||||
throw new Error(`email_sequence_template_unknown:${step.template}`);
|
||||
}
|
||||
}
|
||||
|
||||
function variableValue(
|
||||
vars: Readonly<Record<string, unknown>>,
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1],
|
||||
key: string
|
||||
) {
|
||||
const value = vars[key];
|
||||
if (typeof value === "string" && value.startsWith("$.")) {
|
||||
return resolvePath({ trigger, payload: trigger.payload }, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringVariable(
|
||||
vars: Readonly<Record<string, unknown>>,
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1],
|
||||
key: string,
|
||||
fallback?: string
|
||||
): string | undefined {
|
||||
const value = variableValue(vars, trigger, key);
|
||||
if (typeof value === "string" && value.trim().length > 0) return interpolateTemplate(value, vars, trigger);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function numberVariable(
|
||||
vars: Readonly<Record<string, unknown>>,
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1],
|
||||
key: string,
|
||||
fallback: number
|
||||
): number {
|
||||
const value = variableValue(vars, trigger, key);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function stringArrayVariable(
|
||||
vars: Readonly<Record<string, unknown>>,
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1],
|
||||
key: string,
|
||||
fallback: readonly string[]
|
||||
): readonly string[] {
|
||||
const value = variableValue(vars, trigger, key);
|
||||
if (Array.isArray(value)) {
|
||||
const lines = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||
return lines.length > 0 ? lines.map((line) => interpolateTemplate(line, vars, trigger)) : fallback;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return [interpolateTemplate(value, vars, trigger)];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function interpolateTemplate(
|
||||
template: string,
|
||||
vars: Readonly<Record<string, unknown>>,
|
||||
trigger: Parameters<StepRunnerDeps["renderTemplate"]>[1]
|
||||
) {
|
||||
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/gu, (_match, rawPath: string) => {
|
||||
const path = rawPath.trim();
|
||||
const direct = variableValue(vars, trigger, path);
|
||||
const value = direct === undefined ? resolvePath({ trigger, payload: trigger.payload }, path) : direct;
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleEmailSequenceStep(
|
||||
args: Parameters<SequenceEngineQueue["scheduleStep"]>[0],
|
||||
opts: EmailSequenceRuntimeOptions
|
||||
): Promise<void> {
|
||||
const queue = opts.stepQueue ?? (await createEmailSequenceStepQueue(opts.env ?? process.env));
|
||||
if (!queue) {
|
||||
opts.logger.error(
|
||||
{ op: "sequence.queue.scheduleStep.missing_redis", runId: args.runId, stepIndex: args.stepIndex },
|
||||
"cannot schedule email sequence step: Redis URL is not configured"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = (opts.now ?? (() => new Date()))();
|
||||
const delay = Math.max(0, args.runAt.getTime() - now.getTime());
|
||||
const payload: EmailSequenceStepJobData = {
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
scheduledFor: args.runAt.toISOString(),
|
||||
};
|
||||
const jobId = buildEmailSequenceStepJobId(args.runId, args.stepIndex);
|
||||
|
||||
try {
|
||||
const job = await queue.add(EMAIL_SEQUENCE_STEP_JOB_NAME, payload, {
|
||||
attempts: EMAIL_SEQUENCE_STEP_ATTEMPTS,
|
||||
backoff: { type: "exponential", delay: EMAIL_SEQUENCE_STEP_BACKOFF_MS },
|
||||
delay,
|
||||
jobId,
|
||||
removeOnComplete: false,
|
||||
removeOnFail: EMAIL_SEQUENCE_STEP_REMOVE_ON_FAIL,
|
||||
});
|
||||
opts.logger.info?.(
|
||||
{
|
||||
op: "sequence.queue.scheduleStep.enqueued",
|
||||
queue: EMAIL_SEQUENCE_QUEUE,
|
||||
jobId: job.id ?? jobId,
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
delay,
|
||||
},
|
||||
"email sequence step enqueued"
|
||||
);
|
||||
} finally {
|
||||
if (!opts.stepQueue) {
|
||||
await queue.close?.().catch((error) => {
|
||||
opts.logger.warn?.({ error, queue: EMAIL_SEQUENCE_QUEUE }, "failed to close email sequence step queue");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEmailSequenceStepJobId(runId: string, stepIndex: number): string {
|
||||
return `email-sequence-step:${runId}:${stepIndex}`;
|
||||
}
|
||||
|
||||
async function createEmailSequenceStepQueue(env: RedisConnectionEnv): Promise<EmailSequenceStepQueue | null> {
|
||||
const redisUrl = env.BULLMQ_REDIS_URL?.trim() || env.REDIS_URL?.trim();
|
||||
if (!redisUrl) return null;
|
||||
const { Queue } = await import("bullmq");
|
||||
return new Queue(EMAIL_SEQUENCE_QUEUE, { connection: parseRedisUrl(redisUrl) }) as EmailSequenceStepQueue;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EMAIL_SEQUENCE_QUEUE,
|
||||
EmailSequenceStepJobError,
|
||||
parseEmailSequenceStepJob,
|
||||
runEmailSequenceStepJob,
|
||||
} from "./email-sequence-scheduler.js";
|
||||
import type { StepRunnerDeps } from "@toolctrl/email-sequences";
|
||||
|
||||
function buildDeps(): StepRunnerDeps {
|
||||
return {
|
||||
store: {
|
||||
loadRun: vi.fn(async () => null),
|
||||
loadSteps: vi.fn(async () => []),
|
||||
loadTriggerContext: vi.fn(async () => null),
|
||||
markDelivery: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
findRunByTrigger: vi.fn(async () => null),
|
||||
updateRunStatus: vi.fn(),
|
||||
createDelivery: vi.fn(),
|
||||
},
|
||||
queue: { scheduleStep: vi.fn() },
|
||||
renderTemplate: () => ({ html: "<p>x</p>", text: "x", subject: "s" }),
|
||||
sendMail: async () => ({ ok: true }),
|
||||
buildUnsubscribeUrl: () => "https://app.example.com/unsubscribe/tok",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "true";
|
||||
});
|
||||
|
||||
describe("EMAIL_SEQUENCE_QUEUE", () => {
|
||||
it("exposes the queue name", () => {
|
||||
expect(EMAIL_SEQUENCE_QUEUE).toBe("email-sequence.step");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEmailSequenceStepJob", () => {
|
||||
it("accepts a well-formed envelope", () => {
|
||||
const job = parseEmailSequenceStepJob({ runId: "run_1", stepIndex: 2 });
|
||||
expect(job.runId).toBe("run_1");
|
||||
expect(job.stepIndex).toBe(2);
|
||||
});
|
||||
it("rejects missing runId", () => {
|
||||
expect(() => parseEmailSequenceStepJob({ stepIndex: 0 })).toThrow(EmailSequenceStepJobError);
|
||||
});
|
||||
it("rejects negative stepIndex", () => {
|
||||
expect(() => parseEmailSequenceStepJob({ runId: "run_1", stepIndex: -1 })).toThrow(EmailSequenceStepJobError);
|
||||
});
|
||||
it("rejects non-integer stepIndex", () => {
|
||||
expect(() => parseEmailSequenceStepJob({ runId: "run_1", stepIndex: 0.5 })).toThrow(EmailSequenceStepJobError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEmailSequenceStepJob", () => {
|
||||
it("returns cancelled when killswitch active", async () => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "false";
|
||||
const result = await runEmailSequenceStepJob({ runId: "run_1", stepIndex: 0 }, buildDeps());
|
||||
expect(result.action).toBe("cancelled");
|
||||
});
|
||||
it("invokes runStep when enabled", async () => {
|
||||
const deps = buildDeps();
|
||||
const result = await runEmailSequenceStepJob({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
// runStep cancels because loadRun returns null in our fake — that is
|
||||
// the right deterministic outcome here. We just verify the wiring.
|
||||
expect(result.action).toBe("cancelled");
|
||||
expect(deps.store.loadRun).toHaveBeenCalledWith("run_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — sequence step scheduler.
|
||||
*
|
||||
* BullMQ job handler for the `email-sequence.step` queue. The trigger
|
||||
* handler (and the step engine itself) enqueues a delayed job with
|
||||
* `{ runId, stepIndex, runAt }`. BullMQ wakes the worker at `runAt`
|
||||
* and this handler invokes the pure {@link runStep} routine with the
|
||||
* injected store + mail-sender + template renderer.
|
||||
*
|
||||
* Storage / mail / template surfaces are bound by the worker bootstrap
|
||||
* in production; tests pass in-memory fakes.
|
||||
*/
|
||||
|
||||
import { runStep, type StepRunnerDeps, type StepRunResult } from "@toolctrl/email-sequences";
|
||||
|
||||
export const EMAIL_SEQUENCE_QUEUE = "email-sequence.step" as const;
|
||||
export const EMAIL_SEQUENCE_STEP_JOB_NAME = "run-email-sequence-step" as const;
|
||||
|
||||
export interface EmailSequenceStepJobData {
|
||||
readonly runId: string;
|
||||
readonly stepIndex: number;
|
||||
/** ISO timestamp the job was scheduled for — for tracing only. */
|
||||
readonly scheduledFor?: string;
|
||||
}
|
||||
|
||||
export class EmailSequenceStepJobError extends Error {
|
||||
override readonly name = "EmailSequenceStepJobError";
|
||||
readonly code: "INVALID_PAYLOAD";
|
||||
constructor(message: string, code: EmailSequenceStepJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEmailSequenceStepJob(data: unknown): EmailSequenceStepJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new EmailSequenceStepJobError("scheduler job missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const e = data as Record<string, unknown>;
|
||||
if (typeof e.runId !== "string" || e.runId.length === 0) {
|
||||
throw new EmailSequenceStepJobError("scheduler job missing runId", "INVALID_PAYLOAD");
|
||||
}
|
||||
if (typeof e.stepIndex !== "number" || !Number.isInteger(e.stepIndex) || e.stepIndex < 0) {
|
||||
throw new EmailSequenceStepJobError("scheduler job stepIndex invalid", "INVALID_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
runId: e.runId,
|
||||
stepIndex: e.stepIndex,
|
||||
scheduledFor: typeof e.scheduledFor === "string" ? e.scheduledFor : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runEmailSequenceStepJob(
|
||||
data: unknown,
|
||||
deps: StepRunnerDeps
|
||||
): Promise<StepRunResult> {
|
||||
if (process.env.MARKETING_SEQUENCES_ENABLED !== "true") {
|
||||
return { action: "cancelled", completed: true };
|
||||
}
|
||||
const job = parseEmailSequenceStepJob(data);
|
||||
return runStep({ runId: job.runId, stepIndex: job.stepIndex }, deps);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EMAIL_SEQUENCE_TRIGGER_QUEUE,
|
||||
SEQUENCE_TRIGGER_EVENT_TYPES,
|
||||
SequenceTriggerJobError,
|
||||
normaliseTriggerEventType,
|
||||
parseSequenceTriggerJob,
|
||||
runSequenceTriggerJob,
|
||||
type SequenceTriggerDeps,
|
||||
} from "./email-sequence-trigger.js";
|
||||
import type { SequenceDefinition } from "@toolctrl/email-sequences";
|
||||
|
||||
function buildSequence(overrides: Partial<SequenceDefinition> = {}): SequenceDefinition {
|
||||
return {
|
||||
id: "seq_1",
|
||||
tenantId: null,
|
||||
name: "Welcome",
|
||||
description: null,
|
||||
triggerEvent: "lead.captured",
|
||||
isEnabled: true,
|
||||
steps: [{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeps(sequences: SequenceDefinition[]): SequenceTriggerDeps & {
|
||||
scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }>;
|
||||
createdRuns: Array<{ sequenceId: string }>;
|
||||
rememberedTriggers: unknown[];
|
||||
} {
|
||||
const scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }> = [];
|
||||
const createdRuns: Array<{ sequenceId: string }> = [];
|
||||
const rememberedTriggers: unknown[] = [];
|
||||
return {
|
||||
scheduled,
|
||||
createdRuns,
|
||||
rememberedTriggers,
|
||||
loader: {
|
||||
listEnabledForEvent: vi.fn(async (eventType: string) =>
|
||||
sequences.filter((s) => s.triggerEvent === eventType && s.isEnabled)
|
||||
),
|
||||
},
|
||||
store: {
|
||||
createRun: vi.fn(async ({ sequenceId, leadId, tenantId, triggerEventId }) => {
|
||||
createdRuns.push({ sequenceId });
|
||||
return {
|
||||
id: `run_${createdRuns.length}`,
|
||||
sequenceId,
|
||||
leadId,
|
||||
tenantId,
|
||||
triggerEventId,
|
||||
status: "running" as const,
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
}),
|
||||
findRunByTrigger: vi.fn(async () => null),
|
||||
updateRunStatus: vi.fn(),
|
||||
createDelivery: vi.fn(async ({ runId, stepIndex, scheduledAt }) => ({
|
||||
id: `del_${runId}_${stepIndex}`,
|
||||
runId,
|
||||
stepIndex,
|
||||
status: "pending" as const,
|
||||
scheduledAt,
|
||||
})),
|
||||
rememberTrigger: vi.fn((trigger) => {
|
||||
rememberedTriggers.push(trigger);
|
||||
}),
|
||||
},
|
||||
queue: {
|
||||
scheduleStep: vi.fn(async (args) => {
|
||||
scheduled.push(args);
|
||||
}),
|
||||
},
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
now: () => new Date("2026-05-17T12:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "true";
|
||||
});
|
||||
|
||||
describe("EMAIL_SEQUENCE_TRIGGER_QUEUE", () => {
|
||||
it("subscribes to the outbox.marketing queue", () => {
|
||||
expect(EMAIL_SEQUENCE_TRIGGER_QUEUE).toBe("outbox.marketing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEQUENCE_TRIGGER_EVENT_TYPES", () => {
|
||||
it("covers marketing.lead.* + bare lead.* + tenant.* events", () => {
|
||||
expect(SEQUENCE_TRIGGER_EVENT_TYPES).toContain("marketing.lead.captured");
|
||||
expect(SEQUENCE_TRIGGER_EVENT_TYPES).toContain("lead.captured");
|
||||
expect(SEQUENCE_TRIGGER_EVENT_TYPES).toContain("tenant.signup");
|
||||
expect(SEQUENCE_TRIGGER_EVENT_TYPES).toContain("tenant.trial_expiring");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseTriggerEventType", () => {
|
||||
it("strips marketing prefix", () => {
|
||||
expect(normaliseTriggerEventType("marketing.lead.captured")).toBe("lead.captured");
|
||||
});
|
||||
it("maps dispatcher-emitted convertedtotrial to canonical converted_to_trial", () => {
|
||||
expect(normaliseTriggerEventType("marketing.lead.convertedtotrial")).toBe("lead.converted_to_trial");
|
||||
expect(normaliseTriggerEventType("marketing.lead.convertedtocustomer")).toBe("lead.converted_to_customer");
|
||||
});
|
||||
it("leaves tenant.* events untouched", () => {
|
||||
expect(normaliseTriggerEventType("tenant.signup")).toBe("tenant.signup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSequenceTriggerJob", () => {
|
||||
it("accepts well-formed envelope", () => {
|
||||
const job = parseSequenceTriggerJob({
|
||||
eventType: "marketing.lead.captured",
|
||||
payload: { email: "alice@example.com" },
|
||||
outboxEventId: "evt_1",
|
||||
});
|
||||
expect(job.eventType).toBe("marketing.lead.captured");
|
||||
expect(job.outboxEventId).toBe("evt_1");
|
||||
});
|
||||
it("rejects missing eventType", () => {
|
||||
expect(() => parseSequenceTriggerJob({ payload: {} })).toThrow(SequenceTriggerJobError);
|
||||
});
|
||||
it("rejects unknown eventType", () => {
|
||||
expect(() => parseSequenceTriggerJob({ eventType: "billing.invoice.paid", payload: {} })).toThrow(
|
||||
SequenceTriggerJobError
|
||||
);
|
||||
});
|
||||
it("rejects missing payload", () => {
|
||||
expect(() => parseSequenceTriggerJob({ eventType: "lead.captured" })).toThrow(SequenceTriggerJobError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSequenceTriggerJob", () => {
|
||||
const DATA = {
|
||||
eventType: "marketing.lead.captured",
|
||||
outboxEventId: "evt_1",
|
||||
payload: { email: "alice@example.com", lead: { id: "lead_1", status: "new" } },
|
||||
};
|
||||
|
||||
it("returns disabled-shape when killswitch active", async () => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "false";
|
||||
const deps = buildDeps([buildSequence()]);
|
||||
const result = await runSequenceTriggerJob(DATA, deps);
|
||||
expect(result.matched).toBe(0);
|
||||
expect(result.normalisedEventType).toBe("(disabled)");
|
||||
});
|
||||
|
||||
it("returns 0/0/0 when payload has no recipient email", async () => {
|
||||
const deps = buildDeps([buildSequence()]);
|
||||
const result = await runSequenceTriggerJob({ ...DATA, payload: { lead: { id: "lead_1" } } }, deps);
|
||||
expect(result.matched).toBe(0);
|
||||
expect(result.started).toBe(0);
|
||||
});
|
||||
|
||||
it("returns no-match when zero sequences enabled for event", async () => {
|
||||
const deps = buildDeps([buildSequence({ triggerEvent: "tenant.signup" })]);
|
||||
const result = await runSequenceTriggerJob(DATA, deps);
|
||||
expect(result.matched).toBe(0);
|
||||
expect(result.started).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a run for each matching sequence", async () => {
|
||||
const deps = buildDeps([
|
||||
buildSequence({ id: "seq_1" }),
|
||||
buildSequence({ id: "seq_2" }),
|
||||
buildSequence({ id: "seq_off", isEnabled: false }),
|
||||
]);
|
||||
const result = await runSequenceTriggerJob(DATA, deps);
|
||||
expect(result.matched).toBe(2);
|
||||
expect(result.started).toBe(2);
|
||||
expect(deps.createdRuns.map((r) => r.sequenceId).sort()).toEqual(["seq_1", "seq_2"]);
|
||||
expect(deps.scheduled).toHaveLength(2);
|
||||
expect(deps.store.rememberTrigger).toHaveBeenCalledTimes(2);
|
||||
expect(deps.rememberedTriggers).toEqual([
|
||||
expect.objectContaining({
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
email: "alice@example.com",
|
||||
payload: DATA.payload,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
email: "alice@example.com",
|
||||
payload: DATA.payload,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses outboxEventId as triggerEventId for idempotency", async () => {
|
||||
const deps = buildDeps([buildSequence()]);
|
||||
await runSequenceTriggerJob(DATA, deps);
|
||||
expect(deps.store.findRunByTrigger).toHaveBeenCalledWith("seq_1", "evt_1");
|
||||
});
|
||||
|
||||
it("isolates per-sequence failures so one bad sequence does not block others", async () => {
|
||||
const deps = buildDeps([buildSequence({ id: "seq_1" }), buildSequence({ id: "seq_2" })]);
|
||||
// Make sequence 1 fail at createRun, sequence 2 should still start.
|
||||
let firstCall = true;
|
||||
deps.store.createRun = vi.fn(async (args) => {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
throw new Error("simulated failure");
|
||||
}
|
||||
return {
|
||||
id: "run_ok",
|
||||
sequenceId: args.sequenceId,
|
||||
leadId: args.leadId,
|
||||
tenantId: args.tenantId,
|
||||
triggerEventId: args.triggerEventId,
|
||||
status: "running" as const,
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
});
|
||||
const result = await runSequenceTriggerJob(DATA, deps);
|
||||
expect(result.matched).toBe(2);
|
||||
// One started, one failed (counted as skipped to keep the metric simple).
|
||||
expect(result.started + result.skipped).toBe(2);
|
||||
expect(deps.logger?.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("extracts tenantId from top-level field when not on payload.tenant", async () => {
|
||||
const deps = buildDeps([buildSequence({ triggerEvent: "tenant.signup" })]);
|
||||
await runSequenceTriggerJob(
|
||||
{
|
||||
eventType: "tenant.signup",
|
||||
outboxEventId: "evt_2",
|
||||
tenantId: "tenant_z",
|
||||
payload: { email: "ops@tenant.com" },
|
||||
},
|
||||
deps
|
||||
);
|
||||
expect(deps.store.createRun).toHaveBeenCalledWith(expect.objectContaining({ tenantId: "tenant_z" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — sequence trigger handler.
|
||||
*
|
||||
* Subscribed to BullMQ queue `outbox.marketing` (and a fan-out for
|
||||
* `tenant.*` lifecycle events). For every received outbox-event the
|
||||
* handler:
|
||||
* 1. Normalises the event-type (strip `marketing.` prefix so
|
||||
* `marketing.lead.captured` matches sequences with trigger
|
||||
* `lead.captured`).
|
||||
* 2. Looks up all enabled EmailSequences with matching `triggerEvent`.
|
||||
* 3. Builds a `SequenceTriggerContext` from the outbox payload
|
||||
* (lead-id, tenant-id, recipient email + name, payload bag for
|
||||
* condition evaluation).
|
||||
* 4. Invokes {@link startSequenceRun} once per sequence. Idempotency
|
||||
* is provided by `(sequenceId, triggerEventId)` unique constraint
|
||||
* on EmailSequenceRun — duplicate deliveries are no-ops.
|
||||
*
|
||||
* Storage + queue + sequence-loader surfaces are injected so the
|
||||
* handler is fully unit-testable. Production wiring lives in
|
||||
* `email-sequence-trigger-bootstrap.ts`.
|
||||
*/
|
||||
|
||||
import {
|
||||
startSequenceRun,
|
||||
type SequenceDefinition,
|
||||
type SequenceEngineQueue,
|
||||
type SequenceEngineStore,
|
||||
type SequenceTriggerContext,
|
||||
} from "@toolctrl/email-sequences";
|
||||
|
||||
export const EMAIL_SEQUENCE_TRIGGER_QUEUE = "outbox.marketing" as const;
|
||||
|
||||
/** Set of outbox event-types this handler will react to. */
|
||||
export const SEQUENCE_TRIGGER_EVENT_TYPES: ReadonlyArray<string> = [
|
||||
"marketing.lead.captured",
|
||||
"marketing.lead.qualified",
|
||||
"marketing.lead.convertedtotrial",
|
||||
"marketing.lead.convertedtocustomer",
|
||||
"marketing.lead.stale_60d",
|
||||
"lead.captured",
|
||||
"lead.qualified",
|
||||
"lead.converted_to_trial",
|
||||
"lead.converted_to_customer",
|
||||
"lead.stale_60d",
|
||||
"tenant.signup",
|
||||
"tenant.trial_expiring",
|
||||
"tenant.cancelled",
|
||||
];
|
||||
|
||||
export interface SequenceTriggerJobData {
|
||||
/** Outbox row id — used for idempotency in startSequenceRun. */
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId?: string | null;
|
||||
readonly eventType: string;
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class SequenceTriggerJobError extends Error {
|
||||
override readonly name = "SequenceTriggerJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "UNKNOWN_EVENT_TYPE";
|
||||
constructor(message: string, code: SequenceTriggerJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SequenceTriggerLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-side surface needed to find sequences matching an event. The
|
||||
* production implementation queries Prisma; tests provide an in-memory
|
||||
* fake.
|
||||
*/
|
||||
export interface SequenceTriggerLoader {
|
||||
listEnabledForEvent(normalisedEventType: string): Promise<readonly SequenceDefinition[]>;
|
||||
}
|
||||
|
||||
export interface SequenceTriggerDeps {
|
||||
readonly loader: SequenceTriggerLoader;
|
||||
readonly store: SequenceEngineStore & {
|
||||
rememberTrigger?: (trigger: SequenceTriggerContext | null) => void;
|
||||
};
|
||||
readonly queue: SequenceEngineQueue;
|
||||
readonly logger?: SequenceTriggerLogger;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
export interface SequenceTriggerJobResult {
|
||||
readonly normalisedEventType: string;
|
||||
readonly matched: number;
|
||||
readonly started: number;
|
||||
readonly skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `marketing.` prefix so an outbox event-type like
|
||||
* `marketing.lead.captured` matches a sequence whose `triggerEvent` is
|
||||
* the bare `lead.captured`. We also unify the dashed
|
||||
* `marketing.lead.convertedtotrial` (no underscore — that is what the
|
||||
* dispatcher emits today) to the documented `lead.converted_to_trial`.
|
||||
*/
|
||||
export function normaliseTriggerEventType(eventType: string): string {
|
||||
const trimmed = eventType.startsWith("marketing.") ? eventType.slice("marketing.".length) : eventType;
|
||||
switch (trimmed) {
|
||||
case "lead.convertedtotrial":
|
||||
return "lead.converted_to_trial";
|
||||
case "lead.convertedtocustomer":
|
||||
return "lead.converted_to_customer";
|
||||
default:
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSequenceTriggerJob(data: unknown): SequenceTriggerJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new SequenceTriggerJobError("trigger job missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const e = data as Record<string, unknown>;
|
||||
const eventType = typeof e.eventType === "string" ? e.eventType : "";
|
||||
if (eventType.length === 0) {
|
||||
throw new SequenceTriggerJobError("trigger job missing eventType", "INVALID_PAYLOAD");
|
||||
}
|
||||
if (!SEQUENCE_TRIGGER_EVENT_TYPES.includes(eventType)) {
|
||||
throw new SequenceTriggerJobError(`unsupported event-type: ${eventType}`, "UNKNOWN_EVENT_TYPE");
|
||||
}
|
||||
const payload = e.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new SequenceTriggerJobError("trigger job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
outboxEventId: typeof e.outboxEventId === "string" ? e.outboxEventId : undefined,
|
||||
tenantId: typeof e.tenantId === "string" ? e.tenantId : null,
|
||||
eventType,
|
||||
payload: payload as Readonly<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
|
||||
function extractEmail(payload: Readonly<Record<string, unknown>>): string | null {
|
||||
const direct = payload["email"];
|
||||
if (typeof direct === "string" && direct.includes("@")) return direct;
|
||||
const lead = payload["lead"];
|
||||
if (lead && typeof lead === "object") {
|
||||
const leadEmail = (lead as Record<string, unknown>)["email"];
|
||||
if (typeof leadEmail === "string" && leadEmail.includes("@")) return leadEmail;
|
||||
}
|
||||
const tenant = payload["tenant"];
|
||||
if (tenant && typeof tenant === "object") {
|
||||
const tenantEmail = (tenant as Record<string, unknown>)["email"];
|
||||
if (typeof tenantEmail === "string" && tenantEmail.includes("@")) return tenantEmail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractLeadId(payload: Readonly<Record<string, unknown>>): string | null {
|
||||
const direct = payload["leadId"];
|
||||
if (typeof direct === "string" && direct) return direct;
|
||||
const lead = payload["lead"];
|
||||
if (lead && typeof lead === "object") {
|
||||
const id = (lead as Record<string, unknown>)["id"];
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTenantId(payload: Readonly<Record<string, unknown>>, fallback: string | null): string | null {
|
||||
const direct = payload["tenantId"];
|
||||
if (typeof direct === "string" && direct) return direct;
|
||||
const tenant = payload["tenant"];
|
||||
if (tenant && typeof tenant === "object") {
|
||||
const id = (tenant as Record<string, unknown>)["id"];
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function extractRecipientName(payload: Readonly<Record<string, unknown>>): string | null {
|
||||
const direct = payload["recipientName"] ?? payload["firstName"];
|
||||
if (typeof direct === "string" && direct) return direct;
|
||||
const lead = payload["lead"];
|
||||
if (lead && typeof lead === "object") {
|
||||
const firstName = (lead as Record<string, unknown>)["firstName"];
|
||||
if (typeof firstName === "string" && firstName) return firstName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main handler entry point. Looks up matching sequences and starts a
|
||||
* run for each. Per-sequence failures are isolated so one bad
|
||||
* definition cannot block the others.
|
||||
*/
|
||||
export async function runSequenceTriggerJob(
|
||||
data: unknown,
|
||||
deps: SequenceTriggerDeps
|
||||
): Promise<SequenceTriggerJobResult> {
|
||||
if (process.env.MARKETING_SEQUENCES_ENABLED !== "true") {
|
||||
deps.logger?.info?.({ op: "sequence.trigger.skipped" }, "email sequences disabled by env");
|
||||
return { normalisedEventType: "(disabled)", matched: 0, started: 0, skipped: 0 };
|
||||
}
|
||||
const job = parseSequenceTriggerJob(data);
|
||||
const normalised = normaliseTriggerEventType(job.eventType);
|
||||
const email = extractEmail(job.payload);
|
||||
if (!email) {
|
||||
deps.logger?.warn?.(
|
||||
{ op: "sequence.trigger.no_email", eventType: job.eventType, outboxEventId: job.outboxEventId },
|
||||
"trigger payload missing recipient email — cannot enrol any sequence"
|
||||
);
|
||||
return { normalisedEventType: normalised, matched: 0, started: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const sequences = await deps.loader.listEnabledForEvent(normalised);
|
||||
if (sequences.length === 0) {
|
||||
deps.logger?.info?.({ op: "sequence.trigger.no_match", eventType: normalised }, "no enabled sequences match event");
|
||||
return { normalisedEventType: normalised, matched: 0, started: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const trigger: SequenceTriggerContext = {
|
||||
triggerEventId: job.outboxEventId ?? null,
|
||||
leadId: extractLeadId(job.payload),
|
||||
tenantId: extractTenantId(job.payload, job.tenantId ?? null),
|
||||
email,
|
||||
recipientName: extractRecipientName(job.payload),
|
||||
payload: job.payload,
|
||||
};
|
||||
|
||||
let started = 0;
|
||||
let skipped = 0;
|
||||
for (const sequence of sequences) {
|
||||
try {
|
||||
deps.store.rememberTrigger?.(trigger);
|
||||
const result = await startSequenceRun(
|
||||
{ sequence, trigger },
|
||||
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
|
||||
);
|
||||
if (result.scheduled) started += 1;
|
||||
else skipped += 1;
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
op: result.scheduled ? "sequence.trigger.started" : "sequence.trigger.skipped",
|
||||
sequenceId: sequence.id,
|
||||
runId: result.run.id || null,
|
||||
alreadyExisted: result.alreadyExisted,
|
||||
},
|
||||
result.scheduled ? "sequence run started" : "sequence run not scheduled"
|
||||
);
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
deps.logger?.error(
|
||||
{
|
||||
op: "sequence.trigger.error",
|
||||
sequenceId: sequence.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"sequence run failed to start"
|
||||
);
|
||||
}
|
||||
}
|
||||
return { normalisedEventType: normalised, matched: sequences.length, started, skipped };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TenantPlanTier } from "@toolctrl/exports/limits";
|
||||
import type { ExportRecord } from "@toolctrl/exports/types";
|
||||
import type { ExportJob } from "@toolctrl/queue";
|
||||
import type { SharedStore } from "@toolctrl/shared-store";
|
||||
|
||||
import { createExportJobRunner, type ExportRowSource, type ExportStreamerRegistry } from "./export.js";
|
||||
|
||||
function buildPayload(overrides: Partial<ExportJob> = {}): ExportJob {
|
||||
return {
|
||||
kind: "export.run",
|
||||
exportJobId: "job-1",
|
||||
tenantId: "tenant-1",
|
||||
requestedByUserId: "user-1",
|
||||
exportType: "users",
|
||||
format: "csv",
|
||||
...overrides,
|
||||
} as ExportJob;
|
||||
}
|
||||
|
||||
function buildRowSource(rows: ExportRecord[], columns: readonly string[] = ["id", "name"], estimated?: number): ExportRowSource {
|
||||
return {
|
||||
estimateRowCount: vi.fn().mockResolvedValue(estimated),
|
||||
resolveColumns: vi.fn().mockResolvedValue(columns),
|
||||
iterateRows: () => (async function* () {
|
||||
for (const row of rows) yield row;
|
||||
})(),
|
||||
} satisfies ExportRowSource;
|
||||
}
|
||||
|
||||
interface SharedStoreStub {
|
||||
store: SharedStore;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function buildSharedStoreStub(): SharedStoreStub {
|
||||
const set = vi.fn().mockResolvedValue(true);
|
||||
const stub = {
|
||||
set,
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
incr: vi.fn(),
|
||||
expire: vi.fn(),
|
||||
mget: vi.fn(),
|
||||
scan: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
zAdd: vi.fn(),
|
||||
zRemRangeByScore: vi.fn(),
|
||||
zCard: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
close: vi.fn(),
|
||||
key: vi.fn(),
|
||||
namespace: "x",
|
||||
ttlDefault: undefined,
|
||||
adapter: {} as never,
|
||||
};
|
||||
return { store: stub as unknown as SharedStore, set };
|
||||
}
|
||||
|
||||
function buildCountingRegistry(): { registry: ExportStreamerRegistry; written: PassThrough; calls: { csv: number; json: number; xlsx: number; pdf: number } } {
|
||||
const written = new PassThrough();
|
||||
written.on("data", () => undefined);
|
||||
const calls = { csv: 0, json: 0, xlsx: 0, pdf: 0 };
|
||||
|
||||
const consume: ExportStreamerRegistry["csv"] = async (writeStream, _columns, rows) => {
|
||||
let count = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (const _row of rows) {
|
||||
count += 1;
|
||||
}
|
||||
writeStream.end();
|
||||
return { rowCount: count };
|
||||
};
|
||||
|
||||
return {
|
||||
written,
|
||||
calls,
|
||||
registry: {
|
||||
csv: async (...args) => { calls.csv += 1; return consume(...args); },
|
||||
json: async (...args) => { calls.json += 1; return consume(...args); },
|
||||
xlsx: async (...args) => { calls.xlsx += 1; return consume(...args); },
|
||||
pdf: async (...args) => { calls.pdf += 1; return consume(...args); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildUploadStub(written: PassThrough): { Upload: unknown; done: ReturnType<typeof vi.fn> } {
|
||||
const done = vi.fn().mockResolvedValue({ ETag: '"abc"' });
|
||||
const Upload = vi.fn().mockImplementation((args: { params: { Body: PassThrough } }) => {
|
||||
args.params.Body.pipe(written);
|
||||
return { done };
|
||||
});
|
||||
return { Upload, done };
|
||||
}
|
||||
|
||||
describe("createExportJobRunner", () => {
|
||||
it("returns the resolved row count and artifact metadata on success", async () => {
|
||||
const { registry, written } = buildCountingRegistry();
|
||||
const upload = buildUploadStub(written);
|
||||
|
||||
const runner = createExportJobRunner({
|
||||
s3Client: {} as never,
|
||||
rowSource: buildRowSource([{ id: 1 }, { id: 2 }, { id: 3 }], ["id"]),
|
||||
resolveTenantPlan: async (): Promise<TenantPlanTier> => "pro",
|
||||
buildArtifactKey: () => ({ bucket: "exports", key: "tenants/t/job.csv", contentType: "text/csv", expiresAt: new Date("2026-01-01T00:00:00Z") }),
|
||||
streamerRegistry: registry,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadCtor: upload.Upload as any,
|
||||
});
|
||||
|
||||
const result = await runner(buildPayload());
|
||||
expect(result).toEqual({
|
||||
artifactBucket: "exports",
|
||||
artifactKey: "tenants/t/job.csv",
|
||||
rowCount: 3,
|
||||
expiresAt: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
expect(upload.done).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects up front when estimateRowCount exceeds the resolved plan cap", async () => {
|
||||
const { registry, written } = buildCountingRegistry();
|
||||
const upload = buildUploadStub(written);
|
||||
const runner = createExportJobRunner({
|
||||
s3Client: {} as never,
|
||||
rowSource: buildRowSource([{ id: 1 }], ["id"], 20_000),
|
||||
resolveTenantPlan: async () => "free",
|
||||
buildArtifactKey: () => ({ bucket: "b", key: "k", contentType: "text/csv", expiresAt: new Date() }),
|
||||
streamerRegistry: registry,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadCtor: upload.Upload as any,
|
||||
});
|
||||
|
||||
await expect(runner(buildPayload())).rejects.toThrow(/EXPORT_ROW_LIMIT_PLAN|exceeds the free/);
|
||||
expect(registry.csv).not.toHaveBeenCalled;
|
||||
});
|
||||
|
||||
it("publishes progress to the shared store on every flushInterval rows", async () => {
|
||||
const rows: ExportRecord[] = Array.from({ length: 12 }, (_, i) => ({ id: i }));
|
||||
const { registry, written } = buildCountingRegistry();
|
||||
const upload = buildUploadStub(written);
|
||||
const sharedStore = buildSharedStoreStub();
|
||||
|
||||
const runner = createExportJobRunner({
|
||||
s3Client: {} as never,
|
||||
sharedStore: sharedStore.store,
|
||||
rowSource: buildRowSource(rows, ["id"], 12),
|
||||
resolveTenantPlan: async () => "pro",
|
||||
buildArtifactKey: () => ({ bucket: "b", key: "k", contentType: "text/csv", expiresAt: new Date("2026-01-01") }),
|
||||
streamerRegistry: registry,
|
||||
progressFlushInterval: 5,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadCtor: upload.Upload as any,
|
||||
});
|
||||
|
||||
const result = await runner(buildPayload());
|
||||
expect(result.rowCount).toBe(12);
|
||||
// Expect at least the row-5, row-10 progress flushes plus the final
|
||||
// success flush.
|
||||
expect(sharedStore.set).toHaveBeenCalledWith(
|
||||
"export:progress:job-1",
|
||||
expect.stringContaining('"processedRows":5'),
|
||||
expect.objectContaining({ ttlSeconds: 3600 }),
|
||||
);
|
||||
expect(sharedStore.set).toHaveBeenCalledWith(
|
||||
"export:progress:job-1",
|
||||
expect.stringContaining('"processedRows":12'),
|
||||
expect.objectContaining({ ttlSeconds: 3600 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("destroys the write stream when serialisation throws", async () => {
|
||||
const written = new PassThrough();
|
||||
written.on("data", () => undefined);
|
||||
const failingRegistry: ExportStreamerRegistry = {
|
||||
csv: async () => { throw new Error("boom"); },
|
||||
json: async () => { throw new Error("boom"); },
|
||||
xlsx: async () => { throw new Error("boom"); },
|
||||
pdf: async () => { throw new Error("boom"); },
|
||||
};
|
||||
const upload = buildUploadStub(written);
|
||||
|
||||
const runner = createExportJobRunner({
|
||||
s3Client: {} as never,
|
||||
rowSource: buildRowSource([{ id: 1 }], ["id"]),
|
||||
resolveTenantPlan: async () => "pro",
|
||||
buildArtifactKey: () => ({ bucket: "b", key: "k", contentType: "text/csv", expiresAt: new Date() }),
|
||||
streamerRegistry: failingRegistry,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadCtor: upload.Upload as any,
|
||||
});
|
||||
|
||||
await expect(runner(buildPayload())).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Writable } from "node:stream";
|
||||
|
||||
import type { Upload } from "@aws-sdk/lib-storage";
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import { serializeExportTableToCsvStream } from "@toolctrl/exports/csv";
|
||||
import { serializeExportTableToJsonStream } from "@toolctrl/exports/json";
|
||||
import { ExportRowCapExceededError, assertExportRowCap, resolveExportRowCap, type ExportFormat, type TenantPlanTier } from "@toolctrl/exports/limits";
|
||||
import { serializeExportTableToPdfStream } from "@toolctrl/exports/pdf";
|
||||
import { createS3MultipartUploadSink } from "@toolctrl/exports/s3-multipart";
|
||||
import type { ExportRecord } from "@toolctrl/exports/types";
|
||||
import { serializeExportTableToXlsxStream } from "@toolctrl/exports/xlsx";
|
||||
import type { SharedStore } from "@toolctrl/shared-store";
|
||||
import type { ExportJob } from "@toolctrl/queue";
|
||||
|
||||
import { workerLogger } from "../logger.js";
|
||||
|
||||
export interface WorkerExportRunResult {
|
||||
artifactBucket: string;
|
||||
artifactKey: string;
|
||||
rowCount: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async row-source contract — implementations stream the rows of the
|
||||
* target export in chunks (typically a Prisma cursor) without buffering
|
||||
* the full dataset. The job pipeline orchestrates serialisation,
|
||||
* progress-tracking and S3 multipart upload around this iterator.
|
||||
*/
|
||||
export interface ExportRowSourceContext {
|
||||
payload: ExportJob;
|
||||
plan: TenantPlanTier;
|
||||
/** Effective row cap (min of plan and format hard cap). */
|
||||
effectiveRowCap: number;
|
||||
}
|
||||
|
||||
export interface ExportRowSource {
|
||||
/** Best-effort row-count estimate, used for XLSX strategy selection. */
|
||||
estimateRowCount(context: ExportRowSourceContext): Promise<number | undefined>;
|
||||
/** Column names in export order. */
|
||||
resolveColumns(context: ExportRowSourceContext): Promise<readonly string[]>;
|
||||
/** Async iterator over the row stream. */
|
||||
iterateRows(context: ExportRowSourceContext): AsyncIterable<ExportRecord>;
|
||||
}
|
||||
|
||||
export interface ExportTenantPlanResolver {
|
||||
(tenantId: string): Promise<TenantPlanTier>;
|
||||
}
|
||||
|
||||
export interface ExportArtifactKeyBuilder {
|
||||
(payload: ExportJob): { bucket: string; key: string; contentType: string; expiresAt: Date };
|
||||
}
|
||||
|
||||
export interface ExportJobDependencies {
|
||||
s3Client: S3Client;
|
||||
sharedStore?: SharedStore;
|
||||
rowSource: ExportRowSource;
|
||||
resolveTenantPlan: ExportTenantPlanResolver;
|
||||
buildArtifactKey: ExportArtifactKeyBuilder;
|
||||
/** Test seam — overrides the per-format streamer registry. */
|
||||
streamerRegistry?: ExportStreamerRegistry;
|
||||
/** Progress-publish frequency in rows; defaults to 5_000. */
|
||||
progressFlushInterval?: number;
|
||||
/** TTL applied to the progress key in seconds; defaults to 3600 (1h). */
|
||||
progressTtlSeconds?: number;
|
||||
/** Test seam — overrides the @aws-sdk/lib-storage Upload class. */
|
||||
uploadCtor?: typeof Upload;
|
||||
}
|
||||
|
||||
export type ExportFormatNarrow = ExportFormat;
|
||||
|
||||
export interface ExportStreamerRegistry {
|
||||
csv: (writeStream: Writable, columns: readonly string[], rows: AsyncIterable<ExportRecord>, estimatedRowCount: number | undefined) => Promise<{ rowCount: number }>;
|
||||
json: (writeStream: Writable, columns: readonly string[], rows: AsyncIterable<ExportRecord>, estimatedRowCount: number | undefined) => Promise<{ rowCount: number }>;
|
||||
xlsx: (writeStream: Writable, columns: readonly string[], rows: AsyncIterable<ExportRecord>, estimatedRowCount: number | undefined) => Promise<{ rowCount: number }>;
|
||||
pdf: (writeStream: Writable, columns: readonly string[], rows: AsyncIterable<ExportRecord>, estimatedRowCount: number | undefined) => Promise<{ rowCount: number }>;
|
||||
}
|
||||
|
||||
const DEFAULT_REGISTRY: ExportStreamerRegistry = {
|
||||
csv: async (writeStream, columns, rows) =>
|
||||
serializeExportTableToCsvStream(writeStream, { columns: [...columns], rows }),
|
||||
json: async (writeStream, columns, rows) =>
|
||||
serializeExportTableToJsonStream(writeStream, { columns: [...columns], rows }),
|
||||
xlsx: async (writeStream, columns, rows, estimatedRowCount) =>
|
||||
serializeExportTableToXlsxStream(writeStream, {
|
||||
columns: [...columns],
|
||||
rows,
|
||||
...(estimatedRowCount !== undefined ? { estimatedRowCount } : {}),
|
||||
}),
|
||||
pdf: async (writeStream, columns, rows) => {
|
||||
// PDF needs a buffered table because react-pdf renders the document
|
||||
// tree synchronously; we cap below the format hard limit so heap
|
||||
// usage stays bounded.
|
||||
const buffered: ExportRecord[] = [];
|
||||
for await (const row of rows) {
|
||||
buffered.push(row);
|
||||
}
|
||||
return serializeExportTableToPdfStream(writeStream, { columns: [...columns], rows: buffered });
|
||||
},
|
||||
};
|
||||
|
||||
export function createExportJobRunner(deps: ExportJobDependencies): (payload: ExportJob) => Promise<WorkerExportRunResult> {
|
||||
const flushInterval = deps.progressFlushInterval ?? 5_000;
|
||||
const progressTtl = deps.progressTtlSeconds ?? 3_600;
|
||||
const registry = deps.streamerRegistry ?? DEFAULT_REGISTRY;
|
||||
|
||||
return async function runExport(payload: ExportJob): Promise<WorkerExportRunResult> {
|
||||
const format = normaliseFormat(payload.format);
|
||||
const plan = await deps.resolveTenantPlan(payload.tenantId);
|
||||
const effectiveRowCap = resolveExportRowCap(plan, format);
|
||||
const context: ExportRowSourceContext = { payload, plan, effectiveRowCap };
|
||||
|
||||
const estimate = await deps.rowSource.estimateRowCount(context);
|
||||
if (estimate !== undefined) {
|
||||
assertExportRowCap(estimate, plan, format);
|
||||
}
|
||||
|
||||
const columns = await deps.rowSource.resolveColumns(context);
|
||||
const artifact = deps.buildArtifactKey(payload);
|
||||
const { writeStream, completionPromise } = createS3MultipartUploadSink({
|
||||
client: deps.s3Client,
|
||||
bucket: artifact.bucket,
|
||||
key: artifact.key,
|
||||
contentType: artifact.contentType,
|
||||
metadata: { tenantId: payload.tenantId, exportJobId: payload.exportJobId, exportType: payload.exportType },
|
||||
...(deps.uploadCtor ? { uploadCtor: deps.uploadCtor } : {}),
|
||||
});
|
||||
|
||||
let processedRows = 0;
|
||||
const guardedRows = (async function* () {
|
||||
for await (const row of deps.rowSource.iterateRows(context)) {
|
||||
if (processedRows >= effectiveRowCap) {
|
||||
throw new ExportRowCapExceededError(processedRows + 1, effectiveRowCap, plan, format);
|
||||
}
|
||||
processedRows += 1;
|
||||
if (deps.sharedStore && processedRows % flushInterval === 0) {
|
||||
// Best-effort progress publish — failure here must not abort
|
||||
// the export so we swallow + log only.
|
||||
await publishProgress(deps.sharedStore, payload.exportJobId, processedRows, estimate, progressTtl);
|
||||
}
|
||||
yield row;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const result = await registry[format](writeStream, columns, guardedRows, estimate);
|
||||
writeStream.end();
|
||||
const completion = await completionPromise;
|
||||
|
||||
if (deps.sharedStore) {
|
||||
await publishProgress(deps.sharedStore, payload.exportJobId, result.rowCount, estimate, progressTtl);
|
||||
}
|
||||
|
||||
return {
|
||||
artifactBucket: completion.bucket,
|
||||
artifactKey: completion.key,
|
||||
rowCount: result.rowCount,
|
||||
expiresAt: artifact.expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
writeStream.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function publishProgress(
|
||||
store: SharedStore,
|
||||
exportJobId: string,
|
||||
processedRows: number,
|
||||
totalEstimate: number | undefined,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await store.set(
|
||||
`export:progress:${exportJobId}`,
|
||||
JSON.stringify({ processedRows, totalEstimate: totalEstimate ?? null, updatedAt: new Date().toISOString() }),
|
||||
{ ttlSeconds },
|
||||
);
|
||||
} catch (error) {
|
||||
workerLogger.warn({ error, exportJobId }, "Failed to publish export progress to shared store.");
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseFormat(format: ExportJob["format"]): ExportFormat {
|
||||
switch (format) {
|
||||
case "csv":
|
||||
case "json":
|
||||
return format;
|
||||
default: {
|
||||
// The queue payload only declares csv/json today, but the worker is
|
||||
// ready for xlsx/pdf as soon as the producer side widens the union.
|
||||
const widened = format as unknown as ExportFormat;
|
||||
if (widened === "xlsx" || widened === "pdf") return widened;
|
||||
throw new Error(`Unsupported export format: ${String(format)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the S3Client used by the worker for export multipart uploads.
|
||||
* Mirrors the scanService env shape so operators configure a single set of
|
||||
* STORAGE_* variables for both the scanner and the exporter.
|
||||
*/
|
||||
export function createWorkerExportS3Client(env: EnvRecord = process.env as EnvRecord): S3Client | undefined {
|
||||
if (!env.STORAGE_REGION || !env.STORAGE_ACCESS_KEY_ID || !env.STORAGE_SECRET_ACCESS_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
return new S3Client({
|
||||
endpoint: env.STORAGE_ENDPOINT,
|
||||
region: env.STORAGE_REGION,
|
||||
forcePathStyle: env.STORAGE_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: env.STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY,
|
||||
...(env.STORAGE_SESSION_TOKEN ? { sessionToken: env.STORAGE_SESSION_TOKEN } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// AK-P4a §4 — file-preview service (BullMQ `toolctrl.file-preview`).
|
||||
//
|
||||
// Mirrors scanService.ts: a dedicated AWS S3 client built from STORAGE_* env
|
||||
// GETs the original, renders the preview and PUTs preview/thumb into the same
|
||||
// private bucket (AD-23). previewStatus is written back onto the File row
|
||||
// (pending -> generating -> ready|failed|not_supported).
|
||||
//
|
||||
// Format matrix v1 (designer brief 2026-06-12):
|
||||
// image/heic|heif -> heic-convert -> JPG -> preview.jpg + thumb.webp
|
||||
// image/jpeg|png|gif|webp -> sharp -> WebP -> preview.webp + thumb.webp
|
||||
// application/pdf -> pdf-to-png -> PNG -> preview.png + thumb.webp
|
||||
// text/plain | markdown -> ready (inline render from bytes, no file)
|
||||
// anything else -> not_supported
|
||||
//
|
||||
// The native render libs (sharp, heic-convert, pdf-to-png-converter) are loaded
|
||||
// via dynamic string import (same pattern as qdrant/sentry in index.ts) so
|
||||
// typecheck/build stay green before they are installed.
|
||||
// ACTIVATION: `pnpm add sharp heic-convert pdf-to-png-converter -F @toolctrl/worker`.
|
||||
// If a lib is missing at runtime the service falls back cleanly to
|
||||
// previewStatus=failed (no hard crash) and the user can regenerate from detail.
|
||||
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import type { FilePreviewJob } from "@toolctrl/queue";
|
||||
import { buildFilePreviewKey, buildFileStorageKeys, type FilePreviewExtension } from "@toolctrl/storage";
|
||||
|
||||
import { withTenantRLS } from "../db/tenantRls.js";
|
||||
import type { FilePreviewServiceResult } from "../handlers.js";
|
||||
|
||||
export interface FilePreviewServiceEnv {
|
||||
STORAGE_ENDPOINT?: string;
|
||||
STORAGE_REGION?: string;
|
||||
STORAGE_ACCESS_KEY_ID?: string;
|
||||
STORAGE_SECRET_ACCESS_KEY?: string;
|
||||
STORAGE_SESSION_TOKEN?: string;
|
||||
STORAGE_FORCE_PATH_STYLE?: string;
|
||||
}
|
||||
|
||||
type PreviewLogger = {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The File write surface the preview status write-back uses. `$executeRaw` is
|
||||
* required so the helper can set `app.tenant_id` on the transaction (AK-P4a RLS
|
||||
* prep). `data` stays string-typed to avoid coupling to the generated Prisma
|
||||
* enum types (keeps the dynamic-import build green before native libs install).
|
||||
*/
|
||||
interface FilePreviewTx {
|
||||
$executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>;
|
||||
file: {
|
||||
update(args: {
|
||||
where: { id: string };
|
||||
data: {
|
||||
previewStatus?: string;
|
||||
previewStorageKey?: string | null;
|
||||
thumbnailStorageKey?: string | null;
|
||||
};
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal Prisma surface the service needs (tenant transaction). */
|
||||
export interface FilePreviewPrisma {
|
||||
$transaction<T>(fn: (tx: FilePreviewTx) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface FilePreviewService {
|
||||
generatePreview(payload: FilePreviewJob): Promise<FilePreviewServiceResult>;
|
||||
}
|
||||
|
||||
const THUMBNAIL_MAX_DIMENSION = 480;
|
||||
const PREVIEW_MAX_DIMENSION = 2048;
|
||||
|
||||
const RASTER_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
const HEIC_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const TEXT_TYPES = new Set(["text/plain", "text/markdown", "text/csv"]);
|
||||
|
||||
function normalizeMime(mimeType: string): string {
|
||||
return mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the preview service from STORAGE_* env. Returns undefined when the S3
|
||||
* credentials are missing (the bootstrap wiring then skips it and the handler
|
||||
* refuses jobs with a config error instead of running blind).
|
||||
*/
|
||||
export function createWorkerFilePreviewService(
|
||||
env: EnvRecord & FilePreviewServiceEnv,
|
||||
options: { prisma: FilePreviewPrisma; logger: PreviewLogger },
|
||||
): FilePreviewService | undefined {
|
||||
if (!env.STORAGE_REGION || !env.STORAGE_ACCESS_KEY_ID || !env.STORAGE_SECRET_ACCESS_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { prisma, logger } = options;
|
||||
const s3 = new AwsS3Client({
|
||||
endpoint: env.STORAGE_ENDPOINT,
|
||||
region: env.STORAGE_REGION,
|
||||
forcePathStyle: env.STORAGE_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: env.STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY,
|
||||
sessionToken: env.STORAGE_SESSION_TOKEN,
|
||||
},
|
||||
});
|
||||
|
||||
async function loadOriginal(bucket: string, key: string): Promise<Buffer> {
|
||||
const result = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||
const body = result.Body;
|
||||
if (!body) {
|
||||
throw new Error(`S3 GET ${bucket}/${key} returned an empty body`);
|
||||
}
|
||||
// The AWS SDK exposes transformToByteArray() on the Node stream body.
|
||||
const bytes = await (body as { transformToByteArray(): Promise<Uint8Array> }).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
async function putRendered(bucket: string, key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
||||
}
|
||||
|
||||
async function writeStatus(
|
||||
tenantId: string,
|
||||
fileId: string,
|
||||
data: { previewStatus: string; previewStorageKey?: string | null; thumbnailStorageKey?: string | null },
|
||||
): Promise<void> {
|
||||
// Set the tenant GUC so the write satisfies the File RLS policy once it is
|
||||
// activated (P5 cutover). Preview jobs always carry a tenantId.
|
||||
await withTenantRLS(prisma, tenantId, (tx) => tx.file.update({ where: { id: fileId }, data }));
|
||||
}
|
||||
|
||||
return {
|
||||
async generatePreview(payload: FilePreviewJob): Promise<FilePreviewServiceResult> {
|
||||
const mime = normalizeMime(payload.mimeType);
|
||||
const keys = buildFileStorageKeys(payload.tenantId, payload.fileId);
|
||||
|
||||
// Plain text / markdown: detail view renders inline from bytes, no
|
||||
// rendered preview file needed.
|
||||
if (TEXT_TYPES.has(mime)) {
|
||||
await writeStatus(payload.tenantId, payload.fileId, { previewStatus: "ready", previewStorageKey: null, thumbnailStorageKey: null });
|
||||
return { previewStatus: "ready" };
|
||||
}
|
||||
|
||||
const isHeic = HEIC_TYPES.has(mime);
|
||||
const isRaster = RASTER_IMAGE_TYPES.has(mime);
|
||||
const isPdf = mime === "application/pdf";
|
||||
|
||||
if (!isHeic && !isRaster && !isPdf) {
|
||||
await writeStatus(payload.tenantId, payload.fileId, { previewStatus: "not_supported", previewStorageKey: null, thumbnailStorageKey: null });
|
||||
return { previewStatus: "not_supported" };
|
||||
}
|
||||
|
||||
await writeStatus(payload.tenantId, payload.fileId, { previewStatus: "generating" });
|
||||
|
||||
try {
|
||||
const original = await loadOriginal(payload.bucket, payload.storageKey);
|
||||
let previewBuffer: Buffer;
|
||||
let previewExt: FilePreviewExtension;
|
||||
let previewContentType: string;
|
||||
|
||||
if (isHeic) {
|
||||
// HEIC -> JPG (mandatory for iPhone field-worker photos).
|
||||
const jpg = await heicToJpeg(original);
|
||||
previewBuffer = await rasterToFormat(jpg, "jpeg", PREVIEW_MAX_DIMENSION);
|
||||
previewExt = "jpg";
|
||||
previewContentType = "image/jpeg";
|
||||
} else if (isPdf) {
|
||||
const png = await pdfFirstPageToPng(original);
|
||||
previewBuffer = await rasterToFormat(png, "png", PREVIEW_MAX_DIMENSION);
|
||||
previewExt = "png";
|
||||
previewContentType = "image/png";
|
||||
} else {
|
||||
previewBuffer = await rasterToFormat(original, "webp", PREVIEW_MAX_DIMENSION);
|
||||
previewExt = "webp";
|
||||
previewContentType = "image/webp";
|
||||
}
|
||||
|
||||
const thumbnail = await rasterToFormat(previewBuffer, "webp", THUMBNAIL_MAX_DIMENSION);
|
||||
|
||||
const previewKey = buildFilePreviewKey(payload.tenantId, payload.fileId, previewExt);
|
||||
const thumbnailKey = keys.thumbnail;
|
||||
|
||||
await putRendered(payload.bucket, previewKey, previewBuffer, previewContentType);
|
||||
await putRendered(payload.bucket, thumbnailKey, thumbnail, "image/webp");
|
||||
|
||||
await writeStatus(payload.tenantId, payload.fileId, {
|
||||
previewStatus: "ready",
|
||||
previewStorageKey: previewKey,
|
||||
thumbnailStorageKey: thumbnailKey,
|
||||
});
|
||||
|
||||
return { previewStatus: "ready", previewStorageKey: previewKey, thumbnailStorageKey: thumbnailKey };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ fileId: payload.fileId, tenantId: payload.tenantId, mime }, "file preview generation failed");
|
||||
await writeStatus(payload.tenantId, payload.fileId, { previewStatus: "failed" });
|
||||
return { previewStatus: "failed", reason };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- dynamic native-lib loaders (kept untyped so build stays green pre-install) ---
|
||||
|
||||
type SharpFactory = (input: Buffer) => {
|
||||
rotate(): ReturnType<SharpFactory>;
|
||||
resize(opts: { width: number; height: number; fit: "inside"; withoutEnlargement: boolean }): ReturnType<SharpFactory>;
|
||||
jpeg(opts: { quality: number }): ReturnType<SharpFactory>;
|
||||
png(): ReturnType<SharpFactory>;
|
||||
webp(opts: { quality: number }): ReturnType<SharpFactory>;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
};
|
||||
|
||||
let sharpModule: SharpFactory | undefined;
|
||||
async function loadSharp(): Promise<SharpFactory> {
|
||||
if (sharpModule) return sharpModule;
|
||||
const moduleName = "sharp";
|
||||
const loaded = (await import(/* @vite-ignore */ moduleName)) as { default?: SharpFactory } & SharpFactory;
|
||||
sharpModule = (loaded.default ?? loaded) as SharpFactory;
|
||||
return sharpModule;
|
||||
}
|
||||
|
||||
async function rasterToFormat(input: Buffer, format: "jpeg" | "png" | "webp", maxDimension: number): Promise<Buffer> {
|
||||
const sharp = await loadSharp();
|
||||
const pipeline = sharp(input)
|
||||
.rotate()
|
||||
.resize({ width: maxDimension, height: maxDimension, fit: "inside", withoutEnlargement: true });
|
||||
if (format === "jpeg") return pipeline.jpeg({ quality: 82 }).toBuffer();
|
||||
if (format === "png") return pipeline.png().toBuffer();
|
||||
return pipeline.webp({ quality: 80 }).toBuffer();
|
||||
}
|
||||
|
||||
async function heicToJpeg(input: Buffer): Promise<Buffer> {
|
||||
const moduleName = "heic-convert";
|
||||
const loaded = (await import(/* @vite-ignore */ moduleName)) as
|
||||
| { default: (opts: { buffer: Buffer; format: "JPEG"; quality: number }) => Promise<ArrayBuffer | Buffer> }
|
||||
| ((opts: { buffer: Buffer; format: "JPEG"; quality: number }) => Promise<ArrayBuffer | Buffer>);
|
||||
const convert = typeof loaded === "function" ? loaded : loaded.default;
|
||||
const out = await convert({ buffer: input, format: "JPEG", quality: 0.85 });
|
||||
return Buffer.from(out as ArrayBuffer);
|
||||
}
|
||||
|
||||
async function pdfFirstPageToPng(input: Buffer): Promise<Buffer> {
|
||||
const moduleName = "pdf-to-png-converter";
|
||||
const loaded = (await import(/* @vite-ignore */ moduleName)) as {
|
||||
pdfToPng: (input: Buffer, opts: Record<string, unknown>) => Promise<Array<{ content: Buffer }>>;
|
||||
};
|
||||
const pages = await loaded.pdfToPng(input, { pagesToProcess: [1], viewportScale: 2.0 });
|
||||
const first = pages[0];
|
||||
if (!first) {
|
||||
throw new Error("PDF rendered no pages");
|
||||
}
|
||||
return Buffer.from(first.content);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// AK-P4a §5 — scan-verdict write-back for the BullMQ `toolctrl.file-scan` queue.
|
||||
//
|
||||
// The scan handler (handlers.ts) calls deps.scanService.scanObject() which runs
|
||||
// ClamAV INSTREAM and quarantines infected objects, but it does NOT touch the
|
||||
// database. Without a write-back the File row stays at scanStatus=pending and
|
||||
// the fail-closed media proxy (AD-23) never serves it. This thin service writes
|
||||
// the verdict (clean|infected) back onto File.scanStatus.
|
||||
//
|
||||
// Mirrors createWorkerFilePreviewService: a Prisma seam is injected at the
|
||||
// general-group bootstrap (index.ts) so handlers.ts stays free of direct DB
|
||||
// imports. When the seam is unset the handler simply skips the write-back
|
||||
// (legacy import-path scans carry no fileId either).
|
||||
|
||||
import { withTenantRLS } from "../db/tenantRls.js";
|
||||
|
||||
type ScanStatusLogger = {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The File write surface the write-back uses. `$executeRaw` is required so the
|
||||
* helper can set `app.tenant_id` on the transaction (AK-P4a RLS prep).
|
||||
*/
|
||||
interface FileScanStatusTx {
|
||||
$executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>;
|
||||
file: {
|
||||
updateMany(args: {
|
||||
where: { id: string; tenantId?: string };
|
||||
data: { scanStatus: string };
|
||||
}): Promise<{ count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal Prisma surface the write-back needs (direct write + tenant tx). */
|
||||
export interface FileScanStatusPrisma {
|
||||
$transaction<T>(fn: (tx: FileScanStatusTx) => Promise<T>): Promise<T>;
|
||||
file: {
|
||||
updateMany(args: {
|
||||
where: { id: string; tenantId?: string };
|
||||
data: { scanStatus: string };
|
||||
}): Promise<{ count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export type FileScanVerdict = "clean" | "infected";
|
||||
|
||||
export interface FileScanStatusWriter {
|
||||
recordVerdict(input: { fileId: string; tenantId?: string; verdict: FileScanVerdict }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createWorkerFileScanStatusWriter(options: {
|
||||
prisma: FileScanStatusPrisma;
|
||||
logger: ScanStatusLogger;
|
||||
}): FileScanStatusWriter {
|
||||
const { prisma, logger } = options;
|
||||
return {
|
||||
async recordVerdict({ fileId, tenantId, verdict }) {
|
||||
// updateMany (not update) so a tenant-scoped/RLS-filtered or already
|
||||
// deleted row is a logged no-op instead of a throw that dead-letters
|
||||
// the job after the scan already ran.
|
||||
const write = (db: Pick<FileScanStatusTx, "file">) =>
|
||||
db.file.updateMany({
|
||||
where: tenantId ? { id: fileId, tenantId } : { id: fileId },
|
||||
data: { scanStatus: verdict },
|
||||
});
|
||||
// Set the tenant GUC so the write satisfies the File RLS policy once it is
|
||||
// activated (P5 cutover). Legacy import-path scans carry no tenantId and
|
||||
// fall back to the direct write (no RLS context to set).
|
||||
const result = tenantId
|
||||
? await withTenantRLS(prisma, tenantId, (tx) => write(tx))
|
||||
: await write(prisma);
|
||||
if (result.count === 0) {
|
||||
logger.warn({ fileId, tenantId, verdict }, "scan verdict write-back matched no File row");
|
||||
} else {
|
||||
logger.info({ fileId, verdict }, "scan verdict written to File.scanStatus");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planFormSubmissionFollowUps } from "./form-submission-followups.js";
|
||||
|
||||
function baseInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
submissionId: "sub_1",
|
||||
formId: "form_1",
|
||||
tenantId: "t_1",
|
||||
targetType: "crm-contact" as const,
|
||||
target: { id: "contact_1" },
|
||||
webhookUrl: null as string | null,
|
||||
notifyEmails: [] as readonly string[],
|
||||
values: { email: "a@b.co" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("planFormSubmissionFollowUps", () => {
|
||||
it("emits no follow-ups when neither webhook nor notifyEmails are set", () => {
|
||||
expect(planFormSubmissionFollowUps(baseInput())).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a webhook follow-up when webhookUrl is set", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({ webhookUrl: "https://example.com/hook" }),
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.type).toBe("webhook.form-submission.deliver");
|
||||
expect(events[0]!.dispatchKey).toBe("form-submission-webhook:sub_1");
|
||||
expect(events[0]!.payload.webhookUrl).toBe("https://example.com/hook");
|
||||
expect(events[0]!.payload.targetId).toBe("contact_1");
|
||||
});
|
||||
|
||||
it("skips webhook follow-up when webhookUrl is empty / whitespace", () => {
|
||||
expect(planFormSubmissionFollowUps(baseInput({ webhookUrl: " " }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a mail follow-up when notifyEmails contains valid addresses", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({ notifyEmails: ["ops@example.com", "alerts@example.com"] }),
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.type).toBe("mail.transactional.form-submission");
|
||||
expect(events[0]!.dispatchKey).toBe("form-submission-mail:sub_1");
|
||||
expect(events[0]!.payload.recipients).toEqual(["ops@example.com", "alerts@example.com"]);
|
||||
});
|
||||
|
||||
it("filters out invalid email entries", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({ notifyEmails: ["valid@example.com", "not-an-email", ""] }),
|
||||
);
|
||||
expect(events[0]!.payload.recipients).toEqual(["valid@example.com"]);
|
||||
});
|
||||
|
||||
it("skips mail follow-up when no valid recipients remain after filtering", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({ notifyEmails: ["not-an-email", ""] }),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits both webhook AND mail follow-ups when both are configured", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
notifyEmails: ["ops@example.com"],
|
||||
}),
|
||||
);
|
||||
expect(events.map((e) => e.type)).toEqual([
|
||||
"webhook.form-submission.deliver",
|
||||
"mail.transactional.form-submission",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses stable dispatch keys for idempotent retries", () => {
|
||||
const a = planFormSubmissionFollowUps(
|
||||
baseInput({ webhookUrl: "https://example.com/hook", notifyEmails: ["ops@example.com"] }),
|
||||
);
|
||||
const b = planFormSubmissionFollowUps(
|
||||
baseInput({ webhookUrl: "https://example.com/hook", notifyEmails: ["ops@example.com"] }),
|
||||
);
|
||||
expect(a.map((e) => e.dispatchKey)).toEqual(b.map((e) => e.dispatchKey));
|
||||
});
|
||||
|
||||
it("includes targetId in payload so downstream knows what was created", () => {
|
||||
const events = planFormSubmissionFollowUps(
|
||||
baseInput({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
targetType: "entity-row",
|
||||
target: { id: "row_99" },
|
||||
}),
|
||||
);
|
||||
expect(events[0]!.payload.targetType).toBe("entity-row");
|
||||
expect(events[0]!.payload.targetId).toBe("row_99");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Form-Widget post-routing follow-ups (Phase 05 #58).
|
||||
*
|
||||
* After {@link runFormSubmissionRouteJob} writes a submission to its
|
||||
* target (CRM contact / entity row / generic), this module publishes
|
||||
* two optional outbox events:
|
||||
*
|
||||
* - `form.submission.webhook` — picked up by the existing webhook
|
||||
* delivery worker; only emitted when the form has `webhookUrl` set.
|
||||
* - `mail.transactional.form-submission` — picked up by the mail
|
||||
* worker; only emitted when the form has `notifyEmails` non-empty.
|
||||
*
|
||||
* Both publishes go through the outbox so they enjoy the same
|
||||
* at-least-once delivery guarantees + Sentry visibility as the rest of
|
||||
* the system. The helper is pure / dependency-free so it can be tested
|
||||
* without spinning Prisma; the worker bootstrap wires a thin adapter
|
||||
* that calls `publishOutboxEvent` inside the host's transaction.
|
||||
*/
|
||||
|
||||
export type FormSubmissionFollowUpEventType =
|
||||
| "webhook.form-submission.deliver"
|
||||
| "mail.transactional.form-submission";
|
||||
|
||||
export interface FormSubmissionFollowUpInput {
|
||||
readonly submissionId: string;
|
||||
readonly formId: string;
|
||||
readonly tenantId: string;
|
||||
readonly targetType: "generic" | "crm-contact" | "entity-row";
|
||||
readonly target: { id: string | null };
|
||||
readonly webhookUrl: string | null;
|
||||
readonly notifyEmails: readonly string[];
|
||||
readonly values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FollowUpPublishArgs {
|
||||
readonly tenantId: string;
|
||||
readonly type: FormSubmissionFollowUpEventType;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly dispatchKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure planner: returns the list of outbox events the host should
|
||||
* publish for a given follow-up input. Returns an empty array when
|
||||
* neither webhook nor notify-email is configured.
|
||||
*/
|
||||
export function planFormSubmissionFollowUps(
|
||||
input: FormSubmissionFollowUpInput,
|
||||
): readonly FollowUpPublishArgs[] {
|
||||
const events: FollowUpPublishArgs[] = [];
|
||||
|
||||
if (typeof input.webhookUrl === "string" && input.webhookUrl.trim().length > 0) {
|
||||
events.push({
|
||||
tenantId: input.tenantId,
|
||||
type: "webhook.form-submission.deliver",
|
||||
payload: {
|
||||
submissionId: input.submissionId,
|
||||
formId: input.formId,
|
||||
tenantId: input.tenantId,
|
||||
webhookUrl: input.webhookUrl,
|
||||
targetType: input.targetType,
|
||||
targetId: input.target.id,
|
||||
values: input.values,
|
||||
},
|
||||
dispatchKey: `form-submission-webhook:${input.submissionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipients = input.notifyEmails.filter(
|
||||
(email): email is string => typeof email === "string" && email.includes("@"),
|
||||
);
|
||||
if (recipients.length > 0) {
|
||||
events.push({
|
||||
tenantId: input.tenantId,
|
||||
type: "mail.transactional.form-submission",
|
||||
payload: {
|
||||
submissionId: input.submissionId,
|
||||
formId: input.formId,
|
||||
tenantId: input.tenantId,
|
||||
recipients,
|
||||
targetType: input.targetType,
|
||||
targetId: input.target.id,
|
||||
values: input.values,
|
||||
},
|
||||
dispatchKey: `form-submission-mail:${input.submissionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bootstrapFormSubmissionRoute } from "./form-submission-route-bootstrap.js";
|
||||
import { FORM_SUBMISSION_ROUTE_QUEUE } from "./form-submission-route.js";
|
||||
|
||||
function buildHandlerDeps() {
|
||||
return {
|
||||
store: {
|
||||
markRouted: vi.fn(async () => undefined),
|
||||
load: vi.fn(async () => ({ status: "received", tenantId: "t_1" })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapFormSubmissionRoute", () => {
|
||||
it("disables itself when the worker group is not general", () => {
|
||||
const res = bootstrapFormSubmissionRoute({ WORKER_QUEUE_GROUP: "mail" } as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/non-general/);
|
||||
});
|
||||
|
||||
it("disables itself when the env flag is set to false", () => {
|
||||
const res = bootstrapFormSubmissionRoute({
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
FORM_SUBMISSION_ROUTE_ENABLED: "false",
|
||||
} as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/FORM_SUBMISSION_ROUTE_ENABLED/);
|
||||
});
|
||||
|
||||
it("disables itself when no handlerDeps are supplied", () => {
|
||||
const res = bootstrapFormSubmissionRoute({ WORKER_QUEUE_GROUP: "general" } as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/handler deps/);
|
||||
});
|
||||
|
||||
it("subscribes to the form-submission-route queue when enabled", () => {
|
||||
const create = vi.fn(() => ({ name: FORM_SUBMISSION_ROUTE_QUEUE, close: async () => undefined }));
|
||||
const res = bootstrapFormSubmissionRoute(
|
||||
{ WORKER_QUEUE_GROUP: "general" } as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(res.status).toBe("started");
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
FORM_SUBMISSION_ROUTE_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours FORM_SUBMISSION_ROUTE_CONCURRENCY", () => {
|
||||
const create = vi.fn(() => ({ name: FORM_SUBMISSION_ROUTE_QUEUE, close: async () => undefined }));
|
||||
bootstrapFormSubmissionRoute(
|
||||
{
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
FORM_SUBMISSION_ROUTE_CONCURRENCY: "12",
|
||||
} as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
FORM_SUBMISSION_ROUTE_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 12 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Bootstrap helper for the form-submission-route outbox subscriber.
|
||||
*
|
||||
* Subscribes a BullMQ worker to the `outbox.form-submission-route`
|
||||
* queue inside the `general` worker group. Each delivered job carries
|
||||
* a `form.submission.process` outbox payload; the handler routes the
|
||||
* submission to CRM-contact / entity-row / generic targets and updates
|
||||
* `FormSubmission.status` with the outcome.
|
||||
*
|
||||
* Gating:
|
||||
* - WORKER_QUEUE_GROUP === "general" (the route subscriber co-locates
|
||||
* with the outbox dispatcher to keep DB connections concentrated)
|
||||
* - FORM_SUBMISSION_ROUTE_ENABLED !== "false" (escape hatch)
|
||||
*
|
||||
* Tests inject a fake `createWorkerImpl` to assert subscription
|
||||
* semantics without spinning up BullMQ.
|
||||
*/
|
||||
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
|
||||
import {
|
||||
FORM_SUBMISSION_ROUTE_QUEUE,
|
||||
runFormSubmissionRouteJob,
|
||||
type FormSubmissionRouteDependencies,
|
||||
type FormSubmissionRouteLogger,
|
||||
} from "./form-submission-route.js";
|
||||
|
||||
export type FormSubmissionRouteBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
FORM_SUBMISSION_ROUTE_ENABLED?: string;
|
||||
FORM_SUBMISSION_ROUTE_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface FormSubmissionRouteWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormSubmissionRouteBootstrapDeps {
|
||||
readonly logger?: FormSubmissionRouteLogger;
|
||||
/** Pre-built handler deps (store + crm + entity + reporter + follow-ups). */
|
||||
readonly handlerDeps?: FormSubmissionRouteDependencies;
|
||||
/** BullMQ worker factory. Production builds a real Worker; tests fake it. */
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number },
|
||||
) => FormSubmissionRouteWorkerHandle;
|
||||
}
|
||||
|
||||
export type FormSubmissionRouteBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: FormSubmissionRouteWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function defaultCreateWorker(
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number },
|
||||
): FormSubmissionRouteWorkerHandle {
|
||||
// Real BullMQ wiring lives in `apps/worker/src/index.ts` where the Redis
|
||||
// connection + Sentry tracer + DB client are in scope. The bootstrap
|
||||
// accepts a `createWorkerImpl` so the host can inject the real worker;
|
||||
// when nothing is provided we return an inert handle so tests can run.
|
||||
void processor;
|
||||
void options;
|
||||
return {
|
||||
name: queueName,
|
||||
close: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function bootstrapFormSubmissionRoute(
|
||||
env: FormSubmissionRouteBootstrapEnv,
|
||||
deps: FormSubmissionRouteBootstrapDeps = {},
|
||||
): FormSubmissionRouteBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.FORM_SUBMISSION_ROUTE_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "FORM_SUBMISSION_ROUTE_ENABLED=false" };
|
||||
}
|
||||
if (!deps.handlerDeps) {
|
||||
return {
|
||||
status: "disabled",
|
||||
reason: "no handler deps wired (host must supply store + adapters)",
|
||||
};
|
||||
}
|
||||
|
||||
const handlerDeps = deps.handlerDeps;
|
||||
const concurrency = parsePositiveInt(env.FORM_SUBMISSION_ROUTE_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
|
||||
const processor = async (job: { data: unknown }): Promise<unknown> => {
|
||||
return runFormSubmissionRouteJob(job.data, handlerDeps);
|
||||
};
|
||||
|
||||
const createWorkerImpl = deps.createWorkerImpl ?? defaultCreateWorker;
|
||||
const handle = createWorkerImpl(FORM_SUBMISSION_ROUTE_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.(
|
||||
{
|
||||
component: "form-submission-route",
|
||||
queue: FORM_SUBMISSION_ROUTE_QUEUE,
|
||||
concurrency,
|
||||
},
|
||||
"form-submission-route worker started",
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FORM_SUBMISSION_ROUTE_QUEUE,
|
||||
FormSubmissionRouteJobError,
|
||||
parseFormSubmissionRouteJob,
|
||||
runFormSubmissionRouteJob,
|
||||
type CrmContactCreator,
|
||||
type EntityRowCreator,
|
||||
type FormSubmissionStore,
|
||||
} from "./form-submission-route.js";
|
||||
|
||||
function makeStore(initialStatus = "received"): FormSubmissionStore & {
|
||||
markRouted: ReturnType<typeof vi.fn>;
|
||||
load: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
markRouted: vi.fn(async () => undefined),
|
||||
load: vi.fn(async () => ({ status: initialStatus, tenantId: "t_1" })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCrm(): CrmContactCreator & { createContact: ReturnType<typeof vi.fn> } {
|
||||
return { createContact: vi.fn(async () => ({ id: "contact_1" })) };
|
||||
}
|
||||
|
||||
function makeEntity(): EntityRowCreator & { createRow: ReturnType<typeof vi.fn> } {
|
||||
return { createRow: vi.fn(async () => ({ id: "row_1" })) };
|
||||
}
|
||||
|
||||
function buildJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "ob_1",
|
||||
tenantId: "t_1",
|
||||
eventType: "form.submission.process",
|
||||
payload: {
|
||||
submissionId: "sub_1",
|
||||
formId: "form_1",
|
||||
tenantId: "t_1",
|
||||
targetType: "generic",
|
||||
targetEntityId: null,
|
||||
fieldMapping: {},
|
||||
webhookUrl: null,
|
||||
values: { email: "a@b.co", name: "Alice" },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("FORM_SUBMISSION_ROUTE_QUEUE", () => {
|
||||
it("matches the outbox form-submission-route queue name", () => {
|
||||
expect(FORM_SUBMISSION_ROUTE_QUEUE).toBe("outbox.form-submission-route");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFormSubmissionRouteJob", () => {
|
||||
it("accepts a valid envelope", () => {
|
||||
const parsed = parseFormSubmissionRouteJob(buildJob());
|
||||
expect(parsed.payload.submissionId).toBe("sub_1");
|
||||
expect(parsed.eventType).toBe("form.submission.process");
|
||||
});
|
||||
|
||||
it("rejects envelopes with foreign event-type prefix", () => {
|
||||
expect(() =>
|
||||
parseFormSubmissionRouteJob({ ...buildJob(), eventType: "marketing.lead.captured" }),
|
||||
).toThrow(FormSubmissionRouteJobError);
|
||||
});
|
||||
|
||||
it("rejects payloads missing submissionId", () => {
|
||||
expect(() => parseFormSubmissionRouteJob(buildJob({ submissionId: 42 }))).toThrow(
|
||||
FormSubmissionRouteJobError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid targetType", () => {
|
||||
expect(() => parseFormSubmissionRouteJob(buildJob({ targetType: "rocket-launch" }))).toThrow(
|
||||
FormSubmissionRouteJobError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects envelope and payload tenant mismatches", () => {
|
||||
expect(() => parseFormSubmissionRouteJob(buildJob({ tenantId: "t_2" }))).toThrow(
|
||||
/tenantId mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects null data", () => {
|
||||
expect(() => parseFormSubmissionRouteJob(null)).toThrow(FormSubmissionRouteJobError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFormSubmissionRouteJob", () => {
|
||||
it("no-ops for targetType=generic and marks the submission processed", async () => {
|
||||
const store = makeStore();
|
||||
const result = await runFormSubmissionRouteJob(buildJob(), { store });
|
||||
expect(result.status).toBe("processed");
|
||||
expect(result.target.id).toBeNull();
|
||||
expect(store.markRouted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ submissionId: "sub_1", status: "processed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a CRM contact for targetType=crm-contact using the field mapping", async () => {
|
||||
const store = makeStore();
|
||||
const crm = makeCrm();
|
||||
const result = await runFormSubmissionRouteJob(
|
||||
buildJob({
|
||||
targetType: "crm-contact",
|
||||
fieldMapping: { firstName: "firstName", email: "email" },
|
||||
values: { firstName: "Alice", email: "a@b.co", honeypot: "" },
|
||||
}),
|
||||
{ store, crm },
|
||||
);
|
||||
expect(result.status).toBe("routed-to-crm");
|
||||
expect(result.target.id).toBe("contact_1");
|
||||
expect(crm.createContact).toHaveBeenCalledWith({
|
||||
tenantId: "t_1",
|
||||
values: { firstName: "Alice", email: "a@b.co" },
|
||||
});
|
||||
expect(store.markRouted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: "routed-to-crm" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an entity row for targetType=entity-row", async () => {
|
||||
const store = makeStore();
|
||||
const entity = makeEntity();
|
||||
const result = await runFormSubmissionRouteJob(
|
||||
buildJob({
|
||||
targetType: "entity-row",
|
||||
targetEntityId: "ent_42",
|
||||
fieldMapping: { name: "title" },
|
||||
values: { name: "My Lead" },
|
||||
}),
|
||||
{ store, entity },
|
||||
);
|
||||
expect(result.status).toBe("routed-to-entity");
|
||||
expect(result.target.id).toBe("row_1");
|
||||
expect(entity.createRow).toHaveBeenCalledWith({
|
||||
tenantId: "t_1",
|
||||
entityId: "ent_42",
|
||||
values: { title: "My Lead" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws and marks route-failed when the CRM adapter is missing", async () => {
|
||||
const store = makeStore();
|
||||
await expect(
|
||||
runFormSubmissionRouteJob(buildJob({ targetType: "crm-contact" }), { store }),
|
||||
).rejects.toBeInstanceOf(FormSubmissionRouteJobError);
|
||||
expect(store.markRouted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: "route-failed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws and marks route-failed when entity-row routing has no entityId", async () => {
|
||||
const store = makeStore();
|
||||
const entity = makeEntity();
|
||||
await expect(
|
||||
runFormSubmissionRouteJob(
|
||||
buildJob({ targetType: "entity-row", targetEntityId: null }),
|
||||
{ store, entity },
|
||||
),
|
||||
).rejects.toBeInstanceOf(FormSubmissionRouteJobError);
|
||||
expect(store.markRouted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: "route-failed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates and marks route-failed when the CRM adapter throws", async () => {
|
||||
const store = makeStore();
|
||||
const crm: CrmContactCreator = {
|
||||
createContact: async () => {
|
||||
throw new Error("crm 503");
|
||||
},
|
||||
};
|
||||
const reporter = { captureException: vi.fn() };
|
||||
await expect(
|
||||
runFormSubmissionRouteJob(
|
||||
buildJob({ targetType: "crm-contact", fieldMapping: { email: "email" } }),
|
||||
{ store, crm, reporter },
|
||||
),
|
||||
).rejects.toThrow(/crm 503/);
|
||||
expect(store.markRouted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: "route-failed" }),
|
||||
);
|
||||
expect(reporter.captureException).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips routing for replays where the submission is already routed", async () => {
|
||||
const store = makeStore("routed-to-crm");
|
||||
const crm = makeCrm();
|
||||
const result = await runFormSubmissionRouteJob(
|
||||
buildJob({ targetType: "crm-contact" }),
|
||||
{ store, crm },
|
||||
);
|
||||
expect(result.status).toBe("routed-to-crm");
|
||||
expect(crm.createContact).not.toHaveBeenCalled();
|
||||
expect(store.markRouted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips silently when the submission row no longer exists", async () => {
|
||||
const store: FormSubmissionStore = {
|
||||
load: async () => null,
|
||||
markRouted: vi.fn(async () => undefined),
|
||||
};
|
||||
const result = await runFormSubmissionRouteJob(buildJob(), { store });
|
||||
expect(result.status).toBe("processed");
|
||||
expect((store.markRouted as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-tenant submission id replays before routing", async () => {
|
||||
const store: FormSubmissionStore = {
|
||||
load: vi.fn(async () => ({ status: "received", tenantId: "t_2" })),
|
||||
markRouted: vi.fn(async () => undefined),
|
||||
};
|
||||
const crm = makeCrm();
|
||||
await expect(runFormSubmissionRouteJob(buildJob({ targetType: "crm-contact" }), { store, crm })).rejects.toMatchObject({
|
||||
code: "TENANT_MISMATCH",
|
||||
});
|
||||
expect(crm.createContact).not.toHaveBeenCalled();
|
||||
expect((store.markRouted as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires the emitFollowUps callback after a successful CRM route", async () => {
|
||||
const store = makeStore();
|
||||
const crm = makeCrm();
|
||||
const emitFollowUps = vi.fn(async () => undefined);
|
||||
await runFormSubmissionRouteJob(
|
||||
buildJob({
|
||||
targetType: "crm-contact",
|
||||
fieldMapping: { email: "email" },
|
||||
webhookUrl: "https://example.com/hook",
|
||||
notifyEmails: ["ops@example.com"],
|
||||
}),
|
||||
{ store, crm, emitFollowUps },
|
||||
);
|
||||
expect(emitFollowUps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
submissionId: "sub_1",
|
||||
webhookUrl: "https://example.com/hook",
|
||||
notifyEmails: ["ops@example.com"],
|
||||
target: { id: "contact_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail the job when emitFollowUps throws", async () => {
|
||||
const store = makeStore();
|
||||
const crm = makeCrm();
|
||||
const emitFollowUps = vi.fn(async () => {
|
||||
throw new Error("queue down");
|
||||
});
|
||||
const reporter = { captureException: vi.fn() };
|
||||
const result = await runFormSubmissionRouteJob(
|
||||
buildJob({ targetType: "crm-contact", fieldMapping: { email: "email" } }),
|
||||
{ store, crm, emitFollowUps, reporter },
|
||||
);
|
||||
expect(result.status).toBe("routed-to-crm");
|
||||
expect(reporter.captureException).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Form-Widget submission-routing worker handler (Phase 05 #58).
|
||||
*
|
||||
* Subscribes to the `outbox.form-submission-route` BullMQ queue (populated
|
||||
* by the outbox dispatcher when `eventType` starts with `form.submission.`).
|
||||
*
|
||||
* The submission API ({@link
|
||||
* apps/web/app/routes/api.embed.forms.$formKey.submit.tsx}) already
|
||||
* persists every payload as a generic `FormSubmission` row. This worker
|
||||
* runs the *follow-up* side-effect — depending on `targetType`:
|
||||
*
|
||||
* - `generic` → no-op (the row is already stored)
|
||||
* - `crm-contact` → create a CRM contact via `CrmService.createContact`
|
||||
* using the form's `fieldMapping`
|
||||
* - `entity-row` → create a row in `targetEntityId` via `RowsApi.create`
|
||||
*
|
||||
* On success the worker:
|
||||
* - updates `FormSubmission.status` to `routed-to-crm` /
|
||||
* `routed-to-entity` / `processed` (for generic), and
|
||||
* - writes the target reference (e.g. created row id) to
|
||||
* `FormSubmission.result`.
|
||||
*
|
||||
* Failures are NOT silently swallowed:
|
||||
* - `FormSubmission.status` becomes `route-failed`
|
||||
* - `FormSubmission.result.error` carries the redacted message
|
||||
* - the error is re-thrown so the BullMQ retry machinery + Sentry
|
||||
* reporter both see it.
|
||||
*
|
||||
* Idempotency: routing relies on the outbox dispatch-key
|
||||
* (`form-submission:<id>`) to avoid duplicate delivery. The worker
|
||||
* itself short-circuits when it sees a non-`received` status, which
|
||||
* makes manual replays safe.
|
||||
*/
|
||||
|
||||
import { applyFieldMapping } from "@toolctrl/form-widget";
|
||||
|
||||
export const FORM_SUBMISSION_ROUTE_QUEUE = "outbox.form-submission-route" as const;
|
||||
|
||||
/** Routing-status values written back to `FormSubmission.status`. */
|
||||
export type FormSubmissionRoutingStatus =
|
||||
| "received"
|
||||
| "processed"
|
||||
| "routed-to-crm"
|
||||
| "routed-to-entity"
|
||||
| "route-failed";
|
||||
|
||||
export interface FormSubmissionRouteJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string;
|
||||
readonly eventType: string;
|
||||
readonly payload: {
|
||||
readonly submissionId: string;
|
||||
readonly formId: string;
|
||||
readonly tenantId: string;
|
||||
readonly targetType: "generic" | "crm-contact" | "entity-row";
|
||||
readonly targetEntityId: string | null;
|
||||
readonly fieldMapping: Record<string, string>;
|
||||
readonly webhookUrl: string | null;
|
||||
readonly notifyEmails?: readonly string[];
|
||||
readonly values: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormSubmissionStore {
|
||||
/**
|
||||
* Mark a submission row with routing result. Implementations are
|
||||
* expected to issue a single Prisma `update` keyed by submissionId.
|
||||
*/
|
||||
markRouted(input: {
|
||||
submissionId: string;
|
||||
tenantId: string;
|
||||
status: FormSubmissionRoutingStatus;
|
||||
result: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Look up the current row so we can short-circuit replays / re-runs.
|
||||
* `null` ⇒ submission was deleted; we no-op and log.
|
||||
*/
|
||||
load(submissionId: string): Promise<{ status: string; tenantId: string } | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRM adapter — accepts a flat key/value record produced by
|
||||
* `applyFieldMapping`. We don't bind to a specific CRM signature here
|
||||
* (Phase 05's CRM lives in apps/web). The bootstrap wires a thin
|
||||
* adapter that forwards to `CrmService.createContact`.
|
||||
*/
|
||||
export interface CrmContactCreator {
|
||||
createContact(input: {
|
||||
tenantId: string;
|
||||
values: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface EntityRowCreator {
|
||||
createRow(input: {
|
||||
tenantId: string;
|
||||
entityId: string;
|
||||
values: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface FormSubmissionRouteLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface FormSubmissionRouteReporter {
|
||||
captureException?: (error: unknown, context?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface FormSubmissionRouteDependencies {
|
||||
readonly store: FormSubmissionStore;
|
||||
readonly crm?: CrmContactCreator;
|
||||
readonly entity?: EntityRowCreator;
|
||||
readonly logger?: FormSubmissionRouteLogger;
|
||||
readonly reporter?: FormSubmissionRouteReporter;
|
||||
/**
|
||||
* Optional callback fired after routing succeeds, used by the bootstrap
|
||||
* to publish webhook + notify-email follow-up outbox events. Kept as a
|
||||
* dependency so this handler stays free of DB / Prisma imports.
|
||||
*/
|
||||
readonly emitFollowUps?: (followUp: {
|
||||
submissionId: string;
|
||||
formId: string;
|
||||
tenantId: string;
|
||||
targetType: FormSubmissionRouteJobData["payload"]["targetType"];
|
||||
target: { id: string | null };
|
||||
webhookUrl: string | null;
|
||||
notifyEmails: readonly string[];
|
||||
values: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormSubmissionRouteJobResult {
|
||||
readonly submissionId: string;
|
||||
readonly status: FormSubmissionRoutingStatus;
|
||||
readonly target: { id: string | null };
|
||||
}
|
||||
|
||||
export class FormSubmissionRouteJobError extends Error {
|
||||
override readonly name = "FormSubmissionRouteJobError";
|
||||
readonly code:
|
||||
| "INVALID_PAYLOAD"
|
||||
| "INVALID_EVENT_TYPE"
|
||||
| "TENANT_MISMATCH"
|
||||
| "MISSING_CRM_ADAPTER"
|
||||
| "MISSING_ENTITY_ADAPTER"
|
||||
| "MISSING_TARGET_ENTITY";
|
||||
constructor(message: string, code: FormSubmissionRouteJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFormSubmissionRouteJob(data: unknown): FormSubmissionRouteJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route job missing data envelope",
|
||||
"INVALID_PAYLOAD",
|
||||
);
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const eventType = typeof envelope.eventType === "string" ? envelope.eventType : "";
|
||||
if (!eventType.startsWith("form.submission.")) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
`form-submission-route rejected eventType ${JSON.stringify(eventType)}`,
|
||||
"INVALID_EVENT_TYPE",
|
||||
);
|
||||
}
|
||||
const tenantId = typeof envelope.tenantId === "string" ? envelope.tenantId : "";
|
||||
if (!tenantId) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route job missing tenantId",
|
||||
"INVALID_PAYLOAD",
|
||||
);
|
||||
}
|
||||
const rawPayload = envelope.payload;
|
||||
if (!rawPayload || typeof rawPayload !== "object") {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route job missing payload",
|
||||
"INVALID_PAYLOAD",
|
||||
);
|
||||
}
|
||||
const p = rawPayload as Record<string, unknown>;
|
||||
const submissionId = typeof p.submissionId === "string" ? p.submissionId : "";
|
||||
const formId = typeof p.formId === "string" ? p.formId : "";
|
||||
const targetType = p.targetType;
|
||||
if (!submissionId || !formId) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route payload missing submissionId/formId",
|
||||
"INVALID_PAYLOAD",
|
||||
);
|
||||
}
|
||||
if (targetType !== "generic" && targetType !== "crm-contact" && targetType !== "entity-row") {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
`form-submission-route payload has invalid targetType ${JSON.stringify(targetType)}`,
|
||||
"INVALID_PAYLOAD",
|
||||
);
|
||||
}
|
||||
const fieldMapping =
|
||||
p.fieldMapping && typeof p.fieldMapping === "object"
|
||||
? (p.fieldMapping as Record<string, string>)
|
||||
: {};
|
||||
const values =
|
||||
p.values && typeof p.values === "object" ? (p.values as Record<string, unknown>) : {};
|
||||
const notifyEmails = Array.isArray(p.notifyEmails)
|
||||
? (p.notifyEmails.filter((v) => typeof v === "string") as string[])
|
||||
: [];
|
||||
const payloadTenantId = typeof p.tenantId === "string" ? p.tenantId : tenantId;
|
||||
if (payloadTenantId !== tenantId) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route tenantId mismatch between envelope and payload",
|
||||
"TENANT_MISMATCH",
|
||||
);
|
||||
}
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId,
|
||||
eventType,
|
||||
payload: {
|
||||
submissionId,
|
||||
formId,
|
||||
tenantId: payloadTenantId,
|
||||
targetType,
|
||||
targetEntityId: typeof p.targetEntityId === "string" ? p.targetEntityId : null,
|
||||
fieldMapping,
|
||||
webhookUrl: typeof p.webhookUrl === "string" ? p.webhookUrl : null,
|
||||
notifyEmails,
|
||||
values,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function redactErrorMessage(message: string): string {
|
||||
// Cap length to 500 chars and strip obvious secret-shaped substrings.
|
||||
const capped = message.length > 500 ? `${message.slice(0, 480)}… (truncated)` : message;
|
||||
return capped
|
||||
.replace(/(authorization\s*:\s*Bearer\s+)\S+/gi, "$1[redacted]")
|
||||
.replace(/(password|secret|token|api[_-]?key)(\s*[=:]\s*)\S+/gi, "$1$2[redacted]");
|
||||
}
|
||||
|
||||
export async function runFormSubmissionRouteJob(
|
||||
data: unknown,
|
||||
deps: FormSubmissionRouteDependencies,
|
||||
): Promise<FormSubmissionRouteJobResult> {
|
||||
const job = parseFormSubmissionRouteJob(data);
|
||||
const { submissionId, formId, tenantId, targetType, targetEntityId, fieldMapping, values } =
|
||||
job.payload;
|
||||
|
||||
const existing = await deps.store.load(submissionId);
|
||||
if (!existing) {
|
||||
deps.logger?.warn?.(
|
||||
{ op: "form-submission-route.missing", submissionId, formId },
|
||||
"submission row not found — skipping",
|
||||
);
|
||||
return { submissionId, status: "processed", target: { id: null } };
|
||||
}
|
||||
if (existing.status !== "received") {
|
||||
deps.logger?.info?.(
|
||||
{ op: "form-submission-route.skipped", submissionId, status: existing.status },
|
||||
"submission already routed — skipping replay",
|
||||
);
|
||||
return {
|
||||
submissionId,
|
||||
status: existing.status as FormSubmissionRoutingStatus,
|
||||
target: { id: null },
|
||||
};
|
||||
}
|
||||
if (existing.tenantId !== tenantId) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"form-submission-route loaded a submission for a different tenant",
|
||||
"TENANT_MISMATCH",
|
||||
);
|
||||
}
|
||||
|
||||
let routedTarget: { id: string | null } = { id: null };
|
||||
let newStatus: FormSubmissionRoutingStatus = "processed";
|
||||
|
||||
try {
|
||||
if (targetType === "generic") {
|
||||
newStatus = "processed";
|
||||
} else if (targetType === "crm-contact") {
|
||||
if (!deps.crm) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"CRM adapter not configured",
|
||||
"MISSING_CRM_ADAPTER",
|
||||
);
|
||||
}
|
||||
const mapped = applyFieldMapping({ payload: values, fieldMapping });
|
||||
const contact = await deps.crm.createContact({ tenantId, values: mapped });
|
||||
routedTarget = { id: contact.id };
|
||||
newStatus = "routed-to-crm";
|
||||
} else if (targetType === "entity-row") {
|
||||
if (!targetEntityId) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"entity-row routing requires targetEntityId",
|
||||
"MISSING_TARGET_ENTITY",
|
||||
);
|
||||
}
|
||||
if (!deps.entity) {
|
||||
throw new FormSubmissionRouteJobError(
|
||||
"Entity adapter not configured",
|
||||
"MISSING_ENTITY_ADAPTER",
|
||||
);
|
||||
}
|
||||
const mapped = applyFieldMapping({ payload: values, fieldMapping });
|
||||
const row = await deps.entity.createRow({
|
||||
tenantId,
|
||||
entityId: targetEntityId,
|
||||
values: mapped,
|
||||
});
|
||||
routedTarget = { id: row.id };
|
||||
newStatus = "routed-to-entity";
|
||||
}
|
||||
|
||||
await deps.store.markRouted({
|
||||
submissionId,
|
||||
tenantId,
|
||||
status: newStatus,
|
||||
result: { target: targetType, targetId: routedTarget.id },
|
||||
});
|
||||
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
op: "form-submission-route.routed",
|
||||
submissionId,
|
||||
formId,
|
||||
targetType,
|
||||
targetId: routedTarget.id,
|
||||
status: newStatus,
|
||||
},
|
||||
"submission routed",
|
||||
);
|
||||
|
||||
if (deps.emitFollowUps) {
|
||||
try {
|
||||
await deps.emitFollowUps({
|
||||
submissionId,
|
||||
formId,
|
||||
tenantId,
|
||||
targetType,
|
||||
target: routedTarget,
|
||||
webhookUrl: job.payload.webhookUrl,
|
||||
notifyEmails: job.payload.notifyEmails ?? [],
|
||||
values,
|
||||
});
|
||||
} catch (err) {
|
||||
// Follow-up emission MUST NOT fail the route job; the row is
|
||||
// already routed. We log + capture so ops can backfill.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
deps.logger?.warn?.(
|
||||
{
|
||||
op: "form-submission-route.followup-failed",
|
||||
submissionId,
|
||||
err: redactErrorMessage(msg),
|
||||
},
|
||||
"follow-up emission failed (non-fatal)",
|
||||
);
|
||||
deps.reporter?.captureException?.(err, {
|
||||
component: "form-submission-route",
|
||||
op: "emit-follow-ups",
|
||||
submissionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { submissionId, status: newStatus, target: routedTarget };
|
||||
} catch (err) {
|
||||
const message = redactErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
deps.logger?.error(
|
||||
{
|
||||
op: "form-submission-route.failed",
|
||||
submissionId,
|
||||
formId,
|
||||
targetType,
|
||||
err: message,
|
||||
},
|
||||
"submission routing failed",
|
||||
);
|
||||
deps.reporter?.captureException?.(err, {
|
||||
component: "form-submission-route",
|
||||
op: "route",
|
||||
submissionId,
|
||||
formId,
|
||||
targetType,
|
||||
});
|
||||
try {
|
||||
await deps.store.markRouted({
|
||||
submissionId,
|
||||
tenantId,
|
||||
status: "route-failed",
|
||||
result: { target: targetType, error: message },
|
||||
});
|
||||
} catch (markErr) {
|
||||
deps.logger?.error(
|
||||
{
|
||||
op: "form-submission-route.mark-failed-error",
|
||||
submissionId,
|
||||
err: markErr instanceof Error ? redactErrorMessage(markErr.message) : String(markErr),
|
||||
},
|
||||
"could not mark submission as failed",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bootstrapFormSubmissionWebhookDelivery } from "./form-submission-webhook-delivery-bootstrap.js";
|
||||
import { FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE } from "./form-submission-webhook-delivery.js";
|
||||
|
||||
function buildHandlerDeps() {
|
||||
return {
|
||||
enqueueWebhook: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapFormSubmissionWebhookDelivery", () => {
|
||||
it("disables itself outside the mail worker group", () => {
|
||||
const res = bootstrapFormSubmissionWebhookDelivery({ WORKER_QUEUE_GROUP: "general" } as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/non-mail/);
|
||||
});
|
||||
|
||||
it("disables itself when the env flag is false", () => {
|
||||
const res = bootstrapFormSubmissionWebhookDelivery({
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
FORM_SUBMISSION_WEBHOOK_DELIVERY_ENABLED: "false",
|
||||
} as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/FORM_SUBMISSION_WEBHOOK_DELIVERY_ENABLED/);
|
||||
});
|
||||
|
||||
it("subscribes to outbox.webhook on the mail worker group", () => {
|
||||
const create = vi.fn(() => ({ name: FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE, close: async () => undefined }));
|
||||
const res = bootstrapFormSubmissionWebhookDelivery(
|
||||
{ WORKER_QUEUE_GROUP: "mail" } as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(res.status).toBe("started");
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours FORM_SUBMISSION_WEBHOOK_DELIVERY_CONCURRENCY", () => {
|
||||
const create = vi.fn(() => ({ name: FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE, close: async () => undefined }));
|
||||
bootstrapFormSubmissionWebhookDelivery(
|
||||
{
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
FORM_SUBMISSION_WEBHOOK_DELIVERY_CONCURRENCY: "9",
|
||||
} as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 9 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
|
||||
import {
|
||||
FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE,
|
||||
runFormSubmissionWebhookDeliveryJob,
|
||||
type FormSubmissionWebhookDeliveryDeps,
|
||||
} from "./form-submission-webhook-delivery.js";
|
||||
import type { FormSubmissionRouteLogger } from "./form-submission-route.js";
|
||||
|
||||
export type FormSubmissionWebhookDeliveryBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
FORM_SUBMISSION_WEBHOOK_DELIVERY_ENABLED?: string;
|
||||
FORM_SUBMISSION_WEBHOOK_DELIVERY_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface FormSubmissionWebhookDeliveryWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormSubmissionWebhookDeliveryBootstrapDeps {
|
||||
readonly logger?: FormSubmissionRouteLogger;
|
||||
readonly handlerDeps?: FormSubmissionWebhookDeliveryDeps;
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number },
|
||||
) => FormSubmissionWebhookDeliveryWorkerHandle;
|
||||
}
|
||||
|
||||
export type FormSubmissionWebhookDeliveryBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: FormSubmissionWebhookDeliveryWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function bootstrapFormSubmissionWebhookDelivery(
|
||||
env: FormSubmissionWebhookDeliveryBootstrapEnv,
|
||||
deps: FormSubmissionWebhookDeliveryBootstrapDeps = {},
|
||||
): FormSubmissionWebhookDeliveryBootstrapResult {
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "mail") {
|
||||
return { status: "disabled", reason: "non-mail worker group" };
|
||||
}
|
||||
if (env.FORM_SUBMISSION_WEBHOOK_DELIVERY_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "FORM_SUBMISSION_WEBHOOK_DELIVERY_ENABLED=false" };
|
||||
}
|
||||
if (!deps.handlerDeps) {
|
||||
return { status: "disabled", reason: "no handler deps wired (host must supply webhook enqueue)" };
|
||||
}
|
||||
if (!deps.createWorkerImpl) {
|
||||
return { status: "disabled", reason: "no worker factory wired" };
|
||||
}
|
||||
|
||||
const concurrency = parsePositiveInt(env.FORM_SUBMISSION_WEBHOOK_DELIVERY_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
const handlerDeps = deps.handlerDeps;
|
||||
const processor = (job: { data: unknown }) => runFormSubmissionWebhookDeliveryJob(job.data, handlerDeps);
|
||||
const handle = deps.createWorkerImpl(FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
component: "form-submission-webhook-delivery",
|
||||
queue: FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE,
|
||||
concurrency,
|
||||
},
|
||||
"form-submission webhook delivery bridge started",
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
import {
|
||||
FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE,
|
||||
FormSubmissionWebhookDeliveryJobError,
|
||||
parseFormSubmissionWebhookDeliveryJob,
|
||||
runFormSubmissionWebhookDeliveryJob,
|
||||
} from "./form-submission-webhook-delivery.js";
|
||||
|
||||
function buildJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "outbox_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "webhook.form-submission.deliver",
|
||||
dispatchKey: "form-submission-webhook:sub_1",
|
||||
payload: {
|
||||
submissionId: "sub_1",
|
||||
formId: "form_1",
|
||||
tenantId: "tenant_1",
|
||||
webhookUrl: "https://example.com/hook",
|
||||
targetType: "crm-contact",
|
||||
targetId: "contact_1",
|
||||
values: { email: "ada@example.com" },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE", () => {
|
||||
it("matches the outbox webhook queue", () => {
|
||||
expect(FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE).toBe("outbox.webhook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFormSubmissionWebhookDeliveryJob", () => {
|
||||
it("accepts the exact form-submission webhook event", () => {
|
||||
const parsed = parseFormSubmissionWebhookDeliveryJob(buildJob());
|
||||
expect(parsed.eventType).toBe("webhook.form-submission.deliver");
|
||||
expect(parsed.payload.webhookUrl).toBe("https://example.com/hook");
|
||||
});
|
||||
|
||||
it("rejects other webhook event types", () => {
|
||||
expect(() => parseFormSubmissionWebhookDeliveryJob({ ...buildJob(), eventType: "webhook.other.deliver" })).toThrow(FormSubmissionWebhookDeliveryJobError);
|
||||
});
|
||||
|
||||
it("rejects envelope and payload tenant mismatches", () => {
|
||||
expect(() => parseFormSubmissionWebhookDeliveryJob(buildJob({ tenantId: "tenant_2" }))).toThrow(/tenantId mismatch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFormSubmissionWebhookDeliveryJob", () => {
|
||||
it("enqueues a deliver-webhook job with outbox idempotency", async () => {
|
||||
const enqueueWebhook = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runFormSubmissionWebhookDeliveryJob(buildJob(), { enqueueWebhook });
|
||||
|
||||
expect(result).toEqual({ enqueued: true, queueName: queueNames.email, jobName: "deliver-webhook" });
|
||||
expect(enqueueWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueName: queueNames.email,
|
||||
jobName: "deliver-webhook",
|
||||
payload: expect.objectContaining({
|
||||
kind: "webhook.deliver",
|
||||
url: "https://example.com/hook",
|
||||
method: "POST",
|
||||
eventName: "form.submission",
|
||||
tenantId: "tenant_1",
|
||||
body: expect.objectContaining({ submissionId: "sub_1", tenantId: "tenant_1" }),
|
||||
metadata: expect.objectContaining({ source: "form-submission", outboxEventId: "outbox_1" }),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ tenantId: "tenant_1", idempotencyKey: "outbox_1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Bridges form-submission webhook outbox events onto the existing
|
||||
* `toolctrl.email` webhook delivery processor.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildWebhookDeliveryJob,
|
||||
type QueueEnqueueOptions,
|
||||
type WebhookDeliveryJobDescriptor,
|
||||
} from "@toolctrl/queue";
|
||||
|
||||
export const FORM_SUBMISSION_WEBHOOK_OUTBOX_QUEUE = "outbox.webhook" as const;
|
||||
|
||||
export interface FormSubmissionWebhookDeliveryJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string;
|
||||
readonly eventType: "webhook.form-submission.deliver";
|
||||
readonly dispatchKey?: string | null;
|
||||
readonly payload: {
|
||||
readonly submissionId: string;
|
||||
readonly formId: string;
|
||||
readonly tenantId: string;
|
||||
readonly webhookUrl: string;
|
||||
readonly targetType?: string;
|
||||
readonly targetId?: string | null;
|
||||
readonly values?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormSubmissionWebhookDeliveryDeps {
|
||||
readonly enqueueWebhook: (
|
||||
descriptor: WebhookDeliveryJobDescriptor,
|
||||
options: QueueEnqueueOptions,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class FormSubmissionWebhookDeliveryJobError extends Error {
|
||||
override readonly name = "FormSubmissionWebhookDeliveryJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "INVALID_EVENT_TYPE" | "TENANT_MISMATCH";
|
||||
|
||||
constructor(message: string, code: FormSubmissionWebhookDeliveryJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFormSubmissionWebhookDeliveryJob(data: unknown): FormSubmissionWebhookDeliveryJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new FormSubmissionWebhookDeliveryJobError("form-submission webhook job missing envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
if (envelope.eventType !== "webhook.form-submission.deliver") {
|
||||
throw new FormSubmissionWebhookDeliveryJobError(
|
||||
`form-submission webhook rejected eventType ${JSON.stringify(envelope.eventType)}`,
|
||||
"INVALID_EVENT_TYPE",
|
||||
);
|
||||
}
|
||||
const tenantId = typeof envelope.tenantId === "string" ? envelope.tenantId : "";
|
||||
if (tenantId.length === 0) {
|
||||
throw new FormSubmissionWebhookDeliveryJobError("form-submission webhook job missing tenantId", "INVALID_PAYLOAD");
|
||||
}
|
||||
const rawPayload = envelope.payload;
|
||||
if (!rawPayload || typeof rawPayload !== "object") {
|
||||
throw new FormSubmissionWebhookDeliveryJobError("form-submission webhook job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const payload = rawPayload as Record<string, unknown>;
|
||||
const payloadTenantId = typeof payload.tenantId === "string" ? payload.tenantId : "";
|
||||
if (payloadTenantId !== tenantId) {
|
||||
throw new FormSubmissionWebhookDeliveryJobError("form-submission webhook tenantId mismatch", "TENANT_MISMATCH");
|
||||
}
|
||||
const submissionId = typeof payload.submissionId === "string" ? payload.submissionId : "";
|
||||
const formId = typeof payload.formId === "string" ? payload.formId : "";
|
||||
const webhookUrl = typeof payload.webhookUrl === "string" ? payload.webhookUrl.trim() : "";
|
||||
if (!submissionId || !formId || !webhookUrl) {
|
||||
throw new FormSubmissionWebhookDeliveryJobError("form-submission webhook payload missing required fields", "INVALID_PAYLOAD");
|
||||
}
|
||||
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId,
|
||||
eventType: "webhook.form-submission.deliver",
|
||||
dispatchKey: typeof envelope.dispatchKey === "string" ? envelope.dispatchKey : null,
|
||||
payload: {
|
||||
submissionId,
|
||||
formId,
|
||||
tenantId: payloadTenantId,
|
||||
webhookUrl,
|
||||
targetType: typeof payload.targetType === "string" ? payload.targetType : undefined,
|
||||
targetId: typeof payload.targetId === "string" ? payload.targetId : null,
|
||||
values: payload.values && typeof payload.values === "object" ? (payload.values as Record<string, unknown>) : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runFormSubmissionWebhookDeliveryJob(
|
||||
data: unknown,
|
||||
deps: FormSubmissionWebhookDeliveryDeps,
|
||||
): Promise<{ enqueued: true; queueName: WebhookDeliveryJobDescriptor["queueName"]; jobName: WebhookDeliveryJobDescriptor["jobName"] }> {
|
||||
const job = parseFormSubmissionWebhookDeliveryJob(data);
|
||||
const descriptor = buildWebhookDeliveryJob({
|
||||
url: job.payload.webhookUrl,
|
||||
method: "POST",
|
||||
eventName: "form.submission",
|
||||
tenantId: job.tenantId,
|
||||
body: job.payload,
|
||||
metadata: {
|
||||
source: "form-submission",
|
||||
...(job.outboxEventId ? { outboxEventId: job.outboxEventId } : {}),
|
||||
...(job.dispatchKey ? { dispatchKey: job.dispatchKey } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await deps.enqueueWebhook(descriptor, {
|
||||
tenantId: job.tenantId,
|
||||
idempotencyKey: job.outboxEventId ?? job.dispatchKey ?? `form-submission-webhook:${job.payload.submissionId}`,
|
||||
});
|
||||
|
||||
return { enqueued: true, queueName: descriptor.queueName, jobName: descriptor.jobName };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runLeadStaleTriggerCron, type LeadStaleCronDeps, type LeadStaleRow } from "./lead-stale-trigger.js";
|
||||
|
||||
function buildDeps(leads: LeadStaleRow[]): LeadStaleCronDeps & {
|
||||
published: Array<{ type: string; payload: Record<string, unknown>; dispatchKey: string }>;
|
||||
} {
|
||||
const published: Array<{ type: string; payload: Record<string, unknown>; dispatchKey: string }> = [];
|
||||
return {
|
||||
store: {
|
||||
findStaleLeads: vi.fn(async () => leads),
|
||||
},
|
||||
publisher: {
|
||||
publish: vi.fn(async (args) => {
|
||||
published.push({ type: args.type, payload: args.payload, dispatchKey: args.dispatchKey });
|
||||
return { ok: true };
|
||||
}),
|
||||
},
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
now: () => new Date("2026-05-18T00:00:00Z"),
|
||||
published,
|
||||
};
|
||||
}
|
||||
|
||||
const LEADS: LeadStaleRow[] = [
|
||||
{
|
||||
id: "lead_1",
|
||||
email: "alice@example.com",
|
||||
firstName: "Alice",
|
||||
status: "new",
|
||||
firstSeenAt: new Date("2026-02-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "lead_2",
|
||||
email: "bob@example.com",
|
||||
firstName: null,
|
||||
status: "contacted",
|
||||
firstSeenAt: new Date("2026-01-15T00:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "true";
|
||||
});
|
||||
|
||||
describe("runLeadStaleTriggerCron", () => {
|
||||
it("emits one lead.stale_60d outbox event per stale lead", async () => {
|
||||
const deps = buildDeps(LEADS);
|
||||
const result = await runLeadStaleTriggerCron(deps);
|
||||
expect(result.scanned).toBe(2);
|
||||
expect(result.emitted).toBe(2);
|
||||
expect(deps.published.map((p) => p.type)).toEqual(["lead.stale_60d", "lead.stale_60d"]);
|
||||
});
|
||||
|
||||
it("uses a date-keyed dispatchKey for idempotency", async () => {
|
||||
const deps = buildDeps([LEADS[0]!]);
|
||||
await runLeadStaleTriggerCron(deps);
|
||||
expect(deps.published[0]?.dispatchKey).toBe("lead.stale_60d.lead_1.2026-05-18");
|
||||
});
|
||||
|
||||
it("returns zero work when no stale leads found", async () => {
|
||||
const deps = buildDeps([]);
|
||||
const result = await runLeadStaleTriggerCron(deps);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.emitted).toBe(0);
|
||||
});
|
||||
|
||||
it("short-circuits when killswitch active", async () => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "false";
|
||||
const deps = buildDeps(LEADS);
|
||||
const result = await runLeadStaleTriggerCron(deps);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(deps.store.findStaleLeads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards thresholdDays and statuses to the store", async () => {
|
||||
const deps = buildDeps(LEADS);
|
||||
await runLeadStaleTriggerCron({ ...deps, thresholdDays: 30, statuses: ["new"] });
|
||||
expect(deps.store.findStaleLeads).toHaveBeenCalledWith(expect.objectContaining({ statuses: ["new"] }));
|
||||
const call = vi.mocked(deps.store.findStaleLeads).mock.calls[0]?.[0];
|
||||
expect(call?.thresholdDate.toISOString()).toBe("2026-04-18T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("counts publisher failures as skipped without aborting the scan", async () => {
|
||||
const deps = buildDeps(LEADS);
|
||||
deps.publisher.publish = vi.fn().mockResolvedValueOnce({ ok: true }).mockRejectedValueOnce(new Error("simulated"));
|
||||
const result = await runLeadStaleTriggerCron(deps);
|
||||
expect(result.scanned).toBe(2);
|
||||
expect(result.emitted).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(deps.logger?.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps publish concurrency", async () => {
|
||||
const leads = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index): LeadStaleRow => ({
|
||||
id: `lead_${index + 1}`,
|
||||
email: `lead-${index + 1}@example.com`,
|
||||
firstName: null,
|
||||
status: "new",
|
||||
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
|
||||
})
|
||||
);
|
||||
const deps = buildDeps(leads);
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let releaseIndex = 0;
|
||||
const releases = Array.from({ length: 5 }, () => deferred());
|
||||
deps.publisher.publish = vi.fn(async () => {
|
||||
const release = releases[releaseIndex++]!;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await release.promise;
|
||||
active -= 1;
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const resultPromise = runLeadStaleTriggerCron({ ...deps, concurrency: 2 });
|
||||
await Promise.resolve();
|
||||
releases[0]!.resolve();
|
||||
releases[1]!.resolve();
|
||||
await Promise.resolve();
|
||||
releases[2]!.resolve();
|
||||
releases[3]!.resolve();
|
||||
await Promise.resolve();
|
||||
releases[4]!.resolve();
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toMatchObject({ scanned: 5, emitted: 5, skipped: 0 });
|
||||
expect(maxActive).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("payload contains the canonical envelope fields", async () => {
|
||||
const deps = buildDeps([LEADS[0]!]);
|
||||
await runLeadStaleTriggerCron(deps);
|
||||
expect(deps.published[0]?.payload).toMatchObject({
|
||||
leadId: "lead_1",
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mapWithConcurrency } from "@toolctrl/foundation";
|
||||
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — stale-lead trigger cron.
|
||||
*
|
||||
* Daily cron worker that scans `Lead` for rows older than the
|
||||
* configured threshold (default 60 days) whose `status` is still
|
||||
* `new` or `contacted` and which have not converted. For every match
|
||||
* a `lead.stale_60d` outbox event is emitted; downstream the
|
||||
* email-sequence trigger handler enrols re-activation sequences.
|
||||
*
|
||||
* Storage + clock + outbox publisher are injected so the handler is
|
||||
* fully unit-testable. Intended production wiring is a daily BullMQ
|
||||
* scheduled-job from `apps/worker/src/index.ts`; as of this slice the
|
||||
* runner is not registered by the worker entrypoint.
|
||||
*
|
||||
* Idempotency: the dispatchKey is `lead.stale_60d.<leadId>.<YYYY-MM-DD>`
|
||||
* so re-running the cron on the same day is a no-op even if the worker
|
||||
* restarts mid-scan.
|
||||
*/
|
||||
|
||||
export interface LeadStaleRow {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly firstName: string | null;
|
||||
readonly status: string;
|
||||
readonly firstSeenAt: Date;
|
||||
}
|
||||
|
||||
export interface LeadStaleStore {
|
||||
findStaleLeads(args: { thresholdDate: Date; statuses: readonly string[]; limit: number }): Promise<readonly LeadStaleRow[]>;
|
||||
}
|
||||
|
||||
export interface LeadStalePublisherTx {
|
||||
outboxEvent: {
|
||||
create(args: { data: Record<string, unknown> }): Promise<unknown>;
|
||||
findUnique(args: { where: { dispatchKey: string } }): Promise<unknown | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeadStalePublisher {
|
||||
publish(args: { type: string; payload: Record<string, unknown>; dispatchKey: string; tenantId?: string | null }): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface LeadStaleLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface LeadStaleCronDeps {
|
||||
readonly store: LeadStaleStore;
|
||||
readonly publisher: LeadStalePublisher;
|
||||
readonly logger?: LeadStaleLogger;
|
||||
readonly now?: () => Date;
|
||||
/** Days-since-firstSeenAt cutoff. Default 60. */
|
||||
readonly thresholdDays?: number;
|
||||
/** Statuses to consider stale. Default `["new","contacted"]`. */
|
||||
readonly statuses?: readonly string[];
|
||||
/** Hard cap per cron tick — defends against unbounded scans. */
|
||||
readonly limit?: number;
|
||||
/** Max outbox publishes at once. Default 10. */
|
||||
readonly concurrency?: number;
|
||||
}
|
||||
|
||||
export interface LeadStaleCronResult {
|
||||
readonly scanned: number;
|
||||
readonly emitted: number;
|
||||
readonly skipped: number;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD_DAYS = 60;
|
||||
const DEFAULT_STATUSES = ["new", "contacted"] as const;
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function runLeadStaleTriggerCron(deps: LeadStaleCronDeps): Promise<LeadStaleCronResult> {
|
||||
if (process.env.MARKETING_SEQUENCES_ENABLED !== "true") {
|
||||
deps.logger?.info?.({ op: "lead.stale.skipped" }, "marketing sequences disabled by env");
|
||||
return { scanned: 0, emitted: 0, skipped: 0 };
|
||||
}
|
||||
const now = (deps.now ?? (() => new Date()))();
|
||||
const thresholdDays = deps.thresholdDays ?? DEFAULT_THRESHOLD_DAYS;
|
||||
const statuses = deps.statuses ?? DEFAULT_STATUSES;
|
||||
const limit = deps.limit ?? DEFAULT_LIMIT;
|
||||
const thresholdDate = new Date(now.getTime() - thresholdDays * 24 * 60 * 60 * 1000);
|
||||
const today = isoDate(now);
|
||||
|
||||
const leads = await deps.store.findStaleLeads({ thresholdDate, statuses, limit });
|
||||
let emitted = 0;
|
||||
let skipped = 0;
|
||||
await mapWithConcurrency(leads, deps.concurrency ?? 10, async (lead) => {
|
||||
const dispatchKey = `lead.stale_60d.${lead.id}.${today}`;
|
||||
try {
|
||||
await deps.publisher.publish({
|
||||
type: "lead.stale_60d",
|
||||
payload: {
|
||||
leadId: lead.id,
|
||||
email: lead.email,
|
||||
recipientName: lead.firstName,
|
||||
lead: { id: lead.id, email: lead.email, firstName: lead.firstName, status: lead.status, firstSeenAt: lead.firstSeenAt.toISOString() },
|
||||
thresholdDays,
|
||||
},
|
||||
dispatchKey,
|
||||
});
|
||||
emitted += 1;
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
deps.logger?.error(
|
||||
{ op: "lead.stale.publish_failed", leadId: lead.id, error: error instanceof Error ? error.message : String(error) },
|
||||
"stale-lead outbox publish failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
deps.logger?.info?.({ op: "lead.stale.done", scanned: leads.length, emitted, skipped, thresholdDays }, "stale-lead trigger cron complete");
|
||||
return { scanned: leads.length, emitted, skipped };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Bootstrap helper for the legacy-event outbox receiver.
|
||||
*
|
||||
* Subscribes a BullMQ worker to the `outbox.notification` queue (which
|
||||
* the dispatcher routes `billing.subscription.*` events into) and feeds
|
||||
* each job through {@link runLegacyEventReceiverJob}.
|
||||
*
|
||||
* Gating:
|
||||
* - WORKER_QUEUE_GROUP === "general" (co-located with the outbox
|
||||
* dispatcher to keep DB connection pools concentrated)
|
||||
* - LEGACY_EVENT_RECEIVER_ENABLED !== "false" (escape hatch)
|
||||
*/
|
||||
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
|
||||
import {
|
||||
LEGACY_EVENT_OUTBOX_QUEUE,
|
||||
runLegacyEventReceiverJob,
|
||||
type LegacyEventReceiverDependencies,
|
||||
type LegacyEventReceiverLogger,
|
||||
type LegacyEventReceiverStore,
|
||||
} from "./legacy-event-receiver.js";
|
||||
|
||||
export type LegacyEventReceiverBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
LEGACY_EVENT_RECEIVER_ENABLED?: string;
|
||||
LEGACY_EVENT_RECEIVER_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface LegacyEventReceiverWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverBootstrapDeps {
|
||||
readonly logger?: LegacyEventReceiverLogger;
|
||||
readonly store?: LegacyEventReceiverStore;
|
||||
readonly idempotency?: LegacyEventReceiverDependencies["idempotency"];
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => LegacyEventReceiverWorkerHandle;
|
||||
}
|
||||
|
||||
export type LegacyEventReceiverBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: LegacyEventReceiverWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function bootstrapLegacyEventReceiver(
|
||||
env: LegacyEventReceiverBootstrapEnv,
|
||||
deps: LegacyEventReceiverBootstrapDeps = {}
|
||||
): LegacyEventReceiverBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.LEGACY_EVENT_RECEIVER_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "LEGACY_EVENT_RECEIVER_ENABLED=false" };
|
||||
}
|
||||
if (!deps.store) {
|
||||
return { status: "disabled", reason: "no store wired (worker host must supply createLegacyEvent)" };
|
||||
}
|
||||
if (!deps.createWorkerImpl) {
|
||||
return { status: "disabled", reason: "no createWorkerImpl wired (worker host must supply BullMQ Worker factory)" };
|
||||
}
|
||||
|
||||
const concurrency = parsePositiveInt(env.LEGACY_EVENT_RECEIVER_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
const store = deps.store;
|
||||
const idempotency = deps.idempotency;
|
||||
|
||||
const processor = async (job: { data: unknown }): Promise<unknown> => {
|
||||
const handlerDeps: LegacyEventReceiverDependencies = {
|
||||
store,
|
||||
...(idempotency ? { idempotency } : {}),
|
||||
...(logger ? { logger } : {}),
|
||||
};
|
||||
return runLegacyEventReceiverJob(job.data, handlerDeps);
|
||||
};
|
||||
|
||||
const handle = deps.createWorkerImpl(LEGACY_EVENT_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.(
|
||||
{ component: "legacy-event-receiver", queue: LEGACY_EVENT_OUTBOX_QUEUE, concurrency },
|
||||
"legacy-event receiver worker started"
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
LEGACY_EVENT_OUTBOX_QUEUE,
|
||||
LEGACY_EVENT_OUTBOX_TYPES,
|
||||
LegacyEventReceiverJobError,
|
||||
parseLegacyEventReceiverJob,
|
||||
runLegacyEventReceiverJob,
|
||||
type LegacyEventReceiverStore,
|
||||
} from "./legacy-event-receiver.js";
|
||||
|
||||
function buildJob(overrides: Record<string, unknown> = {}): unknown {
|
||||
return {
|
||||
outboxEventId: "evt-1",
|
||||
tenantId: "tenant-a",
|
||||
eventType: "billing.subscription.ended",
|
||||
payload: {
|
||||
name: "subscription.ended",
|
||||
tenantId: "tenant-a",
|
||||
userId: null,
|
||||
data: JSON.stringify({ tenant: { id: "tenant-a" }, product: { title: "Pro" } }),
|
||||
description: "Acme subscription ended: Pro",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function inMemoryStore(): LegacyEventReceiverStore & { calls: number; last: unknown } {
|
||||
const state = { calls: 0, last: null as unknown };
|
||||
return {
|
||||
calls: 0,
|
||||
last: null,
|
||||
async create(data) {
|
||||
state.calls += 1;
|
||||
state.last = data;
|
||||
this.calls = state.calls;
|
||||
this.last = state.last;
|
||||
return { id: `evt-row-${state.calls}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inMemoryIdempotency() {
|
||||
const claimed = new Set<string>();
|
||||
const completed = new Map<string, unknown>();
|
||||
return {
|
||||
async claim(key: string): Promise<boolean> {
|
||||
if (claimed.has(key)) return false;
|
||||
claimed.add(key);
|
||||
return true;
|
||||
},
|
||||
async complete(key: string, result: unknown): Promise<void> {
|
||||
completed.set(key, result);
|
||||
},
|
||||
async get(key: string) {
|
||||
if (completed.has(key)) {
|
||||
return { state: "complete" as const, result: completed.get(key) };
|
||||
}
|
||||
if (claimed.has(key)) {
|
||||
return { state: "pending" as const };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async forget(key: string): Promise<void> {
|
||||
claimed.delete(key);
|
||||
},
|
||||
_claimed: claimed,
|
||||
};
|
||||
}
|
||||
|
||||
describe("legacy-event-receiver constants", () => {
|
||||
it("declares the documented outbox queue and event types", () => {
|
||||
expect(LEGACY_EVENT_OUTBOX_QUEUE).toBe("outbox.notification");
|
||||
expect([...LEGACY_EVENT_OUTBOX_TYPES]).toEqual([
|
||||
"billing.subscription.ended",
|
||||
"billing.subscription.cancelled",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLegacyEventReceiverJob", () => {
|
||||
it("parses a well-formed envelope", () => {
|
||||
const parsed = parseLegacyEventReceiverJob(buildJob());
|
||||
expect(parsed.outboxEventId).toBe("evt-1");
|
||||
expect(parsed.eventType).toBe("billing.subscription.ended");
|
||||
expect(parsed.payload.name).toBe("subscription.ended");
|
||||
});
|
||||
|
||||
it("rejects when outboxEventId is missing", () => {
|
||||
expect(() => parseLegacyEventReceiverJob(buildJob({ outboxEventId: undefined }))).toThrow(
|
||||
LegacyEventReceiverJobError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown event types", () => {
|
||||
expect(() => parseLegacyEventReceiverJob(buildJob({ eventType: "mail.welcome.send" }))).toThrow(
|
||||
/unsupported eventType/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when payload.name is empty", () => {
|
||||
expect(() =>
|
||||
parseLegacyEventReceiverJob(buildJob({ payload: { name: "", data: "{}", description: null, userId: null, tenantId: "tenant-a" } }))
|
||||
).toThrow(/payload\.name/);
|
||||
});
|
||||
|
||||
it("rejects non-object data", () => {
|
||||
expect(() => parseLegacyEventReceiverJob(null)).toThrow(LegacyEventReceiverJobError);
|
||||
expect(() => parseLegacyEventReceiverJob("nope" as unknown)).toThrow(LegacyEventReceiverJobError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLegacyEventReceiverJob", () => {
|
||||
it("persists the event when no idempotency store is wired", async () => {
|
||||
const store = inMemoryStore();
|
||||
const result = await runLegacyEventReceiverJob(buildJob(), { store });
|
||||
expect(result.status).toBe("persisted");
|
||||
expect(store.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("guards against duplicate delivery via the idempotency store", async () => {
|
||||
const store = inMemoryStore();
|
||||
const idempotency = inMemoryIdempotency();
|
||||
const first = await runLegacyEventReceiverJob(buildJob(), { store, idempotency });
|
||||
const second = await runLegacyEventReceiverJob(buildJob(), { store, idempotency });
|
||||
expect(first.status).toBe("persisted");
|
||||
expect(second.status).toBe("skipped");
|
||||
expect(store.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not ack a redelivery while the previous receiver claim is still pending", async () => {
|
||||
const store = inMemoryStore();
|
||||
const idempotency = inMemoryIdempotency();
|
||||
await idempotency.claim("outbox:evt-1");
|
||||
|
||||
await expect(runLegacyEventReceiverJob(buildJob(), { store, idempotency })).rejects.toThrow(/already pending/);
|
||||
expect(store.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates store failures without poisoning the idempotency claim", async () => {
|
||||
const store: LegacyEventReceiverStore = {
|
||||
create: vi.fn(async () => {
|
||||
throw new Error("DB went away");
|
||||
}),
|
||||
};
|
||||
const idempotency = inMemoryIdempotency();
|
||||
await expect(runLegacyEventReceiverJob(buildJob(), { store, idempotency })).rejects.toThrow(/DB went away/);
|
||||
// Claim must be released so the retry can re-attempt.
|
||||
expect(idempotency._claimed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Outbox receiver for "legacy" Event-table inserts.
|
||||
*
|
||||
* Phase 03 #42 — Stripe-webhook migration. The Stripe webhook used to call
|
||||
* `createLegacyEvent` directly after updating a TenantSubscriptionProduct,
|
||||
* meaning the DB write + the legacy-Event row were committed in two
|
||||
* separate transactions; a worker crash between the two would silently
|
||||
* drop the downstream notification.
|
||||
*
|
||||
* The webhook now wraps both writes in a single Prisma `$transaction` and
|
||||
* the legacy-Event insert is published as an outbox event instead. This
|
||||
* handler is the BullMQ consumer on the resulting `outbox.notification`
|
||||
* queue: it receives `billing.subscription.ended` and
|
||||
* `billing.subscription.cancelled` payloads and writes the legacy Event
|
||||
* row.
|
||||
*
|
||||
* Idempotency: every job is wrapped with `withOutboxIdempotency` against
|
||||
* the producer's outbox event id, so re-delivery (BullMQ retry, worker
|
||||
* restart between ACK and downstream effect) is a no-op.
|
||||
*/
|
||||
|
||||
import {
|
||||
withOutboxIdempotency,
|
||||
type OutboxReceiverIdempotencyStore,
|
||||
} from "@toolctrl/db/outbox-receiver";
|
||||
|
||||
export const LEGACY_EVENT_OUTBOX_QUEUE = "outbox.notification" as const;
|
||||
|
||||
/**
|
||||
* The full set of event types this receiver knows how to write into the
|
||||
* legacy `Event` table. New billing/subscription event types should be
|
||||
* registered here.
|
||||
*/
|
||||
export const LEGACY_EVENT_OUTBOX_TYPES = [
|
||||
"billing.subscription.ended",
|
||||
"billing.subscription.cancelled",
|
||||
] as const;
|
||||
|
||||
export type LegacyEventReceiverType = (typeof LEGACY_EVENT_OUTBOX_TYPES)[number];
|
||||
|
||||
export interface LegacyEventReceiverLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverPayload {
|
||||
readonly name: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly userId: string | null;
|
||||
readonly data: string;
|
||||
readonly description: string | null;
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly eventType: string;
|
||||
readonly payload: LegacyEventReceiverPayload;
|
||||
}
|
||||
|
||||
export class LegacyEventReceiverJobError extends Error {
|
||||
override readonly name = "LegacyEventReceiverJobError";
|
||||
readonly code:
|
||||
| "INVALID_PAYLOAD"
|
||||
| "MISSING_OUTBOX_EVENT_ID"
|
||||
| "UNKNOWN_EVENT_TYPE";
|
||||
constructor(message: string, code: LegacyEventReceiverJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverStore {
|
||||
/**
|
||||
* Insert the Event row. Production wires `createLegacyEvent` from
|
||||
* `@toolctrl/db/events.server`; tests pass an in-memory recorder.
|
||||
*/
|
||||
create(data: LegacyEventReceiverPayload): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverDependencies {
|
||||
readonly store: LegacyEventReceiverStore;
|
||||
readonly idempotency?: OutboxReceiverIdempotencyStore & {
|
||||
forget?(key: string): Promise<void>;
|
||||
};
|
||||
readonly logger?: LegacyEventReceiverLogger;
|
||||
}
|
||||
|
||||
export interface LegacyEventReceiverResult {
|
||||
readonly outboxEventId: string;
|
||||
readonly status: "persisted" | "skipped";
|
||||
}
|
||||
|
||||
function isLegacyType(value: string): value is LegacyEventReceiverType {
|
||||
return (LEGACY_EVENT_OUTBOX_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function parseLegacyEventReceiverJob(data: unknown): LegacyEventReceiverJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new LegacyEventReceiverJobError(
|
||||
"legacy-event receiver job missing data envelope",
|
||||
"INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const outboxEventId = typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined;
|
||||
if (!outboxEventId) {
|
||||
throw new LegacyEventReceiverJobError(
|
||||
"legacy-event receiver job missing outboxEventId",
|
||||
"MISSING_OUTBOX_EVENT_ID"
|
||||
);
|
||||
}
|
||||
const eventType = typeof envelope.eventType === "string" ? envelope.eventType : "";
|
||||
if (!isLegacyType(eventType)) {
|
||||
throw new LegacyEventReceiverJobError(
|
||||
`legacy-event receiver job has unsupported eventType ${JSON.stringify(eventType)}`,
|
||||
"UNKNOWN_EVENT_TYPE"
|
||||
);
|
||||
}
|
||||
const payload = envelope.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new LegacyEventReceiverJobError(
|
||||
"legacy-event receiver job missing payload",
|
||||
"INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
const inner = payload as Record<string, unknown>;
|
||||
const name = typeof inner.name === "string" ? inner.name : "";
|
||||
const data2 = typeof inner.data === "string" ? inner.data : "";
|
||||
if (name.length === 0) {
|
||||
throw new LegacyEventReceiverJobError(
|
||||
"legacy-event receiver job missing payload.name",
|
||||
"INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
return {
|
||||
outboxEventId,
|
||||
tenantId: typeof envelope.tenantId === "string" ? envelope.tenantId : null,
|
||||
eventType,
|
||||
payload: {
|
||||
name,
|
||||
tenantId: typeof inner.tenantId === "string" ? inner.tenantId : null,
|
||||
userId: typeof inner.userId === "string" ? inner.userId : null,
|
||||
data: data2,
|
||||
description: typeof inner.description === "string" ? inner.description : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runLegacyEventReceiverJob(
|
||||
data: unknown,
|
||||
deps: LegacyEventReceiverDependencies
|
||||
): Promise<LegacyEventReceiverResult> {
|
||||
const job = parseLegacyEventReceiverJob(data);
|
||||
|
||||
const persist = async (): Promise<{ status: "persisted" }> => {
|
||||
await deps.store.create(job.payload);
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
component: "legacy-event-receiver",
|
||||
outboxEventId: job.outboxEventId,
|
||||
eventType: job.eventType,
|
||||
tenantId: job.tenantId,
|
||||
name: job.payload.name,
|
||||
},
|
||||
"legacy-event persisted from outbox"
|
||||
);
|
||||
return { status: "persisted" };
|
||||
};
|
||||
|
||||
if (!deps.idempotency) {
|
||||
await persist();
|
||||
return { outboxEventId: job.outboxEventId!, status: "persisted" };
|
||||
}
|
||||
|
||||
const outcome = await withOutboxIdempotency(
|
||||
deps.idempotency,
|
||||
job.outboxEventId!,
|
||||
persist
|
||||
);
|
||||
if (outcome.status === "executed") {
|
||||
return { outboxEventId: job.outboxEventId!, status: "persisted" };
|
||||
}
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
component: "legacy-event-receiver",
|
||||
outboxEventId: job.outboxEventId,
|
||||
eventType: job.eventType,
|
||||
},
|
||||
"legacy-event skipped (idempotent re-delivery)"
|
||||
);
|
||||
return { outboxEventId: job.outboxEventId!, status: "skipped" };
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MARKETING_LEAD_EVENT_TYPES,
|
||||
MARKETING_OUTBOUND_ACTIVE_SUBSCRIBER,
|
||||
MarketingOutboundJobError,
|
||||
parseMarketingOutboundJob,
|
||||
runMarketingOutboundJob,
|
||||
type MarketingIntegrationRow,
|
||||
} from "./marketing-outbound.js";
|
||||
|
||||
const BASE_PAYLOAD = {
|
||||
leadId: "lead_1",
|
||||
email: "test@example.com",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
company: "Acme",
|
||||
utm: { source: "google", campaign: "q2" },
|
||||
};
|
||||
|
||||
function buildJobData(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "ob_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "marketing.lead.captured",
|
||||
payload: BASE_PAYLOAD,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStore(integrations: MarketingIntegrationRow[] = []) {
|
||||
return {
|
||||
listEnabledForEvent: vi.fn().mockResolvedValue(integrations),
|
||||
recordDelivery: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(status = 200): typeof fetch {
|
||||
return vi.fn().mockResolvedValue(new Response("ok", { status })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function buildIdempotency() {
|
||||
const claimed = new Set<string>();
|
||||
const completed = new Map<string, unknown>();
|
||||
return {
|
||||
_claimed: claimed,
|
||||
_completed: completed,
|
||||
claim: vi.fn(async (key: string) => {
|
||||
if (claimed.has(key) || completed.has(key)) return false;
|
||||
claimed.add(key);
|
||||
return true;
|
||||
}),
|
||||
complete: vi.fn(async (key: string, result: unknown) => {
|
||||
claimed.delete(key);
|
||||
completed.set(key, result);
|
||||
}),
|
||||
get: vi.fn(async (key: string) => {
|
||||
if (completed.has(key)) return { state: "complete" as const, result: completed.get(key) };
|
||||
if (claimed.has(key)) return { state: "pending" as const };
|
||||
return null;
|
||||
}),
|
||||
forget: vi.fn(async (key: string) => {
|
||||
claimed.delete(key);
|
||||
completed.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("constants", () => {
|
||||
it("exposes handler-only status + recognised event types", () => {
|
||||
expect(MARKETING_OUTBOUND_ACTIVE_SUBSCRIBER).toBe(false);
|
||||
expect(MARKETING_LEAD_EVENT_TYPES).toContain("marketing.lead.captured");
|
||||
expect(MARKETING_LEAD_EVENT_TYPES).toContain("marketing.lead.convertedtocustomer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMarketingOutboundJob", () => {
|
||||
it("accepts a valid envelope", () => {
|
||||
const job = parseMarketingOutboundJob(buildJobData());
|
||||
expect(job.eventType).toBe("marketing.lead.captured");
|
||||
expect(job.payload.leadId).toBe("lead_1");
|
||||
});
|
||||
|
||||
it("rejects unknown event-type", () => {
|
||||
expect(() => parseMarketingOutboundJob(buildJobData({ eventType: "marketing.lead.unknown" }))).toThrow(MarketingOutboundJobError);
|
||||
});
|
||||
|
||||
it("rejects payload missing leadId/email", () => {
|
||||
expect(() => parseMarketingOutboundJob(buildJobData({ payload: { email: "x@y" } }))).toThrow(MarketingOutboundJobError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMarketingOutboundJob", () => {
|
||||
const originalEnv = process.env.MARKETING_WEBHOOKS_ENABLED;
|
||||
beforeEach(() => {
|
||||
delete process.env.MARKETING_WEBHOOKS_ENABLED;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) delete process.env.MARKETING_WEBHOOKS_ENABLED;
|
||||
else process.env.MARKETING_WEBHOOKS_ENABLED = originalEnv;
|
||||
});
|
||||
|
||||
it("dispatches Slack integration successfully", async () => {
|
||||
const fetchImpl = mockFetch(200);
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_1",
|
||||
type: "slack",
|
||||
name: "Sales channel",
|
||||
config: { webhookUrl: "https://hooks.slack.com/services/X/Y/Z" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store, fetch: fetchImpl });
|
||||
expect(res.dispatched).toBe(1);
|
||||
expect(res.succeeded).toBe(1);
|
||||
expect(res.failed).toBe(0);
|
||||
expect(store.recordDelivery).toHaveBeenCalledWith("int_1", { ok: true, errorTag: undefined });
|
||||
});
|
||||
|
||||
it("uses outbox receiver idempotency to skip duplicate webhook redelivery", async () => {
|
||||
const fetchImpl = mockFetch(200);
|
||||
const idempotency = buildIdempotency();
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_1",
|
||||
type: "slack",
|
||||
name: "Sales channel",
|
||||
config: { webhookUrl: "https://hooks.slack.com/services/X/Y/Z" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runMarketingOutboundJob(buildJobData(), { store, fetch: fetchImpl, idempotency })).resolves.toMatchObject({
|
||||
dispatched: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
});
|
||||
await expect(runMarketingOutboundJob(buildJobData(), { store, fetch: fetchImpl, idempotency })).resolves.toMatchObject({
|
||||
dispatched: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
expect(idempotency.claim).toHaveBeenCalledWith("outbox:ob_1");
|
||||
expect(idempotency.complete).toHaveBeenCalledWith("outbox:ob_1", expect.objectContaining({ succeeded: 1 }));
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(store.recordDelivery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases outbox receiver idempotency when the store fails before delivery", async () => {
|
||||
const idempotency = buildIdempotency();
|
||||
const store = buildStore();
|
||||
store.listEnabledForEvent.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
|
||||
await expect(runMarketingOutboundJob(buildJobData(), { store, idempotency })).rejects.toThrow("database unavailable");
|
||||
|
||||
expect(idempotency.claim).toHaveBeenCalledWith("outbox:ob_1");
|
||||
expect(idempotency.forget).toHaveBeenCalledWith("outbox:ob_1");
|
||||
expect(idempotency._claimed.size).toBe(0);
|
||||
await expect(runMarketingOutboundJob(buildJobData(), { store, idempotency })).resolves.toMatchObject({ dispatched: 0 });
|
||||
});
|
||||
|
||||
it("propagates pending outbox receiver idempotency so BullMQ can retry", async () => {
|
||||
const idempotency = buildIdempotency();
|
||||
await idempotency.claim("outbox:ob_1");
|
||||
const store = buildStore();
|
||||
|
||||
await expect(runMarketingOutboundJob(buildJobData(), { store, idempotency })).rejects.toMatchObject({
|
||||
name: "OutboxReceiverPendingError",
|
||||
key: "outbox:ob_1",
|
||||
});
|
||||
expect(store.listEnabledForEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs without receiver idempotency when outboxEventId is absent", async () => {
|
||||
const fetchImpl = mockFetch(200);
|
||||
const idempotency = buildIdempotency();
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_1",
|
||||
type: "slack",
|
||||
name: "Sales channel",
|
||||
config: { webhookUrl: "https://hooks.slack.com/services/X/Y/Z" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runMarketingOutboundJob(buildJobData({ outboxEventId: undefined }), { store, fetch: fetchImpl, idempotency })).resolves.toMatchObject({
|
||||
dispatched: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(idempotency.claim).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("isolates failures across integrations", async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn().mockImplementation(async () => {
|
||||
calls += 1;
|
||||
return new Response("err", { status: calls === 1 ? 500 : 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_slack",
|
||||
type: "slack",
|
||||
name: "Slack",
|
||||
config: { webhookUrl: "https://hooks.slack.com/services/X" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
{
|
||||
id: "int_discord",
|
||||
type: "discord",
|
||||
name: "Discord",
|
||||
config: { webhookUrl: "https://discord.com/api/webhooks/x/y" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store, fetch: fetchImpl });
|
||||
expect(res.dispatched).toBe(2);
|
||||
expect(res.succeeded).toBe(1);
|
||||
expect(res.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("records unknown_adapter_type for missing adapter", async () => {
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_x",
|
||||
type: "nope",
|
||||
name: "Bad",
|
||||
config: {},
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store });
|
||||
expect(res.failed).toBe(1);
|
||||
expect(store.recordDelivery).toHaveBeenCalledWith("int_x", expect.objectContaining({ errorTag: "unknown_adapter_type" }));
|
||||
});
|
||||
|
||||
it("short-circuits when MARKETING_WEBHOOKS_ENABLED=false", async () => {
|
||||
process.env.MARKETING_WEBHOOKS_ENABLED = "false";
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_1",
|
||||
type: "slack",
|
||||
name: "Slack",
|
||||
config: { webhookUrl: "https://hooks.slack.com/services/X" },
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store });
|
||||
expect(res.dispatched).toBe(0);
|
||||
expect(store.listEnabledForEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when zero integrations subscribe", async () => {
|
||||
const store = buildStore([]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store });
|
||||
expect(res.dispatched).toBe(0);
|
||||
expect(res.succeeded).toBe(0);
|
||||
});
|
||||
|
||||
it("catches adapter parseConfig errors and records adapter_error", async () => {
|
||||
const fetchImpl = mockFetch();
|
||||
const store = buildStore([
|
||||
{
|
||||
id: "int_bad",
|
||||
type: "slack",
|
||||
// Missing webhookUrl => parseConfig throws
|
||||
config: {},
|
||||
name: "Bad Slack",
|
||||
enabled: true,
|
||||
triggerEvents: ["marketing.lead.captured"],
|
||||
},
|
||||
]);
|
||||
const res = await runMarketingOutboundJob(buildJobData(), { store, fetch: fetchImpl });
|
||||
expect(res.failed).toBe(1);
|
||||
expect(store.recordDelivery).toHaveBeenCalledWith("int_bad", expect.objectContaining({ ok: false }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Marketing-funnel Phase 2 outbound webhook handler foundation (Phase 05 #79).
|
||||
*
|
||||
* This module is intentionally a pure handler: it does not claim or
|
||||
* bootstrap the `outbox.marketing` BullMQ queue. That queue is actively
|
||||
* owned by the email-sequence trigger subscriber. A future marketing
|
||||
* integration subscriber must introduce its own bootstrap module and
|
||||
* ownership tests before wiring this handler to a queue.
|
||||
*
|
||||
* The handler looks up every enabled `MarketingIntegration` whose
|
||||
* `triggerEvents` contains the event-type, decrypts its config, and
|
||||
* dispatches the payload through the matching adapter (Slack, Discord,
|
||||
* Mailchimp, HubSpot, Brevo, custom webhook).
|
||||
*
|
||||
* Failures per integration are isolated — one bad integration cannot
|
||||
* starve the others. Final-failures persist `MarketingIntegration.lastError`
|
||||
* and emit `marketing.integration.failed` audit events; the BullMQ
|
||||
* retry machinery handles transient 5xx/network failures by re-queueing
|
||||
* the outbox row.
|
||||
*/
|
||||
|
||||
import { resolveAdapter, type MarketingLeadEventType, type MarketingLeadPayload } from "@toolctrl/marketing-outbound";
|
||||
import { withOutboxIdempotency, type OutboxReceiverIdempotencyStore } from "@toolctrl/db/outbox-receiver";
|
||||
|
||||
export const MARKETING_OUTBOUND_ACTIVE_SUBSCRIBER = false as const;
|
||||
|
||||
/** Set of event-type strings the handler recognises. */
|
||||
export const MARKETING_LEAD_EVENT_TYPES: readonly MarketingLeadEventType[] = [
|
||||
"marketing.lead.captured",
|
||||
"marketing.lead.qualified",
|
||||
"marketing.lead.convertedtotrial",
|
||||
"marketing.lead.convertedtocustomer",
|
||||
];
|
||||
|
||||
export interface MarketingIntegrationRow {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
/** Decrypted config blob. The DB row stores `configJson` encrypted; the
|
||||
* store adapter is responsible for decryption before this handler
|
||||
* sees the values. */
|
||||
config: unknown;
|
||||
enabled: boolean;
|
||||
triggerEvents: string[];
|
||||
}
|
||||
|
||||
export interface MarketingOutboundJobData {
|
||||
outboxEventId?: string;
|
||||
tenantId?: string | null;
|
||||
eventType: string;
|
||||
payload: MarketingLeadPayload;
|
||||
}
|
||||
|
||||
export interface MarketingOutboundStore {
|
||||
/** Fetch all enabled integrations whose triggerEvents contain `eventType`. */
|
||||
listEnabledForEvent(eventType: string): Promise<MarketingIntegrationRow[]>;
|
||||
/** Update lastDeliveredAt / lastError diagnostics on the integration row. */
|
||||
recordDelivery(integrationId: string, result: { ok: boolean; errorTag?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MarketingOutboundLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface MarketingOutboundDependencies {
|
||||
readonly store: MarketingOutboundStore;
|
||||
readonly logger?: MarketingOutboundLogger;
|
||||
readonly idempotency?: OutboxReceiverIdempotencyStore & { forget?(key: string): Promise<void> };
|
||||
readonly fetch?: typeof fetch;
|
||||
/** Override the default 10s adapter timeout (test seam). */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MarketingOutboundJobResult {
|
||||
readonly eventType: string;
|
||||
readonly dispatched: number;
|
||||
readonly succeeded: number;
|
||||
readonly failed: number;
|
||||
}
|
||||
|
||||
export class MarketingOutboundJobError extends Error {
|
||||
override readonly name = "MarketingOutboundJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "UNKNOWN_EVENT_TYPE";
|
||||
constructor(message: string, code: MarketingOutboundJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMarketingOutboundJob(data: unknown): MarketingOutboundJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new MarketingOutboundJobError("marketing-outbound job missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const eventType = typeof envelope.eventType === "string" ? envelope.eventType : "";
|
||||
if (!MARKETING_LEAD_EVENT_TYPES.includes(eventType as MarketingLeadEventType)) {
|
||||
throw new MarketingOutboundJobError(`unknown marketing event-type: ${eventType}`, "UNKNOWN_EVENT_TYPE");
|
||||
}
|
||||
const payload = envelope.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new MarketingOutboundJobError("marketing-outbound job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
if (typeof p.leadId !== "string" || typeof p.email !== "string") {
|
||||
throw new MarketingOutboundJobError("marketing-outbound payload missing leadId/email", "INVALID_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId: typeof envelope.tenantId === "string" ? envelope.tenantId : null,
|
||||
eventType,
|
||||
payload: payload as MarketingLeadPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMarketingOutboundJob(
|
||||
data: unknown,
|
||||
deps: MarketingOutboundDependencies
|
||||
): Promise<MarketingOutboundJobResult> {
|
||||
// Allow operator to short-circuit all outbound webhooks (rollback) by
|
||||
// setting MARKETING_WEBHOOKS_ENABLED=false. Outbox events still
|
||||
// accumulate so the queue can be re-played after a fix.
|
||||
if (process.env.MARKETING_WEBHOOKS_ENABLED === "false") {
|
||||
deps.logger?.info?.({ op: "marketing-outbound.skipped" }, "marketing webhooks disabled by env");
|
||||
return { eventType: "(disabled)", dispatched: 0, succeeded: 0, failed: 0 };
|
||||
}
|
||||
const job = parseMarketingOutboundJob(data);
|
||||
if (deps.idempotency && job.outboxEventId) {
|
||||
const outcome = await withOutboxIdempotency(deps.idempotency, job.outboxEventId, () => dispatchMarketingOutboundJob(job, deps));
|
||||
if (outcome.status === "executed" && isMarketingOutboundJobResult(outcome.result)) {
|
||||
return outcome.result;
|
||||
}
|
||||
deps.logger?.info?.(
|
||||
{ op: "marketing-outbound.idempotent_skip", outboxEventId: job.outboxEventId, eventType: job.eventType },
|
||||
"marketing outbound skipped idempotent redelivery"
|
||||
);
|
||||
return isMarketingOutboundJobResult(outcome.result)
|
||||
? outcome.result
|
||||
: { eventType: job.eventType, dispatched: 0, succeeded: 0, failed: 0 };
|
||||
}
|
||||
|
||||
return dispatchMarketingOutboundJob(job, deps);
|
||||
}
|
||||
|
||||
async function dispatchMarketingOutboundJob(
|
||||
job: MarketingOutboundJobData,
|
||||
deps: MarketingOutboundDependencies
|
||||
): Promise<MarketingOutboundJobResult> {
|
||||
const integrations = await deps.store.listEnabledForEvent(job.eventType);
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
for (const integration of integrations) {
|
||||
const adapter = resolveAdapter(integration.type);
|
||||
if (!adapter) {
|
||||
failed += 1;
|
||||
await deps.store.recordDelivery(integration.id, { ok: false, errorTag: "unknown_adapter_type" });
|
||||
deps.logger?.warn?.(
|
||||
{ op: "marketing-outbound.unknown_adapter", integrationId: integration.id, type: integration.type },
|
||||
"no adapter registered for integration type"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
const config = adapter.parseConfig(integration.config);
|
||||
result = await adapter.send({
|
||||
eventType: job.eventType as MarketingLeadEventType,
|
||||
payload: job.payload,
|
||||
config,
|
||||
ctx: { fetch: deps.fetch, timeoutMs: deps.timeoutMs },
|
||||
});
|
||||
} catch (err) {
|
||||
result = {
|
||||
ok: false,
|
||||
errorTag: err instanceof Error ? `${err.name}` : "adapter_error",
|
||||
} as const;
|
||||
}
|
||||
if (result.ok) succeeded += 1;
|
||||
else failed += 1;
|
||||
await deps.store.recordDelivery(integration.id, { ok: result.ok, errorTag: result.errorTag });
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
op: result.ok ? "marketing-outbound.delivered" : "marketing-outbound.failed",
|
||||
integrationId: integration.id,
|
||||
type: integration.type,
|
||||
eventType: job.eventType,
|
||||
leadId: job.payload.leadId,
|
||||
errorTag: result.errorTag,
|
||||
},
|
||||
result.ok ? "marketing webhook delivered" : "marketing webhook delivery failed"
|
||||
);
|
||||
}
|
||||
return {
|
||||
eventType: job.eventType,
|
||||
dispatched: integrations.length,
|
||||
succeeded,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
function isMarketingOutboundJobResult(value: unknown): value is MarketingOutboundJobResult {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.eventType === "string" &&
|
||||
typeof record.dispatched === "number" &&
|
||||
typeof record.succeeded === "number" &&
|
||||
typeof record.failed === "number"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
OUTBOX_DONE_RETENTION_DEFAULT_BATCH_SIZE,
|
||||
OUTBOX_DONE_RETENTION_DEFAULT_HOURS,
|
||||
OUTBOX_DONE_RETENTION_EVERY_MS,
|
||||
OUTBOX_DONE_RETENTION_JOB_ID,
|
||||
registerOutboxDoneRetentionCron,
|
||||
resolveOutboxDoneRetentionCronConfig,
|
||||
} from "./outbox-done-retention-cron.js";
|
||||
|
||||
describe("outbox-done-retention-cron", () => {
|
||||
it("resolves retention defaults and clamps invalid env", () => {
|
||||
expect(resolveOutboxDoneRetentionCronConfig({})).toEqual({
|
||||
olderThanHours: OUTBOX_DONE_RETENTION_DEFAULT_HOURS,
|
||||
take: OUTBOX_DONE_RETENTION_DEFAULT_BATCH_SIZE,
|
||||
});
|
||||
expect(
|
||||
resolveOutboxDoneRetentionCronConfig({
|
||||
OUTBOX_DONE_RETENTION_HOURS: "168.9",
|
||||
OUTBOX_DONE_RETENTION_BATCH_SIZE: "2500.5",
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toEqual({ olderThanHours: 168, take: 2500 });
|
||||
expect(
|
||||
resolveOutboxDoneRetentionCronConfig({
|
||||
OUTBOX_DONE_RETENTION_HOURS: "-1",
|
||||
OUTBOX_DONE_RETENTION_BATCH_SIZE: "nope",
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toEqual({
|
||||
olderThanHours: OUTBOX_DONE_RETENTION_DEFAULT_HOURS,
|
||||
take: OUTBOX_DONE_RETENTION_DEFAULT_BATCH_SIZE,
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a 24h scheduler with cleanup.run payload", async () => {
|
||||
const upsert = vi.fn().mockResolvedValue(undefined);
|
||||
await registerOutboxDoneRetentionCron(
|
||||
{ upsertJobScheduler: upsert },
|
||||
{
|
||||
OUTBOX_DONE_RETENTION_HOURS: "720",
|
||||
OUTBOX_DONE_RETENTION_BATCH_SIZE: "1000",
|
||||
} as NodeJS.ProcessEnv,
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
expect(upsert.mock.calls[0][0]).toBe(OUTBOX_DONE_RETENTION_JOB_ID);
|
||||
expect(upsert.mock.calls[0][1]).toEqual({ every: OUTBOX_DONE_RETENTION_EVERY_MS });
|
||||
expect(upsert.mock.calls[0][2]).toEqual({
|
||||
name: OUTBOX_DONE_RETENTION_JOB_ID,
|
||||
data: {
|
||||
kind: "cleanup.run",
|
||||
task: "outbox-done-retention",
|
||||
olderThanHours: 720,
|
||||
take: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export const OUTBOX_DONE_RETENTION_JOB_ID = "outbox-done-retention" as const;
|
||||
export const OUTBOX_DONE_RETENTION_EVERY_MS = 24 * 60 * 60 * 1000;
|
||||
export const OUTBOX_DONE_RETENTION_DEFAULT_HOURS = 720;
|
||||
export const OUTBOX_DONE_RETENTION_DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
export function resolveOutboxDoneRetentionCronConfig(env: NodeJS.ProcessEnv = process.env): {
|
||||
olderThanHours: number;
|
||||
take: number;
|
||||
} {
|
||||
return {
|
||||
olderThanHours: parsePositiveInteger(env.OUTBOX_DONE_RETENTION_HOURS, OUTBOX_DONE_RETENTION_DEFAULT_HOURS),
|
||||
take: parsePositiveInteger(env.OUTBOX_DONE_RETENTION_BATCH_SIZE, OUTBOX_DONE_RETENTION_DEFAULT_BATCH_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerOutboxDoneRetentionCron(
|
||||
queue: {
|
||||
upsertJobScheduler: (
|
||||
schedulerId: string,
|
||||
options: { every: number },
|
||||
template: { name: string; data?: Record<string, unknown> },
|
||||
) => Promise<unknown>;
|
||||
},
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<void> {
|
||||
const config = resolveOutboxDoneRetentionCronConfig(env);
|
||||
await queue.upsertJobScheduler(
|
||||
OUTBOX_DONE_RETENTION_JOB_ID,
|
||||
{ every: OUTBOX_DONE_RETENTION_EVERY_MS },
|
||||
{
|
||||
name: OUTBOX_DONE_RETENTION_JOB_ID,
|
||||
data: {
|
||||
kind: "cleanup.run",
|
||||
task: OUTBOX_DONE_RETENTION_JOB_ID,
|
||||
olderThanHours: config.olderThanHours,
|
||||
take: config.take,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT,
|
||||
PLUGIN_AUDIT_RETENTION_CRON_DEFAULT,
|
||||
PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT,
|
||||
PLUGIN_AUDIT_RETENTION_JOB_ID,
|
||||
PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT,
|
||||
PLUGIN_AUDIT_RETENTION_QUEUE,
|
||||
registerPluginAuditRetentionCron,
|
||||
resolvePluginAuditRetentionConfig,
|
||||
runPluginAuditRetention,
|
||||
} from "./plugin-audit-retention.js";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
type SilentLogger = {
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeLogger(): SilentLogger {
|
||||
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
describe("queue + scheduler constants", () => {
|
||||
test("exports stable queue and job-id names", () => {
|
||||
expect(PLUGIN_AUDIT_RETENTION_QUEUE).toBe("plugin-audit-retention");
|
||||
expect(PLUGIN_AUDIT_RETENTION_JOB_ID).toBe("plugin-audit-retention");
|
||||
expect(PLUGIN_AUDIT_RETENTION_CRON_DEFAULT).toBe("0 3 * * *");
|
||||
});
|
||||
|
||||
test("exports documented defaults", () => {
|
||||
expect(PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT).toBe(90);
|
||||
expect(PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT).toBe(1000);
|
||||
expect(PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginAuditRetentionConfig", () => {
|
||||
test("returns defaults when env is empty", () => {
|
||||
expect(resolvePluginAuditRetentionConfig({})).toEqual({
|
||||
retentionDays: PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT,
|
||||
batchSize: PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT,
|
||||
maxBatches: PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns defaults when env is undefined", () => {
|
||||
expect(resolvePluginAuditRetentionConfig(undefined)).toEqual({
|
||||
retentionDays: PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT,
|
||||
batchSize: PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT,
|
||||
maxBatches: PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses overrides from env", () => {
|
||||
expect(
|
||||
resolvePluginAuditRetentionConfig({
|
||||
PLUGIN_AUDIT_RETENTION_DAYS: "30",
|
||||
PLUGIN_AUDIT_RETENTION_BATCH_SIZE: "250",
|
||||
PLUGIN_AUDIT_RETENTION_MAX_BATCHES: "8",
|
||||
})
|
||||
).toEqual({ retentionDays: 30, batchSize: 250, maxBatches: 8 });
|
||||
});
|
||||
|
||||
test("falls back to defaults on garbage / non-positive values", () => {
|
||||
expect(
|
||||
resolvePluginAuditRetentionConfig({
|
||||
PLUGIN_AUDIT_RETENTION_DAYS: "not-a-number",
|
||||
PLUGIN_AUDIT_RETENTION_BATCH_SIZE: "0",
|
||||
PLUGIN_AUDIT_RETENTION_MAX_BATCHES: "-5",
|
||||
})
|
||||
).toEqual({
|
||||
retentionDays: PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT,
|
||||
batchSize: PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT,
|
||||
maxBatches: PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPluginAuditRetention — cutoff math", () => {
|
||||
test("cutoff is exactly retentionDays before `now`", async () => {
|
||||
const now = new Date("2026-05-19T12:00:00.000Z");
|
||||
const cutoffs: Date[] = [];
|
||||
const deleteBatch = vi.fn(async (cutoff: Date) => {
|
||||
cutoffs.push(cutoff);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const result = await runPluginAuditRetention({
|
||||
now,
|
||||
config: { retentionDays: 90, batchSize: 1000, maxBatches: 10 },
|
||||
deleteBatch,
|
||||
});
|
||||
|
||||
expect(cutoffs).toHaveLength(1);
|
||||
const expected = new Date(now.getTime() - 90 * MS_PER_DAY);
|
||||
expect(cutoffs[0]!.toISOString()).toBe(expected.toISOString());
|
||||
expect(result.cutoffIso).toBe(expected.toISOString());
|
||||
// 2026-05-19 minus 90 days = 2026-02-18
|
||||
expect(result.cutoffIso).toBe("2026-02-18T12:00:00.000Z");
|
||||
});
|
||||
|
||||
test("cutoff respects a non-default retentionDays value", async () => {
|
||||
const now = new Date("2026-05-19T00:00:00.000Z");
|
||||
const deleteBatch = vi.fn(async (cutoff: Date, batchSize: number) => {
|
||||
void cutoff;
|
||||
void batchSize;
|
||||
return 0;
|
||||
});
|
||||
const result = await runPluginAuditRetention({
|
||||
now,
|
||||
config: { retentionDays: 7, batchSize: 100, maxBatches: 5 },
|
||||
deleteBatch,
|
||||
});
|
||||
expect(result.cutoffIso).toBe("2026-05-12T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPluginAuditRetention — batching", () => {
|
||||
test("loops until deleteBatch returns 0", async () => {
|
||||
// Simulate: 3 full batches of 1000, then 250, then 0.
|
||||
const sequence = [1000, 1000, 1000, 250, 0];
|
||||
const deleteBatch = vi.fn(async () => sequence.shift() ?? 0);
|
||||
|
||||
const result = await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
config: { retentionDays: 90, batchSize: 1000, maxBatches: 10 },
|
||||
deleteBatch,
|
||||
});
|
||||
|
||||
expect(deleteBatch).toHaveBeenCalledTimes(5);
|
||||
expect(result.deletedRows).toBe(3250);
|
||||
expect(result.batches).toBe(5);
|
||||
});
|
||||
|
||||
test("invokes deleteBatch with the configured batchSize", async () => {
|
||||
const deleteBatch = vi.fn(async (_cutoff: Date, batchSize: number) => (batchSize === 500 ? 0 : 0));
|
||||
await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
config: { retentionDays: 90, batchSize: 500, maxBatches: 10 },
|
||||
deleteBatch,
|
||||
});
|
||||
expect(deleteBatch).toHaveBeenCalledWith(expect.any(Date), 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPluginAuditRetention — max-batches guard", () => {
|
||||
test("stops at maxBatches even if more rows remain", async () => {
|
||||
// Every call returns a full batch — without the cap this would be
|
||||
// an infinite loop. With maxBatches=3 we must stop after 3 calls.
|
||||
const deleteBatch = vi.fn(async () => 1000);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
config: { retentionDays: 90, batchSize: 1000, maxBatches: 3 },
|
||||
deleteBatch,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(deleteBatch).toHaveBeenCalledTimes(3);
|
||||
expect(result.batches).toBe(3);
|
||||
expect(result.deletedRows).toBe(3000);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ batches: 3, deletedRows: 3000, maxBatches: 3 }),
|
||||
"plugin-audit-retention.max-batches-reached"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPluginAuditRetention — empty / soft-fail", () => {
|
||||
test("returns { deletedRows: 0, batches: 1 } when no rows are eligible", async () => {
|
||||
const deleteBatch = vi.fn(async (cutoff: Date, batchSize: number) => {
|
||||
void cutoff;
|
||||
void batchSize;
|
||||
return 0;
|
||||
});
|
||||
const result = await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
config: { retentionDays: 90, batchSize: 1000, maxBatches: 100 },
|
||||
deleteBatch,
|
||||
});
|
||||
expect(deleteBatch).toHaveBeenCalledTimes(1);
|
||||
expect(result.deletedRows).toBe(0);
|
||||
expect(result.batches).toBe(1);
|
||||
});
|
||||
|
||||
test("soft-fails on DB error: logs, terminates, returns accumulated count", async () => {
|
||||
let call = 0;
|
||||
const deleteBatch = vi.fn(async () => {
|
||||
call += 1;
|
||||
if (call === 1) return 1000;
|
||||
if (call === 2) return 500;
|
||||
throw new Error("connection reset");
|
||||
});
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
config: { retentionDays: 90, batchSize: 1000, maxBatches: 100 },
|
||||
deleteBatch,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(deleteBatch).toHaveBeenCalledTimes(3);
|
||||
expect(result.deletedRows).toBe(1500);
|
||||
expect(result.batches).toBe(2);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedRows: 1500, err: "connection reset" }),
|
||||
"plugin-audit-retention.batch-failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("reads config from env when `config` is omitted", async () => {
|
||||
const deleteBatch = vi.fn(async (cutoff: Date, batchSize: number) => {
|
||||
void cutoff;
|
||||
void batchSize;
|
||||
return 0;
|
||||
});
|
||||
const sizesSeen: number[] = [];
|
||||
await runPluginAuditRetention({
|
||||
now: new Date("2026-05-19T03:00:00.000Z"),
|
||||
env: {
|
||||
PLUGIN_AUDIT_RETENTION_DAYS: "30",
|
||||
PLUGIN_AUDIT_RETENTION_BATCH_SIZE: "42",
|
||||
PLUGIN_AUDIT_RETENTION_MAX_BATCHES: "3",
|
||||
},
|
||||
deleteBatch: async (cutoff, batchSize) => {
|
||||
sizesSeen.push(batchSize);
|
||||
return deleteBatch(cutoff, batchSize);
|
||||
},
|
||||
});
|
||||
expect(sizesSeen).toEqual([42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerPluginAuditRetentionCron", () => {
|
||||
test("upserts the job-scheduler with default cron pattern", async () => {
|
||||
const upsertJobScheduler = vi.fn(async () => ({}));
|
||||
const queue = { upsertJobScheduler };
|
||||
|
||||
const result = await registerPluginAuditRetentionCron(queue, {});
|
||||
|
||||
expect(upsertJobScheduler).toHaveBeenCalledTimes(1);
|
||||
expect(upsertJobScheduler).toHaveBeenCalledWith(
|
||||
PLUGIN_AUDIT_RETENTION_JOB_ID,
|
||||
{ pattern: PLUGIN_AUDIT_RETENTION_CRON_DEFAULT },
|
||||
{ name: PLUGIN_AUDIT_RETENTION_JOB_ID, data: { task: PLUGIN_AUDIT_RETENTION_JOB_ID } }
|
||||
);
|
||||
expect(result.pattern).toBe(PLUGIN_AUDIT_RETENTION_CRON_DEFAULT);
|
||||
});
|
||||
|
||||
test("honours PLUGIN_AUDIT_RETENTION_CRON env override", async () => {
|
||||
const upsertJobScheduler = vi.fn(async () => ({}));
|
||||
const queue = { upsertJobScheduler };
|
||||
|
||||
const result = await registerPluginAuditRetentionCron(queue, {
|
||||
PLUGIN_AUDIT_RETENTION_CRON: "15 4 * * *",
|
||||
});
|
||||
|
||||
expect(upsertJobScheduler).toHaveBeenCalledWith(
|
||||
PLUGIN_AUDIT_RETENTION_JOB_ID,
|
||||
{ pattern: "15 4 * * *" },
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(result.pattern).toBe("15 4 * * *");
|
||||
});
|
||||
});
|
||||
|
||||
// F-020 — surviving-rows count + size warning.
|
||||
describe("runPluginAuditRetention — post-sweep size check (F-020)", () => {
|
||||
test("emits a warn line when surviving rows exceed the threshold", async () => {
|
||||
const logger = makeLogger();
|
||||
const deleteBatch = vi.fn(async () => 0);
|
||||
const countRows = vi.fn(async () => 12_345_678);
|
||||
await runPluginAuditRetention({
|
||||
config: { retentionDays: 90, batchSize: 100, maxBatches: 1 },
|
||||
deleteBatch,
|
||||
countRows,
|
||||
warnRowCountThreshold: 10_000_000,
|
||||
logger,
|
||||
});
|
||||
const warns = logger.warn.mock.calls.map(([, msg]) => msg);
|
||||
expect(warns).toContain("plugin-audit-retention.table-size-warning");
|
||||
});
|
||||
|
||||
test("emits an info line when surviving rows are below the threshold", async () => {
|
||||
const logger = makeLogger();
|
||||
const deleteBatch = vi.fn(async () => 0);
|
||||
const countRows = vi.fn(async () => 100);
|
||||
await runPluginAuditRetention({
|
||||
config: { retentionDays: 90, batchSize: 100, maxBatches: 1 },
|
||||
deleteBatch,
|
||||
countRows,
|
||||
warnRowCountThreshold: 10_000_000,
|
||||
logger,
|
||||
});
|
||||
const infos = logger.info.mock.calls.map(([, msg]) => msg);
|
||||
expect(infos).toContain("plugin-audit-retention.table-size");
|
||||
});
|
||||
|
||||
test("a countRows failure does not crash the run", async () => {
|
||||
const logger = makeLogger();
|
||||
const deleteBatch = vi.fn(async () => 0);
|
||||
const countRows = vi.fn(async () => {
|
||||
throw new Error("DB connection lost");
|
||||
});
|
||||
const result = await runPluginAuditRetention({
|
||||
config: { retentionDays: 90, batchSize: 100, maxBatches: 1 },
|
||||
deleteBatch,
|
||||
countRows,
|
||||
logger,
|
||||
});
|
||||
expect(result.deletedRows).toBe(0);
|
||||
const warns = logger.warn.mock.calls.map(([, msg]) => msg);
|
||||
expect(warns).toContain("plugin-audit-retention.count-failed");
|
||||
});
|
||||
|
||||
test("countRows is skipped silently when not supplied", async () => {
|
||||
const logger = makeLogger();
|
||||
const deleteBatch = vi.fn(async () => 0);
|
||||
await runPluginAuditRetention({
|
||||
config: { retentionDays: 90, batchSize: 100, maxBatches: 1 },
|
||||
deleteBatch,
|
||||
logger,
|
||||
});
|
||||
const allMessages = [
|
||||
...logger.info.mock.calls.map(([, msg]) => msg),
|
||||
...logger.warn.mock.calls.map(([, msg]) => msg),
|
||||
];
|
||||
expect(allMessages).not.toContain("plugin-audit-retention.table-size");
|
||||
expect(allMessages).not.toContain("plugin-audit-retention.table-size-warning");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Phase 06 #23 — PluginCapabilityUse audit retention nightly job.
|
||||
*
|
||||
* Walks the `PluginCapabilityUse` table once per day and deletes rows
|
||||
* whose `timestamp` column is older than `PLUGIN_AUDIT_RETENTION_DAYS`
|
||||
* (default 90). DSGVO + storage-bloat concern: the table is
|
||||
* append-only at runtime, so without this sweep it grows unbounded.
|
||||
*
|
||||
* Deletion is batched (default 1000 rows per iteration) and capped at
|
||||
* a max number of iterations per run (default 100) so the cron never
|
||||
* locks the table for an arbitrarily long window. Anything that does
|
||||
* not get cleaned up tonight will be picked up tomorrow.
|
||||
*
|
||||
* Host-agnostic on purpose: the worker bootstrap (apps/worker/src/
|
||||
* index.ts) supplies the seams (`deleteBatch`, `logger`) so this file
|
||||
* is unit-testable without spinning up Prisma or BullMQ.
|
||||
*
|
||||
* Spec: docs/improvement-plan/06-plugin-system/audit-docs.md (Step 5).
|
||||
*/
|
||||
|
||||
export const PLUGIN_AUDIT_RETENTION_QUEUE = "plugin-audit-retention" as const;
|
||||
export const PLUGIN_AUDIT_RETENTION_JOB_ID = "plugin-audit-retention" as const;
|
||||
/** Nightly at 03:00 UTC — matches the spec. */
|
||||
export const PLUGIN_AUDIT_RETENTION_CRON_DEFAULT = "0 3 * * *" as const;
|
||||
|
||||
export const PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT = 90;
|
||||
export const PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT = 1000;
|
||||
export const PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT = 100;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
export interface PluginAuditRetentionLogger {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error: (fields: Record<string, unknown>, msg: string) => void;
|
||||
}
|
||||
|
||||
export interface PluginAuditRetentionConfig {
|
||||
/** Days to keep. Rows with `timestamp < now - days` are deleted. */
|
||||
retentionDays: number;
|
||||
/** Max rows to delete per `deleteBatch` call. */
|
||||
batchSize: number;
|
||||
/** Hard cap on iterations per run (back-pressure / lock-window guard). */
|
||||
maxBatches: number;
|
||||
}
|
||||
|
||||
export type PluginAuditRetentionEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface PluginAuditRetentionInput {
|
||||
/** Reference "now" — defaults to `new Date()`. Injected for tests. */
|
||||
now?: Date;
|
||||
/** Resolved config. If absent, read from `env`. */
|
||||
config?: PluginAuditRetentionConfig;
|
||||
/** Env (process.env in production). Used only when `config` is omitted. */
|
||||
env?: PluginAuditRetentionEnv;
|
||||
/**
|
||||
* Delete up to `batchSize` rows where `timestamp < cutoff`. Returns the
|
||||
* exact number of rows removed. Production wires this to
|
||||
* `prismaWrite.pluginCapabilityUse.deleteMany` with a `where.id in
|
||||
* (subselect)` trick to bound the batch (Postgres does not natively
|
||||
* support LIMIT on DELETE).
|
||||
*/
|
||||
deleteBatch: (cutoff: Date, batchSize: number) => Promise<number>;
|
||||
/**
|
||||
* F-020 — optional post-sweep row counter. When supplied, the
|
||||
* retention job logs the surviving row count at the end of the run.
|
||||
* Production wires this to `prismaWrite.pluginCapabilityUse.count()`.
|
||||
* When omitted (tests, dev) the size-warning step is skipped silently.
|
||||
*/
|
||||
countRows?: () => Promise<number>;
|
||||
/**
|
||||
* F-020 — soft warning threshold. When the surviving row count after
|
||||
* the sweep exceeds this number, the job emits a `warn` log line so
|
||||
* the operator notices unbounded growth (e.g. retention is too lax
|
||||
* for the current invocation volume). Default 10 million rows.
|
||||
*/
|
||||
warnRowCountThreshold?: number;
|
||||
logger?: PluginAuditRetentionLogger;
|
||||
}
|
||||
|
||||
export interface PluginAuditRetentionResult {
|
||||
readonly deletedRows: number;
|
||||
readonly batches: number;
|
||||
readonly cutoffIso: string;
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string | undefined, fallback: number): number {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) return fallback;
|
||||
const parsed = Number.parseInt(raw.trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolvePluginAuditRetentionConfig(
|
||||
env: PluginAuditRetentionEnv | undefined
|
||||
): PluginAuditRetentionConfig {
|
||||
const source = env ?? {};
|
||||
return {
|
||||
retentionDays: parsePositiveInt(source.PLUGIN_AUDIT_RETENTION_DAYS, PLUGIN_AUDIT_RETENTION_DAYS_DEFAULT),
|
||||
batchSize: parsePositiveInt(source.PLUGIN_AUDIT_RETENTION_BATCH_SIZE, PLUGIN_AUDIT_RETENTION_BATCH_SIZE_DEFAULT),
|
||||
maxBatches: parsePositiveInt(source.PLUGIN_AUDIT_RETENTION_MAX_BATCHES, PLUGIN_AUDIT_RETENTION_MAX_BATCHES_DEFAULT),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one retention sweep. Resolves with a structured result so the
|
||||
* BullMQ wrapper can log/metricise without needing to throw on the
|
||||
* "0 rows" common case.
|
||||
*
|
||||
* Soft-fail per batch: if `deleteBatch` throws, the error is logged and
|
||||
* the loop terminates with the rows accumulated so far. The next nightly
|
||||
* run will retry — we deliberately do not throw because BullMQ would
|
||||
* then either (a) retry immediately (hammering a sick DB) or (b)
|
||||
* dead-letter a perfectly resumable cron.
|
||||
*/
|
||||
export async function runPluginAuditRetention(
|
||||
input: PluginAuditRetentionInput
|
||||
): Promise<PluginAuditRetentionResult> {
|
||||
const now = input.now ?? new Date();
|
||||
const config = input.config ?? resolvePluginAuditRetentionConfig(input.env);
|
||||
const cutoff = new Date(now.getTime() - config.retentionDays * MS_PER_DAY);
|
||||
const cutoffIso = cutoff.toISOString();
|
||||
|
||||
let deletedRows = 0;
|
||||
let batches = 0;
|
||||
|
||||
input.logger?.info(
|
||||
{
|
||||
retentionDays: config.retentionDays,
|
||||
batchSize: config.batchSize,
|
||||
maxBatches: config.maxBatches,
|
||||
cutoff: cutoffIso,
|
||||
},
|
||||
"plugin-audit-retention.start"
|
||||
);
|
||||
|
||||
while (batches < config.maxBatches) {
|
||||
let removed: number;
|
||||
try {
|
||||
removed = await input.deleteBatch(cutoff, config.batchSize);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
input.logger?.error(
|
||||
{ batch: batches + 1, cutoff: cutoffIso, deletedRows, err: message },
|
||||
"plugin-audit-retention.batch-failed"
|
||||
);
|
||||
// Soft-fail: stop the loop but return what we successfully deleted.
|
||||
return { deletedRows, batches, cutoffIso };
|
||||
}
|
||||
|
||||
batches += 1;
|
||||
deletedRows += removed;
|
||||
|
||||
input.logger?.info(
|
||||
{ batch: batches, removed, deletedRowsRunning: deletedRows },
|
||||
"plugin-audit-retention.batch-complete"
|
||||
);
|
||||
|
||||
if (removed === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batches >= config.maxBatches && deletedRows > 0) {
|
||||
input.logger?.warn?.(
|
||||
{ batches, deletedRows, cutoff: cutoffIso, maxBatches: config.maxBatches },
|
||||
"plugin-audit-retention.max-batches-reached"
|
||||
);
|
||||
}
|
||||
|
||||
// F-020 — optional surviving-rows count + soft warning when the
|
||||
// table is unusually large. Soft-fail: a count() error never aborts
|
||||
// the retention result the caller already earned.
|
||||
if (input.countRows) {
|
||||
const threshold = input.warnRowCountThreshold ?? 10_000_000;
|
||||
try {
|
||||
const surviving = await input.countRows();
|
||||
if (surviving > threshold) {
|
||||
input.logger?.warn?.(
|
||||
{ survivingRows: surviving, threshold, cutoff: cutoffIso, deletedRows },
|
||||
"plugin-audit-retention.table-size-warning"
|
||||
);
|
||||
} else {
|
||||
input.logger?.info(
|
||||
{ survivingRows: surviving, cutoff: cutoffIso },
|
||||
"plugin-audit-retention.table-size"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
input.logger?.warn?.(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"plugin-audit-retention.count-failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
input.logger?.info(
|
||||
{ deletedRows, batches, cutoff: cutoffIso },
|
||||
"plugin-audit-retention.complete"
|
||||
);
|
||||
|
||||
return { deletedRows, batches, cutoffIso };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for the worker bootstrap. Given a BullMQ-like queue with the
|
||||
* `upsertJobScheduler` API, install (or replace) the nightly schedule.
|
||||
* Pinned to a stable scheduler key so a second call is a no-op
|
||||
* (idempotent on redeploys). The cron pattern is read from
|
||||
* `PLUGIN_AUDIT_RETENTION_CRON` (default `0 3 * * *`).
|
||||
*/
|
||||
export async function registerPluginAuditRetentionCron(
|
||||
queue: {
|
||||
upsertJobScheduler: (
|
||||
schedulerId: string,
|
||||
options: { pattern: string },
|
||||
template: { name: string; data?: Record<string, unknown> }
|
||||
) => Promise<unknown>;
|
||||
},
|
||||
env: PluginAuditRetentionEnv | undefined = process.env
|
||||
): Promise<{ pattern: string }> {
|
||||
const pattern = env?.PLUGIN_AUDIT_RETENTION_CRON?.trim() || PLUGIN_AUDIT_RETENTION_CRON_DEFAULT;
|
||||
await queue.upsertJobScheduler(
|
||||
PLUGIN_AUDIT_RETENTION_JOB_ID,
|
||||
{ pattern },
|
||||
{ name: PLUGIN_AUDIT_RETENTION_JOB_ID, data: { task: PLUGIN_AUDIT_RETENTION_JOB_ID } }
|
||||
);
|
||||
return { pattern };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { PLUGIN_CAPABILITY_USE_EVENT_TYPE, PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE, type PluginCapabilityUseStore } from "./plugin-capability-use.js";
|
||||
import {
|
||||
bootstrapPluginCapabilityUse,
|
||||
isPluginCapabilityUseRequired,
|
||||
PluginCapabilityUseRequiredBootstrapError,
|
||||
type PluginCapabilityUseBootstrapDeps,
|
||||
type PluginCapabilityUseWorkerHandle,
|
||||
} from "./plugin-capability-use-bootstrap.js";
|
||||
|
||||
type WorkerProcessor = (job: { data: unknown }) => Promise<unknown>;
|
||||
type WorkerOptions = { concurrency?: number };
|
||||
|
||||
function fakeWorker() {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const handle: PluginCapabilityUseWorkerHandle = { name: "fake-plugin-capability-use", close };
|
||||
const calls: Array<[string, WorkerProcessor, WorkerOptions]> = [];
|
||||
const createWorkerImpl = vi.fn((queueName: string, processor: WorkerProcessor, options: WorkerOptions) => {
|
||||
calls.push([queueName, processor, options]);
|
||||
return handle;
|
||||
});
|
||||
return { close, handle, createWorkerImpl, calls };
|
||||
}
|
||||
|
||||
function fakeStore(): PluginCapabilityUseStore {
|
||||
return {
|
||||
async insertMany(rows) {
|
||||
return { inserted: rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validJobData() {
|
||||
return {
|
||||
outboxEventId: "evt-1",
|
||||
tenantId: "t1",
|
||||
eventType: PLUGIN_CAPABILITY_USE_EVENT_TYPE,
|
||||
payload: {
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
entries: [
|
||||
{
|
||||
id: "row-1",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
timestamp: "2026-05-17T20:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapPluginCapabilityUse", () => {
|
||||
test("disables on non-general worker groups", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginCapabilityUse({ WORKER_QUEUE_GROUP: "mail" }, { store: fakeStore(), createWorkerImpl });
|
||||
|
||||
expect(result).toEqual({ status: "disabled", reason: "non-general worker group" });
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables when explicitly turned off", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginCapabilityUse(
|
||||
{ WORKER_QUEUE_GROUP: "general", PLUGIN_CAPABILITY_USE_ENABLED: "false" },
|
||||
{ store: fakeStore(), createWorkerImpl }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "disabled", reason: "PLUGIN_CAPABILITY_USE_ENABLED=false" });
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats capability-use persistence as required when production plugin runtime is enabled", () => {
|
||||
expect(
|
||||
isPluginCapabilityUseRequired({
|
||||
NODE_ENV: "production",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("fails closed when required capability-use persistence is explicitly disabled", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
|
||||
expect(() =>
|
||||
bootstrapPluginCapabilityUse(
|
||||
{
|
||||
NODE_ENV: "production",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
PLUGIN_CAPABILITY_USE_ENABLED: "false",
|
||||
},
|
||||
{ store: fakeStore(), createWorkerImpl }
|
||||
)
|
||||
).toThrow(PluginCapabilityUseRequiredBootstrapError);
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables when no store is wired", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginCapabilityUse({ WORKER_QUEUE_GROUP: "general" }, { createWorkerImpl } as PluginCapabilityUseBootstrapDeps);
|
||||
|
||||
expect(result).toEqual({ status: "disabled", reason: "no capability-use store wired" });
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails closed when required capability-use persistence has no store wired", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
|
||||
expect(() =>
|
||||
bootstrapPluginCapabilityUse(
|
||||
{
|
||||
NODE_ENV: "production",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
},
|
||||
{ createWorkerImpl } as PluginCapabilityUseBootstrapDeps
|
||||
)
|
||||
).toThrow("no capability-use store wired");
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("subscribes to the plugin capability-use outbox queue", async () => {
|
||||
const { createWorkerImpl, handle, calls } = fakeWorker();
|
||||
const result = bootstrapPluginCapabilityUse(
|
||||
{ WORKER_QUEUE_GROUP: "general", PLUGIN_CAPABILITY_USE_CONCURRENCY: "3" },
|
||||
{ store: fakeStore(), createWorkerImpl }
|
||||
);
|
||||
|
||||
expect(result.status).toBe("started");
|
||||
expect(result.status === "started" ? result.handle : null).toBe(handle);
|
||||
expect(createWorkerImpl).toHaveBeenCalledWith(PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE, expect.any(Function), expect.objectContaining({ concurrency: 3 }));
|
||||
|
||||
const processor = calls[0]?.[1];
|
||||
expect(processor).toBeDefined();
|
||||
if (!processor) throw new Error("missing processor");
|
||||
await expect(processor({ data: validJobData() })).resolves.toEqual({
|
||||
outcome: "persisted",
|
||||
inserted: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes capability-use batches by default to keep the audit hash-chain linear", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
bootstrapPluginCapabilityUse({ WORKER_QUEUE_GROUP: "general" }, { store: fakeStore(), createWorkerImpl });
|
||||
|
||||
expect(createWorkerImpl).toHaveBeenCalledWith(PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE, expect.any(Function), expect.objectContaining({ concurrency: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import { describePluginRuntimeProductionGate } from "@toolctrl/plugin-runtime";
|
||||
|
||||
import {
|
||||
PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE,
|
||||
handlePluginCapabilityUseJob,
|
||||
type PluginCapabilityUseLogger,
|
||||
type PluginCapabilityUseStore,
|
||||
} from "./plugin-capability-use.js";
|
||||
|
||||
export type PluginCapabilityUseBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
PLUGIN_CAPABILITY_USE_ENABLED?: string;
|
||||
PLUGIN_CAPABILITY_USE_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface PluginCapabilityUseWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseBootstrapDeps {
|
||||
readonly logger?: PluginCapabilityUseLogger;
|
||||
readonly store?: PluginCapabilityUseStore;
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => PluginCapabilityUseWorkerHandle;
|
||||
}
|
||||
|
||||
export type PluginCapabilityUseBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: PluginCapabilityUseWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 1;
|
||||
|
||||
export class PluginCapabilityUseRequiredBootstrapError extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(`Plugin capability-use persistence is required but disabled: ${reason}`);
|
||||
this.name = "PluginCapabilityUseRequiredBootstrapError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginCapabilityUseRequired(env: PluginCapabilityUseBootstrapEnv, workerGroup = env.WORKER_QUEUE_GROUP): boolean {
|
||||
return workerGroup === "general" && env.NODE_ENV === "production" && describePluginRuntimeProductionGate(env) === null;
|
||||
}
|
||||
|
||||
export function bootstrapPluginCapabilityUse(
|
||||
env: PluginCapabilityUseBootstrapEnv,
|
||||
deps: PluginCapabilityUseBootstrapDeps = {}
|
||||
): PluginCapabilityUseBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
const required = isPluginCapabilityUseRequired(env);
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.PLUGIN_CAPABILITY_USE_ENABLED === "false") {
|
||||
return disabledOrRequired("PLUGIN_CAPABILITY_USE_ENABLED=false", required);
|
||||
}
|
||||
if (!deps.store) {
|
||||
return disabledOrRequired("no capability-use store wired", required);
|
||||
}
|
||||
|
||||
const store = deps.store;
|
||||
const concurrency = parsePositiveInt(env.PLUGIN_CAPABILITY_USE_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
const processor = (job: { data: unknown }): Promise<unknown> => handlePluginCapabilityUseJob(job.data, { store, logger });
|
||||
const createWorkerImpl = deps.createWorkerImpl ?? defaultCreateWorker;
|
||||
const handle = createWorkerImpl(PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.({ component: "plugin-capability-use", queue: PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE, concurrency }, "plugin capability-use worker started");
|
||||
return { status: "started", handle };
|
||||
}
|
||||
|
||||
function disabledOrRequired(reason: string, required: boolean): { readonly status: "disabled"; readonly reason: string } {
|
||||
if (required) {
|
||||
throw new PluginCapabilityUseRequiredBootstrapError(reason);
|
||||
}
|
||||
return { status: "disabled", reason };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
const defaultCreateWorker: NonNullable<PluginCapabilityUseBootstrapDeps["createWorkerImpl"]> = () => {
|
||||
throw new Error("bootstrapPluginCapabilityUse requires deps.createWorkerImpl in this build; the worker entry must wire the BullMQ Worker constructor.");
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
PLUGIN_CAPABILITY_USE_EVENT_TYPE,
|
||||
PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE,
|
||||
createPrismaPluginCapabilityUseStore,
|
||||
handlePluginCapabilityUseJob,
|
||||
parsePluginCapabilityUseJob,
|
||||
PluginCapabilityUseJobError,
|
||||
type PluginCapabilityUseRow,
|
||||
type PluginCapabilityUseStorePrisma,
|
||||
type PluginCapabilityUseStore,
|
||||
} from "./plugin-capability-use.js";
|
||||
|
||||
function makeStore(): {
|
||||
store: PluginCapabilityUseStore;
|
||||
inserts: ReadonlyArray<PluginCapabilityUseRow>[];
|
||||
} {
|
||||
const inserts: ReadonlyArray<PluginCapabilityUseRow>[] = [];
|
||||
return {
|
||||
inserts,
|
||||
store: {
|
||||
async insertMany(rows) {
|
||||
inserts.push(rows);
|
||||
return { inserted: rows.length };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validBatch(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
outboxEventId: "evt-1",
|
||||
tenantId: "t1",
|
||||
eventType: PLUGIN_CAPABILITY_USE_EVENT_TYPE,
|
||||
payload: {
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
entries: [
|
||||
{
|
||||
id: "row-1",
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
capability: "http.outbound",
|
||||
outcome: "success",
|
||||
payloadHash: "a".repeat(64),
|
||||
durationMs: 12,
|
||||
timestamp: "2026-05-17T20:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
timestamp: "2026-05-17T20:00:00.100Z",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("queue / event-type constants", () => {
|
||||
test("exports stable queue + event-type names", () => {
|
||||
expect(PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE).toBe("outbox.plugin-capability-use");
|
||||
expect(PLUGIN_CAPABILITY_USE_EVENT_TYPE).toBe("plugin.capability-use.batch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginCapabilityUseJob", () => {
|
||||
test("parses a well-formed batch", () => {
|
||||
const parsed = parsePluginCapabilityUseJob(validBatch());
|
||||
expect(parsed.payload.entries).toHaveLength(2);
|
||||
expect(parsed.payload.pluginId).toBe("de.acme.slack");
|
||||
});
|
||||
|
||||
test("rejects missing payload", () => {
|
||||
expect(() => parsePluginCapabilityUseJob({})).toThrow(PluginCapabilityUseJobError);
|
||||
});
|
||||
|
||||
test("rejects empty entries array", () => {
|
||||
expect(() => parsePluginCapabilityUseJob(validBatch({ entries: [] }))).toThrow(/EMPTY_BATCH|no entries/);
|
||||
});
|
||||
|
||||
test("rejects entry missing required fields", () => {
|
||||
expect(() =>
|
||||
parsePluginCapabilityUseJob(
|
||||
validBatch({ entries: [{ capability: "log", outcome: "success" }] })
|
||||
)
|
||||
).toThrow(PluginCapabilityUseJobError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["tenantId", "other-tenant"],
|
||||
["pluginId", "de.acme.other"],
|
||||
["pluginInvocationId", "inv-other"],
|
||||
])("rejects entries whose %s differs from the batch envelope", (field, value) => {
|
||||
expect(() =>
|
||||
parsePluginCapabilityUseJob(
|
||||
validBatch({
|
||||
entries: [
|
||||
{
|
||||
id: "row-x",
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
timestamp: "2026-05-17T20:00:00.000Z",
|
||||
[field]: value,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toThrow(PluginCapabilityUseJobError);
|
||||
});
|
||||
|
||||
test("rejects an outbox envelope whose tenant differs from the payload tenant", () => {
|
||||
expect(() => parsePluginCapabilityUseJob({ ...validBatch(), tenantId: "other-tenant" })).toThrow(PluginCapabilityUseJobError);
|
||||
});
|
||||
|
||||
test("inherits invocation defaults when entry omits them", () => {
|
||||
const parsed = parsePluginCapabilityUseJob(
|
||||
validBatch({
|
||||
entries: [
|
||||
{
|
||||
id: "row-x",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
timestamp: "2026-05-17T20:00:00.000Z",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(parsed.payload.entries[0]?.tenantId).toBe("t1");
|
||||
expect(parsed.payload.entries[0]?.pluginId).toBe("de.acme.slack");
|
||||
expect(parsed.payload.entries[0]?.pluginInvocationId).toBe("inv-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePluginCapabilityUseJob", () => {
|
||||
test("persists every entry from a valid batch", async () => {
|
||||
const { store, inserts } = makeStore();
|
||||
const result = await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
expect(result.outcome).toBe("persisted");
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]).toHaveLength(2);
|
||||
expect(inserts[0]?.[0]?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("normalises undefined optional fields to null", async () => {
|
||||
const { store, inserts } = makeStore();
|
||||
await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
const row = inserts[0]?.[1];
|
||||
expect(row?.payloadHash).toBeNull();
|
||||
expect(row?.errorCode).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects garbage payloads without retry", async () => {
|
||||
const { store } = makeStore();
|
||||
const warns: Record<string, unknown>[] = [];
|
||||
const result = await handlePluginCapabilityUseJob({}, {
|
||||
store,
|
||||
logger: {
|
||||
warn: (record) => warns.push(record),
|
||||
error: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe("rejected");
|
||||
expect(result.reason).toBe("INVALID_PAYLOAD");
|
||||
expect(warns).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejects scoped-entry mismatches without inserting audit rows", async () => {
|
||||
const { store, inserts } = makeStore();
|
||||
const result = await handlePluginCapabilityUseJob(
|
||||
validBatch({
|
||||
entries: [
|
||||
{
|
||||
id: "row-x",
|
||||
pluginInvocationId: "inv-other",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
timestamp: "2026-05-17T20:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ store }
|
||||
);
|
||||
expect(result).toEqual({ outcome: "rejected", inserted: 0, reason: "INVALID_PAYLOAD" });
|
||||
expect(inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("propagates transient DB errors so dispatcher retries", async () => {
|
||||
const failingStore: PluginCapabilityUseStore = {
|
||||
async insertMany() {
|
||||
throw new Error("connection refused");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
handlePluginCapabilityUseJob(validBatch(), { store: failingStore })
|
||||
).rejects.toThrow(/connection refused/);
|
||||
});
|
||||
|
||||
test("treats duplicate receiver delivery as an idempotent zero-insert", async () => {
|
||||
const inserts: ReadonlyArray<PluginCapabilityUseRow>[] = [];
|
||||
const store: PluginCapabilityUseStore = {
|
||||
async insertMany(rows) {
|
||||
inserts.push(rows);
|
||||
return { inserted: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
|
||||
expect(result).toEqual({ outcome: "persisted", inserted: 0 });
|
||||
expect(inserts).toHaveLength(1);
|
||||
});
|
||||
|
||||
// F-003 — hash chain stamping.
|
||||
describe("hash chain stamping (F-003)", () => {
|
||||
test("stamps every inserted row with prevHash + rowHash", async () => {
|
||||
const { store, inserts } = makeStore();
|
||||
const result = await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
expect(result.outcome).toBe("persisted");
|
||||
const rows = inserts[0]!;
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const row of rows) {
|
||||
expect(row.rowHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
}
|
||||
// Within a batch each row's prevHash must equal the previous row's rowHash.
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
expect(rows[i]!.prevHash).toBe(rows[i - 1]!.rowHash);
|
||||
}
|
||||
// The genesis row's prevHash is null (no chain head supplied).
|
||||
expect(rows[0]!.prevHash).toBeNull();
|
||||
});
|
||||
|
||||
test("seeds prevHash from getChainHead when supplied", async () => {
|
||||
const { inserts } = makeStore();
|
||||
const fakeChainHead = "f".repeat(64);
|
||||
const store: PluginCapabilityUseStore = {
|
||||
async insertMany(rows) {
|
||||
inserts.push(rows);
|
||||
return { inserted: rows.length };
|
||||
},
|
||||
async getChainHead() {
|
||||
return fakeChainHead;
|
||||
},
|
||||
};
|
||||
await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
const rows = inserts[0]!;
|
||||
expect(rows[0]!.prevHash).toBe(fakeChainHead);
|
||||
});
|
||||
|
||||
test("falls back to null seed when getChainHead throws", async () => {
|
||||
const { inserts } = makeStore();
|
||||
const store: PluginCapabilityUseStore = {
|
||||
async insertMany(rows) {
|
||||
inserts.push(rows);
|
||||
return { inserted: rows.length };
|
||||
},
|
||||
async getChainHead() {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
};
|
||||
const result = await handlePluginCapabilityUseJob(validBatch(), { store });
|
||||
expect(result.outcome).toBe("persisted");
|
||||
expect(inserts[0]![0]!.prevHash).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPrismaPluginCapabilityUseStore", () => {
|
||||
test("uses createMany with skipDuplicates so outbox redelivery is idempotent", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const prisma: PluginCapabilityUseStorePrisma = {
|
||||
pluginCapabilityUse: {
|
||||
async createMany(args) {
|
||||
calls.push(args);
|
||||
return { count: args.data.length };
|
||||
},
|
||||
async findFirst() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = createPrismaPluginCapabilityUseStore(prisma);
|
||||
|
||||
const result = await store.insertMany([
|
||||
{
|
||||
id: "row-1",
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
payloadHash: null,
|
||||
errorCode: null,
|
||||
durationMs: null,
|
||||
timestamp: new Date("2026-05-17T20:00:00.000Z"),
|
||||
prevHash: null,
|
||||
rowHash: "a".repeat(64),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ inserted: 1 });
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
data: expect.arrayContaining([expect.objectContaining({ id: "row-1" })]),
|
||||
skipDuplicates: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reads the latest tenant chain head for cross-batch hash continuity", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const prisma: PluginCapabilityUseStorePrisma = {
|
||||
pluginCapabilityUse: {
|
||||
async createMany() {
|
||||
return { count: 0 };
|
||||
},
|
||||
async findFirst(args) {
|
||||
calls.push(args);
|
||||
return { rowHash: "f".repeat(64) };
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = createPrismaPluginCapabilityUseStore(prisma);
|
||||
|
||||
await expect(store.getChainHead?.("t1")).resolves.toBe("f".repeat(64));
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
where: { tenantId: "t1", rowHash: { not: null } },
|
||||
select: { rowHash: true },
|
||||
orderBy: [{ timestamp: "desc" }, { id: "desc" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Plugin capability-use batch insert — Phase 06 #23.
|
||||
*
|
||||
* Subscribes to the `outbox.plugin-capability-use` BullMQ queue
|
||||
* (populated by the outbox dispatcher in apps/worker/src/outbox/
|
||||
* dispatcher.ts) and writes each batch into the `PluginCapabilityUse`
|
||||
* table. Batches are bounded by `PluginCapabilityUseRecorder.maxEntries`
|
||||
* (default 1024) so a single `createMany` call is always safe.
|
||||
*
|
||||
* Failure mode: the handler returns a structured result instead of
|
||||
* throwing on payload validation issues so the dispatcher can dead-
|
||||
* letter without retrying garbage payloads. Transient DB errors
|
||||
* propagate so BullMQ retries with backoff.
|
||||
*/
|
||||
|
||||
export const PLUGIN_CAPABILITY_USE_OUTBOX_QUEUE = "outbox.plugin-capability-use" as const;
|
||||
export const PLUGIN_CAPABILITY_USE_EVENT_TYPE = "plugin.capability-use.batch" as const;
|
||||
|
||||
export interface PluginCapabilityUseLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseRow {
|
||||
readonly id: string;
|
||||
readonly pluginInvocationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
readonly capability: string;
|
||||
readonly outcome: string;
|
||||
readonly payloadHash: string | null;
|
||||
readonly errorCode: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly timestamp: Date;
|
||||
/** F-003 — hash chain. Null on rows persisted before F-003 landed. */
|
||||
readonly prevHash?: string | null;
|
||||
readonly rowHash?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseStore {
|
||||
/**
|
||||
* Persist rows idempotently. Production stores must ignore duplicate
|
||||
* primary keys so an outbox redelivery does not turn an already-written
|
||||
* audit batch into a retry loop.
|
||||
*/
|
||||
insertMany(rows: ReadonlyArray<PluginCapabilityUseRow>): Promise<{ inserted: number }>;
|
||||
/**
|
||||
* F-003 — return the `rowHash` of the most recent row for a tenant,
|
||||
* or null when this is the genesis batch (no prior hashed rows).
|
||||
* Production implementations point this at a SELECT…ORDER BY
|
||||
* timestamp DESC, id DESC LIMIT 1 WHERE rowHash IS NOT NULL.
|
||||
*
|
||||
* Optional for back-compat with stores written before F-003. When
|
||||
* omitted, the handler still computes hashes (genesis chain per
|
||||
* batch) but cannot detect tampering across batches — visible in
|
||||
* `verifyPluginCapabilityUseChain` as a `prev_mismatch` on every
|
||||
* batch boundary.
|
||||
*/
|
||||
getChainHead?: (tenantId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export type PluginCapabilityUseStorePrisma = {
|
||||
pluginCapabilityUse: {
|
||||
createMany(args: {
|
||||
data: ReadonlyArray<PluginCapabilityUseRow>;
|
||||
skipDuplicates: true;
|
||||
}): Promise<{ count: number }>;
|
||||
findFirst(args: {
|
||||
where: { tenantId: string; rowHash: { not: null } };
|
||||
select: { rowHash: true };
|
||||
orderBy: [{ timestamp: "desc" }, { id: "desc" }];
|
||||
}): Promise<{ rowHash: string | null } | null>;
|
||||
};
|
||||
};
|
||||
|
||||
export function createPrismaPluginCapabilityUseStore(
|
||||
prisma: PluginCapabilityUseStorePrisma
|
||||
): PluginCapabilityUseStore {
|
||||
return {
|
||||
async insertMany(rows) {
|
||||
const result = await prisma.pluginCapabilityUse.createMany({
|
||||
data: rows,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return { inserted: result.count };
|
||||
},
|
||||
async getChainHead(tenantId) {
|
||||
const row = await prisma.pluginCapabilityUse.findFirst({
|
||||
where: { tenantId, rowHash: { not: null } },
|
||||
select: { rowHash: true },
|
||||
orderBy: [{ timestamp: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return row?.rowHash ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly eventType: string;
|
||||
readonly payload: PluginCapabilityUseBatchPayload;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseBatchPayload {
|
||||
readonly pluginInvocationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
readonly entries: ReadonlyArray<PluginCapabilityUseEntryPayload>;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseEntryPayload {
|
||||
readonly id: string;
|
||||
readonly pluginInvocationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
readonly capability: string;
|
||||
readonly outcome: string;
|
||||
readonly payloadHash?: string;
|
||||
readonly errorCode?: string;
|
||||
readonly durationMs?: number;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export class PluginCapabilityUseJobError extends Error {
|
||||
override readonly name = "PluginCapabilityUseJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "EMPTY_BATCH";
|
||||
constructor(message: string, code: PluginCapabilityUseJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePluginCapabilityUseJob(data: unknown): PluginCapabilityUseJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new PluginCapabilityUseJobError("missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const payload = envelope.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new PluginCapabilityUseJobError("missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const inner = payload as Record<string, unknown>;
|
||||
const pluginInvocationId = typeof inner.pluginInvocationId === "string" ? inner.pluginInvocationId : "";
|
||||
const tenantId = typeof inner.tenantId === "string" ? inner.tenantId : "";
|
||||
const pluginId = typeof inner.pluginId === "string" ? inner.pluginId : "";
|
||||
if (pluginInvocationId.length === 0 || tenantId.length === 0 || pluginId.length === 0) {
|
||||
throw new PluginCapabilityUseJobError(
|
||||
"missing pluginInvocationId / tenantId / pluginId",
|
||||
"INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
if (typeof envelope.tenantId === "string" && envelope.tenantId !== tenantId) {
|
||||
throw new PluginCapabilityUseJobError("envelope tenantId does not match payload tenantId", "INVALID_PAYLOAD");
|
||||
}
|
||||
const rawEntries = inner.entries;
|
||||
if (!Array.isArray(rawEntries) || rawEntries.length === 0) {
|
||||
throw new PluginCapabilityUseJobError("batch has no entries", "EMPTY_BATCH");
|
||||
}
|
||||
const entries = rawEntries.map((entry, index) => normaliseEntry(entry, index, {
|
||||
pluginInvocationId,
|
||||
tenantId,
|
||||
pluginId,
|
||||
}));
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId: typeof envelope.tenantId === "string" ? envelope.tenantId : null,
|
||||
eventType: typeof envelope.eventType === "string" ? envelope.eventType : "",
|
||||
payload: {
|
||||
pluginInvocationId,
|
||||
tenantId,
|
||||
pluginId,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseEntry(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
defaults: { pluginInvocationId: string; tenantId: string; pluginId: string }
|
||||
): PluginCapabilityUseEntryPayload {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new PluginCapabilityUseJobError(`entry[${index}] is not an object`, "INVALID_PAYLOAD");
|
||||
}
|
||||
const e = raw as Record<string, unknown>;
|
||||
const id = typeof e.id === "string" ? e.id : "";
|
||||
const capability = typeof e.capability === "string" ? e.capability : "";
|
||||
const outcome = typeof e.outcome === "string" ? e.outcome : "";
|
||||
const timestamp = typeof e.timestamp === "string" ? e.timestamp : "";
|
||||
if (id.length === 0 || capability.length === 0 || outcome.length === 0 || timestamp.length === 0) {
|
||||
throw new PluginCapabilityUseJobError(
|
||||
`entry[${index}] missing id / capability / outcome / timestamp`,
|
||||
"INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
pluginInvocationId: normaliseScopedEntryValue(e.pluginInvocationId, defaults.pluginInvocationId, `entry[${index}].pluginInvocationId`),
|
||||
tenantId: normaliseScopedEntryValue(e.tenantId, defaults.tenantId, `entry[${index}].tenantId`),
|
||||
pluginId: normaliseScopedEntryValue(e.pluginId, defaults.pluginId, `entry[${index}].pluginId`),
|
||||
capability,
|
||||
outcome,
|
||||
payloadHash: typeof e.payloadHash === "string" ? e.payloadHash : undefined,
|
||||
errorCode: typeof e.errorCode === "string" ? e.errorCode : undefined,
|
||||
durationMs:
|
||||
typeof e.durationMs === "number" && Number.isFinite(e.durationMs) ? Math.max(0, e.durationMs) : undefined,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseScopedEntryValue(raw: unknown, expected: string, path: string): string {
|
||||
if (raw === undefined || raw === null) {
|
||||
return expected;
|
||||
}
|
||||
if (raw !== expected) {
|
||||
throw new PluginCapabilityUseJobError(`${path} does not match the batch envelope`, "INVALID_PAYLOAD");
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseHandlerDeps {
|
||||
readonly store: PluginCapabilityUseStore;
|
||||
readonly logger?: PluginCapabilityUseLogger;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseHandlerResult {
|
||||
readonly outcome: "persisted" | "rejected";
|
||||
readonly inserted: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one batch. Resolves with a structured outcome — the dispatcher
|
||||
* decides whether to ack (outcome=persisted/rejected) or retry (thrown
|
||||
* exception, e.g. transient DB error).
|
||||
*/
|
||||
export async function handlePluginCapabilityUseJob(
|
||||
data: unknown,
|
||||
deps: PluginCapabilityUseHandlerDeps
|
||||
): Promise<PluginCapabilityUseHandlerResult> {
|
||||
let parsed: PluginCapabilityUseJobData;
|
||||
try {
|
||||
parsed = parsePluginCapabilityUseJob(data);
|
||||
} catch (err) {
|
||||
if (err instanceof PluginCapabilityUseJobError) {
|
||||
deps.logger?.warn?.(
|
||||
{ code: err.code, message: err.message },
|
||||
"plugin capability-use job rejected"
|
||||
);
|
||||
return { outcome: "rejected", inserted: 0, reason: err.code };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const rawRows = parsed.payload.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
pluginInvocationId: entry.pluginInvocationId,
|
||||
tenantId: entry.tenantId,
|
||||
pluginId: entry.pluginId,
|
||||
capability: entry.capability,
|
||||
outcome: entry.outcome,
|
||||
payloadHash: entry.payloadHash ?? null,
|
||||
errorCode: entry.errorCode ?? null,
|
||||
durationMs: entry.durationMs ?? null,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
// F-003 — stamp every row with prevHash + rowHash. The chain is
|
||||
// per-tenant; the store optionally exposes getChainHead so the
|
||||
// chain survives across batches (otherwise each batch is a fresh
|
||||
// mini-chain — still useful for intra-batch integrity).
|
||||
const { stampPluginCapabilityUseChain } = await import("@toolctrl/plugin-runtime");
|
||||
const seedPrevHash = deps.store.getChainHead
|
||||
? await deps.store.getChainHead(parsed.payload.tenantId).catch(() => null)
|
||||
: null;
|
||||
const chainInput = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
pluginInvocationId: r.pluginInvocationId,
|
||||
tenantId: r.tenantId,
|
||||
pluginId: r.pluginId,
|
||||
capability: r.capability,
|
||||
payloadHash: r.payloadHash,
|
||||
outcome: r.outcome,
|
||||
errorCode: r.errorCode,
|
||||
durationMs: r.durationMs,
|
||||
timestamp: r.timestamp.toISOString(),
|
||||
}));
|
||||
const { stamped } = stampPluginCapabilityUseChain({ seedPrevHash, rows: chainInput });
|
||||
const rows: ReadonlyArray<PluginCapabilityUseRow> = rawRows.map((raw, i) => ({
|
||||
...raw,
|
||||
prevHash: stamped[i]?.prevHash ?? null,
|
||||
rowHash: stamped[i]?.rowHash ?? null,
|
||||
}));
|
||||
const result = await deps.store.insertMany(rows);
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
tenantId: parsed.payload.tenantId,
|
||||
pluginId: parsed.payload.pluginId,
|
||||
pluginInvocationId: parsed.payload.pluginInvocationId,
|
||||
inserted: result.inserted,
|
||||
},
|
||||
"plugin capability-use batch persisted"
|
||||
);
|
||||
return { outcome: "persisted", inserted: result.inserted };
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
bootstrapPluginHookFanout,
|
||||
isPluginHookFanoutRequired,
|
||||
PluginHookFanoutRequiredBootstrapError,
|
||||
} from "./plugin-hook-fanout-bootstrap.js";
|
||||
import { PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE } from "./plugin-hook-fanout.js";
|
||||
|
||||
type WorkerProcessor = (job: { data: unknown }) => Promise<unknown>;
|
||||
type WorkerOptions = { concurrency?: number };
|
||||
|
||||
function validManifest(hooks: Array<{ name: string; export: string }>) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
id: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
name: "Audit",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "a".repeat(64),
|
||||
signature: "b".repeat(128),
|
||||
signedBy: "c".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeWorker() {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const handle = { name: "fake-plugin-hook-fanout", close };
|
||||
const calls: Array<[string, WorkerProcessor, WorkerOptions]> = [];
|
||||
const createWorkerImpl = vi.fn((queueName: string, processor: WorkerProcessor, options: WorkerOptions) => {
|
||||
calls.push([queueName, processor, options]);
|
||||
return handle;
|
||||
});
|
||||
return { close, handle, createWorkerImpl, calls };
|
||||
}
|
||||
|
||||
describe("bootstrapPluginHookFanout", () => {
|
||||
test("disables on non-general worker groups", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginHookFanout({ WORKER_QUEUE_GROUP: "plugin" }, { createWorkerImpl });
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables when explicitly turned off", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginHookFanout({ WORKER_QUEUE_GROUP: "general", PLUGIN_HOOK_FANOUT_ENABLED: "false" }, { createWorkerImpl });
|
||||
expect(result.status).toBe("disabled");
|
||||
});
|
||||
|
||||
test("treats production plugin runtime as required even when the optional toggle is disabled", () => {
|
||||
expect(
|
||||
isPluginHookFanoutRequired(
|
||||
{
|
||||
NODE_ENV: "production",
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
BULLMQ_REDIS_URL: "redis://localhost:6379/0",
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
|
||||
PLUGIN_HOOK_FANOUT_ENABLED: "false",
|
||||
},
|
||||
"general"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("fails closed when required fanout is explicitly disabled", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
|
||||
expect(() =>
|
||||
bootstrapPluginHookFanout({ WORKER_QUEUE_GROUP: "general", PLUGIN_HOOK_FANOUT_REQUIRED: "true", PLUGIN_HOOK_FANOUT_ENABLED: "false" }, { createWorkerImpl })
|
||||
).toThrow(PluginHookFanoutRequiredBootstrapError);
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables in production until the plugin runtime rollout gate is enabled", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapPluginHookFanout({ NODE_ENV: "production", WORKER_QUEUE_GROUP: "general" }, { createWorkerImpl });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "disabled",
|
||||
reason: expect.stringContaining("PLUGIN_RUNTIME_PRODUCTION_ENABLED=true"),
|
||||
});
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails closed when required dependencies are not wired", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const env = { WORKER_QUEUE_GROUP: "general", PLUGIN_HOOK_FANOUT_REQUIRED: "true" };
|
||||
|
||||
expect(() => bootstrapPluginHookFanout(env, { createWorkerImpl })).toThrow("no prisma surface wired");
|
||||
expect(() =>
|
||||
bootstrapPluginHookFanout(env, {
|
||||
createWorkerImpl,
|
||||
prisma: { plugin: { findMany: vi.fn(async () => []) } },
|
||||
})
|
||||
).toThrow("no plugin invoke queue wired");
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("starts the outbox plugin-hook subscriber and targets plugin invoke queue", async () => {
|
||||
const { createWorkerImpl, handle, calls } = fakeWorker();
|
||||
const addCalls: Array<[string, unknown, Record<string, unknown>]> = [];
|
||||
const add = vi.fn(async (jobName: string, payload: unknown, options: Record<string, unknown>) => {
|
||||
addCalls.push([jobName, payload, options]);
|
||||
return undefined;
|
||||
});
|
||||
const prisma = {
|
||||
plugin: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const result = bootstrapPluginHookFanout(
|
||||
{ WORKER_QUEUE_GROUP: "general", PLUGIN_HOOK_FANOUT_CONCURRENCY: "3" },
|
||||
{
|
||||
createWorkerImpl,
|
||||
prisma,
|
||||
idempotency: {
|
||||
claim: vi.fn(async () => true),
|
||||
complete: vi.fn(async () => undefined),
|
||||
get: vi.fn(async () => null),
|
||||
},
|
||||
pluginInvokeQueue: { add } as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.status).toBe("started");
|
||||
expect(result.status === "started" ? result.handle : null).toBe(handle);
|
||||
expect(createWorkerImpl).toHaveBeenCalledWith(
|
||||
PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 3 })
|
||||
);
|
||||
|
||||
const processor = calls[0]?.[1];
|
||||
expect(processor).toBeDefined();
|
||||
if (!processor) throw new Error("missing processor");
|
||||
await processor({
|
||||
data: {
|
||||
outboxEventId: "evt_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "plugin.hook.tenant.created",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.test",
|
||||
createdAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
"invoke-plugin",
|
||||
expect.objectContaining({ kind: "plugin.invoke", pluginId: "com.acme.audit" }),
|
||||
expect.objectContaining({ jobId: "plugin-hook:evt_1:com.acme.audit:1.0.0" })
|
||||
);
|
||||
expect(prisma.plugin.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: "tenant_1", enabled: true },
|
||||
select: { pluginId: true, version: true, manifest: true, capabilityGrants: { select: { capabilityName: true } } },
|
||||
});
|
||||
expect(addCalls[0]?.[1]).toMatchObject({ kind: "plugin.invoke" });
|
||||
expect(addCalls[0]?.[2]).toMatchObject({ attempts: 3 });
|
||||
expect(addCalls[0]?.[1]).not.toMatchObject({ queueName: expect.any(String) });
|
||||
});
|
||||
|
||||
test("loads per-user plugin config when the hook payload has userId", async () => {
|
||||
const { createWorkerImpl, calls } = fakeWorker();
|
||||
const add = vi.fn(async () => undefined);
|
||||
const prisma = {
|
||||
plugin: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "user.created", export: "onUserCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:user.created" }],
|
||||
userConfigs: [{ enabled: false }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
bootstrapPluginHookFanout(
|
||||
{ WORKER_QUEUE_GROUP: "general" },
|
||||
{
|
||||
createWorkerImpl,
|
||||
prisma,
|
||||
pluginInvokeQueue: { add } as never,
|
||||
}
|
||||
);
|
||||
|
||||
const processor = calls[0]?.[1];
|
||||
expect(processor).toBeDefined();
|
||||
if (!processor) throw new Error("missing processor");
|
||||
await processor({
|
||||
data: {
|
||||
outboxEventId: "evt_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "plugin.hook.user.created",
|
||||
payload: {
|
||||
userId: "user_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "user@example.test",
|
||||
locale: "en",
|
||||
createdAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.plugin.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: "tenant_1", enabled: true },
|
||||
select: {
|
||||
pluginId: true,
|
||||
version: true,
|
||||
manifest: true,
|
||||
capabilityGrants: { select: { capabilityName: true } },
|
||||
userConfigs: { where: { userId: "user_1" }, select: { enabled: true } },
|
||||
},
|
||||
});
|
||||
expect(add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import type { OutboxReceiverIdempotencyStore } from "@toolctrl/db/outbox-receiver";
|
||||
import { buildPluginInvokeJob, enqueueJob, queueNames, type QueueEnqueueOptions, type TypedQueue } from "@toolctrl/queue";
|
||||
import { describePluginRuntimeProductionGate } from "@toolctrl/plugin-runtime";
|
||||
|
||||
import {
|
||||
PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE,
|
||||
runPluginHookFanoutJob,
|
||||
type PluginHookFanoutLogger,
|
||||
type PluginHookFanoutStore,
|
||||
} from "./plugin-hook-fanout.js";
|
||||
|
||||
export type PluginHookFanoutBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
PLUGIN_HOOK_FANOUT_ENABLED?: string;
|
||||
PLUGIN_HOOK_FANOUT_REQUIRED?: string;
|
||||
PLUGIN_HOOK_FANOUT_CONCURRENCY?: string;
|
||||
BULLMQ_REDIS_URL?: string;
|
||||
REDIS_URL?: string;
|
||||
};
|
||||
|
||||
export interface PluginHookFanoutPrismaSurface {
|
||||
plugin: {
|
||||
findMany(args: {
|
||||
where: { tenantId: string; enabled: true };
|
||||
select: {
|
||||
pluginId: true;
|
||||
version: true;
|
||||
manifest: true;
|
||||
capabilityGrants: { select: { capabilityName: true } };
|
||||
userConfigs?: { where: { userId: string }; select: { enabled: true } };
|
||||
};
|
||||
}): Promise<
|
||||
ReadonlyArray<{
|
||||
pluginId: string;
|
||||
version: string;
|
||||
manifest: unknown;
|
||||
capabilityGrants: ReadonlyArray<{ capabilityName: string }>;
|
||||
userConfigs?: ReadonlyArray<{ enabled: boolean }>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginHookFanoutWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginHookFanoutBootstrapDeps {
|
||||
readonly logger?: PluginHookFanoutLogger;
|
||||
readonly idempotency?: OutboxReceiverIdempotencyStore & { forget?(key: string): Promise<void> };
|
||||
readonly prisma?: PluginHookFanoutPrismaSurface;
|
||||
readonly pluginInvokeQueue?: TypedQueue<typeof queueNames.pluginInvoke>;
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => PluginHookFanoutWorkerHandle;
|
||||
}
|
||||
|
||||
export type PluginHookFanoutBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: PluginHookFanoutWorkerHandle };
|
||||
|
||||
export class PluginHookFanoutRequiredBootstrapError extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(`Plugin hook fanout is required but disabled: ${reason}`);
|
||||
this.name = "PluginHookFanoutRequiredBootstrapError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginHookFanoutRequired(env: PluginHookFanoutBootstrapEnv, workerGroup = env.WORKER_QUEUE_GROUP): boolean {
|
||||
if (workerGroup !== "general") {
|
||||
return false;
|
||||
}
|
||||
if (env.PLUGIN_HOOK_FANOUT_REQUIRED === "true") {
|
||||
return true;
|
||||
}
|
||||
const redisUrl = env.BULLMQ_REDIS_URL?.trim() || env.REDIS_URL?.trim();
|
||||
if (!redisUrl) {
|
||||
return false;
|
||||
}
|
||||
return describePluginRuntimeProductionGate(env) === null;
|
||||
}
|
||||
|
||||
export function bootstrapPluginHookFanout(
|
||||
env: PluginHookFanoutBootstrapEnv,
|
||||
deps: PluginHookFanoutBootstrapDeps = {}
|
||||
): PluginHookFanoutBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
const required = isPluginHookFanoutRequired(env);
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.PLUGIN_HOOK_FANOUT_ENABLED === "false") {
|
||||
return disabledOrRequired("PLUGIN_HOOK_FANOUT_ENABLED=false", required);
|
||||
}
|
||||
const productionGateReason = describePluginRuntimeProductionGate(env);
|
||||
if (productionGateReason) {
|
||||
return disabledOrRequired(productionGateReason, required);
|
||||
}
|
||||
if (!deps.prisma) {
|
||||
return disabledOrRequired("no prisma surface wired", required);
|
||||
}
|
||||
if (!deps.pluginInvokeQueue) {
|
||||
return disabledOrRequired("no plugin invoke queue wired", required);
|
||||
}
|
||||
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: ({ tenantId, userId }) =>
|
||||
deps.prisma!.plugin.findMany({
|
||||
where: { tenantId, enabled: true },
|
||||
select: {
|
||||
pluginId: true,
|
||||
version: true,
|
||||
manifest: true,
|
||||
capabilityGrants: { select: { capabilityName: true } },
|
||||
...(userId ? { userConfigs: { where: { userId }, select: { enabled: true } } } : {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
const pluginInvokeQueue = deps.pluginInvokeQueue;
|
||||
const concurrency = parsePositiveInt(env.PLUGIN_HOOK_FANOUT_CONCURRENCY, 5);
|
||||
const processor = (job: { data: unknown }): Promise<unknown> =>
|
||||
runPluginHookFanoutJob(job.data, {
|
||||
store,
|
||||
logger,
|
||||
idempotency: deps.idempotency,
|
||||
enqueuePluginInvoke: (
|
||||
descriptor: ReturnType<typeof buildPluginInvokeJob>,
|
||||
options: QueueEnqueueOptions
|
||||
) => enqueueJob(pluginInvokeQueue, descriptor, options),
|
||||
});
|
||||
|
||||
const createWorkerImpl = deps.createWorkerImpl ?? defaultCreateWorker;
|
||||
const handle = createWorkerImpl(PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.(
|
||||
{ component: "plugin-hook-fanout", queue: PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE, targetQueue: queueNames.pluginInvoke, concurrency },
|
||||
"plugin hook fanout worker started"
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
|
||||
function disabledOrRequired(reason: string, required: boolean): { readonly status: "disabled"; readonly reason: string } {
|
||||
if (required) {
|
||||
throw new PluginHookFanoutRequiredBootstrapError(reason);
|
||||
}
|
||||
return { status: "disabled", reason };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
const defaultCreateWorker: NonNullable<PluginHookFanoutBootstrapDeps["createWorkerImpl"]> = () => {
|
||||
throw new Error(
|
||||
"bootstrapPluginHookFanout requires deps.createWorkerImpl in this build; the worker entry must wire the BullMQ Worker constructor."
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,795 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
import type { OutboxReceiverIdempotencyStore } from "@toolctrl/db/outbox-receiver";
|
||||
import { registerHook } from "@toolctrl/plugin-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY,
|
||||
PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE,
|
||||
type PluginHookFanoutResult,
|
||||
parsePluginHookFanoutJob,
|
||||
PluginHookFanoutJobError,
|
||||
runPluginHookFanoutJob,
|
||||
type PluginHookFanoutDeps,
|
||||
type PluginHookFanoutStore,
|
||||
} from "./plugin-hook-fanout.js";
|
||||
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
function envelope(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "evt_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "plugin.hook.tenant.created",
|
||||
payload: { tenantId: "tenant_1", slug: "acme", ownerEmail: "owner@example.test", createdAt: "2026-05-26T20:00:00.000Z" },
|
||||
dispatchKey: "tenant:tenant_1:created",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function validManifest(hooks: Array<{ name: string; export: string }>) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
id: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
name: "Audit",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "a".repeat(64),
|
||||
signature: "b".repeat(128),
|
||||
signedBy: "c".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks,
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryIdempotencyStore(): OutboxReceiverIdempotencyStore & { forget(key: string): Promise<void> } {
|
||||
const pending = new Set<string>();
|
||||
const complete = new Map<string, unknown>();
|
||||
return {
|
||||
claim: vi.fn(async (key: string) => {
|
||||
if (pending.has(key) || complete.has(key)) return false;
|
||||
pending.add(key);
|
||||
return true;
|
||||
}),
|
||||
complete: vi.fn(async (key: string, result: unknown) => {
|
||||
pending.delete(key);
|
||||
complete.set(key, result);
|
||||
}),
|
||||
get: vi.fn(async (key: string) => {
|
||||
if (complete.has(key)) return { state: "complete" as const, result: complete.get(key) };
|
||||
if (pending.has(key)) return { state: "pending" as const };
|
||||
return null;
|
||||
}),
|
||||
forget: vi.fn(async (key: string) => {
|
||||
pending.delete(key);
|
||||
complete.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin hook fanout constants", () => {
|
||||
test("exports the stable outbox queue name", () => {
|
||||
expect(PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE).toBe("outbox.plugin-hook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginHookFanoutJob", () => {
|
||||
test("parses a plugin hook outbox envelope", () => {
|
||||
const parsed = parsePluginHookFanoutJob(envelope());
|
||||
expect(parsed.hookName).toBe("tenant.created");
|
||||
expect(parsed.tenantId).toBe("tenant_1");
|
||||
});
|
||||
|
||||
test("rejects non-plugin-hook event types", () => {
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ eventType: "mail.welcome.send" }))).toThrow(PluginHookFanoutJobError);
|
||||
});
|
||||
|
||||
test("rejects malformed plugin hook names", () => {
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ eventType: "plugin.hook.singleword" }))).toThrow(
|
||||
/invalid plugin hook name singleword/
|
||||
);
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ eventType: "plugin.hook.Tenant.created" }))).toThrow(
|
||||
/invalid plugin hook name Tenant\.created/
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects syntactically valid but unregistered plugin hook names", () => {
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ eventType: "plugin.hook.billing.paid" }))).toThrow(
|
||||
/unsupported plugin hook name billing\.paid/
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects registered hooks outside the production allowlist in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
registerHook({
|
||||
name: "billing.settled",
|
||||
outboxEventType: "plugin.hook.billing.settled",
|
||||
payloadSchema: z.object({ invoiceId: z.string() }),
|
||||
});
|
||||
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ eventType: "plugin.hook.billing.settled" }))).toThrow(
|
||||
/not production-available/
|
||||
);
|
||||
});
|
||||
|
||||
test("requires tenant scope", () => {
|
||||
expect(() => parsePluginHookFanoutJob(envelope({ tenantId: null }))).toThrow(/tenantId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPluginHookFanoutJob", () => {
|
||||
test("enqueues one plugin.invoke job per enabled subscriber declaring the hook", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.other",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "entity.created", export: "onEntityCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:entity.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuedPlugins: string[] = [];
|
||||
const enqueuePluginInvoke = vi.fn<PluginHookFanoutDeps["enqueuePluginInvoke"]>(async (descriptor) => {
|
||||
enqueuedPlugins.push(descriptor.payload.pluginId);
|
||||
});
|
||||
const result = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1, hookName: "tenant.created" });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueName: queueNames.pluginInvoke,
|
||||
jobName: "invoke-plugin",
|
||||
payload: expect.objectContaining({
|
||||
kind: "plugin.invoke",
|
||||
tenantId: "tenant_1",
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
hookName: "onTenantCreated",
|
||||
input: expect.objectContaining({ tenantId: "tenant_1", slug: "acme" }),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "plugin-hook:evt_1:com.acme.audit:1.0.0",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("invokes the declared WASM export while preserving the logical hook name", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn<PluginHookFanoutDeps["enqueuePluginInvoke"]>(async () => undefined);
|
||||
|
||||
await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
hookName: "onTenantCreated",
|
||||
metadata: expect.objectContaining({
|
||||
logicalHookName: "tenant.created",
|
||||
eventType: "plugin.hook.tenant.created",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("skips enabled hook subscribers when hook consent grant is missing", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.granted",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.ungranted",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:entity.created" }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.legacy",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn<PluginHookFanoutDeps["enqueuePluginInvoke"]>(async () => undefined);
|
||||
|
||||
const result = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1 });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
pluginId: "com.acme.granted",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("skips subscribers with invalid stored manifests", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.invalid",
|
||||
version: "1.0.0",
|
||||
manifest: { hooks: [{ name: "tenant.created", export: "onTenantCreated" }] },
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.valid",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn<PluginHookFanoutDeps["enqueuePluginInvoke"]>(async () => undefined);
|
||||
|
||||
const result = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1 });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
pluginId: "com.acme.valid",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("skips per-user disabled hook subscribers when payload has userId", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.user-enabled",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "user.created", export: "onUserCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:user.created" }],
|
||||
userConfigs: [{ enabled: true }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.user-disabled",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "user.created", export: "onUserCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:user.created" }],
|
||||
userConfigs: [{ enabled: false }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.tenant-default",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "user.created", export: "onUserCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:user.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuedPlugins: string[] = [];
|
||||
const enqueuePluginInvoke = vi.fn<PluginHookFanoutDeps["enqueuePluginInvoke"]>(async (descriptor) => {
|
||||
enqueuedPlugins.push(descriptor.payload.pluginId);
|
||||
});
|
||||
|
||||
const result = await runPluginHookFanoutJob(
|
||||
envelope({
|
||||
eventType: "plugin.hook.user.created",
|
||||
payload: {
|
||||
userId: "user_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "user@example.test",
|
||||
locale: "en",
|
||||
createdAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
{ store, enqueuePluginInvoke }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ matched: 2, enqueued: 2, hookName: "user.created" });
|
||||
expect(store.listEnabledPlugins).toHaveBeenCalledWith({ tenantId: "tenant_1", userId: "user_1" });
|
||||
expect(enqueuedPlugins).toEqual([
|
||||
"com.acme.user-enabled",
|
||||
"com.acme.tenant-default",
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses stable per-subscriber idempotency keys across partial enqueue retries", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
{
|
||||
pluginId: "com.acme.crm",
|
||||
version: "2.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "syncTenant" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async (_descriptor, options) => {
|
||||
if (options.idempotencyKey === "plugin-hook:evt_1:com.acme.crm:2.0.0") {
|
||||
throw new Error("plugin invoke queue unavailable");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke })).rejects.toThrow("plugin invoke queue unavailable");
|
||||
expect(enqueuePluginInvoke.mock.calls.map(([, options]) => options.idempotencyKey)).toEqual([
|
||||
"plugin-hook:evt_1:com.acme.audit:1.0.0",
|
||||
"plugin-hook:evt_1:com.acme.crm:2.0.0",
|
||||
]);
|
||||
|
||||
enqueuePluginInvoke.mockReset();
|
||||
enqueuePluginInvoke.mockResolvedValue(undefined);
|
||||
await expect(runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke })).resolves.toMatchObject({ matched: 2, enqueued: 2 });
|
||||
expect(enqueuePluginInvoke.mock.calls.map(([, options]) => options.idempotencyKey)).toEqual([
|
||||
"plugin-hook:evt_1:com.acme.audit:1.0.0",
|
||||
"plugin-hook:evt_1:com.acme.crm:2.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
test("enqueues subscribers with bounded concurrency", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () =>
|
||||
Array.from({ length: PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY + 3 }, (_, index) => ({
|
||||
pluginId: `com.acme.audit-${index}`,
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
}))
|
||||
),
|
||||
};
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const enqueuePluginInvoke = vi.fn(async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
const result = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
matched: PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY + 3,
|
||||
enqueued: PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY + 3,
|
||||
});
|
||||
expect(maxActive).toBeGreaterThan(1);
|
||||
expect(maxActive).toBeLessThanOrEqual(PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY);
|
||||
});
|
||||
|
||||
test("uses dispatchKey for plugin invoke idempotency when outboxEventId is absent", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
const result = await runPluginHookFanoutJob(envelope({ outboxEventId: undefined }), {
|
||||
store,
|
||||
enqueuePluginInvoke,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1 });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
eventType: "plugin.hook.tenant.created",
|
||||
dispatchKey: "tenant:tenant_1:created",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "plugin-hook:tenant:tenant_1:created:com.acme.audit:1.0.0",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("wraps outbox deliveries in receiver-side idempotency when a store is provided", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const idempotency = createMemoryIdempotencyStore();
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
const first = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke, idempotency });
|
||||
const second = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke, idempotency });
|
||||
|
||||
expect(first).toMatchObject({ outcome: "fanned_out", matched: 1, enqueued: 1 } satisfies Partial<PluginHookFanoutResult>);
|
||||
expect(second).toEqual(first);
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(idempotency.claim).toHaveBeenCalledWith("outbox:evt_1");
|
||||
expect(idempotency.complete).toHaveBeenCalledWith("outbox:evt_1", first);
|
||||
});
|
||||
|
||||
test("releases the fanout idempotency claim when enqueue fails so redelivery can retry", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const idempotency = createMemoryIdempotencyStore();
|
||||
const enqueuePluginInvoke = vi.fn(async (): Promise<void> => {
|
||||
throw new Error("plugin invoke queue unavailable");
|
||||
});
|
||||
|
||||
await expect(runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke, idempotency })).rejects.toThrow(
|
||||
"plugin invoke queue unavailable"
|
||||
);
|
||||
expect(idempotency.forget).toHaveBeenCalledWith("outbox:evt_1");
|
||||
|
||||
enqueuePluginInvoke.mockImplementation(async () => undefined);
|
||||
await expect(runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke, idempotency })).resolves.toMatchObject({
|
||||
outcome: "fanned_out",
|
||||
matched: 1,
|
||||
enqueued: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("fans out workflow.executed without leaking workflow input, output, or raw errors", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.workflow-audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "workflow.executed", export: "onWorkflowExecuted" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:workflow.executed" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runPluginHookFanoutJob(
|
||||
envelope({
|
||||
eventType: "plugin.hook.workflow.executed",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
{ store, enqueuePluginInvoke }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1, hookName: "workflow.executed" });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueName: queueNames.pluginInvoke,
|
||||
jobName: "invoke-plugin",
|
||||
payload: expect.objectContaining({
|
||||
kind: "plugin.invoke",
|
||||
tenantId: "tenant_1",
|
||||
pluginId: "com.acme.workflow-audit",
|
||||
version: "1.0.0",
|
||||
hookName: "onWorkflowExecuted",
|
||||
input: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
metadata: expect.objectContaining({
|
||||
logicalHookName: "workflow.executed",
|
||||
eventType: "plugin.hook.workflow.executed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "plugin-hook:evt_1:com.acme.workflow-audit:1.0.0",
|
||||
})
|
||||
);
|
||||
const firstCall = enqueuePluginInvoke.mock.calls.at(0) as Parameters<PluginHookFanoutDeps["enqueuePluginInvoke"]> | undefined;
|
||||
expect(firstCall).toBeDefined();
|
||||
const invokePayload = firstCall![0].payload;
|
||||
expect(invokePayload.input).not.toHaveProperty("input");
|
||||
expect(invokePayload.input).not.toHaveProperty("output");
|
||||
expect(invokePayload.input).not.toHaveProperty("error");
|
||||
});
|
||||
|
||||
test("fans out workflow.failed as failure-only metadata", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.workflow-failure",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "workflow.failed", export: "onWorkflowFailed" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:workflow.failed" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runPluginHookFanoutJob(
|
||||
envelope({
|
||||
eventType: "plugin.hook.workflow.failed",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: null,
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
{ store, enqueuePluginInvoke }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1, hookName: "workflow.failed" });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
pluginId: "com.acme.workflow-failure",
|
||||
hookName: "onWorkflowFailed",
|
||||
input: expect.objectContaining({
|
||||
status: "error",
|
||||
userId: null,
|
||||
errorCode: "workflow_execution_failed",
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
logicalHookName: "workflow.failed",
|
||||
eventType: "plugin.hook.workflow.failed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "plugin-hook:evt_1:com.acme.workflow-failure:1.0.0",
|
||||
})
|
||||
);
|
||||
const firstCall = enqueuePluginInvoke.mock.calls.at(0) as Parameters<PluginHookFanoutDeps["enqueuePluginInvoke"]> | undefined;
|
||||
expect(firstCall).toBeDefined();
|
||||
const invokePayload = firstCall![0].payload;
|
||||
expect(invokePayload.input).not.toHaveProperty("input");
|
||||
expect(invokePayload.input).not.toHaveProperty("output");
|
||||
expect(invokePayload.input).not.toHaveProperty("error");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
logicalHookName: "workflow.created",
|
||||
eventType: "plugin.hook.workflow.created",
|
||||
exportName: "onWorkflowCreated",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_1",
|
||||
status: "draft",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalHookName: "workflow.updated",
|
||||
eventType: "plugin.hook.workflow.updated",
|
||||
exportName: "onWorkflowUpdated",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_1",
|
||||
status: "live",
|
||||
appliesToAllTenants: false,
|
||||
changedFields: ["name", "status"],
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalHookName: "workflow.deleted",
|
||||
eventType: "plugin.hook.workflow.deleted",
|
||||
exportName: "onWorkflowDeleted",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_1",
|
||||
status: "archived",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
},
|
||||
])("fans out $logicalHookName workflow definition hooks as metadata-only plugin invokes", async ({ logicalHookName, eventType, exportName, payload }) => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.workflow-definition-audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: logicalHookName, export: exportName }]),
|
||||
capabilityGrants: [{ capabilityName: `hook:${logicalHookName}` }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runPluginHookFanoutJob(envelope({ eventType, payload }), { store, enqueuePluginInvoke });
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, enqueued: 1, hookName: logicalHookName });
|
||||
expect(enqueuePluginInvoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueName: queueNames.pluginInvoke,
|
||||
jobName: "invoke-plugin",
|
||||
payload: expect.objectContaining({
|
||||
kind: "plugin.invoke",
|
||||
tenantId: "tenant_1",
|
||||
pluginId: "com.acme.workflow-definition-audit",
|
||||
version: "1.0.0",
|
||||
hookName: exportName,
|
||||
input: payload,
|
||||
metadata: expect.objectContaining({
|
||||
logicalHookName,
|
||||
eventType,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "plugin-hook:evt_1:com.acme.workflow-definition-audit:1.0.0",
|
||||
})
|
||||
);
|
||||
const firstCall = enqueuePluginInvoke.mock.calls.at(0) as Parameters<PluginHookFanoutDeps["enqueuePluginInvoke"]> | undefined;
|
||||
expect(firstCall).toBeDefined();
|
||||
const invokePayload = firstCall![0].payload;
|
||||
expect(invokePayload.input).not.toHaveProperty("blocks");
|
||||
expect(invokePayload.input).not.toHaveProperty("credentials");
|
||||
expect(invokePayload.input).not.toHaveProperty("input");
|
||||
expect(invokePayload.input).not.toHaveProperty("output");
|
||||
expect(invokePayload.input).not.toHaveProperty("error");
|
||||
});
|
||||
|
||||
test("returns a zero fanout result when no plugin declares the hook", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.empty",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "entity.created", export: "onEntityCreated" }]),
|
||||
capabilityGrants: [],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
const result = await runPluginHookFanoutJob(envelope(), { store, enqueuePluginInvoke });
|
||||
expect(result).toMatchObject({ matched: 0, enqueued: 0 });
|
||||
expect(enqueuePluginInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("revalidates hook payloads before listing subscribers or enqueueing invokes", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "workflow.executed", export: "onWorkflowExecuted" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:workflow.executed" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
await expect(
|
||||
runPluginHookFanoutJob(
|
||||
envelope({
|
||||
eventType: "plugin.hook.workflow.executed",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "waitingBlock",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
{ store, enqueuePluginInvoke, logger }
|
||||
)
|
||||
).rejects.toMatchObject({ code: "INVALID_PAYLOAD" });
|
||||
|
||||
expect(store.listEnabledPlugins).not.toHaveBeenCalled();
|
||||
expect(enqueuePluginInvoke).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ component: "plugin-hook-fanout", hookName: "workflow.executed", tenantId: "tenant_1" }),
|
||||
"plugin hook fanout rejected invalid payload"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects payload tenant mismatches before listing subscribers or enqueueing invokes", async () => {
|
||||
const store: PluginHookFanoutStore = {
|
||||
listEnabledPlugins: vi.fn(async () => [
|
||||
{
|
||||
pluginId: "com.acme.audit",
|
||||
version: "1.0.0",
|
||||
manifest: validManifest([{ name: "tenant.created", export: "onTenantCreated" }]),
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created" }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const enqueuePluginInvoke = vi.fn(async () => undefined);
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
await expect(
|
||||
runPluginHookFanoutJob(
|
||||
envelope({
|
||||
tenantId: "tenant_1",
|
||||
payload: { tenantId: "tenant_2", slug: "acme", ownerEmail: "owner@example.test", createdAt: "2026-05-26T20:00:00.000Z" },
|
||||
}),
|
||||
{ store, enqueuePluginInvoke, logger }
|
||||
)
|
||||
).rejects.toMatchObject({ code: "INVALID_PAYLOAD" });
|
||||
|
||||
expect(store.listEnabledPlugins).not.toHaveBeenCalled();
|
||||
expect(enqueuePluginInvoke).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ component: "plugin-hook-fanout", hookName: "tenant.created", tenantId: "tenant_1", payloadTenantId: "tenant_2" }),
|
||||
"plugin hook fanout rejected tenant mismatch"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { buildPluginInvokeJob, type QueueEnqueueOptions } from "@toolctrl/queue";
|
||||
import { getPluginHook, isPluginHookProductionAvailable, parsePluginManifest, validatePluginHookPayload } from "@toolctrl/plugin-runtime";
|
||||
import { withOutboxIdempotency, type OutboxReceiverIdempotencyStore } from "@toolctrl/db/outbox-receiver";
|
||||
|
||||
export const PLUGIN_HOOK_FANOUT_OUTBOX_QUEUE = "outbox.plugin-hook" as const;
|
||||
export const PLUGIN_HOOK_EVENT_PREFIX = "plugin.hook." as const;
|
||||
export const PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY = 5;
|
||||
|
||||
export interface PluginHookFanoutLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export type PluginHookFanoutJobData = {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string;
|
||||
readonly eventType: `plugin.hook.${string}`;
|
||||
readonly hookName: string;
|
||||
readonly payload: unknown;
|
||||
readonly dispatchKey?: string | null;
|
||||
};
|
||||
|
||||
export type PluginHookSubscriber = {
|
||||
readonly pluginId: string;
|
||||
readonly version: string;
|
||||
readonly manifest: unknown;
|
||||
readonly capabilityGrants?: ReadonlyArray<{ readonly capabilityName: string }>;
|
||||
readonly userConfigs?: ReadonlyArray<{ readonly enabled: boolean }>;
|
||||
};
|
||||
|
||||
type PluginHookFanoutSubscriber = PluginHookSubscriber & {
|
||||
readonly hookExportName: string;
|
||||
};
|
||||
|
||||
export interface PluginHookFanoutStore {
|
||||
listEnabledPlugins(args: { tenantId: string; userId?: string }): Promise<ReadonlyArray<PluginHookSubscriber>>;
|
||||
}
|
||||
|
||||
export interface PluginHookFanoutDeps {
|
||||
readonly store: PluginHookFanoutStore;
|
||||
readonly enqueuePluginInvoke: (
|
||||
descriptor: ReturnType<typeof buildPluginInvokeJob>,
|
||||
options: QueueEnqueueOptions
|
||||
) => Promise<unknown>;
|
||||
readonly idempotency?: OutboxReceiverIdempotencyStore & { forget?(key: string): Promise<void> };
|
||||
readonly logger?: PluginHookFanoutLogger;
|
||||
}
|
||||
|
||||
export class PluginHookFanoutJobError extends Error {
|
||||
override readonly name = "PluginHookFanoutJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "UNSUPPORTED_EVENT";
|
||||
constructor(message: string, code: PluginHookFanoutJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginHookFanoutResult = {
|
||||
outcome: "fanned_out" | "skipped";
|
||||
hookName: string;
|
||||
tenantId: string;
|
||||
matched: number;
|
||||
enqueued: number;
|
||||
};
|
||||
|
||||
export function parsePluginHookFanoutJob(data: unknown): PluginHookFanoutJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new PluginHookFanoutJobError("plugin hook fanout job missing envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const eventType = typeof envelope.eventType === "string" ? envelope.eventType : "";
|
||||
if (!eventType.startsWith(PLUGIN_HOOK_EVENT_PREFIX)) {
|
||||
throw new PluginHookFanoutJobError(`unsupported plugin hook eventType ${eventType}`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
const hookName = eventType.slice(PLUGIN_HOOK_EVENT_PREFIX.length);
|
||||
if (!/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/u.test(hookName)) {
|
||||
throw new PluginHookFanoutJobError(`invalid plugin hook name ${hookName}`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
if (!getPluginHook(hookName)) {
|
||||
throw new PluginHookFanoutJobError(`unsupported plugin hook name ${hookName}`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
if (process.env.NODE_ENV === "production" && !isPluginHookProductionAvailable(hookName)) {
|
||||
throw new PluginHookFanoutJobError(`plugin hook name ${hookName} is not production-available`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
const tenantId = typeof envelope.tenantId === "string" ? envelope.tenantId : "";
|
||||
if (tenantId.length === 0) {
|
||||
throw new PluginHookFanoutJobError("plugin hook fanout requires tenantId", "INVALID_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId,
|
||||
eventType: eventType as `plugin.hook.${string}`,
|
||||
hookName,
|
||||
payload: envelope.payload,
|
||||
dispatchKey: typeof envelope.dispatchKey === "string" ? envelope.dispatchKey : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getManifestHookExportName(manifest: unknown, hookName: string): string | null {
|
||||
const parsed = parsePluginManifest(manifest);
|
||||
if (!parsed.ok) return null;
|
||||
return parsed.manifest.hooks.find((hook) => hook.name === hookName)?.export ?? null;
|
||||
}
|
||||
|
||||
function hasHookConsentGrant(plugin: PluginHookSubscriber, hookName: string): boolean {
|
||||
return plugin.capabilityGrants?.some((grant) => grant.capabilityName === `hook:${hookName}`) ?? false;
|
||||
}
|
||||
|
||||
function isEnabledForUser(plugin: PluginHookSubscriber): boolean {
|
||||
return plugin.userConfigs?.[0]?.enabled !== false;
|
||||
}
|
||||
|
||||
function getPayloadUserId(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const userId = (payload as { userId?: unknown }).userId;
|
||||
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
||||
}
|
||||
|
||||
function getPayloadTenantId(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const tenantId = (payload as { tenantId?: unknown }).tenantId;
|
||||
return typeof tenantId === "string" && tenantId.length > 0 ? tenantId : undefined;
|
||||
}
|
||||
|
||||
function invokeIdempotencyKey(job: PluginHookFanoutJobData, plugin: PluginHookSubscriber): string {
|
||||
const source = job.outboxEventId ?? job.dispatchKey ?? `${job.tenantId}:${job.eventType}`;
|
||||
return `plugin-hook:${source}:${plugin.pluginId}:${plugin.version}`;
|
||||
}
|
||||
|
||||
export async function runPluginHookFanoutJob(
|
||||
data: unknown,
|
||||
deps: PluginHookFanoutDeps
|
||||
): Promise<PluginHookFanoutResult> {
|
||||
const job = parsePluginHookFanoutJob(data);
|
||||
if (deps.idempotency && job.outboxEventId) {
|
||||
const outcome = await withOutboxIdempotency(deps.idempotency, job.outboxEventId, () => fanoutPluginHookJob(job, deps));
|
||||
return isPluginHookFanoutResult(outcome.result)
|
||||
? outcome.result
|
||||
: { outcome: "skipped", hookName: job.hookName, tenantId: job.tenantId, matched: 0, enqueued: 0 };
|
||||
}
|
||||
|
||||
return fanoutPluginHookJob(job, deps);
|
||||
}
|
||||
|
||||
async function fanoutPluginHookJob(job: PluginHookFanoutJobData, deps: PluginHookFanoutDeps): Promise<PluginHookFanoutResult> {
|
||||
const hook = getPluginHook(job.hookName);
|
||||
if (!hook) {
|
||||
throw new PluginHookFanoutJobError(`unsupported plugin hook name ${job.hookName}`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
if (process.env.NODE_ENV === "production" && !isPluginHookProductionAvailable(job.hookName)) {
|
||||
throw new PluginHookFanoutJobError(`plugin hook name ${job.hookName} is not production-available`, "UNSUPPORTED_EVENT");
|
||||
}
|
||||
const validation = validatePluginHookPayload(hook, job.payload);
|
||||
if (!validation.ok) {
|
||||
deps.logger?.warn?.(
|
||||
{ component: "plugin-hook-fanout", hookName: job.hookName, tenantId: job.tenantId, issues: validation.issues },
|
||||
"plugin hook fanout rejected invalid payload"
|
||||
);
|
||||
throw new PluginHookFanoutJobError(`invalid plugin hook payload for ${job.hookName}`, "INVALID_PAYLOAD");
|
||||
}
|
||||
const payloadTenantId = getPayloadTenantId(validation.payload);
|
||||
if (payloadTenantId !== job.tenantId) {
|
||||
deps.logger?.warn?.(
|
||||
{ component: "plugin-hook-fanout", hookName: job.hookName, tenantId: job.tenantId, payloadTenantId },
|
||||
"plugin hook fanout rejected tenant mismatch"
|
||||
);
|
||||
throw new PluginHookFanoutJobError(`plugin hook payload tenant mismatch for ${job.hookName}`, "INVALID_PAYLOAD");
|
||||
}
|
||||
const userId = getPayloadUserId(validation.payload);
|
||||
const candidates = await deps.store.listEnabledPlugins({ tenantId: job.tenantId, userId });
|
||||
const subscribers: PluginHookFanoutSubscriber[] = candidates.flatMap((plugin) => {
|
||||
const hookExportName = getManifestHookExportName(plugin.manifest, job.hookName);
|
||||
return hookExportName && hasHookConsentGrant(plugin, job.hookName) && isEnabledForUser(plugin) ? [{ ...plugin, hookExportName }] : [];
|
||||
});
|
||||
|
||||
let enqueued = 0;
|
||||
for (let index = 0; index < subscribers.length; index += PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY) {
|
||||
const batch = subscribers.slice(index, index + PLUGIN_HOOK_FANOUT_ENQUEUE_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((plugin) => {
|
||||
const descriptor = buildPluginInvokeJob({
|
||||
tenantId: job.tenantId,
|
||||
pluginId: plugin.pluginId,
|
||||
version: plugin.version,
|
||||
hookName: plugin.hookExportName,
|
||||
input: validation.payload,
|
||||
metadata: {
|
||||
outboxEventId: job.outboxEventId,
|
||||
eventType: job.eventType,
|
||||
dispatchKey: job.dispatchKey ?? undefined,
|
||||
logicalHookName: job.hookName,
|
||||
},
|
||||
});
|
||||
return deps.enqueuePluginInvoke(descriptor, {
|
||||
tenantId: job.tenantId,
|
||||
idempotencyKey: invokeIdempotencyKey(job, plugin),
|
||||
});
|
||||
})
|
||||
);
|
||||
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (failed) {
|
||||
throw failed.reason;
|
||||
}
|
||||
enqueued += results.length;
|
||||
}
|
||||
|
||||
deps.logger?.info?.(
|
||||
{ component: "plugin-hook-fanout", hookName: job.hookName, tenantId: job.tenantId, matched: subscribers.length, enqueued },
|
||||
"plugin hook fanout completed"
|
||||
);
|
||||
return { outcome: "fanned_out", hookName: job.hookName, tenantId: job.tenantId, matched: subscribers.length, enqueued };
|
||||
}
|
||||
|
||||
function isPluginHookFanoutResult(value: unknown): value is PluginHookFanoutResult {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const result = value as Partial<PluginHookFanoutResult>;
|
||||
return (
|
||||
(result.outcome === "fanned_out" || result.outcome === "skipped") &&
|
||||
typeof result.hookName === "string" &&
|
||||
typeof result.tenantId === "string" &&
|
||||
typeof result.matched === "number" &&
|
||||
typeof result.enqueued === "number"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* Phase 06 #21 / F-015 — bootstrapPluginInvokeDeps wiring tests.
|
||||
*
|
||||
* Verifies that the production composition wires Prisma, SharedStore,
|
||||
* env, and logger into a valid PluginInvokeDeps record. The deeper
|
||||
* end-to-end behaviour of `handlePluginInvokeJob` is covered by
|
||||
* `plugin-invoke.test.ts`; here we only assert the composition itself.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootstrapPluginInvokeDeps, type PluginInvokeBootstrapPrismaSurface } from "./plugin-invoke-bootstrap.js";
|
||||
|
||||
function makeFakeStore() {
|
||||
const counters = new Map<string, number>();
|
||||
const ttls = new Map<string, number>();
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
namespace: "test",
|
||||
ttlDefault: undefined,
|
||||
adapter: {} as never,
|
||||
key: (s: string) => `test:${s}`,
|
||||
incr: vi.fn(async (key: string, by = 1) => {
|
||||
const next = (counters.get(key) ?? 0) + by;
|
||||
counters.set(key, next);
|
||||
return next;
|
||||
}),
|
||||
expire: vi.fn(async (key: string, ttlSeconds: number) => {
|
||||
ttls.set(key, ttlSeconds);
|
||||
return true;
|
||||
}),
|
||||
get: vi.fn(async (key: string) => values.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
}),
|
||||
del: vi.fn(async (key: string) => (counters.delete(key) ? 1 : 0)),
|
||||
mget: vi.fn(async () => []),
|
||||
scan: vi.fn(async function* () {}),
|
||||
publish: vi.fn(async () => 0),
|
||||
subscribe: vi.fn(async () => async () => undefined),
|
||||
zAdd: vi.fn(async () => 0),
|
||||
zRemRangeByScore: vi.fn(async () => 0),
|
||||
zCard: vi.fn(async () => 0),
|
||||
eval: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
counters,
|
||||
ttls,
|
||||
};
|
||||
}
|
||||
|
||||
type FakePluginRow = {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
manifest: unknown;
|
||||
wasmSha256: string;
|
||||
signaturePublicKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function makeFakePrisma() {
|
||||
return {
|
||||
plugin: {
|
||||
findUnique: vi.fn<(args: unknown) => Promise<FakePluginRow | null>>(async () => null),
|
||||
update: vi.fn<(args: unknown) => Promise<unknown>>(async () => ({})),
|
||||
},
|
||||
outboxEvent: {
|
||||
create: vi.fn<(args: unknown) => Promise<unknown>>(async () => ({})),
|
||||
findUnique: vi.fn<(args: unknown) => Promise<unknown | null>>(async () => null),
|
||||
},
|
||||
pluginInvocation: {
|
||||
create: vi.fn<(args: unknown) => Promise<unknown>>(async () => ({})),
|
||||
},
|
||||
tenantPluginCapabilityGrant: {
|
||||
findMany: vi.fn<(args: unknown) => Promise<Array<{ capabilityName: string; scopeJson: unknown }>>>(async () => []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const noopLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const HOST_IMPORT_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x0a, 0x02, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x01, 0x7f,
|
||||
0x02, 0x30, 0x02,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x6c, 0x6f, 0x67, 0x00, 0x00,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x15, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x61, 0x75, 0x64, 0x69, 0x74, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x01,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x02,
|
||||
0x0a, 0x0e, 0x01, 0x0c, 0x00, 0x20, 0x00, 0x10, 0x00, 0x20, 0x00, 0x10, 0x01, 0x20,
|
||||
0x00, 0x0b,
|
||||
]);
|
||||
|
||||
const INFINITE_LOOP_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x00,
|
||||
0x0a, 0x09, 0x01, 0x07, 0x00, 0x03, 0x40, 0x0c, 0x00, 0x0b, 0x0b,
|
||||
]);
|
||||
|
||||
function manifestWithCapabilities(capabilities: Array<{ name: "log" | "audit" }>) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities,
|
||||
hooks: [{ name: "run", export: "run" }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapPluginInvokeDeps", () => {
|
||||
test("fails closed in production until the plugin runtime rollout gate is enabled", () => {
|
||||
expect(() =>
|
||||
bootstrapPluginInvokeDeps({
|
||||
env: { NODE_ENV: "production" },
|
||||
prisma: makeFakePrisma(),
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
})
|
||||
).toThrow("PLUGIN_RUNTIME_PRODUCTION_ENABLED=true");
|
||||
});
|
||||
|
||||
test("returns a record with every PluginInvokeDeps seam populated", () => {
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_TRUSTED_PUBLIC_KEYS: "aa".repeat(32) },
|
||||
prisma: makeFakePrisma(),
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(typeof deps.loadPlugin).toBe("function");
|
||||
expect(typeof deps.loadCapabilityGrants).toBe("function");
|
||||
expect(typeof deps.loadBinary).toBe("function");
|
||||
expect(typeof deps.verifySignature).toBe("function");
|
||||
expect(typeof deps.consumeRateLimit).toBe("function");
|
||||
expect(typeof deps.invokeInSandbox).toBe("function");
|
||||
expect(typeof deps.recordAudit).toBe("function");
|
||||
expect(typeof deps.recordOutcomeForCrashTracking).toBe("function");
|
||||
expect(Array.isArray(deps.trustedPublicKeysHex)).toBe(true);
|
||||
expect(deps.trustedPublicKeysHex).toEqual(["aa".repeat(32)]);
|
||||
expect(deps.logger).toBe(noopLogger);
|
||||
});
|
||||
|
||||
test("loadCapabilityGrants returns tenant plugin grants for runtime enforcement", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.tenantPluginCapabilityGrant.findMany.mockResolvedValue([
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } },
|
||||
]);
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
|
||||
await expect(deps.loadCapabilityGrants({ tenantId: "tenant-1", pluginId: "de.toolctrl.test" })).resolves.toEqual([
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } },
|
||||
]);
|
||||
expect(prisma.tenantPluginCapabilityGrant.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: "tenant-1", pluginId: "de.toolctrl.test" },
|
||||
select: { capabilityName: true, scopeJson: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("loadPlugin returns null when the row is missing", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
const row = await deps.loadPlugin({ tenantId: "t", pluginId: "p", version: "1.0.0" });
|
||||
expect(row).toBeNull();
|
||||
expect(prisma.plugin.findUnique).toHaveBeenCalledWith({
|
||||
where: { tenantId_pluginId: { tenantId: "t", pluginId: "p" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("loadPlugin returns null when the stored row's version does not match", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.plugin.findUnique = vi.fn(async () => ({
|
||||
tenantId: "t",
|
||||
pluginId: "p",
|
||||
version: "2.0.0",
|
||||
manifest: {},
|
||||
wasmSha256: "x",
|
||||
signaturePublicKey: "y",
|
||||
enabled: true,
|
||||
}));
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(await deps.loadPlugin({ tenantId: "t", pluginId: "p", version: "1.0.0" })).toBeNull();
|
||||
});
|
||||
|
||||
test("loadPlugin returns parsed rows only when the stored manifest is valid", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const manifest = manifestWithCapabilities([{ name: "log" }]);
|
||||
prisma.plugin.findUnique = vi.fn(async () => ({
|
||||
tenantId: "t",
|
||||
pluginId: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
manifest,
|
||||
wasmSha256: "0".repeat(64),
|
||||
signaturePublicKey: "0".repeat(64),
|
||||
enabled: true,
|
||||
}));
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
|
||||
await expect(deps.loadPlugin({ tenantId: "t", pluginId: "de.toolctrl.test", version: "1.0.0" })).resolves.toMatchObject({
|
||||
tenantId: "t",
|
||||
pluginId: "de.toolctrl.test",
|
||||
manifest,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("loadPlugin returns null and warns when the stored manifest is invalid", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.plugin.findUnique = vi.fn(async () => ({
|
||||
tenantId: "t",
|
||||
pluginId: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
manifest: { hooks: [{ name: "run", export: "run" }] },
|
||||
wasmSha256: "0".repeat(64),
|
||||
signaturePublicKey: "0".repeat(64),
|
||||
enabled: true,
|
||||
}));
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(deps.loadPlugin({ tenantId: "t", pluginId: "de.toolctrl.test", version: "1.0.0" })).resolves.toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId: "t",
|
||||
pluginId: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"plugin.invoke.stored_manifest_invalid"
|
||||
);
|
||||
});
|
||||
|
||||
test("loadBinary returns null on ENOENT instead of throwing", async () => {
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_BINARY_BASE_PATH: "/tmp/does-not-exist-for-tests" },
|
||||
prisma: makeFakePrisma(),
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
const buf = await deps.loadBinary({ pluginId: "ghost", version: "1.0.0" });
|
||||
expect(buf).toBeNull();
|
||||
});
|
||||
|
||||
test("loadBinary refuses plugin paths that escape the configured base directory", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_BINARY_BASE_PATH: "/tmp/toolctrl-plugins" },
|
||||
prisma: makeFakePrisma(),
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const buf = await deps.loadBinary({ pluginId: "../outside", version: "../../1.0.0" });
|
||||
|
||||
expect(buf).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ pluginId: "../outside", version: "../../1.0.0" },
|
||||
"plugin.invoke.binary_path_escape_blocked"
|
||||
);
|
||||
});
|
||||
|
||||
test("consumeRateLimit allows the first N calls then rejects", async () => {
|
||||
const store = makeFakeStore();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_RATE_LIMIT_PER_MINUTE: "3", PLUGIN_RATE_LIMIT_WINDOW_SECONDS: "60" },
|
||||
prisma: makeFakePrisma(),
|
||||
store: store as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
const first = await deps.consumeRateLimit({ tenantId: "t", pluginId: "p" });
|
||||
const second = await deps.consumeRateLimit({ tenantId: "t", pluginId: "p" });
|
||||
const third = await deps.consumeRateLimit({ tenantId: "t", pluginId: "p" });
|
||||
const fourth = await deps.consumeRateLimit({ tenantId: "t", pluginId: "p" });
|
||||
expect([first.allowed, second.allowed, third.allowed, fourth.allowed]).toEqual([true, true, true, false]);
|
||||
// First hit must seed the TTL so the window slides from the first hit.
|
||||
expect(store.expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("invokeInSandbox wires log and audit host imports through declared capabilities", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }, { name: "audit" as const }];
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, output: 7 });
|
||||
expect(prisma.outboxEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
eventType: "audit.event.create",
|
||||
dispatchKey: "plugin-host-audit:inv-1:0",
|
||||
priority: 20,
|
||||
payload: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
actorId: "de.toolctrl.test",
|
||||
actorType: "plugin",
|
||||
action: "plugin.audit.recorded",
|
||||
targetType: "plugin",
|
||||
targetId: "de.toolctrl.test",
|
||||
requestId: "inv-1",
|
||||
sourceModule: "worker.plugin-runtime",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
regulatoryScope: ["soc2"],
|
||||
metadata: expect.objectContaining({
|
||||
pluginId: "de.toolctrl.test",
|
||||
pluginManifestId: "de.toolctrl.test",
|
||||
pluginInvocationId: "inv-1",
|
||||
version: "1.0.0",
|
||||
hookName: "run",
|
||||
hostBinding: "audit",
|
||||
sequence: 0,
|
||||
value: { value: 7 },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(prisma.outboxEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
eventType: "plugin.capability-use.batch",
|
||||
dispatchKey: expect.stringMatching(/^plugin-capability-use:/),
|
||||
priority: 10,
|
||||
}),
|
||||
});
|
||||
const outboxArgs = prisma.outboxEvent.create.mock.calls
|
||||
.map((call) => call[0] as { data: { eventType: string; payload: { entries?: Array<{ capability: string; outcome: string }> } } })
|
||||
.find((call) => call.data.eventType === "plugin.capability-use.batch");
|
||||
expect(outboxArgs).toBeDefined();
|
||||
expect(outboxArgs?.data.payload.entries).toHaveLength(2);
|
||||
expect(outboxArgs?.data.payload.entries?.map((entry) => [entry.capability, entry.outcome])).toEqual([
|
||||
["log", "success"],
|
||||
["audit", "success"],
|
||||
]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "de.toolctrl.test",
|
||||
value: { value: 7 },
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"plugin.invoke.host.log"
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "de.toolctrl.test",
|
||||
value: { value: 7 },
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"plugin.invoke.host.audit"
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
pluginInvocationId: "inv-1",
|
||||
sequence: 0,
|
||||
}),
|
||||
"plugin.invoke.host.audit_enqueued"
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox fails closed when capability-use audit cannot be enqueued", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.outboxEvent.create.mockRejectedValueOnce(new Error("outbox down"));
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }];
|
||||
await expect(
|
||||
deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-audit-down",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
})
|
||||
).rejects.toThrow("outbox down");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginInvocationId: "inv-audit-down",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
entryCount: expect.any(Number),
|
||||
}),
|
||||
"plugin capability-use audit flush failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox fails closed when capability-use audit entries are truncated", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES: "1" },
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }, { name: "audit" as const }];
|
||||
await expect(
|
||||
deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-audit-truncated",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
})
|
||||
).rejects.toThrow("Plugin capability-use audit dropped 1 entries after maxEntries=1");
|
||||
expect(prisma.outboxEvent.create).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: "plugin.capability-use.batch",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox treats duplicate capability-use dispatch keys as already enqueued", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.outboxEvent.create
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
||||
prisma.outboxEvent.findUnique.mockResolvedValueOnce({
|
||||
id: "outbox-existing",
|
||||
dispatchKey: "plugin-capability-use:inv-duplicate",
|
||||
});
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }, { name: "audit" as const }];
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-duplicate",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, output: 7 });
|
||||
expect(prisma.outboxEvent.findUnique).toHaveBeenCalledWith({
|
||||
where: { dispatchKey: "plugin-capability-use:inv-duplicate" },
|
||||
});
|
||||
expect(logger.error).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginInvocationId: "inv-duplicate",
|
||||
}),
|
||||
"plugin capability-use audit flush failed"
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginInvocationId: "inv-duplicate",
|
||||
entryCount: 2,
|
||||
}),
|
||||
"plugin.invoke.capability_use_enqueued"
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox treats duplicate host-audit dispatch keys as already enqueued", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.outboxEvent.create.mockRejectedValueOnce(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
||||
prisma.outboxEvent.findUnique.mockResolvedValueOnce({
|
||||
id: "outbox-existing",
|
||||
dispatchKey: "plugin-host-audit:inv-host-audit-duplicate:0",
|
||||
});
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }, { name: "audit" as const }];
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-host-audit-duplicate",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, output: 7 });
|
||||
expect(prisma.outboxEvent.findUnique).toHaveBeenCalledWith({
|
||||
where: { dispatchKey: "plugin-host-audit:inv-host-audit-duplicate:0" },
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginInvocationId: "inv-host-audit-duplicate",
|
||||
sequence: 0,
|
||||
}),
|
||||
"plugin.invoke.host.audit_enqueued"
|
||||
);
|
||||
expect(prisma.outboxEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
eventType: "plugin.capability-use.batch",
|
||||
dispatchKey: "plugin-capability-use:inv-host-audit-duplicate",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("invokeInSandbox denies undeclared host imports through the gateway", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }];
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-2",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: capabilities.map((capability) => ({ capabilityName: capability.name, scopeJson: null })),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(prisma.outboxEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
eventType: "plugin.capability-use.batch",
|
||||
}),
|
||||
});
|
||||
const outboxArgs = prisma.outboxEvent.create.mock.calls[0]?.[0] as { data: { payload: { entries: Array<{ capability: string; outcome: string; errorCode?: string }> } } };
|
||||
expect(outboxArgs.data.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
capability: "audit",
|
||||
outcome: "denied",
|
||||
errorCode: "not_declared_in_manifest",
|
||||
}),
|
||||
]);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("trap");
|
||||
expect(result.message).toContain("capability denied: audit");
|
||||
}
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "audit",
|
||||
reason: "not_declared_in_manifest",
|
||||
}),
|
||||
"plugin.invoke.capability_denied"
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox denies manifest-declared host imports without tenant grants", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const capabilities = [{ name: "log" as const }, { name: "audit" as const }];
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-missing-grant",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithCapabilities(capabilities),
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
capabilityGrants: [{ capabilityName: "log", scopeJson: null }],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
const outboxArgs = prisma.outboxEvent.create.mock.calls[0]?.[0] as { data: { payload: { entries: Array<{ capability: string; outcome: string; errorCode?: string }> } } };
|
||||
expect(outboxArgs.data.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
capability: "log",
|
||||
outcome: "success",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
capability: "audit",
|
||||
outcome: "denied",
|
||||
errorCode: "not_granted_for_tenant",
|
||||
}),
|
||||
]);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("trap");
|
||||
expect(result.message).toContain("capability denied: audit");
|
||||
}
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "audit",
|
||||
reason: "not_granted_for_tenant",
|
||||
}),
|
||||
"plugin.invoke.capability_denied"
|
||||
);
|
||||
});
|
||||
|
||||
test("invokeInSandbox enforces PLUGIN_MAX_WALL_CLOCK_MS", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_MAX_WALL_CLOCK_MS: "50" },
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await deps.invokeInSandbox({
|
||||
pluginInvocationId: "inv-timeout",
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
wasmBuffer: INFINITE_LOOP_WASM,
|
||||
manifest: manifestWithCapabilities([]),
|
||||
hookName: "run",
|
||||
payload: undefined,
|
||||
capabilityGrants: [],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "timeout",
|
||||
message: "plugin sandbox wall-clock timeout",
|
||||
});
|
||||
expect(prisma.outboxEvent.create).not.toHaveBeenCalled();
|
||||
}, 5_000);
|
||||
|
||||
test("recordAudit success path logs at info level, failure at error", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
await deps.recordAudit({
|
||||
tenantId: "t",
|
||||
pluginId: "p",
|
||||
version: "1.0.0",
|
||||
hookName: "tenant.created",
|
||||
pluginInvocationId: "inv-success",
|
||||
outcome: { ok: true, output: 1, fuelUsed: 0, durationMs: 5, pluginInvocationId: "inv-success" },
|
||||
});
|
||||
expect(prisma.pluginInvocation.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
id: "inv-success",
|
||||
outcome: "success",
|
||||
failureReason: null,
|
||||
fuelUsed: 0,
|
||||
}),
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.any(Object), "plugin.invoke.audit.success");
|
||||
await deps.recordAudit({
|
||||
tenantId: "t",
|
||||
pluginId: "p",
|
||||
version: "1.0.0",
|
||||
hookName: "tenant.created",
|
||||
pluginInvocationId: "inv-failure",
|
||||
outcome: { ok: false, reason: "rate_limit_exceeded", durationMs: 1, pluginInvocationId: "inv-failure" },
|
||||
});
|
||||
expect(prisma.pluginInvocation.create).toHaveBeenLastCalledWith({
|
||||
data: expect.objectContaining({
|
||||
id: "inv-failure",
|
||||
outcome: "failure",
|
||||
failureReason: "rate_limit_exceeded",
|
||||
fuelUsed: null,
|
||||
}),
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), "plugin.invoke.audit.failure");
|
||||
});
|
||||
|
||||
test("recordOutcomeForCrashTracking records success vs failure on the tracker", async () => {
|
||||
const store = makeFakeStore();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: {},
|
||||
prisma: makeFakePrisma(),
|
||||
store: store as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "t",
|
||||
pluginId: "p",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-failure" },
|
||||
});
|
||||
expect(store.incr).toHaveBeenCalled();
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "t",
|
||||
pluginId: "p",
|
||||
outcome: { ok: true, output: undefined, fuelUsed: 0, durationMs: 0, pluginInvocationId: "inv-success" },
|
||||
});
|
||||
// Success decrement reads + writes the counter.
|
||||
expect(store.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("recordOutcomeForCrashTracking persists plugin auto-disable when the threshold is crossed", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const transaction = vi.fn<NonNullable<PluginInvokeBootstrapPrismaSurface["$transaction"]>>(async (fn) => fn(prisma));
|
||||
const prismaWithTransaction = { ...prisma, $transaction: transaction };
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_AUTO_DISABLE_THRESHOLD: "2" },
|
||||
prisma: prismaWithTransaction,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-1" },
|
||||
});
|
||||
expect(prisma.plugin.update).not.toHaveBeenCalled();
|
||||
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-2" },
|
||||
});
|
||||
|
||||
expect(prisma.plugin.update).toHaveBeenCalledWith({
|
||||
where: { tenantId_pluginId: { tenantId: "tenant-1", pluginId: "plugin-1" } },
|
||||
data: { enabled: false },
|
||||
});
|
||||
expect(prisma.outboxEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
eventType: "audit.event.create",
|
||||
dispatchKey: "plugin-auto-disable-audit:tenant-1:plugin-1:inv-2",
|
||||
priority: 20,
|
||||
payload: expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
actorType: "system",
|
||||
action: "plugin.auto_disabled",
|
||||
targetType: "plugin",
|
||||
targetId: "plugin-1",
|
||||
sourceModule: "worker.plugin-runtime",
|
||||
outcome: "blocked",
|
||||
metadata: expect.objectContaining({
|
||||
pluginId: "plugin-1",
|
||||
failureCount: 2,
|
||||
reason: "sandbox_failed",
|
||||
pluginInvocationId: "inv-2",
|
||||
trigger: "crash-threshold",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("recordOutcomeForCrashTracking treats duplicate auto-disable audit dispatch keys as already enqueued", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.outboxEvent.create.mockRejectedValueOnce(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
||||
prisma.outboxEvent.findUnique.mockResolvedValueOnce({
|
||||
id: "outbox-existing",
|
||||
dispatchKey: "plugin-auto-disable-audit:tenant-1:plugin-1:inv-1",
|
||||
});
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_AUTO_DISABLE_THRESHOLD: "1" },
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger,
|
||||
});
|
||||
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-1" },
|
||||
});
|
||||
|
||||
expect(prisma.plugin.update).toHaveBeenCalledWith({
|
||||
where: { tenantId_pluginId: { tenantId: "tenant-1", pluginId: "plugin-1" } },
|
||||
data: { enabled: false },
|
||||
});
|
||||
expect(prisma.outboxEvent.findUnique).toHaveBeenCalledWith({
|
||||
where: { dispatchKey: "plugin-auto-disable-audit:tenant-1:plugin-1:inv-1" },
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
failureCount: 1,
|
||||
reason: "sandbox_failed",
|
||||
}),
|
||||
"plugin.crash.auto_disable_audit_enqueued"
|
||||
);
|
||||
});
|
||||
|
||||
test("recordOutcomeForCrashTracking uses invocation-scoped auto-disable audit keys after re-enable", async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const deps = bootstrapPluginInvokeDeps({
|
||||
env: { PLUGIN_AUTO_DISABLE_THRESHOLD: "1" },
|
||||
prisma,
|
||||
store: makeFakeStore() as never,
|
||||
logger: noopLogger,
|
||||
});
|
||||
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-first-disable" },
|
||||
});
|
||||
await deps.recordOutcomeForCrashTracking!({
|
||||
tenantId: "tenant-1",
|
||||
pluginId: "plugin-1",
|
||||
outcome: { ok: false, reason: "sandbox_failed", durationMs: 0, pluginInvocationId: "inv-second-disable" },
|
||||
});
|
||||
|
||||
const dispatchKeys = prisma.outboxEvent.create.mock.calls.map((call) => {
|
||||
const args = call[0] as { data?: { dispatchKey?: string } };
|
||||
return args.data?.dispatchKey;
|
||||
});
|
||||
expect(dispatchKeys).toEqual([
|
||||
"plugin-auto-disable-audit:tenant-1:plugin-1:inv-first-disable",
|
||||
"plugin-auto-disable-audit:tenant-1:plugin-1:inv-second-disable",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* Phase 06 #21 — Production wiring for the plugin-invoke worker handler.
|
||||
*
|
||||
* `handlePluginInvokeJob` lives in `./plugin-invoke.ts` and is fully
|
||||
* dependency-injected. This module composes the real production seams:
|
||||
*
|
||||
* - `loadPlugin` → Prisma `Plugin.findUnique`
|
||||
* - `loadBinary` → filesystem read from `PLUGIN_BINARY_BASE_PATH`
|
||||
* - `verifySignature` → `verifyPluginSignature` from plugin-runtime
|
||||
* - `consumeRateLimit` → SharedStore sliding-window counter
|
||||
* - `invokeInSandbox` → worker-thread sandbox + capability gateway
|
||||
* - `recordAudit` → Prisma `PluginInvocation.create` + structured logger
|
||||
* - `trustedPublicKeysHex` → parsed once from `PLUGIN_TRUSTED_PUBLIC_KEYS`
|
||||
* - `recordOutcomeForCrashTracking` → `createCrashTracker` adapter
|
||||
*
|
||||
* Closes F-015 (Critical) — before this bootstrap, `handlePluginInvokeJob`
|
||||
* was not wired into production; the `plugin-invoke` queue accepted jobs
|
||||
* but the handler at `handlers.ts:752` was a silent no-op stub.
|
||||
*
|
||||
* Out of scope for this bootstrap (tracked in plugin-runtime deviations):
|
||||
* - host-fn implementations for higher-risk capabilities. The safe
|
||||
* `log` and `audit` imports are wired; network, storage, secret,
|
||||
* tenant.info and DB bindings remain fail-closed until reviewed.
|
||||
* - hot-reload of binaries / manifests
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
PluginCapabilityUseAuditTruncatedError,
|
||||
createCrashTracker,
|
||||
createPluginCapabilityUseRecorder,
|
||||
assertPluginRuntimeProductionEnabled,
|
||||
parsePluginManifest,
|
||||
parseTrustedPublicKeys,
|
||||
verifyPluginSignature,
|
||||
type CrashTrackerStore,
|
||||
type PluginCapabilityGrant,
|
||||
type PluginCapabilityUseBatch,
|
||||
type PluginInvocationResult,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
import { AUDIT_EVENT_OUTBOX_TYPE, buildAuditEventCreateData } from "@toolctrl/db/audit-events.server";
|
||||
import type { SharedStore } from "@toolctrl/shared-store";
|
||||
|
||||
import type {
|
||||
PluginInvokeDeps,
|
||||
PluginInvokeOutcome,
|
||||
PluginInvokeRow,
|
||||
RateLimitDecision,
|
||||
} from "./plugin-invoke.js";
|
||||
import { PLUGIN_CAPABILITY_USE_EVENT_TYPE } from "./plugin-capability-use.js";
|
||||
import { invokePluginSandboxInWorker } from "./plugin-sandbox-isolated.js";
|
||||
|
||||
export type PluginInvokeBootstrapLogger = {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg?: string) => void;
|
||||
error?: (fields: Record<string, unknown>, msg?: string) => void;
|
||||
};
|
||||
|
||||
export type PluginInvokeBootstrapPrismaSurface = {
|
||||
plugin: {
|
||||
findUnique: (args: {
|
||||
where: { tenantId_pluginId: { tenantId: string; pluginId: string } };
|
||||
}) => Promise<{
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
manifest: unknown;
|
||||
wasmSha256: string;
|
||||
signaturePublicKey: string;
|
||||
enabled: boolean;
|
||||
} | null>;
|
||||
update: (args: {
|
||||
where: { tenantId_pluginId: { tenantId: string; pluginId: string } };
|
||||
data: { enabled: boolean };
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
$transaction?: (fn: (tx: PluginInvokeBootstrapPrismaSurface) => Promise<unknown>) => Promise<unknown>;
|
||||
outboxEvent: {
|
||||
create: (args: {
|
||||
data: {
|
||||
tenantId: string | null;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
dispatchKey: string;
|
||||
priority?: number;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
findUnique: (args: {
|
||||
where: { dispatchKey: string };
|
||||
}) => Promise<unknown | null>;
|
||||
};
|
||||
pluginInvocation: {
|
||||
create: (args: {
|
||||
data: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
hookName: string;
|
||||
outcome: string;
|
||||
failureReason?: string | null;
|
||||
message?: string | null;
|
||||
durationMs: number;
|
||||
fuelUsed?: number | null;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
tenantPluginCapabilityGrant: {
|
||||
findMany: (args: {
|
||||
where: { tenantId: string; pluginId: string };
|
||||
select: { capabilityName: true; scopeJson: true };
|
||||
}) => Promise<Array<{ capabilityName: string; scopeJson: unknown }>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginInvokeBootstrapOptions = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
prisma: PluginInvokeBootstrapPrismaSurface;
|
||||
store: SharedStore;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
};
|
||||
|
||||
/** Default base path for plugin binaries on disk. */
|
||||
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
|
||||
const DEFAULT_RATE_LIMIT_PER_MINUTE = 100;
|
||||
const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60;
|
||||
const DEFAULT_PLUGIN_MEMORY_LIMIT_MB = 64;
|
||||
const DEFAULT_PLUGIN_FUEL_LIMIT = 1_000_000;
|
||||
const DEFAULT_PLUGIN_WALL_CLOCK_MS = 5_000;
|
||||
const DEFAULT_PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES = 2_048;
|
||||
const DEFAULT_PLUGIN_AUTO_DISABLE_THRESHOLD = 10;
|
||||
const DEFAULT_PLUGIN_AUTO_DISABLE_WINDOW_MS = 600_000;
|
||||
|
||||
function resolveIntEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
|
||||
const raw = env[key];
|
||||
if (typeof raw !== "string" || raw.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin pass-through from SharedStore (`incr`, `expire`, `get`, `set`,
|
||||
* `del`) to the CrashTracker's expected store contract. The shapes are
|
||||
* already aligned; the wrapper exists so callers can swap in a fake
|
||||
* store in tests without dragging SharedStore in.
|
||||
*/
|
||||
function adaptCrashTrackerStore(store: SharedStore): CrashTrackerStore {
|
||||
return {
|
||||
incr: (key, by = 1) => store.incr(key, by),
|
||||
expire: (key, ttlSeconds) => store.expire(key, ttlSeconds),
|
||||
get: (key) => store.get(key),
|
||||
set: async (key, value, opts) => store.set(key, value, opts?.ttlSeconds ? { ttlSeconds: opts.ttlSeconds } : undefined),
|
||||
del: (key) => store.del(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter keyed by `(pluginId, tenantId)`. Uses the
|
||||
* SharedStore counter primitive — atomic incr + ttl on first hit.
|
||||
*/
|
||||
function makeRateLimiter(args: {
|
||||
store: SharedStore;
|
||||
perMinute: number;
|
||||
windowSeconds: number;
|
||||
}): PluginInvokeDeps["consumeRateLimit"] {
|
||||
return async ({ pluginId, tenantId }): Promise<RateLimitDecision> => {
|
||||
const key = `plugin-ratelimit-${pluginId}-${tenantId}`;
|
||||
const count = await args.store.incr(key, 1);
|
||||
if (count === 1) {
|
||||
await args.store.expire(key, args.windowSeconds);
|
||||
}
|
||||
const allowed = count <= args.perMinute;
|
||||
return { allowed, remaining: Math.max(0, args.perMinute - count) };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem loader. Reads `<base>/<pluginId>/<version>/plugin.wasm`.
|
||||
* Returns null on ENOENT so the handler can route to `binary_load_failed`
|
||||
* cleanly; any other error propagates (BullMQ retries).
|
||||
*/
|
||||
function makeBinaryLoader(args: {
|
||||
basePath: string;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
}): PluginInvokeDeps["loadBinary"] {
|
||||
const basePath = path.resolve(args.basePath);
|
||||
return async ({ pluginId, version }) => {
|
||||
const filePath = path.resolve(basePath, pluginId, version, "plugin.wasm");
|
||||
const relativePath = path.relative(basePath, filePath);
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
args.logger.warn?.({ pluginId, version }, "plugin.invoke.binary_path_escape_blocked");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
args.logger.warn?.({ pluginId, version, filePath }, "plugin.invoke.binary_missing_on_disk");
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma adapter. Reads the Plugin row + JSON manifest. Returns null on
|
||||
* miss; the handler then short-circuits to `plugin_not_found`.
|
||||
*/
|
||||
function makePluginLoader(
|
||||
prisma: PluginInvokeBootstrapPrismaSurface,
|
||||
logger: PluginInvokeBootstrapLogger
|
||||
): PluginInvokeDeps["loadPlugin"] {
|
||||
return async ({ tenantId, pluginId, version }) => {
|
||||
const row = await prisma.plugin.findUnique({
|
||||
where: { tenantId_pluginId: { tenantId, pluginId } },
|
||||
});
|
||||
if (!row) return null;
|
||||
if (row.version !== version) return null;
|
||||
const parsed = parsePluginManifest(row.manifest);
|
||||
if (!parsed.ok) {
|
||||
logger.warn?.({ tenantId, pluginId, version, issues: parsed.issues }, "plugin.invoke.stored_manifest_invalid");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: row.tenantId,
|
||||
pluginId: row.pluginId,
|
||||
version: row.version,
|
||||
manifest: parsed.manifest,
|
||||
wasmSha256: row.wasmSha256,
|
||||
signaturePublicKey: row.signaturePublicKey,
|
||||
enabled: row.enabled,
|
||||
} satisfies PluginInvokeRow;
|
||||
};
|
||||
}
|
||||
|
||||
function makeCapabilityGrantLoader(
|
||||
prisma: PluginInvokeBootstrapPrismaSurface
|
||||
): PluginInvokeDeps["loadCapabilityGrants"] {
|
||||
return async ({ tenantId, pluginId }): Promise<PluginCapabilityGrant[]> => {
|
||||
const rows = await prisma.tenantPluginCapabilityGrant.findMany({
|
||||
where: { tenantId, pluginId },
|
||||
select: { capabilityName: true, scopeJson: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
capabilityName: row.capabilityName,
|
||||
scopeJson: row.scopeJson,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
function makeCapabilityUseSink(args: {
|
||||
prisma: PluginInvokeBootstrapPrismaSurface;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
}) {
|
||||
return {
|
||||
emit: async (batch: PluginCapabilityUseBatch): Promise<void> => {
|
||||
const dispatchKey = `plugin-capability-use:${batch.pluginInvocationId}`;
|
||||
try {
|
||||
await args.prisma.outboxEvent.create({
|
||||
data: {
|
||||
tenantId: batch.tenantId,
|
||||
eventType: PLUGIN_CAPABILITY_USE_EVENT_TYPE,
|
||||
payload: batch,
|
||||
dispatchKey,
|
||||
priority: 10,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintViolation(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = await args.prisma.outboxEvent.findUnique({
|
||||
where: { dispatchKey },
|
||||
});
|
||||
if (!existing) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
args.logger.info(
|
||||
{
|
||||
tenantId: batch.tenantId,
|
||||
pluginId: batch.pluginId,
|
||||
pluginInvocationId: batch.pluginInvocationId,
|
||||
entryCount: batch.entries.length,
|
||||
},
|
||||
"plugin.invoke.capability_use_enqueued"
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonSafeValue = JsonPrimitive | JsonSafeValue[] | { [key: string]: JsonSafeValue };
|
||||
|
||||
const MAX_AUDIT_METADATA_DEPTH = 8;
|
||||
|
||||
function toJsonSafeValue(value: unknown, depth = 0): JsonSafeValue {
|
||||
if (depth > MAX_AUDIT_METADATA_DEPTH) {
|
||||
return "[TRUNCATED]";
|
||||
}
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : String(value);
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => toJsonSafeValue(entry, depth + 1));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entry]) => typeof entry !== "undefined" && typeof entry !== "function" && typeof entry !== "symbol")
|
||||
.map(([key, entry]) => [key, toJsonSafeValue(entry, depth + 1)])
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async function enqueuePluginHostAuditEvent(args: {
|
||||
prisma: PluginInvokeBootstrapPrismaSurface;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
hookName: string;
|
||||
pluginInvocationId: string;
|
||||
sequence: number;
|
||||
hostRecord: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const payload = buildAuditEventCreateData({
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.pluginId,
|
||||
actorType: "plugin",
|
||||
action: "plugin.audit.recorded",
|
||||
targetType: "plugin",
|
||||
targetId: args.pluginId,
|
||||
requestId: args.pluginInvocationId,
|
||||
sourceModule: "worker.plugin-runtime",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
regulatoryScope: ["soc2"],
|
||||
metadata: {
|
||||
pluginId: args.pluginId,
|
||||
pluginManifestId: typeof args.hostRecord.pluginId === "string" ? args.hostRecord.pluginId : args.pluginId,
|
||||
pluginInvocationId: args.pluginInvocationId,
|
||||
version: args.version,
|
||||
hookName: args.hookName,
|
||||
hostBinding: "audit",
|
||||
sequence: args.sequence,
|
||||
value: toJsonSafeValue(args.hostRecord.value),
|
||||
},
|
||||
});
|
||||
const dispatchKey = `plugin-host-audit:${args.pluginInvocationId}:${args.sequence}`;
|
||||
try {
|
||||
await args.prisma.outboxEvent.create({
|
||||
data: {
|
||||
tenantId: args.tenantId,
|
||||
eventType: AUDIT_EVENT_OUTBOX_TYPE,
|
||||
payload,
|
||||
dispatchKey,
|
||||
priority: 20,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintViolation(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = await args.prisma.outboxEvent.findUnique({
|
||||
where: { dispatchKey },
|
||||
});
|
||||
if (!existing) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
args.logger.info(
|
||||
{
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
version: args.version,
|
||||
hookName: args.hookName,
|
||||
pluginInvocationId: args.pluginInvocationId,
|
||||
sequence: args.sequence,
|
||||
},
|
||||
"plugin.invoke.host.audit_enqueued"
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueuePluginAutoDisableAudit(args: {
|
||||
prisma: PluginInvokeBootstrapPrismaSurface;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
failureCount: number;
|
||||
reason: string;
|
||||
correlationId: string;
|
||||
}): Promise<void> {
|
||||
const payload = buildAuditEventCreateData({
|
||||
tenantId: args.tenantId,
|
||||
actorType: "system",
|
||||
action: "plugin.auto_disabled",
|
||||
targetType: "plugin",
|
||||
targetId: args.pluginId,
|
||||
sourceModule: "worker.plugin-runtime",
|
||||
outcome: "blocked",
|
||||
dataSensitivity: "internal",
|
||||
regulatoryScope: ["soc2"],
|
||||
metadata: {
|
||||
pluginId: args.pluginId,
|
||||
failureCount: args.failureCount,
|
||||
reason: args.reason,
|
||||
pluginInvocationId: args.correlationId,
|
||||
trigger: "crash-threshold",
|
||||
},
|
||||
});
|
||||
const dispatchKey = `plugin-auto-disable-audit:${args.tenantId}:${args.pluginId}:${args.correlationId}`;
|
||||
try {
|
||||
await args.prisma.outboxEvent.create({
|
||||
data: {
|
||||
tenantId: args.tenantId,
|
||||
eventType: AUDIT_EVENT_OUTBOX_TYPE,
|
||||
payload,
|
||||
dispatchKey,
|
||||
priority: 20,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintViolation(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = await args.prisma.outboxEvent.findUnique({
|
||||
where: { dispatchKey },
|
||||
});
|
||||
if (!existing) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
args.logger.info(
|
||||
{
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
failureCount: args.failureCount,
|
||||
reason: args.reason,
|
||||
},
|
||||
"plugin.crash.auto_disable_audit_enqueued"
|
||||
);
|
||||
}
|
||||
|
||||
function isUniqueConstraintViolation(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox adapter. Runs V8 WebAssembly in a killable worker thread and replays
|
||||
* safe sync `log` and `audit` host imports back into the parent process for
|
||||
* logging + capability-use auditing. Higher-risk host bindings remain absent
|
||||
* and fail closed with `no_host_implementation`.
|
||||
*/
|
||||
function makeSandboxInvoker(args: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
prisma: PluginInvokeBootstrapPrismaSurface;
|
||||
logger: PluginInvokeBootstrapLogger;
|
||||
capabilityUseSink: ReturnType<typeof makeCapabilityUseSink>;
|
||||
}): PluginInvokeDeps["invokeInSandbox"] {
|
||||
const memoryLimitMb = resolveIntEnv(args.env, "PLUGIN_MAX_MEMORY_MB", DEFAULT_PLUGIN_MEMORY_LIMIT_MB);
|
||||
const fuelLimit = resolveIntEnv(args.env, "PLUGIN_MAX_FUEL", DEFAULT_PLUGIN_FUEL_LIMIT);
|
||||
const wallClockMs = resolveIntEnv(args.env, "PLUGIN_MAX_WALL_CLOCK_MS", DEFAULT_PLUGIN_WALL_CLOCK_MS);
|
||||
const maxCapabilityCallEntries = resolveIntEnv(
|
||||
args.env,
|
||||
"PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES",
|
||||
DEFAULT_PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES
|
||||
);
|
||||
|
||||
return async ({ pluginInvocationId, tenantId, pluginId, wasmBuffer, manifest, capabilityGrants, hookName, payload }): Promise<PluginInvocationResult> => {
|
||||
const capabilityRecorder = createPluginCapabilityUseRecorder({
|
||||
pluginInvocationId,
|
||||
tenantId,
|
||||
pluginId,
|
||||
sink: args.capabilityUseSink,
|
||||
logger: args.logger,
|
||||
failClosed: true,
|
||||
});
|
||||
try {
|
||||
const result = await invokePluginSandboxInWorker({
|
||||
wasmBuffer,
|
||||
manifest,
|
||||
capabilityGrants,
|
||||
hookName,
|
||||
payload,
|
||||
memoryLimitMb,
|
||||
fuelLimit,
|
||||
maxCapabilityCallEntries,
|
||||
wallClockMs,
|
||||
});
|
||||
if (result.droppedCapabilityCallCount > 0) {
|
||||
throw new PluginCapabilityUseAuditTruncatedError(pluginInvocationId, result.droppedCapabilityCallCount, result.capabilityCalls.length);
|
||||
}
|
||||
for (const info of result.capabilityCalls) {
|
||||
capabilityRecorder.record(info);
|
||||
}
|
||||
let auditSequence = 0;
|
||||
for (const entry of result.hostLogs) {
|
||||
if (entry.level === "warn") {
|
||||
args.logger.warn?.(entry.record, entry.message);
|
||||
} else {
|
||||
args.logger.info(entry.record, entry.message);
|
||||
}
|
||||
if (entry.message === "plugin.invoke.host.audit") {
|
||||
await enqueuePluginHostAuditEvent({
|
||||
prisma: args.prisma,
|
||||
logger: args.logger,
|
||||
tenantId,
|
||||
pluginId,
|
||||
version: manifest.version,
|
||||
hookName,
|
||||
pluginInvocationId,
|
||||
sequence: auditSequence,
|
||||
hostRecord: entry.record,
|
||||
});
|
||||
auditSequence += 1;
|
||||
}
|
||||
}
|
||||
return result.result;
|
||||
} finally {
|
||||
await capabilityRecorder.flush();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the production PluginInvokeDeps from env + Prisma + SharedStore
|
||||
* + logger. Caller (`apps/worker/src/index.ts`) injects the returned
|
||||
* object into `extraHandlerDependencies.pluginInvokeDeps`.
|
||||
*/
|
||||
export function bootstrapPluginInvokeDeps(options: PluginInvokeBootstrapOptions): PluginInvokeDeps {
|
||||
const { env, prisma, store, logger } = options;
|
||||
assertPluginRuntimeProductionEnabled(env);
|
||||
const basePath = env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
|
||||
const perMinute = resolveIntEnv(env, "PLUGIN_RATE_LIMIT_PER_MINUTE", DEFAULT_RATE_LIMIT_PER_MINUTE);
|
||||
const windowSeconds = resolveIntEnv(env, "PLUGIN_RATE_LIMIT_WINDOW_SECONDS", DEFAULT_RATE_LIMIT_WINDOW_SECONDS);
|
||||
const autoDisableThreshold = resolveIntEnv(env, "PLUGIN_AUTO_DISABLE_THRESHOLD", DEFAULT_PLUGIN_AUTO_DISABLE_THRESHOLD);
|
||||
const autoDisableWindowMs = resolveIntEnv(env, "PLUGIN_AUTO_DISABLE_WINDOW_MS", DEFAULT_PLUGIN_AUTO_DISABLE_WINDOW_MS);
|
||||
const trustedPublicKeysHex = parseTrustedPublicKeys(env.PLUGIN_TRUSTED_PUBLIC_KEYS);
|
||||
const capabilityUseSink = makeCapabilityUseSink({ prisma, logger });
|
||||
const crashTracker = createCrashTracker({
|
||||
store: adaptCrashTrackerStore(store),
|
||||
disablePlugin: async ({ pluginId, tenantId, failureCount, reason, correlationId }) => {
|
||||
logger.warn?.({ pluginId, tenantId, failureCount, reason }, "plugin.crash.auto_disable_requested");
|
||||
const disableAndAudit = async (client: PluginInvokeBootstrapPrismaSurface) => {
|
||||
await client.plugin.update({
|
||||
where: { tenantId_pluginId: { tenantId, pluginId } },
|
||||
data: { enabled: false },
|
||||
});
|
||||
await enqueuePluginAutoDisableAudit({
|
||||
prisma: client,
|
||||
logger,
|
||||
tenantId,
|
||||
pluginId,
|
||||
failureCount,
|
||||
reason,
|
||||
correlationId,
|
||||
});
|
||||
};
|
||||
if (typeof prisma.$transaction === "function") {
|
||||
await prisma.$transaction(disableAndAudit);
|
||||
} else {
|
||||
await disableAndAudit(prisma);
|
||||
}
|
||||
},
|
||||
notify: async ({ pluginId, tenantId, failureCount, reason }) => {
|
||||
logger.error?.({ pluginId, tenantId, failureCount, reason }, "plugin.crash.threshold_exceeded");
|
||||
},
|
||||
options: {
|
||||
logger,
|
||||
threshold: autoDisableThreshold,
|
||||
windowMs: autoDisableWindowMs,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
loadPlugin: makePluginLoader(prisma, logger),
|
||||
loadCapabilityGrants: makeCapabilityGrantLoader(prisma),
|
||||
loadBinary: makeBinaryLoader({ basePath, logger }),
|
||||
verifySignature: ({ wasmBuffer, manifest, trustedPublicKeysHex: trusted }) =>
|
||||
verifyPluginSignature({
|
||||
wasmBuffer,
|
||||
expectedWasmSha256: manifest.wasmSha256,
|
||||
signatureHex: manifest.signature,
|
||||
publicKeyHex: manifest.signedBy,
|
||||
trustedPublicKeysHex: trusted,
|
||||
manifest,
|
||||
}),
|
||||
consumeRateLimit: makeRateLimiter({ store, perMinute, windowSeconds }),
|
||||
invokeInSandbox: makeSandboxInvoker({ env, prisma, logger, capabilityUseSink }),
|
||||
recordAudit: async ({ tenantId, pluginId, version, hookName, pluginInvocationId, outcome }) => {
|
||||
await prisma.pluginInvocation.create({
|
||||
data: {
|
||||
id: pluginInvocationId,
|
||||
tenantId,
|
||||
pluginId,
|
||||
version,
|
||||
hookName,
|
||||
outcome: outcome.ok ? "success" : "failure",
|
||||
failureReason: outcome.ok ? null : outcome.reason,
|
||||
message: outcome.ok ? null : outcome.message ?? null,
|
||||
durationMs: outcome.durationMs,
|
||||
fuelUsed: outcome.ok ? outcome.fuelUsed : null,
|
||||
},
|
||||
});
|
||||
if (outcome.ok) {
|
||||
logger.info(
|
||||
{ tenantId, pluginId, version, hookName, pluginInvocationId, fuelUsed: outcome.fuelUsed, durationMs: outcome.durationMs },
|
||||
"plugin.invoke.audit.success"
|
||||
);
|
||||
} else {
|
||||
logger.error?.(
|
||||
{ tenantId, pluginId, version, hookName, pluginInvocationId, reason: outcome.reason, message: outcome.message, durationMs: outcome.durationMs },
|
||||
"plugin.invoke.audit.failure"
|
||||
);
|
||||
}
|
||||
},
|
||||
trustedPublicKeysHex,
|
||||
recordOutcomeForCrashTracking: async ({ tenantId, pluginId, outcome }) => {
|
||||
if (outcome.ok) {
|
||||
await crashTracker.recordSuccess({ pluginId, tenantId });
|
||||
} else {
|
||||
await crashTracker.recordFailure({
|
||||
pluginId,
|
||||
tenantId,
|
||||
reason: outcome.reason,
|
||||
correlationId: outcome.pluginInvocationId,
|
||||
});
|
||||
}
|
||||
},
|
||||
logger,
|
||||
} satisfies PluginInvokeDeps;
|
||||
}
|
||||
|
||||
export type { PluginInvokeOutcome };
|
||||
@@ -0,0 +1,425 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { PluginCapabilityGrant, PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
import { handlePluginInvokeJob, type PluginInvokeDeps, type PluginInvokeJobPayload, type PluginInvokeRow } from "./plugin-invoke.js";
|
||||
|
||||
function manifest(): PluginManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [{ name: "log" }],
|
||||
hooks: [{ name: "ping", export: "ping" }],
|
||||
};
|
||||
}
|
||||
|
||||
function hookOnlyManifest(): PluginManifest {
|
||||
return {
|
||||
...manifest(),
|
||||
capabilities: [],
|
||||
hooks: [{ name: "tenant.created", export: "onTenantCreated" }],
|
||||
};
|
||||
}
|
||||
|
||||
function outboundManifest(): PluginManifest {
|
||||
return {
|
||||
...manifest(),
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }],
|
||||
};
|
||||
}
|
||||
|
||||
function row(): PluginInvokeRow {
|
||||
return {
|
||||
tenantId: "t-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
manifest: manifest(),
|
||||
wasmSha256: "0".repeat(64),
|
||||
signaturePublicKey: "0".repeat(64),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function grants(): PluginCapabilityGrant[] {
|
||||
return [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "hook:ping", scopeJson: null },
|
||||
];
|
||||
}
|
||||
|
||||
function payload(): PluginInvokeJobPayload {
|
||||
return {
|
||||
tenantId: "t-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
hookName: "ping",
|
||||
input: { x: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultDeps(overrides: Partial<PluginInvokeDeps> = {}): PluginInvokeDeps {
|
||||
return {
|
||||
loadPlugin: vi.fn().mockResolvedValue(row()),
|
||||
loadCapabilityGrants: vi.fn().mockResolvedValue(grants()),
|
||||
loadBinary: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
|
||||
verifySignature: vi.fn().mockReturnValue({ ok: true }),
|
||||
consumeRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 99 }),
|
||||
invokeInSandbox: vi.fn().mockResolvedValue({ ok: true, output: "pong", fuelUsed: 5 }),
|
||||
recordAudit: vi.fn().mockResolvedValue(undefined),
|
||||
trustedPublicKeysHex: ["0".repeat(64)],
|
||||
now: (() => {
|
||||
let n = 1_000;
|
||||
return () => (n += 10);
|
||||
})(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handlePluginInvokeJob", () => {
|
||||
test("happy path returns the sandbox output and records audit", async () => {
|
||||
const deps = defaultDeps();
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.pluginInvocationId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
if (result.ok) expect(result.output).toBe("pong");
|
||||
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
||||
expect(deps.invokeInSandbox).toHaveBeenCalledTimes(1);
|
||||
expect(deps.loadCapabilityGrants).toHaveBeenCalledWith({
|
||||
tenantId: "t-1",
|
||||
pluginId: "de.toolctrl.test",
|
||||
});
|
||||
const sandboxArgs = vi.mocked(deps.invokeInSandbox).mock.calls[0]?.[0];
|
||||
const auditArgs = vi.mocked(deps.recordAudit).mock.calls[0]?.[0];
|
||||
expect(sandboxArgs?.pluginInvocationId).toBe(result.pluginInvocationId);
|
||||
expect(sandboxArgs?.capabilityGrants).toEqual([
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "hook:ping", scopeJson: null },
|
||||
]);
|
||||
expect(auditArgs?.pluginInvocationId).toBe(result.pluginInvocationId);
|
||||
expect(auditArgs?.outcome.pluginInvocationId).toBe(result.pluginInvocationId);
|
||||
});
|
||||
|
||||
test("returns plugin_not_found when the row is missing", async () => {
|
||||
const deps = defaultDeps({ loadPlugin: vi.fn().mockResolvedValue(null) });
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("plugin_not_found");
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns plugin_disabled when the row is disabled", async () => {
|
||||
const deps = defaultDeps({ loadPlugin: vi.fn().mockResolvedValue({ ...row(), enabled: false }) });
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("plugin_disabled");
|
||||
expect(deps.loadCapabilityGrants).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects queue hook names that are not declared as manifest exports", async () => {
|
||||
const deps = defaultDeps();
|
||||
const result = await handlePluginInvokeJob({ ...payload(), hookName: "rogueExport" }, deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("hook_not_declared");
|
||||
expect(result.message).toBe('Hook export "rogueExport" is not declared by plugin manifest.');
|
||||
}
|
||||
expect(deps.loadCapabilityGrants).not.toHaveBeenCalled();
|
||||
expect(deps.loadBinary).not.toHaveBeenCalled();
|
||||
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("allows manifest hook exports even when logical hook names differ", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({
|
||||
...row(),
|
||||
manifest: { ...manifest(), hooks: [{ name: "tenant.created", export: "onTenantCreated" }] },
|
||||
}),
|
||||
loadCapabilityGrants: vi.fn().mockResolvedValue([
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "hook:tenant.created", scopeJson: null },
|
||||
]),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(
|
||||
{
|
||||
...payload(),
|
||||
hookName: "onTenantCreated",
|
||||
metadata: { logicalHookName: "tenant.created" },
|
||||
input: { tenantId: "t-1", slug: "acme", ownerEmail: "owner@example.com", createdAt: "2026-05-28T10:00:00.000Z" },
|
||||
},
|
||||
deps
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(deps.invokeInSandbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hookName: "onTenantCreated",
|
||||
payload: expect.objectContaining({ tenantId: "t-1", slug: "acme" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns capability_grant_missing before loading the binary", async () => {
|
||||
const deps = defaultDeps({ loadCapabilityGrants: vi.fn().mockResolvedValue([]) });
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("capability_grant_missing");
|
||||
expect(result.message).toContain("log");
|
||||
}
|
||||
expect(deps.loadBinary).not.toHaveBeenCalled();
|
||||
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("treats stale grant scopes as missing consent", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: outboundManifest() }),
|
||||
loadCapabilityGrants: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }]),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("capability_grant_missing");
|
||||
expect(result.message).toContain("http.outbound");
|
||||
}
|
||||
expect(deps.loadBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires hook grants even when the manifest declares no capabilities", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
||||
loadCapabilityGrants: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const result = await handlePluginInvokeJob({ ...payload(), hookName: "onTenantCreated", metadata: { logicalHookName: "tenant.created" } }, deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("capability_grant_missing");
|
||||
expect(result.message).toContain("hook:tenant.created");
|
||||
}
|
||||
expect(deps.loadCapabilityGrants).toHaveBeenCalledWith({ tenantId: "t-1", pluginId: "de.toolctrl.test" });
|
||||
expect(deps.loadBinary).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("runs hook-only plugins when the logical hook grant is present", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
||||
loadCapabilityGrants: vi.fn().mockResolvedValue([{ capabilityName: "hook:tenant.created", scopeJson: null }]),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(
|
||||
{
|
||||
...payload(),
|
||||
hookName: "onTenantCreated",
|
||||
metadata: { logicalHookName: "tenant.created" },
|
||||
input: { tenantId: "t-1", slug: "acme", plan: null, createdAt: "2026-05-28T10:00:00.000Z" },
|
||||
},
|
||||
deps
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(deps.invokeInSandbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hookName: "onTenantCreated",
|
||||
capabilityGrants: [{ capabilityName: "hook:tenant.created", scopeJson: null }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid registered hook payloads before loading the binary", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
||||
loadCapabilityGrants: vi.fn().mockResolvedValue([{ capabilityName: "hook:tenant.created", scopeJson: null }]),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(
|
||||
{
|
||||
...payload(),
|
||||
hookName: "onTenantCreated",
|
||||
metadata: { logicalHookName: "tenant.created" },
|
||||
input: { tenantId: "t-1" },
|
||||
},
|
||||
deps
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("invalid_hook_payload");
|
||||
expect(result.message).toBe('Invalid payload for plugin hook "tenant.created".');
|
||||
}
|
||||
expect(deps.loadBinary).not.toHaveBeenCalled();
|
||||
expect(deps.verifySignature).not.toHaveBeenCalled();
|
||||
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns binary_load_failed when the .wasm bytes cannot be loaded", async () => {
|
||||
const deps = defaultDeps({ loadBinary: vi.fn().mockResolvedValue(null) });
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("binary_load_failed");
|
||||
});
|
||||
|
||||
test("returns signature_invalid when verifySignature rejects", async () => {
|
||||
const deps = defaultDeps({
|
||||
verifySignature: vi.fn().mockReturnValue({ ok: false, reason: "public_key_not_trusted" }),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("signature_invalid");
|
||||
expect(result.message).toBe("public_key_not_trusted");
|
||||
}
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects rows whose stored wasm hash no longer matches the manifest", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), wasmSha256: "1".repeat(64) }),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("signature_invalid");
|
||||
expect(result.message).toBe("stored_wasm_sha256_mismatch");
|
||||
}
|
||||
expect(deps.verifySignature).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects rows whose stored signing key no longer matches the manifest", async () => {
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue({ ...row(), signaturePublicKey: "1".repeat(64) }),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("signature_invalid");
|
||||
expect(result.message).toBe("stored_signature_public_key_mismatch");
|
||||
}
|
||||
expect(deps.verifySignature).not.toHaveBeenCalled();
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns rate_limit_exceeded when the limiter denies", async () => {
|
||||
const deps = defaultDeps({
|
||||
consumeRateLimit: vi.fn().mockResolvedValue({ allowed: false, remaining: 0 }),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("rate_limit_exceeded");
|
||||
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns sandbox_failed when the sandbox reports failure", async () => {
|
||||
const deps = defaultDeps({
|
||||
invokeInSandbox: vi.fn().mockResolvedValue({ ok: false, reason: "trap", fuelUsed: 12 }),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("sandbox_failed");
|
||||
expect(result.message).toBe("trap");
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves sandbox failure messages for operator diagnostics", async () => {
|
||||
const deps = defaultDeps({
|
||||
invokeInSandbox: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "timeout",
|
||||
fuelUsed: 0,
|
||||
message: "plugin sandbox wall-clock timeout",
|
||||
}),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("sandbox_failed");
|
||||
expect(result.message).toBe("plugin sandbox wall-clock timeout");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns sandbox_failed when invokeInSandbox throws", async () => {
|
||||
const deps = defaultDeps({
|
||||
invokeInSandbox: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("sandbox_failed");
|
||||
expect(result.message).toBe("boom");
|
||||
}
|
||||
});
|
||||
|
||||
test("audit failure rejects so the queue can retry without losing forensics", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const deps = defaultDeps({
|
||||
recordAudit: vi.fn().mockRejectedValue(new Error("audit-down")),
|
||||
logger,
|
||||
});
|
||||
await expect(handlePluginInvokeJob(payload(), deps)).rejects.toThrow("audit-down");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "de.toolctrl.test",
|
||||
pluginInvocationId: expect.any(String),
|
||||
error: "audit-down",
|
||||
}),
|
||||
"plugin.invoke.audit_failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("crash-tracker seam receives sandbox_failed outcomes", async () => {
|
||||
const record = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = defaultDeps({
|
||||
invokeInSandbox: vi.fn().mockResolvedValue({ ok: false, reason: "trap", fuelUsed: 1 }),
|
||||
recordOutcomeForCrashTracking: record,
|
||||
});
|
||||
await handlePluginInvokeJob(payload(), deps);
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
const args = record.mock.calls[0]?.[0] as { outcome: { ok: boolean } };
|
||||
expect(args.outcome.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("crash-tracker seam ignores plugin_not_found (config issue)", async () => {
|
||||
const record = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = defaultDeps({
|
||||
loadPlugin: vi.fn().mockResolvedValue(null),
|
||||
recordOutcomeForCrashTracking: record,
|
||||
});
|
||||
await handlePluginInvokeJob(payload(), deps);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("crash-tracker seam failure does not break the handler", async () => {
|
||||
const record = vi.fn().mockRejectedValue(new Error("redis-down"));
|
||||
const deps = defaultDeps({ recordOutcomeForCrashTracking: record });
|
||||
const result = await handlePluginInvokeJob(payload(), deps);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Phase 06 #21 — Plugin-invoke worker job.
|
||||
*
|
||||
* Handles one job from the `toolctrl.plugin-invoke` queue:
|
||||
* 1. Loads the Plugin row + .wasm binary for the requested
|
||||
* (tenantId, pluginId, version) tuple.
|
||||
* 2. Verifies the binary's SHA-256 matches the stored hash and the
|
||||
* manifest's Ed25519 signature is valid against the trusted-keys
|
||||
* allowlist.
|
||||
* 3. Enforces a per-tenant sliding-window rate limit on plugin calls.
|
||||
* 4. Builds a fresh WASM sandbox with memory + fuel limits, invokes
|
||||
* the requested hook, and writes an audit event with the outcome.
|
||||
*
|
||||
* The handler is host-agnostic — every dependency (DB lookup, binary
|
||||
* loader, signature verify, rate limiter, audit writer, logger) is
|
||||
* passed in as a seam. The worker bootstrap wires real implementations;
|
||||
* tests inject fakes to assert behaviour without spinning a database.
|
||||
*
|
||||
* Deferred (see docs/improvement-plan/06-plugin-system/wasm-sandbox.md):
|
||||
* - hot-reload of plugin binaries (operator restarts pod)
|
||||
* - rich structured payload marshalling (covered by #22's hook lifecycle)
|
||||
* - Sentry / OTel span wiring (covered by #23's audit + docs)
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
getMissingCapabilityGrantNames,
|
||||
getPluginHook,
|
||||
sanitizePluginOutput,
|
||||
type PluginCapabilityGrant,
|
||||
type PluginInvocationResult,
|
||||
type PluginManifest,
|
||||
type SanitizationStrictness,
|
||||
type VerifyPluginSignatureResult,
|
||||
validatePluginHookPayload,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
|
||||
/**
|
||||
* Resolve the sanitization strictness from env. Default is `moderate`
|
||||
* (redact PANs/JWTs/IBANs/API-keys but keep emails — emails are often
|
||||
* legitimate plugin output). Unknown values fall back to `moderate`.
|
||||
*/
|
||||
function resolveSanitizationStrictness(): SanitizationStrictness {
|
||||
const raw = process.env.PLUGIN_OUTPUT_SANITIZATION_STRICTNESS?.trim().toLowerCase();
|
||||
if (raw === "strict" || raw === "moderate" || raw === "off") {
|
||||
return raw;
|
||||
}
|
||||
return "moderate";
|
||||
}
|
||||
|
||||
function validateStoredPluginVerificationMetadata(row: PluginInvokeRow): string | null {
|
||||
if (row.wasmSha256 !== row.manifest.wasmSha256) {
|
||||
return "stored_wasm_sha256_mismatch";
|
||||
}
|
||||
if (row.signaturePublicKey.toLowerCase() !== row.manifest.signedBy.toLowerCase()) {
|
||||
return "stored_signature_public_key_mismatch";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type PluginInvokeJobPayload = {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
hookName: string;
|
||||
input?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PluginInvokeRow = {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
manifest: PluginManifest;
|
||||
wasmSha256: string;
|
||||
signaturePublicKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PluginInvokeOutcome =
|
||||
| { ok: true; output: unknown; fuelUsed: number; durationMs: number; pluginInvocationId: string }
|
||||
| { ok: false; reason: PluginInvokeFailure; message?: string; durationMs: number; pluginInvocationId: string };
|
||||
|
||||
export type PluginInvokeFailure =
|
||||
| "plugin_not_found"
|
||||
| "plugin_disabled"
|
||||
| "hook_not_declared"
|
||||
| "capability_grant_missing"
|
||||
| "binary_load_failed"
|
||||
| "invalid_hook_payload"
|
||||
| "signature_invalid"
|
||||
| "rate_limit_exceeded"
|
||||
| "sandbox_failed";
|
||||
|
||||
export type RateLimitDecision = { allowed: boolean; remaining: number };
|
||||
|
||||
export type PluginInvokeDeps = {
|
||||
/** Resolve the Plugin row for a tenant+pluginId+version tuple. */
|
||||
loadPlugin: (args: { tenantId: string; pluginId: string; version: string }) => Promise<PluginInvokeRow | null>;
|
||||
/** Resolve operator-approved capability grants for this tenant+plugin pair. */
|
||||
loadCapabilityGrants: (args: { tenantId: string; pluginId: string }) => Promise<ReadonlyArray<PluginCapabilityGrant>>;
|
||||
/** Load the raw .wasm bytes from disk / object storage. */
|
||||
loadBinary: (args: { pluginId: string; version: string }) => Promise<Uint8Array | null>;
|
||||
/** Verify the binary signature against the trusted-keys allowlist. */
|
||||
verifySignature: (args: { wasmBuffer: Uint8Array; manifest: PluginManifest; trustedPublicKeysHex: ReadonlyArray<string> }) => VerifyPluginSignatureResult;
|
||||
/** Per-tenant sliding-window rate limit (returns allowed flag). */
|
||||
consumeRateLimit: (args: { tenantId: string; pluginId: string }) => Promise<RateLimitDecision>;
|
||||
/** Invoke the plugin inside the worker-owned sandbox boundary. */
|
||||
invokeInSandbox: (args: {
|
||||
pluginInvocationId: string;
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
wasmBuffer: Uint8Array;
|
||||
manifest: PluginManifest;
|
||||
hookName: string;
|
||||
payload: unknown;
|
||||
capabilityGrants: ReadonlyArray<PluginCapabilityGrant>;
|
||||
}) => Promise<PluginInvocationResult>;
|
||||
/** Write an audit row for this invocation (success or failure). */
|
||||
recordAudit: (args: {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
hookName: string;
|
||||
pluginInvocationId: string;
|
||||
outcome: PluginInvokeOutcome;
|
||||
}) => Promise<void>;
|
||||
/** Trusted Ed25519 public keys (hex). Loaded once from env at boot. */
|
||||
trustedPublicKeysHex: ReadonlyArray<string>;
|
||||
/** Pino-compatible structured logger seam. */
|
||||
logger?: {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
/**
|
||||
* Phase 06 #22 — crash-tracker seam. Receives every outcome so the
|
||||
* sliding-window counter can auto-disable a misbehaving plugin. The
|
||||
* worker bootstrap wires this to a SharedStore-backed tracker; tests
|
||||
* leave it `undefined` and the handler short-circuits.
|
||||
*/
|
||||
recordOutcomeForCrashTracking?: (args: { tenantId: string; pluginId: string; outcome: PluginInvokeOutcome }) => Promise<void>;
|
||||
/** Injected clock; defaults to Date.now. */
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process exactly one plugin-invoke job. Returns the outcome (which
|
||||
* is also persisted via `recordAudit`). Runtime failures are encoded as
|
||||
* the discriminated `ok=false` shape. Durable audit failures deliberately
|
||||
* throw so BullMQ can retry/dead-letter instead of acknowledging an
|
||||
* invocation with no forensic trail.
|
||||
*/
|
||||
export async function handlePluginInvokeJob(payload: PluginInvokeJobPayload, deps: PluginInvokeDeps): Promise<PluginInvokeOutcome> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
const pluginInvocationId = randomUUID();
|
||||
|
||||
const finalise = async (outcome: PluginInvokeOutcome): Promise<PluginInvokeOutcome> => {
|
||||
await recordInvocationAudit(deps, payload, outcome);
|
||||
// Phase 06 #22 — feed outcome to crash tracker. Some failure modes
|
||||
// (plugin_not_found, plugin_disabled) are configuration issues, not
|
||||
// plugin misbehaviour, so we deliberately skip them in the counter.
|
||||
if (deps.recordOutcomeForCrashTracking) {
|
||||
const trackable = outcome.ok || outcome.reason === "sandbox_failed" || outcome.reason === "signature_invalid" || outcome.reason === "binary_load_failed";
|
||||
if (trackable) {
|
||||
try {
|
||||
await deps.recordOutcomeForCrashTracking({
|
||||
tenantId: payload.tenantId,
|
||||
pluginId: payload.pluginId,
|
||||
outcome,
|
||||
});
|
||||
} catch (err) {
|
||||
deps.logger?.warn?.({ ...payload, error: err instanceof Error ? err.message : String(err) }, "plugin.invoke.crash_tracker_failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
const row = await deps.loadPlugin({
|
||||
tenantId: payload.tenantId,
|
||||
pluginId: payload.pluginId,
|
||||
version: payload.version,
|
||||
});
|
||||
if (!row) {
|
||||
deps.logger?.warn?.({ ...payload }, "plugin.invoke.not_found");
|
||||
return finalise({ ok: false, reason: "plugin_not_found", durationMs: now() - startedAt, pluginInvocationId });
|
||||
}
|
||||
if (!row.enabled) {
|
||||
deps.logger?.warn?.({ ...payload }, "plugin.invoke.disabled");
|
||||
return finalise({ ok: false, reason: "plugin_disabled", durationMs: now() - startedAt, pluginInvocationId });
|
||||
}
|
||||
const declaredHook = getDeclaredHookForPayload(row.manifest, payload);
|
||||
if (!declaredHook) {
|
||||
deps.logger?.warn?.({ ...payload, declaredHooks: getDeclaredHookExports(row.manifest) }, "plugin.invoke.hook_not_declared");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "hook_not_declared",
|
||||
message: `Hook export "${payload.hookName}" is not declared by plugin manifest.`,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
const capabilityGrants =
|
||||
row.manifest.capabilities.length > 0 || row.manifest.hooks.length > 0
|
||||
? await deps.loadCapabilityGrants({ tenantId: payload.tenantId, pluginId: payload.pluginId })
|
||||
: [];
|
||||
const missingCapabilityGrants = [
|
||||
...getMissingCapabilityGrantNames({
|
||||
manifest: row.manifest,
|
||||
grants: capabilityGrants,
|
||||
}),
|
||||
...getMissingHookGrantNames({ declaredHook, grants: capabilityGrants }),
|
||||
];
|
||||
if (missingCapabilityGrants.length > 0) {
|
||||
deps.logger?.warn?.({ ...payload, capabilities: missingCapabilityGrants }, "plugin.invoke.capability_grant_missing");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "capability_grant_missing",
|
||||
message: `Missing approved capability grants: ${missingCapabilityGrants.join(", ")}`,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
let sandboxPayload = payload.input;
|
||||
const registeredHook = getPluginHook(declaredHook.name);
|
||||
if (registeredHook) {
|
||||
const validation = validatePluginHookPayload(registeredHook, payload.input);
|
||||
if (!validation.ok) {
|
||||
deps.logger?.warn?.({ ...payload, hookName: declaredHook.name, issues: validation.issues }, "plugin.invoke.invalid_hook_payload");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "invalid_hook_payload",
|
||||
message: `Invalid payload for plugin hook "${declaredHook.name}".`,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
sandboxPayload = validation.payload;
|
||||
}
|
||||
|
||||
const wasmBuffer = await deps.loadBinary({ pluginId: payload.pluginId, version: payload.version });
|
||||
if (!wasmBuffer) {
|
||||
deps.logger?.error?.({ ...payload }, "plugin.invoke.binary_missing");
|
||||
return finalise({ ok: false, reason: "binary_load_failed", durationMs: now() - startedAt, pluginInvocationId });
|
||||
}
|
||||
|
||||
const storedMetadataError = validateStoredPluginVerificationMetadata(row);
|
||||
if (storedMetadataError) {
|
||||
deps.logger?.error?.({ ...payload, reason: storedMetadataError }, "plugin.invoke.stored_metadata_invalid");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "signature_invalid",
|
||||
message: storedMetadataError,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
const verify = deps.verifySignature({
|
||||
wasmBuffer,
|
||||
manifest: row.manifest,
|
||||
trustedPublicKeysHex: deps.trustedPublicKeysHex,
|
||||
});
|
||||
if (!verify.ok) {
|
||||
deps.logger?.error?.({ ...payload, reason: verify.reason }, "plugin.invoke.signature_invalid");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "signature_invalid",
|
||||
message: verify.reason,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
const rate = await deps.consumeRateLimit({ tenantId: payload.tenantId, pluginId: payload.pluginId });
|
||||
if (!rate.allowed) {
|
||||
deps.logger?.warn?.({ ...payload, remaining: rate.remaining }, "plugin.invoke.rate_limited");
|
||||
return finalise({ ok: false, reason: "rate_limit_exceeded", durationMs: now() - startedAt, pluginInvocationId });
|
||||
}
|
||||
|
||||
let result: PluginInvocationResult;
|
||||
try {
|
||||
result = await deps.invokeInSandbox({
|
||||
pluginInvocationId,
|
||||
tenantId: payload.tenantId,
|
||||
pluginId: payload.pluginId,
|
||||
wasmBuffer,
|
||||
manifest: row.manifest,
|
||||
hookName: payload.hookName,
|
||||
payload: sandboxPayload,
|
||||
capabilityGrants,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
deps.logger?.error?.({ ...payload, error: message }, "plugin.invoke.sandbox_threw");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "sandbox_failed",
|
||||
message,
|
||||
durationMs: now() - startedAt,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
const durationMs = now() - startedAt;
|
||||
if (!result.ok) {
|
||||
deps.logger?.warn?.({ ...payload, reason: result.reason, message: result.message }, "plugin.invoke.sandbox_failed");
|
||||
return finalise({
|
||||
ok: false,
|
||||
reason: "sandbox_failed",
|
||||
message: result.message ?? result.reason,
|
||||
durationMs,
|
||||
pluginInvocationId,
|
||||
});
|
||||
}
|
||||
|
||||
deps.logger?.info({ ...payload, fuelUsed: result.fuelUsed, durationMs }, "plugin.invoke.completed");
|
||||
// Phase 06 #23 — sanitize plugin output before it lands in the audit
|
||||
// trail or worker logs. Strictness is env-driven (default moderate).
|
||||
const sanitizedOutput = sanitizePluginOutput(result.output, resolveSanitizationStrictness());
|
||||
return finalise({ ok: true, output: sanitizedOutput, fuelUsed: result.fuelUsed, durationMs, pluginInvocationId });
|
||||
}
|
||||
|
||||
function getDeclaredHookForPayload(manifest: PluginManifest, payload: PluginInvokeJobPayload): PluginManifest["hooks"][number] | null {
|
||||
if (!Array.isArray(manifest.hooks)) {
|
||||
return null;
|
||||
}
|
||||
const candidates = manifest.hooks.filter((hook) => hook.export === payload.hookName);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const logicalHookName = typeof payload.metadata?.logicalHookName === "string" ? payload.metadata.logicalHookName : null;
|
||||
return candidates.find((hook) => hook.name === logicalHookName) ?? candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function getDeclaredHookExports(manifest: PluginManifest): string[] {
|
||||
if (!Array.isArray(manifest.hooks)) {
|
||||
return [];
|
||||
}
|
||||
return manifest.hooks.map((hook) => hook.export);
|
||||
}
|
||||
|
||||
function getMissingHookGrantNames({
|
||||
declaredHook,
|
||||
grants,
|
||||
}: {
|
||||
declaredHook: PluginManifest["hooks"][number];
|
||||
grants: ReadonlyArray<PluginCapabilityGrant>;
|
||||
}): string[] {
|
||||
const hookGrantName = `hook:${declaredHook.name}`;
|
||||
return grants.some((grant) => grant.capabilityName === hookGrantName) ? [] : [hookGrantName];
|
||||
}
|
||||
|
||||
async function recordInvocationAudit(deps: PluginInvokeDeps, payload: PluginInvokeJobPayload, outcome: PluginInvokeOutcome): Promise<void> {
|
||||
try {
|
||||
await deps.recordAudit({
|
||||
tenantId: payload.tenantId,
|
||||
pluginId: payload.pluginId,
|
||||
version: payload.version,
|
||||
hookName: payload.hookName,
|
||||
pluginInvocationId: outcome.pluginInvocationId,
|
||||
outcome,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
deps.logger?.error?.({ ...payload, pluginInvocationId: outcome.pluginInvocationId, error }, "plugin.invoke.audit_failed");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
|
||||
import { invokePluginSandboxInWorker, resolveSandboxWorkerLocationForTest } from "./plugin-sandbox-isolated.js";
|
||||
import { runSandboxForTest } from "./plugin-sandbox-worker.js";
|
||||
|
||||
const INFINITE_LOOP_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x00,
|
||||
0x0a, 0x09, 0x01, 0x07, 0x00, 0x03, 0x40, 0x0c, 0x00, 0x0b, 0x0b,
|
||||
]);
|
||||
|
||||
const HOST_IMPORT_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x0a, 0x02, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x01, 0x7f,
|
||||
0x02, 0x30, 0x02,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x6c, 0x6f, 0x67, 0x00, 0x00,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x15, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x61, 0x75, 0x64, 0x69, 0x74, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x01,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x02,
|
||||
0x0a, 0x0e, 0x01, 0x0c, 0x00, 0x20, 0x00, 0x10, 0x00, 0x20, 0x00, 0x10, 0x01, 0x20,
|
||||
0x00, 0x0b,
|
||||
]);
|
||||
|
||||
const manifest: PluginManifest = {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.timeout-test",
|
||||
version: "1.0.0",
|
||||
name: "Timeout Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks: [{ name: "run", export: "run" }],
|
||||
};
|
||||
|
||||
const manifestWithHostImports: PluginManifest = {
|
||||
...manifest,
|
||||
capabilities: [{ name: "log" }, { name: "audit" }],
|
||||
};
|
||||
|
||||
describe("invokePluginSandboxInWorker", () => {
|
||||
test("fails closed in production when the compiled sandbox worker is missing", async () => {
|
||||
let sourceBuildAttempted = false;
|
||||
await expect(
|
||||
resolveSandboxWorkerLocationForTest({
|
||||
compiledCandidates: ["/missing/plugin-sandbox-worker.js"],
|
||||
exists: () => false,
|
||||
isProduction: true,
|
||||
buildSource: async () => {
|
||||
sourceBuildAttempted = true;
|
||||
return new URL("data:text/javascript,");
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Compiled plugin sandbox worker is required in production.");
|
||||
expect(sourceBuildAttempted).toBe(false);
|
||||
});
|
||||
|
||||
test("terminates non-yielding WASM after the wall-clock budget", async () => {
|
||||
const startedAt = Date.now();
|
||||
const response = await invokePluginSandboxInWorker({
|
||||
wasmBuffer: INFINITE_LOOP_WASM,
|
||||
manifest,
|
||||
capabilityGrants: [],
|
||||
hookName: "run",
|
||||
payload: undefined,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000_000,
|
||||
wallClockMs: 50,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "timeout",
|
||||
message: "plugin sandbox wall-clock timeout",
|
||||
});
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
}, 5_000);
|
||||
|
||||
test("enforces host import fuel before host logs can grow unbounded", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithHostImports,
|
||||
capabilityGrants: [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "audit", scopeJson: null },
|
||||
],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 100,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "fuel_exhausted",
|
||||
fuelUsed: 100,
|
||||
message: "plugin host import fuel exhausted",
|
||||
});
|
||||
expect(response.hostLogs).toHaveLength(1);
|
||||
expect(response.capabilityCalls).toHaveLength(1);
|
||||
expect(response.droppedCapabilityCallCount).toBe(0);
|
||||
}, 5_000);
|
||||
|
||||
test("denies audit host binding when only log is granted", async () => {
|
||||
const response = await invokePluginSandboxInWorker({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifestWithHostImports,
|
||||
capabilityGrants: [{ capabilityName: "log", scopeJson: null }],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
wallClockMs: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "trap",
|
||||
message: expect.stringContaining("capability denied: audit (not_granted_for_tenant)"),
|
||||
});
|
||||
expect(response.capabilityCalls).toEqual([
|
||||
expect.objectContaining({ capability: "log", outcome: "success" }),
|
||||
expect.objectContaining({ capability: "audit", outcome: "denied", errorCode: "not_granted_for_tenant" }),
|
||||
]);
|
||||
expect(response.hostLogs).toEqual([
|
||||
expect.objectContaining({ level: "info", message: "plugin.invoke.host.log" }),
|
||||
expect.objectContaining({
|
||||
level: "warn",
|
||||
message: "plugin.invoke.capability_denied",
|
||||
record: expect.objectContaining({ capability: "audit", reason: "not_granted_for_tenant" }),
|
||||
}),
|
||||
]);
|
||||
}, 5_000);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import type { CapabilityCallInfo, PluginCapabilityGrant, PluginInvocationResult, PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
|
||||
import type { SandboxWorkerResponse } from "./plugin-sandbox-worker.js";
|
||||
|
||||
export type PluginSandboxHostLogEntry = SandboxWorkerResponse["hostLogs"][number];
|
||||
|
||||
export type PluginSandboxIsolatedResult = {
|
||||
result: PluginInvocationResult;
|
||||
capabilityCalls: CapabilityCallInfo[];
|
||||
droppedCapabilityCallCount: number;
|
||||
hostLogs: PluginSandboxHostLogEntry[];
|
||||
};
|
||||
|
||||
type InvokePluginSandboxInWorkerArgs = {
|
||||
wasmBuffer: Uint8Array;
|
||||
manifest: PluginManifest;
|
||||
capabilityGrants: ReadonlyArray<PluginCapabilityGrant>;
|
||||
hookName: string;
|
||||
payload: unknown;
|
||||
memoryLimitMb: number;
|
||||
fuelLimit: number;
|
||||
maxCapabilityCallEntries?: number;
|
||||
wallClockMs: number;
|
||||
workerUrl?: URL;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MESSAGE = "plugin sandbox wall-clock timeout";
|
||||
|
||||
type SandboxWorkerLocation = {
|
||||
url: URL;
|
||||
options?: ConstructorParameters<typeof Worker>[1];
|
||||
};
|
||||
|
||||
type SandboxWorkerLocationDeps = {
|
||||
compiledCandidates?: readonly string[];
|
||||
sourcePath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
isProduction?: boolean;
|
||||
buildSource?: (sourcePath: string) => Promise<URL>;
|
||||
};
|
||||
|
||||
function getCurrentDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function getCompiledSandboxWorkerCandidates(): string[] {
|
||||
const currentDir = getCurrentDir();
|
||||
const candidates = [
|
||||
join(currentDir, "plugin-sandbox-worker.js"),
|
||||
join(currentDir, "jobs", "plugin-sandbox-worker.js"),
|
||||
];
|
||||
if (currentDir.endsWith(`${join("src", "jobs")}`)) {
|
||||
candidates.push(join(dirname(dirname(currentDir)), "dist", "jobs", "plugin-sandbox-worker.js"));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function getSourceSandboxWorkerPath(): string {
|
||||
return join(getCurrentDir(), "plugin-sandbox-worker.ts");
|
||||
}
|
||||
|
||||
async function buildSourceSandboxWorkerDataUrl(sourcePath: string): Promise<URL> {
|
||||
const { build } = await import("esbuild");
|
||||
const result = await build({
|
||||
entryPoints: [sourcePath],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node20",
|
||||
write: false,
|
||||
sourcemap: "inline",
|
||||
logLevel: "silent",
|
||||
});
|
||||
const code = result.outputFiles[0]?.text;
|
||||
if (!code) {
|
||||
throw new Error("plugin sandbox worker bundle emitted no output");
|
||||
}
|
||||
return new URL(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
|
||||
}
|
||||
|
||||
async function resolveSandboxWorkerLocation(workerUrl?: URL, deps: SandboxWorkerLocationDeps = {}): Promise<SandboxWorkerLocation> {
|
||||
if (workerUrl) {
|
||||
return { url: workerUrl };
|
||||
}
|
||||
const exists = deps.exists ?? existsSync;
|
||||
for (const compiled of deps.compiledCandidates ?? getCompiledSandboxWorkerCandidates()) {
|
||||
if (exists(compiled)) {
|
||||
return { url: pathToFileURL(compiled) };
|
||||
}
|
||||
}
|
||||
if (deps.isProduction ?? process.env.NODE_ENV === "production") {
|
||||
throw new Error("Compiled plugin sandbox worker is required in production.");
|
||||
}
|
||||
const buildSource = deps.buildSource ?? buildSourceSandboxWorkerDataUrl;
|
||||
return { url: await buildSource(deps.sourcePath ?? getSourceSandboxWorkerPath()) };
|
||||
}
|
||||
|
||||
export async function resolveSandboxWorkerLocationForTest(deps: SandboxWorkerLocationDeps): Promise<SandboxWorkerLocation> {
|
||||
return resolveSandboxWorkerLocation(undefined, deps);
|
||||
}
|
||||
|
||||
function timeoutResult(message = DEFAULT_TIMEOUT_MESSAGE): PluginSandboxIsolatedResult {
|
||||
return {
|
||||
result: { ok: false, reason: "timeout", fuelUsed: 0, message },
|
||||
capabilityCalls: [],
|
||||
droppedCapabilityCallCount: 0,
|
||||
hostLogs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokePluginSandboxInWorker(args: InvokePluginSandboxInWorkerArgs): Promise<PluginSandboxIsolatedResult> {
|
||||
const wallClockMs = Math.max(1, Math.floor(args.wallClockMs));
|
||||
const workerLocation = await resolveSandboxWorkerLocation(args.workerUrl);
|
||||
const worker = new Worker(workerLocation.url, workerLocation.options);
|
||||
|
||||
return await new Promise<PluginSandboxIsolatedResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: PluginSandboxIsolatedResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
worker.removeAllListeners();
|
||||
void worker.terminate().catch(() => undefined).finally(() => resolve(result));
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
settle(timeoutResult());
|
||||
}, wallClockMs);
|
||||
|
||||
worker.once("message", (response: SandboxWorkerResponse) => {
|
||||
settle({
|
||||
...response,
|
||||
droppedCapabilityCallCount: response.droppedCapabilityCallCount ?? 0,
|
||||
});
|
||||
});
|
||||
worker.once("error", (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
settle({
|
||||
result: { ok: false, reason: "trap", fuelUsed: 0, message },
|
||||
capabilityCalls: [],
|
||||
droppedCapabilityCallCount: 0,
|
||||
hostLogs: [],
|
||||
});
|
||||
});
|
||||
worker.once("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
settle({
|
||||
result: { ok: false, reason: "trap", fuelUsed: 0, message: `plugin sandbox worker exited with code ${code}` },
|
||||
capabilityCalls: [],
|
||||
droppedCapabilityCallCount: 0,
|
||||
hostLogs: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
wasmBuffer: args.wasmBuffer,
|
||||
manifest: args.manifest,
|
||||
capabilityGrants: args.capabilityGrants,
|
||||
hookName: args.hookName,
|
||||
payload: args.payload,
|
||||
memoryLimitMb: args.memoryLimitMb,
|
||||
fuelLimit: args.fuelLimit,
|
||||
maxCapabilityCallEntries: args.maxCapabilityCallEntries,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
|
||||
import { invokePluginSandboxInWorker } from "./plugin-sandbox-isolated.js";
|
||||
import { createPluginSandboxHostFns, runSandboxForTest } from "./plugin-sandbox-worker.js";
|
||||
|
||||
const DEFERRED_HOST_BINDINGS = [
|
||||
"http.outbound",
|
||||
"secret.read",
|
||||
"db.read",
|
||||
"db.write",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"tenant.info",
|
||||
] as const;
|
||||
|
||||
const UNREACHABLE_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x00,
|
||||
0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b,
|
||||
]);
|
||||
|
||||
const HOST_IMPORT_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x0a, 0x02, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x01, 0x7f,
|
||||
0x02, 0x30, 0x02,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x6c, 0x6f, 0x67, 0x00, 0x00,
|
||||
0x03, 0x65, 0x6e, 0x76, 0x15, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f,
|
||||
0x61, 0x75, 0x64, 0x69, 0x74, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x00, 0x00,
|
||||
0x03, 0x02, 0x01, 0x01,
|
||||
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x02,
|
||||
0x0a, 0x0e, 0x01, 0x0c, 0x00, 0x20, 0x00, 0x10, 0x00, 0x20, 0x00, 0x10, 0x01, 0x20,
|
||||
0x00, 0x0b,
|
||||
]);
|
||||
|
||||
function manifest(capabilities: PluginManifest["capabilities"] = []): PluginManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.worker-test",
|
||||
version: "1.0.0",
|
||||
name: "Worker Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities,
|
||||
hooks: [{ name: "run", export: "run" }],
|
||||
};
|
||||
}
|
||||
|
||||
function expectFailedResultMessage(result: { ok: boolean; message?: unknown }): string {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toEqual(expect.any(String));
|
||||
return result.message as string;
|
||||
}
|
||||
|
||||
describe("plugin sandbox worker", () => {
|
||||
test("keeps deferred capabilities out of the production host-function map", () => {
|
||||
const hostFns = createPluginSandboxHostFns({
|
||||
manifest: manifest(),
|
||||
pushHostLog: () => undefined,
|
||||
});
|
||||
|
||||
expect(Object.keys(hostFns).sort()).toEqual(["audit", "log"]);
|
||||
for (const capability of DEFERRED_HOST_BINDINGS) {
|
||||
expect(hostFns).not.toHaveProperty(capability);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns compile_failed for malformed wasm without host side effects", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
|
||||
manifest: manifest(),
|
||||
capabilityGrants: [],
|
||||
hookName: "run",
|
||||
payload: 1,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "compile_failed",
|
||||
fuelUsed: 0,
|
||||
});
|
||||
expectFailedResultMessage(response.result);
|
||||
expect(response.capabilityCalls).toEqual([]);
|
||||
expect(response.hostLogs).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves wasm trap reason and diagnostic message shape", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: UNREACHABLE_WASM,
|
||||
manifest: manifest(),
|
||||
capabilityGrants: [],
|
||||
hookName: "run",
|
||||
payload: undefined,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "trap",
|
||||
});
|
||||
expect(expectFailedResultMessage(response.result)).toMatch(/unreachable/i);
|
||||
expect(response.capabilityCalls).toEqual([]);
|
||||
expect(response.hostLogs).toEqual([]);
|
||||
});
|
||||
|
||||
test("denies manifest-declared host imports that lack tenant grants", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifest([{ name: "log" }, { name: "audit" }]),
|
||||
capabilityGrants: [{ capabilityName: "log", scopeJson: null }],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "trap",
|
||||
});
|
||||
expect(expectFailedResultMessage(response.result)).toContain("capability denied: audit (not_granted_for_tenant)");
|
||||
expect(response.capabilityCalls).toEqual([
|
||||
expect.objectContaining({ capability: "log", outcome: "success" }),
|
||||
expect.objectContaining({ capability: "audit", outcome: "denied", errorCode: "not_granted_for_tenant" }),
|
||||
]);
|
||||
expect(response.hostLogs).toEqual([
|
||||
expect.objectContaining({ level: "info", message: "plugin.invoke.host.log" }),
|
||||
expect.objectContaining({
|
||||
level: "warn",
|
||||
message: "plugin.invoke.capability_denied",
|
||||
record: expect.objectContaining({ capability: "audit", reason: "not_granted_for_tenant" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not let tenant grants bypass manifest capability declarations", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifest(),
|
||||
capabilityGrants: [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "audit", scopeJson: null },
|
||||
],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "trap",
|
||||
});
|
||||
expect(expectFailedResultMessage(response.result)).toContain("capability denied: log (not_declared_in_manifest)");
|
||||
expect(response.capabilityCalls).toEqual([
|
||||
expect.objectContaining({ capability: "log", outcome: "denied", errorCode: "not_declared_in_manifest" }),
|
||||
]);
|
||||
expect(response.hostLogs).toEqual([
|
||||
expect.objectContaining({
|
||||
level: "warn",
|
||||
message: "plugin.invoke.capability_denied",
|
||||
record: expect.objectContaining({ capability: "log", reason: "not_declared_in_manifest" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("denies undeclared host imports even when another capability is granted", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifest([{ name: "log" }]),
|
||||
capabilityGrants: [{ capabilityName: "log", scopeJson: null }],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "trap",
|
||||
});
|
||||
expect(expectFailedResultMessage(response.result)).toContain("capability denied: audit (not_declared_in_manifest)");
|
||||
expect(response.capabilityCalls).toEqual([
|
||||
expect.objectContaining({ capability: "log", outcome: "success" }),
|
||||
expect.objectContaining({ capability: "audit", outcome: "denied", errorCode: "not_declared_in_manifest" }),
|
||||
]);
|
||||
expect(response.hostLogs.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
level: "warn",
|
||||
message: "plugin.invoke.capability_denied",
|
||||
record: expect.objectContaining({ capability: "audit", reason: "not_declared_in_manifest" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("worker catch path posts trap responses for malformed sandbox limits", async () => {
|
||||
const response = await invokePluginSandboxInWorker({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifest([{ name: "log" }, { name: "audit" }]),
|
||||
capabilityGrants: [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "audit", scopeJson: null },
|
||||
],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 0,
|
||||
wallClockMs: 1_000,
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
result: { ok: false, reason: "trap", fuelUsed: 0, message: "fuelLimit must be positive" },
|
||||
capabilityCalls: [],
|
||||
droppedCapabilityCallCount: 0,
|
||||
hostLogs: [],
|
||||
});
|
||||
}, 5_000);
|
||||
|
||||
test("reports dropped capability audit entries when host calls exceed the worker cap", async () => {
|
||||
const response = await runSandboxForTest({
|
||||
wasmBuffer: HOST_IMPORT_WASM,
|
||||
manifest: manifest([{ name: "log" }, { name: "audit" }]),
|
||||
capabilityGrants: [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "audit", scopeJson: null },
|
||||
],
|
||||
hookName: "run",
|
||||
payload: 7,
|
||||
memoryLimitMb: 64,
|
||||
fuelLimit: 1_000,
|
||||
maxCapabilityCallEntries: 1,
|
||||
});
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, output: 7 });
|
||||
expect(response.capabilityCalls).toEqual([expect.objectContaining({ capability: "log", outcome: "success" })]);
|
||||
expect(response.droppedCapabilityCallCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
|
||||
import {
|
||||
createCapabilityGateway,
|
||||
createPluginSandbox,
|
||||
type CapabilityCallInfo,
|
||||
type CapabilityHostFnMap,
|
||||
type PluginCapabilityGrant,
|
||||
type PluginInvocationResult,
|
||||
type PluginManifest,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
|
||||
type SandboxWorkerRequest = {
|
||||
wasmBuffer: Uint8Array;
|
||||
manifest: PluginManifest;
|
||||
capabilityGrants: ReadonlyArray<PluginCapabilityGrant>;
|
||||
hookName: string;
|
||||
payload: unknown;
|
||||
memoryLimitMb: number;
|
||||
fuelLimit: number;
|
||||
maxCapabilityCallEntries?: number;
|
||||
};
|
||||
|
||||
type HostLogEntry = {
|
||||
level: "info" | "warn";
|
||||
record: Record<string, unknown>;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const HOST_IMPORT_FUEL_COST = 100;
|
||||
const MAX_HOST_LOG_ENTRIES = 1_024;
|
||||
const MAX_CAPABILITY_CALL_ENTRIES = 2_048;
|
||||
|
||||
export type SandboxWorkerResponse = {
|
||||
result: PluginInvocationResult;
|
||||
capabilityCalls: CapabilityCallInfo[];
|
||||
droppedCapabilityCallCount: number;
|
||||
hostLogs: HostLogEntry[];
|
||||
};
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createPluginSandboxHostFns(args: {
|
||||
manifest: PluginManifest;
|
||||
pushHostLog: (entry: HostLogEntry) => void;
|
||||
}): CapabilityHostFnMap {
|
||||
const { manifest, pushHostLog } = args;
|
||||
return {
|
||||
log: (value: unknown) => {
|
||||
pushHostLog({ level: "info", record: { pluginId: manifest.id, version: manifest.version, value }, message: "plugin.invoke.host.log" });
|
||||
return 0;
|
||||
},
|
||||
audit: (value: unknown) => {
|
||||
pushHostLog({ level: "info", record: { pluginId: manifest.id, version: manifest.version, value }, message: "plugin.invoke.host.audit" });
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSandboxForTest({
|
||||
wasmBuffer,
|
||||
manifest,
|
||||
capabilityGrants,
|
||||
hookName,
|
||||
payload,
|
||||
memoryLimitMb,
|
||||
fuelLimit,
|
||||
maxCapabilityCallEntries = MAX_CAPABILITY_CALL_ENTRIES,
|
||||
}: SandboxWorkerRequest): Promise<SandboxWorkerResponse> {
|
||||
const capabilityCalls: CapabilityCallInfo[] = [];
|
||||
const hostLogs: HostLogEntry[] = [];
|
||||
const capabilityCallEntryLimit = Number.isFinite(maxCapabilityCallEntries)
|
||||
? Math.max(0, Math.floor(maxCapabilityCallEntries))
|
||||
: MAX_CAPABILITY_CALL_ENTRIES;
|
||||
let droppedCapabilityCallCount = 0;
|
||||
let hostFuelExhausted = false;
|
||||
const pushHostLog = (entry: HostLogEntry) => {
|
||||
if (hostLogs.length < MAX_HOST_LOG_ENTRIES) {
|
||||
hostLogs.push(entry);
|
||||
}
|
||||
};
|
||||
const hostFns = createPluginSandboxHostFns({ manifest, pushHostLog });
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest,
|
||||
approvedGrants: capabilityGrants,
|
||||
hostFns,
|
||||
fuelCostPerCall: HOST_IMPORT_FUEL_COST,
|
||||
onDenied: (info) => {
|
||||
pushHostLog({
|
||||
level: "warn",
|
||||
record: { pluginId: manifest.id, version: manifest.version, capability: info.capability, reason: info.reason },
|
||||
message: "plugin.invoke.capability_denied",
|
||||
});
|
||||
},
|
||||
onCall: (info) => {
|
||||
if (capabilityCalls.length < capabilityCallEntryLimit) {
|
||||
capabilityCalls.push(info);
|
||||
} else {
|
||||
droppedCapabilityCallCount += 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
const invokeHostSync = (capability: "log" | "audit", value: number) => {
|
||||
if (gateway.fuelCharged() + HOST_IMPORT_FUEL_COST > fuelLimit) {
|
||||
hostFuelExhausted = true;
|
||||
throw new Error("plugin host import fuel exhausted");
|
||||
}
|
||||
return gateway.invokeSync(capability, { value });
|
||||
};
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer,
|
||||
manifest,
|
||||
memoryLimitMb,
|
||||
fuelLimit,
|
||||
hostImports: {
|
||||
env: {
|
||||
toolctrl_log: (value: number) => invokeHostSync("log", value),
|
||||
toolctrl_audit_record: (value: number) => invokeHostSync("audit", value),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await sandbox.invoke(hookName, payload);
|
||||
const fuelUsed = Math.min(fuelLimit, result.fuelUsed + gateway.fuelCharged());
|
||||
if (hostFuelExhausted) {
|
||||
return {
|
||||
result: { ok: false, reason: "fuel_exhausted", fuelUsed, message: "plugin host import fuel exhausted" },
|
||||
capabilityCalls,
|
||||
droppedCapabilityCallCount,
|
||||
hostLogs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: result.ok ? { ...result, fuelUsed } : { ...result, fuelUsed },
|
||||
capabilityCalls,
|
||||
droppedCapabilityCallCount,
|
||||
hostLogs,
|
||||
};
|
||||
} finally {
|
||||
sandbox.close();
|
||||
}
|
||||
}
|
||||
|
||||
parentPort?.on("message", (request: SandboxWorkerRequest) => {
|
||||
void runSandboxForTest(request)
|
||||
.then((response) => parentPort?.postMessage(response))
|
||||
.catch((error: unknown) =>
|
||||
parentPort?.postMessage({
|
||||
result: { ok: false, reason: "trap", fuelUsed: 0, message: stringifyError(error) },
|
||||
capabilityCalls: [],
|
||||
droppedCapabilityCallCount: 0,
|
||||
hostLogs: [],
|
||||
} satisfies SandboxWorkerResponse)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
dispatchPushNotification,
|
||||
redactToken,
|
||||
REVOCATION_ERROR_CODES,
|
||||
type DeviceTokenRow,
|
||||
type PushFanOutInput,
|
||||
type PushPayload,
|
||||
type PushProviderSeam,
|
||||
} from "./push-notification.js";
|
||||
|
||||
function makeRow(overrides: Partial<DeviceTokenRow>): DeviceTokenRow {
|
||||
return {
|
||||
id: overrides.id ?? "dt-1",
|
||||
token: overrides.token ?? "tokAAAAAAAAAAAAAA",
|
||||
userId: overrides.userId ?? "u-1",
|
||||
tenantId: overrides.tenantId ?? null,
|
||||
platform: overrides.platform ?? "ios",
|
||||
pushProvider: overrides.pushProvider ?? "apns",
|
||||
revokedAt: overrides.revokedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function makePayload(overrides: Partial<PushPayload> = {}): PushPayload {
|
||||
return {
|
||||
userId: overrides.userId ?? "u-1",
|
||||
tenantId: overrides.tenantId ?? null,
|
||||
title: overrides.title ?? "Hello",
|
||||
body: overrides.body ?? "World",
|
||||
data: overrides.data,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProviders(overrides: Partial<PushProviderSeam> = {}): PushProviderSeam {
|
||||
return {
|
||||
sendApns: overrides.sendApns ?? vi.fn().mockResolvedValue({ success: true, messageId: "apns-1" }),
|
||||
sendFcm: overrides.sendFcm ?? vi.fn().mockResolvedValue({ success: true, messageId: "fcm-1" }),
|
||||
sendWebPush: overrides.sendWebPush ?? vi.fn().mockResolvedValue({ success: true, messageId: "web-1" }),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("dispatchPushNotification", () => {
|
||||
it("returns zero attempts when no active tokens exist", async () => {
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [],
|
||||
providers: makeProviders(),
|
||||
tokenRevoker: vi.fn(),
|
||||
};
|
||||
const result = await dispatchPushNotification(input);
|
||||
expect(result).toEqual({ attempted: 0, succeeded: 0, revoked: 0, failed: 0 });
|
||||
});
|
||||
|
||||
it("filters out tokens with revokedAt set", async () => {
|
||||
const providers = makeProviders();
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [
|
||||
makeRow({ token: "t-active" }),
|
||||
makeRow({ id: "dt-2", token: "t-revoked", revokedAt: new Date() }),
|
||||
],
|
||||
providers,
|
||||
tokenRevoker: vi.fn(),
|
||||
};
|
||||
const result = await dispatchPushNotification(input);
|
||||
expect(result.attempted).toBe(1);
|
||||
expect(result.succeeded).toBe(1);
|
||||
expect(providers.sendApns).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes ios tokens to APNs, android to FCM, web to WebPush", async () => {
|
||||
const providers = makeProviders();
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [
|
||||
makeRow({ token: "tok-ios", platform: "ios", pushProvider: "apns" }),
|
||||
makeRow({ id: "dt-2", token: "tok-and", platform: "android", pushProvider: "fcm" }),
|
||||
makeRow({ id: "dt-3", token: "tok-web", platform: "web", pushProvider: "webpush" }),
|
||||
],
|
||||
providers,
|
||||
tokenRevoker: vi.fn(),
|
||||
};
|
||||
const result = await dispatchPushNotification(input);
|
||||
expect(result.succeeded).toBe(3);
|
||||
expect(providers.sendApns).toHaveBeenCalledWith("tok-ios", expect.any(Object));
|
||||
expect(providers.sendFcm).toHaveBeenCalledWith("tok-and", expect.any(Object));
|
||||
expect(providers.sendWebPush).toHaveBeenCalledWith("tok-web", expect.any(Object));
|
||||
});
|
||||
|
||||
it("starts multiple provider sends before the first delayed send resolves", async () => {
|
||||
const releases = Array.from({ length: 3 }, () => deferred());
|
||||
const started: string[] = [];
|
||||
const completed: string[] = [];
|
||||
let sendIndex = 0;
|
||||
const providers = makeProviders({
|
||||
sendApns: vi.fn(async (token) => {
|
||||
const release = releases[sendIndex++]!;
|
||||
started.push(token);
|
||||
await release.promise;
|
||||
completed.push(token);
|
||||
return { success: true, messageId: `apns-${token}` };
|
||||
}),
|
||||
});
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [
|
||||
makeRow({ id: "dt-1", token: "tok-one" }),
|
||||
makeRow({ id: "dt-2", token: "tok-two" }),
|
||||
makeRow({ id: "dt-3", token: "tok-three" }),
|
||||
],
|
||||
providers,
|
||||
tokenRevoker: vi.fn(),
|
||||
};
|
||||
|
||||
const resultPromise = dispatchPushNotification(input);
|
||||
try {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(started.length).toBeGreaterThanOrEqual(2);
|
||||
expect(completed).toEqual([]);
|
||||
},
|
||||
{ timeout: 100 }
|
||||
);
|
||||
} finally {
|
||||
for (const release of releases) {
|
||||
release.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ attempted: 3, succeeded: 3, revoked: 0, failed: 0 });
|
||||
});
|
||||
|
||||
it("revokes tokens when the provider reports an irrecoverable error code", async () => {
|
||||
const revoker = vi.fn();
|
||||
const providers = makeProviders({
|
||||
sendApns: vi.fn().mockResolvedValue({ success: false, errorCode: "Unregistered" }),
|
||||
});
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [makeRow({ token: "tok-dead" })],
|
||||
providers,
|
||||
tokenRevoker: revoker,
|
||||
};
|
||||
const result = await dispatchPushNotification(input);
|
||||
expect(result.revoked).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(revoker).toHaveBeenCalledWith("tok-dead");
|
||||
});
|
||||
|
||||
it("counts other errors as failed without revoking", async () => {
|
||||
const revoker = vi.fn();
|
||||
const providers = makeProviders({
|
||||
sendApns: vi.fn().mockResolvedValue({ success: false, errorCode: "InternalServerError" }),
|
||||
});
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [makeRow({ token: "tok-flaky" })],
|
||||
providers,
|
||||
tokenRevoker: revoker,
|
||||
};
|
||||
const result = await dispatchPushNotification(input);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.revoked).toBe(0);
|
||||
expect(revoker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits an info log on successful delivery and a warn log on revocation", async () => {
|
||||
const info = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const providers = makeProviders({
|
||||
sendApns: vi.fn()
|
||||
.mockResolvedValueOnce({ success: true, messageId: "id-1" })
|
||||
.mockResolvedValueOnce({ success: false, errorCode: "BadDeviceToken" }),
|
||||
});
|
||||
const input: PushFanOutInput = {
|
||||
payload: makePayload(),
|
||||
tokenLister: async () => [
|
||||
makeRow({ id: "a", token: "tok-good" }),
|
||||
makeRow({ id: "b", token: "tok-bad" }),
|
||||
],
|
||||
providers,
|
||||
tokenRevoker: vi.fn(),
|
||||
logger: { info, warn },
|
||||
};
|
||||
await dispatchPushNotification(input);
|
||||
expect(info).toHaveBeenCalledWith(expect.objectContaining({ messageId: "id-1" }), "push delivered");
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ errorCode: "BadDeviceToken" }), "push provider reported token revoked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactToken", () => {
|
||||
it("emits an ellipsis-truncated prefix", () => {
|
||||
expect(redactToken("abcdefghij")).toBe("abcdefgh…");
|
||||
expect(redactToken("short")).toBe("sh…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("REVOCATION_ERROR_CODES", () => {
|
||||
it("includes both APNs and FCM canonical errors", () => {
|
||||
expect(REVOCATION_ERROR_CODES.has("Unregistered")).toBe(true);
|
||||
expect(REVOCATION_ERROR_CODES.has("BadDeviceToken")).toBe(true);
|
||||
expect(REVOCATION_ERROR_CODES.has("messaging/registration-token-not-registered")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Phase 08 #65 — Push-notification fan-out worker.
|
||||
*
|
||||
* Consumes events emitted via the existing notifications pipeline and
|
||||
* dispatches one APNs / FCM / WebPush call per active DeviceToken for
|
||||
* the recipient user. The worker is intentionally provider-pluggable:
|
||||
* the two SDK adapters (firebase-admin for FCM, node-apn for APNs) are
|
||||
* injected through the `providers` seam so unit tests can mock them
|
||||
* without dragging the actual libraries into Vitest's module graph.
|
||||
*
|
||||
* Provider response codes that signal a permanent invalidation
|
||||
* (`Unregistered`, `InvalidRegistration`, `BadDeviceToken`) flip the
|
||||
* matching DeviceToken's `revokedAt` so subsequent fan-out passes skip
|
||||
* the dead row.
|
||||
*
|
||||
* Bootstrap-time config (apps/worker/src/index.ts) reads:
|
||||
* - FCM_SERVICE_ACCOUNT_JSON (base64 or raw JSON for the FCM key)
|
||||
* - APNS_KEY (PEM contents of the .p8 sign key)
|
||||
* - APNS_KEY_ID (10-char key id from Apple Developer)
|
||||
* - APNS_TEAM_ID (10-char team id)
|
||||
* - APNS_BUNDLE_ID (defaults to de.toolctrl.app)
|
||||
*
|
||||
* @see packages/db/src/device-tokens.server.ts
|
||||
* @see apps/mobile/src/pushRegistration.ts
|
||||
*/
|
||||
|
||||
import { mapWithConcurrency } from "@toolctrl/foundation";
|
||||
|
||||
export type PushTargetPlatform = "ios" | "android" | "web";
|
||||
export type PushProviderResult = {
|
||||
/** Provider-side message id, when available. */
|
||||
messageId?: string;
|
||||
/** Whether the provider accepted the push. */
|
||||
success: boolean;
|
||||
/** Provider error code (e.g. `Unregistered`, `InvalidRegistration`). */
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
export type PushPayload = {
|
||||
/** Bearer/user/tenant context — also forwarded to the provider as APS-data. */
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type DeviceTokenRow = {
|
||||
id: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
platform: string;
|
||||
pushProvider: string;
|
||||
revokedAt: Date | null;
|
||||
};
|
||||
|
||||
export type PushProviderSeam = {
|
||||
sendFcm: (token: string, payload: PushPayload) => Promise<PushProviderResult>;
|
||||
sendApns: (token: string, payload: PushPayload) => Promise<PushProviderResult>;
|
||||
sendWebPush: (token: string, payload: PushPayload) => Promise<PushProviderResult>;
|
||||
};
|
||||
|
||||
export type PushFanOutInput = {
|
||||
payload: PushPayload;
|
||||
/** Lister: active (revokedAt IS NULL) tokens for the recipient. */
|
||||
tokenLister: (args: { userId: string; tenantId: string | null }) => Promise<readonly DeviceTokenRow[]>;
|
||||
/** Provider seam — injected by apps/worker/src/index.ts. */
|
||||
providers: PushProviderSeam;
|
||||
/** Revoke seam — called when the provider returns a permanent error. */
|
||||
tokenRevoker: (token: string) => Promise<void>;
|
||||
/** Max active device tokens sent at once. Default is intentionally conservative. */
|
||||
concurrency?: number;
|
||||
/** Optional logger. */
|
||||
logger?: {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type PushFanOutResult = {
|
||||
attempted: number;
|
||||
succeeded: number;
|
||||
revoked: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
const REVOCATION_ERROR_CODES = new Set([
|
||||
"Unregistered",
|
||||
"InvalidRegistration",
|
||||
"BadDeviceToken",
|
||||
"DeviceTokenNotForTopic",
|
||||
"messaging/registration-token-not-registered",
|
||||
"messaging/invalid-registration-token",
|
||||
]);
|
||||
|
||||
export const PUSH_NOTIFICATION_FAN_OUT_CONCURRENCY = 5;
|
||||
|
||||
/**
|
||||
* Fan out a single notification to every active device for the user.
|
||||
*
|
||||
* Pure orchestrator — no DB / SDK imports — so it can be unit-tested with
|
||||
* Map-backed stubs. The worker entry point wires the seams against the
|
||||
* real Prisma client and provider SDKs.
|
||||
*/
|
||||
export async function dispatchPushNotification(input: PushFanOutInput): Promise<PushFanOutResult> {
|
||||
const tokens = await input.tokenLister({ userId: input.payload.userId, tenantId: input.payload.tenantId });
|
||||
const active = tokens.filter((row) => row.revokedAt === null);
|
||||
let succeeded = 0;
|
||||
let revoked = 0;
|
||||
let failed = 0;
|
||||
|
||||
await mapWithConcurrency(active, input.concurrency ?? PUSH_NOTIFICATION_FAN_OUT_CONCURRENCY, async (row) => {
|
||||
const result = await sendForRow(row, input.payload, input.providers);
|
||||
if (result.success) {
|
||||
succeeded += 1;
|
||||
input.logger?.info(
|
||||
{ token: redactToken(row.token), platform: row.platform, provider: row.pushProvider, messageId: result.messageId },
|
||||
"push delivered",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.errorCode && REVOCATION_ERROR_CODES.has(result.errorCode)) {
|
||||
await input.tokenRevoker(row.token);
|
||||
revoked += 1;
|
||||
input.logger?.warn?.(
|
||||
{ token: redactToken(row.token), platform: row.platform, errorCode: result.errorCode },
|
||||
"push provider reported token revoked",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
failed += 1;
|
||||
input.logger?.error?.(
|
||||
{ token: redactToken(row.token), platform: row.platform, errorCode: result.errorCode },
|
||||
"push delivery failed",
|
||||
);
|
||||
});
|
||||
|
||||
return { attempted: active.length, succeeded, revoked, failed };
|
||||
}
|
||||
|
||||
async function sendForRow(row: DeviceTokenRow, payload: PushPayload, providers: PushProviderSeam): Promise<PushProviderResult> {
|
||||
if (row.platform === "ios" || row.pushProvider === "apns") {
|
||||
return providers.sendApns(row.token, payload);
|
||||
}
|
||||
if (row.platform === "android" || row.pushProvider === "fcm") {
|
||||
return providers.sendFcm(row.token, payload);
|
||||
}
|
||||
return providers.sendWebPush(row.token, payload);
|
||||
}
|
||||
|
||||
/** Redacts everything but the leading 8 chars of a token for log lines. */
|
||||
export function redactToken(token: string): string {
|
||||
if (token.length <= 8) return `${token.slice(0, 2)}…`;
|
||||
return `${token.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
export { REVOCATION_ERROR_CODES };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSearchClient } from "@toolctrl/search";
|
||||
import { createInMemoryMeili } from "@toolctrl/search/testing";
|
||||
import { bootstrapSearchIndexer } from "./search-indexer-bootstrap.js";
|
||||
import { SEARCH_INDEX_OUTBOX_QUEUE } from "./search-indexer.js";
|
||||
|
||||
function fakeWorker() {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const handle = { name: "fake-worker", close };
|
||||
const createWorkerImpl = vi.fn(() => handle);
|
||||
return { handle, createWorkerImpl };
|
||||
}
|
||||
|
||||
describe("bootstrapSearchIndexer", () => {
|
||||
it("returns disabled when MEILI_URL is unset", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapSearchIndexer({ WORKER_QUEUE_GROUP: "general" }, { createWorkerImpl });
|
||||
expect(result.status).toBe("disabled");
|
||||
if (result.status === "disabled") expect(result.reason).toMatch(/MEILI_URL/);
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns disabled when SEARCH_INDEXER_ENABLED is 'false'", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapSearchIndexer(
|
||||
{ WORKER_QUEUE_GROUP: "general", MEILI_URL: "http://x", SEARCH_INDEXER_ENABLED: "false" },
|
||||
{ createWorkerImpl }
|
||||
);
|
||||
expect(result.status).toBe("disabled");
|
||||
});
|
||||
|
||||
it("returns disabled in non-general groups", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const result = bootstrapSearchIndexer({ WORKER_QUEUE_GROUP: "mail", MEILI_URL: "http://x" }, { createWorkerImpl });
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(createWorkerImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("subscribes the worker to the outbox.search-index queue when enabled", () => {
|
||||
const { createWorkerImpl, handle } = fakeWorker();
|
||||
const meili = createInMemoryMeili();
|
||||
const searchClient = createSearchClient({ url: "http://memory", meiliFactory: meili.factory });
|
||||
const result = bootstrapSearchIndexer(
|
||||
{ WORKER_QUEUE_GROUP: "general", MEILI_URL: "http://memory" },
|
||||
{ createWorkerImpl, searchClient }
|
||||
);
|
||||
expect(result.status).toBe("started");
|
||||
expect(createWorkerImpl).toHaveBeenCalledWith(
|
||||
SEARCH_INDEX_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 5 })
|
||||
);
|
||||
if (result.status === "started") {
|
||||
expect(result.handle).toBe(handle);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours SEARCH_INDEXER_CONCURRENCY override", () => {
|
||||
const { createWorkerImpl } = fakeWorker();
|
||||
const meili = createInMemoryMeili();
|
||||
const searchClient = createSearchClient({ url: "http://memory", meiliFactory: meili.factory });
|
||||
bootstrapSearchIndexer(
|
||||
{ WORKER_QUEUE_GROUP: "general", MEILI_URL: "http://memory", SEARCH_INDEXER_CONCURRENCY: "12" },
|
||||
{ createWorkerImpl, searchClient }
|
||||
);
|
||||
expect(createWorkerImpl).toHaveBeenCalledWith(
|
||||
SEARCH_INDEX_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 12 })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Bootstrap helper that starts a dedicated BullMQ worker subscribed to
|
||||
* the `outbox.search-index` queue inside the worker-general process.
|
||||
*
|
||||
* The worker is gated on:
|
||||
* - WORKER_QUEUE_GROUP === "general" (other groups never touch search)
|
||||
* - MEILI_URL set (otherwise the indexer cannot connect)
|
||||
* - SEARCH_INDEXER_ENABLED !== "false" (escape hatch)
|
||||
*
|
||||
* Returns `{ status: "disabled" }` when any precondition fails. Tests
|
||||
* inject a fake `createWorkerImpl` to assert subscription semantics
|
||||
* without spinning up a BullMQ Worker.
|
||||
*/
|
||||
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
import { createSearchClient, type SearchClient } from "@toolctrl/search";
|
||||
import {
|
||||
runSearchIndexerJob,
|
||||
SEARCH_INDEX_OUTBOX_QUEUE,
|
||||
type SearchIndexerDependencies,
|
||||
type SearchIndexerLogger,
|
||||
} from "./search-indexer.js";
|
||||
|
||||
export type SearchIndexerBootstrapEnv = EnvRecord & {
|
||||
MEILI_URL?: string;
|
||||
MEILI_API_KEY?: string;
|
||||
SEARCH_INDEXER_ENABLED?: string;
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
};
|
||||
|
||||
export interface SearchIndexerWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SearchIndexerBootstrapDeps {
|
||||
readonly logger?: SearchIndexerLogger;
|
||||
readonly bulkPagers?: SearchIndexerDependencies["bulkPagers"];
|
||||
readonly searchClient?: SearchClient;
|
||||
/** Override the BullMQ worker constructor so tests can inject a fake. */
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number }
|
||||
) => SearchIndexerWorkerHandle;
|
||||
}
|
||||
|
||||
export type SearchIndexerBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: SearchIndexerWorkerHandle };
|
||||
|
||||
export function bootstrapSearchIndexer(
|
||||
env: SearchIndexerBootstrapEnv,
|
||||
deps: SearchIndexerBootstrapDeps = {}
|
||||
): SearchIndexerBootstrapResult {
|
||||
const logger = deps.logger;
|
||||
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "general") {
|
||||
return { status: "disabled", reason: "non-general worker group" };
|
||||
}
|
||||
if (env.SEARCH_INDEXER_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "SEARCH_INDEXER_ENABLED=false" };
|
||||
}
|
||||
if (!env.MEILI_URL) {
|
||||
logger?.info?.({ component: "search-indexer" }, "search-indexer disabled: MEILI_URL unset");
|
||||
return { status: "disabled", reason: "MEILI_URL unset" };
|
||||
}
|
||||
|
||||
const client =
|
||||
deps.searchClient ?? createSearchClient({ url: env.MEILI_URL, apiKey: env.MEILI_API_KEY ?? undefined });
|
||||
|
||||
const concurrency = parsePositiveInt(env.SEARCH_INDEXER_CONCURRENCY, 5);
|
||||
|
||||
const processor = async (job: { data: unknown }): Promise<unknown> => {
|
||||
return runSearchIndexerJob(job.data, {
|
||||
searchClient: client,
|
||||
bulkPagers: deps.bulkPagers,
|
||||
logger,
|
||||
});
|
||||
};
|
||||
|
||||
const createWorkerImpl = deps.createWorkerImpl ?? defaultCreateWorker;
|
||||
const handle = createWorkerImpl(SEARCH_INDEX_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
logger?.info?.(
|
||||
{ component: "search-indexer", queue: SEARCH_INDEX_OUTBOX_QUEUE, concurrency },
|
||||
"search-indexer worker started"
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// In production this is replaced by a real BullMQ Worker via createWorkerImpl
|
||||
// in the worker entry point. Calling it directly throws — tests must inject
|
||||
// the dependency or the bootstrap is short-circuited via SEARCH_INDEXER_ENABLED.
|
||||
const defaultCreateWorker: NonNullable<SearchIndexerBootstrapDeps["createWorkerImpl"]> = () => {
|
||||
throw new Error(
|
||||
"bootstrapSearchIndexer requires deps.createWorkerImpl in this build; the worker entry must wire the BullMQ Worker constructor."
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { BULK_REINDEX_SUPPORTED_INDEX_NAMES, crmContactsIndex, kbArticlesIndex, rowsIndex, usersIndex } from "@toolctrl/search";
|
||||
import type { SearchIndexerBulkPager, SearchIndexerDependencies } from "./search-indexer.js";
|
||||
|
||||
type KnowledgeBaseArticleRow = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
contentPublishedAsText: string;
|
||||
language: string;
|
||||
publishedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string | null;
|
||||
knowledgeBase: { tenantId: string | null };
|
||||
};
|
||||
|
||||
const SEARCHABLE_ROW_PROPERTY_TYPES = new Set([1, 8]); // TEXT, SELECT
|
||||
|
||||
type RowSearchProperty = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
};
|
||||
|
||||
type RowSearchValue = {
|
||||
propertyId: string;
|
||||
textValue: string | null;
|
||||
numberValue: { toString(): string } | number | string | null;
|
||||
dateValue: Date | string | null;
|
||||
booleanValue: boolean | null;
|
||||
property: RowSearchProperty;
|
||||
};
|
||||
|
||||
type SearchRow = {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
entityId: string;
|
||||
folio: number;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
deletedAt: Date | string | null;
|
||||
entity: {
|
||||
name: string;
|
||||
properties: RowSearchProperty[];
|
||||
};
|
||||
values: RowSearchValue[];
|
||||
};
|
||||
|
||||
const TENANT_USER_STATUS_ACTIVE = 2;
|
||||
|
||||
type TenantUserSearchRow = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
type: number;
|
||||
status: number;
|
||||
user: {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
admin: { userId: string } | null;
|
||||
logs: { createdAt: Date | string }[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SearchIndexerBulkPagerPrisma = {
|
||||
knowledgeBaseArticle: {
|
||||
findMany(args: {
|
||||
where: {
|
||||
id?: { gt: string };
|
||||
knowledgeBase: { tenantId: string };
|
||||
};
|
||||
orderBy: { id: "asc" };
|
||||
take: number;
|
||||
select: {
|
||||
id: true;
|
||||
knowledgeBaseId: true;
|
||||
slug: true;
|
||||
title: true;
|
||||
description: true;
|
||||
contentPublishedAsText: true;
|
||||
language: true;
|
||||
publishedAt: true;
|
||||
createdAt: true;
|
||||
updatedAt: true;
|
||||
knowledgeBase: { select: { tenantId: true } };
|
||||
};
|
||||
}): Promise<KnowledgeBaseArticleRow[]>;
|
||||
};
|
||||
row: {
|
||||
findMany(args: {
|
||||
where: {
|
||||
id?: { gt: string };
|
||||
tenantId: string;
|
||||
deletedAt: null;
|
||||
entity?: { name: string };
|
||||
};
|
||||
orderBy: { id: "asc" };
|
||||
take: number;
|
||||
select: {
|
||||
id: true;
|
||||
tenantId: true;
|
||||
entityId: true;
|
||||
folio: true;
|
||||
createdByUserId: true;
|
||||
createdAt: true;
|
||||
updatedAt: true;
|
||||
deletedAt: true;
|
||||
entity: {
|
||||
select: {
|
||||
name: true;
|
||||
properties: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
type: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
values: {
|
||||
select: {
|
||||
propertyId: true;
|
||||
textValue: true;
|
||||
numberValue: true;
|
||||
dateValue: true;
|
||||
booleanValue: true;
|
||||
property: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
type: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}): Promise<SearchRow[]>;
|
||||
};
|
||||
tenantUser: {
|
||||
findMany(args: {
|
||||
where: {
|
||||
id?: { gt: string };
|
||||
tenantId: string;
|
||||
};
|
||||
orderBy: { id: "asc" };
|
||||
take: number;
|
||||
select: {
|
||||
id: true;
|
||||
tenantId: true;
|
||||
userId: true;
|
||||
type: true;
|
||||
status: true;
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
createdAt: true;
|
||||
email: true;
|
||||
firstName: true;
|
||||
lastName: true;
|
||||
active: true;
|
||||
admin: { select: { userId: true } };
|
||||
logs: {
|
||||
where: { action: "Login" };
|
||||
orderBy: { createdAt: "desc" };
|
||||
take: 1;
|
||||
select: { createdAt: true };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}): Promise<TenantUserSearchRow[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export function createSearchIndexerBulkPagers(input: { prisma: SearchIndexerBulkPagerPrisma }): NonNullable<SearchIndexerDependencies["bulkPagers"]> {
|
||||
return {
|
||||
[kbArticlesIndex.name]: createKbArticlesPager(input.prisma),
|
||||
[crmContactsIndex.name]: createCrmContactsPager(input.prisma),
|
||||
[rowsIndex.name]: createRowsPager(input.prisma),
|
||||
[usersIndex.name]: createUsersPager(input.prisma),
|
||||
};
|
||||
}
|
||||
|
||||
export const registeredSearchIndexerBulkPagerNames: readonly string[] = BULK_REINDEX_SUPPORTED_INDEX_NAMES;
|
||||
|
||||
function createKbArticlesPager(prisma: SearchIndexerBulkPagerPrisma): SearchIndexerBulkPager {
|
||||
return async ({ tenantId, cursor, pageSize }) => {
|
||||
const rows = await prisma.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
...(cursor ? { id: { gt: cursor } } : {}),
|
||||
knowledgeBase: { tenantId },
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
knowledgeBaseId: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
contentPublishedAsText: true,
|
||||
language: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
knowledgeBase: { select: { tenantId: true } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
documents: rows
|
||||
.filter((row) => row.knowledgeBase.tenantId === tenantId)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
tenantId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
summary: row.description,
|
||||
content: row.contentPublishedAsText,
|
||||
tags: [],
|
||||
language: row.language,
|
||||
isPublished: row.publishedAt !== null,
|
||||
createdAt: serializeDate(row.createdAt),
|
||||
updatedAt: serializeDate(row.updatedAt),
|
||||
publishedAt: serializeDate(row.publishedAt),
|
||||
})),
|
||||
nextCursor: rows.length === pageSize ? (rows[rows.length - 1]?.id ?? null) : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createCrmContactsPager(prisma: SearchIndexerBulkPagerPrisma): SearchIndexerBulkPager {
|
||||
return async ({ tenantId, cursor, pageSize }) => {
|
||||
const rows = await findSearchRows(prisma, {
|
||||
tenantId,
|
||||
cursor,
|
||||
pageSize,
|
||||
entityName: "contact",
|
||||
});
|
||||
return {
|
||||
documents: rows.filter((row) => row.tenantId === tenantId && row.entity.name === "contact").map((row) => rowToContactDocument(row, tenantId)),
|
||||
nextCursor: rows.length === pageSize ? (rows[rows.length - 1]?.id ?? null) : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createRowsPager(prisma: SearchIndexerBulkPagerPrisma): SearchIndexerBulkPager {
|
||||
return async ({ tenantId, cursor, pageSize }) => {
|
||||
const rows = await findSearchRows(prisma, { tenantId, cursor, pageSize });
|
||||
return {
|
||||
documents: rows.filter((row) => row.tenantId === tenantId).map((row) => rowToRowsDocument(row, tenantId)),
|
||||
nextCursor: rows.length === pageSize ? (rows[rows.length - 1]?.id ?? null) : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createUsersPager(prisma: SearchIndexerBulkPagerPrisma): SearchIndexerBulkPager {
|
||||
return async ({ tenantId, cursor, pageSize }) => {
|
||||
const rows = await prisma.tenantUser.findMany({
|
||||
where: {
|
||||
...(cursor ? { id: { gt: cursor } } : {}),
|
||||
tenantId,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
userId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
active: true,
|
||||
admin: { select: { userId: true } },
|
||||
logs: {
|
||||
where: { action: "Login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
documents: rows.filter((row) => row.tenantId === tenantId).map((row) => tenantUserToUserDocument(row, tenantId)),
|
||||
nextCursor: rows.length === pageSize ? (rows[rows.length - 1]?.id ?? null) : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function findSearchRows(
|
||||
prisma: SearchIndexerBulkPagerPrisma,
|
||||
input: { tenantId: string; cursor: string | null; pageSize: number; entityName?: string }
|
||||
) {
|
||||
return prisma.row.findMany({
|
||||
where: {
|
||||
...(input.cursor ? { id: { gt: input.cursor } } : {}),
|
||||
tenantId: input.tenantId,
|
||||
deletedAt: null,
|
||||
...(input.entityName ? { entity: { name: input.entityName } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: input.pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
entityId: true,
|
||||
folio: true,
|
||||
createdByUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
entity: {
|
||||
select: {
|
||||
name: true,
|
||||
properties: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
values: {
|
||||
select: {
|
||||
propertyId: true,
|
||||
textValue: true,
|
||||
numberValue: true,
|
||||
dateValue: true,
|
||||
booleanValue: true,
|
||||
property: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function tenantUserToUserDocument(row: TenantUserSearchRow, tenantId: string): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
return {
|
||||
id: row.user.id,
|
||||
tenantId,
|
||||
firstName: row.user.firstName,
|
||||
lastName: row.user.lastName,
|
||||
email: row.user.email,
|
||||
type: row.type,
|
||||
admin: row.user.admin !== null,
|
||||
active: row.user.active && row.status === TENANT_USER_STATUS_ACTIVE,
|
||||
createdAt: serializeDate(row.user.createdAt),
|
||||
lastLoginAt: serializeDate(row.user.logs[0]?.createdAt ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToContactDocument(row: SearchRow, tenantId: string): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
firstName: getTextValue(row, "firstName"),
|
||||
lastName: getTextValue(row, "lastName"),
|
||||
email: getTextValue(row, "email"),
|
||||
company: getTextValue(row, "company"),
|
||||
jobTitle: getTextValue(row, "jobTitle"),
|
||||
status: getTextValue(row, "status"),
|
||||
ownerId: row.createdByUserId ?? "",
|
||||
createdAt: serializeDate(row.createdAt),
|
||||
updatedAt: serializeDate(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToRowsDocument(row: SearchRow, tenantId: string): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
entityId: row.entityId,
|
||||
entityName: row.entity.name,
|
||||
folio: typeof row.folio === "number" ? row.folio : 0,
|
||||
searchable: buildSearchableBlob(row),
|
||||
createdById: row.createdByUserId ?? "",
|
||||
createdAt: serializeDate(row.createdAt),
|
||||
updatedAt: serializeDate(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSearchableBlob(row: SearchRow): string {
|
||||
return row.entity.properties
|
||||
.filter((property) => SEARCHABLE_ROW_PROPERTY_TYPES.has(property.type))
|
||||
.map((property) => getTextValue(row, property.name))
|
||||
.filter((value) => value.length > 0)
|
||||
.join(" \n ");
|
||||
}
|
||||
|
||||
function getTextValue(row: SearchRow, propertyName: string): string {
|
||||
const property = row.entity.properties.find((item) => item.name === propertyName);
|
||||
const value = property ? row.values.find((item) => item.propertyId === property.id) : undefined;
|
||||
return normalizeRowValue(value).trim();
|
||||
}
|
||||
|
||||
function normalizeRowValue(value: RowSearchValue | undefined): string {
|
||||
if (!value) return "";
|
||||
if (value.textValue !== null) return value.textValue;
|
||||
if (value.numberValue !== null) return value.numberValue.toString();
|
||||
if (value.dateValue !== null) return serializeDate(value.dateValue) ?? "";
|
||||
if (value.booleanValue !== null) return String(value.booleanValue);
|
||||
return "";
|
||||
}
|
||||
|
||||
function serializeDate(value: Date | string | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSearchClient, crmContactsIndex, kbArticlesIndex, rowsIndex, searchInIndex, usersIndex } from "@toolctrl/search";
|
||||
import { createInMemoryMeili } from "@toolctrl/search/testing";
|
||||
import { runSearchIndexerJob, SearchIndexerJobError, parseSearchIndexJob } from "./search-indexer.js";
|
||||
import { createSearchIndexerBulkPagers, registeredSearchIndexerBulkPagerNames } from "./search-indexer-bulk-pagers.js";
|
||||
|
||||
function makeClient() {
|
||||
const meili = createInMemoryMeili();
|
||||
const client = createSearchClient({ url: "http://memory", meiliFactory: meili.factory });
|
||||
return { meili, client };
|
||||
}
|
||||
|
||||
describe("parseSearchIndexJob", () => {
|
||||
it("rejects unknown actions", () => {
|
||||
expect(() => parseSearchIndexJob({ payload: { action: "rename", indexName: "kb-articles", tenantId: "t" } })).toThrow(SearchIndexerJobError);
|
||||
});
|
||||
|
||||
it("rejects missing tenantId in payload", () => {
|
||||
expect(() => parseSearchIndexJob({ payload: { action: "upsert", indexName: "kb-articles" } })).toThrow(/tenantId/);
|
||||
});
|
||||
|
||||
it("accepts a well-formed upsert envelope", () => {
|
||||
const parsed = parseSearchIndexJob({
|
||||
outboxEventId: "evt_1",
|
||||
tenantId: "t1",
|
||||
eventType: "search.index.kb.upsert",
|
||||
payload: { action: "upsert", indexName: "kb-articles", tenantId: "t1", document: { id: "a", tenantId: "t1", title: "X" } },
|
||||
});
|
||||
expect(parsed.payload.action).toBe("upsert");
|
||||
expect(parsed.outboxEventId).toBe("evt_1");
|
||||
});
|
||||
|
||||
it("rejects envelope and payload tenant mismatches", () => {
|
||||
for (const payload of [
|
||||
{ action: "upsert", indexName: "kb-articles", tenantId: "t2", document: { id: "a", tenantId: "t2", title: "X" } },
|
||||
{ action: "delete", indexName: "kb-articles", tenantId: "t2", documentId: "a" },
|
||||
{ action: "bulk-reindex", indexName: "kb-articles", tenantId: "t2" },
|
||||
]) {
|
||||
expect(() =>
|
||||
parseSearchIndexJob({
|
||||
outboxEventId: "evt_1",
|
||||
tenantId: "t1",
|
||||
eventType: "search.index.kb.upsert",
|
||||
payload,
|
||||
})
|
||||
).toThrow(/tenantId mismatch/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSearchIndexerJob", () => {
|
||||
it("upserts a document and isolates it to the tenant", async () => {
|
||||
const { client } = makeClient();
|
||||
const data = {
|
||||
eventType: "search.index.kb.upsert",
|
||||
tenantId: "t1",
|
||||
payload: {
|
||||
action: "upsert",
|
||||
indexName: kbArticlesIndex.name,
|
||||
tenantId: "t1",
|
||||
document: { id: "a", tenantId: "t1", title: "Hello" },
|
||||
},
|
||||
};
|
||||
const result = await runSearchIndexerJob(data, { searchClient: client });
|
||||
expect(result).toMatchObject({ action: "upsert", documentsAffected: 1, tenantId: "t1" });
|
||||
|
||||
const hits = await searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t1", query: "Hello" });
|
||||
expect(hits.hits.map((h) => h.document.id)).toEqual(["a"]);
|
||||
const other = await searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t2", query: "Hello" });
|
||||
expect(other.hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletes a document", async () => {
|
||||
const { client } = makeClient();
|
||||
await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.upsert",
|
||||
tenantId: "t1",
|
||||
payload: { action: "upsert", indexName: kbArticlesIndex.name, tenantId: "t1", document: { id: "a", tenantId: "t1", title: "Hi" } },
|
||||
},
|
||||
{ searchClient: client }
|
||||
);
|
||||
const result = await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.delete",
|
||||
tenantId: "t1",
|
||||
payload: { action: "delete", indexName: kbArticlesIndex.name, tenantId: "t1", documentId: "a" },
|
||||
},
|
||||
{ searchClient: client }
|
||||
);
|
||||
expect(result.action).toBe("delete");
|
||||
const hits = await searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t1", query: "Hi" });
|
||||
expect(hits.hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs bulk-reindex via the supplied pager", async () => {
|
||||
const { client } = makeClient();
|
||||
const pager = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
documents: [
|
||||
{ id: "1", tenantId: "t1", title: "Doc 1" },
|
||||
{ id: "2", tenantId: "t1", title: "Doc 2" },
|
||||
],
|
||||
nextCursor: "cursor-1",
|
||||
})
|
||||
.mockResolvedValueOnce({ documents: [{ id: "3", tenantId: "t1", title: "Doc 3" }], nextCursor: null });
|
||||
const result = await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.bulk-reindex",
|
||||
tenantId: "t1",
|
||||
payload: { action: "bulk-reindex", indexName: kbArticlesIndex.name, tenantId: "t1" },
|
||||
},
|
||||
{
|
||||
searchClient: client,
|
||||
bulkPagers: { [kbArticlesIndex.name]: pager },
|
||||
bulkPageSize: 2,
|
||||
}
|
||||
);
|
||||
expect(result).toMatchObject({ action: "bulk-reindex", documentsAffected: 3, tenantId: "t1" });
|
||||
expect(pager).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails bulk-reindex when the pager cursor does not advance", async () => {
|
||||
const { client } = makeClient();
|
||||
const pager = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
documents: [{ id: "1", tenantId: "t1", title: "Doc 1" }],
|
||||
nextCursor: "cursor-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documents: [{ id: "2", tenantId: "t1", title: "Doc 2" }],
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.bulk-reindex",
|
||||
tenantId: "t1",
|
||||
payload: { action: "bulk-reindex", indexName: kbArticlesIndex.name, tenantId: "t1" },
|
||||
},
|
||||
{
|
||||
searchClient: client,
|
||||
bulkPagers: { [kbArticlesIndex.name]: pager },
|
||||
bulkPageSize: 1,
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({ code: "BULK_PAGER_STALLED" });
|
||||
expect(pager).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails bulk-reindex when maxBulkPages is reached before completion", async () => {
|
||||
const { client } = makeClient();
|
||||
const pager = vi.fn().mockResolvedValueOnce({
|
||||
documents: [{ id: "1", tenantId: "t1", title: "Doc 1" }],
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.bulk-reindex",
|
||||
tenantId: "t1",
|
||||
payload: { action: "bulk-reindex", indexName: kbArticlesIndex.name, tenantId: "t1" },
|
||||
},
|
||||
{
|
||||
searchClient: client,
|
||||
bulkPagers: { [kbArticlesIndex.name]: pager },
|
||||
bulkPageSize: 1,
|
||||
maxBulkPages: 1,
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({ code: "BULK_REINDEX_INCOMPLETE" });
|
||||
expect(pager).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces the tenant slice during bulk-reindex", async () => {
|
||||
const { client } = makeClient();
|
||||
await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.upsert",
|
||||
tenantId: "t1",
|
||||
payload: { action: "upsert", indexName: kbArticlesIndex.name, tenantId: "t1", document: { id: "stale", tenantId: "t1", title: "Stale Doc" } },
|
||||
},
|
||||
{ searchClient: client }
|
||||
);
|
||||
await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.upsert",
|
||||
tenantId: "t2",
|
||||
payload: { action: "upsert", indexName: kbArticlesIndex.name, tenantId: "t2", document: { id: "stale", tenantId: "t2", title: "Other Tenant" } },
|
||||
},
|
||||
{ searchClient: client }
|
||||
);
|
||||
|
||||
const result = await runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.bulk-reindex",
|
||||
tenantId: "t1",
|
||||
payload: { action: "bulk-reindex", indexName: kbArticlesIndex.name, tenantId: "t1" },
|
||||
},
|
||||
{
|
||||
searchClient: client,
|
||||
bulkPagers: {
|
||||
[kbArticlesIndex.name]: vi.fn().mockResolvedValueOnce({
|
||||
documents: [{ id: "fresh", tenantId: "t1", title: "Fresh Doc" }],
|
||||
nextCursor: null,
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ action: "bulk-reindex", documentsAffected: 1, tenantId: "t1" });
|
||||
await expect(searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t1", query: "Stale" })).resolves.toMatchObject({ hits: [] });
|
||||
await expect(searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t1", query: "Fresh" })).resolves.toMatchObject({ hits: [{ document: expect.objectContaining({ id: "fresh" }) }] });
|
||||
await expect(searchInIndex({ client, schema: kbArticlesIndex, tenantId: "t2", query: "Other" })).resolves.toMatchObject({ hits: [{ document: expect.objectContaining({ id: "stale" }) }] });
|
||||
});
|
||||
|
||||
it("throws when bulk-reindex has no pager registered for the index", async () => {
|
||||
const { client } = makeClient();
|
||||
await expect(
|
||||
runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.kb.bulk-reindex",
|
||||
tenantId: "t1",
|
||||
payload: { action: "bulk-reindex", indexName: kbArticlesIndex.name, tenantId: "t1" },
|
||||
},
|
||||
{ searchClient: client }
|
||||
)
|
||||
).rejects.toThrow(SearchIndexerJobError);
|
||||
});
|
||||
|
||||
it("registers a kb-articles bulk pager that maps tenant-scoped documents", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "article_1",
|
||||
knowledgeBaseId: "kb_1",
|
||||
slug: "hello",
|
||||
title: "Hello",
|
||||
description: "Summary",
|
||||
contentPublishedAsText: "Published text",
|
||||
language: "en",
|
||||
publishedAt: new Date("2026-01-02T03:04:05.000Z"),
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
knowledgeBase: { tenantId: "tenant_1" },
|
||||
},
|
||||
]);
|
||||
const pagers = createSearchIndexerBulkPagers({
|
||||
prisma: fakeSearchPagerPrisma({ knowledgeBaseArticle: { findMany } }),
|
||||
});
|
||||
const result = await pagers[kbArticlesIndex.name]?.({
|
||||
indexName: kbArticlesIndex.name,
|
||||
tenantId: "tenant_1",
|
||||
cursor: "article_0",
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
expect(registeredSearchIndexerBulkPagerNames).toContain(kbArticlesIndex.name);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { gt: "article_0" }, knowledgeBase: { tenantId: "tenant_1" } },
|
||||
take: 1,
|
||||
})
|
||||
);
|
||||
expect(result?.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "article_1",
|
||||
tenantId: "tenant_1",
|
||||
knowledgeBaseId: "kb_1",
|
||||
summary: "Summary",
|
||||
content: "Published text",
|
||||
isPublished: true,
|
||||
}),
|
||||
]);
|
||||
expect(result?.nextCursor).toBe("article_1");
|
||||
});
|
||||
|
||||
it("defensively filters kb-article bulk pager rows by tenant and terminates short pages", async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{
|
||||
id: "article_1",
|
||||
knowledgeBaseId: "kb_1",
|
||||
slug: "hello",
|
||||
title: "Hello",
|
||||
description: "Summary",
|
||||
contentPublishedAsText: "Published text",
|
||||
language: "de",
|
||||
publishedAt: "2026-01-02T03:04:05.000Z",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-03T00:00:00.000Z"),
|
||||
knowledgeBase: { tenantId: "tenant_1" },
|
||||
},
|
||||
{
|
||||
id: "article_2",
|
||||
knowledgeBaseId: "kb_2",
|
||||
slug: "other",
|
||||
title: "Other",
|
||||
description: "Other summary",
|
||||
contentPublishedAsText: "Other text",
|
||||
language: "en",
|
||||
publishedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
knowledgeBase: { tenantId: "tenant_2" },
|
||||
},
|
||||
]);
|
||||
const pagers = createSearchIndexerBulkPagers({
|
||||
prisma: fakeSearchPagerPrisma({ knowledgeBaseArticle: { findMany } }),
|
||||
});
|
||||
|
||||
const result = await pagers[kbArticlesIndex.name]?.({
|
||||
indexName: kbArticlesIndex.name,
|
||||
tenantId: "tenant_1",
|
||||
cursor: null,
|
||||
pageSize: 3,
|
||||
});
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { knowledgeBase: { tenantId: "tenant_1" } },
|
||||
orderBy: { id: "asc" },
|
||||
take: 3,
|
||||
})
|
||||
);
|
||||
expect(result?.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "article_1",
|
||||
tenantId: "tenant_1",
|
||||
language: "de",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
publishedAt: "2026-01-02T03:04:05.000Z",
|
||||
}),
|
||||
]);
|
||||
expect(result?.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("registers a rows bulk pager that maps searchable tenant rows", async () => {
|
||||
const findRows = vi.fn(async () => [
|
||||
makeSearchRow({
|
||||
id: "row_1",
|
||||
tenantId: "tenant_1",
|
||||
entityName: "account",
|
||||
values: {
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
hiddenNumber: 42,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const pagers = createSearchIndexerBulkPagers({
|
||||
prisma: fakeSearchPagerPrisma({ row: { findMany: findRows } }),
|
||||
});
|
||||
|
||||
const result = await pagers[rowsIndex.name]?.({
|
||||
indexName: rowsIndex.name,
|
||||
tenantId: "tenant_1",
|
||||
cursor: "row_0",
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
expect(registeredSearchIndexerBulkPagerNames).toContain(rowsIndex.name);
|
||||
expect(findRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { gt: "row_0" }, tenantId: "tenant_1", deletedAt: null },
|
||||
take: 1,
|
||||
})
|
||||
);
|
||||
expect(result?.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "row_1",
|
||||
tenantId: "tenant_1",
|
||||
entityId: "entity_1",
|
||||
entityName: "account",
|
||||
folio: 7,
|
||||
searchable: "Acme \n active",
|
||||
createdById: "user_1",
|
||||
}),
|
||||
]);
|
||||
expect(result?.nextCursor).toBe("row_1");
|
||||
});
|
||||
|
||||
it("registers a crm-contacts bulk pager from contact rows only", async () => {
|
||||
const findRows = vi.fn(async () => [
|
||||
makeSearchRow({
|
||||
id: "contact_1",
|
||||
tenantId: "tenant_1",
|
||||
entityName: "contact",
|
||||
values: {
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
company: "Analytical Engines",
|
||||
jobTitle: "Founder",
|
||||
status: "customer",
|
||||
},
|
||||
}),
|
||||
makeSearchRow({
|
||||
id: "contact_2",
|
||||
tenantId: "tenant_2",
|
||||
entityName: "contact",
|
||||
values: { firstName: "Other" },
|
||||
}),
|
||||
]);
|
||||
const pagers = createSearchIndexerBulkPagers({
|
||||
prisma: fakeSearchPagerPrisma({ row: { findMany: findRows } }),
|
||||
});
|
||||
|
||||
const result = await pagers[crmContactsIndex.name]?.({
|
||||
indexName: crmContactsIndex.name,
|
||||
tenantId: "tenant_1",
|
||||
cursor: null,
|
||||
pageSize: 3,
|
||||
});
|
||||
|
||||
expect(registeredSearchIndexerBulkPagerNames).toContain(crmContactsIndex.name);
|
||||
expect(findRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { tenantId: "tenant_1", deletedAt: null, entity: { name: "contact" } },
|
||||
orderBy: { id: "asc" },
|
||||
take: 3,
|
||||
})
|
||||
);
|
||||
expect(result?.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "contact_1",
|
||||
tenantId: "tenant_1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
company: "Analytical Engines",
|
||||
jobTitle: "Founder",
|
||||
status: "customer",
|
||||
ownerId: "user_1",
|
||||
}),
|
||||
]);
|
||||
expect(result?.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("registers a users bulk pager from tenant memberships", async () => {
|
||||
const findTenantUsers = vi.fn(async () => [
|
||||
{
|
||||
id: "tenant_user_1",
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
type: 1,
|
||||
status: 2,
|
||||
user: {
|
||||
id: "user_1",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
email: "ada@example.test",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
active: true,
|
||||
admin: { userId: "user_1" },
|
||||
logs: [{ createdAt: new Date("2026-01-03T00:00:00.000Z") }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tenant_user_2",
|
||||
tenantId: "tenant_2",
|
||||
userId: "user_2",
|
||||
type: 2,
|
||||
status: 2,
|
||||
user: {
|
||||
id: "user_2",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
email: "other@example.test",
|
||||
firstName: "Other",
|
||||
lastName: "Tenant",
|
||||
active: true,
|
||||
admin: null,
|
||||
logs: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const pagers = createSearchIndexerBulkPagers({
|
||||
prisma: fakeSearchPagerPrisma({ tenantUser: { findMany: findTenantUsers } }),
|
||||
});
|
||||
|
||||
const result = await pagers[usersIndex.name]?.({
|
||||
indexName: usersIndex.name,
|
||||
tenantId: "tenant_1",
|
||||
cursor: "tenant_user_0",
|
||||
pageSize: 2,
|
||||
});
|
||||
|
||||
expect(registeredSearchIndexerBulkPagerNames).toContain(usersIndex.name);
|
||||
expect(findTenantUsers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { gt: "tenant_user_0" }, tenantId: "tenant_1" },
|
||||
orderBy: { id: "asc" },
|
||||
take: 2,
|
||||
})
|
||||
);
|
||||
expect(result?.documents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user_1",
|
||||
tenantId: "tenant_1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
type: 1,
|
||||
admin: true,
|
||||
active: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
lastLoginAt: "2026-01-03T00:00:00.000Z",
|
||||
}),
|
||||
]);
|
||||
expect(result?.nextCursor).toBe("tenant_user_2");
|
||||
});
|
||||
|
||||
it("rejects payload pointing at an unknown index", async () => {
|
||||
const { client } = makeClient();
|
||||
await expect(
|
||||
runSearchIndexerJob(
|
||||
{
|
||||
eventType: "search.index.unknown.upsert",
|
||||
tenantId: "t1",
|
||||
payload: { action: "upsert", indexName: "does-not-exist", tenantId: "t1", document: { id: "a", tenantId: "t1" } },
|
||||
},
|
||||
{ searchClient: client }
|
||||
)
|
||||
).rejects.toThrow(/unknown search index/);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeSearchPagerPrisma(overrides: Partial<Parameters<typeof createSearchIndexerBulkPagers>[0]["prisma"]>) {
|
||||
return {
|
||||
knowledgeBaseArticle: { findMany: vi.fn(async () => []) },
|
||||
row: { findMany: vi.fn(async () => []) },
|
||||
tenantUser: { findMany: vi.fn(async () => []) },
|
||||
...overrides,
|
||||
} as Parameters<typeof createSearchIndexerBulkPagers>[0]["prisma"];
|
||||
}
|
||||
|
||||
function makeSearchRow(input: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
entityName: string;
|
||||
values: Record<string, string | number | boolean>;
|
||||
}) {
|
||||
const properties = Object.keys(input.values).map((name, index) => ({
|
||||
id: `property_${name}`,
|
||||
name,
|
||||
type: typeof input.values[name] === "string" ? (name === "hiddenNumber" ? 0 : 1) : 0,
|
||||
order: index,
|
||||
}));
|
||||
return {
|
||||
id: input.id,
|
||||
tenantId: input.tenantId,
|
||||
entityId: "entity_1",
|
||||
folio: 7,
|
||||
createdByUserId: "user_1",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
entity: {
|
||||
name: input.entityName,
|
||||
properties,
|
||||
},
|
||||
values: properties.map((property) => {
|
||||
const value = input.values[property.name];
|
||||
return {
|
||||
propertyId: property.id,
|
||||
textValue: typeof value === "string" ? value : null,
|
||||
numberValue: typeof value === "number" ? value : null,
|
||||
dateValue: null,
|
||||
booleanValue: typeof value === "boolean" ? value : null,
|
||||
property,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Search-indexer worker job.
|
||||
*
|
||||
* Subscribes to the `outbox.search-index` BullMQ queue (populated by the
|
||||
* outbox dispatcher in apps/worker/src/outbox/dispatcher.ts) and projects
|
||||
* domain events onto Meilisearch:
|
||||
*
|
||||
* - `search.index.<entity>.upsert` → upsert into the named index
|
||||
* - `search.index.<entity>.delete` → delete document by primary key
|
||||
* - `search.index.<entity>.bulk-reindex` → page over Postgres in
|
||||
* 1000-row batches and replace the tenant slice
|
||||
*
|
||||
* All writes go through the tenant-scoped wrapper in @toolctrl/search so
|
||||
* the indexer cannot accidentally cross-pollinate tenants.
|
||||
*/
|
||||
|
||||
import { ALL_SEARCH_INDEXES, bulkIndex, deleteFromIndex, getSearchIndexSchema, type SearchClient, type SearchIndexSchema } from "@toolctrl/search";
|
||||
|
||||
export const SEARCH_INDEX_OUTBOX_QUEUE = "outbox.search-index" as const;
|
||||
|
||||
export type SearchIndexerLogger = {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
};
|
||||
|
||||
export type SearchIndexerBulkPager = (input: {
|
||||
indexName: string;
|
||||
tenantId: string;
|
||||
cursor: string | null;
|
||||
pageSize: number;
|
||||
}) => Promise<SearchIndexerBulkPage>;
|
||||
|
||||
export type SearchIndexerBulkPage = {
|
||||
documents: ReadonlyArray<Record<string, unknown> & { id: string | number }>;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export interface SearchIndexerDependencies {
|
||||
readonly searchClient: SearchClient;
|
||||
/** Provide a pager so the worker can stream rows without coupling to a
|
||||
* specific Prisma model. The producer (CLI / route) decides which
|
||||
* table to scan. Returning `nextCursor=null` terminates the loop. */
|
||||
readonly bulkPagers?: Partial<Record<string, SearchIndexerBulkPager>>;
|
||||
readonly logger?: SearchIndexerLogger;
|
||||
/** Cap how many pages a single bulk-reindex job will walk. Protects
|
||||
* against runaway scans; the job fails when the cap is hit so BullMQ
|
||||
* can retry instead of reporting a partial rebuild as complete. */
|
||||
readonly maxBulkPages?: number;
|
||||
/** Override the page size used by bulk-reindex. */
|
||||
readonly bulkPageSize?: number;
|
||||
}
|
||||
|
||||
export type SearchEventVerb = "upsert" | "delete" | "bulk-reindex";
|
||||
|
||||
export interface SearchIndexJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly eventType: string;
|
||||
readonly payload: SearchIndexJobPayload;
|
||||
readonly dispatchKey?: string | null;
|
||||
readonly retries?: number;
|
||||
}
|
||||
|
||||
export type SearchIndexJobPayload =
|
||||
| SearchUpsertPayload
|
||||
| SearchDeletePayload
|
||||
| SearchBulkReindexPayload;
|
||||
|
||||
export interface SearchUpsertPayload {
|
||||
readonly action: "upsert";
|
||||
readonly indexName: string;
|
||||
readonly tenantId: string;
|
||||
readonly document: Record<string, unknown> & { id: string | number };
|
||||
}
|
||||
|
||||
export interface SearchDeletePayload {
|
||||
readonly action: "delete";
|
||||
readonly indexName: string;
|
||||
readonly tenantId: string;
|
||||
readonly documentId: string | number;
|
||||
}
|
||||
|
||||
export interface SearchBulkReindexPayload {
|
||||
readonly action: "bulk-reindex";
|
||||
readonly indexName: string;
|
||||
/** "*" reindexes every tenant. Concrete IDs scope the job to a single
|
||||
* tenant slice. */
|
||||
readonly tenantId: string;
|
||||
}
|
||||
|
||||
export class SearchIndexerJobError extends Error {
|
||||
override readonly name = "SearchIndexerJobError";
|
||||
readonly code:
|
||||
| "UNKNOWN_INDEX"
|
||||
| "UNKNOWN_ACTION"
|
||||
| "MISSING_BULK_PAGER"
|
||||
| "INVALID_PAYLOAD"
|
||||
| "BULK_PAGER_STALLED"
|
||||
| "BULK_REINDEX_INCOMPLETE";
|
||||
constructor(message: string, code: SearchIndexerJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSearchIndexJob(data: unknown): SearchIndexJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new SearchIndexerJobError("search index job missing data envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
const payload = envelope.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new SearchIndexerJobError("search index job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const inner = payload as Record<string, unknown>;
|
||||
const action = inner.action;
|
||||
if (action !== "upsert" && action !== "delete" && action !== "bulk-reindex") {
|
||||
throw new SearchIndexerJobError(`unknown search index action ${String(action)}`, "UNKNOWN_ACTION");
|
||||
}
|
||||
const indexName = typeof inner.indexName === "string" ? inner.indexName : "";
|
||||
if (indexName.length === 0) {
|
||||
throw new SearchIndexerJobError("search index job missing indexName", "INVALID_PAYLOAD");
|
||||
}
|
||||
const tenantIdInner = typeof inner.tenantId === "string" ? inner.tenantId : "";
|
||||
if (tenantIdInner.length === 0) {
|
||||
throw new SearchIndexerJobError("search index job missing payload.tenantId", "INVALID_PAYLOAD");
|
||||
}
|
||||
const tenantIdEnvelope = typeof envelope.tenantId === "string" ? envelope.tenantId : null;
|
||||
if (tenantIdEnvelope !== null && tenantIdEnvelope !== tenantIdInner) {
|
||||
throw new SearchIndexerJobError("search index job tenantId mismatch between envelope and payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId: tenantIdEnvelope,
|
||||
eventType: typeof envelope.eventType === "string" ? envelope.eventType : "",
|
||||
payload: inner as unknown as SearchIndexJobPayload,
|
||||
dispatchKey: typeof envelope.dispatchKey === "string" ? envelope.dispatchKey : null,
|
||||
retries: typeof envelope.retries === "number" ? envelope.retries : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function tenantFilter(tenantId: string): string {
|
||||
return `tenantId = "${tenantId.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function resolveSchema(indexName: string): SearchIndexSchema {
|
||||
const schema = getSearchIndexSchema(indexName);
|
||||
if (!schema) {
|
||||
throw new SearchIndexerJobError(`unknown search index "${indexName}"`, "UNKNOWN_INDEX");
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export type SearchIndexerResult = {
|
||||
action: SearchEventVerb;
|
||||
indexName: string;
|
||||
tenantId: string;
|
||||
documentsAffected: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process one BullMQ job from `outbox.search-index`. Returns a result
|
||||
* record for logging; throws on retryable failures so BullMQ can
|
||||
* exponentially back off via its own default policy.
|
||||
*/
|
||||
export async function runSearchIndexerJob(
|
||||
data: unknown,
|
||||
deps: SearchIndexerDependencies
|
||||
): Promise<SearchIndexerResult> {
|
||||
const job = parseSearchIndexJob(data);
|
||||
const schema = resolveSchema(job.payload.indexName);
|
||||
const { searchClient, logger } = deps;
|
||||
|
||||
if (job.payload.action === "upsert") {
|
||||
await bulkIndex({
|
||||
client: searchClient,
|
||||
schema,
|
||||
tenantId: job.payload.tenantId,
|
||||
documents: [job.payload.document],
|
||||
});
|
||||
logger?.info?.(
|
||||
{ component: "search-indexer", indexName: schema.name, tenantId: job.payload.tenantId, documentId: job.payload.document.id },
|
||||
"search-indexer upserted document"
|
||||
);
|
||||
return { action: "upsert", indexName: schema.name, tenantId: job.payload.tenantId, documentsAffected: 1 };
|
||||
}
|
||||
|
||||
if (job.payload.action === "delete") {
|
||||
await deleteFromIndex({
|
||||
client: searchClient,
|
||||
schema,
|
||||
tenantId: job.payload.tenantId,
|
||||
documentIds: [job.payload.documentId],
|
||||
});
|
||||
logger?.info?.(
|
||||
{ component: "search-indexer", indexName: schema.name, tenantId: job.payload.tenantId, documentId: job.payload.documentId },
|
||||
"search-indexer deleted document"
|
||||
);
|
||||
return { action: "delete", indexName: schema.name, tenantId: job.payload.tenantId, documentsAffected: 1 };
|
||||
}
|
||||
|
||||
// bulk-reindex
|
||||
const pager = deps.bulkPagers?.[schema.name];
|
||||
if (!pager) {
|
||||
throw new SearchIndexerJobError(
|
||||
`search-indexer has no bulk pager registered for "${schema.name}"`,
|
||||
"MISSING_BULK_PAGER"
|
||||
);
|
||||
}
|
||||
const pageSize = deps.bulkPageSize ?? 1000;
|
||||
const maxPages = deps.maxBulkPages ?? 1000;
|
||||
let cursor: string | null = null;
|
||||
let total = 0;
|
||||
await searchClient.index(schema.name).deleteByFilter(tenantFilter(job.payload.tenantId));
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const previousCursor: string | null = cursor;
|
||||
const result = await pager({ indexName: schema.name, tenantId: job.payload.tenantId, cursor, pageSize });
|
||||
if (result.documents.length > 0) {
|
||||
await bulkIndex({
|
||||
client: searchClient,
|
||||
schema,
|
||||
tenantId: job.payload.tenantId,
|
||||
documents: result.documents as readonly (Record<string, unknown> & { id: string | number })[],
|
||||
});
|
||||
total += result.documents.length;
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
if (cursor === null) break;
|
||||
if (cursor === previousCursor) {
|
||||
throw new SearchIndexerJobError(
|
||||
`search-indexer bulk pager for "${schema.name}" did not advance cursor "${String(cursor)}"`,
|
||||
"BULK_PAGER_STALLED"
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cursor !== null) {
|
||||
throw new SearchIndexerJobError(
|
||||
`search-indexer bulk reindex for "${schema.name}" stopped after ${maxPages} pages before completion`,
|
||||
"BULK_REINDEX_INCOMPLETE"
|
||||
);
|
||||
}
|
||||
logger?.info?.(
|
||||
{ component: "search-indexer", indexName: schema.name, tenantId: job.payload.tenantId, documents: total },
|
||||
"search-indexer bulk reindex complete"
|
||||
);
|
||||
return { action: "bulk-reindex", indexName: schema.name, tenantId: job.payload.tenantId, documentsAffected: total };
|
||||
}
|
||||
|
||||
/** Convenience: enumerate every registered index schema name. */
|
||||
export function allSearchIndexNames(): readonly string[] {
|
||||
return ALL_SEARCH_INDEXES.map((schema) => schema.name);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// P4 Tasks (AK-P4b) §7 — 30-day auto-archive cron (DoD 5).
|
||||
//
|
||||
// Daily maintenance job that archives completed tasks (`status = done`,
|
||||
// `completedAt <= now - 30d`, not yet archived) by setting `archivedAt`. The
|
||||
// execution logic lives in the maintenance handler (handlers.ts) which holds the
|
||||
// Prisma client; this file only carries the config + the idempotent scheduler
|
||||
// registration (BullMQ `upsertJobScheduler`, like the other retention crons).
|
||||
|
||||
export const TASK_ARCHIVE_RETENTION_JOB_ID = "task-archive-retention" as const;
|
||||
export const TASK_ARCHIVE_RETENTION_EVERY_MS = 24 * 60 * 60 * 1000;
|
||||
/** Completed tasks older than this are auto-archived. 30 days = 720 hours. */
|
||||
export const TASK_ARCHIVE_RETENTION_DEFAULT_HOURS = 720;
|
||||
export const TASK_ARCHIVE_RETENTION_DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
export function resolveTaskArchiveRetentionCronConfig(env: NodeJS.ProcessEnv = process.env): {
|
||||
olderThanHours: number;
|
||||
take: number;
|
||||
} {
|
||||
return {
|
||||
olderThanHours: parsePositiveInteger(env.TASK_ARCHIVE_RETENTION_HOURS, TASK_ARCHIVE_RETENTION_DEFAULT_HOURS),
|
||||
take: parsePositiveInteger(env.TASK_ARCHIVE_RETENTION_BATCH_SIZE, TASK_ARCHIVE_RETENTION_DEFAULT_BATCH_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerTaskArchiveRetentionCron(
|
||||
queue: {
|
||||
upsertJobScheduler: (schedulerId: string, options: { every: number }, template: { name: string; data?: Record<string, unknown> }) => Promise<unknown>;
|
||||
},
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
const config = resolveTaskArchiveRetentionCronConfig(env);
|
||||
await queue.upsertJobScheduler(
|
||||
TASK_ARCHIVE_RETENTION_JOB_ID,
|
||||
{ every: TASK_ARCHIVE_RETENTION_EVERY_MS },
|
||||
{
|
||||
name: TASK_ARCHIVE_RETENTION_JOB_ID,
|
||||
data: {
|
||||
kind: "cleanup.run",
|
||||
task: TASK_ARCHIVE_RETENTION_JOB_ID,
|
||||
olderThanHours: config.olderThanHours,
|
||||
take: config.take,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { reminderChannel, reminderMessageKey, reminderScanBounds, reminderSourceKey, selectReminderWindows } from "./task-reminder-cron.js";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
describe("selectReminderWindows", () => {
|
||||
const deadline = new Date("2026-06-25T12:00:00.000Z");
|
||||
|
||||
it("selects nothing before the 24h-before window opens", () => {
|
||||
const now = new Date(deadline.getTime() - DAY - HOUR);
|
||||
expect(selectReminderWindows(deadline, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it("selects only 'before' inside the 24h-before window", () => {
|
||||
const now = new Date(deadline.getTime() - 2 * HOUR);
|
||||
expect(selectReminderWindows(deadline, now)).toEqual(["before"]);
|
||||
});
|
||||
|
||||
it("selects 'before' + 'due' at the deadline", () => {
|
||||
expect(selectReminderWindows(deadline, deadline)).toEqual(["before", "due"]);
|
||||
});
|
||||
|
||||
it("selects all three once the 24h-after window opens", () => {
|
||||
const now = new Date(deadline.getTime() + DAY + HOUR);
|
||||
expect(selectReminderWindows(deadline, now)).toEqual(["before", "due", "after"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder helpers", () => {
|
||||
it("escalates only the after-window to EMAIL", () => {
|
||||
expect(reminderChannel("before")).toBe("IN_APP");
|
||||
expect(reminderChannel("due")).toBe("IN_APP");
|
||||
expect(reminderChannel("after")).toBe("EMAIL");
|
||||
});
|
||||
|
||||
it("derives stable, per-window message + source keys (notify dedup)", () => {
|
||||
expect(reminderMessageKey("before")).toBe("tasks.reminder.before");
|
||||
expect(reminderSourceKey("t1", "after")).toBe("task-reminder-t1-after");
|
||||
// Distinct windows -> distinct source keys so each fires exactly once.
|
||||
expect(reminderSourceKey("t1", "before")).not.toBe(reminderSourceKey("t1", "due"));
|
||||
});
|
||||
|
||||
it("bounds the scan to [-7d, +24h] around now", () => {
|
||||
const now = new Date("2026-06-25T12:00:00.000Z");
|
||||
const { deadlineGte, deadlineLte } = reminderScanBounds(now);
|
||||
expect(deadlineGte.getTime()).toBe(now.getTime() - 7 * DAY);
|
||||
expect(deadlineLte.getTime()).toBe(now.getTime() + DAY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// P4 Tasks (AK-P4b) §7 — task reminder sweep (DoD 4).
|
||||
//
|
||||
// Hourly maintenance job. For every open task with a deadline it sends reminders
|
||||
// via notify() at three windows: 24h before, at the deadline, and 24h after
|
||||
// (the after-window escalates to EMAIL). Idempotency is carried by notify()'s
|
||||
// `sourceKey` upsert (one delivery row per task+window+user), so re-running the
|
||||
// sweep never double-sends and no per-job bookkeeping or delayed-job rescheduling
|
||||
// is needed on deadline changes / completion.
|
||||
//
|
||||
// The execution logic (querying tasks, resolving recipients, calling notify())
|
||||
// lives in the maintenance handler which holds the Prisma client; this file
|
||||
// carries the config + scheduler registration + the PURE window-selection core
|
||||
// (unit-tested without a DB).
|
||||
|
||||
export const TASK_REMINDER_SWEEP_JOB_ID = "task-reminder-sweep" as const;
|
||||
/** Hourly cadence: fine-grained enough for "at deadline" within an hour. */
|
||||
export const TASK_REMINDER_SWEEP_EVERY_MS = 60 * 60 * 1000;
|
||||
export const TASK_REMINDER_SWEEP_DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ReminderWindow = "before" | "due" | "after";
|
||||
|
||||
/** Channel per window: the after-window escalates to EMAIL (DoD 4). */
|
||||
export function reminderChannel(window: ReminderWindow): "IN_APP" | "EMAIL" {
|
||||
return window === "after" ? "EMAIL" : "IN_APP";
|
||||
}
|
||||
|
||||
export function reminderMessageKey(window: ReminderWindow): string {
|
||||
return `tasks.reminder.${window}`;
|
||||
}
|
||||
|
||||
export function reminderSourceKey(taskId: string, window: ReminderWindow): string {
|
||||
return `task-reminder-${taskId}-${window}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: which reminder windows have started for a task with `deadline` at time
|
||||
* `now`. Windows are cumulative — a task seen well past its deadline qualifies
|
||||
* for all three (each fires once, deduped by notify() sourceKey).
|
||||
* before: now >= deadline - 24h
|
||||
* due: now >= deadline
|
||||
* after: now >= deadline + 24h
|
||||
*/
|
||||
export function selectReminderWindows(deadline: Date, now: Date): ReminderWindow[] {
|
||||
const t = now.getTime();
|
||||
const d = deadline.getTime();
|
||||
const windows: ReminderWindow[] = [];
|
||||
if (t >= d - DAY_MS) windows.push("before");
|
||||
if (t >= d) windows.push("due");
|
||||
if (t >= d + DAY_MS) windows.push("after");
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: lower/upper deadline bounds to scan in one sweep. We only need tasks
|
||||
* whose earliest window has started (deadline <= now + 24h) and whose latest
|
||||
* window is still fresh enough to matter (deadline >= now - 7d), keeping the
|
||||
* query bounded while notify() dedup guarantees correctness.
|
||||
*/
|
||||
export function reminderScanBounds(now: Date): { deadlineGte: Date; deadlineLte: Date } {
|
||||
return {
|
||||
deadlineGte: new Date(now.getTime() - 7 * DAY_MS),
|
||||
deadlineLte: new Date(now.getTime() + DAY_MS),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTaskReminderSweepConfig(env: NodeJS.ProcessEnv = process.env): { take: number } {
|
||||
return { take: parsePositiveInteger(env.TASK_REMINDER_SWEEP_BATCH_SIZE, TASK_REMINDER_SWEEP_DEFAULT_BATCH_SIZE) };
|
||||
}
|
||||
|
||||
export async function registerTaskReminderSweepCron(
|
||||
queue: {
|
||||
upsertJobScheduler: (schedulerId: string, options: { every: number }, template: { name: string; data?: Record<string, unknown> }) => Promise<unknown>;
|
||||
},
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
const config = resolveTaskReminderSweepConfig(env);
|
||||
await queue.upsertJobScheduler(
|
||||
TASK_REMINDER_SWEEP_JOB_ID,
|
||||
{ every: TASK_REMINDER_SWEEP_EVERY_MS },
|
||||
{
|
||||
name: TASK_REMINDER_SWEEP_JOB_ID,
|
||||
data: { kind: "cleanup.run", task: TASK_REMINDER_SWEEP_JOB_ID, take: config.take },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bootstrapTransactionalMailOutbox, isTransactionalMailOutboxRequired } from "./transactional-mail-outbox-bootstrap.js";
|
||||
import { TRANSACTIONAL_MAIL_OUTBOX_QUEUE } from "./transactional-mail-outbox.js";
|
||||
|
||||
function buildHandlerDeps() {
|
||||
return {
|
||||
enqueueMail: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapTransactionalMailOutbox", () => {
|
||||
it("disables itself outside the mail worker group", () => {
|
||||
const res = bootstrapTransactionalMailOutbox({ WORKER_QUEUE_GROUP: "general" } as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/non-mail/);
|
||||
});
|
||||
|
||||
it("disables itself when the env flag is false", () => {
|
||||
const res = bootstrapTransactionalMailOutbox({
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
TRANSACTIONAL_MAIL_OUTBOX_ENABLED: "false",
|
||||
} as Record<string, string>);
|
||||
expect(res.status).toBe("disabled");
|
||||
if (res.status === "disabled") expect(res.reason).toMatch(/TRANSACTIONAL_MAIL_OUTBOX_ENABLED/);
|
||||
});
|
||||
|
||||
it("subscribes to outbox.mail on the mail worker group", () => {
|
||||
const create = vi.fn(() => ({ name: TRANSACTIONAL_MAIL_OUTBOX_QUEUE, close: async () => undefined }));
|
||||
const res = bootstrapTransactionalMailOutbox(
|
||||
{ WORKER_QUEUE_GROUP: "mail" } as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(res.status).toBe("started");
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
TRANSACTIONAL_MAIL_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours TRANSACTIONAL_MAIL_OUTBOX_CONCURRENCY", () => {
|
||||
const create = vi.fn(() => ({ name: TRANSACTIONAL_MAIL_OUTBOX_QUEUE, close: async () => undefined }));
|
||||
bootstrapTransactionalMailOutbox(
|
||||
{
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
TRANSACTIONAL_MAIL_OUTBOX_CONCURRENCY: "9",
|
||||
} as Record<string, string>,
|
||||
{ handlerDeps: buildHandlerDeps(), createWorkerImpl: create },
|
||||
);
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
TRANSACTIONAL_MAIL_OUTBOX_QUEUE,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ concurrency: 9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats required mode as mail-group only", () => {
|
||||
expect(
|
||||
isTransactionalMailOutboxRequired({
|
||||
WORKER_QUEUE_GROUP: "mail",
|
||||
TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true",
|
||||
} as Record<string, string>),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransactionalMailOutboxRequired({
|
||||
WORKER_QUEUE_GROUP: "general",
|
||||
TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true",
|
||||
} as Record<string, string>),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { EnvRecord } from "@toolctrl/config";
|
||||
|
||||
import {
|
||||
runTransactionalMailOutboxJob,
|
||||
TRANSACTIONAL_MAIL_OUTBOX_QUEUE,
|
||||
type TransactionalMailOutboxDeps,
|
||||
} from "./transactional-mail-outbox.js";
|
||||
import type { FormSubmissionRouteLogger } from "./form-submission-route.js";
|
||||
|
||||
export type TransactionalMailOutboxBootstrapEnv = EnvRecord & {
|
||||
WORKER_QUEUE_GROUP?: string;
|
||||
TRANSACTIONAL_MAIL_OUTBOX_ENABLED?: string;
|
||||
TRANSACTIONAL_MAIL_OUTBOX_CONCURRENCY?: string;
|
||||
};
|
||||
|
||||
export interface TransactionalMailOutboxWorkerHandle {
|
||||
readonly name: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TransactionalMailOutboxBootstrapDeps {
|
||||
readonly logger?: FormSubmissionRouteLogger;
|
||||
readonly handlerDeps?: TransactionalMailOutboxDeps;
|
||||
readonly createWorkerImpl?: (
|
||||
queueName: string,
|
||||
processor: (job: { data: unknown }) => Promise<unknown>,
|
||||
options: { concurrency?: number },
|
||||
) => TransactionalMailOutboxWorkerHandle;
|
||||
}
|
||||
|
||||
export type TransactionalMailOutboxBootstrapResult =
|
||||
| { readonly status: "disabled"; readonly reason: string }
|
||||
| { readonly status: "started"; readonly handle: TransactionalMailOutboxWorkerHandle };
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
|
||||
export function isTransactionalMailOutboxRequired(env: TransactionalMailOutboxBootstrapEnv, workerGroup = env.WORKER_QUEUE_GROUP): boolean {
|
||||
return workerGroup === "mail" && env.TRANSACTIONAL_MAIL_OUTBOX_REQUIRED === "true";
|
||||
}
|
||||
|
||||
export function bootstrapTransactionalMailOutbox(
|
||||
env: TransactionalMailOutboxBootstrapEnv,
|
||||
deps: TransactionalMailOutboxBootstrapDeps = {},
|
||||
): TransactionalMailOutboxBootstrapResult {
|
||||
if (env.WORKER_QUEUE_GROUP && env.WORKER_QUEUE_GROUP !== "mail") {
|
||||
return { status: "disabled", reason: "non-mail worker group" };
|
||||
}
|
||||
if (env.TRANSACTIONAL_MAIL_OUTBOX_ENABLED === "false") {
|
||||
return { status: "disabled", reason: "TRANSACTIONAL_MAIL_OUTBOX_ENABLED=false" };
|
||||
}
|
||||
if (!deps.handlerDeps) {
|
||||
return { status: "disabled", reason: "no handler deps wired (host must supply mail enqueue)" };
|
||||
}
|
||||
if (!deps.createWorkerImpl) {
|
||||
return { status: "disabled", reason: "no worker factory wired" };
|
||||
}
|
||||
|
||||
const concurrency = parsePositiveInt(env.TRANSACTIONAL_MAIL_OUTBOX_CONCURRENCY, DEFAULT_CONCURRENCY);
|
||||
const handlerDeps = deps.handlerDeps;
|
||||
const processor = (job: { data: unknown }) => runTransactionalMailOutboxJob(job.data, handlerDeps);
|
||||
const handle = deps.createWorkerImpl(TRANSACTIONAL_MAIL_OUTBOX_QUEUE, processor, { concurrency });
|
||||
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
component: "transactional-mail-outbox",
|
||||
queue: TRANSACTIONAL_MAIL_OUTBOX_QUEUE,
|
||||
concurrency,
|
||||
},
|
||||
"transactional mail outbox bridge started",
|
||||
);
|
||||
return { status: "started", handle };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
import {
|
||||
parseTransactionalMailOutboxJob,
|
||||
runTransactionalMailOutboxJob,
|
||||
TRANSACTIONAL_MAIL_OUTBOX_QUEUE,
|
||||
TransactionalMailOutboxJobError,
|
||||
} from "./transactional-mail-outbox.js";
|
||||
|
||||
function buildGenericJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "outbox_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "mail.transactional.send",
|
||||
dispatchKey: "mail:invite:inv_1",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
to: ["ada@example.com"],
|
||||
subject: "Welcome",
|
||||
text: "Hello",
|
||||
priorityOverride: "critical",
|
||||
metadata: { inviteId: "inv_1" },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildFormSubmissionJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
outboxEventId: "outbox_form_1",
|
||||
tenantId: "tenant_1",
|
||||
eventType: "mail.transactional.form-submission",
|
||||
dispatchKey: "form-submission-mail:sub_1",
|
||||
payload: {
|
||||
submissionId: "sub_1",
|
||||
formId: "form_1",
|
||||
tenantId: "tenant_1",
|
||||
recipients: ["ops@example.com", "alerts@example.com"],
|
||||
values: { email: "ada@example.com", message: "<hello>" },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TRANSACTIONAL_MAIL_OUTBOX_QUEUE", () => {
|
||||
it("matches the outbox mail queue", () => {
|
||||
expect(TRANSACTIONAL_MAIL_OUTBOX_QUEUE).toBe("outbox.mail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTransactionalMailOutboxJob", () => {
|
||||
it("accepts supported mail event types", () => {
|
||||
expect(parseTransactionalMailOutboxJob(buildGenericJob()).eventType).toBe("mail.transactional.send");
|
||||
expect(parseTransactionalMailOutboxJob(buildFormSubmissionJob()).eventType).toBe("mail.transactional.form-submission");
|
||||
});
|
||||
|
||||
it("rejects unsupported mail event types", () => {
|
||||
expect(() => parseTransactionalMailOutboxJob({ ...buildGenericJob(), eventType: "mail.marketing.send" })).toThrow(
|
||||
TransactionalMailOutboxJobError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects envelope and payload tenant mismatches", () => {
|
||||
expect(() => parseTransactionalMailOutboxJob(buildGenericJob({ tenantId: "tenant_2" }))).toThrow(/tenantId mismatch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTransactionalMailOutboxJob", () => {
|
||||
it("enqueues a generic transactional mail job with outbox idempotency and priority", async () => {
|
||||
const enqueueMail = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runTransactionalMailOutboxJob(buildGenericJob(), { enqueueMail });
|
||||
|
||||
expect(result).toEqual({ enqueued: true, queueName: queueNames.email, jobName: "send-transactional-email" });
|
||||
expect(enqueueMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueName: queueNames.email,
|
||||
jobName: "send-transactional-email",
|
||||
payload: expect.objectContaining({
|
||||
kind: "mail.transactional",
|
||||
tenantId: "tenant_1",
|
||||
to: ["ada@example.com"],
|
||||
subject: "Welcome",
|
||||
text: "Hello",
|
||||
metadata: expect.objectContaining({
|
||||
source: "outbox-mail",
|
||||
outboxEventId: "outbox_1",
|
||||
dispatchKey: "mail:invite:inv_1",
|
||||
inviteId: "inv_1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
idempotencyKey: "outbox_1",
|
||||
priorityOverride: "critical",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders form-submission follow-up notifications into transactional mail", async () => {
|
||||
const enqueueMail = vi.fn(async () => undefined);
|
||||
|
||||
await runTransactionalMailOutboxJob(buildFormSubmissionJob(), { enqueueMail });
|
||||
|
||||
expect(enqueueMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
kind: "mail.transactional",
|
||||
subject: "New form submission",
|
||||
tags: ["form-submission"],
|
||||
to: ["ops@example.com", "alerts@example.com"],
|
||||
text: expect.stringContaining("Submission: sub_1"),
|
||||
html: expect.stringContaining("<hello>"),
|
||||
metadata: expect.objectContaining({
|
||||
source: "form-submission",
|
||||
formId: "form_1",
|
||||
submissionId: "sub_1",
|
||||
outboxEventId: "outbox_form_1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ tenantId: "tenant_1", idempotencyKey: "outbox_form_1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects generic mail without body or template", async () => {
|
||||
await expect(
|
||||
runTransactionalMailOutboxJob(buildGenericJob({ text: undefined, html: undefined, templateId: undefined }), {
|
||||
enqueueMail: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow(/body\/template/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Bridges transactional mail outbox events onto the existing
|
||||
* `toolctrl.email` send-transactional-email processor.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildTransactionalMailJob,
|
||||
queueNames,
|
||||
type QueueEnqueueOptions,
|
||||
type TransactionalMailJobDescriptor,
|
||||
type TransactionalMailJobInput,
|
||||
} from "@toolctrl/queue";
|
||||
|
||||
export const TRANSACTIONAL_MAIL_OUTBOX_QUEUE = "outbox.mail" as const;
|
||||
|
||||
type TransactionalMailOutboxEventType = "mail.transactional.send" | "mail.transactional.form-submission";
|
||||
|
||||
export interface TransactionalMailOutboxJobData {
|
||||
readonly outboxEventId?: string;
|
||||
readonly tenantId: string;
|
||||
readonly eventType: TransactionalMailOutboxEventType;
|
||||
readonly dispatchKey?: string | null;
|
||||
readonly payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TransactionalMailOutboxDeps {
|
||||
readonly enqueueMail: (
|
||||
descriptor: TransactionalMailJobDescriptor,
|
||||
options: QueueEnqueueOptions,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class TransactionalMailOutboxJobError extends Error {
|
||||
override readonly name = "TransactionalMailOutboxJobError";
|
||||
readonly code: "INVALID_PAYLOAD" | "INVALID_EVENT_TYPE" | "TENANT_MISMATCH";
|
||||
|
||||
constructor(message: string, code: TransactionalMailOutboxJobError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTransactionalMailOutboxJob(data: unknown): TransactionalMailOutboxJobData {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail job missing envelope", "INVALID_PAYLOAD");
|
||||
}
|
||||
const envelope = data as Record<string, unknown>;
|
||||
if (envelope.eventType !== "mail.transactional.send" && envelope.eventType !== "mail.transactional.form-submission") {
|
||||
throw new TransactionalMailOutboxJobError(
|
||||
`transactional mail rejected eventType ${JSON.stringify(envelope.eventType)}`,
|
||||
"INVALID_EVENT_TYPE",
|
||||
);
|
||||
}
|
||||
const tenantId = typeof envelope.tenantId === "string" ? envelope.tenantId : "";
|
||||
if (!tenantId) {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail job missing tenantId", "INVALID_PAYLOAD");
|
||||
}
|
||||
const payload = envelope.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail job missing payload", "INVALID_PAYLOAD");
|
||||
}
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadTenantId = typeof payloadRecord.tenantId === "string" ? payloadRecord.tenantId : tenantId;
|
||||
if (payloadTenantId !== tenantId) {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail tenantId mismatch", "TENANT_MISMATCH");
|
||||
}
|
||||
|
||||
return {
|
||||
outboxEventId: typeof envelope.outboxEventId === "string" ? envelope.outboxEventId : undefined,
|
||||
tenantId,
|
||||
eventType: envelope.eventType,
|
||||
dispatchKey: typeof envelope.dispatchKey === "string" ? envelope.dispatchKey : null,
|
||||
payload: payloadRecord,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runTransactionalMailOutboxJob(
|
||||
data: unknown,
|
||||
deps: TransactionalMailOutboxDeps,
|
||||
): Promise<{ enqueued: true; queueName: TransactionalMailJobDescriptor["queueName"]; jobName: TransactionalMailJobDescriptor["jobName"] }> {
|
||||
const job = parseTransactionalMailOutboxJob(data);
|
||||
const input =
|
||||
job.eventType === "mail.transactional.form-submission"
|
||||
? buildFormSubmissionMailInput(job)
|
||||
: buildGenericTransactionalMailInput(job);
|
||||
const descriptor = buildTransactionalMailJob(input);
|
||||
|
||||
await deps.enqueueMail(descriptor, {
|
||||
tenantId: job.tenantId,
|
||||
idempotencyKey: job.outboxEventId ?? job.dispatchKey ?? `transactional-mail:${job.eventType}`,
|
||||
priorityOverride: readPriorityOverride(job.payload),
|
||||
});
|
||||
|
||||
return { enqueued: true, queueName: queueNames.email, jobName: "send-transactional-email" };
|
||||
}
|
||||
|
||||
function buildGenericTransactionalMailInput(job: TransactionalMailOutboxJobData): TransactionalMailJobInput {
|
||||
const payload = job.payload;
|
||||
const to = readRecipients(payload.to);
|
||||
if (to.length === 0) {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail payload missing recipients", "INVALID_PAYLOAD");
|
||||
}
|
||||
const subject = readOptionalString(payload.subject);
|
||||
const text = readOptionalString(payload.text);
|
||||
const html = readOptionalString(payload.html);
|
||||
const templateId = readOptionalString(payload.templateId);
|
||||
if (!text && !html && !templateId) {
|
||||
throw new TransactionalMailOutboxJobError("transactional mail payload missing body/template", "INVALID_PAYLOAD");
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
tenantId: job.tenantId,
|
||||
...(subject ? { subject } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(html ? { html } : {}),
|
||||
...(templateId ? { templateId } : {}),
|
||||
metadata: mergeMetadata(job, { source: "outbox-mail" }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFormSubmissionMailInput(job: TransactionalMailOutboxJobData): TransactionalMailJobInput {
|
||||
const payload = job.payload;
|
||||
const recipients = readRecipients(payload.recipients);
|
||||
const submissionId = readRequiredString(payload.submissionId, "submissionId");
|
||||
const formId = readRequiredString(payload.formId, "formId");
|
||||
const values = payload.values && typeof payload.values === "object" ? (payload.values as Record<string, unknown>) : {};
|
||||
if (recipients.length === 0) {
|
||||
throw new TransactionalMailOutboxJobError("form-submission mail payload missing recipients", "INVALID_PAYLOAD");
|
||||
}
|
||||
|
||||
const text = renderFormSubmissionText({ formId, submissionId, values });
|
||||
const html = renderFormSubmissionHtml({ formId, submissionId, values });
|
||||
return {
|
||||
to: recipients,
|
||||
tenantId: job.tenantId,
|
||||
subject: "New form submission",
|
||||
text,
|
||||
html,
|
||||
tags: ["form-submission"],
|
||||
metadata: mergeMetadata(job, {
|
||||
source: "form-submission",
|
||||
formId,
|
||||
submissionId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRecipients(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
return value.includes("@") ? [value] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string" && entry.includes("@"));
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
throw new TransactionalMailOutboxJobError(`transactional mail payload missing ${field}`, "INVALID_PAYLOAD");
|
||||
}
|
||||
|
||||
function readPriorityOverride(value: Record<string, unknown>): QueueEnqueueOptions["priorityOverride"] | undefined {
|
||||
return value.priorityOverride === "critical" ||
|
||||
value.priorityOverride === "high" ||
|
||||
value.priorityOverride === "normal" ||
|
||||
value.priorityOverride === "low"
|
||||
? value.priorityOverride
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function mergeMetadata(job: TransactionalMailOutboxJobData, base: Record<string, unknown>): Record<string, unknown> {
|
||||
const payloadMetadata = job.payload.metadata && typeof job.payload.metadata === "object" ? (job.payload.metadata as Record<string, unknown>) : {};
|
||||
return {
|
||||
...base,
|
||||
...payloadMetadata,
|
||||
...(job.outboxEventId ? { outboxEventId: job.outboxEventId } : {}),
|
||||
...(job.dispatchKey ? { dispatchKey: job.dispatchKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderFormSubmissionText(input: {
|
||||
formId: string;
|
||||
submissionId: string;
|
||||
values: Record<string, unknown>;
|
||||
}): string {
|
||||
const lines = [
|
||||
"A new form submission was received.",
|
||||
"",
|
||||
`Form: ${input.formId}`,
|
||||
`Submission: ${input.submissionId}`,
|
||||
"",
|
||||
...Object.entries(input.values).map(([key, value]) => `${key}: ${formatValue(value)}`),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderFormSubmissionHtml(input: {
|
||||
formId: string;
|
||||
submissionId: string;
|
||||
values: Record<string, unknown>;
|
||||
}): string {
|
||||
const rows = Object.entries(input.values)
|
||||
.map(([key, value]) => `<tr><th align="left">${escapeHtml(key)}</th><td>${escapeHtml(formatValue(value))}</td></tr>`)
|
||||
.join("");
|
||||
return [
|
||||
"<p>A new form submission was received.</p>",
|
||||
`<p><strong>Form:</strong> ${escapeHtml(input.formId)}<br/><strong>Submission:</strong> ${escapeHtml(input.submissionId)}</p>`,
|
||||
rows ? `<table>${rows}</table>` : "",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runTrialExpiringTriggerCron, type TrialExpiringCronDeps, type TrialExpiringRow } from "./trial-expiring-trigger.js";
|
||||
|
||||
function buildDeps(rows: TrialExpiringRow[]): TrialExpiringCronDeps & {
|
||||
published: Array<{ type: string; payload: Record<string, unknown>; dispatchKey: string; tenantId?: string | null }>;
|
||||
} {
|
||||
const published: Array<{ type: string; payload: Record<string, unknown>; dispatchKey: string; tenantId?: string | null }> = [];
|
||||
return {
|
||||
store: {
|
||||
findExpiringTrials: vi.fn(async () => rows),
|
||||
},
|
||||
publisher: {
|
||||
publish: vi.fn(async (args) => {
|
||||
published.push({
|
||||
type: args.type,
|
||||
payload: args.payload,
|
||||
dispatchKey: args.dispatchKey,
|
||||
tenantId: args.tenantId,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
},
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
now: () => new Date("2026-05-18T00:00:00Z"),
|
||||
published,
|
||||
};
|
||||
}
|
||||
|
||||
const NOW = new Date("2026-05-18T00:00:00Z");
|
||||
const ROWS: TrialExpiringRow[] = [
|
||||
{
|
||||
tenantId: "tenant_1",
|
||||
tenantEmail: "ops@tenant1.com",
|
||||
tenantName: "Tenant One",
|
||||
trialEndsAt: new Date("2026-05-20T00:00:00Z"), // 2 days remaining
|
||||
},
|
||||
{
|
||||
tenantId: "tenant_2",
|
||||
tenantEmail: "ops@tenant2.com",
|
||||
tenantName: null,
|
||||
trialEndsAt: new Date("2026-05-19T00:00:00Z"), // 1 day remaining
|
||||
},
|
||||
];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MARKETING_SEQUENCES_ENABLED;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "true";
|
||||
});
|
||||
|
||||
describe("runTrialExpiringTriggerCron", () => {
|
||||
it("emits tenant.trial_expiring for every match", async () => {
|
||||
const deps = buildDeps(ROWS);
|
||||
const result = await runTrialExpiringTriggerCron(deps);
|
||||
expect(result.emitted).toBe(2);
|
||||
expect(deps.published.map((p) => p.type)).toEqual(["tenant.trial_expiring", "tenant.trial_expiring"]);
|
||||
});
|
||||
|
||||
it("computes daysRemaining from trialEndsAt and now()", async () => {
|
||||
const deps = buildDeps(ROWS);
|
||||
await runTrialExpiringTriggerCron(deps);
|
||||
expect(deps.published[0]?.payload["daysRemaining"]).toBe(2);
|
||||
expect(deps.published[1]?.payload["daysRemaining"]).toBe(1);
|
||||
});
|
||||
|
||||
it("uses date-keyed dispatchKey for idempotency", async () => {
|
||||
const deps = buildDeps([ROWS[0]!]);
|
||||
await runTrialExpiringTriggerCron(deps);
|
||||
expect(deps.published[0]?.dispatchKey).toBe("tenant.trial_expiring.tenant_1.2026-05-18");
|
||||
});
|
||||
|
||||
it("returns 0 when nothing matches", async () => {
|
||||
const deps = buildDeps([]);
|
||||
const result = await runTrialExpiringTriggerCron(deps);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.emitted).toBe(0);
|
||||
});
|
||||
|
||||
it("short-circuits when killswitch active", async () => {
|
||||
process.env.MARKETING_SEQUENCES_ENABLED = "false";
|
||||
const deps = buildDeps(ROWS);
|
||||
const result = await runTrialExpiringTriggerCron(deps);
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(deps.store.findExpiringTrials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards horizonDays to the store", async () => {
|
||||
const deps = buildDeps(ROWS);
|
||||
await runTrialExpiringTriggerCron({ ...deps, horizonDays: 7 });
|
||||
expect(deps.store.findExpiringTrials).toHaveBeenCalledWith(expect.objectContaining({ horizonDays: 7, now: NOW }));
|
||||
});
|
||||
|
||||
it("counts publisher failures as skipped and keeps scanning", async () => {
|
||||
const deps = buildDeps(ROWS);
|
||||
deps.publisher.publish = vi.fn().mockRejectedValueOnce(new Error("simulated")).mockResolvedValueOnce({ ok: true });
|
||||
const result = await runTrialExpiringTriggerCron(deps);
|
||||
expect(result.scanned).toBe(2);
|
||||
expect(result.emitted).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(deps.logger?.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps publish concurrency", async () => {
|
||||
const rows = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index): TrialExpiringRow => ({
|
||||
tenantId: `tenant_${index + 1}`,
|
||||
tenantEmail: `ops-${index + 1}@example.com`,
|
||||
tenantName: null,
|
||||
trialEndsAt: new Date("2026-05-20T00:00:00Z"),
|
||||
})
|
||||
);
|
||||
const deps = buildDeps(rows);
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let releaseIndex = 0;
|
||||
const releases = Array.from({ length: 5 }, () => deferred());
|
||||
deps.publisher.publish = vi.fn(async () => {
|
||||
const release = releases[releaseIndex++]!;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await release.promise;
|
||||
active -= 1;
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const resultPromise = runTrialExpiringTriggerCron({ ...deps, concurrency: 2 });
|
||||
await Promise.resolve();
|
||||
releases[0]!.resolve();
|
||||
releases[1]!.resolve();
|
||||
await Promise.resolve();
|
||||
releases[2]!.resolve();
|
||||
releases[3]!.resolve();
|
||||
await Promise.resolve();
|
||||
releases[4]!.resolve();
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toMatchObject({ scanned: 5, emitted: 5, skipped: 0 });
|
||||
expect(maxActive).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mapWithConcurrency } from "@toolctrl/foundation";
|
||||
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 (Phase 05 #80) — trial-expiring trigger cron.
|
||||
*
|
||||
* Daily cron-style worker that scans active tenant subscriptions whose
|
||||
* trial window ends within the configured horizon (default 3 days).
|
||||
* For every match a `tenant.trial_expiring` outbox event is emitted;
|
||||
* downstream the email-sequence trigger handler enrols
|
||||
* trial-reminder sequences.
|
||||
*
|
||||
* Storage + clock + outbox publisher are injected so the handler is
|
||||
* fully unit-testable. Intended production wiring is a daily BullMQ
|
||||
* scheduled-job from `apps/worker/src/index.ts`; as of this slice the
|
||||
* runner is not registered by the worker entrypoint.
|
||||
*
|
||||
* Idempotency: the dispatchKey is
|
||||
* `tenant.trial_expiring.<tenantId>.<YYYY-MM-DD>` so re-running the
|
||||
* cron on the same day is a no-op even on worker restart.
|
||||
*/
|
||||
|
||||
export interface TrialExpiringRow {
|
||||
readonly tenantId: string;
|
||||
readonly tenantEmail: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly trialEndsAt: Date;
|
||||
}
|
||||
|
||||
export interface TrialExpiringStore {
|
||||
findExpiringTrials(args: { now: Date; horizonDays: number; limit: number }): Promise<readonly TrialExpiringRow[]>;
|
||||
}
|
||||
|
||||
export interface TrialExpiringPublisher {
|
||||
publish(args: { type: string; payload: Record<string, unknown>; dispatchKey: string; tenantId?: string | null }): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface TrialExpiringLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface TrialExpiringCronDeps {
|
||||
readonly store: TrialExpiringStore;
|
||||
readonly publisher: TrialExpiringPublisher;
|
||||
readonly logger?: TrialExpiringLogger;
|
||||
readonly now?: () => Date;
|
||||
/** Trial-end horizon in days. Default 3 (matches "Trial Expiring" sequence). */
|
||||
readonly horizonDays?: number;
|
||||
/** Hard cap per cron tick. */
|
||||
readonly limit?: number;
|
||||
/** Max outbox publishes at once. Default 10. */
|
||||
readonly concurrency?: number;
|
||||
}
|
||||
|
||||
export interface TrialExpiringCronResult {
|
||||
readonly scanned: number;
|
||||
readonly emitted: number;
|
||||
readonly skipped: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HORIZON_DAYS = 3;
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function runTrialExpiringTriggerCron(deps: TrialExpiringCronDeps): Promise<TrialExpiringCronResult> {
|
||||
if (process.env.MARKETING_SEQUENCES_ENABLED !== "true") {
|
||||
deps.logger?.info?.({ op: "trial.expiring.skipped" }, "marketing sequences disabled by env");
|
||||
return { scanned: 0, emitted: 0, skipped: 0 };
|
||||
}
|
||||
const now = (deps.now ?? (() => new Date()))();
|
||||
const horizonDays = deps.horizonDays ?? DEFAULT_HORIZON_DAYS;
|
||||
const limit = deps.limit ?? DEFAULT_LIMIT;
|
||||
const today = isoDate(now);
|
||||
|
||||
const rows = await deps.store.findExpiringTrials({ now, horizonDays, limit });
|
||||
let emitted = 0;
|
||||
let skipped = 0;
|
||||
await mapWithConcurrency(rows, deps.concurrency ?? 10, async (row) => {
|
||||
const daysRemaining = Math.max(0, Math.ceil((row.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)));
|
||||
const dispatchKey = `tenant.trial_expiring.${row.tenantId}.${today}`;
|
||||
try {
|
||||
await deps.publisher.publish({
|
||||
type: "tenant.trial_expiring",
|
||||
tenantId: row.tenantId,
|
||||
payload: {
|
||||
tenantId: row.tenantId,
|
||||
email: row.tenantEmail,
|
||||
recipientName: row.tenantName,
|
||||
tenant: {
|
||||
id: row.tenantId,
|
||||
name: row.tenantName,
|
||||
email: row.tenantEmail,
|
||||
trialEndsAt: row.trialEndsAt.toISOString(),
|
||||
},
|
||||
daysRemaining,
|
||||
},
|
||||
dispatchKey,
|
||||
});
|
||||
emitted += 1;
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
deps.logger?.error(
|
||||
{ op: "trial.expiring.publish_failed", tenantId: row.tenantId, error: error instanceof Error ? error.message : String(error) },
|
||||
"trial-expiring outbox publish failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
deps.logger?.info?.({ op: "trial.expiring.done", scanned: rows.length, emitted, skipped, horizonDays }, "trial-expiring trigger cron complete");
|
||||
return { scanned: rows.length, emitted, skipped };
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_EVERY_MS,
|
||||
registerWorkflowExecutionStalledReconcilerCron,
|
||||
resolveWorkflowExecutionStalledReconcilerCronConfig,
|
||||
} from "./workflow-execution-stalled-reconciler-cron.js";
|
||||
import {
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_BATCH_SIZE,
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_OLDER_THAN_MINUTES,
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_JOB_ID,
|
||||
} from "./workflow-execution-stalled-reconciler.js";
|
||||
|
||||
describe("workflow-execution-stalled-reconciler-cron", () => {
|
||||
it("resolves defaults and clamps invalid env", () => {
|
||||
expect(resolveWorkflowExecutionStalledReconcilerCronConfig({})).toEqual({
|
||||
olderThanMinutes: WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_OLDER_THAN_MINUTES,
|
||||
take: WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_BATCH_SIZE,
|
||||
});
|
||||
expect(
|
||||
resolveWorkflowExecutionStalledReconcilerCronConfig({
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_OLDER_THAN_MINUTES: "45.9",
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_BATCH_SIZE: "250.5",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toEqual({ olderThanMinutes: 45, take: 250 });
|
||||
expect(
|
||||
resolveWorkflowExecutionStalledReconcilerCronConfig({
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_OLDER_THAN_MINUTES: "0",
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_BATCH_SIZE: "nope",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toEqual({
|
||||
olderThanMinutes: WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_OLDER_THAN_MINUTES,
|
||||
take: WORKFLOW_EXECUTION_STALLED_RECONCILE_DEFAULT_BATCH_SIZE,
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a five-minute scheduler with cleanup.run payload", async () => {
|
||||
const upsert = vi.fn().mockResolvedValue(undefined);
|
||||
await registerWorkflowExecutionStalledReconcilerCron(
|
||||
{ upsertJobScheduler: upsert },
|
||||
{
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_OLDER_THAN_MINUTES: "60",
|
||||
WORKFLOW_EXECUTION_STALLED_RECONCILE_BATCH_SIZE: "500",
|
||||
} as NodeJS.ProcessEnv,
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
expect(upsert.mock.calls[0]?.[0]).toBe(WORKFLOW_EXECUTION_STALLED_RECONCILE_JOB_ID);
|
||||
expect(upsert.mock.calls[0]?.[1]).toEqual({ every: WORKFLOW_EXECUTION_STALLED_RECONCILE_EVERY_MS });
|
||||
expect(upsert.mock.calls[0]?.[2]).toEqual({
|
||||
name: WORKFLOW_EXECUTION_STALLED_RECONCILE_JOB_ID,
|
||||
data: {
|
||||
kind: "cleanup.run",
|
||||
task: "workflow-execution-stalled-reconcile",
|
||||
olderThanMinutes: 60,
|
||||
take: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user