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
@@ -671,6 +671,25 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
671
671
  }
672
672
  }
673
673
 
674
+ if (obj === "gate" && prop === "scope") {
675
+ const scopeName = stringArg(call.arguments[0]);
676
+ if (scopeName) {
677
+ scope.gates[scopeName] = {
678
+ kind: "policy",
679
+ roles: [scopeName],
680
+ scopes: [scopeName],
681
+ };
682
+ const bindingName = enclosingConstName(call, program);
683
+ if (bindingName) {
684
+ scope.gateIds.set(bindingName, scopeName);
685
+ scope.bindings.set(bindingName, {
686
+ kind: "unknown",
687
+ ref: scopeName,
688
+ });
689
+ }
690
+ }
691
+ }
692
+
674
693
  if (obj === "gate" && prop === "rate") {
675
694
  const opts = objectArg(call.arguments[0]);
676
695
  const strategy = (stringProp(opts, "strategy") ?? "sliding-window-counter") as RateStrategy;
@@ -91,8 +91,22 @@ export function encodeExecuteResult(result: {
91
91
  }): Response {
92
92
  if (result.response) return result.response;
93
93
  if (result.failure) return encodeFailure(result.failure);
94
- if (result.error !== undefined && isFlowFailure(result.error)) {
95
- return encodeFailure(result.error);
94
+ if (result.error !== undefined) {
95
+ if (isFlowFailure(result.error)) {
96
+ return encodeFailure(result.error);
97
+ }
98
+ // Unhandled throws must never look like success (`undefined` → 204).
99
+ return Response.json(
100
+ {
101
+ data: null,
102
+ error: {
103
+ code: "InternalError",
104
+ data: {},
105
+ message: result.error instanceof Error ? result.error.message : "internal error",
106
+ },
107
+ } satisfies FailureEnvelope,
108
+ { status: 500 },
109
+ );
96
110
  }
97
111
  return encodeSuccess(result.output);
98
112
  }
@@ -2,12 +2,12 @@
2
2
  * ConsoleApp — operator-plane app built on `createClient<ConsoleApp>`.
3
3
  */
4
4
 
5
- import { auth } from "../../auth/index.ts";
6
5
  import { memorySignalDriver } from "../../drivers/signal-memory.ts";
7
6
  import { signal as declareSignal } from "../../elements/signal/declare.ts";
8
7
  import { createSignalRuntime } from "../../elements/signal/runtime.ts";
9
8
  import { oke, type OkeApp } from "../../kernel/index.ts";
10
9
  import { createManifestAiRuntime } from "./ai.ts";
10
+ import { CONSOLE_GATES } from "./console-gates.ts";
11
11
  import { createConsoleBindings } from "./flows.ts";
12
12
  import { consolePlugin } from "./plugin.ts";
13
13
  import {
@@ -61,14 +61,18 @@ export function createConsoleApp(options: CreateConsoleAppOptions = {}): Console
61
61
  bindings,
62
62
  autoBoot: false,
63
63
  fx: { now: state.now },
64
- auth: {
65
- secret: state.secret,
66
- sessions: state.sessions,
67
- now: state.now,
64
+ gate: {
65
+ auth: {
66
+ secret: state.secret,
67
+ sessions: state.sessions,
68
+ now: state.now,
69
+ // Console owns `/console/session/*` — no app `/auth/*` surfaces.
70
+ http: false,
71
+ },
72
+ policies: [...CONSOLE_GATES],
68
73
  },
69
74
  runs: { driver: "memory" },
70
75
  })
71
- .plug(auth({ secret: state.secret }))
72
76
  .plug(consolePlugin())
73
77
  .adopt({
74
78
  console: {
@@ -53,18 +53,18 @@ describe("auth-rate strategy", () => {
53
53
  await createOperator(store, {
54
54
  email: "ops@example.com",
55
55
  name: "Ops",
56
- password: "password123",
56
+ password: "password1234",
57
57
  });
58
58
 
59
59
  const tMissing = performance.now();
60
- expect(await authenticateOperator(store, "missing@example.com", "password123")).toBeNull();
60
+ expect(await authenticateOperator(store, "missing@example.com", "password1234")).toBeNull();
61
61
  const missingMs = performance.now() - tMissing;
62
62
 
63
63
  const tBad = performance.now();
64
64
  expect(await authenticateOperator(store, "ops@example.com", "wrong-password")).toBeNull();
65
65
  const badMs = performance.now() - tBad;
66
66
 
67
- expect(await authenticateOperator(store, "ops@example.com", "password123")).not.toBeNull();
67
+ expect(await authenticateOperator(store, "ops@example.com", "password1234")).not.toBeNull();
68
68
 
69
69
  // Both paths pay an argon2 verify — neither should be near-instant.
70
70
  expect(missingMs).toBeGreaterThan(5);
@@ -2,18 +2,29 @@
2
2
  * Local HTTP bindings that do not touch the global {@link on} registry.
3
3
  */
4
4
 
5
+ import { gate } from "../../elements/gate.ts";
5
6
  import type { AnyFlowDef } from "../../kernel/flow.ts";
6
7
  import type { Binding } from "../../kernel/on.ts";
7
8
  import { normalizeTrigger, type HttpTrigger, type Trigger } from "../../kernel/triggers.ts";
9
+ import { consoleOperatorGate } from "./console-gates.ts";
10
+ import { PUBLIC_CONSOLE_FLOWS } from "./public-flows.ts";
8
11
 
9
12
  /**
10
13
  * Bind an HTTP trigger to a flow without registering on the global `on` list.
14
+ * Attaches {@link gate.public} or {@link consoleOperatorGate} when the trigger
15
+ * has no declared auth posture yet.
11
16
  *
12
17
  * @param trigger - HTTP trigger
13
18
  * @param flowDef - Flow definition
14
19
  */
15
20
  export function bindHttp(trigger: HttpTrigger, flowDef: AnyFlowDef): Binding {
16
- const normalized = normalizeTrigger(trigger);
21
+ const withPosture =
22
+ trigger.gates.length > 0
23
+ ? trigger
24
+ : PUBLIC_CONSOLE_FLOWS.has(flowDef.name)
25
+ ? trigger.gate(gate.public)
26
+ : trigger.gate(consoleOperatorGate);
27
+ const normalized = normalizeTrigger(withPosture);
17
28
  const list = flowDef.triggers as Trigger[];
18
29
  list.push(normalized);
19
30
  (flowDef as { $trigger: Trigger }).$trigger = normalized;
@@ -115,7 +115,7 @@ describe("console.channel.sendTest", () => {
115
115
  claimCode: code,
116
116
  email: "ops@example.com",
117
117
  name: "Ops",
118
- password: "password123",
118
+ password: "password1234",
119
119
  }),
120
120
  }),
121
121
  );
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Console HTTP auth-posture gates — public sentinel vs operator policy.
3
+ */
4
+
5
+ import { gate, type PolicyGateDecl } from "../../elements/gate.ts";
6
+
7
+ /** Operator must be present (Console plane). */
8
+ export const consoleOperatorGate: PolicyGateDecl = gate.policy(
9
+ "console:operator",
10
+ ({ operator }) => operator.id !== null,
11
+ );
12
+
13
+ /** Gates registered on the Console app runtime. */
14
+ export const CONSOLE_GATES = [gate.public, consoleOperatorGate] as const;
@@ -69,7 +69,7 @@ describe("console kernel", () => {
69
69
  claimCode: code,
70
70
  email: "ops@example.com",
71
71
  name: "Ops",
72
- password: "password123",
72
+ password: "password1234",
73
73
  }),
74
74
  }),
75
75
  );
@@ -89,7 +89,7 @@ describe("console kernel", () => {
89
89
  claimCode: code,
90
90
  email: "other@example.com",
91
91
  name: "Other",
92
- password: "password123",
92
+ password: "password1234",
93
93
  }),
94
94
  }),
