okengine 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -25
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.ts +20 -4
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +200 -20
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/build-lib.ts +9 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit sinks — secret-free stdout/webhook payloads, and chain commands
|
|
3
|
+
* that exist only for the `db` sink.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createAuditSink, type AuditEntry } from "./audit.ts";
|
|
8
|
+
import { buildVaultBootChain } from "./boot-chain.ts";
|
|
9
|
+
import { createBuiltinVaultAdapter, sqlConnectionAsExec } from "./builtin-adapter.ts";
|
|
10
|
+
import { isVaultError } from "./errors.ts";
|
|
11
|
+
import { createMemoryVaultSql } from "./test-helpers.ts";
|
|
12
|
+
|
|
13
|
+
const ENTRY: AuditEntry = {
|
|
14
|
+
action: "set",
|
|
15
|
+
path: "prod/api",
|
|
16
|
+
actorType: "flow",
|
|
17
|
+
actorId: "orders.place",
|
|
18
|
+
success: true,
|
|
19
|
+
requestId: "req-1",
|
|
20
|
+
at: new Date("2026-01-02T03:04:05.000Z"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SECRET = "sk_live_do_not_log_me";
|
|
24
|
+
|
|
25
|
+
describe("webhook audit sink", () => {
|
|
26
|
+
test("missing or non-http webhookUrl fails at construction", () => {
|
|
27
|
+
expect(() => createAuditSink("webhook")).toThrow(/webhookUrl/);
|
|
28
|
+
expect(
|
|
29
|
+
isVaultError(
|
|
30
|
+
catchError(() => createAuditSink("webhook")),
|
|
31
|
+
"MISSING_PEER",
|
|
32
|
+
),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
isVaultError(
|
|
36
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "/relative" })),
|
|
37
|
+
"MISSING_PEER",
|
|
38
|
+
),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
expect(
|
|
41
|
+
isVaultError(
|
|
42
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "ftp://audit.example/hook" })),
|
|
43
|
+
"MISSING_PEER",
|
|
44
|
+
),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("POSTs the stdout-shaped JSON, refuses redirects, and omits secret values", async () => {
|
|
49
|
+
const bodies: string[] = [];
|
|
50
|
+
const inits: RequestInit[] = [];
|
|
51
|
+
const sink = createAuditSink("webhook", {
|
|
52
|
+
webhookUrl: "https://audit.example/hook",
|
|
53
|
+
fetch: (async (_url, init) => {
|
|
54
|
+
inits.push(init ?? {});
|
|
55
|
+
bodies.push(String(init?.body ?? ""));
|
|
56
|
+
return new Response(null, { status: 204 });
|
|
57
|
+
}) as typeof fetch,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await sink.append(ENTRY);
|
|
61
|
+
|
|
62
|
+
expect(inits[0]?.method).toBe("POST");
|
|
63
|
+
expect(inits[0]?.redirect).toBe("error");
|
|
64
|
+
expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
|
|
65
|
+
const parsed = JSON.parse(bodies[0] ?? "{}") as Record<string, unknown>;
|
|
66
|
+
expect(parsed).toEqual({
|
|
67
|
+
sink: "oke.vault.audit",
|
|
68
|
+
action: "set",
|
|
69
|
+
path: "prod/api",
|
|
70
|
+
actorType: "flow",
|
|
71
|
+
actorId: "orders.place",
|
|
72
|
+
success: true,
|
|
73
|
+
errorCode: null,
|
|
74
|
+
errorMessage: null,
|
|
75
|
+
requestId: "req-1",
|
|
76
|
+
createdAt: "2026-01-02T03:04:05.000Z",
|
|
77
|
+
});
|
|
78
|
+
expect(bodies[0]).not.toContain(SECRET);
|
|
79
|
+
expect(parsed).not.toHaveProperty("value");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a non-2xx response rejects without echoing the body", async () => {
|
|
83
|
+
const sink = createAuditSink("webhook", {
|
|
84
|
+
webhookUrl: "https://audit.example/hook",
|
|
85
|
+
fetch: (async () => new Response(SECRET, { status: 502 })) as unknown as typeof fetch,
|
|
86
|
+
});
|
|
87
|
+
const failure = await sink.append(ENTRY).catch((err: unknown) => err);
|
|
88
|
+
expect(isVaultError(failure, "BACKEND_ERROR")).toBe(true);
|
|
89
|
+
expect((failure as Error).message).not.toContain(SECRET);
|
|
90
|
+
expect((failure as Error).message).toContain("502");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("vault boot chain", () => {
|
|
95
|
+
test("forwards vault.audit only onto the builtin driver layer", () => {
|
|
96
|
+
const audit = { sink: "webhook" as const, webhookUrl: "https://audit.example/hook" };
|
|
97
|
+
const chain = buildVaultBootChain({ driverId: "vault", audit });
|
|
98
|
+
expect(chain[0]?.driver.id).toBe("vault");
|
|
99
|
+
expect(chain[0]?.options?.audit).toEqual(audit);
|
|
100
|
+
expect(chain.slice(1).every((layer) => layer.options?.audit === undefined)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("non-db audit commands", () => {
|
|
105
|
+
test("verify refuses a webhook sink before touching the chain", async () => {
|
|
106
|
+
const sql = createMemoryVaultSql();
|
|
107
|
+
try {
|
|
108
|
+
const adapter = createBuiltinVaultAdapter({
|
|
109
|
+
db: sqlConnectionAsExec(sql),
|
|
110
|
+
audit: { sink: "webhook", webhookUrl: "https://audit.example/hook" },
|
|
111
|
+
fetch: (async () => new Response(null, { status: 500 })) as unknown as typeof fetch,
|
|
112
|
+
});
|
|
113
|
+
const failure = await adapter.verifyAudit().catch((err: unknown) => err);
|
|
114
|
+
expect(isVaultError(failure, "UNSUPPORTED")).toBe(true);
|
|
115
|
+
expect((failure as Error).message).toContain('audit.sink "db"');
|
|
116
|
+
|
|
117
|
+
const init = await adapter.initialize();
|
|
118
|
+
expect(init.masterKey.length).toBeGreaterThan(0);
|
|
119
|
+
} finally {
|
|
120
|
+
await sql.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Capture a thrown construction error.
|
|
127
|
+
*
|
|
128
|
+
* @param fn - Throwing call
|
|
129
|
+
*/
|
|
130
|
+
function catchError(fn: () => unknown): unknown {
|
|
131
|
+
try {
|
|
132
|
+
fn();
|
|
133
|
+
return undefined;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { VaultError } from "./errors.ts";
|
|
11
11
|
import type { VaultErrorCode } from "./errors.ts";
|
|
12
|
-
import type { VaultActorType } from "./types.ts";
|
|
13
|
-
|
|
12
|
+
import type { VaultActorType, VaultAuditConfig, VaultAuditSinkKind } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/** Bound on a webhook audit POST so a dead receiver cannot stall a write. */
|
|
15
|
+
const AUDIT_WEBHOOK_TIMEOUT_MS = 5_000;
|
|
14
16
|
|
|
15
17
|
/** Operations recorded in the audit chain. */
|
|
16
18
|
export type AuditAction =
|
|
@@ -110,8 +112,10 @@ export interface AuditWriter {
|
|
|
110
112
|
export interface CreateAuditSinkOptions {
|
|
111
113
|
/** Required for the `db` sink. */
|
|
112
114
|
readonly writer?: AuditWriter;
|
|
113
|
-
/** Required for the `webhook` sink
|
|
115
|
+
/** Required for the `webhook` sink. Absolute `http:` or `https:` URL. */
|
|
114
116
|
readonly webhookUrl?: string;
|
|
117
|
+
/** Fetch implementation for the `webhook` sink (tests). */
|
|
118
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
115
119
|
/** Line writer for the `stdout` sink (tests). */
|
|
116
120
|
readonly write?: (line: string) => void;
|
|
117
121
|
/** Clock for `stdout` timestamps (tests). */
|
|
@@ -169,23 +173,98 @@ export async function computeAuditRowHash(
|
|
|
169
173
|
return Buffer.from(new Uint8Array(digest)).toString("hex");
|
|
170
174
|
}
|
|
171
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Reject a webhook URL that is missing or not an absolute http(s) URL.
|
|
178
|
+
*
|
|
179
|
+
* @param webhookUrl - Configured destination
|
|
180
|
+
* @returns Absolute URL string
|
|
181
|
+
* @throws VaultError `MISSING_PEER` when the URL is absent or not http(s)
|
|
182
|
+
*/
|
|
183
|
+
function requireWebhookUrl(webhookUrl: string | undefined): string {
|
|
184
|
+
const raw = webhookUrl?.trim() ?? "";
|
|
185
|
+
if (raw.length === 0) {
|
|
186
|
+
throw new VaultError("MISSING_PEER", "vault: webhook audit sink requires webhookUrl");
|
|
187
|
+
}
|
|
188
|
+
let parsed: URL;
|
|
189
|
+
try {
|
|
190
|
+
parsed = new URL(raw);
|
|
191
|
+
} catch {
|
|
192
|
+
throw new VaultError(
|
|
193
|
+
"MISSING_PEER",
|
|
194
|
+
"vault: webhook audit sink requires an absolute http(s) webhookUrl",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
198
|
+
throw new VaultError(
|
|
199
|
+
"MISSING_PEER",
|
|
200
|
+
"vault: webhook audit sink requires an absolute http(s) webhookUrl",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return parsed.href;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* JSON body shared by the `stdout` and `webhook` sinks.
|
|
208
|
+
*
|
|
209
|
+
* Only normalized metadata is serialized — an entry never holds a value.
|
|
210
|
+
*
|
|
211
|
+
* @param entry - Operation to record
|
|
212
|
+
* @param now - Clock when the entry has no `at`
|
|
213
|
+
*/
|
|
214
|
+
function auditEventJson(entry: AuditEntry, now: () => Date): string {
|
|
215
|
+
return JSON.stringify({
|
|
216
|
+
sink: "oke.vault.audit",
|
|
217
|
+
...toAuditHashPayload(entry, entry.at ?? now()),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
172
221
|
/**
|
|
173
222
|
* Build an audit sink.
|
|
174
223
|
*
|
|
175
224
|
* - `db` delegates to an {@link AuditWriter} supplied by storage.
|
|
176
225
|
* - `stdout` writes one secret-free JSON line per operation.
|
|
177
|
-
* - `webhook`
|
|
226
|
+
* - `webhook` POSTs that same JSON. Delivery failure throws; callers that
|
|
227
|
+
* record after a successful operation swallow it.
|
|
228
|
+
*
|
|
229
|
+
* Hash-chain verify and purge apply only to the `db` sink.
|
|
178
230
|
*
|
|
179
231
|
* @param kind - Sink kind from `audit.sink`
|
|
180
232
|
* @param opts - Writer / webhook URL / test seams
|
|
181
|
-
* @throws VaultError `
|
|
233
|
+
* @throws VaultError `MISSING_PEER` when `db` has no writer or `webhook` has no absolute URL
|
|
182
234
|
*/
|
|
183
235
|
export function createAuditSink(
|
|
184
236
|
kind: VaultAuditSinkKind,
|
|
185
237
|
opts: CreateAuditSinkOptions = {},
|
|
186
238
|
): AuditSink {
|
|
187
239
|
if (kind === "webhook") {
|
|
188
|
-
|
|
240
|
+
const url = requireWebhookUrl(opts.webhookUrl);
|
|
241
|
+
const fetchFn = opts.fetch ?? globalThis.fetch;
|
|
242
|
+
const now = opts.now ?? (() => new Date());
|
|
243
|
+
return {
|
|
244
|
+
kind: "webhook",
|
|
245
|
+
async append(entry) {
|
|
246
|
+
const body = auditEventJson(entry, now);
|
|
247
|
+
let response: Response;
|
|
248
|
+
try {
|
|
249
|
+
response = await fetchFn(url, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "content-type": "application/json" },
|
|
252
|
+
body,
|
|
253
|
+
redirect: "error",
|
|
254
|
+
signal: AbortSignal.timeout(AUDIT_WEBHOOK_TIMEOUT_MS),
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err instanceof VaultError) throw err;
|
|
258
|
+
throw new VaultError("BACKEND_ERROR", "vault: webhook audit sink request failed");
|
|
259
|
+
}
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new VaultError(
|
|
262
|
+
"BACKEND_ERROR",
|
|
263
|
+
`vault: webhook audit sink failed (${response.status})`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
189
268
|
}
|
|
190
269
|
|
|
191
270
|
if (kind === "db") {
|
|
@@ -206,17 +285,31 @@ export function createAuditSink(
|
|
|
206
285
|
return {
|
|
207
286
|
kind: "stdout",
|
|
208
287
|
async append(entry) {
|
|
209
|
-
|
|
210
|
-
write(
|
|
211
|
-
JSON.stringify({
|
|
212
|
-
sink: "oke.vault.audit",
|
|
213
|
-
...toAuditHashPayload(entry, entry.at ?? now()),
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
288
|
+
write(auditEventJson(entry, now));
|
|
216
289
|
},
|
|
217
290
|
};
|
|
218
291
|
}
|
|
219
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Pick the sink for a built-in adapter from `vault.audit`.
|
|
295
|
+
*
|
|
296
|
+
* `enabled: false` drops every row. Omitted config is the `db` chain.
|
|
297
|
+
*
|
|
298
|
+
* @param config - `vault.audit` block, when present
|
|
299
|
+
* @param opts - SQL writer and optional fetch
|
|
300
|
+
*/
|
|
301
|
+
export function auditSinkForConfig(
|
|
302
|
+
config: VaultAuditConfig | undefined,
|
|
303
|
+
opts: { readonly writer: AuditWriter; readonly fetch?: typeof globalThis.fetch },
|
|
304
|
+
): AuditSink {
|
|
305
|
+
if (config?.enabled === false) return createNullAuditSink();
|
|
306
|
+
return createAuditSink(config?.sink ?? "db", {
|
|
307
|
+
writer: opts.writer,
|
|
308
|
+
...(config?.webhookUrl === undefined ? {} : { webhookUrl: config.webhookUrl }),
|
|
309
|
+
...(opts.fetch === undefined ? {} : { fetch: opts.fetch }),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
220
313
|
/**
|
|
221
314
|
* Sink that drops every entry (`audit.enabled: false`).
|
|
222
315
|
*
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { parseDotenv } from "../../drivers/vault-dotenv-parse.ts";
|
|
23
23
|
import type { VaultDriverId } from "../../drivers/vault-types.ts";
|
|
24
24
|
import { resolveComposeEnvPath } from "./chain.ts";
|
|
25
|
+
import type { VaultAuditConfig } from "./types.ts";
|
|
25
26
|
import type { VaultChainLayer } from "./runtime.ts";
|
|
26
27
|
|
|
27
28
|
/** Options for {@link buildVaultBootChain}. */
|
|
@@ -34,6 +35,10 @@ export interface BuildVaultBootChainOptions {
|
|
|
34
35
|
readonly env?: ConfigEnv;
|
|
35
36
|
/** Seed secrets for the terminal memory / managed layer. */
|
|
36
37
|
readonly seed?: Readonly<Record<string, string>>;
|
|
38
|
+
/**
|
|
39
|
+
* Built-in store audit block. Forwarded only to the `vault` driver layer.
|
|
40
|
+
*/
|
|
41
|
+
readonly audit?: VaultAuditConfig;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -142,7 +147,10 @@ export function buildVaultBootChain(options: BuildVaultBootChainOptions): VaultC
|
|
|
142
147
|
{
|
|
143
148
|
driver: builtinVaultDriver,
|
|
144
149
|
source: "driver",
|
|
145
|
-
options: {
|
|
150
|
+
options: {
|
|
151
|
+
secrets: seed,
|
|
152
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
153
|
+
},
|
|
146
154
|
},
|
|
147
155
|
...envLayers,
|
|
148
156
|
];
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* cleartext value ever reaches the log, an error message, or a `VaultError`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { auditSinkForConfig, type AuditAction, type AuditSink } from "./audit.ts";
|
|
24
24
|
import {
|
|
25
25
|
ALGORITHM,
|
|
26
26
|
buildAad,
|
|
@@ -66,6 +66,7 @@ import type {
|
|
|
66
66
|
VaultActor,
|
|
67
67
|
VaultAdapter,
|
|
68
68
|
VaultAlgorithm,
|
|
69
|
+
VaultAuditConfig,
|
|
69
70
|
VaultGetOptions,
|
|
70
71
|
VaultInitResult,
|
|
71
72
|
VaultListEntry,
|
|
@@ -96,8 +97,15 @@ export interface CreateBuiltinVaultOptions {
|
|
|
96
97
|
* {@link VaultAdapter.unseal} is not required.
|
|
97
98
|
*/
|
|
98
99
|
readonly unsealer?: Unsealer;
|
|
99
|
-
/** Audit destination.
|
|
100
|
+
/** Audit destination. Wins over {@link audit} when both are set. */
|
|
100
101
|
readonly auditSink?: AuditSink;
|
|
102
|
+
/**
|
|
103
|
+
* `vault.audit` from config. Omitted means the `db` hash chain.
|
|
104
|
+
* Ignored when {@link auditSink} is set.
|
|
105
|
+
*/
|
|
106
|
+
readonly audit?: VaultAuditConfig;
|
|
107
|
+
/** Fetch override for the `webhook` audit sink (tests). */
|
|
108
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
101
109
|
/** DEKs re-wrapped per rotation batch. Defaults to {@link DEFAULT_KEK_REWRAP_BATCH_SIZE}. */
|
|
102
110
|
readonly kekRewrapBatchSize?: number;
|
|
103
111
|
/**
|
|
@@ -306,7 +314,25 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
306
314
|
const db = createResilientSqlExec(rawDb);
|
|
307
315
|
const batchSize = Math.max(1, opts.kekRewrapBatchSize ?? DEFAULT_KEK_REWRAP_BATCH_SIZE);
|
|
308
316
|
const rotateLeaseMs = Math.max(50, opts.rotateLeaseMs ?? DEFAULT_ROTATE_LEASE_MS);
|
|
309
|
-
const audit =
|
|
317
|
+
const audit =
|
|
318
|
+
opts.auditSink ??
|
|
319
|
+
auditSinkForConfig(opts.audit, {
|
|
320
|
+
writer: createSqlAuditWriter(rawDb),
|
|
321
|
+
...(opts.fetch === undefined ? {} : { fetch: opts.fetch }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Hash-chain commands only exist for the `db` sink.
|
|
326
|
+
*
|
|
327
|
+
* @param op - Command name for the error
|
|
328
|
+
*/
|
|
329
|
+
function requireDbAudit(op: string): void {
|
|
330
|
+
if (audit.kind === "db") return;
|
|
331
|
+
throw new VaultError(
|
|
332
|
+
"UNSUPPORTED",
|
|
333
|
+
`vault: ${op} requires audit.sink "db" (got "${audit.kind}")`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
310
336
|
|
|
311
337
|
let unsealer: Unsealer | null = opts.unsealer ?? null;
|
|
312
338
|
let pending: PendingRotation | null = null;
|
|
@@ -1018,6 +1044,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
1018
1044
|
},
|
|
1019
1045
|
|
|
1020
1046
|
async purgeAuditBefore(before: Date): Promise<number> {
|
|
1047
|
+
requireDbAudit("audit purge");
|
|
1021
1048
|
await ensureReady();
|
|
1022
1049
|
const removed = await purgeAuditRows(db, before);
|
|
1023
1050
|
await record("purge", undefined);
|
|
@@ -1043,16 +1070,19 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
1043
1070
|
},
|
|
1044
1071
|
|
|
1045
1072
|
async verifyAudit(): Promise<AuditChainResult> {
|
|
1073
|
+
requireDbAudit("audit verify");
|
|
1046
1074
|
await ensureReady();
|
|
1047
1075
|
return verifyAuditChain(db);
|
|
1048
1076
|
},
|
|
1049
1077
|
|
|
1050
1078
|
async listAudit(options?: AuditPageOptions): Promise<readonly VaultAuditRecord[]> {
|
|
1079
|
+
requireDbAudit("audit list");
|
|
1051
1080
|
await ensureReady();
|
|
1052
1081
|
return readAuditPage(db, options ?? {});
|
|
1053
1082
|
},
|
|
1054
1083
|
|
|
1055
1084
|
async readAuditRow(id: string): Promise<VaultAuditRecord | null> {
|
|
1085
|
+
requireDbAudit("audit read");
|
|
1056
1086
|
await ensureReady();
|
|
1057
1087
|
return readAuditRow(db, id);
|
|
1058
1088
|
},
|
package/src/i18n/catalogs/ar.ts
CHANGED
|
@@ -86,6 +86,10 @@ export const builtinAr = {
|
|
|
86
86
|
"DatabaseError.retryable": "قاعدة البيانات مشغولة. حاول بعد قليل.",
|
|
87
87
|
ServiceUnavailable: "الخدمة غير متاحة مؤقتاً.",
|
|
88
88
|
InternalError: "حدث خطأ. حاول مرة أخرى.",
|
|
89
|
+
IdempotencyKeyMissing: "هذا الاستدعاء يتطلب ترويسة Idempotency-Key.",
|
|
90
|
+
IdempotencyKeyInvalid: "يجب أن يكون Idempotency-Key من 16 إلى 255 حرفًا ASCII قابلًا للطباعة.",
|
|
91
|
+
IdempotencyKeyReused: "استُخدم Idempotency-Key هذا من قبل مع طلب مختلف.",
|
|
92
|
+
IdempotencyInProgress: "ما زال Idempotency-Key هذا قيد التنفيذ. أعد المحاولة بعد المهلة.",
|
|
89
93
|
InvalidQuery: "الاستعلام غير صالح.",
|
|
90
94
|
UnsupportedMediaType: "نوع المحتوى هذا غير مدعوم.",
|
|
91
95
|
AuthFailed: "فشلت المصادقة.",
|
package/src/i18n/catalogs/en.ts
CHANGED
|
@@ -86,6 +86,10 @@ export const builtinEn = {
|
|
|
86
86
|
"DatabaseError.retryable": "The database is busy. Try again shortly.",
|
|
87
87
|
ServiceUnavailable: "The service is temporarily unavailable.",
|
|
88
88
|
InternalError: "Something went wrong. Try again.",
|
|
89
|
+
IdempotencyKeyMissing: "This call requires an Idempotency-Key header.",
|
|
90
|
+
IdempotencyKeyInvalid: "Idempotency-Key must be 16–255 printable ASCII characters.",
|
|
91
|
+
IdempotencyKeyReused: "This Idempotency-Key was already used with a different request.",
|
|
92
|
+
IdempotencyInProgress: "This Idempotency-Key is still running. Retry after the given delay.",
|
|
89
93
|
InvalidQuery: "The query is not valid.",
|
|
90
94
|
UnsupportedMediaType: "That content type is not supported.",
|
|
91
95
|
AuthFailed: "Authentication failed.",
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Ambient AbortSignal scope for structured concurrency.
|
|
3
3
|
*
|
|
4
4
|
* Same ALS pattern as dry-run: `fx.all` / `fx.race` enter a child signal so
|
|
5
|
-
* cooperative branches
|
|
6
|
-
*
|
|
5
|
+
* cooperative branches and outbound HTTP can observe cancellation without
|
|
6
|
+
* every call site passing a signal argument.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -34,6 +34,21 @@ export async function withAbortSignal<T>(
|
|
|
34
34
|
return storage.run(signal, fn);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Never-aborted signal for work that must outlive the caller connection. */
|
|
38
|
+
const DETACHED: AbortSignal = new AbortController().signal;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run `fn` without the request abort signal.
|
|
42
|
+
*
|
|
43
|
+
* A claimed idempotent `do` keeps running when the client times out. The
|
|
44
|
+
* lease, not the socket, is what detects a crash.
|
|
45
|
+
*
|
|
46
|
+
* @param fn - Work to detach
|
|
47
|
+
*/
|
|
48
|
+
export function runDetached<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
49
|
+
return withAbortSignal(DETACHED, fn);
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
/**
|
|
38
53
|
* When `parent` aborts, abort `child` with the same reason.
|
|
39
54
|
*
|
|
@@ -88,6 +103,22 @@ export function abortError(reason?: unknown): Error {
|
|
|
88
103
|
return err;
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Signal for one outbound request: the ambient scope, the caller's signal, or both.
|
|
108
|
+
*
|
|
109
|
+
* Read at request time, not at driver `open`, so an `fx.race` / `fx.all` abort
|
|
110
|
+
* reaches HTTP that is already in flight.
|
|
111
|
+
*
|
|
112
|
+
* @param explicit - Caller-supplied signal, when the API already accepts one
|
|
113
|
+
*/
|
|
114
|
+
export function requestSignal(explicit?: AbortSignal | null): AbortSignal {
|
|
115
|
+
const ambient = currentAbortSignal();
|
|
116
|
+
if (explicit == null || explicit === ambient) return ambient;
|
|
117
|
+
if (ambient.aborted) return ambient;
|
|
118
|
+
if (explicit.aborted) return explicit;
|
|
119
|
+
return AbortSignal.any([ambient, explicit]);
|
|
120
|
+
}
|
|
121
|
+
|
|
91
122
|
/**
|
|
92
123
|
* Sleep that rejects promptly when `signal` aborts.
|
|
93
124
|
*
|