okengine 0.15.2 → 0.16.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 (141) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/flow.mdx +6 -0
  3. package/site/content/docs/elements/gate.mdx +55 -0
  4. package/site/content/docs/elements/store.mdx +13 -15
  5. package/site/content/docs/elements/vault.mdx +3 -0
  6. package/site/content/docs/reference/client.mdx +3 -0
  7. package/site/content/docs/reference/fx.mdx +29 -18
  8. package/site/content/docs/reference/security.md +1 -1
  9. package/src/auth/api-key-sql.test.ts +47 -0
  10. package/src/auth/api-key-sql.ts +254 -0
  11. package/src/auth/api-keys.ts +273 -14
  12. package/src/auth/auth.test.ts +56 -0
  13. package/src/auth/config.ts +5 -5
  14. package/src/auth/index.ts +25 -1
  15. package/src/auth/schema.ts +8 -0
  16. package/src/cli/attach-host-console.test.ts +37 -0
  17. package/src/cli/attach-host-console.ts +6 -0
  18. package/src/compiler/effects-infer.ts +17 -2
  19. package/src/console/server/access.test.ts +116 -8
  20. package/src/console/server/access.ts +70 -23
  21. package/src/console/server/app.ts +1 -0
  22. package/src/console/server/dev-identities.test.ts +127 -0
  23. package/src/console/server/dev-identities.ts +229 -0
  24. package/src/console/server/flows-invoke.test.ts +21 -4
  25. package/src/console/server/flows.ts +113 -58
  26. package/src/console/server/gates.test.ts +24 -0
  27. package/src/console/server/gates.ts +8 -21
  28. package/src/console/server/index.ts +1 -0
  29. package/src/console/server/live.test.ts +26 -1
  30. package/src/console/server/live.ts +4 -2
  31. package/src/console/server/operator-db.ts +2 -0
  32. package/src/console/server/state.ts +29 -55
  33. package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
  34. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  35. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  36. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  37. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  38. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  39. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  40. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  41. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  42. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  43. package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
  44. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  45. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  46. package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
  47. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  48. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  49. package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
  50. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  51. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  52. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  53. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  54. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  55. package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
  56. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  57. package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
  58. package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
  59. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  60. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  61. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  62. package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
  63. package/src/console/ui-next/dist/index.html +11 -9
  64. package/src/console/ui-next/index.html +3 -3
  65. package/src/console/ui-next/src/client.ts +167 -0
  66. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  67. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  68. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  69. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  70. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  71. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  72. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  73. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  74. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  75. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  76. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  77. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  78. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  79. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  80. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  81. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  82. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  83. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  84. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  85. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  86. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  87. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  88. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  89. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  90. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  91. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  92. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  93. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  94. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  95. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  96. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  97. package/src/console/ui-next/src/router.tsx +10 -1
  98. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  99. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  100. package/src/console/xss.gate.test.ts +1 -0
  101. package/src/elements/gate/declare.ts +2 -0
  102. package/src/elements/store/cache.ts +1 -1
  103. package/src/elements/store/runtime.ts +14 -0
  104. package/src/elements/store/schema-decl.ts +99 -41
  105. package/src/elements/store/sql-select.types.test.ts +71 -0
  106. package/src/elements/store/sql-session.ts +54 -32
  107. package/src/elements/store.ts +3 -0
  108. package/src/index.ts +1 -0
  109. package/src/kernel/api-key-host-persist.test.ts +143 -0
  110. package/src/kernel/app.ts +14 -2
  111. package/src/kernel/auth-resolve.ts +10 -5
  112. package/src/kernel/boot.test.ts +4 -4
  113. package/src/kernel/fx-auth-keys.test.ts +88 -0
  114. package/src/kernel/fx-auth-keys.ts +198 -0
  115. package/src/kernel/fx.test.ts +17 -1
  116. package/src/kernel/fx.ts +36 -18
  117. package/src/kernel/pipeline-api-key.test.ts +192 -7
  118. package/src/kernel/pipeline.ts +4 -2
  119. package/src/manifest/types.ts +9 -3
  120. package/src/release/build-lib.ts +1 -0
  121. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  122. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  123. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  124. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  125. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  126. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  127. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  128. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  129. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  130. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  131. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  132. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  133. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  134. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  135. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  136. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  137. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  138. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  139. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  140. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  141. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Host API-key durability — standalone `oke()` (no Console).
