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
@@ -0,0 +1,304 @@
1
+ /**
2
+ * HTTP idempotency policy.
3
+ *
4
+ * A flow honors `Idempotency-Key` when it is reached over HTTP as REST
5
+ * non-GET or RPC, its inferred effects mutate or it uses `fx.raw`, and it
6
+ * is not a stream, live feed, or binary response. The IETF draft this header
7
+ * follows expired without becoming an RFC.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { parseDurationMs } from "../elements/clock/duration.ts";
13
+ import type { Effects } from "../manifest/types.ts";
14
+ import { fail } from "./errors.ts";
15
+ import type { EffectKind } from "./effects.ts";
16
+ import type { IdempotencyRow, StoredResponse } from "./idempotency-store.ts";
17
+
18
+ /** Request header. */
19
+ export const IDEMPOTENCY_HEADER = "idempotency-key";
20
+
21
+ /** Set on a replayed response. */
22
+ export const REPLAYED_HEADER = "idempotent-replayed";
23
+
24
+ /** Default TTL stamped when the flow omits one. */
25
+ export const IDEMPOTENCY_DEFAULT_TTL = "24h";
26
+
27
+ /** Author option on `flow({ idempotency })`. */
28
+ export type FlowIdempotencyOption =
29
+ | false
30
+ | "required"
31
+ | { readonly required?: boolean; readonly ttl?: string };
32
+
33
+ /** Manifest / client stamp. */
34
+ export interface ResolvedIdempotency {
35
+ readonly mode: "off" | "auto" | "required";
36
+ readonly ttl: string;
37
+ }
38
+
39
+ const MUTATING: ReadonlySet<EffectKind> = new Set([
40
+ "write",
41
+ "emit",
42
+ "send",
43
+ "call",
44
+ "fetch",
45
+ "ask",
46
+ "embed",
47
+ ]);
48
+
49
+ const KEY_PATTERN = /^[\x20-\x7E]{16,255}$/;
50
+
51
+ /**
52
+ * True when inferred effects include anything besides reads and secrets.
53
+ *
54
+ * @param effects - Declared or inferred effect set
55
+ */
56
+ export function hasMutatingEffects(effects: Effects | undefined): boolean {
57
+ if (effects === undefined) return false;
58
+ return (
59
+ (effects.writes?.length ?? 0) > 0 ||
60
+ (effects.emits?.length ?? 0) > 0 ||
61
+ (effects.sends?.length ?? 0) > 0 ||
62
+ (effects.calls?.length ?? 0) > 0 ||
63
+ (effects.fetches?.length ?? 0) > 0 ||
64
+ (effects.asks?.length ?? 0) > 0 ||
65
+ (effects.embeds?.length ?? 0) > 0
66
+ );
67
+ }
68
+
69
+ /**
70
+ * True when this HTTP entry should look at `Idempotency-Key`.
71
+ *
72
+ * @param args - Trigger shape and inferred effects
73
+ */
74
+ export function idempotencyEligible(args: {
75
+ readonly hasRequest: boolean;
76
+ /** HTTP method. RPC is `POST`. */
77
+ readonly method: string | undefined;
78
+ readonly stream: boolean;
79
+ readonly live: boolean;
80
+ readonly effects: Effects | undefined;
81
+ readonly usesRaw: boolean;
82
+ }): boolean {
83
+ if (!args.hasRequest || args.stream || args.live) return false;
84
+ const method = (args.method ?? "POST").toUpperCase();
85
+ if (method === "GET") return false;
86
+ return args.usesRaw || hasMutatingEffects(args.effects);
87
+ }
88
+
89
+ /** What this request should do with the header. */
90
+ export interface IdempotencyAttempt {
91
+ readonly claiming: boolean;
92
+ readonly mode: "off" | "auto" | "required";
93
+ readonly ttlMs: number;
94
+ readonly usesRaw: boolean;
95
+ }
96
+
97
+ /**
98
+ * Decide whether this request claims a key.
99
+ *
100
+ * A stamped `required` flow still ignores the header when this entry is not
101
+ * an eligible HTTP call (signal delivery, GET, stream, live, read-only).
102
+ *
103
+ * @param args - Flow stamp plus this request
104
+ */
105
+ export function prepareIdempotencyAttempt(args: {
106
+ readonly hasRequest: boolean;
107
+ readonly method: string | undefined;
108
+ readonly stream: boolean;
109
+ readonly live: boolean;
110
+ readonly effects: Effects | undefined;
111
+ readonly usesRaw: boolean;
112
+ readonly option: FlowIdempotencyOption | undefined;
113
+ readonly resolved: ResolvedIdempotency | undefined;
114
+ readonly header: string | null;
115
+ }): IdempotencyAttempt {
116
+ const eligible = idempotencyEligible(args);
117
+ const resolved = args.resolved ?? resolveIdempotencyMode(args.option, eligible);
118
+ const mode = eligible ? resolved.mode : "off";
119
+ const headerPresent = args.header !== null && args.header.trim().length > 0;
120
+ const ttlMs = idempotencyTtlMs(resolved.ttl);
121
+ return {
122
+ claiming: mode === "required" || (mode === "auto" && headerPresent),
123
+ mode,
124
+ ttlMs: ttlMs > 0 ? ttlMs : 24 * 60 * 60 * 1000,
125
+ usesRaw: args.usesRaw,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Resolve `off | auto | required` and the TTL string.
131
+ *
132
+ * Ineligible flows are `off` even when the option says required. Callers
133
+ * that parse source should reject that combination before calling this.
134
+ *
135
+ * @param option - Author option
136
+ * @param eligible - {@link idempotencyEligible}
137
+ */
138
+ export function resolveIdempotencyMode(
139
+ option: FlowIdempotencyOption | undefined,
140
+ eligible: boolean,
141
+ ): ResolvedIdempotency {
142
+ const ttl = ttlOf(option);
143
+ if (!eligible || option === false) return { mode: "off", ttl };
144
+ if (option === "required") return { mode: "required", ttl };
145
+ if (typeof option === "object" && option.required === true) return { mode: "required", ttl };
146
+ return { mode: "auto", ttl };
147
+ }
148
+
149
+ /**
150
+ * Parse a TTL string. `0` means the string is not a duration.
151
+ *
152
+ * @param ttl - Duration such as `24h`
153
+ */
154
+ export function idempotencyTtlMs(ttl: string): number {
155
+ return parseDurationMs(ttl);
156
+ }
157
+
158
+ /**
159
+ * `user:<id>`, else `apikey:<id>`, else `anon`.
160
+ *
161
+ * @param auth - Principal after the gate
162
+ */
163
+ export function idempotencyPrincipal(auth: {
164
+ readonly userId: string | null;
165
+ readonly apiKeyId?: string | null;
166
+ }): string {
167
+ if (auth.userId !== null && auth.userId.length > 0) return `user:${auth.userId}`;
168
+ if (auth.apiKeyId !== null && auth.apiKeyId !== undefined && auth.apiKeyId.length > 0) {
169
+ return `apikey:${auth.apiKeyId}`;
170
+ }
171
+ return "anon";
172
+ }
173
+
174
+ /**
175
+ * SHA-256 of canonical JSON `{ flow, input }`. Computed once at claim.
176
+ *
177
+ * @param flow - Flow name
178
+ * @param input - Validated input
179
+ */
180
+ export function idempotencyFingerprint(flow: string, input: unknown): string {
181
+ const hasher = new Bun.CryptoHasher("sha256");
182
+ hasher.update(canonicalJson({ flow, input }));
183
+ return hasher.digest("hex");
184
+ }
185
+
186
+ /**
187
+ * Classify a raw header value.
188
+ *
189
+ * @param header - Header value, or null when absent
190
+ */
191
+ export function classifyIdempotencyKey(header: string | null): "missing" | "invalid" | string {
192
+ if (header === null) return "missing";
193
+ const key = header.trim();
194
+ if (!KEY_PATTERN.test(key)) return "invalid";
195
+ return key;
196
+ }
197
+
198
+ /**
199
+ * True when this attempt recorded a non-read effect. Secrets do not count.
200
+ * Failed attempts stay on the ledger.
201
+ *
202
+ * @param entries - This run's ledger
203
+ */
204
+ export function attemptedMutation(entries: readonly { readonly kind: EffectKind }[]): boolean {
205
+ return entries.some((entry) => MUTATING.has(entry.kind));
206
+ }
207
+
208
+ /**
209
+ * Replay a stored response and mark it `Idempotent-Replayed`.
210
+ *
211
+ * @param row - Completed row
212
+ */
213
+ export function replayResponse(row: IdempotencyRow): Response {
214
+ const headers = new Headers();
215
+ const contentType = row.responseHeaders?.contentType;
216
+ const location = row.responseHeaders?.location;
217
+ if (contentType !== undefined && contentType.length > 0) headers.set("content-type", contentType);
218
+ if (location !== undefined && location.length > 0) headers.set("location", location);
219
+ headers.set(REPLAYED_HEADER, "true");
220
+ const status = row.responseStatus ?? 200;
221
+ if (status === 204) return new Response(null, { status, headers });
222
+ return new Response(row.responseBody ?? "", { status, headers });
223
+ }
224
+
225
+ /**
226
+ * Protocol failure. `IdempotencyInProgress` also sets `Retry-After`.
227
+ *
228
+ * @param code - Builtin idempotency code
229
+ * @param retryAfterSeconds - Seconds until the lease expires, for 409
230
+ */
231
+ export function idempotencyErrorResponse(
232
+ code:
233
+ | "IdempotencyKeyMissing"
234
+ | "IdempotencyKeyInvalid"
235
+ | "IdempotencyKeyReused"
236
+ | "IdempotencyInProgress",
237
+ retryAfterSeconds?: number,
238
+ ): Response {
239
+ const failure = fail(
240
+ code,
241
+ code === "IdempotencyInProgress" ? { retryAfter: retryAfterSeconds } : {},
242
+ );
243
+ const headers = new Headers();
244
+ if (code === "IdempotencyInProgress") {
245
+ headers.set("retry-after", String(Math.max(1, retryAfterSeconds ?? 1)));
246
+ }
247
+ const status =
248
+ code === "IdempotencyKeyReused" ? 422 : code === "IdempotencyInProgress" ? 409 : 400;
249
+ return Response.json({ data: null, error: failure.error }, { status, headers });
250
+ }
251
+
252
+ /**
253
+ * Buffer a JSON envelope for storage. Streams and non-JSON bodies return
254
+ * undefined so the caller deletes the in-progress row.
255
+ *
256
+ * @param res - Encoded response (not the live stream)
257
+ */
258
+ export async function storedFromResponse(res: {
259
+ readonly status: number;
260
+ readonly headers: { get(name: string): string | null };
261
+ text(): Promise<string>;
262
+ }): Promise<StoredResponse | undefined> {
263
+ const contentType = res.headers.get("content-type") ?? undefined;
264
+ if (contentType?.includes("text/event-stream")) return undefined;
265
+ if (
266
+ contentType !== undefined &&
267
+ !contentType.includes("json") &&
268
+ !contentType.includes("text/") &&
269
+ res.status !== 204
270
+ ) {
271
+ return undefined;
272
+ }
273
+ const location = res.headers.get("location") ?? undefined;
274
+ const body = res.status === 204 ? "" : await res.text();
275
+ return {
276
+ status: res.status,
277
+ body,
278
+ ...(contentType !== undefined ? { contentType } : {}),
279
+ ...(location !== undefined ? { location } : {}),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Author TTL, or {@link IDEMPOTENCY_DEFAULT_TTL}.
285
+ *
286
+ * @param option - Author option
287
+ */
288
+ export function ttlOf(option: FlowIdempotencyOption | undefined): string {
289
+ if (typeof option === "object" && option !== null && typeof option.ttl === "string") {
290
+ return option.ttl;
291
+ }
292
+ return IDEMPOTENCY_DEFAULT_TTL;
293
+ }
294
+
295
+ function canonicalJson(value: unknown): string {
296
+ if (value === undefined) return "null";
297
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
298
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
299
+ const obj = value as Record<string, unknown>;
300
+ const keys = Object.keys(obj)
301
+ .filter((key) => obj[key] !== undefined)
302
+ .sort();
303
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
304
+ }
@@ -212,6 +212,7 @@ export {
212
212
  currentAbortSignal,
213
213
  isAbortError,
214
214
  linkAbort,
215
+ requestSignal,
215
216
  withAbortSignal,
216
217
  } from "./abort-scope.ts";
217
218
 
@@ -10,7 +10,9 @@
10
10
  import { mkdir } from "node:fs/promises";
11
11
  import { dirname } from "node:path";
12
12
  import { okid } from "../okid.ts";
13
+ import type { IdempotencyStore } from "./idempotency-store.ts";
13
14
  import { JournalSuspend } from "./journal-suspend.ts";
15
+ import { lazyRequire } from "./lazy-require.ts";
14
16
 
15
17
  export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
16
18
 
@@ -151,8 +153,21 @@ export interface JournalLeaseStore {
151
153
  listOrphans(now: number): Promise<readonly JournalRun[]>;
152
154
  }
153
155
 
156
+ /**
157
+ * Load the idempotency table implementation without a static import.
158
+ * Computed stem so the edge profile does not inline it.
159
+ */
160
+ function loadIdempotencyStore(): typeof import("./idempotency-store.ts") {
161
+ return lazyRequire(import.meta.dir, ["idempotency", "store"].join("-"));
162
+ }
163
+
154
164
  /** Persistence backend for journal runs. */
155
165
  export interface JournalStore extends Partial<JournalLeaseStore> {
166
+ /**
167
+ * Idempotency records on this same driver. Absent on a custom store that
168
+ * only implements run `get` / `put`.
169
+ */
170
+ readonly idempotency?: IdempotencyStore;
156
171
  /**
157
172
  * Load a run by id.
158
173
  *
@@ -286,6 +301,11 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
286
301
  return [...runs.values()].map(cloneRun);
287
302
  },
288
303
  ...leaseMethods(load),
304
+ get idempotency(): IdempotencyStore {
305
+ const created = loadIdempotencyStore().createMemoryIdempotencyStore();
306
+ Object.defineProperty(this, "idempotency", { value: created });
307
+ return created;
308
+ },
289
309
  };
290
310
  }
291
311
 
@@ -332,6 +352,13 @@ export function createFileJournalStore(path: string): JournalStore {
332
352
  },
333
353
  // Single-host file: leases coordinate same-machine processes only.
334
354
  ...leaseMethods(load, flush),
355
+ get idempotency(): IdempotencyStore {
356
+ const created = loadIdempotencyStore().createFileIdempotencyStore(
357
+ `${dirname(path)}/idempotency.json`,
358
+ );
359
+ Object.defineProperty(this, "idempotency", { value: created });
360
+ return created;
361
+ },
335
362
  };
336
363
  }
337
364
 
@@ -428,6 +455,35 @@ export interface JournalSession {
428
455
  ): Promise<void>;
429
456
  }
430
457
 
458
+ /**
459
+ * A journal session that forwards to a real session once one is assigned.
460
+ *
461
+ * Idempotent durable flows start the journal only after the claim, which is
462
+ * after `fx` has already been created.
463
+ */
464
+ export function createJournalSlot(): {
465
+ readonly slot: { session?: JournalSession };
466
+ readonly facade: JournalSession;
467
+ } {
468
+ const slot: { session?: JournalSession } = {};
469
+ const facade = new Proxy({} as JournalSession, {
470
+ get(_target, prop, receiver) {
471
+ const session = slot.session;
472
+ if (!session) {
473
+ if (prop === "runId") return "";
474
+ if (prop === "stampTenant") return async () => undefined;
475
+ if (prop === "rewind") return () => undefined;
476
+ return undefined;
477
+ }
478
+ const value = Reflect.get(session, prop, receiver);
479
+ return typeof value === "function"
480
+ ? (value as (...args: unknown[]) => unknown).bind(session)
481
+ : value;
482
+ },
483
+ });
484
+ return { slot, facade };
485
+ }
486
+
431
487
  /** Journal facade. */
432
488
  export interface Journal {
433
489
  readonly store: JournalStore;
@@ -201,6 +201,14 @@ export interface Flow {
201
201
  source?: string;
202
202
  plane?: FlowPlane;
203
203
  durable?: boolean;
204
+ /**
205
+ * HTTP idempotency stamp. `off` ignores `Idempotency-Key`.
206
+ * `auto` honors it when present. `required` rejects a missing header.
207
+ * `ttl` is how long a stored response (possibly PII) is kept.
208
+ */
209
+ idempotency?: { mode: "off" | "auto" | "required"; ttl: string };
210
+ /** True when the body calls `fx.raw`. Throws then keep the idempotency record. */
211
+ usesRaw?: boolean;
204
212
  /** Live signal name when this flow streams `delivery: "live"` SSE. */
205
213
  live?: string;
206
214
  cache?: boolean | string;
@@ -18,7 +18,7 @@ import { otp } from "./otp.ts";
18
18
  import { magicLink } from "./magic-link.ts";
19
19
 
20
20
  const SECRET = "test-secret-at-least-16";
21
- const MAILPIT_IMAGE = "axllent/mailpit:v1.31.1";
21
+ const MAILPIT_IMAGE = "axllent/mailpit:v1.31.2";
22
22
 
23
23
  afterEach(() => {
24
24
  resetBindings();
@@ -117,7 +117,71 @@ describe("otp() provider mode boot", () => {
117
117
  const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
118
118
  expect(body.data.ok).toBe(true);
119
119
  expect(body.data.devOtp).toBeUndefined();
120
- expect(app.router.match("POST", "/auth/otp/resend")).toBeFalsy();
120
+ expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy();
121
+ await app.stop();
122
+ });
123
+
124
+ test("SMS resend issues a new request id, honors cooldown, and rejects another channel", async () => {
125
+ let now = 1_000_000;
126
+ const requestIds: string[] = [];
127
+ const verifications = createVerificationStore();
128
+ const driver = mockOtpSmsDriver();
129
+ const transport = driver.smsTransport as unknown as SmsOtpTransport;
130
+ const original = transport.sendOtp.bind(transport);
131
+ transport.sendOtp = async (opts) => {
132
+ requestIds.push(opts.requestId);
133
+ return original(opts);
134
+ };
135
+
136
+ const app = oke({
137
+ name: `otp-provider-resend-${crypto.randomUUID()}`,
138
+ env: "test",
139
+ registry: "ignore",
140
+ gate: { auth: { secret: SECRET } },
141
+ channel: { drivers: [driver] },
142
+ fx: { now: () => now },
143
+ }).plug(
144
+ otp({
145
+ mode: "provider",
146
+ resendCooldownMs: 60_000,
147
+ verifications,
148
+ now: () => now,
149
+ }),
150
+ );
151
+ await app.boot({ env: "test" });
152
+
153
+ const phone = "+15551234567";
154
+ expect((await app.fetch(jsonPost("/auth/otp/request", { phone }))).status).toBe(200);
155
+ const first = findActiveVerification(verifications, `otp:${phone}`, now);
156
+ expect(first?.value.startsWith("provider:")).toBe(true);
157
+ expect(first?.sealedOtp).toBeNull();
158
+ const firstId = first?.value.slice("provider:".length);
159
+
160
+ const cooled = await app.fetch(jsonPost("/auth/otp/resend", { phone }));
161
+ const cooledBody = (await cooled.json()) as { error?: { data?: { reason?: string } } };
162
+ expect(cooledBody.error?.data?.reason).toBe("resend_cooldown");
163
+
164
+ const other = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "email" }));
165
+ const otherBody = (await other.json()) as { error?: { data?: { reason?: string } } };
166
+ expect(otherBody.error?.data?.reason).toBe("invalid_channel");
167
+
168
+ now += 60_001;
169
+ const resent = await app.fetch(jsonPost("/auth/otp/resend", { phone, channel: "sms" }));
170
+ expect(resent.status).toBe(200);
171
+ const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } };
172
+ expect(resentBody.data.channel).toBe("sms");
173
+ expect(resentBody.data.devOtp).toBeUndefined();
174
+
175
+ const second = findActiveVerification(verifications, `otp:${phone}`, now);
176
+ const secondId = second?.value.slice("provider:".length);
177
+ expect(secondId).toBeDefined();
178
+ expect(secondId).not.toBe(firstId);
179
+ if (firstId === undefined || secondId === undefined) {
180
+ throw new Error("expected provider request ids");
181
+ }
182
+ expect(requestIds).toEqual([firstId, secondId]);
183
+ expect(findActiveVerification(verifications, `otp:${phone}`, now)?.sealedOtp).toBeNull();
184
+
121
185
  await app.stop();
122
186
  });
123
187
  });
@@ -77,7 +77,8 @@ export interface OtpProviderModeOptions extends OtpBaseOptions {
77
77
  readonly channels?: never;
78
78
  /** Forbidden in provider mode — code never exists server-side. */
79
79
  readonly exposeDevOtp?: never;
80
- readonly resendCooldownMs?: never;
80
+ /** Spacing between provider SMS resends. Default 60s. */
81
+ readonly resendCooldownMs?: number;
81
82
  readonly from?: never;
82
83
  }
83
84
 
@@ -216,10 +217,7 @@ export function otp(opts: OtpOptions): PluginDef {
216
217
  const identities = resolveSharedIdentities(opts);
217
218
  const verifications = opts.verifications ?? createVerificationStore();
218
219
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
219
- const resendCooldownMs =
220
- opts.mode === "app"
221
- ? (opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS)
222
- : DEFAULT_RESEND_COOLDOWN_MS;
220
+ const resendCooldownMs = opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS;
223
221
  const channels = opts.mode === "app" ? opts.channels : ([] as const);
224
222
  const exposeDevOtp = opts.mode === "app" && opts.exposeDevOtp === true;
225
223
  const from = opts.mode === "app" ? opts.from : undefined;
@@ -288,8 +286,61 @@ export function otp(opts: OtpOptions): PluginDef {
288
286
  attempts: 0,
289
287
  phone,
290
288
  sealedOtp: null,
289
+ lastDeliveredAt: now,
290
+ lastChannel: "sms",
291
+ });
292
+ return { ok: true as const, channel: "sms" as const };
293
+ },
294
+ });
295
+
296
+ const resendContract = {
297
+ in: z.object({
298
+ phone: z.string().min(8),
299
+ lang: z.enum(["en", "ar"]).optional(),
300
+ channel: z.enum(["sms", "whatsapp", "email"]).optional(),
301
+ }),
302
+ out: requestOut,
303
+ errors: { AuthFailed, AuthRateLimited },
304
+ };
305
+
306
+ const resend = flow("auth.resendOtp", {
307
+ plane: "user",
308
+ effects: { sends: ["sms-otp"] },
309
+ do: async (input, fx) => {
310
+ if (input.channel !== undefined && input.channel !== "sms") {
311
+ return fail("AuthFailed", { reason: "invalid_channel" });
312
+ }
313
+ const phone = input.phone.trim();
314
+ if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
315
+ const now = runtime.now();
316
+ const row = findActiveVerification(verifications, `otp:${phone}`, now);
317
+ if (!row || row.sealedOtp) {
318
+ return fail("AuthFailed", { reason: "invalid_credentials" });
319
+ }
320
+ if (row.lastDeliveredAt !== undefined && now - row.lastDeliveredAt < resendCooldownMs) {
321
+ return fail("AuthFailed", { reason: "resend_cooldown" });
322
+ }
323
+
324
+ const requestId = okid();
325
+ await fx.sendOtp({
326
+ to: phone,
327
+ requestId,
328
+ ...(input.lang ? { lang: input.lang } : {}),
329
+ });
330
+ invalidateVerifications(verifications, `otp:${phone}`, now);
331
+ putVerification(verifications, {
332
+ id: okid(),
333
+ identifier: `otp:${phone}`,
334
+ value: `provider:${requestId}`,
335
+ expiresAt: now + ttlMs,
336
+ createdAt: now,
337
+ consumedAt: null,
338
+ attempts: 0,
339
+ phone,
340
+ sealedOtp: null,
341
+ lastDeliveredAt: now,
342
+ lastChannel: "sms",
291
343
  });
292
- // Provider mode: resend-via-different-channel is impossible — provider owns the code.
293
344
  return { ok: true as const, channel: "sms" as const };
294
345
  },
295
346
  });
