okengine 0.4.3 → 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 (127) hide show
  1. package/package.json +3 -1
  2. package/site/content/docs/ai/index.mdx +24 -0
  3. package/site/content/docs/ai/llms-txt.mdx +3 -0
  4. package/site/content/docs/ai/meta.json +1 -1
  5. package/site/content/docs/console/gates.mdx +46 -8
  6. package/site/content/docs/console/index.mdx +54 -0
  7. package/site/content/docs/console/meta.json +1 -0
  8. package/site/content/docs/elements/gate.mdx +187 -48
  9. package/site/content/docs/elements/index.mdx +45 -0
  10. package/site/content/docs/elements/meta.json +1 -1
  11. package/site/content/docs/get-started/basic-usage.mdx +4 -3
  12. package/site/content/docs/get-started/index.mdx +33 -0
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/index.mdx +9 -31
  15. package/site/content/docs/plugins/anonymous.mdx +95 -0
  16. package/site/content/docs/plugins/compression.mdx +2 -2
  17. package/site/content/docs/plugins/cors.mdx +2 -2
  18. package/site/content/docs/plugins/csrf.mdx +2 -2
  19. package/site/content/docs/plugins/email-otp.mdx +111 -0
  20. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
  21. package/site/content/docs/plugins/index.mdx +69 -0
  22. package/site/content/docs/plugins/magic-link.mdx +112 -0
  23. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  24. package/site/content/docs/plugins/meta.json +10 -1
  25. package/site/content/docs/plugins/passkey.mdx +128 -0
  26. package/site/content/docs/plugins/phone-number.mdx +111 -0
  27. package/site/content/docs/plugins/two-factor.mdx +116 -0
  28. package/site/content/docs/plugins/username.mdx +117 -0
  29. package/site/content/docs/reference/client.mdx +331 -0
  30. package/site/content/docs/reference/fx.mdx +20 -5
  31. package/site/content/docs/reference/index.mdx +45 -0
  32. package/site/content/docs/reference/meta.json +11 -1
  33. package/site/content/docs/reference/plugins.mdx +25 -14
  34. package/src/auth/auth.test.ts +20 -2
  35. package/src/auth/bindings.ts +439 -0
  36. package/src/auth/breach-check.ts +112 -0
  37. package/src/auth/config.ts +288 -0
  38. package/src/auth/cookies.ts +123 -0
  39. package/src/auth/gate-auth.test.ts +379 -0
  40. package/src/auth/identity.ts +190 -0
  41. package/src/auth/index.ts +117 -1
  42. package/src/auth/method-context.ts +33 -0
  43. package/src/auth/operator.ts +27 -1
  44. package/src/auth/password-policy.test.ts +126 -0
  45. package/src/auth/password-policy.ts +77 -0
  46. package/src/auth/plugin.ts +62 -4
  47. package/src/auth/rate.ts +45 -0
  48. package/src/auth/schema.ts +260 -0
  49. package/src/auth/secondary-storage.ts +37 -0
  50. package/src/auth/sessions.ts +58 -1
  51. package/src/auth/tables.ts +4 -0
  52. package/src/auth/verification.ts +78 -0
  53. package/src/cli/dev.test.ts +3 -3
  54. package/src/cli/schema.ts +95 -23
  55. package/src/client/auth.ts +120 -0
  56. package/src/client-react/index.ts +93 -0
  57. package/src/compiler/aot.test.ts +2 -1
  58. package/src/compiler/extract.ts +19 -0
  59. package/src/compiler/response.ts +16 -2
  60. package/src/console/server/app.ts +10 -6
  61. package/src/console/server/auth-rate.test.ts +3 -3
  62. package/src/console/server/bind.ts +12 -1
  63. package/src/console/server/channels.test.ts +1 -1
  64. package/src/console/server/console-gates.ts +14 -0
  65. package/src/console/server/console.test.ts +6 -6
  66. package/src/console/server/flows-invoke.test.ts +2 -2
  67. package/src/console/server/flows.ts +2 -0
  68. package/src/console/server/gates.ts +8 -1
  69. package/src/console/server/operator-db.test.ts +4 -4
  70. package/src/console/server/operator-db.ts +22 -4
  71. package/src/console/server/security.gate.test.ts +3 -3
  72. package/src/console/ui/gates/fixture.ts +4 -0
  73. package/src/console/ui/gates/types.ts +2 -0
  74. package/src/console/ui/shell/client.ts +1 -0
  75. package/src/elements/gate/boot.ts +136 -0
  76. package/src/elements/gate/config.ts +69 -0
  77. package/src/elements/gate/declare.ts +51 -1
  78. package/src/elements/gate/runtime.ts +3 -1
  79. package/src/elements/gate.test.ts +77 -0
  80. package/src/elements/gate.ts +20 -1
  81. package/src/elements/index.ts +8 -0
  82. package/src/index.ts +11 -0
  83. package/src/kernel/app.ts +253 -32
  84. package/src/kernel/boot.test.ts +40 -3
  85. package/src/kernel/boot.ts +8 -0
  86. package/src/kernel/call.test.ts +46 -2
  87. package/src/kernel/edge.test.ts +3 -3
  88. package/src/kernel/flow.test.ts +2 -2
  89. package/src/kernel/fx.test.ts +1 -0
  90. package/src/kernel/fx.ts +60 -0
  91. package/src/kernel/hooks.test.ts +4 -4
  92. package/src/kernel/index.ts +12 -0
  93. package/src/kernel/pipeline.test.ts +12 -8
  94. package/src/kernel/pipeline.ts +23 -4
  95. package/src/kernel/plugin/decorate.test.ts +3 -3
  96. package/src/kernel/plugin/scoping.test.ts +3 -3
  97. package/src/kernel/plugin-elements.test.ts +51 -0
  98. package/src/kernel/plugin-needs.test.ts +83 -0
  99. package/src/kernel/plugin-needs.ts +129 -0
  100. package/src/kernel/plugin.ts +101 -0
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/registry.ts +102 -3
  103. package/src/manifest/types.ts +2 -0
  104. package/src/plugins/anonymous.ts +58 -0
  105. package/src/plugins/auth/shared.ts +121 -0
  106. package/src/plugins/auth-methods.test.ts +176 -0
  107. package/src/plugins/compression.test.ts +5 -5
  108. package/src/plugins/config-source.test.ts +1 -1
  109. package/src/plugins/cors.test.ts +16 -10
  110. package/src/plugins/csrf.test.ts +1 -1
  111. package/src/plugins/email-otp.ts +161 -0
  112. package/src/plugins/index.ts +31 -0
  113. package/src/plugins/ip-allowlist.test.ts +19 -9
  114. package/src/plugins/magic-link.ts +163 -0
  115. package/src/plugins/maintenance-mode.test.ts +9 -5
  116. package/src/plugins/passkey.ts +216 -0
  117. package/src/plugins/phone-number.ts +149 -0
  118. package/src/plugins/security-headers.test.ts +14 -14
  119. package/src/plugins/two-factor.ts +249 -0
  120. package/src/plugins/username.ts +148 -0
  121. package/src/runs/runs.test.ts +6 -2
  122. package/src/runtime/primitives.ts +37 -4
  123. package/src/runtime/serve.test.ts +3 -2
  124. package/src/runtime/types.ts +25 -2
  125. package/src/test/create-test-app.test.ts +1 -1
  126. package/src/test/create-test-app.ts +4 -1
  127. package/src/test/provisions.integration.test.ts +1 -1
