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.
Files changed (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. 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
- import type { VaultAuditSinkKind } from "./types.ts";
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 (not yet implemented). */
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` is not implemented yet and throws `UNSUPPORTED`.
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 `UNSUPPORTED` for `webhook`, or `MISSING_PEER` when `db` has no writer
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
- throw new VaultError("UNSUPPORTED", "vault: webhook audit sink is not implemented");
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
- // Only normalized metadata is serialized — an entry never holds a value.
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: { secrets: seed },
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 { createAuditSink, type AuditAction, type AuditSink } from "./audit.ts";
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. Defaults to a `db` sink over `oke_vault_audit`. */
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 = opts.auditSink ?? createAuditSink("db", { writer: createSqlAuditWriter(rawDb) });
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
  },
@@ -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: "فشلت المصادقة.",
@@ -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 (and future driver plumbing) can observe cancellation
6
- * without every call site passing a signal argument.
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
  *