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
@@ -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;
@@ -27,6 +36,19 @@ export interface CreateApiKeyOptions {
27
36
  readonly secret?: string;
28
37
  readonly hash?: string;
29
38
  readonly now?: () => number;
39
+ /** HMAC pepper override (defaults to store pepper). */
40
+ readonly pepper?: string;
41
+ }
42
+
43
+ /** Patch for {@link updateApiKey}. */
44
+ export interface UpdateApiKeyOptions {
45
+ readonly name?: string;
46
+ readonly scopes?: readonly string[];
47
+ readonly expiresAt?: number | null;
48
+ readonly rateLimit?: { max: number; per: string } | null;
49
+ readonly ipAllowlist?: readonly string[];
50
+ /** Ceiling for scope changes (stored creatorScopes ∩ live scopes). */
51
+ readonly ceiling: ReadonlySet<string> | Iterable<string>;
30
52
  }
31
53
 
32
54
  /** Result of key creation — secret shown exactly once. */
@@ -36,27 +58,76 @@ export interface CreatedApiKey {
36
58
  readonly secret: string;
37
59
  }
38
60
 
39
- /** In-memory API key store. */
61
+ /** Public key row returned by `fx.auth.listApiKeys` (never includes hash). */
62
+ export interface ApiKeyPublicRow {
63
+ readonly id: string;
64
+ readonly name: string;
65
+ readonly plane: AuthPlane;
66
+ readonly scopes: readonly string[];
67
+ readonly createdAt: number;
68
+ readonly lastUsedAt: number | null;
69
+ readonly expiresAt: number | null;
70
+ readonly revokedAt: number | null;
71
+ readonly rateLimit: { max: number; per: string } | null;
72
+ readonly ipAllowlist: readonly string[];
73
+ }
74
+
75
+ /**
76
+ * API key store — in-memory Map, optionally HMAC-peppered.
77
+ * SQL persist is a write-through adapter bound at boot.
78
+ */
40
79
  export interface ApiKeyStore {
41
80
  keys: Map<string, ApiKeyRow>;
81
+ /** HMAC pepper (`gate.auth.secret`). */
82
+ pepper?: string;
83
+ /** Optional write-through persist (SQL). */
84
+ persist?: (row: ApiKeyRow) => void | Promise<void>;
85
+ /** Sliding-window hits for per-key rateLimit (subject = key id). */
86
+ rateHits?: Map<string, number[]>;
87
+ }
88
+
89
+ /** Options for {@link createApiKeyStore}. */
90
+ export interface CreateApiKeyStoreOptions {
91
+ readonly pepper?: string;
92
+ readonly persist?: (row: ApiKeyRow) => void | Promise<void>;
42
93
  }
43
94
 
44
95
  /**
45
96
  * Create an empty API key store.
97
+ *
98
+ * @param options - Optional HMAC pepper / persist hook
46
99
  */
47
- export function createApiKeyStore(): ApiKeyStore {
48
- return { keys: new Map() };
100
+ export function createApiKeyStore(options: CreateApiKeyStoreOptions = {}): ApiKeyStore {
101
+ return {
102
+ keys: new Map(),
103
+ ...(options.pepper !== undefined ? { pepper: options.pepper } : {}),
104
+ ...(options.persist !== undefined ? { persist: options.persist } : {}),
105
+ };
49
106
  }
50
107
 
51
108
  /**
52
- * Hash an API key secret (SHA-256 hex).
109
+ * HMAC-SHA-256 hex of an API key secret.
53
110
  *
54
111
  * @param secret - Raw secret
112
+ * @param pepper - HMAC key (`gate.auth.secret`); defaults to a test pepper
55
113
  */
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("");
114
+ export async function hashApiKeySecret(
115
+ secret: string,
116
+ pepper: string = DEFAULT_PEPPER,
117
+ ): Promise<string> {
118
+ const key = await crypto.subtle.importKey(
119
+ "raw",
120
+ new TextEncoder().encode(pepper),
121
+ { name: "HMAC", hash: "SHA-256" },
122
+ false,
123
+ ["sign"],
124
+ );
125
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(secret));
126
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
127
+ }
128
+
129
+ async function persistRow(store: ApiKeyStore, row: ApiKeyRow): Promise<void> {
130
+ await store.persist?.(row);
60
131
  }
61
132
 