3
+ *
4
+ * Creates a key via `fx.auth.createApiKey`, stops, boots a fresh app against
5
+ * the same SQL URL, and verifies Bearer on the new `gate.auth.apiKeyStore`.
6
+ * Production uses postgres + `DATABASE_URL`; this suite uses file-backed
7
+ * PGlite at that same URL so the restart is hermetic.
8
+ */
9
+
10
+ import { afterEach, describe, expect, test } from "bun:test";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { AUTH_TABLES } from "../auth/tables.ts";
15
+ import { gate } from "../elements/gate.ts";
16
+ import { resetStores, store } from "../elements/store/declare.ts";
17
+ import { oke } from "./app.ts";
18
+ import { flow, resetFlowSeq } from "./flow.ts";
19
+ import { on, resetBindings } from "./on.ts";
20
+ import { http } from "./triggers.ts";
21
+
22
+ const PEPPER = "oke-host-api-key-persist-pepper";
23
+ const member = gate.policy("member", ({ auth }) => !!auth.verified);
24
+
25
+ function resetIsolated(): void {
26
+ resetBindings();
27
+ resetFlowSeq();
28
+ resetStores();
29
+ }
30
+
31
+ afterEach(() => {
32
+ resetIsolated();
33
+ });
34
+
35
+ describe("host API key persist via store.sql()", () => {
36
+ test("standalone oke() persists keys across restart; Bearer verifies on a fresh store", async () => {
37
+ const dir = await mkdtemp(join(tmpdir(), "oke-host-keys-"));
38
+ const sqlUrl = join(dir, "pgdata");
39
+ const prevDb = process.env.DATABASE_URL;
40
+ const prevPglite = process.env.OKE_PGLITE_URL;
41
+ process.env.DATABASE_URL = sqlUrl;
42
+ process.env.OKE_PGLITE_URL = sqlUrl;
43
+
44
+ try {
45
+ resetIsolated();
46
+ const db1 = store.sql("app", { schema: {} });
47
+ on(
48
+ http.post("/keys").gate(member),
49
+ flow("keys.create", {
50
+ effects: { writes: ["auth:api-keys"] },
51
+ do: (_input, fx) => fx.auth.createApiKey({ name: "prod-ci", scopes: ["member"] }),
52
+ }),
53
+ );
54
+ on(
55
+ http.get("/secure").gate(member),
56
+ flow("secure.ping", {
57
+ effects: {},
58
+ do: () => ({ ok: true }),
59
+ }),
60
+ );
61
+
62
+ const first = oke({
63
+ name: "host-keys-first",
64
+ stores: [db1],
65
+ gate: {
66
+ auth: { secret: PEPPER, http: false },
67
+ policies: [member],
68
+ },
69
+ env: "test",
70
+ startScheduler: false,
71
+ });
72
+ await first.boot({ env: "test", startScheduler: false });
73
+ expect(first.apiKeys?.persist).toBeTypeOf("function");
74
+
75
+ const created = await first.execute(
76
+ first.flow("keys.create")!,
77
+ {},
78
+ http.post("/keys").gate(member),
79
+ {
80
+ principal: {
81
+ plane: "user",
82
+ userId: "u_issuer",
83
+ scopes: new Set(["member"]),
84
+ verified: true,
85
+ },
86
+ },
87
+ );
88
+ expect(created.failure).toBeUndefined();
89
+ const secret = (created.output as { secret?: string } | undefined)?.secret;
90
+ expect(secret?.startsWith("oke_")).toBe(true);
91
+
92
+ const firstStore = first.bootResult?.store;
93
+ await first.stop();
94
+ await firstStore?.close();
95
+
96
+ resetIsolated();
97
+ const db2 = store.sql("app", { schema: {} });
98
+ on(
99
+ http.get("/secure").gate(member),
100
+ flow("secure.ping", {
101
+ effects: {},
102
+ do: () => ({ ok: true }),
103
+ }),
104
+ );
105
+ const second = oke({
106
+ name: "host-keys-second",
107
+ stores: [db2],
108
+ gate: {
109
+ auth: { secret: PEPPER, http: false },
110
+ policies: [member],
111
+ },
112
+ env: "test",
113
+ startScheduler: false,
114
+ });
115
+ await second.boot({ env: "test", startScheduler: false });
116
+ expect(second.apiKeys).not.toBe(first.apiKeys);
117
+ expect(second.apiKeys?.keys.size).toBeGreaterThan(0);
118
+
119
+ const conn = await second.bootResult?.store?.primarySql();
120
+ const rows = await conn?.query(`SELECT id, name FROM ${AUTH_TABLES.apiKeys}`);
121
+ expect(rows?.some((r) => r.name === "prod-ci")).toBe(true);
122
+
123
+ const res = await second.fetch(
124
+ new Request("http://localhost/secure", {
125
+ method: "GET",
126
+ headers: { authorization: `Bearer ${secret}` },
127
+ }),
128
+ );
129
+ expect(res.status).toBe(200);
130
+ expect(await res.json()).toEqual({ data: { ok: true }, error: null });
131
+
132
+ const secondStore = second.bootResult?.store;
133
+ await second.stop();
134
+ await secondStore?.close();
135
+ } finally {
136
+ if (prevDb === undefined) delete process.env.DATABASE_URL;
137
+ else process.env.DATABASE_URL = prevDb;
138
+ if (prevPglite === undefined) delete process.env.OKE_PGLITE_URL;
139
+ else process.env.OKE_PGLITE_URL = prevPglite;
140
+ await rm(dir, { recursive: true, force: true });
141
+ }
142
+ }, 20_000);
143
+ });
package/src/kernel/app.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import type { BootOptions, BootResult, ElementRuntimes } from "./boot.ts";
25
25
  import type { CapabilityToken } from "./capability.ts";
