Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,24 @@
{
"name": "@hivegrid/activity",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@toolctrl/db": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,6 @@
// @hivegrid/activity — central Activity-Stream cross-cutting service (M045 / M00).
// Server entry: `import { logActivity } from "@hivegrid/activity"`.
// Browser-safe types only: `import type { ActivityType } from "@hivegrid/activity/types"`.
export * from "./types.js";
export { logActivity } from "./logActivity.server.js";
export type { ActivityWriteClient, LogActivityOptions } from "./logActivity.server.js";
@@ -0,0 +1,54 @@
import { getDbClient } from "@toolctrl/db/client.server";
import type { Prisma } from "@toolctrl/db/prisma-browser";
import type { LogActivityInput } from "./types.js";
/**
* Minimal write surface `logActivity` needs. Satisfied by the full Prisma client
* AND by interactive-transaction clients, so callers can append an activity inside
* the same transaction as the action that triggered it.
*/
export type ActivityWriteClient = {
activity: {
create: (args: { data: Prisma.ActivityUncheckedCreateInput }) => Promise<{ id: string }>;
};
};
export type LogActivityOptions = {
/** Transaction/client to write through. Defaults to the shared db singleton. */
client?: ActivityWriteClient;
};
/**
* Append a user-facing activity to the central `Activity` stream (M045 / DoD #14).
*
* This is NOT the audit log — the tamper-evident `AuditEvent` (M001) is written
* separately (auto via Prisma middleware). `logActivity` records the human-readable,
* per-entity timeline shown to tenant users.
*/
export async function logActivity(input: LogActivityInput, options: LogActivityOptions = {}): Promise<{ id: string }> {
if (!input.tenantId) {
throw new Error("logActivity: tenantId is required");
}
if (!input.subjectId) {
throw new Error("logActivity: subjectId is required");
}
if (!input.summaryText || input.summaryText.trim().length === 0) {
throw new Error("logActivity: summaryText (Klartext-Zusammenfassung) is required");
}
const client = options.client ?? (getDbClient() as unknown as ActivityWriteClient);
return client.activity.create({
data: {
tenantId: input.tenantId,
actorUserId: input.actorUserId ?? null,
actorKind: input.actorKind,
activityType: input.activityType,
subjectType: input.subjectType,
subjectId: input.subjectId,
summaryText: input.summaryText,
relatedEntityRefs: input.relatedEntityRefs ? (input.relatedEntityRefs as unknown as Prisma.InputJsonValue) : undefined,
metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined,
},
});
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import type { Prisma } from "@toolctrl/db/prisma-browser";
import { logActivity, type ActivityWriteClient } from "./logActivity.server.js";
import type { LogActivityInput } from "./types.js";
function makeMockClient() {
const create = vi.fn<(args: { data: Prisma.ActivityUncheckedCreateInput }) => Promise<{ id: string }>>(() =>
Promise.resolve({ id: "act_1" })
);
const client: ActivityWriteClient = { activity: { create } };
return { client, create };
}
const base: LogActivityInput = {
tenantId: "t1",
actorUserId: "u1",
actorKind: "user",
activityType: "note_added",
subjectType: "contact",
subjectId: "c1",
summaryText: "Notiz hinzugefügt",
};
describe("logActivity", () => {
it("writes a tenant-scoped activity row with mapped fields through the given client", async () => {
const { client, create } = makeMockClient();
const result = await logActivity(base, { client });
expect(result).toEqual({ id: "act_1" });
expect(create).toHaveBeenCalledTimes(1);
expect(create.mock.calls[0]?.[0].data).toMatchObject({
tenantId: "t1",
actorUserId: "u1",
actorKind: "user",
activityType: "note_added",
subjectType: "contact",
subjectId: "c1",
summaryText: "Notiz hinzugefügt",
});
});
it("passes relatedEntityRefs and metadata through as JSON", async () => {
const { client, create } = makeMockClient();
await logActivity(
{ ...base, relatedEntityRefs: [{ type: "company", id: "co1" }], metadata: { from: "neu", to: "kontaktiert" } },
{ client }
);
const data = create.mock.calls[0]?.[0].data;
expect(data?.relatedEntityRefs).toEqual([{ type: "company", id: "co1" }]);
expect(data?.metadata).toEqual({ from: "neu", to: "kontaktiert" });
});
it("defaults a missing actorUserId to null (system/automation actors)", async () => {
const { client, create } = makeMockClient();
await logActivity({ ...base, actorUserId: undefined, actorKind: "system" }, { client });
expect(create.mock.calls[0]?.[0].data.actorUserId).toBeNull();
});
it("requires tenantId, subjectId and a non-empty summaryText", async () => {
const { client } = makeMockClient();
await expect(logActivity({ ...base, tenantId: "" }, { client })).rejects.toThrow("tenantId");
await expect(logActivity({ ...base, subjectId: "" }, { client })).rejects.toThrow("subjectId");
await expect(logActivity({ ...base, summaryText: " " }, { client })).rejects.toThrow("summaryText");
});
});
@@ -0,0 +1,57 @@
// Public, browser-safe types for the HiveGrid Activity-Stream (M045 / M01 Block E).
// No runtime/server imports here so view-models and UI can import the contract.
/** Actor that produced an activity. Mirrors the `ActivityActorKind` Prisma enum. */
export type ActivityActorKind = "user" | "system" | "automation" | "ki_agent";
/**
* Canonical activity types (M01 Block E). Closed union = spec "Enum" + compile-time
* safety; the DB column is a plain string, so adding a new type is a one-line union
* extension here (no schema migration). Phase-2 types (whatsapp_*) are included so
* callers can reference them early.
*/
export type ActivityType =
| "call_inbound"
| "call_outbound"
| "email_sent"
| "email_received"
| "whatsapp_sent"
| "whatsapp_received"
| "chat_message"
| "note_added"
| "tag_added"
| "tag_removed"
| "assignee_changed"
| "deal_stage_changed"
| "project_stage_changed"
| "file_uploaded"
| "form_submitted"
| "task_created"
| "task_completed"
| "appointment_scheduled"
| "appointment_held"
| "lead_promoted"
| "deal_won"
| "deal_lost"
| "automation_triggered"
| "ki_action"
| "audit_event";
/** Main object a stream hangs off (DoD #14: Kontakt/Firma/Lead/Verkaufsfall/Auftrag …). */
export type ActivitySubjectType = "contact" | "company" | "lead" | "deal" | "project" | "task";
/** Additional entities referenced by an activity (denormalized for the timeline). */
export type ActivityRelatedRef = { type: ActivitySubjectType; id: string };
/** Input for {@link logActivity}. `summaryText` is the mandatory DE Klartext line. */
export type LogActivityInput = {
tenantId: string;
actorUserId?: string | null;
actorKind: ActivityActorKind;
activityType: ActivityType;
subjectType: ActivitySubjectType;
subjectId: string;
summaryText: string;
relatedEntityRefs?: ActivityRelatedRef[];
metadata?: Record<string, unknown>;
};
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,47 @@
# @toolctrl/api-client-ts
Official TypeScript SDK for the toolctrl boilerplate REST API.
The SDK couples a `@hey-api/openapi-ts`-generated typed client with a hand-written
Higher-Level-Wrapper that adds:
- Bearer-token, API-key, and async-getter auth modes
- Retry with exponential backoff and jitter, honoring `Retry-After`
- Auto-generated `Idempotency-Key` headers on POST/PUT/PATCH
- Async iterator pagination (cursor / token / page)
- HMAC-SHA256 webhook signature verification with constant-time compare
## Quickstart
```ts
import { createToolctrlClient } from "@toolctrl/api-client-ts";
const client = createToolctrlClient({
baseUrl: "https://api.toolctrl.de",
auth: { apiKey: process.env.TOOLCTRL_API_KEY! },
tenantId: process.env.TOOLCTRL_TENANT_ID,
});
const me = await client.request({ method: "GET", path: "/v1/me" });
for await (const contact of client.paginate("/v1/contacts")) {
console.log(contact);
}
```
## Codegen
The `src/generated/` directory is produced from the upstream OpenAPI document
checked into this package as `openapi.json`. Regenerate with:
```bash
pnpm --filter @toolctrl/api-client-ts generate:all
```
The CI workflow `.github/workflows/sdk-codegen.yml` rejects PRs whose generated
output drifts from the committed spec.
## See also
- `docs/sdks/typescript.md` — full usage guide
- `docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md` — spec
@@ -0,0 +1,40 @@
/**
* @hey-api/openapi-ts configuration.
*
* Reads the captured `openapi.json` (produced by scripts/generate-spec.ts) and
* emits a tree-shakable TypeScript client + Zod schemas + types into
* `src/generated/`.
*
* Run via:
* pnpm --filter @toolctrl/api-client-ts generate
*
* The full pipeline (capture spec → generate client) is:
* pnpm --filter @toolctrl/api-client-ts generate:all
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
import { defineConfig } from "@hey-api/openapi-ts";
/**
* Note on plugin selection:
*
* The `zod` plugin is intentionally omitted. The latest @hey-api/openapi-ts
* emits `z.iso.*`, `z.email()`, `z.int()`, and `z.url()` calls that require
* zod v4, while the wider toolctrl workspace is pinned to zod v3 via
* pnpm overrides. The generated TypeScript types and fetch SDK are
* sufficient for the Higher-Level-Wrapper in `src/client.ts`, which performs
* its own runtime validation policy (idempotency, retry) outside of zod.
*
* When the workspace migrates to zod v4 we can re-enable the plugin in a
* follow-up commit.
*/
export default defineConfig({
input: "./openapi.json",
output: {
path: "./src/generated",
format: "prettier",
lint: false,
},
plugins: ["@hey-api/typescript", "@hey-api/sdk", "@hey-api/client-fetch"],
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
{
"name": "@toolctrl/api-client-ts",
"version": "0.1.0",
"private": true,
"description": "Official TypeScript SDK for the toolctrl boilerplate REST API. Generated from the upstream OpenAPI spec with @hey-api/openapi-ts and wrapped with auth, retry, idempotency, pagination, and webhook-verify helpers.",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./webhooks": "./src/webhooks.ts",
"./types": "./src/generated/types.gen.ts",
"./generated": "./src/generated/index.ts"
},
"files": [
"src",
"openapi.json",
"openapi-ts.config.ts",
"README.md"
],
"scripts": {
"generate-spec": "tsx scripts/generate-spec.ts",
"generate": "openapi-ts",
"generate:all": "pnpm run generate-spec && pnpm run generate",
"generate:check": "tsx scripts/generate-spec.ts --check",
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.97.1",
"@toolctrl/api": "workspace:*",
"@types/node": "^25.0.0",
"tsup": "^8.5.1",
"tsx": "^4.6.2",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const scriptPath = resolve(here, "generate-spec.ts");
/**
* Smoke test: the script exits 0 in write mode, then `--check` mode confirms
* the file is in sync. We do not exercise the network or invoke the heavy
* Hono import here — these tests run alongside vitest's default include.
*
* This file deliberately lives under scripts/ so the package's vitest config
* (which only globs src/**) skips it during regular `pnpm test`. It is kept
* here as documentation + smoke for manual / CI invocation:
*
* pnpm exec vitest run packages/api-client-ts/scripts/generate-spec.test.ts
*/
describe("generate-spec script", () => {
it("compiles via tsx --check without throwing", () => {
// Just verify the script is importable / syntactically valid by running --help-equivalent.
// We invoke the file with --check, which only succeeds when openapi.json already exists;
// when it doesn't, the script exits with code 1 (missing) or 2 (drift). Either is a "ran" signal.
const result = spawnSync("pnpm", ["exec", "tsx", scriptPath, "--check"], {
cwd: resolve(here, ".."),
encoding: "utf8",
});
expect([0, 1, 2]).toContain(result.status ?? -1);
});
});
@@ -0,0 +1,64 @@
#!/usr/bin/env tsx
/**
* generate-spec — capture the boilerplate's OpenAPI document into a local file.
*
* Invokes the headless `createOpenApiDocument` factory from `@toolctrl/api` and
* writes the result to `packages/api-client-ts/openapi.json`. This file feeds
* `@hey-api/openapi-ts` (see `openapi-ts.config.ts`) which then regenerates
* `src/generated/`.
*
* Usage:
* pnpm --filter @toolctrl/api-client-ts generate-spec
* pnpm --filter @toolctrl/api-client-ts generate-spec -- --check
*
* `--check` re-runs the generation and exits non-zero if the on-disk
* `openapi.json` would change — useful as a CI drift gate.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
import { writeFileSync, readFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createOpenApiDocument } from "@toolctrl/api/openapi";
const here = dirname(fileURLToPath(import.meta.url));
const outputPath = resolve(here, "..", "openapi.json");
function buildSpec(): string {
const document = createOpenApiDocument({
routing: {
protocol: "https",
apiHost: "api.toolctrl.de",
} as never,
});
return `${JSON.stringify(document, null, 2)}\n`;
}
function main(): void {
const checkMode = process.argv.includes("--check");
const next = buildSpec();
if (checkMode) {
if (!existsSync(outputPath)) {
process.stderr.write(`openapi.json missing at ${outputPath} — run \`pnpm --filter @toolctrl/api-client-ts generate-spec\`.\n`);
process.exit(1);
}
const current = readFileSync(outputPath, "utf8");
if (current !== next) {
process.stderr.write(
`openapi.json is stale — the upstream OpenAPI document has changed.\n` +
`Run \`pnpm --filter @toolctrl/api-client-ts generate:all\` and commit the result.\n`,
);
process.exit(2);
}
process.stdout.write("openapi.json is in sync with the upstream document.\n");
return;
}
writeFileSync(outputPath, next, "utf8");
process.stdout.write(`Wrote ${outputPath} (${(next.length / 1024).toFixed(1)} KiB)\n`);
}
main();
@@ -0,0 +1,262 @@
import { describe, it, expect } from "vitest";
import { createToolctrlClient, ToolctrlApiError, ToolctrlNetworkError } from "./client.js";
function mockFetchSequence(responses: Array<Response | Error>): typeof fetch {
let index = 0;
return (async () => {
const next = responses[index++];
if (next === undefined) {
throw new Error("mock fetch exhausted");
}
if (next instanceof Error) throw next;
return next;
}) as unknown as typeof fetch;
}
function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
describe("createToolctrlClient", () => {
it("rejects when baseUrl is missing", () => {
expect(() => createToolctrlClient({ baseUrl: "" } as never)).toThrow(/baseUrl/);
});
it("strips trailing slashes from the base url", () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com///",
fetch: mockFetchSequence([jsonResponse(200, { ok: true })]),
});
expect(client.baseUrl).toBe("https://api.example.com");
});
it("sends Authorization bearer when bearerToken is set", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(200, { hello: "world" });
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
auth: { bearerToken: "secret-token" },
fetch: stub,
});
const result = await client.request<{ hello: string }>({ method: "GET", path: "/v1/me" });
expect(result.hello).toBe("world");
expect(captured.headers!.get("authorization")).toBe("Bearer secret-token");
});
it("sends X-API-Key when apiKey is set", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(200, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
auth: { apiKey: "ak_test_123" },
fetch: stub,
});
await client.request({ method: "GET", path: "/v1/ping" });
expect(captured.headers!.get("x-api-key")).toBe("ak_test_123");
});
it("auto-generates Idempotency-Key on POST", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(201, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
fetch: stub,
});
await client.request({ method: "POST", path: "/v1/items", body: { foo: 1 } });
expect(captured.headers!.get("idempotency-key")).toMatch(/^[0-9a-f-]{36}$/i);
});
it("does not auto-generate Idempotency-Key on GET", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(200, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
fetch: stub,
});
await client.request({ method: "GET", path: "/v1/items" });
expect(captured.headers!.get("idempotency-key")).toBeNull();
});
it("respects autoGenerate=false", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(201, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
idempotency: { autoGenerate: false },
fetch: stub,
});
await client.request({ method: "POST", path: "/v1/items", body: { foo: 1 } });
expect(captured.headers!.get("idempotency-key")).toBeNull();
});
it("honors caller-provided Idempotency-Key", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(201, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
fetch: stub,
});
await client.request({ method: "POST", path: "/v1/items", body: {}, idempotencyKey: "caller-key" });
expect(captured.headers!.get("idempotency-key")).toBe("caller-key");
});
it("forwards X-Tenant-ID and Sentry trace headers", async () => {
const captured: { headers: Headers | null } = { headers: null };
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.headers = init.headers as Headers;
return jsonResponse(200, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
tenantId: "ten_abc",
sentry: { trace: "abc-1", baggage: "k=v" },
fetch: stub,
});
await client.request({ method: "GET", path: "/v1/ping" });
expect(captured.headers!.get("x-tenant-id")).toBe("ten_abc");
expect(captured.headers!.get("sentry-trace")).toBe("abc-1");
expect(captured.headers!.get("baggage")).toBe("k=v");
});
it("retries on 5xx then succeeds", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
retry: { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 5 },
fetch: mockFetchSequence([jsonResponse(503, { error: "down" }), jsonResponse(200, { ok: true })]),
});
const result = await client.request<{ ok: boolean }>({ method: "GET", path: "/v1/ping" });
expect(result.ok).toBe(true);
});
it("retries on 429 honoring Retry-After", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
retry: { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 10_000 },
fetch: mockFetchSequence([
jsonResponse(429, { error: "rate" }, { "retry-after": "0" }),
jsonResponse(200, { ok: true }),
]),
});
const t0 = Date.now();
const result = await client.request<{ ok: boolean }>({ method: "GET", path: "/v1/ping" });
expect(result.ok).toBe(true);
// retry-after=0 should not have produced exponential backoff (which would be >=1000ms)
expect(Date.now() - t0).toBeLessThan(1000);
});
it("throws ToolctrlApiError on non-retryable 4xx", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
retry: { maxAttempts: 3, baseDelayMs: 1 },
fetch: mockFetchSequence([
jsonResponse(404, { error: { code: "not_found", message: "missing", requestId: "req_1" } }, { "x-request-id": "req_1" }),
]),
});
await expect(client.request({ method: "GET", path: "/v1/missing" })).rejects.toMatchObject({
name: "ToolctrlApiError",
status: 404,
requestId: "req_1",
});
});
it("throws ToolctrlNetworkError after exhausting retries", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 },
fetch: mockFetchSequence([new Error("ECONNRESET"), new Error("ECONNRESET")]),
});
await expect(client.request({ method: "GET", path: "/v1/ping" })).rejects.toBeInstanceOf(ToolctrlNetworkError);
});
it("paginate auto-follows nextCursor and stops on null", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
fetch: mockFetchSequence([
jsonResponse(200, { data: [{ id: 1 }, { id: 2 }], nextCursor: "c1" }),
jsonResponse(200, { data: [{ id: 3 }], nextCursor: null }),
]),
});
const items: Array<{ id: number }> = [];
for await (const item of client.paginate<{ id: number }>("/v1/items")) {
items.push(item);
}
expect(items.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("paginate handles nextPage offset pagination", async () => {
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
fetch: mockFetchSequence([
jsonResponse(200, { data: [{ id: "a" }], nextPage: 2 }),
jsonResponse(200, { data: [{ id: "b" }] }),
]),
});
const items: Array<{ id: string }> = [];
for await (const item of client.paginate<{ id: string }>("/v1/items", { maxPages: 5 })) {
items.push(item);
}
expect(items.map((i) => i.id)).toEqual(["a", "b"]);
});
it("getter mode resolves bearer token freshly", async () => {
let calls = 0;
const captured: Array<string | null> = [];
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
captured.push((init.headers as Headers).get("authorization"));
return jsonResponse(200, {});
}) as unknown as typeof fetch;
const client = createToolctrlClient({
baseUrl: "https://api.example.com",
auth: {
getToken: async () => `jwt-${++calls}`,
},
fetch: stub,
});
await client.request({ method: "GET", path: "/v1/a" });
await client.request({ method: "GET", path: "/v1/b" });
expect(captured).toEqual(["Bearer jwt-1", "Bearer jwt-2"]);
});
});
describe("error types", () => {
it("ToolctrlApiError carries status, body, and requestId", () => {
const err = new ToolctrlApiError(403, "Forbidden", { error: { code: "forbidden" } }, "req_x");
expect(err.name).toBe("ToolctrlApiError");
expect(err.status).toBe(403);
expect(err.requestId).toBe("req_x");
});
it("ToolctrlNetworkError preserves cause", () => {
const cause = new Error("ECONNRESET");
const err = new ToolctrlNetworkError("net", cause);
expect(err.cause).toBe(cause);
});
});
@@ -0,0 +1,354 @@
/**
* Higher-Level-Wrapper around the generated client.
*
* Adds:
* - Bearer-token / API-key auth header injection
* - Retry with exponential backoff (honoring Retry-After) on 429 + 5xx + network errors
* - Auto-generated Idempotency-Key headers on POST/PUT/PATCH
* - Async iterator pagination helper that auto-follows next-page cursors
* - Optional Sentry trace propagation via sentry-trace + baggage headers
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
import { randomUUID } from "node:crypto";
export interface ToolctrlAuthConfig {
/** Bearer-token style — set as `Authorization: Bearer <key>`. */
bearerToken?: string;
/** API-key style — set as `X-API-Key: <key>`. */
apiKey?: string;
/** Async getter that produces a fresh bearer token (e.g. for JWT refresh). */
getToken?: () => Promise<string>;
}
export interface ToolctrlRetryConfig {
/** Maximum number of retry attempts (default 5). The initial request counts as 1. */
maxAttempts?: number;
/** Initial delay in ms before the first retry (default 1000). */
baseDelayMs?: number;
/** Upper bound for any individual retry delay (default 30000). */
maxDelayMs?: number;
}
export interface ToolctrlIdempotencyConfig {
/** Auto-generate `Idempotency-Key` headers on POST/PUT/PATCH. Defaults to true. */
autoGenerate?: boolean;
}
export interface ToolctrlClientConfig {
/** Base URL of the toolctrl API, e.g. `https://api.toolctrl.de`. */
baseUrl: string;
/** Auth configuration. Exactly one of bearerToken / apiKey / getToken should be set. */
auth?: ToolctrlAuthConfig;
/** Optional tenant scope; forwarded as `X-Tenant-ID` on every call. */
tenantId?: string;
/** User-Agent override. Defaults to `@toolctrl/api-client-ts/<version>`. */
userAgent?: string;
/** Retry policy for 429 + 5xx + network errors. */
retry?: ToolctrlRetryConfig;
/** Idempotency-Key policy. */
idempotency?: ToolctrlIdempotencyConfig;
/** Optional fetch implementation (defaults to global fetch). Useful for Capacitor or test stubs. */
fetch?: typeof fetch;
/** Sentry trace headers to forward (optional). */
sentry?: { trace?: string; baggage?: string };
}
export interface PaginatedResponse<T> {
data: T[];
/** Optional next-page cursor. */
nextCursor?: string | null;
/** Optional next-page token (alternate API style). */
nextPageToken?: string | null;
/** Optional next-page number (offset pagination). */
nextPage?: number | null;
}
export interface PaginateOptions {
/** Maximum number of pages to fetch (safety stop, defaults to 1000). */
maxPages?: number;
/** Initial query parameters. */
query?: Record<string, string | number | undefined>;
}
export class ToolctrlApiError extends Error {
public readonly status: number;
public readonly body: unknown;
public readonly requestId?: string;
constructor(status: number, message: string, body: unknown, requestId?: string) {
super(message);
this.name = "ToolctrlApiError";
this.status = status;
this.body = body;
this.requestId = requestId;
}
}
export class ToolctrlNetworkError extends Error {
public readonly cause: unknown;
constructor(message: string, cause: unknown) {
super(message);
this.name = "ToolctrlNetworkError";
this.cause = cause;
}
}
export interface ToolctrlClient {
/** Issue a typed request against the API. */
request<T = unknown>(input: {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
headers?: Record<string, string>;
idempotencyKey?: string;
}): Promise<T>;
/** Async iterator that auto-follows cursor / token / page pagination. */
paginate<T = unknown>(path: string, options?: PaginateOptions): AsyncIterable<T>;
/** Re-fetch the bearer token via the configured getter, if any. */
refreshAuth(): Promise<void>;
/** The fully-resolved base URL (trailing slash stripped). */
readonly baseUrl: string;
}
const DEFAULT_RETRY_MAX_ATTEMPTS = 5;
const DEFAULT_RETRY_BASE_MS = 1000;
const DEFAULT_RETRY_MAX_MS = 30_000;
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH"]);
const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
const PACKAGE_VERSION = "0.1.0";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function computeBackoff(attempt: number, baseMs: number, maxMs: number): number {
// attempt is 1-based: 1 -> base, 2 -> 2*base, 3 -> 4*base, ...
const exp = Math.min(maxMs, baseMs * Math.pow(2, attempt - 1));
// 0..exp jitter
const jitter = Math.floor(Math.random() * Math.min(250, exp));
return Math.min(maxMs, exp + jitter);
}
function parseRetryAfter(header: string | null): number | null {
if (!header) return null;
const seconds = Number(header);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.floor(seconds * 1000);
}
const dateMs = Date.parse(header);
if (!Number.isNaN(dateMs)) {
const delta = dateMs - Date.now();
return delta > 0 ? delta : 0;
}
return null;
}
async function readBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
const text = await response.text();
return text.length === 0 ? null : text;
}
export function createToolctrlClient(config: ToolctrlClientConfig): ToolctrlClient {
if (!config.baseUrl) {
throw new Error("createToolctrlClient: baseUrl is required");
}
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const fetchImpl: typeof fetch =
config.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (undefined as unknown as typeof fetch));
if (!fetchImpl) {
throw new Error("createToolctrlClient: no fetch implementation available — pass `fetch` in the config");
}
const retryMaxAttempts = config.retry?.maxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS;
const retryBaseMs = config.retry?.baseDelayMs ?? DEFAULT_RETRY_BASE_MS;
const retryMaxMs = config.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_MS;
const autoIdempotency = config.idempotency?.autoGenerate ?? true;
let cachedToken: string | undefined = config.auth?.bearerToken;
async function resolveAuthHeader(): Promise<Record<string, string>> {
if (config.auth?.apiKey) {
return { "X-API-Key": config.auth.apiKey };
}
if (config.auth?.getToken) {
const token = await config.auth.getToken();
cachedToken = token;
return { Authorization: `Bearer ${token}` };
}
if (cachedToken) {
return { Authorization: `Bearer ${cachedToken}` };
}
return {};
}
function buildHeaders(method: string, extra?: Record<string, string>, idempotencyKey?: string): Headers {
const headers = new Headers();
headers.set("Accept", "application/json");
headers.set("Content-Type", "application/json");
headers.set("User-Agent", config.userAgent ?? `@toolctrl/api-client-ts/${PACKAGE_VERSION}`);
if (config.tenantId) headers.set("X-Tenant-ID", config.tenantId);
if (config.sentry?.trace) headers.set("sentry-trace", config.sentry.trace);
if (config.sentry?.baggage) headers.set("baggage", config.sentry.baggage);
if (autoIdempotency && MUTATING_METHODS.has(method)) {
headers.set("Idempotency-Key", idempotencyKey ?? randomUUID());
} else if (idempotencyKey) {
headers.set("Idempotency-Key", idempotencyKey);
}
if (extra) {
for (const [key, value] of Object.entries(extra)) {
headers.set(key, value);
}
}
return headers;
}
function buildUrl(path: string, query?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
async function executeOnce(
method: string,
url: string,
headers: Headers,
body: string | undefined,
): Promise<{ response: Response | null; error: unknown }> {
try {
const response = await fetchImpl(url, {
method,
headers,
body,
});
return { response, error: null };
} catch (err) {
return { response: null, error: err };
}
}
async function request<T = unknown>(input: {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
headers?: Record<string, string>;
idempotencyKey?: string;
}): Promise<T> {
const method = input.method.toUpperCase();
const url = buildUrl(input.path, input.query);
const authHeaders = await resolveAuthHeader();
const headers = buildHeaders(method, { ...authHeaders, ...input.headers }, input.idempotencyKey);
const body = input.body === undefined ? undefined : JSON.stringify(input.body);
let attempt = 1;
let lastError: unknown = null;
while (attempt <= retryMaxAttempts) {
const { response, error } = await executeOnce(method, url, headers, body);
if (response) {
if (response.ok) {
return (await readBody(response)) as T;
}
// Retry conditions
if (RETRY_STATUS.has(response.status) && attempt < retryMaxAttempts) {
const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
const delay = retryAfter !== null ? Math.min(retryMaxMs, retryAfter) : computeBackoff(attempt, retryBaseMs, retryMaxMs);
await sleep(delay);
attempt += 1;
continue;
}
const errorBody = await readBody(response);
const requestId = response.headers.get("x-request-id") ?? undefined;
const message =
errorBody && typeof errorBody === "object" && "error" in errorBody && errorBody.error && typeof errorBody.error === "object"
? String((errorBody.error as { message?: unknown }).message ?? `HTTP ${response.status}`)
: `HTTP ${response.status}`;
throw new ToolctrlApiError(response.status, message, errorBody, requestId);
}
// Network error path
lastError = error;
if (attempt < retryMaxAttempts) {
await sleep(computeBackoff(attempt, retryBaseMs, retryMaxMs));
attempt += 1;
continue;
}
break;
}
throw new ToolctrlNetworkError("Network request failed after retries", lastError);
}
async function* paginate<T = unknown>(path: string, options: PaginateOptions = {}): AsyncIterable<T> {
const maxPages = options.maxPages ?? 1000;
let cursor: string | null | undefined = undefined;
let pageToken: string | null | undefined = undefined;
let pageNumber: number | null | undefined = undefined;
let pages = 0;
while (pages < maxPages) {
const query: Record<string, string | number | undefined> = { ...(options.query ?? {}) };
if (cursor !== undefined && cursor !== null) query.cursor = cursor;
if (pageToken !== undefined && pageToken !== null) query.pageToken = pageToken;
if (pageNumber !== undefined && pageNumber !== null) query.page = pageNumber;
const result = (await request<PaginatedResponse<T>>({ method: "GET", path, query })) ?? {
data: [] as T[],
};
const items = Array.isArray(result?.data) ? result.data : [];
for (const item of items) yield item;
pages += 1;
const nextCursor = result?.nextCursor ?? null;
const nextToken = result?.nextPageToken ?? null;
const nextPage = result?.nextPage ?? null;
if (nextCursor) {
cursor = nextCursor;
pageToken = null;
pageNumber = null;
} else if (nextToken) {
pageToken = nextToken;
cursor = null;
pageNumber = null;
} else if (nextPage) {
pageNumber = nextPage;
cursor = null;
pageToken = null;
} else {
break;
}
if (items.length === 0) break;
}
}
async function refreshAuth(): Promise<void> {
if (config.auth?.getToken) {
cachedToken = await config.auth.getToken();
}
}
return {
request,
paginate,
refreshAuth,
baseUrl,
};
}
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { getAuthToken } from "./generated/core/auth.gen.js";
import { createClient } from "./generated/client/client.gen.js";
import {
buildUrl,
createConfig,
createInterceptors,
createQuerySerializer,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./generated/client/utils.gen.js";
describe("generated auth helpers", () => {
it("formats auth tokens by security scheme", async () => {
await expect(getAuthToken({ scheme: "bearer", type: "http" }, "jwt")).resolves.toBe("Bearer jwt");
await expect(getAuthToken({ scheme: "basic", type: "http" }, "user:pass")).resolves.toBe(`Basic ${btoa("user:pass")}`);
await expect(getAuthToken({ in: "query", name: "api_key", type: "apiKey" }, () => "key")).resolves.toBe("key");
await expect(getAuthToken({ type: "apiKey" }, undefined)).resolves.toBeUndefined();
});
});
describe("generated client utilities", () => {
it("serializes query parameters with global and per-parameter overrides", () => {
const querySerializer = createQuerySerializer({
allowReserved: true,
array: { explode: false, style: "pipeDelimited" },
object: { explode: false, style: "form" },
parameters: {
ids: { allowReserved: false, array: { explode: true, style: "form" } },
},
});
expect(
querySerializer({
filter: { owner: "ops/team", status: "open" },
ids: ["a/b", "c"],
q: "a/b",
skipped: undefined,
})
).toBe("filter=owner,ops/team,status,open&ids=a%2Fb&ids=c&q=a/b");
});
it("infers response parsing from content type", () => {
expect(getParseAs(null)).toBe("stream");
expect(getParseAs("application/problem+json; charset=utf-8")).toBe("json");
expect(getParseAs("multipart/form-data; boundary=x")).toBe("formData");
expect(getParseAs("image/png")).toBe("blob");
expect(getParseAs("text/csv")).toBe("text");
expect(getParseAs("")).toBe("stream");
expect(getParseAs("x-custom/value")).toBeUndefined();
});
it("sets auth in headers, query params, and cookies without overwriting existing values", async () => {
const headers = new Headers({ Authorization: "Bearer existing", Cookie: "session=abc" });
const query = { api_key: "existing" };
await setAuthParams({
auth: ({ name }) => `token-for-${name ?? "default"}`,
headers,
query,
security: [
{ in: "header", name: "Authorization", scheme: "bearer", type: "http" },
{ in: "query", name: "api_key", type: "apiKey" },
{ in: "cookie", name: "csrf", type: "apiKey" },
{ in: "header", name: "X-New-Token", type: "apiKey" },
],
});
expect(headers.get("Authorization")).toBe("Bearer existing");
expect(query.api_key).toBe("existing");
expect(headers.get("Cookie")).toBe("session=abc; csrf=token-for-csrf");
expect(headers.get("X-New-Token")).toBe("token-for-X-New-Token");
});
it("merges config and headers while honoring removals", () => {
const mergedHeaders = mergeHeaders(
{ Accept: "application/json", "X-List": ["a", "b"], "X-Json": { ok: true } },
new Headers({ Accept: "text/plain" }),
{ "X-Json": null, "X-Skip": undefined }
);
expect(mergedHeaders.get("Accept")).toBe("text/plain");
expect(mergedHeaders.get("X-List")).toBe("a, b");
expect(mergedHeaders.has("X-Json")).toBe(false);
const config = mergeConfigs({ baseUrl: "https://api.example.test/", headers: { A: "1" } }, { headers: { B: "2" } });
const configHeaders = config.headers as Headers;
expect(config.baseUrl).toBe("https://api.example.test");
expect(configHeaders.get("A")).toBe("1");
expect(configHeaders.get("B")).toBe("2");
});
it("creates default config, build URLs, and manages interceptors", () => {
const config = createConfig({ baseUrl: "https://api.example.test" });
expect(config.parseAs).toBe("auto");
expect(config.headers).toEqual({ "Content-Type": "application/json" });
expect(
buildUrl({
...config,
path: { id: "tool/a" },
query: { q: "drill" },
url: "/v1/tools/{id}",
})
).toBe("https://api.example.test/v1/tools/tool%2Fa?q=drill");
const interceptors = createInterceptors<Request, Response, unknown, { tag: string }>();
const fn = (request: Request) => request;
const id = interceptors.request.use(fn);
expect(interceptors.request.exists(id)).toBe(true);
expect(interceptors.request.update(id, fn)).toBe(id);
interceptors.request.eject(fn);
expect(interceptors.request.exists(id)).toBe(false);
interceptors.request.clear();
expect(interceptors.request.fns).toEqual([]);
});
});
describe("generated fetch client", () => {
it("builds requests, runs interceptors, validates, transforms, and returns data", async () => {
const seen: Array<string> = [];
const client = createClient({
baseUrl: "https://api.example.test",
fetch: async (requestInfo) => {
const request = requestInfo as Request;
seen.push(request.headers.get("X-Request-Interceptor") ?? "");
expect(request.method).toBe("POST");
expect(request.url).toBe("https://api.example.test/v1/tools?q=drill");
expect(await request.text()).toBe('{"name":"press"}');
return new Response('{"id":"tool_1"}', { headers: { "Content-Type": "application/json" } });
},
responseStyle: "data",
});
client.interceptors.request.use((request) => {
request.headers.set("X-Request-Interceptor", "ran");
return request;
});
client.interceptors.response.use((response) => {
response.headers.set("X-Response-Interceptor", "ran");
return response;
});
const data = await client.post({
body: { name: "press" },
query: { q: "drill" },
responseTransformer: async (body) => ({ ...(body as Record<string, unknown>), transformed: true }),
responseValidator: async (body) => {
expect(body).toEqual({ id: "tool_1" });
},
url: "/v1/tools",
});
expect(seen).toEqual(["ran"]);
expect(data).toEqual({ id: "tool_1", transformed: true });
});
it("returns structured errors when throwOnError is disabled", async () => {
const client = createClient({
baseUrl: "https://api.example.test",
fetch: async () => new Response('{"error":"nope"}', { status: 400 }),
throwOnError: false,
});
client.interceptors.error.use((error) => ({ wrapped: error }));
await expect(client.get({ url: "/v1/fail" })).resolves.toMatchObject({
error: { wrapped: { error: "nope" } },
response: { status: 400 },
});
});
it("returns typed empty responses for no-content results", async () => {
const client = createClient({
baseUrl: "https://api.example.test",
fetch: async () => new Response(null, { status: 204 }),
responseStyle: "data",
});
await expect(client.delete({ parseAs: "json", url: "/v1/tools/tool_1" })).resolves.toEqual({});
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "./generated/core/bodySerializer.gen.js";
import { buildClientParams } from "./generated/core/params.gen.js";
import {
defaultPathSerializer,
getUrl,
getValidRequestBody,
} from "./generated/core/utils.gen.js";
import {
separatorArrayExplode,
separatorArrayNoExplode,
separatorObjectExplode,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./generated/core/pathSerializer.gen.js";
import { serializeQueryKeyValue, stringifyToJsonValue } from "./generated/core/queryKeySerializer.gen.js";
describe("generated core body serializers", () => {
it("serializes JSON bodies with bigint values", () => {
expect(jsonBodySerializer.bodySerializer({ id: 12n, name: "tool" })).toBe('{"id":"12","name":"tool"}');
});
it("serializes FormData bodies while skipping nullish values", async () => {
const file = new Blob(["hello"], { type: "text/plain" });
const date = new Date("2026-05-25T10:30:00.000Z");
const data = formDataBodySerializer.bodySerializer({
ignored: undefined,
list: ["a", 2],
meta: { ok: true },
stamp: date,
upload: file,
});
expect(data.getAll("list")).toEqual(["a", "2"]);
expect(data.get("meta")).toBe('{"ok":true}');
expect(data.get("stamp")).toBe(date.toISOString());
expect(data.get("upload")).toBeInstanceOf(Blob);
expect(data.has("ignored")).toBe(false);
});
it("serializes URLSearchParams bodies with repeated array entries", () => {
expect(
urlSearchParamsBodySerializer.bodySerializer({
filters: ["active", { owner: "me" }],
page: 2,
skipped: null,
})
).toBe("filters=active&filters=%7B%22owner%22%3A%22me%22%7D&page=2");
});
});
describe("generated core parameter mapping", () => {
it("maps positional and object parameters into request slots", () => {
const params = buildClientParams(
[
"project-1",
{ raw: true },
{
$headers_traceparent: "00-ab",
$query_search: "drill",
apiVersion: "2026-05",
extra: "kept",
payload: { name: "new tool" },
},
],
[
{ in: "path", key: "projectId", map: "project_id" },
{ in: "body" },
{
allowExtra: { query: true },
args: [
{ in: "headers", key: "apiVersion", map: "X-Api-Version" },
{ key: "payload", map: "body" },
],
},
]
);
expect(params).toEqual({
body: { name: "new tool" },
headers: {
"X-Api-Version": "2026-05",
traceparent: "00-ab",
},
path: { project_id: "project-1" },
query: {
extra: "kept",
search: "drill",
},
});
});
it("removes empty slots from the mapped params", () => {
expect(buildClientParams(["abc"], [{ in: "path", key: "id" }])).toEqual({ path: { id: "abc" } });
});
});
describe("generated core path and URL serializers", () => {
it("serializes primitive, array, and object path params", () => {
expect(
defaultPathSerializer({
path: {
coords: ["10", "20"],
filter: { status: "open", owner: "ops" },
id: "tool/a",
tag: "beta",
},
url: "/v1/tools/{id}/{.tag}/{;coords*}/{filter}",
})
).toBe("/v1/tools/tool%2Fa/.beta/;coords=10;coords=20/status,open,owner,ops");
});
it("serializes query and path parameters into a full URL", () => {
const url = getUrl({
baseUrl: "https://api.example.test",
path: { id: "a/b" },
query: { active: true, tags: ["red", "blue"] },
querySerializer: (query) => `?active=${query.active}&tags=${(query.tags as string[]).join(",")}`,
url: "v1/tools/{id}",
});
expect(url).toBe("https://api.example.test/v1/tools/a%2Fb?active=true&tags=red,blue");
});
it("exposes OpenAPI separator and primitive serialization behavior", () => {
expect(separatorArrayExplode("label")).toBe(".");
expect(separatorArrayNoExplode("pipeDelimited")).toBe("|");
expect(separatorObjectExplode("matrix")).toBe(";");
expect(serializeArrayParam({ explode: false, name: "ids", style: "pipeDelimited", value: ["a/b", "c"] })).toBe("ids=a%2Fb|c");
expect(serializePrimitiveParam({ allowReserved: true, name: "q", value: "a/b?c" })).toBe("q=a/b?c");
expect(() => serializePrimitiveParam({ name: "bad", value: { nested: true } as never })).toThrow(/Deeply-nested/);
});
it("serializes object params for dates, labels, and deep objects", () => {
const date = new Date("2026-05-25T12:00:00.000Z");
expect(serializeObjectParam({ explode: true, name: "at", style: "form", value: date, valueOnly: true })).toBe(date.toISOString());
expect(serializeObjectParam({ explode: false, name: "filter", style: "label", value: { status: "open" } })).toBe(".status,open");
expect(serializeObjectParam({ explode: true, name: "filter", style: "deepObject", value: { status: "open" } })).toBe(
"filter[status]=open"
);
});
it("returns the valid request body shape for serialized, plain, and empty bodies", () => {
expect(getValidRequestBody({ body: { ok: true } })).toEqual({ ok: true });
expect(getValidRequestBody({ body: "", bodySerializer: JSON.stringify })).toBeNull();
expect(getValidRequestBody({ body: { ok: true }, bodySerializer: JSON.stringify, serializedBody: '{"ok":true}' })).toBe('{"ok":true}');
expect(getValidRequestBody({ body: { ok: true }, bodySerializer: JSON.stringify, serializedBody: "" })).toBeNull();
expect(getValidRequestBody({})).toBeUndefined();
});
});
describe("generated core query key serialization", () => {
it("normalizes query key values into JSON-safe shapes", () => {
const params = new URLSearchParams([
["b", "2"],
["a", "1"],
["a", "3"],
]);
expect(serializeQueryKeyValue(params)).toEqual({ a: ["1", "3"], b: "2" });
expect(serializeQueryKeyValue({ createdAt: new Date("2026-05-25T00:00:00.000Z"), id: 123n })).toEqual({
createdAt: "2026-05-25T00:00:00.000Z",
id: "123",
});
expect(serializeQueryKeyValue(Object.create(null))).toEqual({});
expect(serializeQueryKeyValue(Symbol("skip"))).toBeUndefined();
});
it("returns undefined when JSON serialization cannot produce a value", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(stringifyToJsonValue(circular)).toBeUndefined();
expect(stringifyToJsonValue(() => undefined)).toBeUndefined();
});
});
@@ -0,0 +1,132 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { createSseClient } from "./generated/core/serverSentEvents.gen.js";
const encoder = new TextEncoder();
function streamChunks(chunks: Array<string>, options: { errorAfterChunks?: Error } = {}) {
let index = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(encoder.encode(chunks[index]));
index += 1;
return;
}
if (options.errorAfterChunks) {
controller.error(options.errorAfterChunks);
return;
}
controller.close();
},
});
}
function requestCompatibleSignal() {
return new Request("https://signal.example.test").signal;
}
describe("generated SSE client", () => {
it("parses events, transforms JSON payloads, and resumes retries with Last-Event-ID", async () => {
const requests: Array<Request> = [];
const events: Array<unknown> = [];
const retryDelays: Array<number> = [];
const errors: Array<unknown> = [];
const client = createSseClient<{ kind: string; count: number }>({
fetch: async (requestInfo) => {
const request = requestInfo as Request;
requests.push(request);
if (requests.length === 1) {
return new Response(
streamChunks(
[
"id: cursor-1\r\nevent: snapshot\r\nretry: 25\r\ndata: {\"kind\":\"snapshot\",\"count\":1}\r\n\r\n",
],
{ errorAfterChunks: new Error("socket closed") }
),
{ headers: { "Content-Type": "text/event-stream" } }
);
}
expect(request.headers.get("Last-Event-ID")).toBe("cursor-1");
return new Response(streamChunks(["event: notification\ndata: plain text\n\n"]), {
headers: { "Content-Type": "text/event-stream" },
});
},
onSseError: (error) => errors.push(error),
onSseEvent: (event) => events.push(event),
responseTransformer: async (data) => ({ ...(data as Record<string, unknown>), transformed: true }),
responseValidator: async (data) => {
expect(data).toEqual({ kind: "snapshot", count: 1 });
},
signal: requestCompatibleSignal(),
sseMaxRetryAttempts: 2,
sseSleepFn: async (ms) => {
retryDelays.push(ms);
},
url: "https://api.example.test/v1/notifications/stream",
});
await expect(client.stream.next()).resolves.toEqual({
done: false,
value: { kind: "snapshot", count: 1, transformed: true },
});
await expect(client.stream.next()).resolves.toEqual({
done: false,
value: "plain text",
});
await expect(client.stream.next()).resolves.toEqual({
done: true,
value: undefined,
});
expect(requests).toHaveLength(2);
expect(requests[0].headers.has("Last-Event-ID")).toBe(false);
expect(events).toEqual([
{
data: { kind: "snapshot", count: 1, transformed: true },
event: "snapshot",
id: "cursor-1",
retry: 25,
},
{
data: "plain text",
event: "notification",
id: "cursor-1",
retry: 25,
},
]);
expect(errors).toHaveLength(1);
expect(retryDelays).toEqual([25]);
});
it("reports failed responses and stops when retry attempts are exhausted", async () => {
const errors: Array<unknown> = [];
const client = createSseClient({
fetch: async () => new Response("nope", { status: 503, statusText: "Unavailable" }),
onSseError: (error) => errors.push(error),
signal: requestCompatibleSignal(),
sseMaxRetryAttempts: 1,
sseSleepFn: async () => {
throw new Error("sleep should not run after final attempt");
},
url: "https://api.example.test/v1/notifications/stream",
});
await expect(client.stream.next()).resolves.toEqual({
done: true,
value: undefined,
});
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(Error);
expect((errors[0] as Error).message).toBe("SSE failed: 503 Unavailable");
});
});
@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
import type { ClientOptions as ClientOptions2 } from "./types.gen";
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "https://api.toolctrl.de" }));
@@ -0,0 +1,267 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen";
import type { HttpMethod } from "../core/types.gen";
import { getValidRequestBody } from "../core/utils.gen";
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
import { buildUrl, createConfig, createInterceptors, getParseAs, mergeConfigs, mergeHeaders, setAuthParams } from "./utils.gen";
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async <
TData = unknown,
TResponseStyle extends "data" | "fields" = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>
) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type");
}
const resolvedOpts = opts as typeof opts & ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts: resolvedOpts, url };
};
const request: Client["request"] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const responseStyle = options.responseStyle ?? _config.responseStyle;
let request: Request | undefined;
let response: Response | undefined;
try {
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: getValidRequestBody(opts),
};
request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
response = await _fetch(request);
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs = (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any;
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]();
break;
case "formData":
emptyData = new FormData();
break;
case "stream":
emptyData = response.body;
break;
case "json":
default:
emptyData = {};
break;
}
return opts.responseStyle === "data"
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "formData":
case "text":
data = await response[parseAs]();
break;
case "json": {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === "data"
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
throw jsonError ?? textError;
} catch (error) {
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
}
}
finalError = finalError || {};
if (throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return responseStyle === "data"
? undefined
: {
error: finalError,
request,
response,
};
}
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};
const _buildUrl: Client["buildUrl"] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl: _buildUrl,
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
getConfig,
head: makeMethodFn("HEAD"),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
request,
setConfig,
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client;
};
@@ -0,0 +1,21 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen";
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen";
export { buildClientParams } from "../core/params.gen";
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
export { createClient } from "./client.gen";
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from "./types.gen";
export { createConfig, mergeHeaders } from "./utils.gen";
@@ -0,0 +1,184 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from "../core/auth.gen";
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
import type { Middleware } from "./utils.gen";
export type ResponseStyle = "data" | "fields";
export interface Config<T extends ClientOptions = ClientOptions> extends Omit<RequestInit, "body" | "headers" | "method">, CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T["baseUrl"];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
>
extends
Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<ServerSentEventsOptions<TData>, "onRequest" | "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true
? Promise<
TResponseStyle extends "data"
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends "data"
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
/** request may be undefined, because error may be from building the request object itself */
request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, "method">
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> & Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
([TData] extends [never] ? unknown : Omit<TData, "url">);
@@ -0,0 +1,288 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from "../core/auth.gen";
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
import { jsonBodySerializer } from "../core/bodySerializer.gen";
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
import { getUrl } from "../core/utils.gen";
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "form",
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "deepObject",
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join("&");
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return "stream";
}
const cleanContent = contentType.split(";")[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
return "json";
}
if (cleanContent === "multipart/form-data") {
return "formData";
}
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
return "blob";
}
if (cleanContent.startsWith("text/")) {
return "text";
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, "auth" | "query"> & {
headers: Headers;
},
name?: string
): boolean => {
if (!name) {
return false;
}
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? "Authorization";
switch (auth.in) {
case "query":
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case "cookie":
options.headers.append("Cookie", `${name}=${token}`);
break;
case "header":
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client["buildUrl"] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer: typeof options.querySerializer === "function" ? options.querySerializer : createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
/** response may be undefined due to a network error where no response object is produced */
response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: "form",
},
object: {
explode: true,
style: "deepObject",
},
});
const defaultHeaders = {
"Content-Type": "application/json",
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {}
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: "auto",
querySerializer: defaultQuerySerializer,
...override,
});
@@ -0,0 +1,38 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: "header" | "query" | "cookie";
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: "basic" | "bearer";
type: "apiKey" | "http";
}
export const getAuthToken = async (auth: Auth, callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken): Promise<string | undefined> => {
const token = typeof callback === "function" ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === "bearer") {
return `Bearer ${token}`;
}
if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`;
}
return token;
};
@@ -0,0 +1,81 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === "string") {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: (body: unknown): string => JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};
@@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = "body" | "headers" | "path" | "query";
export type Field =
| {
in: Exclude<Slot, "body">;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, "body">;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ("in" in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ("key" in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === "object" && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ("in" in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ("allowExtra" in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};
@@ -0,0 +1,163 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = "label" | "matrix" | "simple";
export type ObjectStyle = "form" | "deepObject";
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
default:
return "&";
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "form":
return ",";
case "pipeDelimited":
return "|";
case "spaceDelimited":
return "%20";
default:
return ",";
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
default:
return "&";
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(separatorArrayNoExplode(style));
switch (style) {
case "label":
return `.${joinedValues}`;
case "matrix":
return `;${name}=${joinedValues}`;
case "simple":
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
};
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "object") {
throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== "deepObject" && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
});
const joinedValues = values.join(",");
switch (style) {
case "form":
return `${name}=${joinedValues}`;
case "label":
return `.${joinedValues}`;
case "matrix":
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string,
})
)
.join(separator);
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
};
@@ -0,0 +1,111 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};
@@ -0,0 +1,235 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen";
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit["body"];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
};
export function createSseClient<TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error("No body in SSE response");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener("abort", abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
buffer = buffer.replace(/\r\n?/g, "\n"); // normalize line endings
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const lines = chunk.split("\n");
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""));
} else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, "");
} else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, "");
} else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join("\n");
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener("abort", abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
}
@@ -0,0 +1,78 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from "./auth.gen";
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?: RequestInit["headers"] | Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] ? true : [T] extends [never | undefined] ? ([undefined] extends [T] ? false : true) : false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
};
@@ -0,0 +1,128 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
import { type ArraySeparatorStyle, serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./pathSerializer.gen";
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
name = name.substring(1);
style = "matrix";
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
})
);
continue;
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`
);
continue;
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? "") + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: { body?: unknown; bodySerializer?: BodySerializer | null; serializedBody?: unknown }) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== "" ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}
@@ -0,0 +1,260 @@
// This file is auto-generated by @hey-api/openapi-ts
export {
acceptWorkspaceInvite,
createAnalyticsEvent,
createAnalyticsPageView,
createLegacyEntityRow,
createLegacyRowRelationship,
createSupportTicketFromInboundEmail,
createWorkspaceInvite,
deleteLegacyEntityRow,
deleteLegacyRowRelationship,
enqueueExportArtifactRetention,
getAccount,
getAdminAnalyticsOverview,
getApiDocs,
getApiUsage,
getCurrentUserSession,
getExportDownloadUrl,
getExportJobProgress,
getHealthz,
getLegacyEntityByName,
getLegacyEntityRow,
getLegacyRowRelationship,
getMetrics,
getOpenApiDocument,
getPublicAnalyticsOverview,
getReadyz,
getStatus,
getV1Health,
listAccounts,
listLegacyEntityRows,
listWorkspaceAuthProviders,
listWorkspaceInvites,
loginWithPassword,
logoutApiSession,
type Options,
postDeviceRegister,
postDeviceRevoke,
receiveEmailSequenceMailjetWebhook,
receiveStripeBillingWebhook,
refreshJwtToken,
retryEventWebhookAttempt,
revalidateWorkspaceContext,
revokeWorkspaceInvite,
streamNotifications,
updateLegacyEntityRow,
uploadAiFile,
validateApiKey,
} from "./sdk.gen";
export type {
AcceptWorkspaceInviteData,
AcceptWorkspaceInviteError,
AcceptWorkspaceInviteErrors,
AcceptWorkspaceInviteResponse,
AcceptWorkspaceInviteResponses,
ClientOptions,
CreateAnalyticsEventData,
CreateAnalyticsEventError,
CreateAnalyticsEventErrors,
CreateAnalyticsEventResponse,
CreateAnalyticsEventResponses,
CreateAnalyticsPageViewData,
CreateAnalyticsPageViewError,
CreateAnalyticsPageViewErrors,
CreateAnalyticsPageViewResponse,
CreateAnalyticsPageViewResponses,
CreateLegacyEntityRowData,
CreateLegacyEntityRowError,
CreateLegacyEntityRowErrors,
CreateLegacyEntityRowResponse,
CreateLegacyEntityRowResponses,
CreateLegacyRowRelationshipData,
CreateLegacyRowRelationshipError,
CreateLegacyRowRelationshipErrors,
CreateLegacyRowRelationshipResponse,
CreateLegacyRowRelationshipResponses,
CreateSupportTicketFromInboundEmailData,
CreateSupportTicketFromInboundEmailError,
CreateSupportTicketFromInboundEmailErrors,
CreateSupportTicketFromInboundEmailResponses,
CreateWorkspaceInviteData,
CreateWorkspaceInviteError,
CreateWorkspaceInviteErrors,
CreateWorkspaceInviteResponse,
CreateWorkspaceInviteResponses,
DeleteLegacyEntityRowData,
DeleteLegacyEntityRowError,
DeleteLegacyEntityRowErrors,
DeleteLegacyEntityRowResponse,
DeleteLegacyEntityRowResponses,
DeleteLegacyRowRelationshipData,
DeleteLegacyRowRelationshipError,
DeleteLegacyRowRelationshipErrors,
DeleteLegacyRowRelationshipResponse,
DeleteLegacyRowRelationshipResponses,
EnqueueExportArtifactRetentionData,
EnqueueExportArtifactRetentionError,
EnqueueExportArtifactRetentionErrors,
EnqueueExportArtifactRetentionResponse,
EnqueueExportArtifactRetentionResponses,
GetAccountData,
GetAccountError,
GetAccountErrors,
GetAccountResponse,
GetAccountResponses,
GetAdminAnalyticsOverviewData,
GetAdminAnalyticsOverviewError,
GetAdminAnalyticsOverviewErrors,
GetAdminAnalyticsOverviewResponse,
GetAdminAnalyticsOverviewResponses,
GetApiDocsData,
GetApiDocsResponses,
GetApiUsageData,
GetApiUsageError,
GetApiUsageErrors,
GetApiUsageResponse,
GetApiUsageResponses,
GetCurrentUserSessionData,
GetCurrentUserSessionError,
GetCurrentUserSessionErrors,
GetCurrentUserSessionResponse,
GetCurrentUserSessionResponses,
GetExportDownloadUrlData,
GetExportDownloadUrlError,
GetExportDownloadUrlErrors,
GetExportDownloadUrlResponse,
GetExportDownloadUrlResponses,
GetExportJobProgressData,
GetExportJobProgressError,
GetExportJobProgressErrors,
GetExportJobProgressResponse,
GetExportJobProgressResponses,
GetHealthzData,
GetHealthzResponse,
GetHealthzResponses,
GetLegacyEntityByNameData,
GetLegacyEntityByNameError,
GetLegacyEntityByNameErrors,
GetLegacyEntityByNameResponses,
GetLegacyEntityRowData,
GetLegacyEntityRowError,
GetLegacyEntityRowErrors,
GetLegacyEntityRowResponse,
GetLegacyEntityRowResponses,
GetLegacyRowRelationshipData,
GetLegacyRowRelationshipError,
GetLegacyRowRelationshipErrors,
GetLegacyRowRelationshipResponse,
GetLegacyRowRelationshipResponses,
GetMetricsData,
GetMetricsError,
GetMetricsErrors,
GetMetricsResponse,
GetMetricsResponses,
GetOpenApiDocumentData,
GetOpenApiDocumentResponses,
GetPublicAnalyticsOverviewData,
GetPublicAnalyticsOverviewError,
GetPublicAnalyticsOverviewErrors,
GetPublicAnalyticsOverviewResponse,
GetPublicAnalyticsOverviewResponses,
GetReadyzData,
GetReadyzError,
GetReadyzErrors,
GetReadyzResponse,
GetReadyzResponses,
GetStatusData,
GetStatusError,
GetStatusErrors,
GetStatusResponse,
GetStatusResponses,
GetV1HealthData,
GetV1HealthResponse,
GetV1HealthResponses,
ListAccountsData,
ListAccountsError,
ListAccountsErrors,
ListAccountsResponse,
ListAccountsResponses,
ListLegacyEntityRowsData,
ListLegacyEntityRowsError,
ListLegacyEntityRowsErrors,
ListLegacyEntityRowsResponse,
ListLegacyEntityRowsResponses,
ListWorkspaceAuthProvidersData,
ListWorkspaceAuthProvidersError,
ListWorkspaceAuthProvidersErrors,
ListWorkspaceAuthProvidersResponse,
ListWorkspaceAuthProvidersResponses,
ListWorkspaceInvitesData,
ListWorkspaceInvitesError,
ListWorkspaceInvitesErrors,
ListWorkspaceInvitesResponse,
ListWorkspaceInvitesResponses,
LoginWithPasswordData,
LoginWithPasswordError,
LoginWithPasswordErrors,
LoginWithPasswordResponses,
LogoutApiSessionData,
LogoutApiSessionError,
LogoutApiSessionErrors,
LogoutApiSessionResponses,
PostDeviceRegisterData,
PostDeviceRegisterError,
PostDeviceRegisterErrors,
PostDeviceRegisterResponse,
PostDeviceRegisterResponses,
PostDeviceRevokeData,
PostDeviceRevokeError,
PostDeviceRevokeErrors,
PostDeviceRevokeResponse,
PostDeviceRevokeResponses,
ReceiveEmailSequenceMailjetWebhookData,
ReceiveEmailSequenceMailjetWebhookError,
ReceiveEmailSequenceMailjetWebhookErrors,
ReceiveEmailSequenceMailjetWebhookResponses,
ReceiveStripeBillingWebhookData,
ReceiveStripeBillingWebhookError,
ReceiveStripeBillingWebhookErrors,
ReceiveStripeBillingWebhookResponses,
RefreshJwtTokenData,
RefreshJwtTokenError,
RefreshJwtTokenErrors,
RefreshJwtTokenResponses,
RetryEventWebhookAttemptData,
RetryEventWebhookAttemptError,
RetryEventWebhookAttemptErrors,
RetryEventWebhookAttemptResponses,
RevalidateWorkspaceContextData,
RevalidateWorkspaceContextError,
RevalidateWorkspaceContextErrors,
RevalidateWorkspaceContextResponse,
RevalidateWorkspaceContextResponses,
RevokeWorkspaceInviteData,
RevokeWorkspaceInviteError,
RevokeWorkspaceInviteErrors,
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
StreamNotificationsData,
StreamNotificationsError,
StreamNotificationsErrors,
StreamNotificationsResponse,
StreamNotificationsResponses,
UpdateLegacyEntityRowData,
UpdateLegacyEntityRowError,
UpdateLegacyEntityRowErrors,
UpdateLegacyEntityRowResponse,
UpdateLegacyEntityRowResponses,
UploadAiFileData,
UploadAiFileError,
UploadAiFileErrors,
UploadAiFileResponse,
UploadAiFileResponses,
ValidateApiKeyData,
ValidateApiKeyError,
ValidateApiKeyErrors,
ValidateApiKeyResponse,
ValidateApiKeyResponses,
} from "./types.gen";
@@ -0,0 +1,631 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from "./client";
import { client } from "./client.gen";
import type {
AcceptWorkspaceInviteData,
AcceptWorkspaceInviteErrors,
AcceptWorkspaceInviteResponses,
CreateAnalyticsEventData,
CreateAnalyticsEventErrors,
CreateAnalyticsEventResponses,
CreateAnalyticsPageViewData,
CreateAnalyticsPageViewErrors,
CreateAnalyticsPageViewResponses,
CreateLegacyEntityRowData,
CreateLegacyEntityRowErrors,
CreateLegacyEntityRowResponses,
CreateLegacyRowRelationshipData,
CreateLegacyRowRelationshipErrors,
CreateLegacyRowRelationshipResponses,
CreateSupportTicketFromInboundEmailData,
CreateSupportTicketFromInboundEmailErrors,
CreateSupportTicketFromInboundEmailResponses,
CreateWorkspaceInviteData,
CreateWorkspaceInviteErrors,
CreateWorkspaceInviteResponses,
DeleteLegacyEntityRowData,
DeleteLegacyEntityRowErrors,
DeleteLegacyEntityRowResponses,
DeleteLegacyRowRelationshipData,
DeleteLegacyRowRelationshipErrors,
DeleteLegacyRowRelationshipResponses,
EnqueueExportArtifactRetentionData,
EnqueueExportArtifactRetentionErrors,
EnqueueExportArtifactRetentionResponses,
GetAccountData,
GetAccountErrors,
GetAccountResponses,
GetAdminAnalyticsOverviewData,
GetAdminAnalyticsOverviewErrors,
GetAdminAnalyticsOverviewResponses,
GetApiDocsData,
GetApiDocsResponses,
GetApiUsageData,
GetApiUsageErrors,
GetApiUsageResponses,
GetCurrentUserSessionData,
GetCurrentUserSessionErrors,
GetCurrentUserSessionResponses,
GetExportDownloadUrlData,
GetExportDownloadUrlErrors,
GetExportDownloadUrlResponses,
GetExportJobProgressData,
GetExportJobProgressErrors,
GetExportJobProgressResponses,
GetHealthzData,
GetHealthzResponses,
GetLegacyEntityByNameData,
GetLegacyEntityByNameErrors,
GetLegacyEntityByNameResponses,
GetLegacyEntityRowData,
GetLegacyEntityRowErrors,
GetLegacyEntityRowResponses,
GetLegacyRowRelationshipData,
GetLegacyRowRelationshipErrors,
GetLegacyRowRelationshipResponses,
GetMetricsData,
GetMetricsErrors,
GetMetricsResponses,
GetOpenApiDocumentData,
GetOpenApiDocumentResponses,
GetPublicAnalyticsOverviewData,
GetPublicAnalyticsOverviewErrors,
GetPublicAnalyticsOverviewResponses,
GetReadyzData,
GetReadyzErrors,
GetReadyzResponses,
GetStatusData,
GetStatusErrors,
GetStatusResponses,
GetV1HealthData,
GetV1HealthResponses,
ListAccountsData,
ListAccountsErrors,
ListAccountsResponses,
ListLegacyEntityRowsData,
ListLegacyEntityRowsErrors,
ListLegacyEntityRowsResponses,
ListWorkspaceAuthProvidersData,
ListWorkspaceAuthProvidersErrors,
ListWorkspaceAuthProvidersResponses,
ListWorkspaceInvitesData,
ListWorkspaceInvitesErrors,
ListWorkspaceInvitesResponses,
LoginWithPasswordData,
LoginWithPasswordErrors,
LoginWithPasswordResponses,
LogoutApiSessionData,
LogoutApiSessionErrors,
LogoutApiSessionResponses,
PostDeviceRegisterData,
PostDeviceRegisterErrors,
PostDeviceRegisterResponses,
PostDeviceRevokeData,
PostDeviceRevokeErrors,
PostDeviceRevokeResponses,
ReceiveEmailSequenceMailjetWebhookData,
ReceiveEmailSequenceMailjetWebhookErrors,
ReceiveEmailSequenceMailjetWebhookResponses,
ReceiveStripeBillingWebhookData,
ReceiveStripeBillingWebhookErrors,
ReceiveStripeBillingWebhookResponses,
RefreshJwtTokenData,
RefreshJwtTokenErrors,
RefreshJwtTokenResponses,
RetryEventWebhookAttemptData,
RetryEventWebhookAttemptErrors,
RetryEventWebhookAttemptResponses,
RevalidateWorkspaceContextData,
RevalidateWorkspaceContextErrors,
RevalidateWorkspaceContextResponses,
RevokeWorkspaceInviteData,
RevokeWorkspaceInviteErrors,
RevokeWorkspaceInviteResponses,
StreamNotificationsData,
StreamNotificationsErrors,
StreamNotificationsResponse,
StreamNotificationsResponses,
UpdateLegacyEntityRowData,
UpdateLegacyEntityRowErrors,
UpdateLegacyEntityRowResponses,
UploadAiFileData,
UploadAiFileErrors,
UploadAiFileResponses,
ValidateApiKeyData,
ValidateApiKeyErrors,
ValidateApiKeyResponses,
} from "./types.gen";
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<
TData,
ThrowOnError,
TResponse
> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
export const getHealthz = <ThrowOnError extends boolean = false>(options?: Options<GetHealthzData, ThrowOnError>) =>
(options?.client ?? client).get<GetHealthzResponses, unknown, ThrowOnError>({ url: "/healthz", ...options });
export const getV1Health = <ThrowOnError extends boolean = false>(options?: Options<GetV1HealthData, ThrowOnError>) =>
(options?.client ?? client).get<GetV1HealthResponses, unknown, ThrowOnError>({ url: "/v1/health", ...options });
export const getReadyz = <ThrowOnError extends boolean = false>(options?: Options<GetReadyzData, ThrowOnError>) =>
(options?.client ?? client).get<GetReadyzResponses, GetReadyzErrors, ThrowOnError>({ url: "/readyz", ...options });
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) =>
(options?.client ?? client).get<GetStatusResponses, GetStatusErrors, ThrowOnError>({ url: "/status", ...options });
export const getMetrics = <ThrowOnError extends boolean = false>(options?: Options<GetMetricsData, ThrowOnError>) =>
(options?.client ?? client).get<GetMetricsResponses, GetMetricsErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/metrics",
...options,
});
export const getCurrentUserSession = <ThrowOnError extends boolean = false>(options?: Options<GetCurrentUserSessionData, ThrowOnError>) =>
(options?.client ?? client).get<GetCurrentUserSessionResponses, GetCurrentUserSessionErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/session/current-user",
...options,
});
export const getApiDocs = <ThrowOnError extends boolean = false>(options?: Options<GetApiDocsData, ThrowOnError>) =>
(options?.client ?? client).get<GetApiDocsResponses, unknown, ThrowOnError>({ url: "/v1/docs", ...options });
export const getOpenApiDocument = <ThrowOnError extends boolean = false>(options?: Options<GetOpenApiDocumentData, ThrowOnError>) =>
(options?.client ?? client).get<GetOpenApiDocumentResponses, unknown, ThrowOnError>({ url: "/v1/openapi.json", ...options });
export const loginWithPassword = <ThrowOnError extends boolean = false>(options: Options<LoginWithPasswordData, ThrowOnError>) =>
(options.client ?? client).post<LoginWithPasswordResponses, LoginWithPasswordErrors, ThrowOnError>({
url: "/v1/auth/login",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const refreshJwtToken = <ThrowOnError extends boolean = false>(options?: Options<RefreshJwtTokenData, ThrowOnError>) =>
(options?.client ?? client).post<RefreshJwtTokenResponses, RefreshJwtTokenErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/auth/refresh-token",
...options,
});
export const logoutApiSession = <ThrowOnError extends boolean = false>(options?: Options<LogoutApiSessionData, ThrowOnError>) =>
(options?.client ?? client).post<LogoutApiSessionResponses, LogoutApiSessionErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/auth/logout",
...options,
});
export const validateApiKey = <ThrowOnError extends boolean = false>(options?: Options<ValidateApiKeyData, ThrowOnError>) =>
(options?.client ?? client).post<ValidateApiKeyResponses, ValidateApiKeyErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/api-keys/validate",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
export const uploadAiFile = <ThrowOnError extends boolean = false>(options?: Options<UploadAiFileData, ThrowOnError>) =>
(options?.client ?? client).post<UploadAiFileResponses, UploadAiFileErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/ai/upload",
...options,
});
/**
* Accepts Mailjet inbound (parse) email webhooks authenticated with HTTP Basic Auth and creates an idempotent support ticket for the matched tenant inbound address.
*/
export const createSupportTicketFromInboundEmail = <ThrowOnError extends boolean = false>(
options: Options<CreateSupportTicketFromInboundEmailData, ThrowOnError>
) =>
(options.client ?? client).post<CreateSupportTicketFromInboundEmailResponses, CreateSupportTicketFromInboundEmailErrors, ThrowOnError>({
security: [{ scheme: "basic", type: "http" }],
url: "/v1/support/inbound-email",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const createAnalyticsEvent = <ThrowOnError extends boolean = false>(options: Options<CreateAnalyticsEventData, ThrowOnError>) =>
(options.client ?? client).post<CreateAnalyticsEventResponses, CreateAnalyticsEventErrors, ThrowOnError>({
security: [{ name: "x-analytics-token", type: "apiKey" }],
url: "/v1/analytics/events",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const createAnalyticsPageView = <ThrowOnError extends boolean = false>(options: Options<CreateAnalyticsPageViewData, ThrowOnError>) =>
(options.client ?? client).post<CreateAnalyticsPageViewResponses, CreateAnalyticsPageViewErrors, ThrowOnError>({
security: [{ name: "x-analytics-token", type: "apiKey" }],
url: "/v1/analytics/page-views",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const getPublicAnalyticsOverview = <ThrowOnError extends boolean = false>(options?: Options<GetPublicAnalyticsOverviewData, ThrowOnError>) =>
(options?.client ?? client).get<GetPublicAnalyticsOverviewResponses, GetPublicAnalyticsOverviewErrors, ThrowOnError>({
security: [{ name: "x-analytics-token", type: "apiKey" }],
url: "/v1/analytics/public",
...options,
});
/**
* Revalidates a workspace context switch for a tenant/user pair using service-token credentials. The response is a request-ID-correlated, redacted context validity envelope and never includes SSO provider secrets or invite/token material.
*/
export const revalidateWorkspaceContext = <ThrowOnError extends boolean = false>(options: Options<RevalidateWorkspaceContextData, ThrowOnError>) =>
(options.client ?? client).post<RevalidateWorkspaceContextResponses, RevalidateWorkspaceContextErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/context/revalidate",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Lists redacted tenant-scoped OIDC/SAML provider metadata for service-token callers. Responses expose provider status and non-secret endpoints only; client secrets, SAML certificates/private keys, token hashes, invite tokens, and raw metadata documents are omitted.
*/
export const listWorkspaceAuthProviders = <ThrowOnError extends boolean = false>(options: Options<ListWorkspaceAuthProvidersData, ThrowOnError>) =>
(options.client ?? client).get<ListWorkspaceAuthProvidersResponses, ListWorkspaceAuthProvidersErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/auth/providers",
...options,
});
/**
* Lists tenant-scoped workspace invites for service-token callers. Response envelopes expose lifecycle timestamps, status, and tokenPrefix only while omitting reusable invite token material.
*/
export const listWorkspaceInvites = <ThrowOnError extends boolean = false>(options: Options<ListWorkspaceInvitesData, ThrowOnError>) =>
(options.client ?? client).get<ListWorkspaceInvitesResponses, ListWorkspaceInvitesErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/invites",
...options,
});
/**
* Creates a tenant workspace invite. The response includes the one-time invite token only in the top-level token field; invite envelopes expose only tokenPrefix and never reusable token examples.
*/
export const createWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<CreateWorkspaceInviteData, ThrowOnError>) =>
(options.client ?? client).post<CreateWorkspaceInviteResponses, CreateWorkspaceInviteErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/invites",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Accepts a workspace invite with a one-time token in the request body. Success and error responses are redacted and never echo the token.
*/
export const acceptWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<AcceptWorkspaceInviteData, ThrowOnError>) =>
(options.client ?? client).post<AcceptWorkspaceInviteResponses, AcceptWorkspaceInviteErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/invites/{inviteId}/accept",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Revokes a pending workspace invite by tenant and invite id. Request and error paths do not expose token material.
*/
export const revokeWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<RevokeWorkspaceInviteData, ThrowOnError>) =>
(options.client ?? client).post<RevokeWorkspaceInviteResponses, RevokeWorkspaceInviteErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/workspaces/{tenantId}/invites/{inviteId}/revoke",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const getAdminAnalyticsOverview = <ThrowOnError extends boolean = false>(options?: Options<GetAdminAnalyticsOverviewData, ThrowOnError>) =>
(options?.client ?? client).get<GetAdminAnalyticsOverviewResponses, GetAdminAnalyticsOverviewErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/analytics/admin",
...options,
});
/**
* Streams bounded, tenant/user scoped notification delivery diagnostics as sanitized SSE frames for authenticated service clients. The initial snapshot and reconnect cursor are deterministic for Last-Event-ID resume semantics; the stream does not expose provider payloads, provider message ids, metadata, tokens, request bodies, or raw secrets.
*/
export const streamNotifications = <ThrowOnError extends boolean = false>(
options?: Options<StreamNotificationsData, ThrowOnError, StreamNotificationsResponse>
) =>
(options?.client ?? client).sse.get<StreamNotificationsResponses, StreamNotificationsErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/notifications/stream",
...options,
});
/**
* Lists tenant account metadata and subscription summaries for service-token callers from the canonical API runtime.
*/
export const listAccounts = <ThrowOnError extends boolean = false>(options?: Options<ListAccountsData, ThrowOnError>) =>
(options?.client ?? client).get<ListAccountsResponses, ListAccountsErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/accounts",
...options,
});
/**
* Looks up one tenant account by id or slug for service-token callers from the canonical API runtime.
*/
export const getAccount = <ThrowOnError extends boolean = false>(options: Options<GetAccountData, ThrowOnError>) =>
(options.client ?? client).get<GetAccountResponses, GetAccountErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/accounts/{id}",
...options,
});
/**
* Canonical API-host entry point for API usage metadata. The API runtime validates the tenant API key and reads usage state through DB package boundaries.
*/
export const getApiUsage = <ThrowOnError extends boolean = false>(options?: Options<GetApiUsageData, ThrowOnError>) =>
(options?.client ?? client).get<GetApiUsageResponses, GetApiUsageErrors, ThrowOnError>({
security: [{ name: "x-api-key", type: "apiKey" }],
url: "/v1/usage",
...options,
});
/**
* Canonical API-host entry point for Stripe billing lifecycle webhooks. The API runtime preserves the raw body and signature headers and applies subscription updates through API-owned billing services.
*/
export const receiveStripeBillingWebhook = <ThrowOnError extends boolean = false>(options: Options<ReceiveStripeBillingWebhookData, ThrowOnError>) =>
(options.client ?? client).post<ReceiveStripeBillingWebhookResponses, ReceiveStripeBillingWebhookErrors, ThrowOnError>({
security: [{ name: "stripe-signature", type: "apiKey" }],
url: "/v1/billing/stripe/webhooks",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Accepts Mailjet provider event webhooks for email sequences, authenticated with HTTP Basic Auth. Bounce, blocked, and spam events correlate by provider message id (Message_GUID), mark the sequence delivery bounced, and fail the in-flight run without echoing provider payloads.
*/
export const receiveEmailSequenceMailjetWebhook = <ThrowOnError extends boolean = false>(
options: Options<ReceiveEmailSequenceMailjetWebhookData, ThrowOnError>
) =>
(options.client ?? client).post<ReceiveEmailSequenceMailjetWebhookResponses, ReceiveEmailSequenceMailjetWebhookErrors, ThrowOnError>({
security: [{ scheme: "basic", type: "http" }],
url: "/v1/email-sequences/mailjet/webhooks",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const retryEventWebhookAttempt = <ThrowOnError extends boolean = false>(options: Options<RetryEventWebhookAttemptData, ThrowOnError>) =>
(options.client ?? client).post<RetryEventWebhookAttemptResponses, RetryEventWebhookAttemptErrors, ThrowOnError>({
security: [{ scheme: "basic", type: "http" }],
url: "/v1/events/webhooks/attempts/{id}",
...options,
});
/**
* Queues the expired export artifact retention cleanup job for operators. Optional Idempotency-Key deduplicates operator retries; x-request-id remains correlation-only. The response does not expose Redis or storage credentials.
*/
export const enqueueExportArtifactRetention = <ThrowOnError extends boolean = false>(options?: Options<EnqueueExportArtifactRetentionData, ThrowOnError>) =>
(options?.client ?? client).post<EnqueueExportArtifactRetentionResponses, EnqueueExportArtifactRetentionErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/maintenance/export-retention/enqueue",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const listLegacyEntityRows = <ThrowOnError extends boolean = false>(options: Options<ListLegacyEntityRowsData, ThrowOnError>) =>
(options.client ?? client).get<ListLegacyEntityRowsResponses, ListLegacyEntityRowsErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/entities/{entity}/rows",
...options,
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const createLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<CreateLegacyEntityRowData, ThrowOnError>) =>
(options.client ?? client).post<CreateLegacyEntityRowResponses, CreateLegacyEntityRowErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/entities/{entity}/rows",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const deleteLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<DeleteLegacyEntityRowData, ThrowOnError>) =>
(options.client ?? client).delete<DeleteLegacyEntityRowResponses, DeleteLegacyEntityRowErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/entities/{entity}/rows/{rowId}",
...options,
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const getLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<GetLegacyEntityRowData, ThrowOnError>) =>
(options.client ?? client).get<GetLegacyEntityRowResponses, GetLegacyEntityRowErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/entities/{entity}/rows/{rowId}",
...options,
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const updateLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<UpdateLegacyEntityRowData, ThrowOnError>) =>
(options.client ?? client).patch<UpdateLegacyEntityRowResponses, UpdateLegacyEntityRowErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/entities/{entity}/rows/{rowId}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const createLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<CreateLegacyRowRelationshipData, ThrowOnError>) =>
(options.client ?? client).post<CreateLegacyRowRelationshipResponses, CreateLegacyRowRelationshipErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/relationships",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const deleteLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<DeleteLegacyRowRelationshipData, ThrowOnError>) =>
(options.client ?? client).delete<DeleteLegacyRowRelationshipResponses, DeleteLegacyRowRelationshipErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/relationships/{id}",
...options,
});
/**
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
*/
export const getLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<GetLegacyRowRelationshipData, ThrowOnError>) =>
(options.client ?? client).get<GetLegacyRowRelationshipResponses, GetLegacyRowRelationshipErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/relationships/{id}",
...options,
});
/**
* Creates a short-lived signed GET URL for a completed, unexpired export job. Authorize with either the configured service token bearer credential or a tenant-bound stored API key supplied in x-api-key; stored API keys are scoped to their own tenant and this route does not publish CRUD entity-scope claims. Responses never include export artifact bucket/key values, storage credentials, signed URL internals beyond the URL envelope, payload filters, or object bodies.
*/
export const getExportDownloadUrl = <ThrowOnError extends boolean = false>(options: Options<GetExportDownloadUrlData, ThrowOnError>) =>
(options.client ?? client).get<GetExportDownloadUrlResponses, GetExportDownloadUrlErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/exports/{exportJobId}/download-url",
...options,
});
/**
* Poll a tenant-scoped export job's progress envelope. Authorize with either the configured service-token bearer credential or a tenant-bound stored API key supplied in x-api-key; stored API keys are scoped to their own tenant. Responses contain progress fields only (status, percent, rowCount) — no artifact URLs, no compliance-pause internals, no payload filters.
*/
export const getExportJobProgress = <ThrowOnError extends boolean = false>(options: Options<GetExportJobProgressData, ThrowOnError>) =>
(options.client ?? client).get<GetExportJobProgressResponses, GetExportJobProgressErrors, ThrowOnError>({
security: [
{ scheme: "bearer", type: "http" },
{ name: "x-api-key", type: "apiKey" },
],
url: "/v1/exports/{exportJobId}/progress",
...options,
});
/**
* Register or update a mobile push token (APNs / FCM / WebPush) for the authenticated principal. Requires a service-token bearer credential; the body's userId + tenantId are reconciled downstream against the authenticated principal. Tokens are upserted by their globally unique value so cold-start replays are idempotent.
*/
export const postDeviceRegister = <ThrowOnError extends boolean = false>(options: Options<PostDeviceRegisterData, ThrowOnError>) =>
(options.client ?? client).post<PostDeviceRegisterResponses, PostDeviceRegisterErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/devices/register",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Revoke a mobile push token. Requires a service-token bearer credential; the handler revokes by token hash and never echoes the raw token in the response.
*/
export const postDeviceRevoke = <ThrowOnError extends boolean = false>(options: Options<PostDeviceRevokeData, ThrowOnError>) =>
(options.client ?? client).post<PostDeviceRevokeResponses, PostDeviceRevokeErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/devices/revoke",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
export const getLegacyEntityByName = <ThrowOnError extends boolean = false>(options: Options<GetLegacyEntityByNameData, ThrowOnError>) =>
(options.client ?? client).get<GetLegacyEntityByNameResponses, GetLegacyEntityByNameErrors, ThrowOnError>({
security: [{ scheme: "bearer", type: "http" }],
url: "/v1/entities/{entity}",
...options,
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
/**
* @toolctrl/api-client-ts
*
* Official TypeScript SDK for the toolctrl boilerplate REST API.
*
* The public surface is a thin Higher-Level-Wrapper around the @hey-api/openapi-ts
* generated client. The wrapper adds auth header injection, retry with exponential
* backoff (honoring Retry-After), idempotency-key auto-generation on mutating
* requests, an async pagination iterator, and an HMAC webhook signature verifier.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
export {
createToolctrlClient,
ToolctrlApiError,
ToolctrlNetworkError,
type ToolctrlClient,
type ToolctrlClientConfig,
type ToolctrlAuthConfig,
type ToolctrlRetryConfig,
type ToolctrlIdempotencyConfig,
type PaginatedResponse,
type PaginateOptions,
} from "./client.js";
export { verifyWebhookSignature, signWebhookPayload, type VerifyWebhookOptions, type SignWebhookOptions } from "./webhooks.js";
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { verifyWebhookSignature, signWebhookPayload, DEFAULT_REPLAY_WINDOW_MS } from "./webhooks.js";
const SECRET = "whsec_test_super_secret";
const PAYLOAD = `{"event":"contact.created","data":{"id":"c_42"}}`;
const TIMESTAMP = 1_700_000_000;
describe("signWebhookPayload", () => {
it("produces a deterministic hex digest", () => {
const sigA = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const sigB = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
expect(sigA).toBe(sigB);
expect(sigA).toMatch(/^[0-9a-f]{64}$/);
});
it("differs when the timestamp changes", () => {
const sig1 = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const sig2 = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP + 1 });
expect(sig1).not.toBe(sig2);
});
it("rejects empty secrets", () => {
expect(() => signWebhookPayload({ secret: "", payload: PAYLOAD, timestamp: TIMESTAMP })).toThrow(/secret/);
});
});
describe("verifyWebhookSignature", () => {
const now = () => TIMESTAMP * 1000 + 30_000; // 30s after issue
it("accepts a valid signature within the replay window", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now })).toBe(true);
});
it("rejects a tampered payload", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const tampered = PAYLOAD.replace("c_42", "c_99");
expect(verifyWebhookSignature({ secret: SECRET, payload: tampered, signature, timestamp: TIMESTAMP, now })).toBe(false);
});
it("rejects a tampered signature", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const broken = signature.replace(/.$/, signature.slice(-1) === "a" ? "b" : "a");
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: broken, timestamp: TIMESTAMP, now })).toBe(false);
});
it("rejects a wrong secret", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
expect(verifyWebhookSignature({ secret: "other_secret", payload: PAYLOAD, signature, timestamp: TIMESTAMP, now })).toBe(false);
});
it("rejects a signature outside the replay window", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const farFuture = () => TIMESTAMP * 1000 + DEFAULT_REPLAY_WINDOW_MS + 1000;
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: farFuture })).toBe(false);
});
it("accepts a clock-skewed signature in the past within the window", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const slightlyEarly = () => TIMESTAMP * 1000 - 30_000;
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: slightlyEarly })).toBe(true);
});
it("accepts signatures with `sha256=` prefix", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: `sha256=${signature}`, timestamp: TIMESTAMP, now })).toBe(true);
});
it("returns false on malformed input rather than throwing", () => {
expect(verifyWebhookSignature({ secret: "", payload: PAYLOAD, signature: "x", timestamp: TIMESTAMP, now })).toBe(false);
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "", timestamp: TIMESTAMP, now })).toBe(false);
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "not-hex", timestamp: TIMESTAMP, now })).toBe(false);
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "ff", timestamp: "not-a-number", now })).toBe(false);
});
it("verifies Buffer payloads identically", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
expect(
verifyWebhookSignature({
secret: SECRET,
payload: Buffer.from(PAYLOAD, "utf8"),
signature,
timestamp: TIMESTAMP,
now,
}),
).toBe(true);
});
it("respects a custom replay window override", () => {
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
const past = () => TIMESTAMP * 1000 + 90_000;
// Default window (5min) accepts it.
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: past })).toBe(true);
// 30-second window rejects it.
expect(
verifyWebhookSignature({
secret: SECRET,
payload: PAYLOAD,
signature,
timestamp: TIMESTAMP,
now: past,
replayWindowMs: 30_000,
}),
).toBe(false);
});
});
@@ -0,0 +1,89 @@
/**
* Webhook signature verification & signing helpers.
*
* Mirrors the server-side signing logic used by the workflow engine: HMAC-SHA256
* over `${timestamp}.${payload}` with a 5-minute anti-replay window. Signatures are
* compared in constant time to defeat timing side-channels.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** Default replay-protection window: 5 minutes in milliseconds. */
export const DEFAULT_REPLAY_WINDOW_MS = 5 * 60 * 1000;
export interface VerifyWebhookOptions {
/** Shared secret used to sign the payload server-side. */
secret: string;
/** Raw request body, exactly as received (do not re-serialize). */
payload: string | Buffer;
/** Hex-encoded HMAC-SHA256 signature delivered via header (e.g. `X-Toolctrl-Signature`). */
signature: string;
/** Unix timestamp in seconds when the webhook was emitted. */
timestamp: number | string;
/** Optional override for the replay window in ms. Defaults to 5 minutes. */
replayWindowMs?: number;
/** Optional override for the reference time (used by tests). Defaults to `Date.now()`. */
now?: () => number;
}
export interface SignWebhookOptions {
secret: string;
payload: string | Buffer;
timestamp: number | string;
}
function normalizeSignature(value: string): string {
// Strip optional algorithm prefix like "sha256=" or "v1=".
const eq = value.indexOf("=");
if (eq >= 0 && eq < 16) {
return value.slice(eq + 1).trim();
}
return value.trim();
}
function computeSignatureHex(secret: string, payload: string | Buffer, timestamp: number | string): string {
const hmac = createHmac("sha256", secret);
hmac.update(`${timestamp}.`);
hmac.update(payload);
return hmac.digest("hex");
}
/** Sign a webhook payload exactly the way the server does — used by tests and by senders. */
export function signWebhookPayload(options: SignWebhookOptions): string {
if (!options.secret) throw new Error("signWebhookPayload: secret is required");
return computeSignatureHex(options.secret, options.payload, options.timestamp);
}
/**
* Verify a webhook signature in constant time.
*
* Returns `true` if the signature is valid and the timestamp is within the
* replay-protection window. Returns `false` for malformed, expired, or tampered
* payloads without throwing — callers should reject with HTTP 403.
*/
export function verifyWebhookSignature(options: VerifyWebhookOptions): boolean {
if (!options.secret || !options.signature || options.timestamp === undefined || options.timestamp === null) {
return false;
}
const tsNumber = typeof options.timestamp === "string" ? Number(options.timestamp) : options.timestamp;
if (!Number.isFinite(tsNumber)) return false;
const now = (options.now ?? Date.now)();
const window = options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
const deltaMs = Math.abs(now - tsNumber * 1000);
if (deltaMs > window) return false;
const expectedHex = computeSignatureHex(options.secret, options.payload, options.timestamp);
const providedHex = normalizeSignature(options.signature);
if (expectedHex.length !== providedHex.length) return false;
try {
return timingSafeEqual(Buffer.from(expectedHex, "hex"), Buffer.from(providedHex, "hex"));
} catch {
return false;
}
}
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": true,
"types": ["node"],
"paths": {}
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
}
@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
client: "src/client.ts",
webhooks: "src/webhooks.ts",
},
format: ["esm", "cjs"],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
target: "node18",
external: [],
});
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
exclude: ["src/generated/**", "src/__integration__/**", "node_modules/**"],
globals: false,
},
});
@@ -0,0 +1,12 @@
# @toolctrl/api-types
Types-only re-export of the toolctrl boilerplate REST API surface.
This package is dependency-free at runtime: it re-exports `type` declarations
from `@toolctrl/api-client-ts` plus the generated OpenAPI types. Use it in
consumers that only need compile-time types (e.g. shared interfaces in another
TypeScript service) without pulling in the SDK's fetch + crypto runtime.
```ts
import type { ToolctrlClientConfig, PaginatedResponse } from "@toolctrl/api-types";
```
@@ -0,0 +1,28 @@
{
"name": "@toolctrl/api-types",
"version": "0.1.0",
"private": true,
"description": "Types-only re-export of the toolctrl boilerplate REST API surface. Zero runtime, zero dependencies — for consumers that need OpenAPI-derived TypeScript types without pulling in fetch / crypto code.",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"files": [
"src",
"README.md"
],
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@toolctrl/api-client-ts": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,16 @@
import { describe, it, expectTypeOf } from "vitest";
import type { ToolctrlClientConfig, PaginatedResponse, GetHealthzData, LoginWithPasswordData } from "./index.js";
describe("@toolctrl/api-types", () => {
it("re-exports the wrapper ToolctrlClientConfig", () => {
expectTypeOf<ToolctrlClientConfig>().toHaveProperty("baseUrl").toEqualTypeOf<string>();
});
it("re-exports PaginatedResponse<T>", () => {
expectTypeOf<PaginatedResponse<number>>().toHaveProperty("data").toEqualTypeOf<number[]>();
});
it("re-exports OpenAPI-derived operation types", () => {
// url should be the literal path captured by the generator.
expectTypeOf<GetHealthzData["url"]>().toEqualTypeOf<"/healthz">();
expectTypeOf<LoginWithPasswordData["url"]>().toEqualTypeOf<"/v1/auth/login">();
});
});
@@ -0,0 +1,27 @@
/**
* @toolctrl/api-types
*
* Types-only re-export of the toolctrl boilerplate REST API surface.
* No runtime code, no dependencies that emit code at runtime.
*
* Consumers can import OpenAPI-derived TypeScript types without pulling in the
* SDK's fetch + crypto runtime.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
*/
// Higher-level wrapper types (auth config, retry config, pagination, etc.).
export type {
ToolctrlClient,
ToolctrlClientConfig,
ToolctrlAuthConfig,
ToolctrlRetryConfig,
ToolctrlIdempotencyConfig,
PaginatedResponse,
PaginateOptions,
} from "@toolctrl/api-client-ts";
// OpenAPI-derived request/response types for every operation.
// Re-exported as a `*` namespace to keep the surface area discoverable while
// preserving the original generated names (GetHealthzData, LoginWithPasswordData, …).
export type * from "@toolctrl/api-client-ts/types";
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node"],
"paths": {}
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
globals: false,
},
});
@@ -0,0 +1,18 @@
{
"name": "@toolctrl/brand",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^5.3.2"
}
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { loadBrandFromEnv } from "./env.js";
import { createBrandTokens, defaultBrandTokens } from "./index.js";
describe("loadBrandFromEnv", () => {
it("returns no overrides when env is empty", () => {
expect(loadBrandFromEnv({})).toEqual({});
});
it("treats whitespace-only env values as absent", () => {
expect(
loadBrandFromEnv({
BRAND_PRODUCT_NAME: " ",
BRAND_SITE_URL: "",
BRAND_SUPPORT_EMAIL: "\n",
BRAND_LEGAL_PRIVACY_URL: "\t",
BRAND_LOGO_URL: " ",
BRAND_MAIL_PRIMARY: " ",
})
).toEqual({});
});
it("extracts product name and site URL when provided", () => {
expect(
loadBrandFromEnv({
BRAND_PRODUCT_NAME: "Acme Ops",
BRAND_SITE_URL: "https://acme.test",
})
).toEqual({
productName: "Acme Ops",
siteUrl: "https://acme.test",
});
});
it("groups support, legal, assets and mail color overrides", () => {
expect(
loadBrandFromEnv({
BRAND_SUPPORT_EMAIL: "help@acme.test",
BRAND_SUPPORT_FROM_NAME: "Acme Help",
BRAND_LEGAL_IMPRINT_URL: "https://acme.test/imprint",
BRAND_LEGAL_PRIVACY_URL: "https://acme.test/privacy",
BRAND_LEGAL_TERMS_URL: "https://acme.test/terms",
BRAND_LOGO_URL: "https://cdn.acme.test/logo.svg",
BRAND_FAVICON_URL: "https://cdn.acme.test/favicon.ico",
BRAND_MAIL_PRIMARY: "#0f766e",
BRAND_MAIL_PRIMARY_TEXT: "#ffffff",
BRAND_MAIL_BACKGROUND: "#f5f5f5",
BRAND_MAIL_LINK: "#0e7490",
})
).toEqual({
support: { email: "help@acme.test", fromName: "Acme Help" },
legal: {
imprintUrl: "https://acme.test/imprint",
privacyUrl: "https://acme.test/privacy",
termsUrl: "https://acme.test/terms",
},
assets: {
logoUrl: "https://cdn.acme.test/logo.svg",
faviconUrl: "https://cdn.acme.test/favicon.ico",
},
mail: {
colors: {
primary: "#0f766e",
primaryText: "#ffffff",
background: "#f5f5f5",
link: "#0e7490",
},
},
});
});
it("composes with createBrandTokens, preserving defaults for unset env keys", () => {
const tokens = createBrandTokens(
loadBrandFromEnv({
BRAND_PRODUCT_NAME: "Acme Ops",
BRAND_MAIL_PRIMARY: "#123456",
})
);
expect(tokens.productName).toBe("Acme Ops");
expect(tokens.siteUrl).toBe(defaultBrandTokens.siteUrl);
expect(tokens.support).toEqual(defaultBrandTokens.support);
expect(tokens.mail.colors.primary).toBe("#123456");
expect(tokens.mail.colors.primaryText).toBe(defaultBrandTokens.mail.colors.primaryText);
});
it("emits only partial sub-objects when only some sub-keys are present", () => {
expect(loadBrandFromEnv({ BRAND_SUPPORT_EMAIL: "help@acme.test" })).toEqual({
support: { email: "help@acme.test" },
});
expect(loadBrandFromEnv({ BRAND_LEGAL_TERMS_URL: "https://acme.test/terms" })).toEqual({
legal: { termsUrl: "https://acme.test/terms" },
});
expect(loadBrandFromEnv({ BRAND_LOGO_URL: "https://cdn.acme.test/logo.svg" })).toEqual({
assets: { logoUrl: "https://cdn.acme.test/logo.svg" },
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { BrandTokenOverrides } from "./index.js";
export type BrandEnvSource = Readonly<Record<string, string | undefined>>;
const trimmedOrUndefined = (value: string | undefined): string | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
export function loadBrandFromEnv(env: BrandEnvSource): BrandTokenOverrides {
const overrides: BrandTokenOverrides = {};
const productName = trimmedOrUndefined(env.BRAND_PRODUCT_NAME);
if (productName !== undefined) overrides.productName = productName;
const siteUrl = trimmedOrUndefined(env.BRAND_SITE_URL);
if (siteUrl !== undefined) overrides.siteUrl = siteUrl;
const supportEmail = trimmedOrUndefined(env.BRAND_SUPPORT_EMAIL);
const supportFromName = trimmedOrUndefined(env.BRAND_SUPPORT_FROM_NAME);
if (supportEmail !== undefined || supportFromName !== undefined) {
const support: NonNullable<BrandTokenOverrides["support"]> = {};
if (supportEmail !== undefined) support.email = supportEmail;
if (supportFromName !== undefined) support.fromName = supportFromName;
overrides.support = support;
}
const imprintUrl = trimmedOrUndefined(env.BRAND_LEGAL_IMPRINT_URL);
const privacyUrl = trimmedOrUndefined(env.BRAND_LEGAL_PRIVACY_URL);
const termsUrl = trimmedOrUndefined(env.BRAND_LEGAL_TERMS_URL);
if (imprintUrl !== undefined || privacyUrl !== undefined || termsUrl !== undefined) {
const legal: NonNullable<BrandTokenOverrides["legal"]> = {};
if (imprintUrl !== undefined) legal.imprintUrl = imprintUrl;
if (privacyUrl !== undefined) legal.privacyUrl = privacyUrl;
if (termsUrl !== undefined) legal.termsUrl = termsUrl;
overrides.legal = legal;
}
const logoUrl = trimmedOrUndefined(env.BRAND_LOGO_URL);
const faviconUrl = trimmedOrUndefined(env.BRAND_FAVICON_URL);
if (logoUrl !== undefined || faviconUrl !== undefined) {
const assets: NonNullable<BrandTokenOverrides["assets"]> = {};
if (logoUrl !== undefined) assets.logoUrl = logoUrl;
if (faviconUrl !== undefined) assets.faviconUrl = faviconUrl;
overrides.assets = assets;
}
const mailPrimary = trimmedOrUndefined(env.BRAND_MAIL_PRIMARY);
const mailPrimaryText = trimmedOrUndefined(env.BRAND_MAIL_PRIMARY_TEXT);
const mailBackground = trimmedOrUndefined(env.BRAND_MAIL_BACKGROUND);
const mailLink = trimmedOrUndefined(env.BRAND_MAIL_LINK);
if (mailPrimary !== undefined || mailPrimaryText !== undefined || mailBackground !== undefined || mailLink !== undefined) {
const colors: NonNullable<NonNullable<BrandTokenOverrides["mail"]>["colors"]> = {};
if (mailPrimary !== undefined) colors.primary = mailPrimary;
if (mailPrimaryText !== undefined) colors.primaryText = mailPrimaryText;
if (mailBackground !== undefined) colors.background = mailBackground;
if (mailLink !== undefined) colors.link = mailLink;
overrides.mail = { colors };
}
return overrides;
}
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import {
brandCssVariables,
createBrandTokens,
defaultBrandLinks,
defaultBrandTokens,
resolveTransactionalBrandTokens,
} from "./index.js";
describe("brand token helpers", () => {
it("exposes stable default brand tokens", () => {
expect(defaultBrandTokens).toMatchObject({
productName: "ToolCTRL",
siteUrl: "https://tool-ctrl.de",
legal: {
imprintUrl: "https://tool-ctrl.de/impressum",
privacyUrl: "https://tool-ctrl.de/datenschutz",
termsUrl: "https://tool-ctrl.de/agb",
},
support: {
email: "info@tool-ctrl.de",
fromName: "ToolCTRL Support",
},
assets: {
logoUrl: "/android-chrome-512x512.png",
faviconUrl: "/favicon.ico",
},
socials: {},
mail: {
colors: {
primary: "#2563eb",
primaryText: "#ffffff",
link: "#2563eb",
},
},
});
});
it("falls back to defaults for empty overrides", () => {
const tokens = createBrandTokens({
productName: " ",
siteUrl: "",
legal: {
imprintUrl: "",
privacyUrl: "\n",
},
support: {
email: "",
fromName: "\t",
},
assets: {
logoUrl: null,
faviconUrl: " ",
},
socials: {
linkedinUrl: "",
xUrl: " ",
},
mail: {
colors: {
primary: "",
link: " ",
},
},
});
expect(tokens.productName).toBe(defaultBrandTokens.productName);
expect(tokens.siteUrl).toBe(defaultBrandTokens.siteUrl);
expect(tokens.legal.imprintUrl).toBe(defaultBrandTokens.legal.imprintUrl);
expect(tokens.legal.privacyUrl).toBe(defaultBrandTokens.legal.privacyUrl);
expect(tokens.legal.termsUrl).toBe(defaultBrandTokens.legal.termsUrl);
expect(tokens.support.email).toBe(defaultBrandTokens.support.email);
expect(tokens.support.fromName).toBe(defaultBrandTokens.support.fromName);
expect(tokens.assets.logoUrl).toBe(defaultBrandTokens.assets.logoUrl);
expect(tokens.assets.faviconUrl).toBe(defaultBrandTokens.assets.faviconUrl);
expect(tokens.socials).toEqual(defaultBrandTokens.socials);
expect(tokens.mail.colors.primary).toBe(defaultBrandTokens.mail.colors.primary);
expect(tokens.mail.colors.link).toBe(defaultBrandTokens.mail.colors.link);
});
it("applies non-empty overrides while preserving unspecified defaults", () => {
const tokens = createBrandTokens({
productName: "Acme Ops",
siteUrl: "https://acme.test",
legal: {
imprintUrl: "https://acme.test/imprint",
},
support: {
fromName: "Acme Support",
},
assets: {
logoUrl: "https://cdn.acme.test/logo.svg",
},
socials: {
linkedinUrl: "https://linkedin.com/company/acme",
},
mail: {
colors: {
primary: "#123456",
},
},
});
expect(tokens.productName).toBe("Acme Ops");
expect(tokens.siteUrl).toBe("https://acme.test");
expect(tokens.legal.imprintUrl).toBe("https://acme.test/imprint");
expect(tokens.legal.privacyUrl).toBe(defaultBrandTokens.legal.privacyUrl);
expect(tokens.support.email).toBe(defaultBrandTokens.support.email);
expect(tokens.support.fromName).toBe("Acme Support");
expect(tokens.assets.logoUrl).toBe("https://cdn.acme.test/logo.svg");
expect(tokens.assets.faviconUrl).toBe(defaultBrandTokens.assets.faviconUrl);
expect(tokens.socials.linkedinUrl).toBe("https://linkedin.com/company/acme");
expect(tokens.mail.colors.primary).toBe("#123456");
expect(tokens.mail.colors.primaryText).toBe(defaultBrandTokens.mail.colors.primaryText);
});
it("resolves app configuration fallbacks defensively", () => {
const tokens = resolveTransactionalBrandTokens({
appName: "Acme Ops",
siteUrl: "https://portal.acme.test",
supportEmail: "help@acme.test",
supportFromName: "Help Desk",
logoUrl: "https://cdn.acme.test/app-logo.png",
faviconUrl: "https://cdn.acme.test/favicon.ico",
imprintUrl: "https://acme.test/imprint",
privacyUrl: "https://acme.test/privacy",
termsUrl: "https://acme.test/terms",
mail: {
colors: {
primary: "#0f766e",
link: "#0e7490",
},
},
brand: {
productName: "",
support: {
email: "brand-help@acme.test",
fromName: " ",
},
socials: {
githubUrl: "https://github.com/acme",
},
mail: {
colors: {
primary: "#dc2626",
link: "",
},
},
},
});
expect(tokens.productName).toBe("Acme Ops");
expect(tokens.siteUrl).toBe("https://portal.acme.test");
expect(tokens.support.email).toBe("brand-help@acme.test");
expect(tokens.support.fromName).toBe("Help Desk");
expect(tokens.assets.logoUrl).toBe("https://cdn.acme.test/app-logo.png");
expect(tokens.assets.faviconUrl).toBe("https://cdn.acme.test/favicon.ico");
expect(tokens.legal.imprintUrl).toBe("https://acme.test/imprint");
expect(tokens.legal.privacyUrl).toBe("https://acme.test/privacy");
expect(tokens.legal.termsUrl).toBe("https://acme.test/terms");
expect(tokens.socials.githubUrl).toBe("https://github.com/acme");
expect(tokens.mail.colors.primary).toBe("#dc2626");
expect(tokens.mail.colors.link).toBe("#0e7490");
});
it("does not allow empty app configuration values to replace defaults", () => {
const tokens = resolveTransactionalBrandTokens({
productName: "",
siteUrl: "",
appName: " ",
name: "\t",
supportEmail: "",
supportFromName: " ",
emailFromName: "\n",
logoUrl: "",
faviconUrl: " ",
imprintUrl: "",
privacyUrl: " ",
termsUrl: "\t",
mail: {
colors: {
primary: "",
primaryText: " ",
},
},
});
expect(tokens.productName).toBe(defaultBrandTokens.productName);
expect(tokens.siteUrl).toBe(defaultBrandTokens.siteUrl);
expect(tokens.support).toEqual(defaultBrandTokens.support);
expect(tokens.assets).toEqual(defaultBrandTokens.assets);
expect(tokens.legal).toEqual(defaultBrandTokens.legal);
expect(tokens.mail.colors.primary).toBe(defaultBrandTokens.mail.colors.primary);
expect(tokens.mail.colors.primaryText).toBe(defaultBrandTokens.mail.colors.primaryText);
});
it("emits deterministic CSS variables", () => {
const tokens = createBrandTokens({
productName: "Acme Ops",
assets: {
logoUrl: "https://cdn.acme.test/logo.svg",
},
mail: {
colors: {
primary: "#123456",
},
},
});
expect(brandCssVariables(tokens)).toEqual({
"--toolctrl-product-name": "Acme Ops",
"--toolctrl-site-url": defaultBrandLinks.siteUrl,
"--toolctrl-logo-url": "url(https://cdn.acme.test/logo.svg)",
"--toolctrl-favicon-url": `url(${defaultBrandTokens.assets.faviconUrl})`,
"--toolctrl-mail-background": defaultBrandTokens.mail.colors.background,
"--toolctrl-mail-surface": defaultBrandTokens.mail.colors.surface,
"--toolctrl-mail-primary": "#123456",
"--toolctrl-mail-primary-text": defaultBrandTokens.mail.colors.primaryText,
"--toolctrl-mail-text": defaultBrandTokens.mail.colors.text,
"--toolctrl-mail-muted-text": defaultBrandTokens.mail.colors.mutedText,
"--toolctrl-mail-border": defaultBrandTokens.mail.colors.border,
"--toolctrl-mail-link": defaultBrandTokens.mail.colors.link,
});
});
});
+237
View File
@@ -0,0 +1,237 @@
export type BrandLegalLinks = {
imprintUrl: string;
privacyUrl: string;
termsUrl: string;
};
export type BrandSupportContact = {
email: string;
fromName: string;
};
export type BrandAssets = {
logoUrl: string;
faviconUrl: string;
};
const brandSocialLinkKeys = ["linkedinUrl", "xUrl", "instagramUrl", "githubUrl", "youtubeUrl", "discordUrl"] as const;
type BrandSocialLinkKey = (typeof brandSocialLinkKeys)[number];
export type BrandSocialLinks = Partial<Record<BrandSocialLinkKey, string>>;
export type BrandLinkConfiguration = {
siteUrl: string;
legal: BrandLegalLinks;
support: BrandSupportContact;
assets: BrandAssets;
socials: BrandSocialLinks;
};
export type TransactionalMailColors = {
background: string;
surface: string;
primary: string;
primaryText: string;
text: string;
mutedText: string;
border: string;
link: string;
};
export type ToolCTRLBrandTokens = {
productName: string;
siteUrl: string;
legal: BrandLegalLinks;
support: BrandSupportContact;
assets: BrandAssets;
socials: BrandSocialLinks;
mail: {
colors: TransactionalMailColors;
};
};
export type BrandTokens = ToolCTRLBrandTokens;
export type BrandTokenOverrides = Partial<{
productName: string | null;
siteUrl: string | null;
legal: Partial<Record<keyof BrandLegalLinks, string | null>> | null;
support: Partial<Record<keyof BrandSupportContact, string | null>> | null;
assets: Partial<Record<keyof BrandAssets, string | null>> | null;
socials: Partial<Record<BrandSocialLinkKey, string | null>> | null;
mail: {
colors?: Partial<Record<keyof TransactionalMailColors, string | null>> | null;
} | null;
}>;
export type AppConfigurationLike = Partial<{
productName: string | null;
siteUrl: string | null;
appName: string | null;
name: string | null;
supportEmail: string | null;
supportFromName: string | null;
emailFromName: string | null;
logoUrl: string | null;
faviconUrl: string | null;
imprintUrl: string | null;
privacyUrl: string | null;
termsUrl: string | null;
brand: BrandTokenOverrides | null;
mail: {
colors?: Partial<Record<keyof TransactionalMailColors, string | null>> | null;
} | null;
}>;
export const defaultBrandLinks: BrandLinkConfiguration = {
siteUrl: "https://tool-ctrl.de",
legal: {
imprintUrl: "https://tool-ctrl.de/impressum",
privacyUrl: "https://tool-ctrl.de/datenschutz",
termsUrl: "https://tool-ctrl.de/agb",
},
support: {
email: "info@tool-ctrl.de",
fromName: "ToolCTRL Support",
},
assets: {
logoUrl: "/android-chrome-512x512.png",
faviconUrl: "/favicon.ico",
},
socials: {},
};
export const defaultBrandTokens: BrandTokens = {
productName: "ToolCTRL",
siteUrl: defaultBrandLinks.siteUrl,
legal: defaultBrandLinks.legal,
support: defaultBrandLinks.support,
assets: defaultBrandLinks.assets,
socials: defaultBrandLinks.socials,
mail: {
colors: {
background: "#f6f8fb",
surface: "#ffffff",
primary: "#2563eb",
primaryText: "#ffffff",
text: "#111827",
mutedText: "#6b7280",
border: "#e5e7eb",
link: "#2563eb",
},
},
};
const nonEmpty = (value: string | null | undefined): string | undefined => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
};
const pickFirst = (...values: Array<string | null | undefined>): string | undefined => {
for (const value of values) {
const resolved = nonEmpty(value);
if (resolved) return resolved;
}
return undefined;
};
const resolveSocialLinks = (overrides?: Partial<Record<BrandSocialLinkKey, string | null>> | null): BrandSocialLinks => {
const socials: BrandSocialLinks = {};
for (const key of brandSocialLinkKeys) {
const value = nonEmpty(overrides?.[key]) ?? defaultBrandLinks.socials[key];
if (value) {
socials[key] = value;
}
}
return socials;
};
export function createBrandTokens(overrides: BrandTokenOverrides = {}): BrandTokens {
return {
productName: nonEmpty(overrides.productName) ?? defaultBrandTokens.productName,
siteUrl: nonEmpty(overrides.siteUrl) ?? defaultBrandTokens.siteUrl,
legal: {
imprintUrl: nonEmpty(overrides.legal?.imprintUrl) ?? defaultBrandTokens.legal.imprintUrl,
privacyUrl: nonEmpty(overrides.legal?.privacyUrl) ?? defaultBrandTokens.legal.privacyUrl,
termsUrl: nonEmpty(overrides.legal?.termsUrl) ?? defaultBrandTokens.legal.termsUrl,
},
support: {
email: nonEmpty(overrides.support?.email) ?? defaultBrandTokens.support.email,
fromName: nonEmpty(overrides.support?.fromName) ?? defaultBrandTokens.support.fromName,
},
assets: {
logoUrl: nonEmpty(overrides.assets?.logoUrl) ?? defaultBrandTokens.assets.logoUrl,
faviconUrl: nonEmpty(overrides.assets?.faviconUrl) ?? defaultBrandTokens.assets.faviconUrl,
},
socials: resolveSocialLinks(overrides.socials),
mail: {
colors: {
background: nonEmpty(overrides.mail?.colors?.background) ?? defaultBrandTokens.mail.colors.background,
surface: nonEmpty(overrides.mail?.colors?.surface) ?? defaultBrandTokens.mail.colors.surface,
primary: nonEmpty(overrides.mail?.colors?.primary) ?? defaultBrandTokens.mail.colors.primary,
primaryText: nonEmpty(overrides.mail?.colors?.primaryText) ?? defaultBrandTokens.mail.colors.primaryText,
text: nonEmpty(overrides.mail?.colors?.text) ?? defaultBrandTokens.mail.colors.text,
mutedText: nonEmpty(overrides.mail?.colors?.mutedText) ?? defaultBrandTokens.mail.colors.mutedText,
border: nonEmpty(overrides.mail?.colors?.border) ?? defaultBrandTokens.mail.colors.border,
link: nonEmpty(overrides.mail?.colors?.link) ?? defaultBrandTokens.mail.colors.link,
},
},
};
}
export function resolveTransactionalBrandTokens(appConfigurationLike: AppConfigurationLike | null | undefined): BrandTokens {
const config = appConfigurationLike ?? {};
return createBrandTokens({
productName: pickFirst(config.brand?.productName, config.productName, config.appName, config.name),
siteUrl: pickFirst(config.brand?.siteUrl, config.siteUrl),
legal: {
imprintUrl: pickFirst(config.brand?.legal?.imprintUrl, config.imprintUrl),
privacyUrl: pickFirst(config.brand?.legal?.privacyUrl, config.privacyUrl),
termsUrl: pickFirst(config.brand?.legal?.termsUrl, config.termsUrl),
},
support: {
email: pickFirst(config.brand?.support?.email, config.supportEmail),
fromName: pickFirst(config.brand?.support?.fromName, config.supportFromName, config.emailFromName),
},
assets: {
logoUrl: pickFirst(config.brand?.assets?.logoUrl, config.logoUrl),
faviconUrl: pickFirst(config.brand?.assets?.faviconUrl, config.faviconUrl),
},
socials: config.brand?.socials,
mail: {
colors: {
background: pickFirst(config.brand?.mail?.colors?.background, config.mail?.colors?.background),
surface: pickFirst(config.brand?.mail?.colors?.surface, config.mail?.colors?.surface),
primary: pickFirst(config.brand?.mail?.colors?.primary, config.mail?.colors?.primary),
primaryText: pickFirst(config.brand?.mail?.colors?.primaryText, config.mail?.colors?.primaryText),
text: pickFirst(config.brand?.mail?.colors?.text, config.mail?.colors?.text),
mutedText: pickFirst(config.brand?.mail?.colors?.mutedText, config.mail?.colors?.mutedText),
border: pickFirst(config.brand?.mail?.colors?.border, config.mail?.colors?.border),
link: pickFirst(config.brand?.mail?.colors?.link, config.mail?.colors?.link),
},
},
});
}
export { loadBrandFromEnv, type BrandEnvSource } from "./env.js";
export function brandCssVariables(tokens: BrandTokens): Record<string, string> {
return {
"--toolctrl-product-name": tokens.productName,
"--toolctrl-site-url": tokens.siteUrl,
"--toolctrl-logo-url": `url(${tokens.assets.logoUrl})`,
"--toolctrl-favicon-url": `url(${tokens.assets.faviconUrl})`,
"--toolctrl-mail-background": tokens.mail.colors.background,
"--toolctrl-mail-surface": tokens.mail.colors.surface,
"--toolctrl-mail-primary": tokens.mail.colors.primary,
"--toolctrl-mail-primary-text": tokens.mail.colors.primaryText,
"--toolctrl-mail-text": tokens.mail.colors.text,
"--toolctrl-mail-muted-text": tokens.mail.colors.mutedText,
"--toolctrl-mail-border": tokens.mail.colors.border,
"--toolctrl-mail-link": tokens.mail.colors.link,
};
}
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,19 @@
{
"name": "@toolctrl/captcha",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { resolveCaptchaConfig, getSitekey, isCaptchaConfigured } from "./config.js";
describe("resolveCaptchaConfig", () => {
it("maps env vars to config", () => {
const cfg = resolveCaptchaConfig({
MCAPTCHA_INSTANCE_URL: "https://captcha.tool-ctrl.de",
MCAPTCHA_ACCOUNT_SECRET: "secret",
MCAPTCHA_SITEKEY_LOGIN: "login-key",
MCAPTCHA_SITEKEY_FORMS: "forms-key",
MCAPTCHA_SITEKEY_BOOKING: "booking-key",
});
expect(cfg.provider).toBe("mcaptcha");
expect(cfg.instanceUrl).toBe("https://captcha.tool-ctrl.de");
expect(getSitekey(cfg, "login")).toBe("login-key");
expect(getSitekey(cfg, "booking")).toBe("booking-key");
expect(isCaptchaConfigured(cfg)).toBe(true);
});
it("defaults failOpen to true and respects explicit false", () => {
expect(resolveCaptchaConfig({}).failOpen).toBe(true);
expect(resolveCaptchaConfig({ CAPTCHA_FAIL_OPEN: "false" }).failOpen).toBe(false);
expect(resolveCaptchaConfig({ CAPTCHA_FAIL_OPEN: "true" }).failOpen).toBe(true);
});
it("treats blank env values as undefined and reports unconfigured", () => {
const cfg = resolveCaptchaConfig({ MCAPTCHA_INSTANCE_URL: " ", MCAPTCHA_ACCOUNT_SECRET: "" });
expect(cfg.instanceUrl).toBeUndefined();
expect(cfg.accountSecret).toBeUndefined();
expect(isCaptchaConfigured(cfg)).toBe(false);
});
});
@@ -0,0 +1,43 @@
import type { CaptchaConfig, CaptchaSurface } from "./types.js";
type EnvLike = Record<string, string | undefined>;
function present(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/**
* Resolves the captcha config from env. mCaptcha is the only provider
* (see HiveGrid docs/ARCHITECTURE-DECISIONS.md AD-7).
*
* MCAPTCHA_INSTANCE_URL=https://captcha.tool-ctrl.de
* MCAPTCHA_ACCOUNT_SECRET=<dashboard account secret>
* MCAPTCHA_SITEKEY_LOGIN / _FORMS / _BOOKING=<public sitekeys>
* CAPTCHA_FAIL_OPEN=true|false (default true)
*/
export function resolveCaptchaConfig(env: EnvLike): CaptchaConfig {
return {
provider: "mcaptcha",
instanceUrl: present(env.MCAPTCHA_INSTANCE_URL),
accountSecret: present(env.MCAPTCHA_ACCOUNT_SECRET),
sitekeys: {
login: present(env.MCAPTCHA_SITEKEY_LOGIN),
forms: present(env.MCAPTCHA_SITEKEY_FORMS),
booking: present(env.MCAPTCHA_SITEKEY_BOOKING),
},
failOpen: present(env.CAPTCHA_FAIL_OPEN) !== "false",
};
}
export function getSitekey(
config: CaptchaConfig,
surface: CaptchaSurface,
): string | undefined {
return config.sitekeys[surface];
}
/** True when the instance + account secret are present (sitekeys checked per-surface). */
export function isCaptchaConfigured(config: CaptchaConfig): boolean {
return Boolean(config.instanceUrl && config.accountSecret);
}
@@ -0,0 +1,5 @@
export type { CaptchaSurface, CaptchaLogger, CaptchaConfig } from "./types.js";
export { CAPTCHA_SURFACES } from "./types.js";
export { verifyCaptchaToken } from "./verify.js";
export type { VerifyCaptchaArgs } from "./verify.js";
export { resolveCaptchaConfig, getSitekey, isCaptchaConfigured } from "./config.js";
@@ -0,0 +1,25 @@
// Captcha surfaces — one mCaptcha sitekey per surface so difficulty/rate-limit
// can be tuned independently (see infra/mcaptcha/README.md).
export type CaptchaSurface = "login" | "forms" | "booking";
export const CAPTCHA_SURFACES: readonly CaptchaSurface[] = ["login", "forms", "booking"];
export type CaptchaLogger = {
warn: (metadata: Record<string, unknown>, message: string) => void;
};
export type CaptchaConfig = {
provider: "mcaptcha";
/** Public base URL of the self-hosted mCaptcha instance, e.g. https://captcha.tool-ctrl.de */
instanceUrl: string | undefined;
/** mCaptcha dashboard account secret used for server-side siteverify. */
accountSecret: string | undefined;
/** Public sitekey per surface (created in the mCaptcha dashboard). */
sitekeys: Record<CaptchaSurface, string | undefined>;
/**
* When the instance is unreachable/misconfigured: true = let the request
* through (fail-open, default), false = block it (fail-closed). A missing
* token is always a hard failure regardless of this flag.
*/
failOpen: boolean;
};
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { verifyCaptchaToken } from "./verify.js";
const base = {
token: "pow-token",
sitekey: "site-key",
secret: "account-secret",
instanceUrl: "https://captcha.example.test",
};
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
describe("verifyCaptchaToken", () => {
it("returns true when mCaptcha reports valid", async () => {
let calledUrl = "";
let calledBody: unknown = null;
const fetchImpl = (async (url: string, init?: RequestInit) => {
calledUrl = url;
calledBody = JSON.parse(String(init?.body));
return jsonResponse({ valid: true });
}) as unknown as typeof fetch;
const ok = await verifyCaptchaToken({ ...base, fetchImpl });
expect(ok).toBe(true);
expect(calledUrl).toBe("https://captcha.example.test/api/v1/pow/siteverify");
expect(calledBody).toEqual({ token: "pow-token", key: "site-key", secret: "account-secret" });
});
it("returns false when mCaptcha reports invalid", async () => {
const fetchImpl = (async () => jsonResponse({ valid: false })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(false);
});
it("returns false on missing token even when configured (hard failure)", async () => {
const fetchImpl = (async () => jsonResponse({ valid: true })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, token: "", fetchImpl })).toBe(false);
});
it("fails open (true) on network error by default", async () => {
const fetchImpl = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(true);
});
it("fails closed (false) on network error when failOpen=false", async () => {
const fetchImpl = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, failOpen: false, fetchImpl })).toBe(false);
});
it("fails open on non-OK HTTP status", async () => {
const fetchImpl = (async () => jsonResponse({}, false, 502)) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(true);
});
it("applies fail policy when misconfigured (no secret)", async () => {
const fetchImpl = (async () => jsonResponse({ valid: true })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, secret: undefined, fetchImpl })).toBe(true);
expect(
await verifyCaptchaToken({ ...base, secret: undefined, failOpen: false, fetchImpl }),
).toBe(false);
});
});
@@ -0,0 +1,91 @@
import type { CaptchaLogger } from "./types.js";
const DEFAULT_TIMEOUT_MS = 3_000;
const SITEVERIFY_PATH = "/api/v1/pow/siteverify";
export type VerifyCaptchaArgs = {
/** PoW solution token produced by the mCaptcha widget. */
token: string | null | undefined;
/** Public sitekey for the surface being protected. */
sitekey: string | undefined;
/** mCaptcha account secret (server-side only). */
secret: string | undefined;
/** Base URL of the mCaptcha instance. */
instanceUrl: string | undefined;
/** Outage/misconfig policy: true (default) lets the request through. */
failOpen?: boolean;
logger?: CaptchaLogger;
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
/**
* Verifies an mCaptcha proof-of-work token against a self-hosted instance.
*
* POST {instanceUrl}/api/v1/pow/siteverify body: { token, key, secret }
* response: { valid: boolean }
*
* Semantics:
* - missing token -> false (real client failure, never fail-open)
* - misconfigured / outage -> `failOpen` (default true), with a warn log
* - valid:true -> true, otherwise false
*/
export async function verifyCaptchaToken({
token,
sitekey,
secret,
instanceUrl,
failOpen = true,
logger,
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
}: VerifyCaptchaArgs): Promise<boolean> {
if (!instanceUrl || !secret || !sitekey) {
logger?.warn(
{
op: "captcha.misconfigured",
hasInstance: Boolean(instanceUrl),
hasSecret: Boolean(secret),
hasSitekey: Boolean(sitekey),
failOpen,
},
"mCaptcha not configured — applying fail policy",
);
return failOpen;
}
if (!token) return false;
const url = instanceUrl.replace(/\/+$/, "") + SITEVERIFY_PATH;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, key: sitekey, secret }),
signal: controller.signal,
});
if (!res.ok) {
logger?.warn(
{ op: "captcha.http_error", status: res.status, failOpen },
"mCaptcha siteverify returned non-OK — applying fail policy",
);
return failOpen;
}
const data = (await res.json()) as { valid?: boolean };
return data.valid === true;
} catch (err) {
logger?.warn(
{
op: "captcha.error",
err: err instanceof Error ? err.message : String(err),
failOpen,
},
"mCaptcha verification failed — applying fail policy",
);
return failOpen;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+41
View File
@@ -0,0 +1,41 @@
# @toolctrl/cli
Operator CLI for the ToolCTRL boilerplate. Currently ships the `backup` family
of subcommands. The entrypoint is `bin/toolctrl-cli.mjs`, wired up via
[citty](https://github.com/unjs/citty) for consistent subcommand handling.
## Subcommands
| Command | Description |
| ----------------- | ---------------------------------------------------------------------- |
| `backup:create` | Dump Postgres + (optional) Storage + (optional) ClickHouse, encrypt, upload to S3. |
| `backup:list` | List S3-stored backups with manifest metadata. |
| `backup:restore` | Download, decrypt, extract; restore Postgres + Storage from a backup. |
| `backup:verify` | Test-restore Postgres dump into a `verify_<uuid>` schema, sanity-check. |
| `backup:rotate` | Apply daily/weekly/monthly retention policy against the backup bucket. |
See [`docs/operations/backup.md`](../../docs/operations/backup.md) for the
operator runbook (setup, key management, restore drill, troubleshooting).
## Environment variables
The CLI uses a backup-scoped S3 credential set so the backup bucket can be
isolated from the application bucket:
- `BACKUP_S3_ENDPOINT`, `BACKUP_S3_REGION`, `BACKUP_S3_BUCKET`
- `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY`
- `BACKUP_S3_FORCE_PATH_STYLE`
- `BACKUP_ENCRYPTION` (`age` | `gpg` | `none`, default `age`)
- `BACKUP_AGE_PUBLIC_KEY` (required when encryption is `age`)
- `BACKUP_AGE_IDENTITY_FILE` (required for `restore` / `verify` with age)
- `BACKUP_RETENTION_DAILY` (default `7`)
- `BACKUP_RETENTION_WEEKLY` (default `4`)
- `BACKUP_RETENTION_MONTHLY` (default `12`)
- `BACKUP_INCLUDE_CLICKHOUSE` (`true` to include ClickHouse snapshots)
## Development
```sh
pnpm --filter @toolctrl/cli test
pnpm --filter @toolctrl/cli typecheck
```
@@ -0,0 +1,17 @@
#!/usr/bin/env node
// Thin shim so the CLI is callable as `toolctrl-cli`.
// The actual command tree lives in ../src/index.ts and is loaded via tsx in dev,
// or via the compiled output once the build pipeline produces dist/index.js.
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(__dirname, "../dist/index.js");
const srcEntry = resolve(__dirname, "../src/index.ts");
const target = existsSync(distEntry) ? distEntry : srcEntry;
const moduleUrl = pathToFileURL(target).href;
const { runMain } = await import(moduleUrl);
await runMain();
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@toolctrl/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./backup": "./src/commands/backup/index.ts"
},
"bin": {
"toolctrl-cli": "./bin/toolctrl-cli.mjs"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1048.0",
"@aws-sdk/lib-storage": "^3.1048.0",
"@toolctrl/db": "workspace:*",
"@toolctrl/entitlements": "workspace:*",
"@toolctrl/exports": "workspace:*",
"@toolctrl/feature-flags": "workspace:*",
"@toolctrl/form-widget": "workspace:*",
"@toolctrl/plugin-runtime": "workspace:*",
"@toolctrl/search": "workspace:*",
"citty": "^0.1.6",
"consola": "^3.4.0",
"tar": "^7.4.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.0.0",
"@types/tar": "^6.1.13",
"aws-sdk-client-mock": "^4.1.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,102 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
camelCase,
createScaffoldResult,
kebabCase,
pascalCase,
pathExists,
snakeCase,
validateIdentifier,
writeScaffoldFile,
} from "./scaffold.js";
describe("scaffold helpers / string conversions", () => {
it.each([
["foo bar", "fooBar"],
["foo-bar-baz", "fooBarBaz"],
["FooBarBaz", "fooBarBaz"],
["foo_bar", "fooBar"],
])("camelCase(%s) === %s", (input, expected) => {
expect(camelCase(input)).toBe(expected);
});
it.each([
["foo bar", "FooBar"],
["foo-bar-baz", "FooBarBaz"],
["fooBarBaz", "FooBarBaz"],
])("pascalCase(%s) === %s", (input, expected) => {
expect(pascalCase(input)).toBe(expected);
});
it("kebabCase + snakeCase", () => {
expect(kebabCase("FooBarBaz")).toBe("foo-bar-baz");
expect(snakeCase("FooBarBaz")).toBe("foo_bar_baz");
});
});
describe("scaffold helpers / validateIdentifier", () => {
it("accepts valid identifiers", () => {
expect(validateIdentifier("foo")).toBe("foo");
expect(validateIdentifier("foo-bar")).toBe("foo-bar");
expect(validateIdentifier("foo_bar")).toBe("foo_bar");
});
it("rejects empty / invalid input", () => {
expect(() => validateIdentifier("")).toThrow(/required/);
expect(() => validateIdentifier("1foo")).toThrow(/Invalid/);
expect(() => validateIdentifier("foo!bar")).toThrow(/Invalid/);
});
});
describe("scaffold helpers / writeScaffoldFile", () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "toolctrl-cli-scaffold-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
it("writes the file in non-dry-run mode and records it on the result", async () => {
const result = createScaffoldResult(false);
const target = path.join(tempRoot, "nested", "out.txt");
await writeScaffoldFile({ targetPath: target, contents: "hello", dryRun: false, result });
expect(result.files).toHaveLength(1);
expect(result.files[0]).toMatchObject({ path: target, contents: "hello" });
const written = await readFile(target, "utf8");
expect(written).toBe("hello");
});
it("records the write but does NOT touch the FS in dry-run mode", async () => {
const result = createScaffoldResult(true);
const target = path.join(tempRoot, "ghost.txt");
await writeScaffoldFile({ targetPath: target, contents: "ghost", dryRun: true, result });
expect(result.files).toHaveLength(1);
await expect(stat(target)).rejects.toThrow();
});
it("refuses to overwrite an existing file", async () => {
const target = path.join(tempRoot, "existing.txt");
await writeFile(target, "old", "utf8");
const result = createScaffoldResult(false);
await expect(
writeScaffoldFile({ targetPath: target, contents: "new", dryRun: false, result }),
).rejects.toThrow(/already exists/);
});
it("pathExists distinguishes existing vs missing paths", async () => {
const target = path.join(tempRoot, "maybe.txt");
expect(await pathExists(target)).toBe(false);
await writeFile(target, "x", "utf8");
expect(await pathExists(target)).toBe(true);
});
});
@@ -0,0 +1,152 @@
/**
* Shared helpers for the `toolctrl create …` / `toolctrl add …` scaffolding
* generators (Phase 08 / task #60).
*
* Design goals:
* - **Safe by default.** Generators NEVER overwrite existing files. If a
* target path already exists, the helper throws — callers surface the
* error and exit with a non-zero status. Use `--force` semantics at the
* command level if a re-generation is needed (not implemented yet —
* `git checkout` is the documented escape hatch).
* - **Dry-run-friendly.** All write operations go through `writeFile` which
* respects a `dryRun` flag and records the would-have-been writes on a
* `ScaffoldResult.files` array. Tests assert on that array directly
* instead of touching the real FS.
* - **No template engine dependency.** We use plain JS tagged templates +
* a tiny `render` helper. Adding eta/handlebars to a Node-only operator
* CLI would mean another pnpm install + bundle bloat for a small win.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/cli-scaffolding.md
*/
import { mkdir, stat, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
export type ScaffoldFile = {
/** Absolute target path on disk. */
path: string;
/** Rendered file contents. */
contents: string;
};
export type ScaffoldResult = {
/** Files that were (or would be, in dry-run mode) written. */
files: ScaffoldFile[];
/** Free-form follow-up notes the CLI surfaces to the user. */
notes: string[];
/** `true` if no FS mutation happened. */
dryRun: boolean;
};
export function createScaffoldResult(dryRun: boolean): ScaffoldResult {
return { files: [], notes: [], dryRun };
}
export type WriteFileOptions = {
/** Absolute path the file would be written to. */
targetPath: string;
/** Already-rendered content. */
contents: string;
/** When `true` no actual mkdir/write is performed. */
dryRun: boolean;
/** Mutated in place with the recorded write. */
result: ScaffoldResult;
};
/**
* Add a file to the scaffold result, ensuring we never silently overwrite an
* existing path. Caller is responsible for `mkdir -p` semantics — we handle
* it transparently in non-dry-run mode.
*/
export async function writeScaffoldFile(options: WriteFileOptions): Promise<void> {
const { targetPath, contents, dryRun, result } = options;
const exists = await pathExists(targetPath);
if (exists) {
throw new Error(
`Target already exists: ${targetPath}. Refusing to overwrite. Delete the file or pick a different name.`,
);
}
result.files.push({ path: targetPath, contents });
if (dryRun) return;
await mkdir(path.dirname(targetPath), { recursive: true });
await fsWriteFile(targetPath, contents, "utf8");
}
export async function pathExists(target: string): Promise<boolean> {
try {
await stat(target);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
/** Convert "foo bar-baz" → "fooBarBaz". */
export function camelCase(input: string): string {
return splitWords(input)
.map((word, index) => (index === 0 ? word.toLowerCase() : capitalize(word)))
.join("");
}
/** Convert "foo bar-baz" → "FooBarBaz". */
export function pascalCase(input: string): string {
return splitWords(input).map(capitalize).join("");
}
/** Convert "FooBarBaz" → "foo-bar-baz". */
export function kebabCase(input: string): string {
return splitWords(input)
.map((word) => word.toLowerCase())
.join("-");
}
/** Convert "FooBarBaz" → "foo_bar_baz". */
export function snakeCase(input: string): string {
return splitWords(input)
.map((word) => word.toLowerCase())
.join("_");
}
function capitalize(word: string): string {
if (!word) return word;
return word[0]!.toUpperCase() + word.slice(1).toLowerCase();
}
function splitWords(input: string): string[] {
return input
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_.\s]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
}
/**
* Lightweight identifier validator. Rejects empty strings, leading digits,
* and anything that isn't `[A-Za-z0-9_-]`. Used by every generator to avoid
* writing files with shell-unsafe names.
*/
export function validateIdentifier(name: string, label: string = "name"): string {
const trimmed = name.trim();
if (!trimmed) {
throw new Error(`${label} is required.`);
}
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(trimmed)) {
throw new Error(
`Invalid ${label}: "${name}". Must start with a letter and contain only letters, digits, '-' or '_'.`,
);
}
return trimmed;
}
/**
* Resolve the repo-root path used as base for generators. We trust the
* working directory the operator invoked the CLI from (consistent with the
* existing `backup` / `tenant` commands).
*/
export function resolveRepoRoot(cwd: string = process.cwd()): string {
return cwd;
}
@@ -0,0 +1,60 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { generateEventType } from "./event-type.js";
describe("add event-type generator", () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "toolctrl-cli-event-type-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
it("emits one file under workflowEngine/eventTypes/_generated/", async () => {
const result = await generateEventType({
name: "crm.contact.created",
repoRoot: tempRoot,
dryRun: true,
});
const rels = result.files.map((f) => path.relative(tempRoot, f.path));
expect(rels).toEqual([
"apps/web/app/modules/workflowEngine/eventTypes/_generated/crm_contact_created.ts",
]);
});
it("contains zod schema + example payload + handler stub", async () => {
const result = await generateEventType({
name: "crm.contact.created",
repoRoot: tempRoot,
dryRun: true,
});
const file = result.files[0]!;
expect(file.contents).toContain('import { z } from "zod"');
expect(file.contents).toContain("CrmContactCreatedPayloadSchema");
expect(file.contents).toContain("CrmContactCreatedExamplePayload");
expect(file.contents).toContain("export async function handleCrmContactCreated");
expect(file.contents).toContain("CrmContactCreatedEventTypeSpec");
expect(file.contents).not.toContain("TODO");
});
it("rejects non-dotted event names", async () => {
await expect(
generateEventType({ name: "ContactCreated", repoRoot: tempRoot, dryRun: true }),
).rejects.toThrow(/Invalid event-type/);
await expect(generateEventType({ name: "", repoRoot: tempRoot, dryRun: true })).rejects.toThrow(
/required/,
);
});
it("refuses to overwrite an existing event-type", async () => {
await generateEventType({ name: "crm.contact.created", repoRoot: tempRoot, dryRun: false });
await expect(
generateEventType({ name: "crm.contact.created", repoRoot: tempRoot, dryRun: false }),
).rejects.toThrow(/already exists/);
});
});
@@ -0,0 +1,141 @@
/**
* `toolctrl add event-type <name>` — scaffold a new EntityWebhook
* event-type definition (zod schema + example payload + handler stub).
*
* The workflow engine does not yet expose a single canonical event-type
* registry file (see workflowEngine/routes/workflow-engine.api.server.tsx —
* only a metatag loader exists today). Until that refactor lands, we
* write the event-type spec under
* `apps/web/app/modules/workflowEngine/eventTypes/_generated/<name>.ts`,
* which the runtime will be able to glob-import once the registry is
* extracted.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/cli-scaffolding.md
*/
import { defineCommand } from "citty";
import consola from "consola";
import path from "node:path";
import {
createScaffoldResult,
pascalCase,
resolveRepoRoot,
writeScaffoldFile,
type ScaffoldResult,
} from "../_lib/scaffold.js";
export type GenerateEventTypeOptions = {
/** Dotted event name, e.g. "crm.contact.created". */
name: string;
repoRoot?: string;
dryRun?: boolean;
};
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/;
export async function generateEventType(options: GenerateEventTypeOptions): Promise<ScaffoldResult> {
const eventName = options.name.trim();
if (!eventName) throw new Error("event-type name is required.");
if (!EVENT_NAME_PATTERN.test(eventName)) {
throw new Error(
`Invalid event-type name: "${eventName}". Use dotted lowercase identifiers, e.g. "crm.contact.created".`,
);
}
const dryRun = options.dryRun ?? false;
const repoRoot = resolveRepoRoot(options.repoRoot);
const fileSlug = eventName.replace(/\./g, "_");
const typeName = pascalCase(eventName.replace(/\./g, " "));
const result = createScaffoldResult(dryRun);
await writeScaffoldFile({
targetPath: path.join(
repoRoot,
"apps/web/app/modules/workflowEngine/eventTypes/_generated",
`${fileSlug}.ts`,
),
contents: renderEventTypeFile({ eventName, typeName }),
dryRun,
result,
});
result.notes.push(
`Event-type "${eventName}" scaffolded.`,
"Register the export in your workflow-engine event registry once the central registry is extracted.",
);
return result;
}
function renderEventTypeFile({ eventName, typeName }: { eventName: string; typeName: string }): string {
return `/**
* EntityWebhook event-type: ${eventName}
* Generated by toolctrl-cli.
*/
import { z } from "zod";
export const ${typeName}EventName = "${eventName}" as const;
export const ${typeName}PayloadSchema = z.object({
tenantId: z.string(),
occurredAt: z.string().datetime(),
// Add domain-specific fields here before wiring the event into production.
data: z.record(z.string(), z.unknown()).default({}),
});
export type ${typeName}Payload = z.infer<typeof ${typeName}PayloadSchema>;
export const ${typeName}ExamplePayload: ${typeName}Payload = {
tenantId: "ten_example",
occurredAt: "2026-01-01T00:00:00.000Z",
data: {},
};
/**
* Handler stub. Wire this into the workflow-engine event dispatcher
* once the central registry is extracted (see workflowEngine/lib).
*/
export async function handle${typeName}(payload: ${typeName}Payload): Promise<void> {
// Replace this no-op with the event dispatcher integration for your domain.
void payload;
}
export const ${typeName}EventTypeSpec = {
name: ${typeName}EventName,
schema: ${typeName}PayloadSchema,
example: ${typeName}ExamplePayload,
handler: handle${typeName},
} as const;
`;
}
export const addEventTypeCommand = defineCommand({
meta: {
name: "event-type",
description: "Scaffold an EntityWebhook event-type (zod schema + handler stub).",
},
args: {
name: {
type: "positional",
required: true,
description: "Dotted event name, e.g. \"crm.contact.created\".",
},
"dry-run": {
type: "boolean",
required: false,
description: "Print the would-be writes without touching the filesystem.",
},
},
async run({ args }) {
const result = await generateEventType({ name: args.name, dryRun: args["dry-run"] === true });
consola.success(
`${result.dryRun ? "[dry-run] " : ""}Generated event-type "${args.name}" — ${result.files.length} file(s).`,
);
for (const file of result.files) {
consola.info(` ${result.dryRun ? "would write" : "wrote"} ${file.path}`);
}
for (const note of result.notes) {
consola.info(note);
}
},
});
@@ -0,0 +1,47 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { generateHook } from "./hook.js";
describe("add hook generator", () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "toolctrl-cli-hook-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
it("emits a hook file under apps/web/app/hooks/_generated/", async () => {
const result = await generateHook({ name: "on-contact-created", repoRoot: tempRoot, dryRun: true });
const rels = result.files.map((f) => path.relative(tempRoot, f.path));
expect(rels).toEqual(["apps/web/app/hooks/_generated/onContactCreated.hook.ts"]);
});
it("file contains a forward-compat header pointing at Phase 06 docs", async () => {
const result = await generateHook({ name: "on-foo", repoRoot: tempRoot, dryRun: true });
expect(result.files[0]!.contents).toContain("Phase 06");
expect(result.files[0]!.contents).toMatch(/registerOnFooHook/);
expect(result.files[0]!.contents).toMatch(/triggerOnFooHook/);
expect(result.files[0]!.contents).not.toContain("TODO");
});
it("notes mention the plugin SDK migration path", async () => {
const result = await generateHook({ name: "on-foo", repoRoot: tempRoot, dryRun: true });
expect(result.notes.join("\n")).toMatch(/@toolctrl\/plugin-sdk/);
});
it("refuses to overwrite an existing hook", async () => {
await generateHook({ name: "on-foo", repoRoot: tempRoot, dryRun: false });
await expect(generateHook({ name: "on-foo", repoRoot: tempRoot, dryRun: false })).rejects.toThrow(
/already exists/,
);
});
it("rejects invalid hook names", async () => {
await expect(generateHook({ name: "1bad", repoRoot: tempRoot, dryRun: true })).rejects.toThrow(/Invalid/);
});
});
@@ -0,0 +1,117 @@
/**
* `toolctrl add hook <name>` — scaffold a plugin-hook registration file.
*
* NOTE: the plugin-system itself (Phase 06) is not yet built. This
* generator therefore produces a *stub* with a clear "scaffolding for
* future plugin system" header so downstream readers know the API
* surface is provisional. Once Phase 06 lands the template will be
* upgraded to import the real `@toolctrl/plugin-sdk`.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/cli-scaffolding.md
* Refs: docs/improvement-plan/06-plugin-system/audit-docs.md
*/
import { defineCommand } from "citty";
import consola from "consola";
import path from "node:path";
import {
camelCase,
createScaffoldResult,
pascalCase,
resolveRepoRoot,
validateIdentifier,
writeScaffoldFile,
type ScaffoldResult,
} from "../_lib/scaffold.js";
export type GenerateHookOptions = {
name: string;
repoRoot?: string;
dryRun?: boolean;
};
export async function generateHook(options: GenerateHookOptions): Promise<ScaffoldResult> {
const baseName = validateIdentifier(options.name, "hook name");
const dryRun = options.dryRun ?? false;
const repoRoot = resolveRepoRoot(options.repoRoot);
const camel = camelCase(baseName);
const pascal = pascalCase(baseName);
const result = createScaffoldResult(dryRun);
const hookDir = path.join(repoRoot, "apps/web/app/hooks/_generated");
await writeScaffoldFile({
targetPath: path.join(hookDir, `${camel}.hook.ts`),
contents: renderHookFile({ camel, pascal }),
dryRun,
result,
});
result.notes.push(
"Phase 06 plugin system is not implemented yet — this hook is a forward-compatible stub.",
"Update the import path to @toolctrl/plugin-sdk once Phase 06 ships.",
);
return result;
}
function renderHookFile({ camel, pascal }: { camel: string; pascal: string }): string {
return `/**
* Scaffolding for the future plugin-system (Phase 06).
*
* This file registers the "${camel}" hook. The current implementation
* is a no-op because the plugin-runtime is not wired up yet — see
* docs/improvement-plan/06-plugin-system/audit-docs.md for the planned
* surface. Once the SDK lands, replace the body of \`register${pascal}Hook\`
* with the real \`pluginRuntime.registerHook(…)\` call.
*/
export type ${pascal}HookPayload = {
tenantId: string;
// Add domain-specific payload fields before exposing this hook publicly.
};
export type ${pascal}HookHandler = (payload: ${pascal}HookPayload) => Promise<void> | void;
const handlers: ${pascal}HookHandler[] = [];
export function register${pascal}Hook(handler: ${pascal}HookHandler): () => void {
handlers.push(handler);
return () => {
const idx = handlers.indexOf(handler);
if (idx >= 0) handlers.splice(idx, 1);
};
}
export async function trigger${pascal}Hook(payload: ${pascal}HookPayload): Promise<void> {
for (const handler of handlers) {
await handler(payload);
}
}
`;
}
export const addHookCommand = defineCommand({
meta: {
name: "hook",
description: "Scaffold a plugin-hook registration file (Phase 06 forward-compat stub).",
},
args: {
name: { type: "positional", required: true, description: "Hook name (camel/kebab/pascal — normalised)." },
"dry-run": {
type: "boolean",
required: false,
description: "Print the would-be writes without touching the filesystem.",
},
},
async run({ args }) {
const result = await generateHook({ name: args.name, dryRun: args["dry-run"] === true });
consola.success(
`${result.dryRun ? "[dry-run] " : ""}Generated hook "${args.name}" — ${result.files.length} file(s).`,
);
for (const file of result.files) {
consola.info(` ${result.dryRun ? "would write" : "wrote"} ${file.path}`);
}
for (const note of result.notes) {
consola.warn(note);
}
},
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { addCommand } from "./index.js";
async function resolveValue<T>(value: T | (() => T) | (() => Promise<T>)): Promise<T> {
if (typeof value === "function") {
return await (value as () => T | Promise<T>)();
}
return value;
}
describe("add command tree", () => {
it("exposes hook / event-type / permission subcommands", async () => {
const meta = await resolveValue(addCommand.meta);
expect(meta).toMatchObject({ name: "add" });
const subs = await resolveValue(addCommand.subCommands);
expect(Object.keys(subs ?? {}).sort()).toEqual(["event-type", "hook", "permission"]);
});
});
@@ -0,0 +1,16 @@
import { defineCommand } from "citty";
import { addEventTypeCommand } from "./event-type.js";
import { addHookCommand } from "./hook.js";
import { addPermissionCommand } from "./permission.js";
export const addCommand = defineCommand({
meta: {
name: "add",
description: "Add-on generators: add hook / event-type / permission.",
},
subCommands: {
hook: addHookCommand,
"event-type": addEventTypeCommand,
permission: addPermissionCommand,
},
});
@@ -0,0 +1,71 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { generatePermission } from "./permission.js";
describe("add permission generator", () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "toolctrl-cli-permission-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
it("emits seed fragment + frontend mapper", async () => {
const result = await generatePermission({
name: "app.inventory.view",
repoRoot: tempRoot,
dryRun: true,
});
const rels = result.files.map((f) => path.relative(tempRoot, f.path)).sort();
expect(rels).toEqual([
"apps/web/app/utils/permissions/_generated/app_inventory_view.ts",
"packages/db/src/_generated-permissions/app_inventory_view.ts",
]);
});
it("classifies admin.* permissions correctly", async () => {
const result = await generatePermission({
name: "admin.audit.view",
repoRoot: tempRoot,
dryRun: true,
});
const seedFragment = result.files.find((f) => f.path.includes("packages/db/"))!;
expect(seedFragment.contents).toContain('PermissionType: "admin" | "app" = "admin"');
expect(seedFragment.contents).toContain('"SuperAdmin"');
expect(seedFragment.contents).not.toContain("TODO");
});
it("classifies app.* permissions correctly", async () => {
const result = await generatePermission({
name: "app.inventory.view",
repoRoot: tempRoot,
dryRun: true,
});
const seedFragment = result.files.find((f) => f.path.includes("packages/db/"))!;
expect(seedFragment.contents).toContain('PermissionType: "admin" | "app" = "app"');
expect(seedFragment.contents).toContain('"SuperUser"');
expect(seedFragment.contents).toContain('"Admin"');
expect(seedFragment.contents).not.toContain("TODO");
});
it("rejects invalid permission keys", async () => {
await expect(
generatePermission({ name: "AppFooBar", repoRoot: tempRoot, dryRun: true }),
).rejects.toThrow(/Invalid/);
await expect(generatePermission({ name: "", repoRoot: tempRoot, dryRun: true })).rejects.toThrow(
/required/,
);
});
it("refuses to overwrite an existing permission fragment", async () => {
await generatePermission({ name: "app.inventory.view", repoRoot: tempRoot, dryRun: false });
await expect(
generatePermission({ name: "app.inventory.view", repoRoot: tempRoot, dryRun: false }),
).rejects.toThrow(/already exists/);
});
});
@@ -0,0 +1,151 @@
/**
* `toolctrl add permission <name>` — scaffold a new permission key plus
* a frontend-mapper stub.
*
* The canonical permission list lives in
* `packages/db/src/permissions-seed.server.ts` as a string-array
* literal. We deliberately do NOT patch that file from the CLI — the
* array is hand-curated and each entry has an associated `inRoles`
* mapping a few lines below. Instead the generator writes a single
* "fragment" file under
* `packages/db/src/_generated-permissions/<name>.ts` that the caller
* can paste into the canonical list, plus a frontend permission-mapper
* stub under `apps/web/app/utils/permissions/_generated/`.
*
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/cli-scaffolding.md
*/
import { defineCommand } from "citty";
import consola from "consola";
import path from "node:path";
import {
createScaffoldResult,
pascalCase,
resolveRepoRoot,
writeScaffoldFile,
type ScaffoldResult,
} from "../_lib/scaffold.js";
export type GeneratePermissionOptions = {
/** Dotted permission key, e.g. "app.inventory.view". */
name: string;
repoRoot?: string;
dryRun?: boolean;
};
const PERMISSION_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/;
export async function generatePermission(options: GeneratePermissionOptions): Promise<ScaffoldResult> {
const permission = options.name.trim();
if (!permission) throw new Error("permission name is required.");
if (!PERMISSION_PATTERN.test(permission)) {
throw new Error(
`Invalid permission key: "${permission}". Use dotted lowercase, e.g. "app.inventory.view".`,
);
}
const dryRun = options.dryRun ?? false;
const repoRoot = resolveRepoRoot(options.repoRoot);
const fileSlug = permission.replace(/\./g, "_");
const constName = pascalCase(permission.replace(/\./g, " "));
const isAdmin = permission.startsWith("admin.");
const result = createScaffoldResult(dryRun);
await writeScaffoldFile({
targetPath: path.join(repoRoot, "packages/db/src/_generated-permissions", `${fileSlug}.ts`),
contents: renderSeedFragment({ permission, constName, isAdmin }),
dryRun,
result,
});
await writeScaffoldFile({
targetPath: path.join(repoRoot, "apps/web/app/utils/permissions/_generated", `${fileSlug}.ts`),
contents: renderMapperFragment({ permission, constName }),
dryRun,
result,
});
result.notes.push(
`Permission key: "${permission}"`,
`Manual follow-up:`,
` 1. Add "${permission}" to defaultPermissionSeeds in packages/db/src/permissions-seed.server.ts`,
` 2. Add the role-mapping entry in the same file (rolePermissionMatrix)`,
` 3. Wire the frontend check via the helper in apps/web/app/utils/helpers/PermissionsHelper.ts`,
);
return result;
}
function renderSeedFragment({
permission,
constName,
isAdmin,
}: {
permission: string;
constName: string;
isAdmin: boolean;
}): string {
return `/**
* Permission seed fragment for "${permission}".
* Generated by toolctrl-cli.
*
* Paste the constant into the \`defaultPermissionSeeds\` array in
* \`packages/db/src/permissions-seed.server.ts\` and add a matching
* entry to the \`rolePermissionMatrix\` further down in that file.
*/
export const ${constName}Permission = "${permission}" as const;
export const ${constName}PermissionType: "admin" | "app" = ${JSON.stringify(isAdmin ? "admin" : "app")};
export const ${constName}DefaultRoles: readonly string[] = [
${isAdmin ? ' "SuperAdmin",' : ' "SuperUser",\n "Admin",'}
];
`;
}
function renderMapperFragment({ permission, constName }: { permission: string; constName: string }): string {
return `/**
* Frontend permission-mapper stub for "${permission}".
* Generated by toolctrl-cli.
*
* Wire this into your route loaders via the project's permission
* helper (typically apps/web/app/utils/helpers/PermissionsHelper).
*/
export const ${constName}PermissionKey = "${permission}" as const;
export function require${constName}(allowedPermissions: ReadonlyArray<string>): boolean {
return allowedPermissions.includes(${constName}PermissionKey);
}
`;
}
export const addPermissionCommand = defineCommand({
meta: {
name: "permission",
description: "Scaffold a permission key + frontend mapper stub.",
},
args: {
name: {
type: "positional",
required: true,
description: "Dotted permission key, e.g. \"app.inventory.view\".",
},
"dry-run": {
type: "boolean",
required: false,
description: "Print the would-be writes without touching the filesystem.",
},
},
async run({ args }) {
const result = await generatePermission({ name: args.name, dryRun: args["dry-run"] === true });
consola.success(
`${result.dryRun ? "[dry-run] " : ""}Generated permission "${args.name}" — ${result.files.length} file(s).`,
);
for (const file of result.files) {
consola.info(` ${result.dryRun ? "would write" : "wrote"} ${file.path}`);
}
for (const note of result.notes) {
consola.info(note);
}
},
});
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditPdfEvent, AuditPdfIntegrity, AuditPdfReportInput } from "@toolctrl/exports/audit-pdf";
import { runAuditExportPdf, type AuditExportPdfDependencies } from "./export-pdf.js";
function makeEvent(over: Partial<AuditPdfEvent> = {}): AuditPdfEvent {
return {
id: "audit_1",
createdAt: new Date("2026-05-17T00:00:00.000Z"),
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,
previousHash: null,
eventHash: "h1",
...over,
};
}
type TestState = {
writtenPath: string | null;
writtenBytes: Uint8Array | null;
renderInput: AuditPdfReportInput | null;
};
function makeDeps(opts: {
events: ReadonlyArray<AuditPdfEvent>;
integrity?: AuditPdfIntegrity;
}): {
deps: AuditExportPdfDependencies;
state: TestState;
logger: { info: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
} {
const state: TestState = { writtenPath: null, writtenBytes: null, renderInput: null };
const logger = { info: vi.fn(), error: vi.fn() };
const deps: AuditExportPdfDependencies = {
loadEvents: async () => opts.events,
verify: () => opts.integrity ?? { ok: true, inspected: opts.events.length, firstBreakId: null, reason: null },
render: async (input) => {
state.renderInput = input;
// Fake PDF bytes — the renderer is unit-tested separately in
// @toolctrl/exports.
return new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]); // "%PDF-1.7"
},
writeOutput: async (path, bytes) => {
state.writtenPath = path;
state.writtenBytes = bytes;
},
logger,
now: () => new Date("2026-05-17T12:00:00.000Z"),
};
return { deps, state, logger };
}
describe("runAuditExportPdf", () => {
it("rejects empty --tenant", async () => {
const { deps, logger } = makeDeps({ events: [] });
const out = await runAuditExportPdf({ tenant: " ", out: "/tmp/x.pdf" }, deps);
expect(out.exitCode).toBe(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Missing --tenant"));
});
it("rejects missing --out", async () => {
const { deps, logger } = makeDeps({ events: [] });
const out = await runAuditExportPdf({ tenant: "t1", out: "" }, deps);
expect(out.exitCode).toBe(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Missing --out"));
});
it("renders + writes a PDF and exits 0 when the chain is intact", async () => {
const events = [makeEvent(), makeEvent({ id: "audit_2", action: "widget.updated", previousHash: "h1", eventHash: "h2" })];
const { deps, state, logger } = makeDeps({ events });
const out = await runAuditExportPdf(
{
tenant: "tenant_1",
from: "2026-05-01T00:00:00.000Z",
to: "2026-05-17T00:00:00.000Z",
regulatoryScope: "soc2",
out: "/tmp/report.pdf",
generatedBy: "ops-bot",
},
deps
);
expect(out.exitCode).toBe(0);
expect(out.rowCount).toBe(2);
expect(state.writtenPath).toBe("/tmp/report.pdf");
expect(state.writtenBytes?.[0]).toBe(0x25); // '%'
expect(state.renderInput?.tenantId).toBe("tenant_1");
expect(state.renderInput?.filterSummary).toContain("regulatoryScope=soc2");
expect(state.renderInput?.events).toHaveLength(2);
expect(state.renderInput?.integrity.ok).toBe(true);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("audit-pdf written"));
});
it("treats tenant=__platform__ as null tenantId in the loader", async () => {
const events: AuditPdfEvent[] = [];
let captured: { tenantId: string | null } | null = null;
const { deps: baseDeps } = makeDeps({ events });
const deps: AuditExportPdfDependencies = {
...baseDeps,
loadEvents: async (args) => {
captured = { tenantId: args.tenantId };
return events;
},
};
await runAuditExportPdf({ tenant: "__platform__", out: "/tmp/x.pdf" }, deps);
expect(captured).toEqual({ tenantId: null });
});
it("exits 1 when integrity is broken — but still writes the file as evidence", async () => {
const events = [makeEvent()];
const { deps, state, logger } = makeDeps({
events,
integrity: { ok: false, inspected: 1, firstBreakId: "audit_1", reason: "hash-mismatch" },
});
const out = await runAuditExportPdf({ tenant: "t1", out: "/tmp/x.pdf" }, deps);
expect(out.exitCode).toBe(1);
expect(state.writtenPath).toBe("/tmp/x.pdf");
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("BROKEN CHAIN"));
});
it("throws when --from is not parseable", async () => {
const { deps } = makeDeps({ events: [] });
await expect(runAuditExportPdf({ tenant: "t1", from: "garbage", out: "/tmp/x.pdf" }, deps)).rejects.toThrow(/Invalid --from/);
});
});
@@ -0,0 +1,218 @@
/**
* `toolctrl audit export-pdf --tenant <id> [--from] [--to] [--regulatory-scope]
* --out <path>`
*
* Renders a tamper-evident PDF compliance report from AuditEvent rows for
* the given tenant and time-range. The report header includes a
* `verifyAuditChain` integrity check; the body lists each event in
* chronological order with hash + actor + outcome + scope.
*
* Renders via @react-pdf/renderer through `@toolctrl/exports/audit-pdf`.
*
* See docs/improvement-plan/10-compliance-polish/compliance-audit-export.md.
*/
import { writeFile } from "node:fs/promises";
import { defineCommand } from "citty";
import consola from "consola";
import type { AuditPdfEvent, AuditPdfIntegrity, AuditPdfReportInput } from "@toolctrl/exports/audit-pdf";
const AUDIT_PDF_QUERY_LIMIT = 5_000;
export type AuditExportPdfArgs = {
tenant: string;
from?: string | null;
to?: string | null;
regulatoryScope?: string | null;
out: string;
generatedBy?: string | null;
};
export type AuditExportPdfDependencies = {
/** Load matching AuditEvent rows in chronological order. */
loadEvents: (args: {
tenantId: string | null;
from: Date | null;
to: Date | null;
regulatoryScope: string | null;
limit: number;
}) => Promise<ReadonlyArray<AuditPdfEvent>>;
/** Compute hash-chain integrity over the loaded events. */
verify: (events: ReadonlyArray<AuditPdfEvent>) => AuditPdfIntegrity;
/** Render the PDF document to a byte buffer. */
render: (input: AuditPdfReportInput) => Promise<Uint8Array>;
/** Write the rendered bytes to the chosen output path. */
writeOutput: (path: string, bytes: Uint8Array) => Promise<void>;
/** Logger sink. */
logger: {
info: (msg: string) => void;
error: (msg: string) => void;
};
/** Optional `now` clock for tests. */
now?: () => Date;
};
export type AuditExportPdfOutcome = {
exitCode: 0 | 1;
rowCount: number;
byteLength: number;
outPath: string;
};
function parseIsoDate(raw: string | null | undefined, label: string): Date | null {
if (!raw || raw.length === 0) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) {
throw new Error(`Invalid --${label} value ${JSON.stringify(raw)} (expected ISO-8601).`);
}
return d;
}
export async function runAuditExportPdf(
args: AuditExportPdfArgs,
deps: AuditExportPdfDependencies
): Promise<AuditExportPdfOutcome> {
const tenantArg = args.tenant.trim();
if (tenantArg.length === 0) {
deps.logger.error("Missing --tenant.");
return { exitCode: 1, rowCount: 0, byteLength: 0, outPath: args.out };
}
if (!args.out || args.out.trim().length === 0) {
deps.logger.error("Missing --out.");
return { exitCode: 1, rowCount: 0, byteLength: 0, outPath: args.out };
}
const from = parseIsoDate(args.from ?? null, "from");
const to = parseIsoDate(args.to ?? null, "to");
const tenantId = tenantArg === "__platform__" ? null : tenantArg;
const regulatoryScope = args.regulatoryScope?.trim() || null;
const events = await deps.loadEvents({ tenantId, from, to, regulatoryScope, limit: AUDIT_PDF_QUERY_LIMIT });
const integrity = deps.verify(events);
const filterSummary = [
regulatoryScope ? `regulatoryScope=${regulatoryScope}` : null,
]
.filter((s): s is string => s !== null)
.join(" ");
const input: AuditPdfReportInput = {
tenantId,
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
filterSummary,
integrity,
events,
...(args.generatedBy ? { generatedBy: args.generatedBy } : {}),
generatedAt: (deps.now ?? (() => new Date()))(),
};
const bytes = await deps.render(input);
await deps.writeOutput(args.out, bytes);
if (integrity.ok) {
deps.logger.info(`audit-pdf written — out=${args.out} rows=${events.length} bytes=${bytes.length} integrity=ok`);
} else {
deps.logger.error(
`audit-pdf written WITH BROKEN CHAIN — out=${args.out} rows=${events.length} bytes=${bytes.length} firstBreakId=${integrity.firstBreakId ?? "?"} reason=${integrity.reason ?? "unknown"}`
);
}
return {
exitCode: integrity.ok ? 0 : 1,
rowCount: events.length,
byteLength: bytes.length,
outPath: args.out,
};
}
export const auditExportPdfCommand = defineCommand({
meta: { name: "export-pdf", description: "Render a tamper-evident PDF compliance report for a tenant." },
args: {
tenant: { type: "string", required: true, description: "Tenant ID (use --tenant=__platform__ for platform events)." },
from: { type: "string", required: false, description: "Lower bound (inclusive) ISO-8601 createdAt." },
to: { type: "string", required: false, description: "Upper bound (exclusive) ISO-8601 createdAt." },
"regulatory-scope": { type: "string", required: false, description: "Filter by regulatoryScope value (gdpr | hipaa | soc2 | pci)." },
out: { type: "string", required: true, description: "Output file path (e.g. ./audit-report.pdf)." },
"generated-by": { type: "string", required: false, description: "Operator display name to embed on the cover page." },
},
async run({ args }) {
const [{ getReadDbClient }, { verifyAuditChain }, { serializeAuditEventsToPdf }] = await Promise.all([
import("@toolctrl/db/client.server"),
import("@toolctrl/db/audit-hash"),
import("@toolctrl/exports/audit-pdf"),
]);
const db = getReadDbClient();
const outcome = await runAuditExportPdf(
{
tenant: String(args.tenant),
...(args.from !== undefined ? { from: String(args.from) } : {}),
...(args.to !== undefined ? { to: String(args.to) } : {}),
...(args["regulatory-scope"] !== undefined ? { regulatoryScope: String(args["regulatory-scope"]) } : {}),
out: String(args.out),
...(args["generated-by"] !== undefined ? { generatedBy: String(args["generated-by"]) } : {}),
},
{
async loadEvents({ tenantId, from, to, regulatoryScope, limit }) {
const where: Record<string, unknown> = { tenantId };
if (from || to) {
const range: Record<string, Date> = {};
if (from) range.gte = from;
if (to) range.lt = to;
where.createdAt = range;
}
if (regulatoryScope) {
where.regulatoryScope = { hasSome: [regulatoryScope] };
}
const rows = (await db.auditEvent.findMany({
where,
orderBy: { createdAt: "asc" },
take: limit,
})) as unknown as ReadonlyArray<AuditPdfEvent>;
return rows;
},
verify(events) {
// Convert to AuditChainEvent-compatible shape: id + createdAt as
// strings, the rest matching AuditPdfEvent.
const chainEvents = events.map((e) => ({
id: e.id,
createdAt: e.createdAt instanceof Date ? e.createdAt.toISOString() : String(e.createdAt),
tenantId: e.tenantId,
actorType: e.actorType,
actorId: e.actorId,
actorEmail: e.actorEmail,
actorIp: e.actorIp,
actorUserAgent: e.actorUserAgent,
action: e.action,
targetType: e.targetType,
targetId: e.targetId,
requestId: e.requestId,
sourceModule: e.sourceModule,
outcome: e.outcome,
dataSensitivity: e.dataSensitivity,
regulatoryScope: e.regulatoryScope as string[],
metadata: e.metadata,
previousHash: e.previousHash,
eventHash: e.eventHash,
}));
const result = verifyAuditChain(chainEvents);
return {
ok: result.ok,
inspected: result.inspected,
firstBreakId: result.firstBreak?.eventId ?? null,
reason: result.firstBreak?.reason ?? null,
};
},
render: serializeAuditEventsToPdf,
writeOutput: async (path, bytes) => writeFile(path, bytes),
logger: {
info: (msg) => consola.success(msg),
error: (msg) => consola.error(msg),
},
}
);
process.exitCode = outcome.exitCode;
},
});
@@ -0,0 +1,17 @@
import { defineCommand } from "citty";
import { auditVerifyChainCommand } from "./verify-chain.js";
import { auditExportPdfCommand } from "./export-pdf.js";
export const auditCommand = defineCommand({
meta: {
name: "audit",
description: "AuditEvent compliance operations (hash-chain verify, PDF report export).",
},
subCommands: {
"verify-chain": auditVerifyChainCommand,
"export-pdf": auditExportPdfCommand,
},
});
export { auditVerifyChainCommand, auditExportPdfCommand };
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import { computeAuditHash, verifyAuditChain } from "@toolctrl/db/audit-hash";
import {
runAuditVerifyChain,
type AuditVerifyChainDependencies,
type AuditVerifyChainRow,
} from "./verify-chain.js";
function makeOutput() {
return {
info: vi.fn<(msg: string) => void>(),
error: vi.fn<(msg: string) => void>(),
json: vi.fn<(obj: unknown) => void>(),
};
}
function basePayload(over: Partial<AuditVerifyChainRow> = {}): AuditVerifyChainRow {
return {
id: "audit_1",
createdAt: "2026-05-17T00:00:00.000Z",
tenantId: "tenant_1",
actorType: "user",
actorId: "user_1",
actorEmail: null,
actorIp: null,
actorUserAgent: null,
action: "x.do",
targetType: null,
targetId: null,
requestId: null,
sourceModule: null,
outcome: "success",
dataSensitivity: "internal",
regulatoryScope: [],
metadata: null,
previousHash: null,
eventHash: null,
...over,
};
}
function hashFor(row: AuditVerifyChainRow): string {
return computeAuditHash({
previousHash: row.previousHash,
payload: {
id: row.id,
createdAt: row.createdAt,
tenantId: row.tenantId,
actorType: row.actorType,
actorId: row.actorId,
actorEmail: row.actorEmail,
actorIp: row.actorIp,
actorUserAgent: row.actorUserAgent,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
requestId: row.requestId,
sourceModule: row.sourceModule,
outcome: row.outcome,
dataSensitivity: row.dataSensitivity,
regulatoryScope: row.regulatoryScope,
metadata: row.metadata,
},
});
}
function makeDeps(rows: AuditVerifyChainRow[]): AuditVerifyChainDependencies & { output: ReturnType<typeof makeOutput> } {
const output = makeOutput();
return {
output,
verify: verifyAuditChain,
async *readRows() {
for (const row of rows) yield row;
},
};
}
describe("runAuditVerifyChain", () => {
it("returns exit code 0 + prints OK message for an intact chain", async () => {
const r1 = basePayload();
r1.eventHash = hashFor(r1);
const r2 = basePayload({ id: "audit_2", action: "x.do2", previousHash: r1.eventHash });
r2.eventHash = hashFor(r2);
const deps = makeDeps([r1, r2]);
const result = await runAuditVerifyChain({ tenant: "tenant_1" }, deps);
expect(result.exitCode).toBe(0);
expect(result.ok).toBe(true);
expect(result.inspected).toBe(2);
expect(deps.output.info).toHaveBeenCalledWith(expect.stringContaining("audit-chain OK"));
});
it("returns exit code 1 with a diagnostic on hash mismatch", async () => {
const r1 = basePayload();
r1.eventHash = hashFor(r1);
// tamper: change metadata but keep stored hash
const tampered = { ...r1, metadata: { tampered: true } };
const deps = makeDeps([tampered]);
const result = await runAuditVerifyChain({ tenant: "tenant_1" }, deps);
expect(result.exitCode).toBe(1);
expect(result.ok).toBe(false);
expect(deps.output.error).toHaveBeenCalledWith(expect.stringContaining("audit-chain BROKEN"));
expect(deps.output.error).toHaveBeenCalledWith(expect.stringContaining("hash-mismatch"));
});
it("emits a single JSON object when --json is set", async () => {
const r1 = basePayload();
r1.eventHash = hashFor(r1);
const deps = makeDeps([r1]);
const result = await runAuditVerifyChain({ tenant: "tenant_1", json: true }, deps);
expect(result.exitCode).toBe(0);
expect(deps.output.info).not.toHaveBeenCalled();
expect(deps.output.json).toHaveBeenCalledTimes(1);
const arg = deps.output.json.mock.calls[0]?.[0] as Record<string, unknown>;
expect(arg).toMatchObject({ tenantId: "tenant_1", ok: true, inspected: 1 });
});
it("rejects an empty tenant arg", async () => {
const deps = makeDeps([]);
const result = await runAuditVerifyChain({ tenant: " " }, deps);
expect(result.exitCode).toBe(1);
expect(deps.output.error).toHaveBeenCalledWith(expect.stringContaining("Missing --tenant"));
});
it("parses ISO-8601 --from/--to and propagates them to the reader", async () => {
const captured: { from: Date | null; to: Date | null }[] = [];
const deps: AuditVerifyChainDependencies & { output: ReturnType<typeof makeOutput> } = {
output: makeOutput(),
verify: () => ({ ok: true, inspected: 0, firstBreak: null }),
async *readRows(opts) {
captured.push({ from: opts.from, to: opts.to });
return;
},
};
await runAuditVerifyChain({ tenant: "t1", from: "2026-01-01T00:00:00.000Z", to: "2026-05-17T00:00:00.000Z" }, deps);
expect(captured[0]?.from?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
expect(captured[0]?.to?.toISOString()).toBe("2026-05-17T00:00:00.000Z");
});
it("throws when --from is not parseable", async () => {
const deps = makeDeps([]);
await expect(runAuditVerifyChain({ tenant: "t1", from: "not-a-date" }, deps)).rejects.toThrow(/Invalid --from/);
});
});
@@ -0,0 +1,169 @@
/**
* `toolctrl audit verify-chain --tenant <id> [--from <iso8601>] [--to <iso8601>] [--json]`
*
* Thin CLI wrapper around `verifyAuditChain` from @toolctrl/db/audit-hash.
* Loads the per-tenant AuditEvent chain in chronological order, recomputes
* each event's eventHash and compares it against the stored value. Exits
* 0 on intact chain, non-zero with first-break diagnostics otherwise.
*
* Design notes:
* - Reads via getReadDbClient — the verifier is read-only.
* - Streams rows in batches of 5_000 by id ascending; the verifier
* consumes rows in createdAt order so we sort each batch before
* appending to the running chain state. For tenants with > a few
* hundred thousand events the operator should run the verifier
* against a time-restricted range to keep memory bounded.
* - `--json` switches the printer to a single machine-readable JSON
* object on stdout (no consola decorations).
*
* See docs/improvement-plan/10-compliance-polish/compliance-audit-export.md.
*/
import { defineCommand } from "citty";
import consola from "consola";
import type { AuditChainEvent, VerifyAuditChainResult } from "@toolctrl/db/audit-hash";
const AUDIT_VERIFY_BATCH_SIZE = 5_000;
export type AuditVerifyChainArgs = {
tenant: string;
from?: string | null;
to?: string | null;
json?: boolean;
};
export type AuditVerifyChainRow = AuditChainEvent;
export type AuditVerifyChainDependencies = {
/** Cursor-iterable read of events ordered by createdAt ascending. */
readRows: (args: { tenantId: string; from: Date | null; to: Date | null; batchSize: number }) => AsyncIterable<AuditVerifyChainRow>;
/** Chain verifier — usually `verifyAuditChain` from @toolctrl/db/audit-hash. */
verify: (events: ReadonlyArray<AuditVerifyChainRow>, options?: { secret?: string | null }) => VerifyAuditChainResult;
/** Output sink. Tests pass an array collector; production wires consola. */
output: { info: (msg: string) => void; json: (obj: unknown) => void; error: (msg: string) => void };
/** Optional secret override (otherwise reads AUDIT_CHAIN_SECRET env). */
secret?: string | null;
};
export type AuditVerifyChainOutcome = {
exitCode: 0 | 1;
inspected: number;
ok: boolean;
};
function parseIsoDate(raw: string | null | undefined, label: string): Date | null {
if (!raw || raw.length === 0) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) {
throw new Error(`Invalid --${label} value ${JSON.stringify(raw)} (expected ISO-8601).`);
}
return d;
}
export async function runAuditVerifyChain(
args: AuditVerifyChainArgs,
deps: AuditVerifyChainDependencies
): Promise<AuditVerifyChainOutcome> {
const tenantId = args.tenant.trim();
if (tenantId.length === 0) {
deps.output.error("Missing --tenant.");
return { exitCode: 1, inspected: 0, ok: false };
}
const from = parseIsoDate(args.from ?? null, "from");
const to = parseIsoDate(args.to ?? null, "to");
// Collect rows. The verifier is stateful per-chain (compares against the
// previous event's stored hash) so we load the whole window in-memory.
// For tenants beyond ~100k events the operator should pass a date range.
const rows: AuditVerifyChainRow[] = [];
for await (const row of deps.readRows({ tenantId, from, to, batchSize: AUDIT_VERIFY_BATCH_SIZE })) {
rows.push(row);
}
const result = deps.verify(rows, deps.secret !== undefined ? { secret: deps.secret } : {});
if (args.json) {
deps.output.json({
tenantId,
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
inspected: result.inspected,
ok: result.ok,
firstBreak: result.firstBreak,
});
} else if (result.ok) {
deps.output.info(`audit-chain OK — tenant=${tenantId} inspected=${result.inspected}`);
} else {
deps.output.error(
`audit-chain BROKEN — tenant=${tenantId} inspected=${result.inspected} firstBreakId=${result.firstBreak?.eventId ?? "?"} reason=${result.firstBreak?.reason ?? "unknown"}`
);
}
return { exitCode: result.ok ? 0 : 1, inspected: result.inspected, ok: result.ok };
}
export const auditVerifyChainCommand = defineCommand({
meta: { name: "verify-chain", description: "Verify the AuditEvent hash-chain integrity for a tenant." },
args: {
tenant: { type: "string", required: true, description: "Tenant ID to verify (use --tenant=__platform__ for platform events)." },
from: { type: "string", required: false, description: "Lower bound (inclusive) ISO-8601 createdAt." },
to: { type: "string", required: false, description: "Upper bound (exclusive) ISO-8601 createdAt." },
json: { type: "boolean", required: false, default: false, description: "Emit a single JSON object instead of human-readable text." },
},
async run({ args }) {
const { verifyAuditChain } = await import("@toolctrl/db/audit-hash");
const { getReadDbClient } = await import("@toolctrl/db/client.server");
const db = getReadDbClient();
const tenantId = String(args.tenant);
async function* readRows(opts: { tenantId: string; from: Date | null; to: Date | null; batchSize: number }): AsyncIterable<AuditVerifyChainRow> {
const where: Record<string, unknown> = {
tenantId: opts.tenantId === "__platform__" ? null : opts.tenantId,
};
if (opts.from || opts.to) {
const range: Record<string, Date> = {};
if (opts.from) range.gte = opts.from;
if (opts.to) range.lt = opts.to;
where.createdAt = range;
}
let cursor: string | undefined;
while (true) {
const page: AuditVerifyChainRow[] = (await db.auditEvent.findMany({
where,
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: opts.batchSize,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
})) as unknown as AuditVerifyChainRow[];
for (const row of page) yield row;
if (page.length < opts.batchSize) return;
cursor = page[page.length - 1]?.id;
}
}
const tenantArg = String(args.tenant);
const fromArg = args.from ? String(args.from) : null;
const toArg = args.to ? String(args.to) : null;
const jsonArg = Boolean(args.json);
try {
const outcome = await runAuditVerifyChain(
{ tenant: tenantArg, ...(fromArg !== null ? { from: fromArg } : {}), ...(toArg !== null ? { to: toArg } : {}), json: jsonArg },
{
readRows,
verify: verifyAuditChain,
output: {
info: (msg) => consola.success(msg),
json: (obj) => consola.log(JSON.stringify(obj)),
error: (msg) => consola.error(msg),
},
}
);
process.exitCode = outcome.exitCode;
// Surface the tenant for ops cross-referencing.
void tenantId;
} catch (error) {
consola.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
},
});
@@ -0,0 +1,68 @@
import { EventEmitter } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clickhouseEnabled, maybeSnapshotClickhouse } from "./clickhouse.js";
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "cli-clickhouse-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("clickhouseEnabled", () => {
it("returns false when feature flag is missing", () => {
expect(clickhouseEnabled({})).toBe(false);
expect(clickhouseEnabled({ BACKUP_INCLUDE_CLICKHOUSE: "false", CLICKHOUSE_URL: "http://x" })).toBe(false);
});
it("returns false when feature flag set but url missing", () => {
expect(clickhouseEnabled({ BACKUP_INCLUDE_CLICKHOUSE: "true" })).toBe(false);
});
it("returns true when feature flag and url are present", () => {
expect(clickhouseEnabled({ BACKUP_INCLUDE_CLICKHOUSE: "true", CLICKHOUSE_URL: "http://x" })).toBe(true);
});
});
describe("maybeSnapshotClickhouse", () => {
it("returns null when not enabled (no-op path)", async () => {
const result = await maybeSnapshotClickhouse({ outDir: workDir, env: {} });
expect(result).toBeNull();
});
it("invokes clickhouse-client when enabled and reports a result", async () => {
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
const spawnFn = ((_cmd: string, args: readonly string[]) => {
expect(args).toContain("--query");
queueMicrotask(() => {
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
const result = await maybeSnapshotClickhouse({
outDir: workDir,
env: {
BACKUP_INCLUDE_CLICKHOUSE: "true",
CLICKHOUSE_URL: "http://localhost:8123",
CLICKHOUSE_DATABASE: "analytics",
},
spawnFn,
});
expect(result).not.toBeNull();
expect(result?.path).toMatch(/snapshot\.native\.gz$/u);
expect(result?.checksum.algorithm).toBe("sha256");
});
});
@@ -0,0 +1,123 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
/**
* ClickHouse snapshot is opt-in. When `BACKUP_INCLUDE_CLICKHOUSE !== "true"` (or
* required env vars are missing) `maybeSnapshotClickhouse` returns `null` and
* the backup proceeds without a `clickhouse/` component.
*/
export interface ClickhouseSnapshotEnv {
BACKUP_INCLUDE_CLICKHOUSE?: string;
CLICKHOUSE_URL?: string;
CLICKHOUSE_USER?: string;
CLICKHOUSE_PASSWORD?: string;
CLICKHOUSE_DATABASE?: string;
}
export interface ClickhouseSnapshotOptions {
env?: ClickhouseSnapshotEnv;
outDir: string;
/** Override binary (default: `clickhouse-client`). */
binary?: string;
/** Inject spawn for tests. */
spawnFn?: typeof spawn;
}
export interface ClickhouseSnapshotResult {
path: string;
sizeBytes: number;
checksum: { algorithm: "sha256"; value: string };
}
export function clickhouseEnabled(env: ClickhouseSnapshotEnv | undefined): boolean {
return env?.BACKUP_INCLUDE_CLICKHOUSE === "true" && Boolean(env.CLICKHOUSE_URL);
}
export async function maybeSnapshotClickhouse(
opts: ClickhouseSnapshotOptions,
): Promise<ClickhouseSnapshotResult | null> {
const env = opts.env ?? (process.env as unknown as ClickhouseSnapshotEnv);
if (!clickhouseEnabled(env)) {
return null;
}
await mkdir(opts.outDir, { recursive: true });
const outPath = join(opts.outDir, "snapshot.native.gz");
const binary = opts.binary ?? "clickhouse-client";
const spawner = opts.spawnFn ?? spawn;
const args = [
"--host",
parseHost(env.CLICKHOUSE_URL ?? ""),
"--query",
`BACKUP DATABASE ${env.CLICKHOUSE_DATABASE ?? "default"} TO File('${outPath}')`,
];
const spawnOptions: SpawnOptions = {
env: {
...process.env,
CLICKHOUSE_USER: env.CLICKHOUSE_USER ?? "",
CLICKHOUSE_PASSWORD: env.CLICKHOUSE_PASSWORD ?? "",
},
stdio: ["ignore", "pipe", "pipe"],
};
const child = spawner(binary, args, spawnOptions);
const stderrChunks: Buffer[] = [];
child.stderr?.on("data", (c: Buffer) => stderrChunks.push(c));
// For native BACKUP statements ClickHouse writes the file itself; we drain
// stdout into a placeholder so the process completes cleanly when run.
if (child.stdout) {
const sink = createWriteStream(`${outPath}.log`);
child.stdout.pipe(sink);
}
const exitCode: number | null = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code: number | null) => resolve(code));
});
if (exitCode !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf8");
throw new Error(`clickhouse-client exited with code ${exitCode ?? "<null>"}: ${stderr}`);
}
let size = 0;
try {
const s = await stat(outPath);
size = s.size;
} catch {
/* path may not exist in stub environments */
}
const hash = await sha256OfFileOrEmpty(outPath);
return {
path: outPath,
sizeBytes: size,
checksum: { algorithm: "sha256", value: hash },
};
}
function parseHost(url: string): string {
try {
return new URL(url).host || "localhost";
} catch {
return url || "localhost";
}
}
async function sha256OfFileOrEmpty(path: string): Promise<string> {
const hash = createHash("sha256");
try {
const { createReadStream } = await import("node:fs");
for await (const chunk of createReadStream(path)) {
hash.update(chunk as Buffer);
}
} catch {
// ignore - returns hash of empty input
}
return hash.digest("hex");
}
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createTar: vi.fn(async (options: { file: string }) => {
const { writeFile } = await import("node:fs/promises");
await writeFile(options.file, "archive");
}),
dumpPostgresToFile: vi.fn(),
backupStorageBucket: vi.fn(),
maybeSnapshotClickhouse: vi.fn(),
encryptFile: vi.fn(async (options: { outputPath: string }) => {
const { writeFile } = await import("node:fs/promises");
await writeFile(options.outputPath, "encrypted");
}),
uploadFileMultipart: vi.fn(),
s3Client: vi.fn(function S3Client(this: { config: unknown }, config: unknown) {
this.config = config;
}),
}));
vi.mock("tar", () => ({
create: mocks.createTar,
}));
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: mocks.s3Client,
}));
vi.mock("./postgres.js", () => ({
dumpPostgresToFile: mocks.dumpPostgresToFile,
}));
vi.mock("./storage.js", () => ({
backupStorageBucket: mocks.backupStorageBucket,
}));
vi.mock("./clickhouse.js", () => ({
maybeSnapshotClickhouse: mocks.maybeSnapshotClickhouse,
}));
vi.mock("./encryption.js", () => ({
encryptFile: mocks.encryptFile,
}));
vi.mock("./s3-upload.js", () => ({
uploadFileMultipart: mocks.uploadFileMultipart,
}));
import { createBackupCommand, runCreateBackup } from "./create.js";
const checksum = {
algorithm: "sha256" as const,
value: "a".repeat(64),
};
const baseEnv = {
BACKUP_S3_BUCKET: "backups",
BACKUP_S3_REGION: "eu-central-1",
DATABASE_URL: "postgres://db",
BACKUP_ENCRYPTION: "none",
};
const now = () => new Date("2026-05-03T04:05:06.007Z");
const logger = {
info: vi.fn(),
warn: vi.fn(),
};
describe("runCreateBackup", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.dumpPostgresToFile.mockResolvedValue({ sizeBytes: 111, checksum });
mocks.backupStorageBucket.mockResolvedValue({
objectCount: 2,
totalBytes: 222,
checksum,
});
mocks.maybeSnapshotClickhouse.mockResolvedValue({ sizeBytes: 333, checksum });
mocks.uploadFileMultipart.mockResolvedValue(undefined);
});
it("creates a postgres-only backup when storage is skipped", async () => {
const client = { send: vi.fn() } as never;
const result = await runCreateBackup({
env: { ...baseEnv, STORAGE_BUCKET: "app-storage" },
client,
now,
skipStorage: true,
logger,
});
expect(result.backupId).toMatch(/^backup-2026-05-03T040506007Z-[a-f0-9]{8}$/u);
expect(result.key).toBe(`${result.backupId}.tar.gz`);
expect(result.sizeBytes).toBe("archive".length);
expect(result.manifest.components).toEqual({
postgres: {
path: "postgres/dump.sql.gz",
sizeBytes: 111,
checksum,
},
});
expect(logger.warn).toHaveBeenCalledWith("--skip-storage flag set; skipping storage component");
expect(mocks.backupStorageBucket).not.toHaveBeenCalled();
expect(mocks.encryptFile).not.toHaveBeenCalled();
expect(mocks.uploadFileMultipart).toHaveBeenCalledWith(
expect.objectContaining({
client,
bucket: "backups",
key: result.key,
contentType: "application/gzip",
metadata: expect.objectContaining({
"backup-id": result.backupId,
encryption: "none",
}),
}),
);
});
it("includes storage, clickhouse, and encrypted upload metadata when enabled", async () => {
const result = await runCreateBackup({
env: {
...baseEnv,
STORAGE_BUCKET: "app-storage",
BACKUP_ENCRYPTION: "age",
BACKUP_AGE_PUBLIC_KEY: "age1example",
BACKUP_INCLUDE_CLICKHOUSE: "true",
},
now,
logger,
});
expect(result.key).toBe(`${result.backupId}.tar.gz.age`);
expect(result.sizeBytes).toBe("encrypted".length);
expect(mocks.s3Client).toHaveBeenCalledWith(expect.objectContaining({ region: "eu-central-1" }));
expect(mocks.backupStorageBucket).toHaveBeenCalledWith(
expect.objectContaining({
bucket: "app-storage",
s3: expect.objectContaining({ region: "eu-central-1" }),
}),
);
expect(mocks.maybeSnapshotClickhouse).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ BACKUP_INCLUDE_CLICKHOUSE: "true" }),
}),
);
expect(mocks.encryptFile).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ method: "age", recipient: "age1example" }),
}),
);
expect(result.manifest.components.storage).toMatchObject({
path: "storage/objects.tar",
sizeBytes: 222,
objectCount: 2,
checksum,
});
expect(result.manifest.components.clickhouse).toMatchObject({
path: "clickhouse/snapshot.native.gz",
sizeBytes: 333,
checksum,
});
});
});
describe("createBackupCommand", () => {
it("exposes the skip-storage operator flag", () => {
expect(createBackupCommand.args).toMatchObject({
"skip-storage": {
type: "boolean",
default: false,
},
});
});
});
@@ -0,0 +1,177 @@
import { defineCommand } from "citty";
import consola from "consola";
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { create as createTar } from "tar";
import { hostname } from "node:os";
import { S3Client } from "@aws-sdk/client-s3";
import {
BACKUP_MANIFEST_VERSION,
type BackupManifest,
classifyBackup,
serializeManifest,
} from "./manifest.js";
import { dumpPostgresToFile } from "./postgres.js";
import { backupStorageBucket } from "./storage.js";
import { maybeSnapshotClickhouse } from "./clickhouse.js";
import { encryptFile } from "./encryption.js";
import { uploadFileMultipart } from "./s3-upload.js";
import { resolveBackupConfig, type BackupEnv } from "./env.js";
export interface RunCreateBackupOptions {
env?: BackupEnv;
/** Inject a pre-built S3 client (tests). */
client?: S3Client;
/** Override now() for deterministic naming. */
now?: () => Date;
/** Skip storage component (CLI --skip-storage flag). */
skipStorage?: boolean;
/** Logger override (tests). */
logger?: { info: (msg: string) => void; warn: (msg: string) => void };
}
export interface RunCreateBackupResult {
backupId: string;
key: string;
sizeBytes: number;
manifest: BackupManifest;
}
/** Run the backup-create flow. Exposed as a function so other commands and tests can re-use it. */
export async function runCreateBackup(opts: RunCreateBackupOptions = {}): Promise<RunCreateBackupResult> {
const env = opts.env ?? (process.env as unknown as BackupEnv);
const logger = opts.logger ?? consola.create({ defaults: { tag: "backup:create" } });
const config = resolveBackupConfig(env);
const now = (opts.now ?? (() => new Date()))();
const createdAt = now.toISOString();
const shortUuid = randomBytes(4).toString("hex");
const backupId = `backup-${createdAt.replace(/[:.]/gu, "")}-${shortUuid}`;
const objectKey = `${backupId}.tar.gz${config.encryption.method === "none" ? "" : `.${config.encryption.method}`}`;
const tempRoot = await mkdtemp(join(tmpdir(), "backup-create-"));
try {
const stageDir = join(tempRoot, backupId);
await mkdir(stageDir, { recursive: true });
logger.info(`Dumping Postgres into ${stageDir}/postgres/dump.sql.gz`);
const pgDir = join(stageDir, "postgres");
await mkdir(pgDir, { recursive: true });
const pgPath = join(pgDir, "dump.sql.gz");
const pgResult = await dumpPostgresToFile({ connectionString: config.databaseUrl, outPath: pgPath });
let storageEntry: BackupManifest["components"]["storage"] | undefined;
if (!opts.skipStorage && config.appStorageBucket) {
logger.info(`Backing up storage bucket ${config.appStorageBucket}`);
const storageDir = join(stageDir, "storage");
const storageResult = await backupStorageBucket({
s3: config.s3,
bucket: config.appStorageBucket,
outDir: storageDir,
client: opts.client,
});
storageEntry = {
path: "storage/objects.tar",
sizeBytes: storageResult.totalBytes,
checksum: storageResult.checksum,
objectCount: storageResult.objectCount,
};
} else if (opts.skipStorage) {
logger.warn("--skip-storage flag set; skipping storage component");
} else {
logger.warn("STORAGE_BUCKET env not set; skipping storage component");
}
let clickhouseEntry: BackupManifest["components"]["clickhouse"] | undefined;
if (config.includeClickhouse) {
logger.info("Snapshotting ClickHouse");
const chDir = join(stageDir, "clickhouse");
const chResult = await maybeSnapshotClickhouse({ outDir: chDir, env: env as never });
if (chResult) {
clickhouseEntry = {
path: "clickhouse/snapshot.native.gz",
sizeBytes: chResult.sizeBytes,
checksum: chResult.checksum,
};
}
}
const manifest: BackupManifest = {
schemaVersion: BACKUP_MANIFEST_VERSION,
backupId,
createdAt,
creator: { cliVersion: "0.0.0", host: hostname() },
classification: classifyBackup(createdAt),
components: {
postgres: {
path: "postgres/dump.sql.gz",
sizeBytes: pgResult.sizeBytes,
checksum: pgResult.checksum,
},
...(storageEntry ? { storage: storageEntry } : {}),
...(clickhouseEntry ? { clickhouse: clickhouseEntry } : {}),
},
encryption: {
method: config.encryption.method,
recipient: config.encryption.recipient,
},
};
const manifestPath = join(stageDir, "manifest.json");
await writeFile(manifestPath, serializeManifest(manifest));
const archivePath = join(tempRoot, `${backupId}.tar.gz`);
logger.info(`Packaging ${archivePath}`);
await createTar(
{ gzip: true, file: archivePath, cwd: tempRoot, portable: true },
[backupId],
);
let uploadPath = archivePath;
if (config.encryption.method !== "none") {
const encryptedPath = `${archivePath}.${config.encryption.method}`;
logger.info(`Encrypting via ${config.encryption.method}`);
await encryptFile({ config: config.encryption, inputPath: archivePath, outputPath: encryptedPath });
uploadPath = encryptedPath;
}
const client = opts.client ?? new S3Client(config.s3);
logger.info(`Uploading to s3://${config.backupBucket}/${objectKey}`);
await uploadFileMultipart({
client,
bucket: config.backupBucket,
key: objectKey,
filePath: uploadPath,
contentType: "application/gzip",
metadata: {
"backup-id": backupId,
"manifest-version": String(BACKUP_MANIFEST_VERSION),
encryption: config.encryption.method,
classification: manifest.classification.join(","),
},
});
const uploadStat = await readFile(uploadPath).then((b) => b.length);
return { backupId, key: objectKey, sizeBytes: uploadStat, manifest };
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
}
export const createBackupCommand = defineCommand({
meta: {
name: "create",
description: "Create a new backup (Postgres + Storage) and upload to S3.",
},
args: {
"skip-storage": {
type: "boolean",
description: "Do not include the application storage bucket.",
default: false,
},
},
async run({ args }) {
const result = await runCreateBackup({ skipStorage: Boolean(args["skip-storage"]) });
consola.success(`Created ${result.backupId} (${result.sizeBytes} bytes) at key ${result.key}`);
},
});
@@ -0,0 +1,112 @@
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { decryptFile, encryptFile, EncryptionError, trivialXorRoundTrip } from "./encryption.js";
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "cli-enc-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
function fakeSpawn() {
return ((..._spawnArgs: unknown[]) => {
void _spawnArgs;
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
queueMicrotask(() => {
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
}
describe("encryption layer", () => {
it("method=none copies the file untouched (round-trip)", async () => {
const src = join(workDir, "src.bin");
const enc = join(workDir, "src.bin.enc");
const dec = join(workDir, "src.bin.dec");
await writeFile(src, "hello world");
await encryptFile({ config: { method: "none" }, inputPath: src, outputPath: enc });
await decryptFile({ config: { method: "none" }, inputPath: enc, outputPath: dec });
expect((await readFile(dec)).toString("utf8")).toBe("hello world");
});
it("method=age requires a recipient", async () => {
const src = join(workDir, "src.bin");
await writeFile(src, "x");
await expect(
encryptFile({
config: { method: "age" },
inputPath: src,
outputPath: join(workDir, "out.age"),
spawnFn: fakeSpawn(),
}),
).rejects.toBeInstanceOf(EncryptionError);
});
it("method=age decrypt requires an identity file", async () => {
await expect(
decryptFile({
config: { method: "age" },
inputPath: join(workDir, "any.age"),
outputPath: join(workDir, "out.bin"),
spawnFn: fakeSpawn(),
}),
).rejects.toBeInstanceOf(EncryptionError);
});
it("invokes the age binary with -r recipient", async () => {
const src = join(workDir, "src.bin");
await writeFile(src, "secret");
let captured: readonly string[] = [];
const spawnFn = ((cmd: string, args: readonly string[]) => {
void cmd;
captured = args;
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
queueMicrotask(() => {
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
await encryptFile({
config: { method: "age", recipient: "age1example" },
inputPath: src,
outputPath: join(workDir, "out.age"),
spawnFn,
});
expect(captured).toEqual(["-r", "age1example", "-o", expect.any(String), src]);
});
it("trivialXorRoundTrip produces unreadable cipher and recoverable plaintext", async () => {
const src = join(workDir, "src.bin");
const enc = join(workDir, "src.bin.enc");
const dec = join(workDir, "src.bin.dec");
const plaintext = "the quick brown fox jumps over the lazy dog";
await writeFile(src, plaintext);
await trivialXorRoundTrip(src, enc, 0x5a);
const encBytes = await readFile(enc);
expect(encBytes.toString("utf8")).not.toBe(plaintext);
await trivialXorRoundTrip(enc, dec, 0x5a);
expect((await readFile(dec)).toString("utf8")).toBe(plaintext);
});
});
@@ -0,0 +1,152 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { createReadStream, createWriteStream } from "node:fs";
import { copyFile } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
export type EncryptionMethod = "age" | "gpg" | "none";
export interface EncryptionConfig {
method: EncryptionMethod;
/** age public key (recipient) when method=age. */
recipient?: string;
/** age identity file when method=age and we're decrypting. */
identityFile?: string;
/** gpg key id for method=gpg. */
gpgRecipient?: string;
/** Override binary path. */
binary?: string;
}
export interface EncryptOptions {
config: EncryptionConfig;
inputPath: string;
outputPath: string;
spawnFn?: typeof spawn;
}
export class EncryptionError extends Error {
readonly exitCode: number | null;
readonly stderr: string;
constructor(message: string, exitCode: number | null, stderr: string) {
super(message);
this.name = "EncryptionError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
export async function encryptFile(opts: EncryptOptions): Promise<{ method: EncryptionMethod; outputPath: string }> {
const { method } = opts.config;
if (method === "none") {
await copyFile(opts.inputPath, opts.outputPath);
return { method, outputPath: opts.outputPath };
}
if (method === "age") {
if (!opts.config.recipient) {
throw new EncryptionError("BACKUP_AGE_PUBLIC_KEY is required when BACKUP_ENCRYPTION=age", null, "");
}
await runSpawnPipe({
binary: opts.config.binary ?? "age",
args: ["-r", opts.config.recipient, "-o", opts.outputPath, opts.inputPath],
spawnFn: opts.spawnFn,
});
return { method, outputPath: opts.outputPath };
}
if (method === "gpg") {
if (!opts.config.gpgRecipient) {
throw new EncryptionError("BACKUP_GPG_RECIPIENT is required when BACKUP_ENCRYPTION=gpg", null, "");
}
await runSpawnPipe({
binary: opts.config.binary ?? "gpg",
args: ["--yes", "--encrypt", "--recipient", opts.config.gpgRecipient, "--output", opts.outputPath, opts.inputPath],
spawnFn: opts.spawnFn,
});
return { method, outputPath: opts.outputPath };
}
throw new EncryptionError(`Unsupported encryption method: ${String(method)}`, null, "");
}
export interface DecryptOptions {
config: EncryptionConfig;
inputPath: string;
outputPath: string;
spawnFn?: typeof spawn;
}
export async function decryptFile(opts: DecryptOptions): Promise<{ method: EncryptionMethod; outputPath: string }> {
const { method } = opts.config;
if (method === "none") {
await copyFile(opts.inputPath, opts.outputPath);
return { method, outputPath: opts.outputPath };
}
if (method === "age") {
if (!opts.config.identityFile) {
throw new EncryptionError("BACKUP_AGE_IDENTITY_FILE is required to decrypt age backups", null, "");
}
await runSpawnPipe({
binary: opts.config.binary ?? "age",
args: ["-d", "-i", opts.config.identityFile, "-o", opts.outputPath, opts.inputPath],
spawnFn: opts.spawnFn,
});
return { method, outputPath: opts.outputPath };
}
if (method === "gpg") {
await runSpawnPipe({
binary: opts.config.binary ?? "gpg",
args: ["--yes", "--decrypt", "--output", opts.outputPath, opts.inputPath],
spawnFn: opts.spawnFn,
});
return { method, outputPath: opts.outputPath };
}
throw new EncryptionError(`Unsupported encryption method: ${String(method)}`, null, "");
}
/**
* Convenience helper that emulates an in-process encryption when no binary is
* available — used in unit tests so the round-trip is fully deterministic.
* Production code paths must use age or gpg via the real binary.
*/
export async function trivialXorRoundTrip(inputPath: string, outputPath: string, key: number): Promise<void> {
const src = createReadStream(inputPath);
const dst = createWriteStream(outputPath);
await pipeline(
src,
async function* (source) {
for await (const chunk of source) {
const buf = chunk as Buffer;
const out = Buffer.alloc(buf.length);
for (let i = 0; i < buf.length; i += 1) {
out[i] = buf[i] ^ key;
}
yield out;
}
},
dst,
);
}
interface RunSpawnPipeOptions {
binary: string;
args: readonly string[];
spawnFn?: typeof spawn;
}
async function runSpawnPipe(opts: RunSpawnPipeOptions): Promise<void> {
const spawner = opts.spawnFn ?? spawn;
const spawnOptions: SpawnOptions = { stdio: ["ignore", "pipe", "pipe"] };
const child = spawner(opts.binary, [...opts.args], spawnOptions);
const stderrChunks: Buffer[] = [];
child.stderr?.on("data", (c: Buffer) => stderrChunks.push(c));
const exitCode: number | null = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code: number | null) => resolve(code));
});
if (exitCode !== 0) {
throw new EncryptionError(
`${opts.binary} exited with code ${exitCode ?? "<null>"}`,
exitCode,
Buffer.concat(stderrChunks).toString("utf8"),
);
}
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { BackupConfigError, resolveBackupConfig } from "./env.js";
describe("resolveBackupConfig", () => {
it("requires BACKUP_S3_BUCKET", () => {
expect(() => resolveBackupConfig({ DATABASE_URL: "x" })).toThrow(BackupConfigError);
});
it("requires DATABASE_URL or DIRECT_URL", () => {
expect(() => resolveBackupConfig({ BACKUP_S3_BUCKET: "b" })).toThrow(BackupConfigError);
});
it("requires BACKUP_AGE_PUBLIC_KEY when encryption=age (default)", () => {
expect(() =>
resolveBackupConfig({ BACKUP_S3_BUCKET: "b", DATABASE_URL: "postgres://" }),
).toThrow(/BACKUP_AGE_PUBLIC_KEY/u);
});
it("defaults retention to 7/4/12 and method=age", () => {
const cfg = resolveBackupConfig({
BACKUP_S3_BUCKET: "b",
DATABASE_URL: "postgres://",
BACKUP_AGE_PUBLIC_KEY: "age1example",
});
expect(cfg.encryption.method).toBe("age");
expect(cfg.retention).toEqual({ daily: 7, weekly: 4, monthly: 12 });
expect(cfg.s3.region).toBe("us-east-1");
});
it("parses retention overrides and includeClickhouse", () => {
const cfg = resolveBackupConfig({
BACKUP_S3_BUCKET: "b",
DATABASE_URL: "x",
BACKUP_ENCRYPTION: "none",
BACKUP_RETENTION_DAILY: "14",
BACKUP_RETENTION_WEEKLY: "8",
BACKUP_RETENTION_MONTHLY: "24",
BACKUP_INCLUDE_CLICKHOUSE: "true",
});
expect(cfg.encryption.method).toBe("none");
expect(cfg.retention).toEqual({ daily: 14, weekly: 8, monthly: 24 });
expect(cfg.includeClickhouse).toBe(true);
});
it("rejects unknown encryption methods", () => {
expect(() =>
resolveBackupConfig({ BACKUP_S3_BUCKET: "b", DATABASE_URL: "x", BACKUP_ENCRYPTION: "rot13" }),
).toThrow(/Invalid BACKUP_ENCRYPTION/u);
});
it("passes through S3 credentials when both set", () => {
const cfg = resolveBackupConfig({
BACKUP_S3_BUCKET: "b",
DATABASE_URL: "x",
BACKUP_ENCRYPTION: "none",
BACKUP_S3_ACCESS_KEY_ID: "AKIA",
BACKUP_S3_SECRET_ACCESS_KEY: "secret",
BACKUP_S3_FORCE_PATH_STYLE: "true",
});
expect(cfg.s3.credentials).toBeDefined();
expect(cfg.s3.forcePathStyle).toBe(true);
});
});
@@ -0,0 +1,109 @@
/**
* Centralised resolver for backup-cli environment variables. Kept in one place
* so the create/list/restore/verify/rotate commands all use the exact same
* env-var contract and so unit tests can construct an isolated env object.
*/
import type { S3ClientConfig } from "@aws-sdk/client-s3";
import type { EncryptionConfig, EncryptionMethod } from "./encryption.js";
export interface BackupEnv {
BACKUP_S3_ENDPOINT?: string;
BACKUP_S3_REGION?: string;
BACKUP_S3_BUCKET?: string;
BACKUP_S3_ACCESS_KEY_ID?: string;
BACKUP_S3_SECRET_ACCESS_KEY?: string;
BACKUP_S3_SESSION_TOKEN?: string;
BACKUP_S3_FORCE_PATH_STYLE?: string;
BACKUP_ENCRYPTION?: string;
BACKUP_AGE_PUBLIC_KEY?: string;
BACKUP_AGE_IDENTITY_FILE?: string;
BACKUP_GPG_RECIPIENT?: string;
BACKUP_RETENTION_DAILY?: string;
BACKUP_RETENTION_WEEKLY?: string;
BACKUP_RETENTION_MONTHLY?: string;
BACKUP_INCLUDE_CLICKHOUSE?: string;
DATABASE_URL?: string;
DIRECT_URL?: string;
STORAGE_BUCKET?: string;
}
export interface ResolvedBackupConfig {
s3: S3ClientConfig;
backupBucket: string;
appStorageBucket: string | null;
databaseUrl: string;
encryption: EncryptionConfig;
retention: {
daily: number;
weekly: number;
monthly: number;
};
includeClickhouse: boolean;
}
export class BackupConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "BackupConfigError";
}
}
export function resolveBackupConfig(env: BackupEnv): ResolvedBackupConfig {
const bucket = env.BACKUP_S3_BUCKET;
if (!bucket) {
throw new BackupConfigError("BACKUP_S3_BUCKET is required.");
}
const databaseUrl = env.DIRECT_URL ?? env.DATABASE_URL;
if (!databaseUrl) {
throw new BackupConfigError("DIRECT_URL or DATABASE_URL is required to dump Postgres.");
}
const encryptionMethod = (env.BACKUP_ENCRYPTION ?? "age") as EncryptionMethod;
if (encryptionMethod !== "age" && encryptionMethod !== "gpg" && encryptionMethod !== "none") {
throw new BackupConfigError(`Invalid BACKUP_ENCRYPTION=${env.BACKUP_ENCRYPTION}; expected age|gpg|none.`);
}
const encryption: EncryptionConfig = {
method: encryptionMethod,
recipient: env.BACKUP_AGE_PUBLIC_KEY,
identityFile: env.BACKUP_AGE_IDENTITY_FILE,
gpgRecipient: env.BACKUP_GPG_RECIPIENT,
};
if (encryptionMethod === "age" && !encryption.recipient) {
throw new BackupConfigError("BACKUP_AGE_PUBLIC_KEY is required when BACKUP_ENCRYPTION=age.");
}
const s3: S3ClientConfig = {
region: env.BACKUP_S3_REGION ?? "us-east-1",
endpoint: env.BACKUP_S3_ENDPOINT,
forcePathStyle: env.BACKUP_S3_FORCE_PATH_STYLE === "true",
};
if (env.BACKUP_S3_ACCESS_KEY_ID && env.BACKUP_S3_SECRET_ACCESS_KEY) {
s3.credentials = {
accessKeyId: env.BACKUP_S3_ACCESS_KEY_ID,
secretAccessKey: env.BACKUP_S3_SECRET_ACCESS_KEY,
sessionToken: env.BACKUP_S3_SESSION_TOKEN,
};
}
return {
s3,
backupBucket: bucket,
appStorageBucket: env.STORAGE_BUCKET ?? null,
databaseUrl,
encryption,
retention: {
daily: parseIntOrDefault(env.BACKUP_RETENTION_DAILY, 7),
weekly: parseIntOrDefault(env.BACKUP_RETENTION_WEEKLY, 4),
monthly: parseIntOrDefault(env.BACKUP_RETENTION_MONTHLY, 12),
},
includeClickhouse: env.BACKUP_INCLUDE_CLICKHOUSE === "true",
};
}
function parseIntOrDefault(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
@@ -0,0 +1,20 @@
import { defineCommand } from "citty";
import { createBackupCommand } from "./create.js";
import { listBackupCommand } from "./list.js";
import { restoreBackupCommand } from "./restore.js";
import { verifyBackupCommand } from "./verify.js";
import { rotateBackupCommand } from "./rotate.js";
export const backupCommand = defineCommand({
meta: {
name: "backup",
description: "Postgres + Storage backup, restore, verify, and rotation.",
},
subCommands: {
create: createBackupCommand,
list: listBackupCommand,
restore: restoreBackupCommand,
verify: verifyBackupCommand,
rotate: rotateBackupCommand,
},
});
@@ -0,0 +1,52 @@
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { mockClient } from "aws-sdk-client-mock";
import { beforeEach, describe, expect, it } from "vitest";
import { listBackups } from "./list.js";
const s3Mock = mockClient(S3Client as never);
beforeEach(() => {
s3Mock.reset();
});
describe("listBackups", () => {
it("classifies backups by encoded ISO timestamp", async () => {
s3Mock.on(ListObjectsV2Command as never).resolves({
Contents: [
// Sunday 2026-05-17
{ Key: "backup-20260517T030000Z-aabbccdd.tar.gz.age", Size: 100, LastModified: new Date("2026-05-17T03:00:00Z") },
// Monday 2026-06-01
{ Key: "backup-20260601T030000Z-11223344.tar.gz", Size: 200, LastModified: new Date("2026-06-01T03:00:00Z") },
],
IsTruncated: false,
} as never);
const result = await listBackups({
env: {
BACKUP_S3_BUCKET: "bkp",
DATABASE_URL: "x",
BACKUP_ENCRYPTION: "none",
} as never,
client: new S3Client({ region: "us-east-1" }),
});
expect(result).toHaveLength(2);
expect(result[0].classification).toEqual(["daily", "weekly"]);
expect(result[0].encryption).toBe("age");
expect(result[1].classification).toEqual(["daily", "monthly"]);
expect(result[1].encryption).toBe("none");
});
it("falls back to LastModified for unparsable keys", async () => {
s3Mock.on(ListObjectsV2Command as never).resolves({
Contents: [{ Key: "backup-weird-name.tar.gz", Size: 1, LastModified: new Date("2026-05-18T00:00:00Z") }],
IsTruncated: false,
} as never);
const result = await listBackups({
env: { BACKUP_S3_BUCKET: "bkp", DATABASE_URL: "x", BACKUP_ENCRYPTION: "none" } as never,
client: new S3Client({ region: "us-east-1" }),
});
expect(result[0].classification).toEqual(["daily"]);
});
});
@@ -0,0 +1,90 @@
import { defineCommand } from "citty";
import consola from "consola";
import { S3Client } from "@aws-sdk/client-s3";
import { listBackupKeys, type S3KeyEntry } from "./s3-upload.js";
import { resolveBackupConfig, type BackupEnv } from "./env.js";
export interface ListBackupsOptions {
env?: BackupEnv;
client?: S3Client;
}
export interface BackupEntry extends S3KeyEntry {
/** "daily" | "weekly" | "monthly" derived from the embedded ISO timestamp. */
classification: readonly string[];
encryption: "age" | "gpg" | "none" | "unknown";
}
const BACKUP_KEY_RE = /^backup-(\d{4})-?(\d{2})-?(\d{2})T(\d{2})(\d{2})(\d{2})Z?-?[0-9a-f]{4,}\.tar\.gz(\.(age|gpg))?$/iu;
const BACKUP_KEY_FALLBACK_RE = /\.(age|gpg)$/iu;
export async function listBackups(opts: ListBackupsOptions = {}): Promise<BackupEntry[]> {
const config = resolveBackupConfig(opts.env ?? (process.env as unknown as BackupEnv));
const client = opts.client ?? new S3Client(config.s3);
const raw = await listBackupKeys({ client, bucket: config.backupBucket });
return raw.map((entry) => ({
...entry,
classification: classifyKey(entry.key, entry.lastModified),
encryption: detectEncryption(entry.key),
}));
}
function classifyKey(key: string, lastModified: Date | null): readonly string[] {
const match = BACKUP_KEY_RE.exec(key);
let date: Date | null = null;
if (match) {
date = new Date(
Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
),
);
} else if (lastModified) {
date = lastModified;
}
if (!date) return ["unknown"];
const out: string[] = ["daily"];
if (date.getUTCDay() === 0) out.push("weekly");
if (date.getUTCDate() === 1) out.push("monthly");
return out;
}
function detectEncryption(key: string): BackupEntry["encryption"] {
const m = BACKUP_KEY_FALLBACK_RE.exec(key);
if (!m) return "none";
const suffix = m[1].toLowerCase();
return suffix === "age" || suffix === "gpg" ? suffix : "unknown";
}
export const listBackupCommand = defineCommand({
meta: {
name: "list",
description: "List backups stored in the backup S3 bucket.",
},
async run() {
const entries = await listBackups();
if (entries.length === 0) {
consola.info("No backups found.");
return;
}
consola.box(
[
"KEY".padEnd(60) + " | " + "SIZE".padStart(10) + " | " + "CLASS".padEnd(20) + " | ENC",
...entries.map(
(e) =>
e.key.padEnd(60) +
" | " +
String(e.size).padStart(10) +
" | " +
e.classification.join(",").padEnd(20) +
" | " +
e.encryption,
),
].join("\n"),
);
},
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
BACKUP_MANIFEST_VERSION,
BackupManifestSchema,
classifyBackup,
deserializeManifest,
serializeManifest,
sha256Hex,
type BackupManifest,
} from "./manifest.js";
const baseManifest: BackupManifest = {
schemaVersion: BACKUP_MANIFEST_VERSION,
backupId: "backup-2026-05-17T03:00:00Z-abcd1234",
createdAt: "2026-05-17T03:00:00.000Z",
creator: { cliVersion: "0.0.0", host: "ops-jumphost" },
classification: ["daily"],
components: {
postgres: {
path: "postgres/dump.sql.gz",
sizeBytes: 1024,
checksum: { algorithm: "sha256", value: "a".repeat(64) },
},
storage: {
path: "storage/objects.tar",
sizeBytes: 2048,
checksum: { algorithm: "sha256", value: "b".repeat(64) },
objectCount: 7,
},
},
encryption: { method: "age", recipient: "age1example..." },
};
describe("backup manifest schema", () => {
it("round-trips a valid manifest", () => {
const serialized = serializeManifest(baseManifest);
expect(serialized.endsWith("\n")).toBe(true);
const parsed = deserializeManifest(serialized);
expect(parsed).toEqual(baseManifest);
});
it("rejects a manifest with an unsupported encryption method", () => {
const invalid = { ...baseManifest, encryption: { method: "rot13" } } as unknown;
expect(() => BackupManifestSchema.parse(invalid)).toThrow();
});
it("rejects unknown top-level keys (strict)", () => {
const invalid = { ...baseManifest, oops: true } as unknown;
expect(() => BackupManifestSchema.parse(invalid)).toThrow();
});
it("rejects an invalid checksum value", () => {
const invalid: BackupManifest = {
...baseManifest,
components: {
...baseManifest.components,
postgres: {
path: "postgres/dump.sql.gz",
sizeBytes: 1024,
checksum: { algorithm: "sha256", value: "not-hex" },
},
},
};
expect(() => BackupManifestSchema.parse(invalid)).toThrow();
});
it("sha256Hex returns lowercase hex of expected length", () => {
const hex = sha256Hex("hello world");
expect(hex).toHaveLength(64);
expect(hex).toMatch(/^[a-f0-9]{64}$/u);
});
it("classifyBackup adds weekly on Sundays and monthly on the 1st", () => {
// 2026-05-17 is a Sunday → daily + weekly
expect(classifyBackup("2026-05-17T03:00:00Z")).toEqual(["daily", "weekly"]);
// 2026-06-01 is a Monday → daily + monthly
expect(classifyBackup("2026-06-01T03:00:00Z")).toEqual(["daily", "monthly"]);
// 2026-03-01 is a Sunday → daily + weekly + monthly
expect(classifyBackup("2026-03-01T03:00:00Z")).toEqual(["daily", "weekly", "monthly"]);
// 2026-05-18 is a Monday → only daily
expect(classifyBackup("2026-05-18T03:00:00Z")).toEqual(["daily"]);
});
});
@@ -0,0 +1,85 @@
import { createHash } from "node:crypto";
import { z } from "zod";
export const BACKUP_MANIFEST_VERSION = 1;
export const ComponentChecksumSchema = z
.object({
algorithm: z.literal("sha256"),
value: z.string().regex(/^[a-f0-9]{64}$/u, "expected lowercase hex sha256"),
})
.strict();
export const ComponentEntrySchema = z
.object({
path: z.string().min(1),
sizeBytes: z.number().int().nonnegative(),
checksum: ComponentChecksumSchema,
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
})
.strict();
export const EncryptionMetadataSchema = z
.object({
method: z.enum(["age", "gpg", "none"]),
recipient: z.string().optional(),
keyFingerprint: z.string().optional(),
})
.strict();
export const BackupManifestSchema = z
.object({
schemaVersion: z.literal(BACKUP_MANIFEST_VERSION),
backupId: z.string().min(1),
createdAt: z.string().datetime({ offset: false }),
creator: z
.object({
cliVersion: z.string().min(1),
host: z.string().optional(),
})
.strict(),
classification: z.enum(["daily", "weekly", "monthly"]).array().nonempty(),
components: z
.object({
postgres: ComponentEntrySchema.optional(),
clickhouse: ComponentEntrySchema.optional(),
storage: ComponentEntrySchema.extend({ objectCount: z.number().int().nonnegative() }).optional(),
metadata: ComponentEntrySchema.optional(),
})
.strict(),
encryption: EncryptionMetadataSchema,
notes: z.string().optional(),
})
.strict();
export type ComponentChecksum = z.infer<typeof ComponentChecksumSchema>;
export type ComponentEntry = z.infer<typeof ComponentEntrySchema>;
export type EncryptionMetadata = z.infer<typeof EncryptionMetadataSchema>;
export type BackupManifest = z.infer<typeof BackupManifestSchema>;
export function serializeManifest(manifest: BackupManifest): string {
const parsed = BackupManifestSchema.parse(manifest);
return `${JSON.stringify(parsed, null, 2)}\n`;
}
export function deserializeManifest(raw: string | Buffer): BackupManifest {
const text = typeof raw === "string" ? raw : raw.toString("utf8");
const data = JSON.parse(text) as unknown;
return BackupManifestSchema.parse(data);
}
export function sha256Hex(input: Buffer | string): string {
return createHash("sha256").update(input).digest("hex");
}
export function classifyBackup(createdAtIso: string): BackupManifest["classification"] {
const date = new Date(createdAtIso);
const out: BackupManifest["classification"][number][] = ["daily"];
if (date.getUTCDay() === 0) {
out.push("weekly");
}
if (date.getUTCDate() === 1) {
out.push("monthly");
}
return out as BackupManifest["classification"];
}
@@ -0,0 +1,102 @@
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { gunzipSync } from "node:zlib";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { dumpPostgresToFile, PostgresDumpError, type SpawnLike } from "./postgres.js";
interface FakeChild extends EventEmitter {
stdout: PassThrough;
stderr: PassThrough;
}
function makeFakeChild(): FakeChild {
const emitter = new EventEmitter() as FakeChild;
emitter.stdout = new PassThrough();
emitter.stderr = new PassThrough();
return emitter;
}
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "cli-pgdump-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("dumpPostgresToFile", () => {
it("pipes pg_dump stdout through gzip into the target file and returns size+checksum", async () => {
const child = makeFakeChild();
const spawnFn: SpawnLike = ((_cmd: string, args: readonly string[]) => {
// sanity: the connection string flag must be at the end
expect(args).toContain("--dbname");
expect(args[args.length - 1]).toBe("postgres://example/db");
queueMicrotask(() => {
Readable.from(["PGDUMP_PAYLOAD_ABCDEF"]).pipe(child.stdout);
child.stdout.once("end", () => {
child.emit("close", 0);
});
});
return child as unknown as ReturnType<SpawnLike>;
}) as unknown as SpawnLike;
const outPath = join(workDir, "dump.sql.gz");
const result = await dumpPostgresToFile({
connectionString: "postgres://example/db",
outPath,
spawnFn,
});
expect(result.sizeBytes).toBeGreaterThan(0);
expect(result.checksum.algorithm).toBe("sha256");
expect(result.checksum.value).toMatch(/^[a-f0-9]{64}$/u);
const decompressed = gunzipSync(await readFile(outPath)).toString("utf8");
expect(decompressed).toBe("PGDUMP_PAYLOAD_ABCDEF");
});
it("throws PostgresDumpError with stderr when pg_dump exits non-zero", async () => {
const child = makeFakeChild();
const spawnFn: SpawnLike = (() => {
queueMicrotask(() => {
child.stderr.write("FATAL: connection failed\n");
child.stderr.end();
child.stdout.end();
child.emit("close", 2);
});
return child as unknown as ReturnType<SpawnLike>;
}) as unknown as SpawnLike;
await expect(
dumpPostgresToFile({
connectionString: "postgres://bad/db",
outPath: join(workDir, "dump.sql.gz"),
spawnFn,
}),
).rejects.toBeInstanceOf(PostgresDumpError);
});
it("propagates spawn errors", async () => {
const child = makeFakeChild();
const spawnFn: SpawnLike = (() => {
queueMicrotask(() => {
child.stdout.end();
child.emit("error", new Error("spawn ENOENT"));
});
return child as unknown as ReturnType<SpawnLike>;
}) as unknown as SpawnLike;
await expect(
dumpPostgresToFile({
connectionString: "postgres://example/db",
outPath: join(workDir, "dump.sql.gz"),
spawnFn,
}),
).rejects.toThrow(/ENOENT/);
});
});
@@ -0,0 +1,115 @@
import { spawn, type ChildProcessByStdio, type SpawnOptions } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { stat } from "node:fs/promises";
import type { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
import { sha256Hex } from "./manifest.js";
/**
* Lightweight contract for the `child_process.spawn` builder we use here. Pulled
* out so tests can inject a stub spawner without depending on real binaries.
*/
export interface SpawnLike {
(command: string, args: readonly string[], options: SpawnOptions): ChildProcessByStdio<null, Readable, Readable>;
}
export interface DumpPostgresOptions {
/** Postgres connection string passed via `pg_dump --dbname`. */
connectionString: string;
/** Absolute path to the gzipped dump destination. */
outPath: string;
/** Extra args (e.g. `--schema=public`). Defaults to `["--format=custom", "--no-owner", "--no-privileges"]`. */
extraArgs?: readonly string[];
/** Override the pg_dump binary location. Defaults to `pg_dump`. */
binary?: string;
/** Override spawn (tests). */
spawnFn?: SpawnLike;
}
export interface DumpPostgresResult {
/** Size of the gzipped dump on disk. */
sizeBytes: number;
/** sha256 of the gzipped dump on disk. */
checksum: { algorithm: "sha256"; value: string };
}
export class PostgresDumpError extends Error {
readonly exitCode: number | null;
readonly stderr: string;
constructor(message: string, exitCode: number | null, stderr: string) {
super(message);
this.name = "PostgresDumpError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
const DEFAULT_PG_DUMP_ARGS = ["--format=custom", "--no-owner", "--no-privileges"] as const;
/**
* Dumps a Postgres database via `pg_dump`, piping stdout through gzip into the
* given file. Computes the size + sha256 of the resulting gzipped file.
*
* Throws PostgresDumpError on non-zero exit (with captured stderr).
*/
export async function dumpPostgresToFile(opts: DumpPostgresOptions): Promise<DumpPostgresResult> {
const spawnFn = opts.spawnFn ?? (spawn as unknown as SpawnLike);
const binary = opts.binary ?? "pg_dump";
const args = [...(opts.extraArgs ?? DEFAULT_PG_DUMP_ARGS), "--dbname", opts.connectionString];
const child = spawnFn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
const stderrChunks: Buffer[] = [];
child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
const gzip = createGzip();
const sink = createWriteStream(opts.outPath);
const hash = createHash("sha256");
child.stdout.on("data", (chunk: Buffer) => hash.update(chunk));
const pipePromise = pipeline(child.stdout, gzip, sink);
const exitPromise = new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code));
});
// Always observe exitPromise so a rejection (spawn error) before we await it
// does not surface as an unhandled rejection. We re-await below.
exitPromise.catch(() => undefined);
// Order matters: stdout must finish flushing before we resolve, then exit code is authoritative.
const [, exitCode] = await Promise.all([pipePromise, exitPromise]);
if (exitCode !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf8");
throw new PostgresDumpError(`pg_dump exited with code ${exitCode ?? "<null>"}`, exitCode, stderr);
}
const sourceChecksum = hash.digest("hex");
// Stat for size; recompute hash on disk to cover gzip determinism for the manifest contract.
const fileStat = await stat(opts.outPath);
const fileChecksum = await sha256File(opts.outPath);
return {
sizeBytes: fileStat.size,
// Compute checksum of the gzipped file on disk — this is what the manifest references.
// (sourceChecksum is the pre-gzip stream checksum, retained internally for future use.)
checksum: { algorithm: "sha256", value: fileChecksum || sourceChecksum },
};
}
async function sha256File(path: string): Promise<string> {
const { createReadStream } = await import("node:fs");
const hash = createHash("sha256");
const stream = createReadStream(path);
for await (const chunk of stream) {
hash.update(chunk as Buffer);
}
return hash.digest("hex");
}
/** Re-export for test convenience and to keep the manifest contract single-sourced. */
export const computeStringSha256 = sha256Hex;
@@ -0,0 +1,78 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { pgRestore } from "./restore.js";
describe("pgRestore", () => {
it("invokes pg_restore with --clean/--if-exists and the dump path last", async () => {
let captured: readonly string[] = [];
const spawnFn = ((cmd: string, args: readonly string[]) => {
void cmd;
captured = args;
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
queueMicrotask(() => {
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
await pgRestore({
connectionString: "postgres://example/db",
dumpPath: "/tmp/dump.sql.gz",
spawnFn,
});
expect(captured).toContain("--clean");
expect(captured).toContain("--if-exists");
expect(captured[captured.length - 1]).toBe("/tmp/dump.sql.gz");
});
it("forwards --schema when schemaOnly is set", async () => {
let captured: readonly string[] = [];
const spawnFn = ((cmd: string, args: readonly string[]) => {
void cmd;
captured = args;
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
queueMicrotask(() => {
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
await pgRestore({
connectionString: "postgres://example/db",
dumpPath: "/tmp/dump.sql.gz",
schemaOnly: "verify_abc",
spawnFn,
});
expect(captured).toContain("--schema");
expect(captured).toContain("verify_abc");
});
it("throws with stderr on non-zero exit", async () => {
const spawnFn = ((cmd: string) => {
void cmd;
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough };
child.stdout = new PassThrough();
child.stderr = new PassThrough();
queueMicrotask(() => {
child.stderr.write("FATAL: missing dump");
child.stderr.end();
child.stdout.end();
child.emit("close", 1);
});
return child as never;
}) as unknown as typeof import("node:child_process").spawn;
await expect(pgRestore({ connectionString: "x", dumpPath: "y", spawnFn })).rejects.toThrow(/missing dump|exited/);
});
});
@@ -0,0 +1,157 @@
import { defineCommand } from "citty";
import consola from "consola";
import { spawn, type SpawnOptions } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extract as extractTar } from "tar";
import { S3Client } from "@aws-sdk/client-s3";
import { downloadObjectToFile } from "./s3-upload.js";
import { decryptFile } from "./encryption.js";
import { deserializeManifest, type BackupManifest } from "./manifest.js";
import { resolveBackupConfig, type BackupEnv } from "./env.js";
export interface RunRestoreOptions {
/** S3 key (e.g. `backup-2026-...tar.gz.age`) or just a backup id (the CLI prefixes/suffixes). */
snapshot: string;
env?: BackupEnv;
client?: S3Client;
/** Override the target Postgres DSN; defaults to env.DIRECT_URL / DATABASE_URL. */
targetDatabaseUrl?: string;
/** Skip restoring storage objects. */
skipStorage?: boolean;
/** Override pg_restore binary (tests). */
pgRestoreBinary?: string;
/** Spawn override for tests. */
spawnFn?: typeof spawn;
}
export interface RunRestoreResult {
manifest: BackupManifest;
archivePath: string;
}
export async function runRestoreBackup(opts: RunRestoreOptions): Promise<RunRestoreResult> {
const env = opts.env ?? (process.env as unknown as BackupEnv);
const config = resolveBackupConfig(env);
const client = opts.client ?? new S3Client(config.s3);
const tempRoot = await mkdtemp(join(tmpdir(), "backup-restore-"));
try {
const downloadPath = join(tempRoot, "download.bin");
consola.info(`Downloading s3://${config.backupBucket}/${opts.snapshot}`);
await downloadObjectToFile({ client, bucket: config.backupBucket, key: opts.snapshot, destPath: downloadPath });
const decryptedPath = join(tempRoot, "archive.tar.gz");
if (config.encryption.method !== "none") {
consola.info(`Decrypting via ${config.encryption.method}`);
await decryptFile({ config: config.encryption, inputPath: downloadPath, outputPath: decryptedPath });
} else {
await rename(downloadPath, decryptedPath);
}
const extractDir = join(tempRoot, "extracted");
await mkdir(extractDir, { recursive: true });
await extractTar({ file: decryptedPath, cwd: extractDir });
const entries = await readdir(extractDir);
if (entries.length !== 1) {
throw new Error(`Unexpected archive layout; got ${entries.length} top-level entries`);
}
const stageDir = join(extractDir, entries[0]);
const manifestRaw = await readFile(join(stageDir, "manifest.json"), "utf8");
const manifest = deserializeManifest(manifestRaw);
const dbUrl = opts.targetDatabaseUrl ?? config.databaseUrl;
if (manifest.components.postgres) {
consola.info("Restoring Postgres dump");
await pgRestore({
connectionString: dbUrl,
dumpPath: join(stageDir, manifest.components.postgres.path),
binary: opts.pgRestoreBinary,
spawnFn: opts.spawnFn,
});
}
if (manifest.components.storage && !opts.skipStorage) {
// Storage restore is operator-driven via re-upload from the staged tar.
// Implementation lives in storage.ts in a follow-up if/when staging
// verifies the create flow against a real bucket. For now we surface
// a clear hint so the operator can manually re-import.
consola.warn(
`Storage tar at ${join(stageDir, manifest.components.storage.path)} not auto-uploaded; re-upload via aws s3 sync or use --target-bucket in a future revision.`,
);
}
return { manifest, archivePath: decryptedPath };
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
}
async function rename(from: string, to: string): Promise<void> {
const fs = await import("node:fs/promises");
await fs.rename(from, to);
}
interface PgRestoreArgs {
connectionString: string;
dumpPath: string;
binary?: string;
spawnFn?: typeof spawn;
/** Optional `--schema` override used by `backup:verify` to isolate the restore. */
schemaOnly?: string;
}
export async function pgRestore(args: PgRestoreArgs): Promise<void> {
const spawner = args.spawnFn ?? spawn;
const binary = args.binary ?? "pg_restore";
const cliArgs = ["--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", args.connectionString];
if (args.schemaOnly) {
cliArgs.push("--schema", args.schemaOnly);
}
cliArgs.push(args.dumpPath);
const spawnOptions: SpawnOptions = { stdio: ["ignore", "pipe", "pipe"] };
const child = spawner(binary, cliArgs, spawnOptions);
const stderrChunks: Buffer[] = [];
child.stderr?.on("data", (c: Buffer) => stderrChunks.push(c));
const exitCode: number | null = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code: number | null) => resolve(code));
});
if (exitCode !== 0) {
throw new Error(`pg_restore exited with code ${exitCode ?? "<null>"}: ${Buffer.concat(stderrChunks).toString("utf8")}`);
}
}
export const restoreBackupCommand = defineCommand({
meta: {
name: "restore",
description: "Restore Postgres + Storage from a backup archive in S3.",
},
args: {
snapshot: { type: "positional", required: true, description: "S3 key of the backup archive." },
yes: { type: "boolean", description: "Skip confirmation prompt.", default: false },
"skip-storage": { type: "boolean", description: "Do not restore storage objects.", default: false },
"target-db": { type: "string", description: "Override Postgres DSN for the restore." },
},
async run({ args }) {
if (!args.yes) {
const confirmed = await consola.prompt(
`This will overwrite the target Postgres database with backup ${args.snapshot}. Continue?`,
{ type: "confirm" },
);
if (!confirmed) {
consola.warn("Restore cancelled.");
return;
}
}
const result = await runRestoreBackup({
snapshot: String(args.snapshot),
skipStorage: Boolean(args["skip-storage"]),
targetDatabaseUrl: args["target-db"] ? String(args["target-db"]) : undefined,
});
consola.success(`Restored ${result.manifest.backupId}.`);
},
});
@@ -0,0 +1,73 @@
import { S3Client } from "@aws-sdk/client-s3";
import { describe, expect, it } from "vitest";
import { rotateBackups } from "./rotate.js";
import type { BackupEntry } from "./list.js";
function makeEntry(date: string, classification: readonly string[]): BackupEntry {
const ts = date.replace(/[:.-]/gu, "").replace(/Z$/u, "Z");
return {
key: `backup-${ts}-aabb1234.tar.gz`,
size: 1,
lastModified: new Date(date),
etag: null,
classification,
encryption: "none",
};
}
const env = {
BACKUP_S3_BUCKET: "bkp",
DATABASE_URL: "x",
BACKUP_ENCRYPTION: "none",
BACKUP_RETENTION_DAILY: "3",
BACKUP_RETENTION_WEEKLY: "2",
BACKUP_RETENTION_MONTHLY: "1",
} as never;
describe("rotateBackups", () => {
it("keeps top-N per classification and marks the rest for deletion", async () => {
const entries: BackupEntry[] = [
// 90 daily backups, every Sunday is also weekly, every 1st is also monthly.
...Array.from({ length: 90 }, (_, i) => {
const date = new Date(Date.UTC(2026, 1, 1));
date.setUTCDate(date.getUTCDate() + i);
const classes: string[] = ["daily"];
if (date.getUTCDay() === 0) classes.push("weekly");
if (date.getUTCDate() === 1) classes.push("monthly");
return makeEntry(date.toISOString(), classes);
}),
];
const result = await rotateBackups({
env,
client: new S3Client({ region: "us-east-1" }),
entries,
dryRun: true,
});
// We expect: 3 daily + 2 weekly + 1 monthly slots; overlap allowed so unique
// kept-keys are at most 3+2+1.
expect(result.keptKeys.length).toBeGreaterThanOrEqual(3);
expect(result.keptKeys.length).toBeLessThanOrEqual(3 + 2 + 1);
// No double-count and no overlap between keep and delete.
const keep = new Set(result.keptKeys);
for (const k of result.deletedKeys) {
expect(keep.has(k)).toBe(false);
}
// Total accounted for equals input size.
expect(result.keptKeys.length + result.deletedKeys.length).toBe(entries.length);
});
it("dry-run does not invoke DeleteObjects", async () => {
const entries = [makeEntry("2026-05-17T03:00:00.000Z", ["daily", "weekly"])];
const result = await rotateBackups({
env,
client: new S3Client({ region: "us-east-1" }),
entries,
dryRun: true,
});
// Only one entry, well within limits → kept.
expect(result.deletedKeys).toHaveLength(0);
});
});
@@ -0,0 +1,90 @@
import { defineCommand } from "citty";
import consola from "consola";
import { S3Client } from "@aws-sdk/client-s3";
import { listBackups, type BackupEntry } from "./list.js";
import { deleteBackupKeys } from "./s3-upload.js";
import { resolveBackupConfig, type BackupEnv } from "./env.js";
export interface RotateBackupsOptions {
env?: BackupEnv;
client?: S3Client;
dryRun?: boolean;
/** Pre-fetched entries (tests). */
entries?: readonly BackupEntry[];
}
export interface RotateBackupsResult {
keptKeys: string[];
deletedKeys: string[];
}
/**
* Apply daily/weekly/monthly retention.
*
* Each backup is classified by its embedded ISO timestamp. We keep up to N
* most-recent entries per classification (independently) and delete everything
* else. This means a Sunday-on-the-1st backup can be retained as both weekly
* and monthly even if outside the daily window.
*/
export async function rotateBackups(opts: RotateBackupsOptions = {}): Promise<RotateBackupsResult> {
const config = resolveBackupConfig(opts.env ?? (process.env as unknown as BackupEnv));
const client = opts.client ?? new S3Client(config.s3);
const entries = opts.entries ?? (await listBackups({ env: opts.env, client }));
const sorted = [...entries].sort((a, b) => {
const ta = a.lastModified?.getTime() ?? 0;
const tb = b.lastModified?.getTime() ?? 0;
return tb - ta;
});
const keep = new Set<string>();
const byClass: Record<string, BackupEntry[]> = { daily: [], weekly: [], monthly: [] };
for (const entry of sorted) {
for (const cls of entry.classification) {
if (cls in byClass) {
byClass[cls].push(entry);
}
}
}
const limits = { daily: config.retention.daily, weekly: config.retention.weekly, monthly: config.retention.monthly };
for (const cls of ["daily", "weekly", "monthly"] as const) {
for (const entry of byClass[cls].slice(0, limits[cls])) {
keep.add(entry.key);
}
}
const deletedKeys: string[] = [];
for (const entry of sorted) {
if (!keep.has(entry.key)) {
deletedKeys.push(entry.key);
}
}
if (!opts.dryRun && deletedKeys.length > 0) {
await deleteBackupKeys({ client, bucket: config.backupBucket, keys: deletedKeys });
}
return {
keptKeys: [...keep],
deletedKeys,
};
}
export const rotateBackupCommand = defineCommand({
meta: {
name: "rotate",
description: "Apply daily/weekly/monthly retention policy against the backup bucket.",
},
args: {
"dry-run": {
type: "boolean",
description: "Print the rotation plan without deleting anything.",
default: false,
},
},
async run({ args }) {
const result = await rotateBackups({ dryRun: Boolean(args["dry-run"]) });
consola.info(`Kept ${result.keptKeys.length} backups; ${args["dry-run"] ? "would delete" : "deleted"} ${result.deletedKeys.length}.`);
for (const k of result.deletedKeys) consola.log(` - ${k}`);
},
});
@@ -0,0 +1,115 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { mockClient } from "aws-sdk-client-mock";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deleteBackupKeys,
downloadObjectToFile,
getObjectRangeBuffer,
listBackupKeys,
uploadFileMultipart,
} from "./s3-upload.js";
const s3Mock = mockClient(S3Client as never);
let workDir: string;
beforeEach(async () => {
s3Mock.reset();
workDir = await mkdtemp(join(tmpdir(), "cli-s3-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("s3-upload helpers", () => {
it("uploads via injected Upload ctor (multipart wrapper)", async () => {
const src = join(workDir, "blob.bin");
await writeFile(src, "payload bytes");
let receivedKey = "";
class FakeUpload {
constructor(opts: { params: { Bucket: string; Key: string } }) {
receivedKey = opts.params.Key;
}
async done() {
return { ETag: '"deadbeef"' };
}
}
const result = await uploadFileMultipart({
client: new S3Client({ region: "us-east-1" }),
bucket: "bkp",
key: "backup-test.tar.gz",
filePath: src,
uploadCtor: FakeUpload as never,
});
expect(receivedKey).toBe("backup-test.tar.gz");
expect(result.etag).toBe('"deadbeef"');
});
it("downloads a backup object to disk via streamed Body", async () => {
s3Mock.on(GetObjectCommand as never).resolves({ Body: Readable.from(["chunk1", "chunk2"]) } as never);
const dest = join(workDir, "out.bin");
const result = await downloadObjectToFile({
client: new S3Client({ region: "us-east-1" }),
bucket: "bkp",
key: "any.tar.gz",
destPath: dest,
});
expect(result.sizeBytes).toBe("chunk1chunk2".length);
expect((await readFile(dest)).toString("utf8")).toBe("chunk1chunk2");
});
it("getObjectRangeBuffer concatenates streamed chunks", async () => {
s3Mock.on(GetObjectCommand as never).resolves({ Body: Readable.from(["abc", "def"]) } as never);
const buf = await getObjectRangeBuffer(new S3Client({ region: "us-east-1" }), "bkp", "x", "bytes=0-5");
expect(buf.toString("utf8")).toBe("abcdef");
});
it("listBackupKeys paginates with the backup- prefix by default", async () => {
let listCalls = 0;
s3Mock.on(ListObjectsV2Command as never).callsFake((input: { Prefix?: string }) => {
listCalls += 1;
expect(input.Prefix).toBe("backup-");
if (listCalls === 1) {
return {
Contents: [{ Key: "backup-a.tar.gz", Size: 10, LastModified: new Date("2026-05-17T00:00:00Z"), ETag: '"a"' }],
IsTruncated: true,
NextContinuationToken: "tok",
};
}
return {
Contents: [{ Key: "backup-b.tar.gz", Size: 20, LastModified: new Date("2026-05-18T00:00:00Z"), ETag: '"b"' }],
IsTruncated: false,
};
});
const out = await listBackupKeys({ client: new S3Client({ region: "us-east-1" }), bucket: "bkp" });
expect(out).toHaveLength(2);
expect(out[0].key).toBe("backup-a.tar.gz");
expect(out[1].key).toBe("backup-b.tar.gz");
});
it("deleteBackupKeys batches up to 1000 per DeleteObjects call", async () => {
let calls = 0;
s3Mock.on(DeleteObjectsCommand as never).callsFake(() => {
calls += 1;
return {};
});
const keys = Array.from({ length: 1500 }, (_, i) => `backup-${i}.tar.gz`);
const result = await deleteBackupKeys({ client: new S3Client({ region: "us-east-1" }), bucket: "bkp", keys });
expect(result.deletedCount).toBe(1500);
expect(calls).toBe(2);
});
it("deleteBackupKeys is a no-op for empty input", async () => {
const result = await deleteBackupKeys({ client: new S3Client({ region: "us-east-1" }), bucket: "bkp", keys: [] });
expect(result.deletedCount).toBe(0);
});
});
@@ -0,0 +1,174 @@
import {
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
S3Client,
type S3ClientConfig,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream, createWriteStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
export interface S3UploaderConfig extends S3ClientConfig {
bucket: string;
}
export interface UploadFileOptions {
client: S3Client;
bucket: string;
key: string;
filePath: string;
contentType?: string;
metadata?: Record<string, string>;
/** Override the lib-storage Upload class (tests). */
uploadCtor?: typeof Upload;
}
export interface DownloadFileOptions {
client: S3Client;
bucket: string;
key: string;
destPath: string;
}
export interface ListBackupKeysOptions {
client: S3Client;
bucket: string;
prefix?: string;
}
export interface DeleteKeysOptions {
client: S3Client;
bucket: string;
keys: readonly string[];
/** Page size; AWS allows up to 1000 keys per DeleteObjects call. */
batchSize?: number;
}
export interface S3KeyEntry {
key: string;
size: number;
lastModified: Date | null;
etag: string | null;
}
export async function uploadFileMultipart(opts: UploadFileOptions): Promise<{ key: string; etag: string | null }> {
const Ctor = opts.uploadCtor ?? Upload;
const stream = createReadStream(opts.filePath);
const upload = new Ctor({
client: opts.client,
params: {
Bucket: opts.bucket,
Key: opts.key,
Body: stream,
ContentType: opts.contentType,
Metadata: opts.metadata,
},
});
const result = (await upload.done()) as { ETag?: string };
return { key: opts.key, etag: result.ETag ?? null };
}
export async function downloadObjectToFile(opts: DownloadFileOptions): Promise<{ sizeBytes: number }> {
const response = await opts.client.send(new GetObjectCommand({ Bucket: opts.bucket, Key: opts.key }));
const body = response.Body;
const stream = asReadable(body);
const sink = createWriteStream(opts.destPath);
await new Promise<void>((resolve, reject) => {
stream.pipe(sink);
sink.on("finish", () => resolve());
sink.on("error", reject);
stream.on("error", reject);
});
const s = await stat(opts.destPath);
return { sizeBytes: s.size };
}
export async function getObjectRangeBuffer(
client: S3Client,
bucket: string,
key: string,
range: string,
): Promise<Buffer> {
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key, Range: range }));
const stream = asReadable(response.Body);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string | Uint8Array));
}
return Buffer.concat(chunks);
}
export async function headBackupObject(
client: S3Client,
bucket: string,
key: string,
): Promise<{ size: number | null; lastModified: Date | null; etag: string | null }> {
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return {
size: head.ContentLength ?? null,
lastModified: head.LastModified ?? null,
etag: head.ETag ?? null,
};
}
export async function listBackupKeys(opts: ListBackupKeysOptions): Promise<S3KeyEntry[]> {
const out: S3KeyEntry[] = [];
let continuationToken: string | undefined;
do {
const page = await opts.client.send(
new ListObjectsV2Command({
Bucket: opts.bucket,
Prefix: opts.prefix ?? "backup-",
ContinuationToken: continuationToken,
}),
);
for (const obj of page.Contents ?? []) {
if (!obj.Key) continue;
out.push({
key: obj.Key,
size: obj.Size ?? 0,
lastModified: obj.LastModified ?? null,
etag: obj.ETag ?? null,
});
}
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (continuationToken);
return out;
}
export async function deleteBackupKeys(opts: DeleteKeysOptions): Promise<{ deletedCount: number }> {
if (opts.keys.length === 0) {
return { deletedCount: 0 };
}
const batchSize = opts.batchSize ?? 1000;
let deletedCount = 0;
for (let i = 0; i < opts.keys.length; i += batchSize) {
const batch = opts.keys.slice(i, i + batchSize);
await opts.client.send(
new DeleteObjectsCommand({
Bucket: opts.bucket,
Delete: { Objects: batch.map((Key) => ({ Key })) },
}),
);
deletedCount += batch.length;
}
return { deletedCount };
}
function asReadable(body: unknown): Readable {
if (body && typeof (body as Readable).pipe === "function") {
return body as Readable;
}
if (body && typeof (body as { transformToWebStream?: () => unknown }).transformToWebStream === "function") {
const webStream = (body as { transformToWebStream: () => ReadableStream<Uint8Array> }).transformToWebStream();
return Readable.fromWeb(webStream as never);
}
if (body && Symbol.asyncIterator in (body as object)) {
return Readable.from(body as AsyncIterable<Buffer>);
}
throw new Error("Unsupported S3 GetObject body type");
}
@@ -0,0 +1,91 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { mockClient } from "aws-sdk-client-mock";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { backupStorageBucket } from "./storage.js";
// aws-sdk-client-mock's smithy peer can be a minor revision ahead of the rest of
// the resolved aws-sdk graph; we don't need the precise generic typing in tests.
const s3Mock = mockClient(S3Client as never);
let workDir: string;
beforeEach(async () => {
s3Mock.reset();
workDir = await mkdtemp(join(tmpdir(), "cli-storage-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("backupStorageBucket", () => {
it("lists, streams, tars and writes a JSONL manifest", async () => {
let listCallCount = 0;
s3Mock.on(ListObjectsV2Command as never).callsFake(() => {
listCallCount += 1;
if (listCallCount === 1) {
return {
Contents: [
{ Key: "a/one.txt", Size: 5, ETag: '"e1"' },
{ Key: "a/two.txt", Size: 6, ETag: '"e2"' },
],
IsTruncated: true,
NextContinuationToken: "tok",
};
}
return {
Contents: [{ Key: "b/three.txt", Size: 7, ETag: '"e3"' }],
IsTruncated: false,
};
});
const payloads: Record<string, string> = {
"a/one.txt": "hello",
"a/two.txt": "world!",
"b/three.txt": "bonjour",
};
s3Mock.on(GetObjectCommand as never).callsFake((input: { Key: string }) => ({
Body: Readable.from([payloads[input.Key]]),
}));
const result = await backupStorageBucket({
s3: { region: "us-east-1" },
bucket: "files",
outDir: workDir,
client: new S3Client({ region: "us-east-1" }),
});
expect(result.objectCount).toBe(3);
expect(result.totalBytes).toBe(5 + 6 + 7);
expect(result.checksum.algorithm).toBe("sha256");
expect(result.checksum.value).toMatch(/^[a-f0-9]{64}$/u);
const manifestRaw = await readFile(result.manifestPath, "utf8");
const lines = manifestRaw.trim().split("\n").map((line) => JSON.parse(line));
expect(lines).toHaveLength(3);
expect(lines[0]).toMatchObject({ bucket: "files", key: "a/one.txt", size: 5, etag: '"e1"' });
const archiveBytes = await readFile(result.archivePath);
expect(archiveBytes.length).toBeGreaterThan(0);
});
it("produces an empty manifest when the bucket has no objects", async () => {
s3Mock.on(ListObjectsV2Command as never).resolves({ Contents: [], IsTruncated: false } as never);
const result = await backupStorageBucket({
s3: { region: "us-east-1" },
bucket: "empty",
outDir: workDir,
client: new S3Client({ region: "us-east-1" }),
});
expect(result.objectCount).toBe(0);
expect(result.totalBytes).toBe(0);
const manifestRaw = await readFile(result.manifestPath, "utf8");
expect(manifestRaw).toBe("");
});
});
@@ -0,0 +1,155 @@
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { GetObjectCommand, ListObjectsV2Command, S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
import { create as createTar } from "tar";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { Readable } from "node:stream";
export interface StorageBackupOptions {
s3: S3ClientConfig;
bucket: string;
/** Filesystem directory where `objects.tar` + `manifest.jsonl` are written. */
outDir: string;
/** Optional S3 prefix to limit which objects are backed up. */
prefix?: string;
/** Inject a pre-built client (tests). */
client?: S3Client;
}
export interface StorageObjectEntry {
bucket: string;
key: string;
size: number;
etag: string | null;
}
export interface StorageBackupResult {
objectCount: number;
totalBytes: number;
manifestPath: string;
archivePath: string;
/** sha256 of the produced `objects.tar`. */
checksum: { algorithm: "sha256"; value: string };
}
/**
* Backs up every object in `bucket` to `<outDir>/objects.tar`, writing
* one NDJSON line per object to `<outDir>/manifest.jsonl`.
*/
export async function backupStorageBucket(opts: StorageBackupOptions): Promise<StorageBackupResult> {
const client = opts.client ?? new S3Client(opts.s3);
await mkdir(opts.outDir, { recursive: true });
const stagingDir = join(opts.outDir, "_staging");
await mkdir(stagingDir, { recursive: true });
const manifestPath = join(opts.outDir, "manifest.jsonl");
const archivePath = join(opts.outDir, "objects.tar");
const entries: StorageObjectEntry[] = [];
const relPaths: string[] = [];
let totalBytes = 0;
let continuationToken: string | undefined;
do {
const page = await client.send(
new ListObjectsV2Command({
Bucket: opts.bucket,
Prefix: opts.prefix,
ContinuationToken: continuationToken,
}),
);
for (const obj of page.Contents ?? []) {
if (!obj.Key) continue;
const entry: StorageObjectEntry = {
bucket: opts.bucket,
key: obj.Key,
size: obj.Size ?? 0,
etag: obj.ETag ?? null,
};
entries.push(entry);
totalBytes += entry.size;
const stagedPath = stageRelPath(obj.Key);
relPaths.push(stagedPath);
const absPath = join(stagingDir, stagedPath);
await mkdir(dirnameOf(absPath), { recursive: true });
const getResp = await client.send(new GetObjectCommand({ Bucket: opts.bucket, Key: obj.Key }));
await streamToFile(asReadable(getResp.Body), absPath);
}
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (continuationToken);
const manifestLines = entries.map((entry) => JSON.stringify(entry)).join("\n");
await writeFile(manifestPath, manifestLines.length === 0 ? "" : `${manifestLines}\n`);
if (relPaths.length === 0) {
// Write an empty placeholder so the tar file always exists for downstream checksumming.
const { open } = await import("node:fs/promises");
const handle = await open(archivePath, "w");
await handle.close();
} else {
await createTar({ file: archivePath, cwd: stagingDir, portable: true }, relPaths);
}
const checksum = await sha256File(archivePath);
try {
const { rm } = await import("node:fs/promises");
await rm(stagingDir, { recursive: true, force: true });
} catch {
/* ignore */
}
return {
objectCount: entries.length,
totalBytes,
manifestPath,
archivePath,
checksum: { algorithm: "sha256", value: checksum },
};
}
function stageRelPath(key: string): string {
return key.replace(/^\/+/u, "");
}
function dirnameOf(p: string): string {
const idx = p.lastIndexOf("/");
return idx === -1 ? "." : p.slice(0, idx);
}
function asReadable(body: unknown): Readable {
if (body && typeof (body as Readable).pipe === "function") {
return body as Readable;
}
if (body && typeof (body as { transformToWebStream?: () => unknown }).transformToWebStream === "function") {
const webStream = (body as { transformToWebStream: () => ReadableStream<Uint8Array> }).transformToWebStream();
return Readable.fromWeb(webStream as never);
}
if (body && Symbol.asyncIterator in (body as object)) {
return Readable.from(body as AsyncIterable<Buffer>);
}
throw new Error("Unsupported S3 GetObject body type");
}
async function streamToFile(stream: Readable, path: string): Promise<void> {
const sink = createWriteStream(path);
await new Promise<void>((resolve, reject) => {
stream.pipe(sink);
sink.on("finish", () => resolve());
sink.on("error", reject);
stream.on("error", reject);
});
}
async function sha256File(path: string): Promise<string> {
const { createReadStream } = await import("node:fs");
const hash = createHash("sha256");
const stream = createReadStream(path);
for await (const chunk of stream) {
hash.update(chunk as Buffer);
}
return hash.digest("hex");
}
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more