okengine 0.21.1 → 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 (140) 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 -20
  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/docker-cli.test.ts +1 -1
  33. package/src/cli/index.ts +9 -1
  34. package/src/cli/load-config.images.test.ts +10 -10
  35. package/src/cli/load-config.ts +1 -1
  36. package/src/cli/vault-cmd.test.ts +23 -0
  37. package/src/cli/vault-cmd.ts +7 -0
  38. package/src/client/create.ts +18 -1
  39. package/src/client/transport.test.ts +225 -0
  40. package/src/client/transport.ts +132 -19
  41. package/src/client/types.ts +29 -3
  42. package/src/compiler/effects-infer.ts +726 -16
  43. package/src/compiler/extract.test.ts +37 -0
  44. package/src/compiler/extract.ts +113 -12
  45. package/src/compiler/fixtures/skyport.expected.json +24 -0
  46. package/src/compiler/fx-follow.test.ts +164 -0
  47. package/src/compiler/fx-index.ts +399 -0
  48. package/src/console/server/flows-invoke.test.ts +2 -2
  49. package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
  50. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  51. package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  57. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  58. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  59. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
  63. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  64. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  65. package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
  67. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  68. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  69. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  70. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  71. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  72. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
  74. package/src/console/ui-next/dist/index.html +2 -2
  75. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  76. package/src/docker/docker.test.ts +13 -13
  77. package/src/docker/images-config.test.ts +12 -12
  78. package/src/docker/stack-id.test.ts +1 -1
  79. package/src/drivers/journal-postgres.ts +168 -3
  80. package/src/drivers/meilisearch.ts +2 -0
  81. package/src/drivers/vault-builtin.test.ts +10 -0
  82. package/src/drivers/vault-builtin.ts +13 -1
  83. package/src/drivers/vault-remote-bag.ts +5 -1
  84. package/src/drivers/vault-types.ts +6 -1
  85. package/src/elements/store/emit-drizzle.ts +2 -2
  86. package/src/elements/store/live-default.test.ts +2 -0
  87. package/src/elements/store/live-http.test.ts +1 -0
  88. package/src/elements/store/resource-list-docs.test.ts +2 -2
  89. package/src/elements/store/resource.test.ts +3 -3
  90. package/src/elements/store/schema-decl.test.ts +1 -0
  91. package/src/elements/store/sql-condition.ts +24 -7
  92. package/src/elements/store/upsert-app.test.ts +1 -1
  93. package/src/elements/vault/audit.test.ts +137 -0
  94. package/src/elements/vault/audit.ts +106 -13
  95. package/src/elements/vault/boot-chain.ts +9 -1
  96. package/src/elements/vault/builtin-adapter.ts +33 -3
  97. package/src/i18n/catalogs/ar.ts +4 -0
  98. package/src/i18n/catalogs/en.ts +4 -0
  99. package/src/kernel/abort-scope.ts +33 -2
  100. package/src/kernel/app.ts +191 -18
  101. package/src/kernel/auto-cache.test.ts +6 -6
  102. package/src/kernel/boot-bind/vault.ts +1 -0
  103. package/src/kernel/boot.ts +19 -1
  104. package/src/kernel/builtin-errors.ts +8 -0
  105. package/src/kernel/client-descriptor.ts +1 -0
  106. package/src/kernel/concurrency.test.ts +33 -0
  107. package/src/kernel/effects-stamping.test.ts +2 -2
  108. package/src/kernel/errors-compiler.ts +20 -0
  109. package/src/kernel/external-effects.test.ts +36 -0
  110. package/src/kernel/flow.ts +24 -0
  111. package/src/kernel/fx-fetch.ts +5 -1
  112. package/src/kernel/fx.ts +5 -1
  113. package/src/kernel/idempotency-store.ts +578 -0
  114. package/src/kernel/idempotency.test.ts +556 -0
  115. package/src/kernel/idempotency.ts +304 -0
  116. package/src/kernel/index.ts +1 -0
  117. package/src/kernel/journal.ts +56 -0
  118. package/src/manifest/types.ts +8 -0
  119. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  120. package/src/plugins/otp.test.ts +65 -1
  121. package/src/plugins/otp.ts +59 -7
  122. package/src/release/absolute-regression.test.ts +63 -0
  123. package/src/release/http-graph.test.ts +25 -0
  124. package/src/release/http-graph.ts +90 -0
  125. package/src/release/index.ts +3 -0
  126. package/src/release/limits.ts +17 -2
  127. package/src/release/measure.ts +105 -14
  128. package/src/test/create-test-app.test.ts +3 -1
  129. package/src/test/create-test-app.ts +40 -0
  130. package/src/test/live-signals.test.ts +1 -0
  131. package/src/test/provisions.integration.test.ts +1 -0
  132. package/src/test/tenant-isolation.test.ts +1 -0
  133. package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
  134. package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
  135. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  136. package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
  137. package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
  138. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  139. package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
  140. package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
