okengine 0.4.3 → 0.5.1
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.
- package/package.json +3 -1
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +140 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +117 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/fx.mdx +20 -5
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +119 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +12 -12
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +42 -3
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +282 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +253 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/plugins/security-headers.ts +0 -255
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: {
|
package/src/kernel/hooks.test.ts
CHANGED
|
@@ -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", {});
|
package/src/kernel/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
+
gate: { policies: [member] },
|
|
318
322
|
env: "local",
|
|
319
323
|
startScheduler: false,
|
|
320
324
|
});
|
package/src/kernel/pipeline.ts
CHANGED
|
@@ -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
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|