95
95
  );
@@ -127,7 +127,7 @@ describe("console kernel", () => {
127
127
  claimCode: handle.state.claim.code,
128
128
  email: "ops@example.com",
129
129
  name: "Ops",
130
- password: "password123",
130
+ password: "password1234",
131
131
  }),
132
132
  }),
133
133
  );
@@ -175,7 +175,7 @@ describe("console kernel", () => {
175
175
  claimCode: handle.state.claim.code,
176
176
  email: "ops@example.com",
177
177
  name: "Ops",
178
- password: "password123",
178
+ password: "password1234",
179
179
  }),
180
180
  }),
181
181
  );
@@ -223,7 +223,7 @@ describe("console kernel", () => {
223
223
  claimCode: handle.state.claim.code,
224
224
  email: "ops@example.com",
225
225
  name: "Ops",
226
- password: "password123",
226
+ password: "password1234",
227
227
  }),
228
228
  }),
229
229
  );
@@ -317,7 +317,7 @@ describe("console serve security", () => {
317
317
  claimCode: server.console.state.claim.code,
318
318
  email: "ops@example.com",
319
319
  name: "Ops",
320
- password: "password123",
320
+ password: "password1234",
321
321
  }),
322
322
  }),
323
323
  );
@@ -31,7 +31,7 @@ describe("console flows invoke", () => {
31
31
  claimCode: code,
32
32
  email: "ops@example.com",
33
33
  name: "Ops",
34
- password: "password123",
34
+ password: "password1234",
35
35
  }),