@@ -373,7 +424,8 @@ export function otp(opts: OtpOptions): PluginDef {
373
424
  return plugin("otp", { version: "0.0.1", config: configSnapshot })
374
425
  .needs("auth")
375
426
  .binding(bindPublicAuth("/otp/request", request, "otp", requestContract))
376
- .binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract));
427
+ .binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract))
428
+ .binding(bindPublicAuth("/otp/resend", resend, "otp", resendContract));
377
429
  }
378
430
 
379
431
  // ── App mode ───────────────────────────────────────────────────────────
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Absolute budgets with room under the cap also fail at 2× the last value.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ absoluteBudgetOk,
8
+ absoluteRegressionCeiling,
9
+ formatBudgetsReport,
10
+ type BudgetsSnapshot,
11
+ } from "./measure.ts";
12
+ import {
13
+ ABSOLUTE_REGRESSION_RATIO,
14
+ CLIENT_BUDGET_BYTES,
15
+ COLD_START_BUDGET_MS,
16
+ CONSOLE_BUDGET_BYTES,
17
+ KERNEL_EDGE_BUDGET_BYTES,
18
+ } from "./limits.ts";
19
+
20
+ describe("absolute regression ceilings", () => {
21
+ test("ratio is 2× so a confirmed doubling fails and a single noisy run can be re-measured", () => {
22
+ expect(ABSOLUTE_REGRESSION_RATIO).toBe(2);
23
+ });
24
+
25
+ test("cold start and console initial load grow a ceiling; kernel, client, and a zero baseline do not", () => {
26
+ expect(absoluteRegressionCeiling(undefined, COLD_START_BUDGET_MS, "ms")).toBeUndefined();
27
+ expect(absoluteRegressionCeiling(0, 1, "ms")).toBeUndefined();
28
+ expect(absoluteRegressionCeiling(8.915, COLD_START_BUDGET_MS, "ms")).toBe(17.83);
29
+ expect(absoluteRegressionCeiling(353_621, CONSOLE_BUDGET_BYTES, "bytes")).toBe(707_242);
30
+ expect(absoluteRegressionCeiling(12_556, KERNEL_EDGE_BUDGET_BYTES, "bytes")).toBeUndefined();
31
+ expect(absoluteRegressionCeiling(4598, CLIENT_BUDGET_BYTES, "bytes")).toBeUndefined();
32
+ });
33
+
34
+ test("ok requires both the cap and the ceiling", () => {
35
+ expect(absoluteBudgetOk(8.915, COLD_START_BUDGET_MS, 17.83)).toBe(true);
36
+ expect(absoluteBudgetOk(17.83, COLD_START_BUDGET_MS, 17.83)).toBe(false);
37
+ expect(absoluteBudgetOk(20, COLD_START_BUDGET_MS, 17.83)).toBe(false);
38
+ expect(absoluteBudgetOk(80, COLD_START_BUDGET_MS, undefined)).toBe(false);
39
+ expect(absoluteBudgetOk(12_556, KERNEL_EDGE_BUDGET_BYTES, undefined)).toBe(true);
40
+ });
41
+
42
+ test("the CI log prints the 2× ceiling and the absolute cap", () => {
43
+ const snapshot: BudgetsSnapshot = {
44
+ measuredAt: "2026-09-23T00:00:00.000Z",
45
+ version: "0.0.0",
46
+ budgets: [
47
+ {
48
+ id: "coldStartMedianMs",
49
+ label: "Cold start on Bun",
50
+ value: 20,
51
+ limit: COLD_START_BUDGET_MS,
52
+ regressionLimit: 17.83,
53
+ unit: "ms",
54
+ gate: "absolute",
55
+ group: "core",
56
+ ok: false,
57
+ },
58
+ ],
59
+ };
60
+ const report = formatBudgetsReport(snapshot);
61
+ expect(report).toContain("[FAIL] Cold start on Bun: 20.000 ms < 17.830 ms (cap 75.000 ms)");
62
+ });
63
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `okengine/http` must not statically import heavy modules.
3
+ * Forbidden ids live in {@link HTTP_STATIC_GRAPH_FORBIDDEN}.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { forbiddenHttpGraphHits, readStaticGraphSpecifiers } from "./http-graph.ts";
8
+
9
+ describe("okengine/http static import graph", () => {
10
+ test("zod and the hybrid-search runtime are not in the graph", async () => {
11
+ const specifiers = await readStaticGraphSpecifiers();
12
+ expect(specifiers.length).toBeGreaterThan(10);
13
+ expect(forbiddenHttpGraphHits(specifiers)).toEqual([]);
14
+ });
15
+
16
+ test("the forbidden list matches zod and search-runtime, and nothing nearby", () => {
17
+ expect(forbiddenHttpGraphHits(["node_modules/zod/index.js"])).toEqual(["zod"]);
18
+ expect(forbiddenHttpGraphHits(["zod"])).toEqual(["zod"]);
19
+ expect(forbiddenHttpGraphHits(["src/elements/store/search-runtime.ts"])).toEqual([
20
+ "hybrid-search runtime",
21
+ ]);
22
+ expect(forbiddenHttpGraphHits(["src/elements/store/search-lsh.ts"])).toEqual([]);
23
+ expect(forbiddenHttpGraphHits(["src/kernel/app.ts"])).toEqual([]);
24
+ });
25
+ });