okengine 0.3.6 → 0.5.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 (205) hide show
  1. package/AGENTS.md +2 -0
  2. package/package.json +14 -12
  3. package/site/content/docs/ai/index.mdx +24 -0
  4. package/site/content/docs/ai/llms-txt.mdx +3 -0
  5. package/site/content/docs/ai/meta.json +1 -1
  6. package/site/content/docs/ai/skills.mdx +5 -3
  7. package/site/content/docs/console/gates.mdx +46 -8
  8. package/site/content/docs/console/index.mdx +54 -0
  9. package/site/content/docs/console/meta.json +1 -0
  10. package/site/content/docs/elements/ai.mdx +2 -0
  11. package/site/content/docs/elements/channel.mdx +2 -0
  12. package/site/content/docs/elements/clock.mdx +1 -4
  13. package/site/content/docs/elements/flow.mdx +4 -10
  14. package/site/content/docs/elements/gate.mdx +189 -48
  15. package/site/content/docs/elements/index.mdx +45 -0
  16. package/site/content/docs/elements/meta.json +1 -1
  17. package/site/content/docs/elements/signal.mdx +1 -5
  18. package/site/content/docs/elements/store.mdx +27 -6
  19. package/site/content/docs/elements/vault.mdx +10 -11
  20. package/site/content/docs/get-started/basic-usage.mdx +79 -43
  21. package/site/content/docs/get-started/index.mdx +33 -0
  22. package/site/content/docs/get-started/installation.mdx +95 -43
  23. package/site/content/docs/get-started/introduction.mdx +128 -75
  24. package/site/content/docs/get-started/meta.json +1 -1
  25. package/site/content/docs/get-started/why.mdx +141 -0
  26. package/site/content/docs/index.mdx +9 -31
  27. package/site/content/docs/plugins/anonymous.mdx +95 -0
  28. package/site/content/docs/plugins/compression.mdx +2 -2
  29. package/site/content/docs/plugins/cors.mdx +2 -2
  30. package/site/content/docs/plugins/csrf.mdx +2 -2
  31. package/site/content/docs/plugins/email-otp.mdx +111 -0
  32. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +2 -2
  33. package/site/content/docs/plugins/index.mdx +69 -0
  34. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  35. package/site/content/docs/plugins/magic-link.mdx +112 -0
  36. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  37. package/site/content/docs/plugins/meta.json +10 -1
  38. package/site/content/docs/plugins/passkey.mdx +128 -0
  39. package/site/content/docs/plugins/phone-number.mdx +111 -0
  40. package/site/content/docs/plugins/two-factor.mdx +116 -0
  41. package/site/content/docs/plugins/username.mdx +117 -0
  42. package/site/content/docs/reference/client.mdx +331 -0
  43. package/site/content/docs/reference/configuration.mdx +1 -1
  44. package/site/content/docs/reference/environment-variables.mdx +5 -3
  45. package/site/content/docs/reference/fx.mdx +36 -8
  46. package/site/content/docs/reference/index.mdx +45 -0
  47. package/site/content/docs/reference/meta.json +11 -1
  48. package/site/content/docs/reference/plugins.mdx +25 -14
  49. package/src/auth/auth.test.ts +20 -2
  50. package/src/auth/bindings.ts +439 -0
  51. package/src/auth/breach-check.ts +112 -0
  52. package/src/auth/config.ts +288 -0
  53. package/src/auth/cookies.ts +123 -0
  54. package/src/auth/gate-auth.test.ts +379 -0
  55. package/src/auth/identity.ts +190 -0
  56. package/src/auth/index.ts +117 -1
  57. package/src/auth/method-context.ts +33 -0
  58. package/src/auth/operator.ts +27 -1
  59. package/src/auth/password-policy.test.ts +126 -0
  60. package/src/auth/password-policy.ts +77 -0
  61. package/src/auth/plugin.ts +62 -4
  62. package/src/auth/rate.ts +45 -0
  63. package/src/auth/schema.ts +260 -0
  64. package/src/auth/secondary-storage.ts +37 -0
  65. package/src/auth/sessions.ts +58 -1
  66. package/src/auth/tables.ts +4 -0
  67. package/src/auth/verification.ts +78 -0
  68. package/src/cli/competitor-mention-removal.test.ts +117 -0
  69. package/src/cli/dev.test.ts +3 -3
  70. package/src/cli/dev.ts +20 -0
  71. package/src/cli/meilisearch-local.test.ts +69 -0
  72. package/src/cli/meilisearch-local.ts +188 -0
  73. package/src/cli/schema.ts +95 -23
  74. package/src/client/auth.ts +120 -0
  75. package/src/client-react/index.ts +93 -0
  76. package/src/compiler/aot.test.ts +2 -1
  77. package/src/compiler/extract.ts +19 -0
  78. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  79. package/src/compiler/response.ts +16 -2
  80. package/src/console/server/app.ts +10 -6
  81. package/src/console/server/auth-rate.test.ts +3 -3
  82. package/src/console/server/bind.ts +12 -1
  83. package/src/console/server/channels.test.ts +1 -1
  84. package/src/console/server/console-gates.ts +14 -0
  85. package/src/console/server/console.test.ts +6 -6
  86. package/src/console/server/flows-invoke.test.ts +2 -2
  87. package/src/console/server/flows.ts +2 -0
  88. package/src/console/server/gates.ts +8 -1
  89. package/src/console/server/operator-db.test.ts +4 -4
  90. package/src/console/server/operator-db.ts +22 -4
  91. package/src/console/server/security.gate.test.ts +3 -3
  92. package/src/console/server/store.test.ts +1 -1
  93. package/src/console/server/store.ts +11 -1
  94. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  95. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  96. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  97. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  98. package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
  99. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  100. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  101. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  102. package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
  103. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  104. package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
  105. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  106. package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
  107. package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
  108. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  109. package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
  110. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  111. package/src/console/ui/dist/index.html +2 -2
  112. package/src/console/ui/gates/fixture.ts +4 -0
  113. package/src/console/ui/gates/types.ts +2 -0
  114. package/src/console/ui/shell/client.ts +1 -0
  115. package/src/docker/compose.ts +5 -0
  116. package/src/docker/docker.test.ts +41 -0
  117. package/src/docker/recipes/index.ts +10 -2
  118. package/src/docker/recipes/meilisearch.ts +31 -0
  119. package/src/drivers/conformance.test.ts +16 -1
  120. package/src/drivers/conformance.ts +40 -3
  121. package/src/drivers/index.ts +14 -2
  122. package/src/drivers/libsql.ts +4 -4
  123. package/src/drivers/meilisearch.integration.test.ts +77 -0
  124. package/src/drivers/meilisearch.test.ts +181 -0
  125. package/src/drivers/meilisearch.ts +208 -0
  126. package/src/drivers/memory.ts +4 -4
  127. package/src/drivers/pgvector.ts +6 -6
  128. package/src/drivers/types.ts +93 -12
  129. package/src/drivers/vault-driver-removal.test.ts +6 -0
  130. package/src/drivers/vault-types.ts +4 -4
  131. package/src/elements/ai/runtime.ts +6 -0
  132. package/src/elements/ai.test.ts +22 -0
  133. package/src/elements/gate/boot.ts +136 -0
  134. package/src/elements/gate/config.ts +69 -0
  135. package/src/elements/gate/declare.ts +51 -1
  136. package/src/elements/gate/runtime.ts +3 -1
  137. package/src/elements/gate.test.ts +77 -0
  138. package/src/elements/gate.ts +20 -1
  139. package/src/elements/index.ts +8 -0
  140. package/src/elements/store/index-boot.test.ts +49 -7
  141. package/src/elements/store/runtime.ts +50 -15
  142. package/src/elements/store.ts +2 -0
  143. package/src/elements/vault.test.ts +27 -4
  144. package/src/elements/vault.ts +1 -1
  145. package/src/index.ts +15 -0
  146. package/src/kernel/app.ts +253 -32
  147. package/src/kernel/boot-bind/store.test.ts +9 -0
  148. package/src/kernel/boot-bind/store.ts +30 -2
  149. package/src/kernel/boot.test.ts +40 -3
  150. package/src/kernel/boot.ts +8 -0
  151. package/src/kernel/call.test.ts +46 -2
  152. package/src/kernel/concurrency.test.ts +58 -0
  153. package/src/kernel/concurrency.ts +48 -0
  154. package/src/kernel/edge.test.ts +3 -3
  155. package/src/kernel/flow.test.ts +2 -2
  156. package/src/kernel/fx.test.ts +12 -2
  157. package/src/kernel/fx.ts +97 -5
  158. package/src/kernel/hooks.test.ts +4 -4
  159. package/src/kernel/index.ts +22 -1
  160. package/src/kernel/pipeline.test.ts +12 -8
  161. package/src/kernel/pipeline.ts +23 -4
  162. package/src/kernel/plugin/decorate.test.ts +3 -3
  163. package/src/kernel/plugin/scoping.test.ts +3 -3
  164. package/src/kernel/plugin-elements.test.ts +51 -0
  165. package/src/kernel/plugin-needs.test.ts +83 -0
  166. package/src/kernel/plugin-needs.ts +129 -0
  167. package/src/kernel/plugin.ts +101 -0
  168. package/src/kernel/redacted.ts +74 -0
  169. package/src/kernel/registry-isolation.test.ts +5 -5
  170. package/src/kernel/registry.ts +102 -3
  171. package/src/kernel/router.ts +3 -3
  172. package/src/manifest/types.ts +2 -0
  173. package/src/plugins/anonymous.ts +58 -0
  174. package/src/plugins/auth/shared.ts +121 -0
  175. package/src/plugins/auth-methods.test.ts +176 -0
  176. package/src/plugins/compression.test.ts +5 -5
  177. package/src/plugins/config-source.test.ts +1 -1
  178. package/src/plugins/cors.test.ts +16 -10
  179. package/src/plugins/csrf.test.ts +1 -1
  180. package/src/plugins/email-otp.ts +161 -0
  181. package/src/plugins/index.ts +31 -0
  182. package/src/plugins/ip-allowlist.test.ts +19 -9
  183. package/src/plugins/magic-link.ts +163 -0
  184. package/src/plugins/maintenance-mode.test.ts +9 -5
  185. package/src/plugins/passkey.ts +216 -0
  186. package/src/plugins/phone-number.ts +149 -0
  187. package/src/plugins/security-headers.test.ts +14 -14
  188. package/src/plugins/two-factor.ts +249 -0
  189. package/src/plugins/username.ts +148 -0
  190. package/src/runs/runs.test.ts +6 -2
  191. package/src/runtime/primitives.ts +37 -4
  192. package/src/runtime/serve.test.ts +3 -2
  193. package/src/runtime/types.ts +25 -2
  194. package/src/test/create-test-app.test.ts +1 -1
  195. package/src/test/create-test-app.ts +4 -1
  196. package/src/test/provisions.integration.test.ts +2 -2
  197. package/site/content/docs/get-started/comparison.mdx +0 -65
  198. package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
  199. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  200. package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
  201. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  202. package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
  203. package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
  204. package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
  205. package/src/drivers/vault-infisical.ts +0 -57