62
133
  /**
@@ -74,7 +145,8 @@ export async function createApiKey(
74
145
  const now = options.now ?? (() => Date.now());
75
146
  const id = options.id ?? crypto.randomUUID();
76
147
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
77
- const hash = options.hash ?? (await hashApiKeySecret(secret));
148
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
149
+ const hash = options.hash ?? (await hashApiKeySecret(secret, pepper));
78
150
  const creatorScopes = [...options.creatorScopes];
79
151
 
80
152
  const row: ApiKeyRow = {
@@ -93,32 +165,77 @@ export async function createApiKey(
93
165
  revokedAt: null,
94
166
  };
95
167
  store.keys.set(id, row);
168
+ await persistRow(store, row);
96
169
  return { row, secret };
97
170
  }
98
171
 
172
+ /** Resolve a stored hostname to addresses (injected in tests). */
173
+ export type AllowlistLookup = (host: string) => Promise<readonly string[]>;
174
+
175
+ /** Options for {@link authenticateApiKey}. */
176
+ export interface AuthenticateApiKeyOptions {
177
+ readonly now?: () => number;
178
+ /** Client IP for allowlist enforcement. */
179
+ readonly ip?: string;
180
+ /** Hostname → A/AAAA (defaults to system DNS). */
181
+ readonly lookup?: AllowlistLookup;
182
+ }
183
+
99
184
  /**
100
- * Resolve a key by raw secret; enforces expiry and revocation.
185
+ * Resolve a key by raw secret; enforces expiry, revocation, and allowlist.
101
186
  *
102
187
  * @param store - Key store
103
188
  * @param secret - Raw secret
104
- * @param now - Clock
189
+ * @param nowOrOptions - Clock, or clock + client IP
105
190
  */
106
191
  export async function authenticateApiKey(
107
192
  store: ApiKeyStore,
108
193
  secret: string,
109
- now: () => number = () => Date.now(),
194
+ nowOrOptions: (() => number) | AuthenticateApiKeyOptions = () => Date.now(),
110
195
  ): Promise<ApiKeyRow | null> {
111
- const hash = await hashApiKeySecret(secret);
196
+ const options: AuthenticateApiKeyOptions =
197
+ typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
198
+ const now = options.now ?? (() => Date.now());
199
+ const pepper = store.pepper ?? DEFAULT_PEPPER;
200
+ const hash = await hashApiKeySecret(secret, pepper);
112
201
  for (const row of store.keys.values()) {
113
202
  if (row.hash !== hash) continue;
114
203
  if (row.revokedAt !== null) return null;
115
204
  if (row.expiresAt !== null && row.expiresAt <= now()) return null;
205
+ if (row.ipAllowlist.length > 0) {
206
+ const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
207
+ if (!allowed) return null;
208
+ }
209
+ if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
116
210
  row.lastUsedAt = now();
211
+ await persistRow(store, row);
117
212
  return row;
118
213
  }
119
214
  return null;
120
215
  }
121
216
 