36
36
  }),
37
37
  );
@@ -100,7 +100,7 @@ describe("console flows invoke", () => {
100
100
  claimCode: code,
101
101
  email: "ops@example.com",
102
102
  name: "Ops",
103
- password: "password123",
103
+ password: "password1234",
104
104
  }),
105
105
  }),
106
106
  );
@@ -751,6 +751,7 @@ const GatesListOut = z.object({
751
751
  plane: z.enum(["user", "operator"]),
752
752
  gates: z.array(z.string()),
753
753
  unguarded: z.boolean(),
754
+ explicitPublic: z.boolean(),
754
755
  }),
755
756
  ),
756
757
  gates: z.array(
@@ -2817,6 +2818,7 @@ function createGatesList(state: ConsoleState) {
2817
2818
  plane: f.plane,
2818
2819
  gates: [...f.gates],
2819
2820
  unguarded: f.unguarded,
2821
+ explicitPublic: f.explicitPublic,
2820
2822
  })),
2821
2823
  gates: projection.gates.map((g) => ({
2822
2824
  name: g.name,
@@ -66,8 +66,13 @@ export interface ConsoleFlowGatesRow {
66
66
  readonly flowId: string;
67
67
  readonly plane: "user" | "operator";
68
68
  readonly gates: readonly string[];
69
- /** User-plane with empty chain — public. */
69
+ /**
70
+ * User-plane with empty chain — missing auth posture (boot error when
71
+ * `unguardedHttp: "deny"`). Distinct from {@link explicitPublic}.
72
+ */
70
73
  readonly unguarded: boolean;
74
+ /** Chain includes the reserved `public` sentinel (`gate.public`). */
75
+ readonly explicitPublic: boolean;
71
76
  }
72
77
 
73
78
  /**
@@ -449,11 +454,13 @@ function projectFlows(manifest: Manifest | null): ConsoleFlowGatesRow[] {
449
454
  for (const [flowId, flow] of Object.entries(manifest.flows)) {
450
455
  const plane = flow.plane === "operator" ? "operator" : "user";
451
456
  const gates = [...(flow.gates ?? [])];
457
+ const explicitPublic = gates.includes("public");
452
458
  rows.push({
453
459
  flowId,
454
460
  plane,
455
461
  gates,
456
462
  unguarded: plane === "user" && gates.length === 0,
463
+ explicitPublic,
457
464
  });
458
465
  }
459
466
  return rows.sort((a, b) => a.flowId.localeCompare(b.flowId));
@@ -36,7 +36,7 @@ describe("console operator persistence", () => {
36
36
  const op = await createOperator(first.operators, {
37
37
  email: "ops@example.com",
38
38
  name: "Ops",
39
- password: "password123",
39
+ password: "password1234",
40
40
  });
41
41
  first.persistOperator(op.id);
42
42
  first.close();
@@ -77,7 +77,7 @@ describe("console operator persistence", () => {
77
77
  const op = await createOperator(first.operators, {
78
78
  email: "ops@example.com",
79
79
  name: "Ops",
80
- password: "password123",
80
+ password: "password1234",
81
81
  });
82
82
  first.persistOperator(op.id);
83
83
  const issued = await issueSession(
@@ -120,7 +120,7 @@ describe("console operator persistence", () => {
120
120
  claimCode: first.state.claim.code,
121
121
  email: "ops@example.com",
122
122
  name: "Ops",
123
- password: "password123",
123
+ password: "password1234",
124
124
  }),
125
125
  }),
126
126
  );
@@ -169,7 +169,7 @@ describe("console operator persistence", () => {
169
169
  const op = await createOperator(persistence.operators, {
170
170
  email: "ops@example.com",
171
171
  name: "Ops",
172
- password: "password123",
172
+ password: "password1234",
173
173
  });
174
174
  persistence.persistOperator(op.id);
175
175
 
@@ -157,7 +157,8 @@ export function migrateOperatorSchema(db: Database): void {
157
157
  family_id TEXT NOT NULL,
158
158
  revoked_at INTEGER,
159
159
  created_at INTEGER NOT NULL,
160
- expires_at INTEGER NOT NULL
160
+ expires_at INTEGER NOT NULL,
161
+ last_active_at INTEGER
161
162
  );
162
163
  CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
163
164
  id TEXT PRIMARY KEY NOT NULL,
@@ -169,6 +170,20 @@ export function migrateOperatorSchema(db: Database): void {
169
170
  revoked_at INTEGER
170
171
  );
171
172
  `);
173
+ ensureSessionLastActiveColumn(db);
174
+ }
175
+
176
+ /**
177
+ * Add `last_active_at` when opening an older console.sqlite that predates idle TTL.
178
+ *
179
+ * @param db - Open database
180
+ */
181
+ function ensureSessionLastActiveColumn(db: Database): void {
182
+ const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
183
+ name: string;
184
+ }>;
185
+ if (cols.some((c) => c.name === "last_active_at")) return;
186
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
172
187
  }
173
188
 
174
189
  /**
@@ -181,7 +196,7 @@ export function loadSessionStore(db: Database): SessionStore {
181
196
 
182
197
  const sessionRows = db
183
198
  .query(
184
- `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at
199
+ `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
185
200
  FROM ${AUTH_TABLES.sessions}`,
186
201
  )
187
202
  .all() as Array<{
@@ -192,6 +207,7 @@ export function loadSessionStore(db: Database): SessionStore {
192
207
  revoked_at: number | null;
193
208
  created_at: number;
194
209
  expires_at: number;
210
+ last_active_at: number | null;
195
211
  }>;
196
212
 
197
213
  for (const row of sessionRows) {
@@ -203,6 +219,7 @@ export function loadSessionStore(db: Database): SessionStore {
203
219
  revokedAt: row.revoked_at,
204
220
  createdAt: row.created_at,
205
221
  expiresAt: row.expires_at,
222
+ lastActiveAt: row.last_active_at ?? row.created_at,
206
223
  };
207
224
  store.sessions.set(session.id, session);
208
225
  }
@@ -250,8 +267,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
250
267
 
251
268
  const insertSession = db.query(
252
269
  `INSERT INTO ${AUTH_TABLES.sessions}
253
- (id, plane, principal_id, family_id, revoked_at, created_at, expires_at)
254
- VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires)`,
270
+ (id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at)
271
+ VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive)`,
255
272
  );
256
273
  for (const session of store.sessions.values()) {
257
274
  insertSession.run({
@@ -262,6 +279,7 @@ export function persistSessions(db: Database, store: SessionStore): void {
262
279
  $revoked: session.revokedAt,
263
280
  $created: session.createdAt,
264
281
  $expires: session.expiresAt,
282
+ $lastActive: session.lastActiveAt,
265
283
  });
266
284
  }
267
285
 
@@ -69,7 +69,7 @@ describe("console security gates (whole surface)", () => {
69
69
  claimCode: handle.state.claim.code,
70
70
  email: "ops@example.com",
71
71
  name: "Ops",
72
- password: "password123",
72
+ password: "password1234",
73
73
  }),
74
74
  }),
75
75
  );
@@ -234,7 +234,7 @@ describe("console security gates (whole surface)", () => {
234
234
  headers: { "content-type": "application/json" },
235
235
  body: JSON.stringify({
236
236
  email: "ops@example.com",
237
- password: "password123",
237
+ password: "password1234",
238
238
  }),
239
239
  }),
240
240
  );
@@ -252,7 +252,7 @@ describe("console security gates (whole surface)", () => {
252
252
  headers: { "content-type": "application/json" },
253
253
  body: JSON.stringify({
254
254
  email: "other@example.com",
255
- password: "password123",
255
+ password: "password1234",
256
256
  }),
257
257
  }),
258
258
  );
@@ -20,24 +20,28 @@ export const GATES_LIST_FIXTURE: GatesListResponse = {
20
20
  plane: "user",
21
21
  gates: ["member", "booking:create", "rate:sliding-window-counter:300/1m"],
22
22
  unguarded: false,
23
+ explicitPublic: false,
23
24
  },
24
25
  {
25
26
  flowId: "health.ping",
26
27
  plane: "user",
27
28
  gates: [],
28
29
  unguarded: true,
30
+ explicitPublic: false,
29
31
  },
30
32
  {
31
33
  flowId: "reports.export",
32
34
  plane: "user",
33
35
  gates: ["staff"],
34
36
  unguarded: false,
37
+ explicitPublic: false,
35
38
  },
36
39
  {
37
40
  flowId: "console.store.query",
38
41
  plane: "operator",
39
42
  gates: [],
40
43
  unguarded: false,
44
+ explicitPublic: false,
41
45
  },
42
46
  ],
43
47
  gates: [
@@ -11,6 +11,8 @@ export interface FlowGatesRecord {
11
11
  readonly plane: "user" | "operator";
12
12
  readonly gates: readonly string[];
13
13
  readonly unguarded: boolean;
14
+ /** Chain includes the reserved `public` sentinel. */
15
+ readonly explicitPublic?: boolean;
14
16
  }
15
17
 
16
18
  /** Declared gate definition. */
@@ -808,6 +808,7 @@ interface ConsoleClient {
808
808
  plane: "user" | "operator";
809
809
  gates: string[];
810
810
  unguarded: boolean;
811
+ explicitPublic: boolean;
811
812
  }>;
812
813
  gates: Array<{
813
814
  name: string;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Gate boot audit — every HTTP trigger must declare auth posture.
3
+ *
4
+ * Fail loud: missing gate and missing {@link gate.public} → {@link GateBootError}.
5
+ * `unguardedHttp: "allow"` is honoured **only** when `env === "test"` — never a
6
+ * production-wide bypass. Migrate real apps with per-trigger `gate.public`.
7
+ */
8
+
9
+ import { GATE_PUBLIC_NAME } from "./declare.ts";
10
+
11
+ /** One HTTP flow missing declared auth posture. */
12
+ export interface GatePostureGap {
13
+ /** Flow id (`unit.name` or flow name). */
14
+ readonly flowId: string;
15
+ /** HTTP method. */
16
+ readonly method: string;
17
+ /** HTTP path template. */
18
+ readonly path: string;
19
+ }
20
+
21
+ /**
22
+ * Thrown when HTTP triggers omit both a gate chain and {@link gate.public}.
23
+ * Lists every gap (Vault-style — fail once with the full set).
24
+ */
25
+ export class GateBootError extends Error {
26
+ readonly gaps: readonly GatePostureGap[];
27
+
28
+ constructor(gaps: readonly GatePostureGap[]) {
29
+ const lines = gaps.map((g) => ` - ${g.flowId} ${g.method} ${g.path}`);
30
+ super(
31
+ `gate boot failed — ${gaps.length} HTTP trigger(s) missing auth posture (attach a gate or gate.public):\n${lines.join("\n")}`,
32
+ );
33
+ this.name = "GateBootError";
34
+ this.gaps = gaps;
35
+ }
36
+ }
37
+
38
+ /** Minimal HTTP trigger shape for posture audit. */
39
+ export interface HttpTriggerPosture {
40
+ readonly kind: "http";
41
+ readonly method: string;
42
+ readonly path: string;
43
+ readonly gates: readonly (string | { readonly name: string })[];
44
+ }
45
+
46
+ /** Binding shape consumed by the audit. */
47
+ export interface BindingPosture {
48
+ readonly trigger: { readonly kind: string } & Omit<Partial<HttpTriggerPosture>, "kind">;
49
+ readonly flow: { readonly name: string };
50
+ }
51
+
52
+ /** Options for {@link assertHttpGatePosture}. */
53
+ export interface AssertHttpGatePostureOptions {
54
+ /**
55
+ * `"allow"` skips the audit **only** when {@link env} is `"test"`.
56
+ * Outside test, `"allow"` has no effect — enforcement still runs.
57
+ */
58
+ readonly unguardedHttp?: "deny" | "allow";
59
+ /**
60
+ * Boot environment. Hard-coded gate: `"allow"` is ignored unless this is
61
+ * `"test"` (not developer-overridable beyond the env value itself).
62
+ */
63
+ readonly env?: string;
64
+ }
65
+
66
+ /**
67
+ * Whether a gate ref is the public sentinel.
68
+ *
69
+ * @param ref - Gate name or named handle
70
+ */
71
+ export function isPublicGateRef(ref: string | { readonly name: string }): boolean {
72
+ return (typeof ref === "string" ? ref : ref.name) === GATE_PUBLIC_NAME;
73
+ }
74
+
75
+ /**
76
+ * Whether an HTTP trigger has declared auth posture (any gate or public).
77
+ *
78
+ * @param gates - Trigger gate chain
79
+ */
80
+ export function hasHttpGatePosture(
81
+ gates: readonly (string | { readonly name: string })[],
82
+ ): boolean {
83
+ return gates.length > 0;
84
+ }
85
+
86
+ /**
87
+ * Collect HTTP triggers with neither a gate nor {@link gate.public}.
88
+ *
89
+ * @param bindings - Adopted / registered bindings
90
+ */
91
+ export function collectUnguardedHttpGaps(bindings: readonly BindingPosture[]): GatePostureGap[] {
92
+ const gaps: GatePostureGap[] = [];
93
+ for (const b of bindings) {
94
+ if (b.trigger.kind !== "http") continue;
95
+ const t = b.trigger as HttpTriggerPosture;
96
+ if (hasHttpGatePosture(t.gates)) continue;
97
+ gaps.push({
98
+ flowId: b.flow.name,
99
+ method: t.method,
100
+ path: t.path,
101
+ });
102
+ }
103
+ return gaps;
104
+ }
105
+
106
+ /**
107
+ * Whether `unguardedHttp: "allow"` may skip the audit for this boot.
108
+ * Hard-coded: only `env === "test"`.
109
+ *
110
+ * @param unguardedHttp - Requested opt-out
111
+ * @param env - Boot environment
112
+ */
113
+ export function unguardedHttpAllowActive(
114
+ unguardedHttp: "deny" | "allow" | undefined,
115
+ env: string | undefined,
116
+ ): boolean {
117
+ return unguardedHttp === "allow" && env === "test";
118
+ }
119
+
120
+ /**
121
+ * Assert every HTTP trigger declares auth posture. Throws {@link GateBootError}.
122
+ *
123
+ * @param bindings - Adopted / registered bindings
124
+ * @param options - Opt-out + env (allow only in test)
125
+ */
126
+ export function assertHttpGatePosture(
127
+ bindings: readonly BindingPosture[],
128
+ options: AssertHttpGatePostureOptions | "deny" | "allow" = "deny",
129
+ ): void {
130
+ // Back-compat: second arg was previously just `"deny" | "allow"`.
131
+ const opts: AssertHttpGatePostureOptions =
132
+ typeof options === "string" ? { unguardedHttp: options } : options;
133
+ if (unguardedHttpAllowActive(opts.unguardedHttp, opts.env)) return;
134
+ const gaps = collectUnguardedHttpGaps(bindings);
135
+ if (gaps.length > 0) throw new GateBootError(gaps);
136
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `oke({ gate })` — nested Gate bag (auth · policies · rate · posture).
3
+ */
4
+
5
+ import { resolveGateAuth, type GateAuthOptions, type ResolvedGateAuth } from "../../auth/config.ts";
6
+ import type { GateDecl } from "./declare.ts";
7
+
8
+ /** Rate-limit defaults under Gate. */
9
+ export interface GateRateLimitOptions {
10
+ /** When true, auth Flows attach stricter rate presets (default true when auth enabled). */
11
+ readonly enabled?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Nested Gate options for {@link oke}.
16
+ *
17
+ * Replaces root `auth` / `gates` / `unguardedHttp`.
18
+ */
19
+ export interface GateOptions {
20
+ /** Builtin hybrid-session auth + core Flows under `basePath`. */
21
+ readonly auth?: GateAuthOptions;
22
+ /** Named policy / rate declarations for the Gate runtime. */
23
+ readonly policies?: readonly GateDecl[];
24
+ /** Global rate-limit posture (auth path presets). */
25
+ readonly rateLimit?: GateRateLimitOptions;
26
+ /**
27
+ * HTTP auth-posture enforcement at boot.
28
+ * Default `"deny"`. `"allow"` is honoured only when `env === "test"`.
29
+ */
30
+ readonly unguardedHttp?: "deny" | "allow";
31
+ }
32
+
33
+ /** Fully resolved Gate config consumed by `oke` / boot. */
34
+ export interface ResolvedGateConfig {
35
+ readonly auth: ResolvedGateAuth | undefined;
36
+ readonly policies: readonly GateDecl[];
37
+ readonly rateLimitEnabled: boolean;
38
+ readonly unguardedHttp: "deny" | "allow";
39
+ }
40
+
41
+ /** Options for {@link resolveGateConfig}. */
42
+ export interface ResolveGateConfigOptions {
43
+ readonly gate?: GateOptions;
44
+ readonly env?: string;
45
+ }
46
+
47
+ /**
48
+ * Resolve the nested `gate` bag for construction / boot.
49
+ *
50
+ * @param options - Gate bag + env (for auth secret rules)
51
+ */
52
+ export function resolveGateConfig(options: ResolveGateConfigOptions = {}): ResolvedGateConfig {
53
+ const bag = options.gate ?? {};
54
+ const auth =
55
+ bag.auth !== undefined ? resolveGateAuth({ auth: bag.auth, env: options.env }) : undefined;
56
+ const rateLimitEnabled =
57
+ bag.rateLimit?.enabled !== undefined
58
+ ? bag.rateLimit.enabled
59
+ : auth !== undefined
60
+ ? true
61
+ : false;
62
+
63
+ return {
64
+ auth,
65
+ policies: bag.policies ?? [],
66
+ rateLimitEnabled,
67
+ unguardedHttp: bag.unguardedHttp ?? "deny",
68
+ };
69
+ }