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/app.ts CHANGED
@@ -25,22 +25,28 @@ import {
25
25
  // when an app actually boots (AGENTS.md / unified-theory budget: cold start < 75 ms).
26
26
  import type { BootOptions, BootResult, ElementRuntimes } from "./boot.ts";
27
27
  import type { CapabilityToken } from "./capability.ts";
28
+ import { createAppAuthBinding, verifyBearerToken, type AppAuthBinding } from "./auth-resolve.ts";
29
+ import { createAuthHttpBindings, type AuthHttpMaterialization } from "../auth/bindings.ts";
30
+ import { auth as authPlugin } from "../auth/plugin.ts";
31
+ import { tokenFromCookieHeader } from "../auth/cookies.ts";
32
+ import { setActiveGateAuthContext } from "../auth/method-context.ts";
28
33
  import {
29
- createAppAuthBinding,
30
- verifyBearerToken,
31
- type AppAuthBinding,
32
- type CreateAppAuthBindingOptions,
33
- } from "./auth-resolve.ts";
34
+ resolveGateConfig,
35
+ type GateOptions,
36
+ type ResolvedGateConfig,
37
+ } from "../elements/gate/config.ts";
34
38
  import { runDurable } from "../elements/clock/durable.ts";
35
39
  import { isFlow, type AnyFlowDef } from "./flow.ts";
36
40
  import { fxRetry } from "./concurrency.ts";
37
41
  import {
38
42
  createFxContext,
43
+ freezePrincipal,
39
44
  resolveName,
40
45
  type CreateFxOptions,
41
46
  type Fx,
42
47
  type FxAuth,
43
48
  type FxOperator,
49
+ type FxPrincipal,
44
50
  type NamedRef,
45
51
  } from "./fx.ts";