217
+ /**
218
+ * List keys created by `creatorId` (includes revoked rows for audit).
219
+ *
220
+ * @param store - Key store
221
+ * @param creatorId - Issuer id
222
+ */
223
+ export function listApiKeys(store: ApiKeyStore, creatorId: string): ApiKeyRow[] {
224
+ return [...store.keys.values()]
225
+ .filter((row) => row.creatorId === creatorId)
226
+ .sort((a, b) => a.name.localeCompare(b.name));
227
+ }
228
+
229
+ /**
230
+ * Load one key by id.
231
+ *
232
+ * @param store - Key store
233
+ * @param id - Key id
234
+ */
235
+ export function getApiKey(store: ApiKeyStore, id: string): ApiKeyRow | undefined {
236
+ return store.keys.get(id);
237
+ }
238
+
122
239
  /**
123
240
  * Revoke an API key — irreversible. The row remains for audit / hygiene.
124
241
  *
@@ -134,6 +251,7 @@ export function revokeApiKey(
134
251
  const row = store.keys.get(id);
135
252
  if (!row || row.revokedAt !== null) return null;
136
253
  row.revokedAt = now();
254
+ void persistRow(store, row);
137
255
  return row;
138
256
  }
139
257
 
@@ -150,15 +268,156 @@ export async function rotateApiKey(
150
268
  options: {
151
269
  readonly secret?: string;
152
270
  readonly now?: () => number;
271
+ readonly pepper?: string;
153
272
  } = {},
154
273
  ): Promise<CreatedApiKey | null> {
155
274
  const row = store.keys.get(id);
156
275
  if (!row || row.revokedAt !== null) return null;
157
276
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
158
- row.hash = await hashApiKeySecret(secret);
277
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
278
+ row.hash = await hashApiKeySecret(secret, pepper);
279
+ await persistRow(store, row);
159
280
  return { row, secret };
160
281
  }
161
282
 
283
+ /**
284
+ * Update key metadata. Scope changes re-run {@link assertAttenuated}.
285
+ *
286
+ * @param store - Key store
287
+ * @param id - Key id
288
+ * @param options - Patch + attenuation ceiling
289
+ */
290
+ export function updateApiKey(
291
+ store: ApiKeyStore,
292
+ id: string,
293
+ options: UpdateApiKeyOptions,
294
+ ): ApiKeyRow | null {
295
+ const row = store.keys.get(id);
296
+ if (!row || row.revokedAt !== null) return null;
297
+ if (options.scopes !== undefined) {
298
+ assertAttenuated(options.ceiling, options.scopes, "api key");
299
+ row.scopes = [...options.scopes];
300
+ }
301
+ if (options.name !== undefined) row.name = options.name;
302
+ if (options.expiresAt !== undefined) row.expiresAt = options.expiresAt;
303
+ if (options.rateLimit !== undefined) row.rateLimit = options.rateLimit;
304
+ if (options.ipAllowlist !== undefined) row.ipAllowlist = [...options.ipAllowlist];
305
+ void persistRow(store, row);
306
+ return row;
307
+ }
308
+
309
+ /**
310
+ * Public projection of a key row (no hash / creatorScopes).
311
+ *
312
+ * @param row - Stored row
313
+ */
314
+ export function toApiKeyPublicRow(row: ApiKeyRow): ApiKeyPublicRow {
315
+ return {
316
+ id: row.id,
317
+ name: row.name,
318
+ plane: row.plane,
319
+ scopes: [...row.scopes],
320
+ createdAt: row.createdAt,
321
+ lastUsedAt: row.lastUsedAt,
322
+ expiresAt: row.expiresAt,
323
+ revokedAt: row.revokedAt,
324
+ rateLimit: row.rateLimit,
325
+ ipAllowlist: [...row.ipAllowlist],
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Whether the client IP matches an allowlist entry.
331
+ * Literals compare exactly. Hostnames resolve at verify time (fail closed).
332
+ *
333
+ * @param entries - IPs and/or hostnames
334
+ * @param ip - Client IP
335
+ * @param lookup - Hostname resolver
336
+ */
337
+ export async function allowlistAllowsIp(
338
+ entries: readonly string[],
339
+ ip: string | undefined,
340
+ lookup: AllowlistLookup = lookupAllowlistHost,
341
+ ): Promise<boolean> {
342
+ if (entries.length === 0) return true;
343
+ if (ip === undefined) return false;
344
+ const client = ip.trim().toLowerCase();
345
+ for (const raw of entries) {
346
+ const entry = normalizeAllowlistEntry(raw);
347
+ if (entry.length === 0) continue;
348
+ if (isIpLiteral(entry)) {
349
+ if (entry === client) return true;
350
+ continue;
351
+ }
352
+ try {
353
+ const addrs = await lookup(entry);
354
+ if (addrs.some((addr) => addr.trim().toLowerCase() === client)) return true;
355
+ } catch {
356
+ // This host fails closed; other entries may still match.
357
+ }
358
+ }
359
+ return false;
360
+ }
361
+
362
+ function normalizeAllowlistEntry(raw: string): string {
363
+ let entry = raw.trim().toLowerCase();
364
+ entry = entry.replace(/^https?:\/\//, "");
365
+ const slash = entry.indexOf("/");
366
+ if (slash >= 0) entry = entry.slice(0, slash);
367
+ if (entry.endsWith(".")) entry = entry.slice(0, -1);
368
+ return entry;
369
+ }
370
+
371
+ function isIpLiteral(entry: string): boolean {
372
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
373
+ return entry.split(".").every((octet) => Number(octet) <= 255);
374
+ }
375
+ return entry.includes(":") && /^[0-9a-f:.]+$/.test(entry);
376
+ }
377
+
378
+ async function lookupAllowlistHost(host: string): Promise<readonly string[]> {
379
+ const rows = await dnsLookup(host, { all: true, verbatim: true });
380
+ return rows.map((row) => row.address);
381
+ }
382
+
383
+ /**
384
+ * Client IP from X-Forwarded-For (right-most trusted hop) or X-Real-IP.
385
+ *
386
+ * @param request - Incoming request
387
+ * @param trustedProxyDepth - Hops from the right (default 1)
388
+ */
389
+ export function clientIpFromRequest(request: Request, trustedProxyDepth = 1): string | undefined {
390
+ const xff = request.headers.get("x-forwarded-for");
391
+ if (xff) {
392
+ const hops = xff
393
+ .split(",")
394
+ .map((h) => h.trim())
395
+ .filter((h) => h.length > 0);
396
+ const index = hops.length - trustedProxyDepth;
397
+ if (index >= 0) return hops[index];
398
+ }
399
+ return request.headers.get("x-real-ip")?.trim() || undefined;
400
+ }
401
+
402
+ function takeKeyRate(
403
+ store: ApiKeyStore,
404
+ keyId: string,
405
+ limit: { max: number; per: string },
406
+ nowMs: number,
407
+ ): boolean {
408
+ const windowMs = parseDurationMs(limit.per);
409
+ const hits = store.rateHits ?? (store.rateHits = new Map<string, number[]>());
410
+ const prev = hits.get(keyId) ?? [];
411
+ const kept = prev.filter((t) => nowMs - t < windowMs);
412
+ if (kept.length >= limit.max) {
413
+ hits.set(keyId, kept);
414
+ return false;
415
+ }
416
+ kept.push(nowMs);
417
+ hits.set(keyId, kept);
418
+ return true;
419
+ }
420
+
162
421
  function randomSecret(): string {
163
422
  const bytes = crypto.getRandomValues(new Uint8Array(24));
164
423
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -114,6 +114,62 @@ describe("auth plugin", () => {
114
114
  });
115
115
  });
116
116
 
117
+ describe("API key allowlist", () => {
118
+ test("hostname entry matches the resolved client IP", async () => {
119
+ const { authenticateApiKey } = await import("./api-keys.ts");
120
+ const store = createApiKeyStore();
121
+ const created = await createApiKey(store, {
122
+ plane: "user",
123
+ name: "ci",
124
+ scopes: ["member"],
125
+ creatorId: "u1",
126
+ creatorScopes: ["member"],
127
+ ipAllowlist: ["ci.example.com"],
128
+ });
129
+ const lookup = async (host: string) => (host === "ci.example.com" ? ["203.0.113.10"] : []);
130
+ expect(
131
+ await authenticateApiKey(store, created.secret, {
132
+ ip: "203.0.113.10",
133
+ lookup,
134
+ }),
135
+ ).not.toBeNull();
136
+ expect(
137
+ await authenticateApiKey(store, created.secret, {
138
+ ip: "198.51.100.7",
139
+ lookup,
140
+ }),
141
+ ).toBeNull();
142
+ });
143
+
144
+ test("hostname lookup failure is closed", async () => {
145
+ const { authenticateApiKey } = await import("./api-keys.ts");
146
+ const store = createApiKeyStore();
147
+ const created = await createApiKey(store, {
148
+ plane: "user",
149
+ name: "ci",
150
+ scopes: ["member"],
151
+ creatorId: "u1",
152
+ creatorScopes: ["member"],
153
+ ipAllowlist: ["ci.example.com", "203.0.113.10"],
154
+ });
155
+ const lookup = async () => {
156
+ throw new Error("nxdomain");
157
+ };
158
+ expect(
159
+ await authenticateApiKey(store, created.secret, {
160
+ ip: "203.0.113.10",
161
+ lookup,
162
+ }),
163
+ ).not.toBeNull();
164
+ expect(
165
+ await authenticateApiKey(store, created.secret, {
166
+ ip: "198.51.100.7",
167
+ lookup,
168
+ }),
169
+ ).toBeNull();
170
+ });
171
+ });
172
+
117
173
  describe("API key attenuation", () => {
118
174
  test("key cannot be created with a scope its creator lacks", async () => {
119
175
  const store = createApiKeyStore();
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { PasswordHashOptions } from "../runtime/types.ts";
8
8
  import type { SessionStore } from "./sessions.ts";
9
- import type { ApiKeyStore } from "./api-keys.ts";
9
+ import { createApiKeyStore, type ApiKeyStore } from "./api-keys.ts";
10
10
  import type { BreachCheckFn } from "./breach-check.ts";
11
11
  import type { PasswordPolicyOptions } from "./password-policy.ts";
12
12
  import {
@@ -111,8 +111,8 @@ export interface GateAuthOptions extends AuthSchemaOptions {
111
111
  */
112
112
  readonly sessions?: SessionStore;
113
113
  /**
114
- * In-memory API key store for Bearer key auth (tests / embedding).
115
- * Distinct from schema `apiKeys` table options.
114
+ * Shared API key store. Created automatically when omitted
115
+ * (`gate.auth.secret` is the HMAC pepper).
116
116
  */
117
117
  readonly apiKeyStore?: ApiKeyStore;
118
118
  /** Injectable clock. */
@@ -170,7 +170,7 @@ export interface ResolvedGateAuth {
170
170
  };
171
171
  readonly hooks: AuthDatabaseHooks | undefined;
172
172
  readonly sessions: SessionStore | undefined;
173
- readonly apiKeyStore: ApiKeyStore | undefined;
173
+ readonly apiKeyStore: ApiKeyStore;
174
174
  readonly now: (() => number) | undefined;
175
175
  }
176
176
 
@@ -278,7 +278,7 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
278
278
  },
279
279
  hooks: auth.hooks,
280
280
  sessions: auth.sessions,
281
- apiKeyStore: auth.apiKeyStore,
281
+ apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
282
282
  now: auth.now,
283
283
  };
284
284
  }
package/src/auth/index.ts CHANGED
@@ -161,17 +161,41 @@ export {
161
161
  } from "./attenuation.ts";
162
162
 
163
163
  export {
164
+ AUTH_API_KEYS_RESOURCE,
165
+ allowlistAllowsIp,
166
+ authenticateApiKey,
167
+ clientIpFromRequest,
164
168
  createApiKey,
165
169
  createApiKeyStore,
166
- authenticateApiKey,
170
+ getApiKey,
167
171
  hashApiKeySecret,
172
+ listApiKeys,
168
173
  revokeApiKey,
169
174
  rotateApiKey,
175
+ toApiKeyPublicRow,
176
+ updateApiKey,
177
+ type AllowlistLookup,
178
+ type ApiKeyPublicRow,
170
179
  type ApiKeyStore,
180
+ type AuthenticateApiKeyOptions,
171
181
  type CreateApiKeyOptions,
182
+ type CreateApiKeyStoreOptions,
172
183
  type CreatedApiKey,
184
+ type UpdateApiKeyOptions,
173
185
  } from "./api-keys.ts";
174
186
 
187
+ export {
188
+ apiKeySqlExec,
189
+ bindApiKeySqlPersist,
190
+ bindHostApiKeySql,
191
+ bindHostApiKeySqlFromStore,
192
+ ensureApiKeyTable,
193
+ hydrateApiKeyStore,
194
+ persistApiKeyRow,
195
+ type ApiKeySqlExec,
196
+ type HostApiKeySqlRuntime,
197
+ } from "./api-key-sql.ts";
198
+
175
199
  export {
176
200
  createOperatorInviteStore,
177
201
  createOperatorInvite,
@@ -105,6 +105,10 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
105
105
  name: "name",
106
106
  scopes: "scopes",
107
107
  expiresAt: "expires_at",
108
+ rateLimit: "rate_limit",
109
+ ipAllowlist: "ip_allowlist",
110
+ creatorId: "creator_id",
111
+ creatorScopes: "creator_scopes",
108
112
  createdAt: "created_at",
109
113
  lastUsedAt: "last_used_at",
110
114
  revokedAt: "revoked_at",
@@ -190,6 +194,10 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
190
194
  scopes: "TEXT",
191
195
  audience: "TEXT",
192
196
  lastUsedAt: "INTEGER",
197
+ rateLimit: "TEXT",
198
+ ipAllowlist: "TEXT",
199
+ creatorId: "TEXT",
200
+ creatorScopes: "TEXT",
193
201
  };
194
202
 
195
203
  /**
@@ -123,6 +123,43 @@ export const app = oke({
123
123
  await attached!.stop();
124
124
  });
125
125
 
126
+ test("Console Access uses the host apiKeyStore instance (not Console SQL)", async () => {
127
+ const dir = await mkdtemp(join(tmpdir(), "oke-attach-keys-"));
128
+ const entry = join(dir, "app.ts");
129
+ await writeFile(
130
+ entry,
131
+ `
132
+ import { oke, on, flow, http, gate, store } from ${JSON.stringify(OKE)};
133
+ const db = store.sql("db");
134
+ const member = gate.policy("member", ({ auth }) => !!auth.verified);
135
+ export const ping = on(
136
+ http.get("/ping").gate(member),
137
+ flow("main.ping", { do: () => ({ ok: true }) }),
138
+ );
139
+ export const app = oke({
140
+ name: "attach-keys",
141
+ stores: [db],
142
+ gate: {
143
+ auth: { secret: "attach-host-key-pepper", http: false },
144
+ policies: [member],
145
+ },
146
+ }).adopt({ ping });
147
+ `,
148
+ );
149
+
150
+ const consoleKeys = { keys: new Map(), persist: async () => {} };
151
+ const state = {
152
+ invokeUserFlow: null,
153
+ storeRuntime: null,
154
+ apiKeys: consoleKeys,
155
+ } as unknown as ConsoleState;
156
+ const attached = await attachHostToConsole({ entry, cwd: dir, state });
157
+ expect(attached).not.toBeNull();
158
+ expect(state.apiKeys).not.toBe(consoleKeys);
159
+ expect(state.apiKeys.persist).not.toBe(consoleKeys.persist);
160
+ await attached!.stop();
161
+ });
162
+
126
163
  test("returns null when the entry is not a bootable app", async () => {
127
164
  const dir = await mkdtemp(join(tmpdir(), "oke-attach-empty-"));
128
165
  const entry = join(dir, "app.ts");
@@ -69,6 +69,12 @@ export async function attachHostToConsole(
69
69
  if (vault) options.state.vaultRuntime = vault;
70
70
  const ai = host.elements?.ai ?? host.bootResult?.ai;
71
71
  if (ai) bindAiRuntime(options.state, ai);
72
+ const hostKeys = host.apiKeys;
73
+ if (hostKeys) {
74
+ // Host `gate.auth.apiKeyStore` is the durable store (`oke_api_keys` on
75
+ // `store.sql()`). Console displays that instance — never the reverse.
76
+ options.state.apiKeys = hostKeys;
77
+ }
72
78
 
73
79
  return {
74
80
  stop: async () => {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type {
10
+ AuthApiKeysResourceRef,
10
11
  Effects,
11
12
  PromptRef,
12
13
  ResourceRef,
@@ -145,8 +146,8 @@ const TABLE_ARG_METHODS = new Set([
145
146
  * @param options - Handler AST, bindings, and annotation flag
146
147
  */
147
148
  export function inferEffects(options: InferEffectsOptions): InferredEffects {
148
- const reads = new Set<ResourceRef | SignalResourceRef>();
149
- const writes = new Set<ResourceRef>();
149
+ const reads = new Set<ResourceRef | SignalResourceRef | AuthApiKeysResourceRef>();
150
+ const writes = new Set<ResourceRef | AuthApiKeysResourceRef>();
150
151
  const emits = new Set<SignalRef>();
151
152
  const sends = new Set<TemplateRef>();
152
153
  const asks = new Set<PromptRef>();
@@ -217,6 +218,20 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
217
218
  continue;
218
219
  }
219
220
 
221
+ if (chain.rootMethod === "auth" && call === chain.rootCall) {
222
+ const method = chain.methods[1];
223
+ if (method === "listApiKeys") reads.add("auth:api-keys");
224
+ if (
225
+ method === "createApiKey" ||
226
+ method === "revokeApiKey" ||
227
+ method === "rotateApiKey" ||
228
+ method === "updateApiKey"
229
+ ) {
230
+ writes.add("auth:api-keys");
231
+ }
232
+ continue;
233
+ }
234
+
220
235
  if (chain.rootMethod === "search" && call === chain.rootCall) {
221
236
  const ref = resolveNamed(call.arguments[0], options.bindings, "embed");
222
237
  if (ref) {