Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
Reference in new issue
Block a user