46
52
  import {
@@ -149,8 +155,11 @@ export interface OkeOptions {
149
155
  readonly vault?: BootOptions["vault"];
150
156
  /** Vault secret contracts (shorthand for `vault.secrets`). */
151
157
  readonly secrets?: BootOptions["secrets"];
152
- /** Gate declarations for the runtime. */
153
- readonly gates?: BootOptions["gates"];
158
+ /**
159
+ * Nested Gate bag — auth, policies, rate limits, HTTP posture.
160
+ * Replaces root `auth` / `gates` / `unguardedHttp`.
161
+ */
162
+ readonly gate?: GateOptions;
154
163
  /** Signal declarations. */
155
164
  readonly signals?: BootOptions["signals"];
156
165
  /** Named clock declarations. */
@@ -163,8 +172,10 @@ export interface OkeOptions {
163
172
  readonly ai?: BootOptions["ai"];
164
173
  /**
165
174
  * Boot automatically on first {@link OkeApp.execute} / {@link OkeApp.fetch}
166
- * call. Default `false` — call {@link OkeApp.boot} explicitly (tests
167
- * usually want a fail-fast boot before serving any request).
175
+ * call. Default `true` — gate posture, vault, capabilities, and the element
176
+ * pipeline must run before any real traffic. Opt out only for intentional
177
+ * pre-boot unit tests: `oke({ autoBoot: false })` skips `doBoot` entirely
178
+ * (no gates / elements) and must never ship as a serving default.
168
179
  */
169
180
  readonly autoBoot?: boolean;
170
181
  /**
@@ -174,11 +185,6 @@ export interface OkeOptions {
174
185
  readonly startScheduler?: boolean;
175
186
  /** Scheduler tick period ms (default 1000). Forwarded to boot. */
176
187
  readonly schedulerIntervalMs?: number;
177
- /**
178
- * Builtin hybrid-session auth (HMAC access tokens).
179
- * Required to accept `Authorization: Bearer` in production.
180
- */
181
- readonly auth?: CreateAppAuthBindingOptions;
182
188
  }
183
189
 
184
190
  /** Payload for a CDC invocation. */
@@ -405,6 +411,22 @@ export function oke(options: OkeOptions): OkeApp {
405
411
  ? [...(options.bindings ?? [])]
406
412
  : [...listBindings(), ...(options.bindings ?? [])];
407
413
  if (registry === "consume") resetBindings();
414
+
415
+ // Resolve Gate bag early so auth HTTP Bindings join `adopted` + the router
416
+ // before posture audit (same ensureBoot → doBoot path — never a side channel).
417
+ const gateConfig: ResolvedGateConfig = resolveGateConfig({
418
+ gate: options.gate,
419
+ env: options.env,
420
+ });
421
+ let authMaterialization: AuthHttpMaterialization | undefined;
422
+ if (gateConfig.auth?.http) {
423
+ authMaterialization = createAuthHttpBindings(gateConfig.auth, {
424
+ rateLimitEnabled: gateConfig.rateLimitEnabled,
425
+ sessions: gateConfig.auth.sessions,
426
+ });
427
+ adopted.push(...authMaterialization.bindings);
428
+ }
429
+
408
430
  /** Retained for test harness / boot merges. */
409
431
  const $options = options;
410
432
 
@@ -417,6 +439,29 @@ export function oke(options: OkeOptions): OkeApp {
417
439
  /** Runtime route table — types accumulate on the returned {@link OkeApp}. */
418
440
  const routes: RuntimeRouteMap = {};
419
441
 
442
+ // Absorb auth() tables into gate.auth — `.needs("auth")` sees plugin name.
443
+ if (gateConfig.auth) {
444
+ applyPlugin(
445
+ pluginRegistry,
446
+ authPlugin({
447
+ secret: gateConfig.auth.secret,
448
+ accessTtlMs: gateConfig.auth.session.accessTtlMs,
449
+ refreshTtlMs: gateConfig.auth.session.refreshTtlMs,
450
+ session: {
451
+ accessTtlMs: gateConfig.auth.session.accessTtlMs,
452
+ refreshTtlMs: gateConfig.auth.session.refreshTtlMs,
453
+ idleTtlMs: gateConfig.auth.session.idleTtlMs,
454
+ absoluteTtlMs: gateConfig.auth.session.absoluteTtlMs,
455
+ singleSessionPerUser: gateConfig.auth.session.singleSessionPerUser,
456
+ },
457
+ password: gateConfig.auth.password,
458
+ passwordPolicy: gateConfig.auth.passwordPolicy,
459
+ breachCheck: gateConfig.auth.breachCheck,
460
+ }),
461
+ appPluginScope,
462
+ );
463
+ }
464
+
420
465
  const smart = createRouter<Binding>(options.router ?? "default");
421
466
  for (const b of adopted) {
422
467
  if (b.trigger.kind === "http") {
@@ -424,9 +469,65 @@ export function oke(options: OkeOptions): OkeApp {
424
469
  compiled.set(b, compileHttpBinding(b, aot));
425
470
  }
426
471
  }
427
- smart.build();
472
+ // Defer SmartRouter selection until first match so `.plug()` can still
473
+ // contribute auth-method Bindings before traffic (plan Phase 2).
428
474
  const router: Router<Binding> = smart;
429
475
 
476
+ function adoptBinding(b: Binding): void {
477
+ adopted.push(b);
478
+ registerFlow(b.flow);
479
+ if (b.trigger.kind === "http") {
480
+ smart.add(b.trigger.method, b.trigger.path, b);
481
+ compiled.set(b, compileHttpBinding(b, aot));
482
+ }
483
+ }
484
+
485
+ // Phase 1a: mirror tokens into Set-Cookie when gate.auth.cookies.enabled.
486
+ if (gateConfig.auth?.cookies.enabled) {
487
+ const cookieCfg = gateConfig.auth.cookies;
488
+ const basePath = gateConfig.auth.basePath;
489
+ const list = appHooks.afterHandle ?? (appHooks.afterHandle = []);
490
+ list.push(async (ctx) => {
491
+ const res = ctx.response;
492
+ if (!(res instanceof Response) || !ctx.request) return;
493
+ const url = new URL(ctx.request.url);
494
+ const path = url.pathname;
495
+ const underAuth = path === basePath || path.startsWith(`${basePath}/`);
496
+ const isRevoke = path === `${basePath}/revoke` || path.endsWith("/revoke");
497
+ const isIssue = underAuth && !isRevoke;
498
+ if (!isIssue && !isRevoke) return;
499
+ const { buildAuthSetCookies, clearAuthSetCookies } = await import("../auth/cookies.ts");
500
+ const { ACCESS_TTL_MS, REFRESH_TTL_MS } = await import("../auth/sessions.ts");
501
+ if (isRevoke || res.status >= 400) {
502
+ if (isRevoke) {
503
+ const headers = new Headers(res.headers);
504
+ for (const c of clearAuthSetCookies(cookieCfg)) headers.append("Set-Cookie", c);
505
+ ctx.response = new Response(res.body, { status: res.status, headers });
506
+ }
507
+ return;
508
+ }
509
+ try {
510
+ const body = (await res.clone().json()) as {
511
+ data?: { accessToken?: string; refreshToken?: string };
512
+ };
513
+ if (!body.data?.accessToken || !body.data?.refreshToken) return;
514
+ const headers = new Headers(res.headers);
515
+ const accessTtl = gateConfig.auth!.session.accessTtlMs ?? ACCESS_TTL_MS;
516
+ const refreshTtl = gateConfig.auth!.session.refreshTtlMs ?? REFRESH_TTL_MS;
517
+ for (const c of buildAuthSetCookies(
518
+ cookieCfg,
519
+ { accessToken: body.data.accessToken, refreshToken: body.data.refreshToken },
520
+ { access: Math.floor(accessTtl / 1000), refresh: Math.floor(refreshTtl / 1000) },
521
+ )) {
522
+ headers.append("Set-Cookie", c);
523
+ }
524
+ ctx.response = new Response(res.body, { status: res.status, headers });
525
+ } catch {
526
+ /* ignore non-JSON */
527
+ }
528
+ });
529
+ }
530
+
430
531
  function flushFlowPlugins(flowDef: AnyFlowDef): void {
431
532
  let done = flushedPlugins.get(flowDef);
432
533
  if (!done) {
@@ -454,12 +555,22 @@ export function oke(options: OkeOptions): OkeApp {
454
555
  let bootPromise: Promise<BootResult> | undefined;
455
556
  let bootEnv: BootOptions["env"] = options.env ?? "local";
456
557
  let authBinding: AppAuthBinding | undefined =
457
- options.auth !== undefined
558
+ gateConfig.auth !== undefined
458
559
  ? createAppAuthBinding({
459
- ...options.auth,
460
- now: options.fx?.now,
560
+ secret: gateConfig.auth.secret,
561
+ sessions: authMaterialization?.ctx.sessions ?? gateConfig.auth.sessions,
562
+ now: gateConfig.auth.now ?? options.fx?.now,
461
563
  })
462
564
  : undefined;
565
+ if (authBinding) {
566
+ setActiveGateAuthContext({
567
+ secret: authBinding.secret,
568
+ sessions: authBinding.sessions,
569
+ now: authBinding.now,
570
+ });
571
+ } else {
572
+ setActiveGateAuthContext(undefined);
573
+ }
463
574
  const journalStore = createMemoryJournalStore();
464
575
  const sleepingRuns = new Map<
465
576
  string,
@@ -482,22 +593,108 @@ export function oke(options: OkeOptions): OkeApp {
482
593
  }
483
594
 
484
595
  async function doBoot(overrides?: Partial<BootOptions>): Promise<BootResult> {
485
- const { bootApplication } = await import("./boot.ts");
596
+ const { bootApplication, resolveElementNeeds } = await import("./boot.ts");
597
+ const { assertHttpGatePosture } = await import("../elements/gate/boot.ts");
598
+ const { assertPluginNeeds, buildAvailableNeedTokens } = await import("./plugin-needs.ts");
486
599
  bootEnv = overrides?.env ?? options.env ?? "local";
487
- const merged: BootOptions = {
600
+
601
+ // Prod without a real secret fails even if construction minted a dev secret.
602
+ if (gateConfig.auth?.secretMinted && bootEnv === "prod") {
603
+ throw new Error(
604
+ "gate.auth: secret is required in production (set gate.auth.secret or OKE_AUTH_SECRET)",
605
+ );
606
+ }
607
+
608
+ const unguardedHttp = overrides?.unguardedHttp ?? gateConfig.unguardedHttp ?? "deny";
609
+ // Auth bindings are in `adopted` — missing posture fails with GateBootError.
610
+ assertHttpGatePosture(adopted, { unguardedHttp, env: bootEnv });
611
+
612
+ const caps = pluginRegistry.capabilities();
613
+ const pluginNames = new Set(Object.keys(caps));
614
+ const authGates = authMaterialization?.authGates ?? [];
615
+ const baseGates = overrides?.gates ?? gateConfig.policies ?? [];
616
+ const elementNeeds = resolveElementNeeds({
488
617
  env: bootEnv,
489
618
  docker: overrides?.docker ?? options.docker,
490
619
  config: overrides?.config ?? options.config,
491
620
  elements: overrides?.elements ?? options.elements,
492
621
  secrets: overrides?.secrets ?? options.secrets,
493
622
  vault: overrides?.vault ?? options.vault,
494
- gates: overrides?.gates ?? options.gates,
623
+ gates: [...baseGates, ...authGates],
495
624
  signals: overrides?.signals ?? options.signals,
496
625
  clocks: overrides?.clocks ?? options.clocks,
497
626
  stores: overrides?.stores ?? options.stores,
498
627
  channel: overrides?.channel ?? options.channel,
499
628
  ai: overrides?.ai ?? options.ai,
500
629
  runs: overrides?.runs ?? options.runs,
630
+ bindings: adopted,
631
+ flows: [...flowsByName.values()],
632
+ });
633
+ const driverIds: string[] = [];
634
+ for (const c of Object.values(caps)) {
635
+ for (const d of c.declares) {
636
+ if (d.startsWith("driver:")) driverIds.push(d.slice("driver:".length));
637
+ }
638
+ }
639
+ const stores = overrides?.stores ?? options.stores ?? [];
640
+ const available = buildAvailableNeedTokens({
641
+ elements: {
642
+ storeSql:
643
+ elementNeeds.store ||
644
+ stores.some((s) => s.facet === "sql") ||
645
+ pluginRegistry.tableContributions().length > 0,
646
+ storeKv:
647
+ elementNeeds.store ||
648
+ stores.some((s) => s.facet === "kv") ||
649
+ gateConfig.auth?.secondaryStorage.enabled === true,
650
+ storeFiles: elementNeeds.store || stores.some((s) => s.facet === "files"),
651
+ storeIndex: elementNeeds.store || stores.some((s) => s.facet === "index"),
652
+ signal: elementNeeds.signal,
653
+ clock: elementNeeds.clock,
654
+ gate: elementNeeds.gate || baseGates.length > 0 || authGates.length > 0,
655
+ vault: elementNeeds.vault,
656
+ channel: elementNeeds.channel,
657
+ ai: elementNeeds.ai,
658
+ },
659
+ driverIds,
660
+ });
661
+ const pluginSecrets = pluginRegistry.vaultContributions();
662
+ const pluginGates = pluginRegistry.gateContributions();
663
+ const pluginSignals = pluginRegistry.signalContributions();
664
+ const pluginClocks = pluginRegistry.clockContributions();
665
+ const pluginChannelTemplates = pluginRegistry.channelTemplateContributions();
666
+ if (pluginSecrets.length > 0) available.add("vault");
667
+ if (pluginGates.length > 0) available.add("gate");
668
+ if (pluginSignals.length > 0) available.add("signal");
669
+ if (pluginClocks.length > 0) available.add("clock");
670
+ if (pluginChannelTemplates.length > 0) available.add("channel");
671
+ // gate.auth (auto-absorbed auth plugin) satisfies `.needs("auth")`.
672
+ if (gateConfig.auth) available.add("auth");
673
+ assertPluginNeeds(caps, { pluginNames, available });
674
+
675
+ const baseSecrets = overrides?.secrets ?? options.secrets ?? [];
676
+ const baseSignals = overrides?.signals ?? options.signals ?? [];
677
+ const baseClocks = overrides?.clocks ?? options.clocks ?? [];
678
+ const baseChannel = overrides?.channel ?? options.channel;
679
+
680
+ const merged: BootOptions = {
681
+ env: bootEnv,
682
+ docker: overrides?.docker ?? options.docker,
683
+ config: overrides?.config ?? options.config,
684
+ elements: overrides?.elements ?? options.elements,
685
+ secrets: [...baseSecrets, ...pluginSecrets],
686
+ vault: overrides?.vault ?? options.vault,
687
+ gates: [...baseGates, ...authGates, ...pluginGates],
688
+ unguardedHttp,
689
+ signals: [...baseSignals, ...pluginSignals],
690
+ clocks: [...baseClocks, ...pluginClocks],
691
+ stores: overrides?.stores ?? options.stores,
692
+ channel: {
693
+ ...(baseChannel ?? {}),
694
+ templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],
695
+ },
696
+ ai: overrides?.ai ?? options.ai,
697
+ runs: overrides?.runs ?? options.runs,
501
698
  now: overrides?.now ?? options.fx?.now,
502
699
  instanceId: overrides?.instanceId,
503
700
  startScheduler: overrides?.startScheduler ?? options.startScheduler,
@@ -521,13 +718,14 @@ export function oke(options: OkeOptions): OkeApp {
521
718
  }
522
719
 
523
720
  /**
524
- * Boot lazily when `autoBoot` is enabled; otherwise legacy (pre-boot)
525
- * execution continues to work without gates / element runtimes — existing
526
- * callers that never call {@link OkeApp.boot} see unchanged behavior.
721
+ * Boot lazily on first execute/fetch unless {@link OkeOptions.autoBoot} is
722
+ * explicitly `false` (legacy pre-boot escape hatch for unit tests).
723
+ * Default is on security posture and element runtimes are not optional
724
+ * for traffic-serving apps.
527
725
  */
528
726
  async function ensureBoot(): Promise<BootResult | undefined> {
529
727
  if (bootResult) return bootResult;
530
- if (options.autoBoot !== true) return undefined;
728
+ if (options.autoBoot === false) return undefined;
531
729
  if (!bootPromise) {
532
730
  bootPromise = doBoot();
533
731
  }
@@ -554,6 +752,8 @@ export function oke(options: OkeOptions): OkeApp {
554
752
  readonly auth?: ResolvedPrincipal | FxAuth;
555
753
  readonly operator?: FxOperator;
556
754
  readonly principal?: ResolvedPrincipal;
755
+ /** Frozen origin identity for {@link Fx.principal} across `fx.call`. */
756
+ readonly originPrincipal?: FxPrincipal;
557
757
  },
558
758
  ): Promise<ExecuteResult> {
559
759
  registerFlow(flowDef);
@@ -588,8 +788,8 @@ export function oke(options: OkeOptions): OkeApp {
588
788
  const startedAt = now();
589
789
  const telemetry = createRunTelemetry();
590
790
 
591
- // Element pipeline wiring only kicks in once booted — pre-boot execution
592
- // (existing tests) runs exactly as before, with no gates / elements.
791
+ // Element pipeline wiring only kicks in once booted — `autoBoot: false`
792
+ // keeps intentional pre-boot unit tests ungated (no elements either).
593
793
  let principals: PrincipalBag | undefined;
594
794
  let hooks: HookMap = composedHooks;
595
795
  let capability: CapabilityToken | undefined;
@@ -615,12 +815,24 @@ export function oke(options: OkeOptions): OkeApp {
615
815
  }
616
816
 
617
817
  const binding = authBinding;
818
+ const cookieOpts = gateConfig.auth?.cookies;
618
819
  const elementHooks = createElementPipelineHooks({
619
820
  gates: booted.gate,
620
821
  principals,
621
822
  telemetry,
622
823
  allowTestPrincipals: testMode,
623
- verifyBearer: binding ? (token) => verifyBearerToken(binding, token) : undefined,
824
+ verifyBearer: binding ? async (token) => verifyBearerToken(binding, token) : undefined,
825
+ // Phase 1a: opt-in cookie → Bearer when Authorization is absent.
826
+ resolveToken:
827
+ binding && cookieOpts?.enabled
828
+ ? (request) => {
829
+ const header = request.headers.get("authorization");
830
+ if (header?.startsWith("Bearer ")) {
831
+ return header.slice("Bearer ".length).trim() || undefined;
832
+ }
833
+ return tokenFromCookieHeader(request.headers.get("cookie"), cookieOpts);
834
+ }
835
+ : undefined,
624
836
  });
625
837
 
626
838
  // Element hooks run first in the app-level onAuth/beforeHandle chain —
@@ -649,6 +861,7 @@ export function oke(options: OkeOptions): OkeApp {
649
861
  runTelemetry: telemetry,
650
862
  now,
651
863
  ...(principals ? { auth: principals.auth as FxAuth, operator: principals.operator } : {}),
864
+ ...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
652
865
  ...(capability ? { capability } : {}),
653
866
  ...(booted
654
867
  ? {
@@ -665,9 +878,12 @@ export function oke(options: OkeOptions): OkeApp {
665
878
  if (!target) {
666
879
  return undefined;
667
880
  }
668
- const inner = await execute(target, callInput, {
669
- kind: "internal",
670
- } satisfies InternalTrigger);
881
+ const inner = await execute(
882
+ target,
883
+ callInput,
884
+ { kind: "internal" } satisfies InternalTrigger,
885
+ { originPrincipal: freezePrincipal(fx.principal) },
886
+ );
671
887
  if (inner.failure) return inner.failure;
672
888
  return inner.output;
673
889
  },
@@ -842,8 +1058,13 @@ export function oke(options: OkeOptions): OkeApp {
842
1058
  }
843
1059
  },
844
1060
  plug(pluginDef) {
1061
+ const before = new Set(pluginRegistry.bindingContributions().map((b) => b.flow.name));
845
1062
  applyPlugin(pluginRegistry, pluginDef, appPluginScope);
846
- // Decoration accumulate on the interface; runtime object is unchanged.
1063
+ for (const b of pluginRegistry.bindingContributions()) {
1064
+ if (before.has(b.flow.name)) continue;
1065
+ adoptBinding(b);
1066
+ }
1067
+ // Decorations accumulate on the interface; runtime object is unchanged.
847
1068
  return app as never;
848
1069
  },
849
1070
  pluginCapabilities() {
@@ -190,8 +190,8 @@ describe("boot — cron fires without manual dispatch", () => {
190
190
  });
191
191
  });
192
192
 
193
- describe("boot — HTTP gate wiring lives behind boot", () => {
194
- test("unbooted app still serves without gates (legacy)", async () => {
193
+ describe("boot — HTTP gate wiring on the default path", () => {
194
+ test("autoBoot: false still serves without gates (explicit escape hatch)", async () => {
195
195
  resetBindings();
196
196
  resetFlowSeq();
197
197
  on(
@@ -201,9 +201,46 @@ describe("boot — HTTP gate wiring lives behind boot", () => {
201
201
  do: () => ({ ok: true }),
202
202
  }),
203
203
  );
204
- const app = oke({ name: "legacy" });
204
+ const app = oke({ name: "legacy", autoBoot: false });
205
205
  const res = await app.fetch(new Request("http://localhost/ping", { method: "GET" }));
206
206
  expect(res.status).toBe(200);
207
+ expect(app.booted).toBe(false);
208
+ });
209
+
210
+ test("bare oke().fetch() enforces gate posture by default (no flags)", async () => {
211
+ resetBindings();
212
+ resetFlowSeq();
213
+ const { GateBootError } = await import("../elements/gate/boot.ts");
214
+ on(
215
+ http.get("/ping"),
216
+ flow({
217
+ name: "ping",
218
+ do: () => ({ ok: true }),
219
+ }),
220
+ );
221
+ const app = oke({ name: "default-posture" });
222
+ await expect(
223
+ app.fetch(new Request("http://localhost/ping", { method: "GET" })),
224
+ ).rejects.toThrow(GateBootError);
225
+ expect(app.booted).toBe(false);
226
+ });
227
+
228
+ test("bare oke().fetch() with gate.public boots and serves", async () => {
229
+ resetBindings();
230
+ resetFlowSeq();
231
+ const { gate } = await import("../elements/gate.ts");
232
+ on(
233
+ http.get("/ping").gate(gate.public),
234
+ flow({
235
+ name: "ping-public",
236
+ do: () => ({ ok: true }),
237
+ }),
238
+ );
239
+ const app = oke({ name: "default-public", env: "test", startScheduler: false });
240
+ const res = await app.fetch(new Request("http://localhost/ping", { method: "GET" }));
241
+ expect(res.status).toBe(200);
242
+ expect(app.booted).toBe(true);
243
+ await app.stop();
207
244
  });
208
245
  });
209
246
 
@@ -75,6 +75,14 @@ export interface BootOptions {
75
75
  };
76
76
  /** Gate declarations for the runtime. */
77
77
  readonly gates?: readonly GateDecl[];
78
+ /**
79
+ * HTTP auth-posture enforcement at boot.
80
+ * - `"deny"` (default) — every HTTP trigger must carry a gate or `gate.public`
81
+ * - `"allow"` — skips the audit **only** when {@link env} is `"test"`;
82
+ * ignored in local/prod/docker (never a production-wide bypass).
83
+ * Migrate real apps with per-trigger `gate.public`.
84
+ */
85
+ readonly unguardedHttp?: "deny" | "allow";
78
86
  /** Signal declarations. */
79
87
  readonly signals?: readonly SignalDecl[];
80
88
  /** Named clock declarations. */
@@ -28,7 +28,7 @@ describe("fx.call — untriggered flows", () => {
28
28
  }),
29
29
  );
30
30
 
31
- const app = oke({ name: "call" }).adopt(stats);
31
+ const app = oke({ autoBoot: false, name: "call" }).adopt(stats);
32
32
 
33
33
  // Direct call (Linkly ⑤)
34
34
  await expect(app.call(stats, { code: "sa" })).resolves.toEqual({
@@ -53,6 +53,50 @@ describe("fx.call — untriggered flows", () => {
53
53
  expect(stats.triggers).toHaveLength(0);
54
54
  });
55
55
 
56
+ test("fx.call propagates fx.principal without filling fx.auth", async () => {
57
+ const audit = flow({
58
+ name: "audit.log",
59
+ do: (_input: { event: string }, fx) => ({
60
+ authUserId: fx.auth.userId,
61
+ principalUserId: fx.principal.userId,
62
+ principalScopes: [...fx.principal.scopes],
63
+ }),
64
+ });
65
+
66
+ const act = on(
67
+ http.post("/act"),
68
+ flow({
69
+ name: "act",
70
+ effects: { calls: ["audit.log"] },
71
+ do: async (_input: Record<string, never>, fx) => {
72
+ return fx.call("audit.log", { event: "act" });
73
+ },
74
+ }),
75
+ );
76
+
77
+ const app = oke({
78
+ autoBoot: false,
79
+ name: "principal-call",
80
+ gate: { unguardedHttp: "allow" },
81
+ }).adopt(audit);
82
+ await app.boot({ env: "test", unguardedHttp: "allow" });
83
+
84
+ const result = await app.execute(act, {}, act.$trigger ?? act.triggers[0]!, {
85
+ principal: {
86
+ userId: "user-1",
87
+ scopes: ["booking:create"],
88
+ verified: true,
89
+ },
90
+ });
91
+
92
+ expect(result.failure).toBeUndefined();
93
+ expect(result.output).toEqual({
94
+ authUserId: null,
95
+ principalUserId: "user-1",
96
+ principalScopes: ["booking:create"],
97
+ });
98
+ });
99
+
56
100
  test("http and signal invocations of the same flow execute identically", async () => {
57
101
  let runs = 0;
58
102
  const work = flow({
@@ -66,7 +110,7 @@ describe("fx.call — untriggered flows", () => {
66
110
  on(http.post("/work"), work);
67
111
  on({ name: "tick", delivery: "broadcast" }, work);
68
112
 
69
- const app = oke({ name: "identical" });
113
+ const app = oke({ autoBoot: false, name: "identical" });
70
114
 
71
115
  const a = await app.fetch(
72
116
  new Request("http://localhost/work", {
@@ -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);
@@ -163,6 +163,7 @@ describe("fx — wholesale swap", () => {
163
163
  id: () => "fixed-id",
164
164
  auth: { userId: "u1", scopes: new Set(["a"]) },
165
165
  operator: { id: null },
166
+ principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },
166
167
  tenant: { id: "t1" },
167
168
  fail,
168
169
  json: {