@@ -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
  *
package/src/kernel/app.ts CHANGED
@@ -43,7 +43,7 @@ import { parseAcceptLanguage } from "../elements/channel/locale.ts";
43
43
  import { runWithLocale } from "../i18n/locale-context.ts";
44
44
  import { isFlow, type AnyFlowDef } from "./flow.ts";
45
45
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
46
- import { withAbortSignal } from "./abort-scope.ts";
46
+ import { runDetached, withAbortSignal } from "./abort-scope.ts";
47
47
  import { withCdcMutationId } from "../elements/store/sql-session.ts";
48
48
  import { MUTATION_ID_HEADER } from "./mutation-id.ts";
49
49
  import { fxRetry } from "./concurrency.ts";
@@ -83,6 +83,7 @@ import {
83
83
  } from "./hooks.ts";
84
84
  import {
85
85
  createJournal,
86
+ createJournalSlot,
86
87
  createMemoryJournalStore,
87
88
  hasJournalLease,
88
89
  isJournalLeaseBusy,
@@ -91,6 +92,7 @@ import {
91
92
  type JournalSession,
92
93
  type JournalStore,
93
94
  } from "./journal.ts";
95
+ import type { IdempotencyAttempt } from "./idempotency.ts";
94
96
  import { releaseInstanceLeases } from "./graceful-shutdown.ts";
95
97
  import { mintInstanceId } from "./instance-id.ts";
96
98
  import type { JournalRuntime } from "./boot-bind/journal.ts";
@@ -181,6 +183,11 @@ function loadFx(): {
181
183
  return lazyRequire(import.meta.dir, ["fx", "runtime"].join("-"));
182
184
  }
183
185
 
186
+ /** Idempotency policy — off the edge graph until a request actually claims. */
187
+ function loadIdempotency(): typeof import("./idempotency.ts") {
188
+ return lazyRequire(import.meta.dir, ["idem", "potency"].join(""));
189
+ }
190
+
184
191
  /** App-shell tenancy wiring — loaded only when `gate.auth.tenant` is on. */
185
192
  function loadAppTenant(): { w: (kind: number, ...args: unknown[]) => unknown } {
186
193
  return lazyRequire(import.meta.dir, ["app", "tenant"].join("-"));
@@ -1837,23 +1844,61 @@ export function oke(options: OkeOptions): OkeApp {
1837
1844
  );
1838
1845
 
1839
1846
  capability = booted.capabilities.get(flowDef.name);
1840
-
1841
- if (flowDef.durable) {
1842
- const { store, instanceId, leaseMs } = activeJournal();
1843
- const journal = createJournal({
1844
- store,
1845
- now,
1846
- id: () => runId,
1847
- // Hold the run lease for the request's lifetime — a crash mid-run
1848
- // leaves an expired lease another instance can reclaim and resume.
1849
- ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1850
- });
1851
- journalSession = await journal.start(flowDef.name, input);
1852
- inflightRuns.add(journalSession.runId);
1853
- }
1854
1847
  }
1855
1848
 
1856
1849
  const effects = flowDef.effects ?? capability?.declared;
1850
+ const requestForIdem = extras?.request;
1851
+ const rawIdemHeader = requestForIdem?.headers.get("idempotency-key") ?? null;
1852
+ const idemRequired =
1853
+ flowDef.idempotency === "required" ||
1854
+ (typeof flowDef.idempotency === "object" && flowDef.idempotency.required === true) ||
1855
+ flowDef.resolvedIdempotency?.mode === "required";
1856
+ let idemAttempt: IdempotencyAttempt | undefined;
1857
+ if (requestForIdem && (rawIdemHeader !== null || idemRequired)) {
1858
+ const method = trigger.kind === "http" ? trigger.method : requestForIdem.method;
1859
+ const live =
1860
+ flowDef.live !== undefined || (trigger.kind === "http" && trigger.liveSignal !== undefined);
1861
+ idemAttempt = loadIdempotency().prepareIdempotencyAttempt({
1862
+ hasRequest: true,
1863
+ method,
1864
+ stream: flowDef.stream === true,
1865
+ live,
1866
+ effects,
1867
+ usesRaw: flowDef.usesRaw === true,
1868
+ option: flowDef.idempotency,
1869
+ resolved: flowDef.resolvedIdempotency,
1870
+ header: rawIdemHeader,
1871
+ });
1872
+ }
1873
+ const claiming = idemAttempt?.claiming === true;
1874
+
1875
+ const openJournal = (): ReturnType<typeof createJournal> => {
1876
+ const { store, instanceId, leaseMs } = activeJournal();
1877
+ return createJournal({
1878
+ store,
1879
+ now,
1880
+ id: () => runId,
1881
+ // Hold the run lease for the request's lifetime — a crash mid-run
1882
+ // leaves an expired lease another instance can reclaim and resume.
1883
+ ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1884
+ });
1885
+ };
1886
+
1887
+ // A claim starts the journal only after it wins, so a replay or a 409
1888
+ // does not open a run. Auto with no header keeps the start here.
1889
+ if (booted && flowDef.durable && !claiming) {
1890
+ journalSession = await openJournal().start(flowDef.name, input);
1891
+ inflightRuns.add(journalSession.runId);
1892
+ }
1893
+
1894
+ let journalSlot: { session?: JournalSession } | undefined;
1895
+ let journalFacade: JournalSession | undefined;
1896
+ if (booted && flowDef.durable && claiming) {
1897
+ const created = createJournalSlot();
1898
+ journalSlot = created.slot;
1899
+ journalFacade = created.facade;
1900
+ }
1901
+ const journalForFx = journalSession ?? journalFacade;
1857
1902
  const { fx, ledger } = loadFx().createFxContext({
1858
1903
  ...options.fx,
1859
1904
  flow: flowDef.name,
@@ -1906,7 +1951,7 @@ export function oke(options: OkeOptions): OkeApp {
1906
1951
  }
1907
1952
  : {}),
1908
1953
  runsRuntime: booted?.runs ?? (isRunsRuntimeLike(options.runs) ? options.runs : undefined),
1909
- ...(journalSession ? { journal: journalSession, durable: true } : {}),
1954
+ ...(journalForFx ? { journal: journalForFx, durable: true } : {}),
1910
1955
  callHandler: async (name, callInput) => {
1911
1956
  const target = flowsByName.get(name);
1912
1957
  if (!target) {
@@ -2038,13 +2083,141 @@ export function oke(options: OkeOptions): OkeApp {
2038
2083
  }
2039
2084
  return output;
2040
2085
  };
2086
+ const runClaimed = async (validated: unknown): Promise<unknown> => {
2087
+ if (!claiming || idemAttempt === undefined) return invoke(validated);
2088
+ const policy = loadIdempotency();
2089
+ const classified = policy.classifyIdempotencyKey(rawIdemHeader);
2090
+ if (classified === "missing") {
2091
+ return policy.idempotencyErrorResponse("IdempotencyKeyMissing");
2092
+ }
2093
+ if (classified === "invalid") {
2094
+ return policy.idempotencyErrorResponse("IdempotencyKeyInvalid");
2095
+ }
2096
+ const idemStore = activeJournal().store.idempotency;
2097
+ if (!idemStore) return invoke(validated);
2098
+ const scope = {
2099
+ tenant: fx.tenant.id ?? "",
2100
+ principal: policy.idempotencyPrincipal(fx.auth),
2101
+ flow: flowDef.name,
2102
+ key: classified,
2103
+ };
2104
+ const claimToken = crypto.randomUUID();
2105
+ const leaseMs = activeJournal().leaseMs;
2106
+ const claimed = await idemStore.claim({
2107
+ scope,
2108
+ fingerprint: policy.idempotencyFingerprint(flowDef.name, validated),
2109
+ claimToken,
2110
+ now: now(),
2111
+ leaseMs,
2112
+ ttlMs: idemAttempt.ttlMs,
2113
+ });
2114
+ if (claimed.kind === "mismatch") {
2115
+ return policy.idempotencyErrorResponse("IdempotencyKeyReused");
2116
+ }
2117
+ if (claimed.kind === "in_progress") {
2118
+ return policy.idempotencyErrorResponse(
2119
+ "IdempotencyInProgress",
2120
+ claimed.retryAfterSeconds,
2121
+ );
2122
+ }
2123
+ if (claimed.kind === "replay") return policy.replayResponse(claimed.row);
2124
+
2125
+ if (booted && flowDef.durable) {
2126
+ const journal = openJournal();
2127
+ const resumeId = claimed.kind === "reclaimed" ? claimed.row.runId : undefined;
2128
+ try {
2129
+ const session = resumeId
2130
+ ? await journal.resume(resumeId)
2131
+ : await journal.start(flowDef.name, validated);
2132
+ if (journalSlot) journalSlot.session = session;
2133
+ journalSession = session;
2134
+ inflightRuns.add(session.runId);
2135
+ const tenantId = principals?.tenant.id ?? fx.tenant.id;
2136
+ if (tenantId) await session.stampTenant(tenantId);
2137
+ await idemStore.attachRun(scope, claimToken, session.runId);
2138
+ } catch (err) {
2139
+ if (isJournalLeaseBusy(err)) {
2140
+ await idemStore.forfeit(scope, claimToken);
2141
+ return policy.idempotencyErrorResponse("IdempotencyInProgress", 1);
2142
+ }
2143
+ await idemStore.remove(scope, claimToken);
2144
+ throw err;
2145
+ }
2146
+ }
2147
+
2148
+ const timer = setInterval(
2149
+ () => {
2150
+ void idemStore.renew(scope, claimToken, now() + leaseMs);
2151
+ },
2152
+ Math.max(5, Math.floor(leaseMs / 3)),
2153
+ );
2154
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
2155
+
2156
+ const storeEncoded = async (encoded: {
2157
+ readonly status: number;
2158
+ readonly headers: { get(name: string): string | null };
2159
+ text(): Promise<string>;
2160
+ }): Promise<void> => {
2161
+ const stored = await policy.storedFromResponse(encoded);
2162
+ if (!stored) {
2163
+ await idemStore.remove(scope, claimToken);
2164
+ return;
2165
+ }
2166
+ await idemStore.complete(scope, claimToken, stored, journalSession?.runId);
2167
+ };
2168
+ try {
2169
+ const output = await runDetached(() => invoke(validated));
2170
+ if (loadFx().isJsonStreamResult(output)) {
2171
+ await idemStore.remove(scope, claimToken);
2172
+ return output;
2173
+ }
2174
+ if (output instanceof Response) {
2175
+ const contentType = output.headers.get("content-type") ?? "";
2176
+ const buffered =
2177
+ output.status === 204 ||
2178
+ contentType.includes("json") ||
2179
+ contentType.startsWith("text/");
2180
+ if (!buffered || contentType.includes("text/event-stream")) {
2181
+ await idemStore.remove(scope, claimToken);
2182
+ return output;
2183
+ }
2184
+ await storeEncoded(output.clone());
2185
+ return output;
2186
+ }
2187
+ await storeEncoded(
2188
+ await encodeExecuteResult({
2189
+ output: isFlowFailure(output) ? undefined : output,
2190
+ failure: isFlowFailure(output) ? output : undefined,
2191
+ }),
2192
+ );
2193
+ return output;
2194
+ } catch (err) {
2195
+ if (flowDef.durable && isJournalSuspend(err)) {
2196
+ await idemStore.complete(
2197
+ scope,
2198
+ claimToken,
2199
+ { status: 204, body: "" },
2200
+ journalSession?.runId,
2201
+ );
2202
+ throw err;
2203
+ }
2204
+ if (policy.attemptedMutation(ledger.entries) || idemAttempt.usesRaw) {
2205
+ await storeEncoded(encodeFailure(fail("InternalError", {})));
2206
+ } else {
2207
+ await idemStore.remove(scope, claimToken);
2208
+ }
2209
+ throw err;
2210
+ } finally {
2211
+ clearInterval(timer);
2212
+ }
2213
+ };
2041
2214
  if (!alreadyValidated) {
2042
2215
  const parsed = await validate(flowDef.in, ctx.input);
2043
2216
  if (!parsed.ok) return parsed.failure;
2044
2217
  ctx.input = parsed.value;
2045
- return await invoke(parsed.value);
2218
+ return await runClaimed(parsed.value);
2046
2219
  }
2047
- return await invoke(ctx.input);
2220
+ return await runClaimed(ctx.input);
2048
2221
  } catch (err) {
2049
2222
  if (isFlowFailure(err)) return err;
2050
2223
  // A durable sleep that has not yet elapsed suspends the run — this
@@ -58,7 +58,7 @@ describe("automatic tier-1 cache from effects", () => {
58
58
  stores: [db],
59
59
  gate: { policies: [gate.public] },
60
60
  }).adopt({ list });
61
- const t = await createTestApp(app);
61
+ const t = await createTestApp(app, { capability: "open" });
62
62
  const notesApi = requireNotesApi(t);
63
63
 
64
64
  const first = await notesApi.list({});
@@ -90,7 +90,7 @@ describe("automatic tier-1 cache from effects", () => {
90
90
  stores: [db],
91
91
  gate: { policies: [gate.public] },
92
92
  }).adopt({ list });
93
- const t = await createTestApp(app);
93
+ const t = await createTestApp(app, { capability: "open" });
94
94
  const trigger = list.triggers[0];
95
95
  if (trigger === undefined) throw new Error("notes.list has no trigger");
96
96
 
@@ -128,7 +128,7 @@ describe("automatic tier-1 cache from effects", () => {
128
128
  stores: [db],
129
129
  gate: { policies: [gate.public] },
130
130
  }).adopt({ list });
131
- const t = await createTestApp(app);
131
+ const t = await createTestApp(app, { capability: "open" });
132
132
  const notesApi = requireNotesApi(t);
133
133
  await notesApi.list({});
134
134
  await notesApi.list({});
@@ -158,7 +158,7 @@ describe("automatic tier-1 cache from effects", () => {
158
158
  stores: [db],
159
159
  gate: { policies: [gate.public] },
160
160
  }).adopt({ list });
161
- const t = await createTestApp(app);
161
+ const t = await createTestApp(app, { capability: "open" });
162
162
  const notesApi = requireNotesApi(t);
163
163
 
164
164
  await notesApi.list({});
@@ -209,7 +209,7 @@ describe("automatic tier-1 cache from effects", () => {
209
209
  stores: [db],
210
210
  gate: { policies: [gate.public] },
211
211
  }).adopt({ list, create });
212
- const t = await createTestApp(app);
212
+ const t = await createTestApp(app, { capability: "open" });
213
213
  const notesApi = requireNotesApi(t);
214
214
 
215
215
  await notesApi.list({});
@@ -251,7 +251,7 @@ describe("automatic tier-1 cache from effects", () => {
251
251
  stores: [db],
252
252
  gate: { policies: [gate.public] },
253
253
  }).adopt({ list, create });
254
- const t = await createTestApp(app);
254
+ const t = await createTestApp(app, { capability: "open" });
255
255
  const notesApi = requireNotesApi(t);
256
256
 
257
257
  await notesApi.list({});