package/src/kernel/fx.ts CHANGED
@@ -75,6 +75,33 @@ export interface FxOperator {
75
75
  readonly id: string | null;
76
76
  }
77
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
+
78
105
  /** Active tenant (multi-tenancy as a dimension of `fx`). */
79
106
  export interface FxTenant {
80
107
  readonly id: string | null;
@@ -334,6 +361,12 @@ export interface Fx {
334
361
  readonly auth: FxAuth;
335
362
  /** Operator-plane principal. */
336
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;
337
370
  /** Active tenant. */
338
371
  readonly tenant: FxTenant;
339
372
  /**
@@ -419,6 +452,11 @@ export interface CreateFxOptions {
419
452
  readonly auth?: FxAuth;
420
453
  /** Operator principal. */
421
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;
422
460
  /** Tenant. */
423
461
  readonly tenant?: FxTenant;
424
462
  /** Secret name → value map for `fx.vault`. */
@@ -522,6 +560,27 @@ export function createFxContext(options: CreateFxOptions): FxContext {
522
560
  };
523
561
  const operator: FxOperator = options.operator ?? { id: null };
524
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);
525
584
  const journal = options.journal;
526
585
 
527
586
  async function gated<T>(
@@ -1069,6 +1128,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1069
1128
  },
1070
1129
  auth,
1071
1130
  operator,
1131
+ principal,
1072
1132
  tenant,
1073
1133
  fail,
1074
1134
  json: {
@@ -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,
@@ -244,6 +246,16 @@ export {
244
246
  type PluginScope,
245
247
  } from "./registry.ts";
246
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
+
247
259
  export {
248
260
  createRouter,
249
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"));
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test, beforeEach } from "bun:test";
2
+ import { clock } from "../elements/clock.ts";
3
+ import { gate } from "../elements/gate.ts";
4
+ import { signal } from "../elements/signal.ts";
5
+ import { vault } from "../elements/vault.ts";
6
+ import { oke } from "./app.ts";
7
+ import { flow, resetFlowSeq } from "./flow.ts";
8
+ import { on, resetBindings } from "./on.ts";
9
+ import { plugin } from "./plugin.ts";
10
+ import { http } from "./triggers.ts";
11
+
12
+ beforeEach(() => {
13
+ resetBindings();
14
+ resetFlowSeq();
15
+ });
16
+
17
+ describe("plugin element contributions", () => {
18
+ test("vault / clock / signal / gate merge into boot options", async () => {
19
+ const otpSecret = vault.secret("OTP_SIGNING_KEY", {
20
+ description: "2FA HMAC",
21
+ dev: "test-otp-signing-key",
22
+ });
23
+ const expire = clock("expire-otp", { every: "1m" });
24
+ const bus = signal("otp.events", { delivery: "once" });
25
+ const member = gate.policy("plugin-member", ({ auth }) => !!auth.verified);
26
+
27
+ const twoFactor = plugin("twoFactor-scaffold", { version: "0.0.1" })
28
+ .vault(otpSecret)
29
+ .clock(expire)
30
+ .signal(bus)
31
+ .gate(member)
32
+ .table("otp_codes")
33
+ .needs("store.sql");
34
+
35
+ on(http.get("/ok").gate(gate.public), flow({ name: "ok", do: () => ({ ok: true }) }));
36
+
37
+ const app = oke({
38
+ name: "plugin-elements",
39
+ gate: { policies: [gate.public] },
40
+ vault: { allowDevFallbacks: true },
41
+ }).plug(twoFactor);
42
+
43
+ await app.boot({ env: "test", unguardedHttp: "allow" });
44
+ expect(app.booted).toBe(true);
45
+ expect(app.elements?.vault).toBeDefined();
46
+ expect(app.elements?.clock).toBeDefined();
47
+ expect(app.elements?.signal).toBeDefined();
48
+ expect(app.elements?.gate).toBeDefined();
49
+ await app.stop();
50
+ });
51
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { oke } from "./app.ts";
3
+ import { flow } from "./flow.ts";
4
+ import { on, resetBindings } from "./on.ts";
5
+ import { plugin } from "./plugin.ts";
6
+ import { assertPluginNeeds, PluginNeedsError, buildAvailableNeedTokens } from "./plugin-needs.ts";
7
+ import { http } from "./triggers.ts";
8
+ import { resetFlowSeq } from "./flow.ts";
9
+ import { gate } from "../elements/gate.ts";
10
+
11
+ describe("plugin .needs() resolution", () => {
12
+ test("plugin-name dependency fails when missing", () => {
13
+ expect(() =>
14
+ assertPluginNeeds(
15
+ {
16
+ twoFactor: {
17
+ name: "twoFactor",
18
+ version: "0.0.1",
19
+ declares: [],
20
+ intercepts: [],
21
+ needs: ["auth"],
22
+ },
23
+ },
24
+ { pluginNames: new Set(["twoFactor"]), available: new Set() },
25
+ ),
26
+ ).toThrow(PluginNeedsError);
27
+ });
28
+
29
+ test("plugin-name dependency passes when plugged", () => {
30
+ expect(() =>
31
+ assertPluginNeeds(
32
+ {
33
+ auth: {
34
+ name: "auth",
35
+ version: "0.0.1",
36
+ declares: [],
37
+ intercepts: [],
38
+ needs: [],
39
+ },
40
+ twoFactor: {
41
+ name: "twoFactor",
42
+ version: "0.0.1",
43
+ declares: [],
44
+ intercepts: [],
45
+ needs: ["auth"],
46
+ },
47
+ },
48
+ {
49
+ pluginNames: new Set(["auth", "twoFactor"]),
50
+ available: new Set(),
51
+ },
52
+ ),
53
+ ).not.toThrow();
54
+ });
55
+
56
+ test("element token store.sql from available set", () => {
57
+ const available = buildAvailableNeedTokens({ elements: { storeSql: true } });
58
+ expect(available.has("store.sql")).toBe(true);
59
+ expect(() =>
60
+ assertPluginNeeds(
61
+ {
62
+ auth: {
63
+ name: "auth",
64
+ version: "0.0.1",
65
+ declares: ["table:oke_sessions"],
66
+ intercepts: [],
67
+ needs: ["store.sql"],
68
+ },
69
+ },
70
+ { pluginNames: new Set(["auth"]), available },
71
+ ),
72
+ ).not.toThrow();
73
+ });
74
+
75
+ test("boot fails when plugin needs missing peer plugin", async () => {
76
+ resetBindings();
77
+ resetFlowSeq();
78
+ on(http.get("/x").gate(gate.public), flow({ name: "x", do: () => ({ ok: true }) }));
79
+ const dependent = plugin("needs-auth", { version: "0.0.1" }).needs("auth");
80
+ const app = oke({ name: "needs-boot", gate: { policies: [gate.public] } }).plug(dependent);
81
+ await expect(app.boot({ env: "test" })).rejects.toThrow(PluginNeedsError);
82
+ });
83
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Resolve plugin `.needs(dep)` at boot — fail loud when unmet.
3
+ *
4
+ * Tokens are either:
5
+ * - another plugged plugin name (e.g. `"auth"`)
6
+ * - an element / driver id (e.g. `"store.sql"`, `"postgres"`)
7
+ */
8
+
9
+ import type { PluginCapabilities } from "./plugin.ts";
10
+
11
+ /** Known element / facet dependency tokens. */
12
+ export const ELEMENT_NEED_TOKENS = [
13
+ "store.sql",
14
+ "store.kv",
15
+ "store.files",
16
+ "store.index",
17
+ "signal",
18
+ "clock",
19
+ "gate",
20
+ "vault",
21
+ "channel",
22
+ "ai",
23
+ "flow",
24
+ ] as const;
25
+
26
+ /** One unmet `.needs()` dependency. */
27
+ export interface PluginNeedGap {
28
+ /** Plugin that declared the need. */
29
+ readonly plugin: string;
30
+ /** Unmet dependency token. */
31
+ readonly need: string;
32
+ }
33
+
34
+ /**
35
+ * Thrown when installed plugins declare unmet `.needs()` dependencies.
36
+ */
37
+ export class PluginNeedsError extends Error {
38
+ readonly gaps: readonly PluginNeedGap[];
39
+
40
+ constructor(gaps: readonly PluginNeedGap[]) {
41
+ const lines = gaps.map((g) => ` - ${g.plugin} needs "${g.need}"`);
42
+ super(
43
+ `plugin boot failed — ${gaps.length} unmet .needs() dependenc${gaps.length === 1 ? "y" : "ies"}:\n${lines.join("\n")}`,
44
+ );
45
+ this.name = "PluginNeedsError";
46
+ this.gaps = gaps;
47
+ }
48
+ }
49
+
50
+ /** Context for resolving whether a need token is available. */
51
+ export interface PluginNeedsContext {
52
+ /** Installed plugin names. */
53
+ readonly pluginNames: ReadonlySet<string>;
54
+ /**
55
+ * Available element / driver tokens (e.g. `store.sql`, `postgres`).
56
+ * Built from boot options, declared drivers, and element needs.
57
+ */
58
+ readonly available: ReadonlySet<string>;
59
+ }
60
+
61
+ /**
62
+ * Collect unmet needs across all plugin capabilities.
63
+ *
64
+ * @param capabilities - Registry capability map
65
+ * @param ctx - Available plugins + element/driver tokens
66
+ */
67
+ export function collectUnmetPluginNeeds(
68
+ capabilities: Readonly<Record<string, PluginCapabilities>>,
69
+ ctx: PluginNeedsContext,
70
+ ): PluginNeedGap[] {
71
+ const gaps: PluginNeedGap[] = [];
72
+ for (const caps of Object.values(capabilities)) {
73
+ for (const need of caps.needs) {
74
+ if (ctx.pluginNames.has(need) || ctx.available.has(need)) continue;
75
+ gaps.push({ plugin: caps.name, need });
76
+ }
77
+ }
78
+ return gaps;
79
+ }
80
+
81
+ /**
82
+ * Assert every plugin `.needs()` is satisfied. Throws {@link PluginNeedsError}.
83
+ *
84
+ * @param capabilities - Registry capability map
85
+ * @param ctx - Available plugins + element/driver tokens
86
+ */
87
+ export function assertPluginNeeds(
88
+ capabilities: Readonly<Record<string, PluginCapabilities>>,
89
+ ctx: PluginNeedsContext,
90
+ ): void {
91
+ const gaps = collectUnmetPluginNeeds(capabilities, ctx);
92
+ if (gaps.length > 0) throw new PluginNeedsError(gaps);
93
+ }
94
+
95
+ /**
96
+ * Build the available token set from boot-time signals.
97
+ *
98
+ * @param input - Element/driver presence flags and declared driver ids
99
+ */
100
+ export function buildAvailableNeedTokens(input: {
101
+ readonly elements?: {
102
+ readonly storeSql?: boolean;
103
+ readonly storeKv?: boolean;
104
+ readonly storeFiles?: boolean;
105
+ readonly storeIndex?: boolean;
106
+ readonly signal?: boolean;
107
+ readonly clock?: boolean;
108
+ readonly gate?: boolean;
109
+ readonly vault?: boolean;
110
+ readonly channel?: boolean;
111
+ readonly ai?: boolean;
112
+ };
113
+ readonly driverIds?: readonly string[];
114
+ }): Set<string> {
115
+ const available = new Set<string>();
116
+ const e = input.elements ?? {};
117
+ if (e.storeSql) available.add("store.sql");
118
+ if (e.storeKv) available.add("store.kv");
119
+ if (e.storeFiles) available.add("store.files");
120
+ if (e.storeIndex) available.add("store.index");
121
+ if (e.signal) available.add("signal");
122
+ if (e.clock) available.add("clock");
123
+ if (e.gate) available.add("gate");
124
+ if (e.vault) available.add("vault");
125
+ if (e.channel) available.add("channel");
126
+ if (e.ai) available.add("ai");
127
+ for (const id of input.driverIds ?? []) available.add(id);
128
+ return available;
129
+ }