@@ -92,6 +92,64 @@ describe("fx.race — structured concurrency", () => {
92
92
  });
93
93
  });
94
94
 
95
+ describe("fx.using — scoped cleanup", () => {
96
+ test("release runs on success and on thrown error", async () => {
97
+ const fx = createFx({ flow: "t", effects: {} });
98
+ const released: string[] = [];
99
+
100
+ const ok = await fx.using(
101
+ async () => "res-a",
102
+ (r) => {
103
+ released.push(`ok:${r}`);
104
+ },
105
+ async (r) => r.toUpperCase(),
106
+ );
107
+ expect(ok).toBe("RES-A");
108
+ expect(released).toEqual(["ok:res-a"]);
109
+
110
+ await expect(
111
+ fx.using(
112
+ async () => "res-b",
113
+ async (r) => {
114
+ released.push(`err:${r}`);
115
+ },
116
+ async () => {
117
+ throw new Error("boom");
118
+ },
119
+ ),
120
+ ).rejects.toThrow("boom");
121
+ expect(released).toEqual(["ok:res-a", "err:res-b"]);
122
+ });
123
+
124
+ test("release runs when a sibling fx.race winner aborts mid-use", async () => {
125
+ const fx = createFx({ flow: "t", effects: {} });
126
+ let released = false;
127
+
128
+ const winner = await fx.race([
129
+ () =>
130
+ fx.using(
131
+ async () => "lock",
132
+ () => {
133
+ released = true;
134
+ },
135
+ () =>
136
+ new Promise<never>(() => {
137
+ // Stay pending until ambient abort releases us.
138
+ }),
139
+ ),
140
+ async () => {
141
+ await new Promise((r) => setTimeout(r, 5));
142
+ return "fast";
143
+ },
144
+ ]);
145
+
146
+ expect(winner).toBe("fast");
147
+ // Let the abort listener settle.
148
+ await new Promise((r) => setTimeout(r, 20));
149
+ expect(released).toBe(true);
150
+ });
151
+ });
152
+
95
153
  describe("fx.retry", () => {
96
154
  test("retries then succeeds", async () => {
97
155
  const fx = createFx({ flow: "t", effects: {} });
@@ -8,6 +8,7 @@
8
8
  import { parseDurationMs } from "../elements/clock/duration.ts";
9
9
  import {
10
10
  abortableSleep,
11
+ abortError,
11
12
  currentAbortSignal,
12
13
  isAbortError,
13
14
  linkAbort,
@@ -144,6 +145,53 @@ export async function fxRace<T>(thunks: ReadonlyArray<FxThunk<T>>): Promise<T> {
144
145
  });
145
146
  }
146
147
 
148
+ /**
149
+ * Scope a resource to a unit of work — `release` runs exactly once when
150
+ * `use` settles or when the ambient abort signal fires, whichever comes
151
+ * first. Uses the same AbortSignal as {@link Fx.signal}; no second
152
+ * cancellation channel.
153
+ *
154
+ * Process-local only: do not journal acquire/release, and do not hold
155
+ * handles across durable park/resume — journal replay returns values and
156
+ * never re-enters step bodies.
157
+ *
158
+ * @param acquire - Open the resource
159
+ * @param release - Cleanup, always run
160
+ * @param use - Work with the resource
161
+ */
162
+ export async function fxUsing<A, T>(
163
+ acquire: () => A | Promise<A>,
164
+ release: (resource: A) => void | Promise<void>,
165
+ use: (resource: A) => T | Promise<T>,
166
+ ): Promise<T> {
167
+ const resource = await acquire();
168
+ const signal = currentAbortSignal();
169
+ let released = false;
170
+
171
+ const releaseOnce = async (): Promise<void> => {
172
+ if (released) return;
173
+ released = true;
174
+ await release(resource);
175
+ };
176
+
177
+ const onAbort = (): void => {
178
+ void releaseOnce();
179
+ };
180
+
181
+ if (signal.aborted) {
182
+ await releaseOnce();
183
+ throw abortError(signal.reason);
184
+ }
185
+ signal.addEventListener("abort", onAbort);
186
+
187
+ try {
188
+ return await use(resource);
189
+ } finally {
190
+ signal.removeEventListener("abort", onAbort);
191
+ await releaseOnce();
192
+ }
193
+ }
194
+
147
195
  /**
148
196
  * Retry a thunk with exponential backoff and optional full jitter.
149
197
  *
@@ -26,7 +26,7 @@ describe("plugin edge handlers", () => {
26
26
  }
27
27
  return undefined;
28
28
  });
29
- const app = oke({ name: "edge" }).plug(edge);
29
+ const app = oke({ autoBoot: false, name: "edge" }).plug(edge);
30
30
 
31
31
  const preflight = await app.fetch(new Request("http://localhost/x", { method: "OPTIONS" }));
32
32
  expect(preflight.status).toBe(204);
@@ -42,13 +42,13 @@ describe("plugin edge handlers", () => {
42
42
  const answer = plugin("edge-answer", { version: "0.0.1" }).edge(
43
43
  () => new Response("answered", { status: 418 }),
44
44
  );
45
- const app = oke({ name: "edge-order" }).plug(pass).plug(answer);
45
+ const app = oke({ autoBoot: false, name: "edge-order" }).plug(pass).plug(answer);
46
46
 
47
47
  const res = await app.fetch(new Request("http://localhost/nowhere", { method: "OPTIONS" }));
48
48
  expect(res.status).toBe(418);
49
49
  expect(await res.text()).toBe("answered");
50
50
 
51
- const noPlugins = oke({ name: "edge-none" });
51
+ const noPlugins = oke({ autoBoot: false, name: "edge-none" });
52
52
  const missing = await noPlugins.fetch(
53
53
  new Request("http://localhost/nowhere", { method: "OPTIONS" }),
54
54
  );
@@ -45,7 +45,7 @@ describe("flow — one species", () => {
45
45
  on(http.post("/work"), shared);
46
46
  on(signal, shared);
47
47
 
48
- const app = oke({ name: "one-species" });
48
+ const app = oke({ autoBoot: false, name: "one-species" });
49
49
  expect(app.bindings).toHaveLength(2);
50
50
  expect(app.bindings[0]?.flow).toBe(shared);
51
51
  expect(app.bindings[1]?.flow).toBe(shared);
@@ -115,7 +115,7 @@ describe("five trigger kinds", () => {
115
115
  }),
116
116
  );
117
117
 
118
- const app = oke({ name: "five" });
118
+ const app = oke({ autoBoot: false, name: "five" });
119
119
 
120
120
  const res = await app.fetch(new Request("http://localhost/abc", { method: "GET" }));
121
121
  expect(res.status).toBe(200);
@@ -9,6 +9,7 @@ import {
9
9
  type Fx,
10
10
  type FxStubStoreHandle,
11
11
  } from "./fx.ts";
12
+ import { Redacted } from "./redacted.ts";
12
13
 
13
14
  /** Narrow stub handle for tests that exercise the in-memory store. */
14
15
  function stub(fx: Fx, ref: string): FxStubStoreHandle {
@@ -99,7 +100,7 @@ describe("fx — effect ledger", () => {
99
100
  await fx.emit("order-placed", {});
100
101
  await fx.send("order-confirmed", { to: "u1" });
101
102
  await fx.ask("triage@1", {});
102
- expect(fx.vault("STRIPE_KEY")).toBe("sk_test");
103
+ expect(fx.vault("STRIPE_KEY").reveal()).toBe("sk_test");
103
104
  await fx.call("payments.charge", {});
104
105
 
105
106
  expect(ledger.entries).toHaveLength(7);
@@ -130,7 +131,7 @@ describe("fx — wholesale swap", () => {
130
131
  sleep: async () => undefined,
131
132
  },
132
133
  vault() {
133
- return "";
134
+ return new Redacted("");
134
135
  },
135
136
  cache: {
136
137
  get: async () => undefined,
@@ -162,6 +163,7 @@ describe("fx — wholesale swap", () => {
162
163
  id: () => "fixed-id",
163
164
  auth: { userId: "u1", scopes: new Set(["a"]) },
164
165
  operator: { id: null },
166
+ principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },
165
167
  tenant: { id: "t1" },
166
168
  fail,
167
169
  json: {
@@ -193,6 +195,14 @@ describe("fx — wholesale swap", () => {
193
195
  retry(fn) {
194
196
  return Promise.resolve().then(fn);
195
197
  },
198
+ async using(acquire, release, use) {
199
+ const resource = await acquire();
200
+ try {
201
+ return await use(resource);
202
+ } finally {
203
+ await release(resource);
204
+ }
205
+ },
196
206
  };
197
207
 
198
208
  // Any code that accepts `Fx` can run against a total replacement.
package/src/kernel/fx.ts CHANGED
@@ -33,7 +33,15 @@ import {
33
33
  } from "./dry-run.ts";
34
34
  import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
35
35
  import { currentAbortSignal } from "./abort-scope.ts";
36
- import { fxAll, fxRace, fxRetry, type FxRetryOptions, type FxThunk } from "./concurrency.ts";
36
+ import {
37
+ fxAll,
38
+ fxRace,
39
+ fxRetry,
40
+ fxUsing,
41
+ type FxRetryOptions,
42
+ type FxThunk,
43
+ } from "./concurrency.ts";
44
+ import { maskRedactedDeep, Redacted } from "./redacted.ts";
37
45
  import type { JournalSession } from "./journal.ts";
38
46
  import type { RunTelemetry } from "./run-telemetry.ts";
39
47
 
@@ -67,6 +75,33 @@ export interface FxOperator {
67
75
  readonly id: string | null;
68
76
  }
69
77
 
78
+ /**
79
+ * Read-only originating identity for audit / attribution.
80
+ * Never consulted by gate evaluation — authorization stays on {@link Fx.auth}.
81
+ */
82
+ export interface FxPrincipal {
83
+ readonly userId: string | null;
84
+ readonly operatorId: string | null;
85
+ readonly scopes: ReadonlySet<string>;
86
+ readonly verified?: boolean;
87
+ readonly plane?: "user" | "operator";
88
+ }
89
+
90
+ /**
91
+ * Freeze a principal snapshot for propagation across {@link Fx.call}.
92
+ *
93
+ * @param p - Live or frozen principal
94
+ */
95
+ export function freezePrincipal(p: FxPrincipal): FxPrincipal {
96
+ return {
97
+ userId: p.userId,
98
+ operatorId: p.operatorId,
99
+ scopes: new Set(p.scopes),
100
+ ...(p.verified !== undefined ? { verified: p.verified } : {}),
101
+ ...(p.plane !== undefined ? { plane: p.plane } : {}),
102
+ };
103
+ }
104
+
70
105
  /** Active tenant (multi-tenancy as a dimension of `fx`). */
71
106
  export interface FxTenant {
72
107
  readonly id: string | null;
@@ -262,9 +297,13 @@ export interface Fx {
262
297
  /**
263
298
  * Read a vault secret (records `secret`).
264
299
  *
300
+ * Returns a {@link Redacted} — printing / logging / serializing it yields a
301
+ * placeholder, never the value. Call `.reveal()` at the one boundary that
302
+ * needs the real value (e.g. passing a credential to a driver).
303
+ *
265
304
  * @param secret - Secret name or handle
266
305
  */
267
- vault(secret: NamedRef): string;
306
+ vault(secret: NamedRef): Redacted<string>;
268
307
  /** Cache surface. */
269
308
  readonly cache: FxCache;
270
309
  /**
@@ -322,6 +361,12 @@ export interface Fx {
322
361
  readonly auth: FxAuth;
323
362
  /** Operator-plane principal. */
324
363
  readonly operator: FxOperator;
364
+ /**
365
+ * Read-only originating identity (audit / attribution).
366
+ * On HTTP entry, tracks the resolved principal; across {@link Fx.call},
367
+ * propagates explicitly — never copied into {@link Fx.auth}.
368
+ */
369
+ readonly principal: FxPrincipal;
325
370
  /** Active tenant. */
326
371
  readonly tenant: FxTenant;
327
372
  /**
@@ -369,6 +414,20 @@ export interface Fx {
369
414
  * @param opts - Retry policy
370
415
  */
371
416
  retry<T>(fn: FxThunk<T>, opts?: FxRetryOptions): Promise<T>;
417
+ /**
418
+ * Scope a resource to `use` — `release` runs exactly once when `use`
419
+ * settles or the ambient abort signal fires (e.g. a sibling `fx.race`
420
+ * winner). Same-attempt cleanup; not journaled.
421
+ *
422
+ * @param acquire - Open the resource
423
+ * @param release - Cleanup, always run
424
+ * @param use - Work with the resource
425
+ */
426
+ using<A, T>(
427
+ acquire: () => A | Promise<A>,
428
+ release: (resource: A) => void | Promise<void>,
429
+ use: (resource: A) => T | Promise<T>,
430
+ ): Promise<T>;
372
431
  }
373
432
 
374
433
  /**
@@ -393,6 +452,11 @@ export interface CreateFxOptions {
393
452
  readonly auth?: FxAuth;
394
453
  /** Operator principal. */
395
454
  readonly operator?: FxOperator;
455
+ /**
456
+ * Frozen originating principal (e.g. propagated across {@link Fx.call}).
457
+ * When omitted, {@link Fx.principal} tracks live {@link Fx.auth} / {@link Fx.operator}.
458
+ */
459
+ readonly principal?: FxPrincipal;
396
460
  /** Tenant. */
397
461
  readonly tenant?: FxTenant;
398
462
  /** Secret name → value map for `fx.vault`. */
@@ -496,6 +560,27 @@ export function createFxContext(options: CreateFxOptions): FxContext {
496
560
  };
497
561
  const operator: FxOperator = options.operator ?? { id: null };
498
562
  const tenant: FxTenant = options.tenant ?? { id: null };
563
+ const principal: FxPrincipal =
564
+ options.principal ??
565
+ ({
566
+ get userId() {
567
+ return auth.userId;
568
+ },
569
+ get operatorId() {
570
+ return operator.id;
571
+ },
572
+ get scopes() {
573
+ return auth.scopes;
574
+ },
575
+ get verified() {
576
+ return auth.verified;
577
+ },
578
+ get plane() {
579
+ if (operator.id) return "operator" as const;
580
+ if (auth.userId) return "user" as const;
581
+ return undefined;
582
+ },
583
+ } satisfies FxPrincipal);
499
584
  const journal = options.journal;
500
585
 
501
586
  async function gated<T>(
@@ -850,9 +935,12 @@ export function createFxContext(options: CreateFxOptions): FxContext {
850
935
  data?: Record<string, unknown>,
851
936
  ): { message: string; data?: Record<string, unknown> } {
852
937
  const vault = options.vaultRuntime;
853
- if (!vault) return { message, data };
938
+ // Redacted<T> never yields the real value, but replace instances with a
939
+ // placeholder so payloads stay plain JSON (and never re-wrap on replay).
940
+ const maskedData = data ? maskRedactedDeep(data) : undefined;
941
+ if (!vault) return { message, data: maskedData };
854
942
  const safeMessage = vault.redactString(message);
855
- const safeData = data ? (vault.redact(data) as Record<string, unknown>) : undefined;
943
+ const safeData = maskedData ? (vault.redact(maskedData) as Record<string, unknown>) : undefined;
856
944
  return { message: safeMessage, data: safeData };
857
945
  }
858
946
 
@@ -925,7 +1013,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
925
1013
  duration: Math.max(0, now() - timestamp),
926
1014
  reversibility: reversibilityOf("secret"),
927
1015
  });
928
- return value;
1016
+ return new Redacted(value);
929
1017
  },
930
1018
  cache,
931
1019
  send(template, opts) {
@@ -1040,6 +1128,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1040
1128
  },
1041
1129
  auth,
1042
1130
  operator,
1131
+ principal,
1043
1132
  tenant,
1044
1133
  fail,
1045
1134
  json: {
@@ -1079,6 +1168,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1079
1168
  retry(fn, opts) {
1080
1169
  return fxRetry(fn, opts);
1081
1170
  },
1171
+ using(acquire, release, use) {
1172
+ return fxUsing(acquire, release, use);
1173
+ },
1082
1174
  };
1083
1175
 
1084
1176
  return { fx, ledger, capability };
@@ -74,7 +74,7 @@ describe("hooks — order and composition", () => {
74
74
 
75
75
  on(http.get("/ordered"), f);
76
76
 
77
- const app = oke({ name: "hooks-order" })
77
+ const app = oke({ autoBoot: false, name: "hooks-order" })
78
78
  .hook("onRequest", () => {
79
79
  order.push("app:onRequest");
80
80
  })
@@ -128,7 +128,7 @@ describe("hooks — order and composition", () => {
128
128
  }),
129
129
  );
130
130
 
131
- const app = oke({ name: "sc-res" })
131
+ const app = oke({ autoBoot: false, name: "sc-res" })
132
132
  .hook("onAuth", () => {
133
133
  order.push("onAuth");
134
134
  return new Response("denied", { status: 401 });
@@ -157,7 +157,7 @@ describe("hooks — order and composition", () => {
157
157
  }),
158
158
  );
159
159
 
160
- const app = oke({ name: "data-res" }).hook("onResponse", (ctx) => {
160
+ const app = oke({ autoBoot: false, name: "data-res" }).hook("onResponse", (ctx) => {
161
161
  seenAtHook = ctx.response;
162
162
  if (!ctx.response) return;
163
163
  const headers = new Headers(ctx.response.headers);
@@ -192,7 +192,7 @@ describe("hooks — order and composition", () => {
192
192
  }),
193
193
  );
194
194
 
195
- const app = oke({ name: "sc-err" })
195
+ const app = oke({ autoBoot: false, name: "sc-err" })
196
196
  .hook("beforeHandle", () => {
197
197
  order.push("beforeHandle");
198
198
  return fail("Nope", {});
@@ -103,6 +103,7 @@ export {
103
103
  export {
104
104
  createFx,
105
105
  createFxContext,
106
+ freezePrincipal,
106
107
  isJsonResult,
107
108
  jsonResultBrand,
108
109
  resolveName,
@@ -118,6 +119,7 @@ export {
118
119
  type FxJson,
119
120
  type FxLog,
120
121
  type FxOperator,
122
+ type FxPrincipal,
121
123
  type FxRetryOptions,
122
124
  type FxSearchOptions,
123
125
  type FxSendOptions,
@@ -137,7 +139,16 @@ export {
137
139
  withAbortSignal,
138
140
  } from "./abort-scope.ts";
139
141
 
140
- export { defaultRetryWhen, fxAll, fxRace, fxRetry, resolveRetryDelayMs } from "./concurrency.ts";
142
+ export {
143
+ defaultRetryWhen,
144
+ fxAll,
145
+ fxRace,
146
+ fxRetry,
147
+ fxUsing,
148
+ resolveRetryDelayMs,
149
+ } from "./concurrency.ts";
150
+
151
+ export { isRedacted, maskRedactedDeep, REDACTED_PLACEHOLDER, Redacted } from "./redacted.ts";
141
152
 
142
153
  export {
143
154
  DryRunWriteIsolationError,
@@ -235,6 +246,16 @@ export {
235
246
  type PluginScope,
236
247
  } from "./registry.ts";
237
248
 
249
+ export {
250
+ assertPluginNeeds,
251
+ buildAvailableNeedTokens,
252
+ collectUnmetPluginNeeds,
253
+ PluginNeedsError,
254
+ ELEMENT_NEED_TOKENS,
255
+ type PluginNeedGap,
256
+ type PluginNeedsContext,
257
+ } from "./plugin-needs.ts";
258
+
238
259
  export {
239
260
  createRouter,
240
261
  isUnsupportedByRegExp,
@@ -79,7 +79,7 @@ describe("pipeline — Unauthorized for anonymous", () => {
79
79
  const runs = createRunsRuntime({ driver: memoryRunsDriver });
80
80
  const app = oke({
81
81
  name: "gates-pipeline",
82
- gates: [member],
82
+ gate: { policies: [member] },
83
83
  runs,
84
84
  env: "test",
85
85
  });
@@ -116,7 +116,7 @@ describe("pipeline — Unauthorized for anonymous", () => {
116
116
 
117
117
  const app = oke({
118
118
  name: "gates-forbid",
119
- gates: [member, canOrder],
119
+ gate: { policies: [member, canOrder] },
120
120
  env: "test",
121
121
  });
122
122
  await app.boot({ env: "test", gates: [member, canOrder] });
@@ -157,7 +157,7 @@ describe("pipeline — evaluated gates on the run", () => {
157
157
  await runs.open();
158
158
  const app = oke({
159
159
  name: "gates-eval",
160
- gates: [member, canOrder],
160
+ gate: { policies: [member, canOrder] },
161
161
  runs,
162
162
  env: "test",
163
163
  });
@@ -208,9 +208,11 @@ describe("pipeline — Bearer cryptographic verification", () => {
208
208
  const sessions = createSessionStore();
209
209
  const app = oke({
210
210
  name: "auth-forge",
211
- gates: [member],
211
+ gate: {
212
+ auth: { secret: "hmac-secret-for-tests", sessions, http: false },
213
+ policies: [member],
214
+ },
212
215
  env: "local",
213
- auth: { secret: "hmac-secret-for-tests", sessions },
214
216
  startScheduler: false,
215
217
  });
216
218
  await app.boot({ env: "local", gates: [member], startScheduler: false });
@@ -262,9 +264,11 @@ describe("pipeline — Bearer cryptographic verification", () => {
262
264
  });
263
265
  const app = oke({
264
266
  name: "auth-expired",
265
- gates: [member],
267
+ gate: {
268
+ auth: { secret, sessions, now: () => nowMs, http: false },
269
+ policies: [member],
270
+ },
266
271
  env: "local",
267
- auth: { secret, sessions, now: () => nowMs },
268
272
  elements: { clock: clockRt },
269
273
  startScheduler: false,
270
274
  });
@@ -314,7 +318,7 @@ describe("pipeline — Bearer cryptographic verification", () => {
314
318
 
315
319
  const app = oke({
316
320
  name: "no-inject",
317
- gates: [member],
321
+ gate: { policies: [member] },
318
322
  env: "local",
319
323
  startScheduler: false,
320
324
  });
@@ -52,6 +52,13 @@ export interface PipelineDeps {
52
52
  * @throws On forge / expiry / revoke — pipeline maps to `Unauthorized`
53
53
  */
54
54
  readonly verifyBearer?: (token: string) => Promise<ResolvedPrincipal>;
55
+ /**
56
+ * Optional token extraction (e.g. cookie → Bearer when cookies enabled).
57
+ * When omitted, only `Authorization: Bearer` is read.
58
+ *
59
+ * @param request - Incoming HTTP request
60
+ */
61
+ readonly resolveToken?: (request: Request) => string | undefined;
55
62
  /**
56
63
  * When true, `ctx.state.principal` / execute `extras.principal` injection
57
64
  * is honoured (test harness only). Must be false for production HTTP.
@@ -158,14 +165,22 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
158
165
  delete ctx.state.principal;
159
166
  }
160
167
 
161
- const header = ctx.request?.headers.get("authorization");
162
- if (header?.startsWith("Bearer ")) {
163
- const token = header.slice("Bearer ".length).trim();
168
+ let token: string | undefined;
169
+ if (deps.resolveToken && ctx.request) {
170
+ token = deps.resolveToken(ctx.request);
171
+ } else {
172
+ const header = ctx.request?.headers.get("authorization");
173
+ if (header?.startsWith("Bearer ")) {
174
+ token = header.slice("Bearer ".length).trim() || undefined;
175
+ }
176
+ }
177
+
178
+ if (token !== undefined) {
164
179
  if (!token) {
165
180
  return fail("Unauthorized", {});
166
181
  }
167
182
  if (!deps.verifyBearer) {
168
- // Bearer present but no auth binding — refuse rather than trust.
183
+ // Token present but no auth binding — refuse rather than trust.
169
184
  return fail("Unauthorized", {});
170
185
  }
171
186
  try {
@@ -187,6 +202,10 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
187
202
 
188
203
  const beforeHandle: HookFn = async (ctx, fxOrErr) => {
189
204
  const names = gateNamesOf(ctx.trigger);
205
+ // Empty chain: boot audit should have rejected this when unguardedHttp is
206
+ // "deny". If we still reach here without a gate runtime, skip only for the
207
+ // explicit `autoBoot: false` escape hatch. Public sentinel is a normal
208
+ // named gate in `names`.
190
209
  if (names.length === 0 || !deps.gates) return;
191
210
 
192
211
  const policyCtx = policyContextOf(fxOrErr as Fx, ctx);
@@ -37,7 +37,7 @@ describe("plugin decorate — types and runtime", () => {
37
37
  }),
38
38
  );
39
39
 
40
- const app = oke({ name: "decorate" }).plug(audit);
40
+ const app = oke({ autoBoot: false, name: "decorate" }).plug(audit);
41
41
  await app.fetch(new Request("http://localhost/x"));
42
42
 
43
43
  expect(seen).toEqual({ enabled: true, retain: "2y" });
@@ -51,7 +51,7 @@ describe("plugin decorate — types and runtime", () => {
51
51
  currency: "SAR" as const,
52
52
  });
53
53
 
54
- const app = oke({ name: "types" }).plug(withAudit).plug(withBilling);
54
+ const app = oke({ autoBoot: false, name: "types" }).plug(withAudit).plug(withBilling);
55
55
 
56
56
  type AppDeco = typeof app extends OkeApp<infer D> ? D : never;
57
57
  type HasBoth = ExpectExtends<
@@ -94,7 +94,7 @@ describe("plugin decorate — types and runtime", () => {
94
94
  }),
95
95
  );
96
96
 
97
- const app = oke({ name: "deco-scope" });
97
+ const app = oke({ autoBoot: false, name: "deco-scope" });
98
98
  app.unit("orders").plug(plugin("flag", { version: "1.0.0" }).decorate("flag", "orders-only"));
99
99
 
100
100
  await app.fetch(new Request("http://localhost/orders"));
@@ -48,7 +48,7 @@ describe("plugin scoping — attachment point is the scope", () => {
48
48
  fired.push("rate-limit");
49
49
  });
50
50
 
51
- const app = oke({ name: "scoping" });
51
+ const app = oke({ autoBoot: false, name: "scoping" });
52
52
  app.unit("orders").plug(rateLimit);
53
53
 
54
54
  await app.fetch(new Request("http://localhost/orders"));
@@ -87,7 +87,7 @@ describe("plugin scoping — attachment point is the scope", () => {
87
87
  fired.push("audit");
88
88
  });
89
89
 
90
- const app = oke({ name: "app-scope" }).plug(audit);
90
+ const app = oke({ autoBoot: false, name: "app-scope" }).plug(audit);
91
91
 
92
92
  await app.fetch(new Request("http://localhost/a"));
93
93
  await app.fetch(new Request("http://localhost/b"));
@@ -121,7 +121,7 @@ describe("plugin scoping — attachment point is the scope", () => {
121
121
  on(http.get("/marked"), marked);
122
122
  on(http.get("/plain"), plain);
123
123
 
124
- const app = oke({ name: "flow-scope" });
124
+ const app = oke({ autoBoot: false, name: "flow-scope" });
125
125
 
126
126
  await app.fetch(new Request("http://localhost/marked"));
127
127
  await app.fetch(new Request("http://localhost/plain"));