26
26
  import type { AppAuthBinding } from "./auth-resolve.ts";
27
+ import type { ApiKeyStore } from "../auth/api-keys.ts";
27
28
  import type { AuthHttpMaterialization } from "../auth/bindings.ts";
28
29
  import type { WiredGateAuth } from "./app-auth.ts";
29
30
  import {
@@ -371,6 +372,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
371
372
  * Builtin auth binding after boot (session store + secret), when configured.
372
373
  */
373
374
  readonly authBinding: AppAuthBinding | undefined;
375
+ /** Shared API key store when `gate.auth` is enabled. */
376
+ readonly apiKeys: ApiKeyStore | undefined;
374
377
  /**
375
378
  * Attach a plugin app-wide. Scope is the attachment point.
376
379
  * Types accumulate — decorations are visible on downstream handlers.
@@ -1176,6 +1179,10 @@ export function oke(options: OkeOptions): OkeApp {
1176
1179
  };
1177
1180
  const result = await bootApplication(merged);
1178
1181
  bootResult = result;
1182
+ if (gateConfig.auth?.apiKeyStore && result.store) {
1183
+ const { bindHostApiKeySqlFromStore } = await import("../auth/api-key-sql.ts");
1184
+ await bindHostApiKeySqlFromStore(result.store, gateConfig.auth.apiKeyStore);
1185
+ }
1179
1186
  // otp() provider / app mode capability — fail loud at boot, never silent downgrade.
1180
1187
  if (result.channel) {
1181
1188
  const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
@@ -1365,6 +1372,7 @@ export function oke(options: OkeOptions): OkeApp {
1365
1372
  userId: options.fx?.auth?.userId ?? null,
1366
1373
  scopes: new Set(options.fx?.auth?.scopes ?? []),
1367
1374
  verified: options.fx?.auth?.verified,
1375
+ apiKeyId: options.fx?.auth?.apiKeyId,
1368
1376
  },
1369
1377
  operator: { id: options.fx?.operator?.id ?? null },
1370
1378
  };
@@ -1393,7 +1401,8 @@ export function oke(options: OkeOptions): OkeApp {
1393
1401
  : {}),
1394
1402
  verifyBearer:
1395
1403
  binding && wiredAuth
1396
- ? async (token) => wiredAuth.verifyBearerOrApiKey(binding, token, apiKeyStore)
1404
+ ? (token, request) =>
1405
+ wiredAuth.verifyBearerOrApiKey(binding, token, apiKeyStore, request)
1397
1406
  : undefined,
1398
1407
  // Phase 1a: opt-in cookie → Bearer when Authorization is absent.
1399
1408
  resolveToken:
@@ -1450,7 +1459,8 @@ export function oke(options: OkeOptions): OkeApp {
1450
1459
  catalogs: loadMessages().getMessageCatalogs(),
1451
1460
  ...options.fx?.i18n,
1452
1461
  },
1453
- ...(principals ? { auth: principals.auth as FxAuth, operator: principals.operator } : {}),
1462
+ ...(principals ? { auth: principals.auth, operator: principals.operator } : {}),
1463
+ apiKeyStore: gateConfig.auth?.apiKeyStore,
1454
1464
  ...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
1455
1465
  ...(extras?.trustedInvoke === true && extras.revealPii === true ? { revealPii: true } : {}),
1456
1466
  rlsGateNames: gateNamesOf(trigger),
@@ -1604,6 +1614,7 @@ export function oke(options: OkeOptions): OkeApp {
1604
1614
  }
1605
1615
  return await invoke(ctx.input);
1606
1616
  } catch (err) {
1617
+ if (isFlowFailure(err)) return err;
1607
1618
  // A durable sleep that has not yet elapsed suspends the run — this
1608
1619
  // is a park, not a pipeline failure, so it must not throw upward.
1609
1620
  if (flowDef.durable && journalSession && isJournalSuspend(err)) {
@@ -1780,6 +1791,7 @@ export function oke(options: OkeOptions): OkeApp {
1780
1791
  get authBinding() {
1781
1792
  return authBinding;
1782
1793
  },
1794
+ apiKeys: gateConfig.auth?.apiKeyStore,
1783
1795
  async resumeDurable(now) {
1784
1796
  const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
1785
1797
  const { store, instanceId, leaseMs } = activeJournal();
@@ -6,7 +6,7 @@
6
6
  * separately in the pipeline (`allowTestPrincipals`).
7
7
  */
8
8
 
9
- import { authenticateApiKey, type ApiKeyStore } from "../auth/api-keys.ts";
9
+ import { authenticateApiKey, clientIpFromRequest, type ApiKeyStore } from "../auth/api-keys.ts";
10
10
  import type { ApiKeyRow } from "../auth/tables.ts";
11
11
  import {
12
12
  createSessionStore,
@@ -89,7 +89,9 @@ export function claimsToPrincipal(claims: AccessClaims): ResolvedPrincipal {
89
89
 
90
90
  /**
91
91
  * Map an authenticated API key row to the pipeline principal shape.
92
- * Uses the bare key id so Access `runTouchesKey` matches `WideEvent.principal`.
92
+ *
93
+ * The key is the issuer with fewer gates: `userId` / `operatorId` is
94
+ * {@link ApiKeyRow.creatorId}. The credential id is {@link ResolvedPrincipal.apiKeyId}.
93
95
  *
94
96
  * @param row - Authenticated key
95
97
  */
@@ -97,7 +99,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
97
99
  if (row.plane === "operator") {
98
100
  return {
99
101
  plane: "operator",
100
- operatorId: row.id,
102
+ operatorId: row.creatorId,
101
103
  userId: null,
102
104
  scopes: row.scopes,
103
105
  verified: true,
@@ -106,7 +108,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
106
108
  }
107
109
  return {
108
110
  plane: "user",
109
- userId: row.id,
111
+ userId: row.creatorId,
110
112
  scopes: row.scopes,
111
113
  verified: true,
112
114
  apiKeyId: row.id,
@@ -119,17 +121,20 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
119
121
  * @param auth - Session binding
120
122
  * @param token - Raw Bearer token
121
123
  * @param apiKeys - Optional key store
124
+ * @param request - Incoming request (allowlist IP is read here, not in `oke()`)
122
125
  */
123
126
  export async function verifyBearerOrApiKey(
124
127
  auth: AppAuthBinding,
125
128
  token: string,
126
129
  apiKeys?: ApiKeyStore,
130
+ request?: Request,
127
131
  ): Promise<ResolvedPrincipal> {
128
132
  try {
129
133
  return await verifyBearerToken(auth, token);
130
134
  } catch (err) {
131
135
  if (apiKeys) {
132
- const row = await authenticateApiKey(apiKeys, token, auth.now);
136
+ const ip = request ? clientIpFromRequest(request) : undefined;
137
+ const row = await authenticateApiKey(apiKeys, token, { now: auth.now, ip });
133
138
  if (row) return apiKeyRowToPrincipal(row);
134
139
  }
135
140
  throw err;
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 48 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -116,9 +116,9 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Rebased after clock / gate / kv / index auto-drain on `oke()`.
120
- // (~48.3 kB gzip with export externals).
121
- expect(total).toBeLessThan(49_000);
119
+ // Rebased after host API-key persist + allowlist on `oke()`.
120
+ // (~50.3 kB gzip with export externals).
121
+ expect(total).toBeLessThan(51_000);
122
122
  } finally {
123
123
  await rm(dir, { recursive: true, force: true });
124
124
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Session-only `fx.auth` key methods — issuer derived, never from flow input.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { AttenuationError } from "../auth/attenuation.ts";
7
+ import { createApiKey, createApiKeyStore } from "../auth/api-keys.ts";
8
+ import { isFlowFailure } from "./hooks.ts";
9
+ import { createFx } from "./fx.ts";
10
+
11
+ describe("fx.auth key methods", () => {
12
+ test("createApiKey derives creator from the live session and rejects broader scopes", async () => {
13
+ const store = createApiKeyStore();
14
+ const fx = createFx({
15
+ flow: "keys.create",
16
+ effects: { writes: ["auth:api-keys"] },
17
+ apiKeyStore: store,
18
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
19
+ });
20
+ const created = await fx.auth.createApiKey({ name: "mine", scopes: ["member"] });
21
+ expect(created.key.scopes).toEqual(["member"]);
22
+ expect(created.secret.startsWith("oke_")).toBe(true);
23
+ expect(store.keys.get(created.key.id)?.creatorId).toBe("u1");
24
+
25
+ await expect(fx.auth.createApiKey({ name: "wide", scopes: ["admin"] })).rejects.toBeInstanceOf(
26
+ AttenuationError,
27
+ );
28
+ });
29
+
30
+ test("key-authenticated caller cannot create / list / revoke / rotate / update", async () => {
31
+ const store = createApiKeyStore();
32
+ await createApiKey(store, {
33
+ plane: "user",
34
+ name: "existing",
35
+ scopes: ["member"],
36
+ creatorId: "u1",
37
+ creatorScopes: ["member"],
38
+ id: "key_existing",
39
+ });
40
+ const fx = createFx({
41
+ flow: "keys.as-key",
42
+ effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
43
+ apiKeyStore: store,
44
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: "key_existing" },
45
+ });
46
+ for (const call of [
47
+ () => fx.auth.createApiKey({ name: "nested", scopes: ["member"] }),
48
+ () => fx.auth.listApiKeys(),
49
+ () => fx.auth.revokeApiKey("key_existing"),
50
+ () => fx.auth.rotateApiKey("key_existing"),
51
+ () => fx.auth.updateApiKey("key_existing", { name: "nope" }),
52
+ ]) {
53
+ try {
54
+ await call();
55
+ throw new Error("expected Forbidden");
56
+ } catch (err) {
57
+ expect(isFlowFailure(err)).toBe(true);
58
+ if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
59
+ }
60
+ }
61
+ });
62
+
63
+ test("list is self-only; cross-user revoke is Forbidden", async () => {
64
+ const store = createApiKeyStore();
65
+ await createApiKey(store, {
66
+ plane: "user",
67
+ name: "theirs",
68
+ scopes: ["member"],
69
+ creatorId: "u2",
70
+ creatorScopes: ["member"],
71
+ id: "key_theirs",
72
+ });
73
+ const fx = createFx({
74
+ flow: "keys.list",
75
+ effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
76
+ apiKeyStore: store,
77
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
78
+ });
79
+ expect(await fx.auth.listApiKeys()).toEqual([]);
80
+ try {
81
+ await fx.auth.revokeApiKey("key_theirs");
82
+ throw new Error("expected Forbidden");
83
+ } catch (err) {
84
+ expect(isFlowFailure(err)).toBe(true);
85
+ if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `fx.auth` key-management methods — session-only, attenuated, one store.
3
+ */
4
+
5
+ import {
6
+ AUTH_API_KEYS_RESOURCE,
7
+ createApiKey,
8
+ getApiKey,
9
+ listApiKeys,
10
+ revokeApiKey,
11
+ rotateApiKey,
12
+ toApiKeyPublicRow,
13
+ updateApiKey,
14
+ type ApiKeyPublicRow,
15
+ type ApiKeyStore,
16
+ type CreatedApiKey,
17
+ } from "../auth/api-keys.ts";
18
+ import { parseDurationMs } from "../elements/clock/duration.ts";
19
+ import type { EffectKind } from "./effects.ts";
20
+ import { fail, type FlowFailure } from "./errors.ts";
21
+
22
+ /**
23
+ * Identity bag on {@link FxAuth} before key-management methods attach.
24
+ * Passed into {@link createFx} / {@link attach}.
25
+ */
26
+ export interface FxAuthIdentity {
27
+ /** User-plane subject id, or null when unauthenticated. */
28
+ readonly userId: string | null;
29
+ /** Granted scopes for the live principal. */
30
+ readonly scopes: ReadonlySet<string>;
31
+ /** Whether the identity has completed verification (email / MFA). */
32
+ readonly verified?: boolean;
33
+ /** Authenticating API key id when Bearer was a key secret; otherwise null. */
34
+ readonly apiKeyId?: string | null;
35
+ }
36
+
37
+ /** Options for {@link FxAuth.createApiKey}. */
38
+ export interface FxCreateApiKeyInput {
39
+ readonly name: string;
40
+ readonly scopes: readonly string[];
41
+ readonly expiresIn?: string;
42
+ readonly ipAllowlist?: readonly string[];
43
+ readonly rateLimit?: { max: number; per: string } | null;
44
+ }
45
+
46
+ /** Options for {@link FxAuth.updateApiKey}. */
47
+ export interface FxUpdateApiKeyInput {
48
+ readonly name?: string;
49
+ readonly scopes?: readonly string[];
50
+ readonly expiresIn?: string;
51
+ readonly ipAllowlist?: readonly string[];
52
+ readonly rateLimit?: { max: number; per: string } | null;
53
+ }
54
+
55
+ /** Create result for `fx.auth.createApiKey` / `rotateApiKey`. */
56
+ export interface FxCreatedApiKey {
57
+ readonly key: ApiKeyPublicRow;
58
+ readonly secret: string;
59
+ }
60
+
61
+ /**
62
+ * Session-only key management attached onto {@link FxAuthIdentity}.
63
+ * Methods throw {@link FlowFailure} values (`Unauthorized` / `Forbidden`).
64
+ */
65
+ export interface FxAuthKeyMethods {
66
+ createApiKey(input: FxCreateApiKeyInput): Promise<FxCreatedApiKey>;
67
+ listApiKeys(): Promise<readonly ApiKeyPublicRow[]>;
68
+ revokeApiKey(id: string): Promise<ApiKeyPublicRow>;
69
+ rotateApiKey(id: string): Promise<FxCreatedApiKey>;
70
+ updateApiKey(id: string, input: FxUpdateApiKeyInput): Promise<ApiKeyPublicRow>;
71
+ }
72
+
73
+ /** Dependencies for {@link attach}. */
74
+ export interface AttachAuthKeyMethodsOptions {
75
+ readonly auth: FxAuthIdentity;
76
+ readonly store: ApiKeyStore | undefined;
77
+ readonly now: () => number;
78
+ readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
79
+ }
80
+
81
+ function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
82
+ if (auth.apiKeyId) {
83
+ return fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "session_only" });
84
+ }
85
+ if (!auth.userId) {
86
+ return fail("Unauthorized", {});
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function requireStore(store: ApiKeyStore | undefined): ApiKeyStore {
92
+ if (!store) {
93
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "no_store" });
94
+ }
95
+ return store;
96
+ }
97
+
98
+ function requireOwned(
99
+ store: ApiKeyStore,
100
+ id: string,
101
+ userId: string,
102
+ ): ReturnType<typeof getApiKey> {
103
+ const row = getApiKey(store, id);
104
+ if (!row || row.creatorId !== userId) {
105
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
106
+ }
107
+ return row;
108
+ }
109
+
110
+ function expiresAtFrom(expiresIn: string | undefined, now: () => number): number | undefined {
111
+ if (expiresIn === undefined) return undefined;
112
+ return now() + parseDurationMs(expiresIn);
113
+ }
114
+
115
+ /**
116
+ * Attach create / list / revoke / rotate / update onto a live identity bag.
117
+ *
118
+ * @param options - Auth bag + store + capability gate
119
+ */
120
+ export function attach(options: AttachAuthKeyMethodsOptions): FxAuthIdentity & FxAuthKeyMethods {
121
+ const { auth, now, gated } = options;
122
+
123
+ const methods: FxAuthKeyMethods = {
124
+ createApiKey(input) {
125
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
126
+ const denied = sessionOnly(auth);
127
+ if (denied) throw denied;
128
+ const store = requireStore(options.store);
129
+ const created: CreatedApiKey = await createApiKey(store, {
130
+ plane: "user",
131
+ name: input.name,
132
+ scopes: input.scopes,
133
+ creatorId: auth.userId!,
134
+ creatorScopes: auth.scopes,
135
+ expiresAt: expiresAtFrom(input.expiresIn, now) ?? null,
136
+ rateLimit: input.rateLimit ?? null,
137
+ ipAllowlist: input.ipAllowlist,
138
+ now,
139
+ });
140
+ return { key: toApiKeyPublicRow(created.row), secret: created.secret };
141
+ });
142
+ },
143
+ listApiKeys() {
144
+ return gated("read", AUTH_API_KEYS_RESOURCE, async () => {
145
+ const denied = sessionOnly(auth);
146
+ if (denied) throw denied;
147
+ const store = requireStore(options.store);
148
+ return listApiKeys(store, auth.userId!).map(toApiKeyPublicRow);
149
+ });
150
+ },
151
+ revokeApiKey(id) {
152
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
153
+ const denied = sessionOnly(auth);
154
+ if (denied) throw denied;
155
+ const store = requireStore(options.store);
156
+ requireOwned(store, id, auth.userId!);
157
+ const row = revokeApiKey(store, id, now);
158
+ if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
159
+ return toApiKeyPublicRow(row);
160
+ });
161
+ },
162
+ rotateApiKey(id) {
163
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
164
+ const denied = sessionOnly(auth);
165
+ if (denied) throw denied;
166
+ const store = requireStore(options.store);
167
+ requireOwned(store, id, auth.userId!);
168
+ const rotated = await rotateApiKey(store, id, { now });
169
+ if (!rotated)
170
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
171
+ return { key: toApiKeyPublicRow(rotated.row), secret: rotated.secret };
172
+ });
173
+ },
174
+ updateApiKey(id, input) {
175
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
176
+ const denied = sessionOnly(auth);
177
+ if (denied) throw denied;
178
+ const store = requireStore(options.store);
179
+ const existing = requireOwned(store, id, auth.userId!);
180
+ if (!existing)
181
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
182
+ const ceiling = new Set([...existing.creatorScopes].filter((s) => auth.scopes.has(s)));
183
+ const row = updateApiKey(store, id, {
184
+ ceiling,
185
+ name: input.name,
186
+ scopes: input.scopes,
187
+ expiresAt: expiresAtFrom(input.expiresIn, now),
188
+ rateLimit: input.rateLimit,
189
+ ipAllowlist: input.ipAllowlist,
190
+ });
191
+ if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
192
+ return toApiKeyPublicRow(row);
193
+ });
194
+ },
195
+ };
196
+
197
+ return Object.assign(auth, methods);
198
+ }
@@ -432,7 +432,23 @@ describe("fx — wholesale swap", () => {
432
432
  t: (key) => key,
433
433
  locale: "en",
434
434
  id: () => "fixed-id",
435
- auth: { userId: "u1", scopes: new Set(["a"]) },
435
+ auth: {
436
+ userId: "u1",
437
+ scopes: new Set(["a"]),
438
+ createApiKey: async () => {
439
+ throw new Error("auth.createApiKey must not be used");
440
+ },
441
+ listApiKeys: async () => [],
442
+ revokeApiKey: async () => {
443
+ throw new Error("auth.revokeApiKey must not be used");
444
+ },
445
+ rotateApiKey: async () => {
446
+ throw new Error("auth.rotateApiKey must not be used");
447
+ },
448
+ updateApiKey: async () => {
449
+ throw new Error("auth.updateApiKey must not be used");
450
+ },
451
+ },
436
452
  operator: { id: null },
437
453
  principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },
438
454
  tenant: { id: "t1" },