okengine 0.15.2 → 0.17.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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -0,0 +1,261 @@
1
+ /**
2
+ * SQL persist / hydrate for {@link ApiKeyRow} on `oke_api_keys`.
3
+ *
4
+ * Host path: public schema on the app `store.sql()` connection
5
+ * (`sharedSqlConn` / `DATABASE_URL`). Console attaches that same
6
+ * {@link ApiKeyStore} — it does not own a second key table.
7
+ */
8
+
9
+ import type { SqlConnection } from "../drivers/types.ts";
10
+ import type { ApiKeyRow } from "./tables.ts";
11
+ import { AUTH_TABLES } from "./tables.ts";
12
+ import type { ApiKeyStore } from "./api-keys.ts";
13
+
14
+ /** Minimal SQL executor for the auth key table. */
15
+ export interface ApiKeySqlExec {
16
+ execute(sql: string, params: readonly unknown[]): Promise<void>;
17
+ all(sql: string): Promise<readonly Record<string, unknown>[]>;
18
+ }
19
+
20
+ /**
21
+ * Persist one key row to `oke_api_keys`.
22
+ *
23
+ * @param sql - Executor
24
+ * @param row - Key row
25
+ * @param table - Physical table (`oke_api_keys` or schema-qualified)
26
+ */
27
+ export async function persistApiKeyRow(
28
+ sql: ApiKeySqlExec,
29
+ row: ApiKeyRow,
30
+ table: string = AUTH_TABLES.apiKeys,
31
+ ): Promise<void> {
32
+ await sql.execute(
33
+ `INSERT INTO ${table} (
34
+ id, plane, hash, name, scopes, expires_at, rate_limit, ip_allowlist,
35
+ creator_id, creator_scopes, created_at, last_used_at, revoked_at, tenant_id
36
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
37
+ ON CONFLICT(id) DO UPDATE SET
38
+ hash = excluded.hash,
39
+ name = excluded.name,
40
+ scopes = excluded.scopes,
41
+ expires_at = excluded.expires_at,
42
+ rate_limit = excluded.rate_limit,
43
+ ip_allowlist = excluded.ip_allowlist,
44
+ creator_id = excluded.creator_id,
45
+ creator_scopes = excluded.creator_scopes,
46
+ last_used_at = excluded.last_used_at,
47
+ revoked_at = excluded.revoked_at,
48
+ tenant_id = excluded.tenant_id`,
49
+ [
50
+ row.id,
51
+ row.plane,
52
+ row.hash,
53
+ row.name,
54
+ JSON.stringify(row.scopes),
55
+ row.expiresAt,
56
+ row.rateLimit ? JSON.stringify(row.rateLimit) : null,
57
+ JSON.stringify(row.ipAllowlist),
58
+ row.creatorId,
59
+ JSON.stringify(row.creatorScopes),
60
+ row.createdAt,
61
+ row.lastUsedAt,
62
+ row.revokedAt,
63
+ row.tenantId ?? null,
64
+ ],
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Load every key row from `oke_api_keys` into a store.
70
+ *
71
+ * @param sql - Executor
72
+ * @param store - Destination store
73
+ * @param table - Physical table
74
+ */
75
+ export async function hydrateApiKeyStore(
76
+ sql: ApiKeySqlExec,
77
+ store: ApiKeyStore,
78
+ table: string = AUTH_TABLES.apiKeys,
79
+ ): Promise<void> {
80
+ const rows = await sql.all(`SELECT * FROM ${table}`);
81
+ for (const raw of rows) {
82
+ const row = rowFromSql(raw);
83
+ if (row) store.keys.set(row.id, row);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Bind write-through persist on a store.
89
+ *
90
+ * @param store - Key store
91
+ * @param sql - Executor
92
+ * @param table - Physical table
93
+ */
94
+ export function bindApiKeySqlPersist(
95
+ store: ApiKeyStore,
96
+ sql: ApiKeySqlExec,
97
+ table: string = AUTH_TABLES.apiKeys,
98
+ ): void {
99
+ store.persist = (row) => persistApiKeyRow(sql, row, table);
100
+ }
101
+
102
+ /** Runtime that can open the app's shared primary SQL connection. */
103
+ export interface HostApiKeySqlRuntime {
104
+ primarySql(): Promise<SqlConnection | undefined>;
105
+ }
106
+
107
+ /**
108
+ * Adapt a driver connection to {@link ApiKeySqlExec}.
109
+ *
110
+ * @param conn - Shared `store.sql()` connection
111
+ */
112
+ export function apiKeySqlExec(conn: SqlConnection): ApiKeySqlExec {
113
+ return {
114
+ execute: async (sql, params) => {
115
+ await conn.exec(sql, params);
116
+ },
117
+ all: async (sql) => conn.query(sql),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Create public `oke_api_keys` when missing (host schema, not `oke_console`).
123
+ *
124
+ * @param conn - Shared SQL connection
125
+ * @param table - Physical table
126
+ */
127
+ export async function ensureApiKeyTable(
128
+ conn: SqlConnection,
129
+ table: string = AUTH_TABLES.apiKeys,
130
+ ): Promise<void> {
131
+ await conn.exec(`
132
+ CREATE TABLE IF NOT EXISTS ${table} (
133
+ id TEXT PRIMARY KEY NOT NULL,
134
+ plane TEXT NOT NULL,
135
+ hash TEXT NOT NULL,
136
+ name TEXT NOT NULL,
137
+ scopes TEXT NOT NULL DEFAULT '[]',
138
+ expires_at BIGINT,
139
+ rate_limit TEXT,
140
+ ip_allowlist TEXT NOT NULL DEFAULT '[]',
141
+ creator_id TEXT NOT NULL,
142
+ creator_scopes TEXT NOT NULL DEFAULT '[]',
143
+ created_at BIGINT NOT NULL,
144
+ last_used_at BIGINT,
145
+ revoked_at BIGINT,
146
+ tenant_id TEXT
147
+ )
148
+ `);
149
+ await conn.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS tenant_id TEXT`);
150
+ }
151
+
152
+ /**
153
+ * Hydrate and write-through-persist a host {@link ApiKeyStore} on `conn`.
154
+ *
155
+ * Leaves an already-bound `persist` hook in place (injected test stores).
156
+ *
157
+ * @param conn - App SQL connection (`sharedSqlConn`)
158
+ * @param store - `gate.auth.apiKeyStore`
159
+ * @param table - Physical table (`oke_api_keys` in `public`)
160
+ */
161
+ export async function bindHostApiKeySql(
162
+ conn: SqlConnection,
163
+ store: ApiKeyStore,
164
+ table: string = AUTH_TABLES.apiKeys,
165
+ ): Promise<void> {
166
+ await ensureApiKeyTable(conn, table);
167
+ const exec = apiKeySqlExec(conn);
168
+ await hydrateApiKeyStore(exec, store, table);
169
+ if (!store.persist) bindApiKeySqlPersist(store, exec, table);
170
+ }
171
+
172
+ /**
173
+ * Bind host key persist through {@link HostApiKeySqlRuntime.primarySql}.
174
+ *
175
+ * No-ops when the store runtime has no SQL driver (in-memory-only apps).
176
+ *
177
+ * @param runtime - Booted `store` element
178
+ * @param store - `gate.auth.apiKeyStore`
179
+ */
180
+ export async function bindHostApiKeySqlFromStore(
181
+ runtime: HostApiKeySqlRuntime,
182
+ store: ApiKeyStore,
183
+ ): Promise<void> {
184
+ const conn = await runtime.primarySql();
185
+ if (!conn) return;
186
+ await bindHostApiKeySql(conn, store);
187
+ }
188
+
189
+ function rowFromSql(raw: Record<string, unknown>): ApiKeyRow | null {
190
+ const id = asString(raw.id ?? raw.ID);
191
+ const plane = raw.plane === "operator" ? "operator" : "user";
192
+ const hash = asString(raw.hash);
193
+ const name = asString(raw.name);
194
+ const creatorId = asString(raw.creator_id ?? raw.creatorId);
195
+ if (!id || !hash || !name || !creatorId) return null;
196
+ return {
197
+ id,
198
+ plane,
199
+ hash,
200
+ name,
201
+ scopes: asStringArray(raw.scopes),
202
+ expiresAt: asNumberOrNull(raw.expires_at ?? raw.expiresAt),
203
+ rateLimit: asRateLimit(raw.rate_limit ?? raw.rateLimit),
204
+ ipAllowlist: asStringArray(raw.ip_allowlist ?? raw.ipAllowlist),
205
+ creatorId,
206
+ creatorScopes: asStringArray(raw.creator_scopes ?? raw.creatorScopes),
207
+ createdAt: asNumberOrNull(raw.created_at ?? raw.createdAt) ?? 0,
208
+ lastUsedAt: asNumberOrNull(raw.last_used_at ?? raw.lastUsedAt),
209
+ revokedAt: asNumberOrNull(raw.revoked_at ?? raw.revokedAt),
210
+ ...(asString(raw.tenant_id ?? raw.tenantId) !== null
211
+ ? { tenantId: asString(raw.tenant_id ?? raw.tenantId) }
212
+ : {}),
213
+ };
214
+ }
215
+
216
+ function asString(value: unknown): string | null {
217
+ return typeof value === "string" && value.length > 0 ? value : null;
218
+ }
219
+
220
+ function asNumberOrNull(value: unknown): number | null {
221
+ if (typeof value === "number" && Number.isFinite(value)) return value;
222
+ if (typeof value === "string" && value.length > 0) {
223
+ const n = Number(value);
224
+ return Number.isFinite(n) ? n : null;
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function asStringArray(value: unknown): string[] {
230
+ if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
231
+ if (typeof value === "string" && value.length > 0) {
232
+ try {
233
+ const parsed: unknown = JSON.parse(value);
234
+ if (Array.isArray(parsed)) {
235
+ return parsed.filter((item): item is string => typeof item === "string");
236
+ }
237
+ } catch {
238
+ return [];
239
+ }
240
+ }
241
+ return [];
242
+ }
243
+
244
+ function asRateLimit(value: unknown): { max: number; per: string } | null {
245
+ const parsed: unknown =
246
+ typeof value === "string" && value.length > 0
247
+ ? (() => {
248
+ try {
249
+ return JSON.parse(value) as unknown;
250
+ } catch {
251
+ return null;
252
+ }
253
+ })()
254
+ : value;
255
+ if (!parsed || typeof parsed !== "object") return null;
256
+ const rec = parsed as { max?: unknown; per?: unknown };
257
+ if (typeof rec.max === "number" && typeof rec.per === "string") {
258
+ return { max: rec.max, per: rec.per };
259
+ }
260
+ return null;
261
+ }
@@ -3,14 +3,23 @@
3
3
  *
4
4
  * A key can never exceed the permissions of whoever created it.
5
5
  * The raw secret is returned exactly once on create / rotate.
6
+ * Hash is HMAC-SHA-256 with the auth pepper (gate.auth.secret).
6
7
  *
7
8
  * @see docs/spec/console.md §3.3 · §9.14
8
9
  */
9
10
 
11
+ import { lookup as dnsLookup } from "node:dns/promises";
12
+ import { parseDurationMs } from "../elements/clock/duration.ts";
10
13
  import { assertAttenuated } from "./attenuation.ts";
11
14
  import type { AuthPlane } from "./planes.ts";
12
15
  import type { ApiKeyRow } from "./tables.ts";
13
16
 
17
+ /** Capability / Manifest resource for `fx.auth` key methods. */
18
+ export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
19
+
20
+ /** Default HMAC pepper when no `gate.auth.secret` is bound (tests). */
21
+ const DEFAULT_PEPPER = "oke.api-key";
22
+
14
23
  /** Options when creating an API key. */
15
24
  export interface CreateApiKeyOptions {
16
25
  readonly plane: AuthPlane;
@@ -22,11 +31,26 @@ export interface CreateApiKeyOptions {
22
31
  readonly expiresAt?: number | null;
23
32
  readonly rateLimit?: { max: number; per: string } | null;
24
33
  readonly ipAllowlist?: readonly string[];
34
+ /** Optional tenant claim copied onto the key principal. */
35
+ readonly tenantId?: string | null;
25
36
  /** Injectable id / secret / hash (tests). */
26
37
  readonly id?: string;
27
38
  readonly secret?: string;
28
39
  readonly hash?: string;
29
40
  readonly now?: () => number;
41
+ /** HMAC pepper override (defaults to store pepper). */
42
+ readonly pepper?: string;
43
+ }
44
+
45
+ /** Patch for {@link updateApiKey}. */
46
+ export interface UpdateApiKeyOptions {
47
+ readonly name?: string;
48
+ readonly scopes?: readonly string[];
49
+ readonly expiresAt?: number | null;
50
+ readonly rateLimit?: { max: number; per: string } | null;
51
+ readonly ipAllowlist?: readonly string[];
52
+ /** Ceiling for scope changes (stored creatorScopes ∩ live scopes). */
53
+ readonly ceiling: ReadonlySet<string> | Iterable<string>;
30
54
  }
31
55
 
32
56
  /** Result of key creation — secret shown exactly once. */
@@ -36,27 +60,76 @@ export interface CreatedApiKey {
36
60
  readonly secret: string;
37
61
  }
38
62
 
39
- /** In-memory API key store. */
63
+ /** Public key row returned by `fx.auth.listApiKeys` (never includes hash). */
64
+ export interface ApiKeyPublicRow {
65
+ readonly id: string;
66
+ readonly name: string;
67
+ readonly plane: AuthPlane;
68
+ readonly scopes: readonly string[];
69
+ readonly createdAt: number;
70
+ readonly lastUsedAt: number | null;
71
+ readonly expiresAt: number | null;
72
+ readonly revokedAt: number | null;
73
+ readonly rateLimit: { max: number; per: string } | null;
74
+ readonly ipAllowlist: readonly string[];
75
+ }
76
+
77
+ /**
78
+ * API key store — in-memory Map, optionally HMAC-peppered.
79
+ * SQL persist is a write-through adapter bound at boot.
80
+ */
40
81
  export interface ApiKeyStore {
41
82
  keys: Map<string, ApiKeyRow>;
83
+ /** HMAC pepper (`gate.auth.secret`). */
84
+ pepper?: string;
85
+ /** Optional write-through persist (SQL). */
86
+ persist?: (row: ApiKeyRow) => void | Promise<void>;
87
+ /** Sliding-window hits for per-key rateLimit (subject = key id). */
88
+ rateHits?: Map<string, number[]>;
89
+ }
90
+
91
+ /** Options for {@link createApiKeyStore}. */
92
+ export interface CreateApiKeyStoreOptions {
93
+ readonly pepper?: string;
94
+ readonly persist?: (row: ApiKeyRow) => void | Promise<void>;
42
95
  }
43
96
 
44
97
  /**
45
98
  * Create an empty API key store.
99
+ *
100
+ * @param options - Optional HMAC pepper / persist hook
46
101
  */
47
- export function createApiKeyStore(): ApiKeyStore {
48
- return { keys: new Map() };
102
+ export function createApiKeyStore(options: CreateApiKeyStoreOptions = {}): ApiKeyStore {
103
+ return {
104
+ keys: new Map(),
105
+ ...(options.pepper !== undefined ? { pepper: options.pepper } : {}),
106
+ ...(options.persist !== undefined ? { persist: options.persist } : {}),
107
+ };
49
108
  }
50
109
 
51
110
  /**
52
- * Hash an API key secret (SHA-256 hex).
111
+ * HMAC-SHA-256 hex of an API key secret.
53
112
  *
54
113
  * @param secret - Raw secret
114
+ * @param pepper - HMAC key (`gate.auth.secret`); defaults to a test pepper
55
115
  */
56
- export async function hashApiKeySecret(secret: string): Promise<string> {
57
- const bytes = new TextEncoder().encode(secret);
58
- const digest = await crypto.subtle.digest("SHA-256", bytes);
59
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
116
+ export async function hashApiKeySecret(
117
+ secret: string,
118
+ pepper: string = DEFAULT_PEPPER,
119
+ ): Promise<string> {
120
+ const key = await crypto.subtle.importKey(
121
+ "raw",
122
+ new TextEncoder().encode(pepper),
123
+ { name: "HMAC", hash: "SHA-256" },
124
+ false,
125
+ ["sign"],
126
+ );
127
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(secret));
128
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
129
+ }
130
+
131
+ async function persistRow(store: ApiKeyStore, row: ApiKeyRow): Promise<void> {
132
+ await store.persist?.(row);
60
133
  }
61
134
 
62
135
  /**
@@ -74,7 +147,8 @@ export async function createApiKey(
74
147
  const now = options.now ?? (() => Date.now());
75
148
  const id = options.id ?? crypto.randomUUID();
76
149
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
77
- const hash = options.hash ?? (await hashApiKeySecret(secret));
150
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
151
+ const hash = options.hash ?? (await hashApiKeySecret(secret, pepper));
78
152
  const creatorScopes = [...options.creatorScopes];
79
153
 
80
154
  const row: ApiKeyRow = {
@@ -91,34 +165,80 @@ export async function createApiKey(
91
165
  createdAt: now(),
92
166
  lastUsedAt: null,
93
167
  revokedAt: null,
168
+ ...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
94
169
  };
95
170
  store.keys.set(id, row);
171
+ await persistRow(store, row);
96
172
  return { row, secret };
97
173
  }
98
174
 
175
+ /** Resolve a stored hostname to addresses (injected in tests). */
176
+ export type AllowlistLookup = (host: string) => Promise<readonly string[]>;
177
+
178
+ /** Options for {@link authenticateApiKey}. */
179
+ export interface AuthenticateApiKeyOptions {
180
+ readonly now?: () => number;
181
+ /** Client IP for allowlist enforcement. */
182
+ readonly ip?: string;
183
+ /** Hostname → A/AAAA (defaults to system DNS). */
184
+ readonly lookup?: AllowlistLookup;
185
+ }
186
+
99
187
  /**
100
- * Resolve a key by raw secret; enforces expiry and revocation.
188
+ * Resolve a key by raw secret; enforces expiry, revocation, and allowlist.
101
189
  *
102
190
  * @param store - Key store
103
191
  * @param secret - Raw secret
104
- * @param now - Clock
192
+ * @param nowOrOptions - Clock, or clock + client IP
105
193
  */
106
194
  export async function authenticateApiKey(
107
195
  store: ApiKeyStore,
108
196
  secret: string,
109
- now: () => number = () => Date.now(),
197
+ nowOrOptions: (() => number) | AuthenticateApiKeyOptions = () => Date.now(),
110
198
  ): Promise<ApiKeyRow | null> {
111
- const hash = await hashApiKeySecret(secret);
199
+ const options: AuthenticateApiKeyOptions =
200
+ typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
201
+ const now = options.now ?? (() => Date.now());
202
+ const pepper = store.pepper ?? DEFAULT_PEPPER;
203
+ const hash = await hashApiKeySecret(secret, pepper);
112
204
  for (const row of store.keys.values()) {
113
205
  if (row.hash !== hash) continue;
114
206
  if (row.revokedAt !== null) return null;
115
207
  if (row.expiresAt !== null && row.expiresAt <= now()) return null;
208
+ if (row.ipAllowlist.length > 0) {
209
+ const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
210
+ if (!allowed) return null;
211
+ }
212
+ if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
116
213
  row.lastUsedAt = now();
214
+ await persistRow(store, row);
117
215
  return row;
118
216
  }
119
217
  return null;
120
218
  }
121
219
 
220
+ /**
221
+ * List keys created by `creatorId` (includes revoked rows for audit).
222
+ *
223
+ * @param store - Key store
224
+ * @param creatorId - Issuer id
225
+ */
226
+ export function listApiKeys(store: ApiKeyStore, creatorId: string): ApiKeyRow[] {
227
+ return [...store.keys.values()]
228
+ .filter((row) => row.creatorId === creatorId)
229
+ .sort((a, b) => a.name.localeCompare(b.name));
230
+ }
231
+
232
+ /**
233
+ * Load one key by id.
234
+ *
235
+ * @param store - Key store
236
+ * @param id - Key id
237
+ */
238
+ export function getApiKey(store: ApiKeyStore, id: string): ApiKeyRow | undefined {
239
+ return store.keys.get(id);
240
+ }
241
+
122
242
  /**
123
243
  * Revoke an API key — irreversible. The row remains for audit / hygiene.
124
244
  *
@@ -134,6 +254,7 @@ export function revokeApiKey(
134
254
  const row = store.keys.get(id);
135
255
  if (!row || row.revokedAt !== null) return null;
136
256
  row.revokedAt = now();
257
+ void persistRow(store, row);
137
258
  return row;
138
259
  }
139
260
 
@@ -150,15 +271,156 @@ export async function rotateApiKey(
150
271
  options: {
151
272
  readonly secret?: string;
152
273
  readonly now?: () => number;
274
+ readonly pepper?: string;
153
275
  } = {},
154
276
  ): Promise<CreatedApiKey | null> {
155
277
  const row = store.keys.get(id);
156
278
  if (!row || row.revokedAt !== null) return null;
157
279
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
158
- row.hash = await hashApiKeySecret(secret);
280
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
281
+ row.hash = await hashApiKeySecret(secret, pepper);
282
+ await persistRow(store, row);
159
283
  return { row, secret };
160
284
  }
161
285
 
286
+ /**
287
+ * Update key metadata. Scope changes re-run {@link assertAttenuated}.
288
+ *
289
+ * @param store - Key store
290
+ * @param id - Key id
291
+ * @param options - Patch + attenuation ceiling
292
+ */
293
+ export function updateApiKey(
294
+ store: ApiKeyStore,
295
+ id: string,
296
+ options: UpdateApiKeyOptions,
297
+ ): ApiKeyRow | null {
298
+ const row = store.keys.get(id);
299
+ if (!row || row.revokedAt !== null) return null;
300
+ if (options.scopes !== undefined) {
301
+ assertAttenuated(options.ceiling, options.scopes, "api key");
302
+ row.scopes = [...options.scopes];
303
+ }
304
+ if (options.name !== undefined) row.name = options.name;
305
+ if (options.expiresAt !== undefined) row.expiresAt = options.expiresAt;
306
+ if (options.rateLimit !== undefined) row.rateLimit = options.rateLimit;
307
+ if (options.ipAllowlist !== undefined) row.ipAllowlist = [...options.ipAllowlist];
308
+ void persistRow(store, row);
309
+ return row;
310
+ }
311
+
312
+ /**
313
+ * Public projection of a key row (no hash / creatorScopes).
314
+ *
315
+ * @param row - Stored row
316
+ */
317
+ export function toApiKeyPublicRow(row: ApiKeyRow): ApiKeyPublicRow {
318
+ return {
319
+ id: row.id,
320
+ name: row.name,
321
+ plane: row.plane,
322
+ scopes: [...row.scopes],
323
+ createdAt: row.createdAt,
324
+ lastUsedAt: row.lastUsedAt,
325
+ expiresAt: row.expiresAt,
326
+ revokedAt: row.revokedAt,
327
+ rateLimit: row.rateLimit,
328
+ ipAllowlist: [...row.ipAllowlist],
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Whether the client IP matches an allowlist entry.
334
+ * Literals compare exactly. Hostnames resolve at verify time (fail closed).
335
+ *
336
+ * @param entries - IPs and/or hostnames
337
+ * @param ip - Client IP
338
+ * @param lookup - Hostname resolver
339
+ */
340
+ export async function allowlistAllowsIp(
341
+ entries: readonly string[],
342
+ ip: string | undefined,
343
+ lookup: AllowlistLookup = lookupAllowlistHost,
344
+ ): Promise<boolean> {
345
+ if (entries.length === 0) return true;
346
+ if (ip === undefined) return false;
347
+ const client = ip.trim().toLowerCase();
348
+ for (const raw of entries) {
349
+ const entry = normalizeAllowlistEntry(raw);
350
+ if (entry.length === 0) continue;
351
+ if (isIpLiteral(entry)) {
352
+ if (entry === client) return true;
353
+ continue;
354
+ }
355
+ try {
356
+ const addrs = await lookup(entry);
357
+ if (addrs.some((addr) => addr.trim().toLowerCase() === client)) return true;
358
+ } catch {
359
+ // This host fails closed; other entries may still match.
360
+ }
361
+ }
362
+ return false;
363
+ }
364
+
365
+ function normalizeAllowlistEntry(raw: string): string {
366
+ let entry = raw.trim().toLowerCase();
367
+ entry = entry.replace(/^https?:\/\//, "");
368
+ const slash = entry.indexOf("/");
369
+ if (slash >= 0) entry = entry.slice(0, slash);
370
+ if (entry.endsWith(".")) entry = entry.slice(0, -1);
371
+ return entry;
372
+ }
373
+
374
+ function isIpLiteral(entry: string): boolean {
375
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
376
+ return entry.split(".").every((octet) => Number(octet) <= 255);
377
+ }
378
+ return entry.includes(":") && /^[0-9a-f:.]+$/.test(entry);
379
+ }
380
+
381
+ async function lookupAllowlistHost(host: string): Promise<readonly string[]> {
382
+ const rows = await dnsLookup(host, { all: true, verbatim: true });
383
+ return rows.map((row) => row.address);
384
+ }
385
+
386
+ /**
387
+ * Client IP from X-Forwarded-For (right-most trusted hop) or X-Real-IP.
388
+ *
389
+ * @param request - Incoming request
390
+ * @param trustedProxyDepth - Hops from the right (default 1)
391
+ */
392
+ export function clientIpFromRequest(request: Request, trustedProxyDepth = 1): string | undefined {
393
+ const xff = request.headers.get("x-forwarded-for");
394
+ if (xff) {
395
+ const hops = xff
396
+ .split(",")
397
+ .map((h) => h.trim())
398
+ .filter((h) => h.length > 0);
399
+ const index = hops.length - trustedProxyDepth;
400
+ if (index >= 0) return hops[index];
401
+ }
402
+ return request.headers.get("x-real-ip")?.trim() || undefined;
403
+ }
404
+
405
+ function takeKeyRate(
406
+ store: ApiKeyStore,
407
+ keyId: string,
408
+ limit: { max: number; per: string },
409
+ nowMs: number,
410
+ ): boolean {
411
+ const windowMs = parseDurationMs(limit.per);
412
+ const hits = store.rateHits ?? (store.rateHits = new Map<string, number[]>());
413
+ const prev = hits.get(keyId) ?? [];
414
+ const kept = prev.filter((t) => nowMs - t < windowMs);
415
+ if (kept.length >= limit.max) {
416
+ hits.set(keyId, kept);
417
+ return false;
418
+ }
419
+ kept.push(nowMs);
420
+ hits.set(keyId, kept);
421
+ return true;
422
+ }
423
+
162
424
  function randomSecret(): string {
163
425
  const bytes = crypto.getRandomValues(new Uint8Array(24));
164
426
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");