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
@@ -132,6 +132,10 @@ describe("projectAccessPanel", () => {
132
132
  expect(projection.userPlane.grantableScopes.every((s) => !s.startsWith("console:"))).toBe(true);
133
133
 
134
134
  expect(projection.hygiene.unusedKeys.some((k) => k.id === "key_stale")).toBe(true);
135
+ expect(projection.userPlane.keys.find((k) => k.id === "key_stale")?.creatorId).toBe("op1");
136
+ expect(projection.userPlane.keys.find((k) => k.id === "key_stale")?.creatorScopes).toEqual([
137
+ "member",
138
+ ]);
135
139
  expect(projection.hygiene.neverSignedInOperators.some((o) => o.id === "op_never")).toBe(true);
136
140
  expect(projection.hygiene.expiredInvitations.some((i) => i.id === "inv1")).toBe(true);
137
141
  });
@@ -154,6 +158,40 @@ describe("projectAccessPanel", () => {
154
158
  expect(projection.operatorPlane.grantableScopes).toContain("console:store.sql:read");
155
159
  expect(projection.operatorPlane.grantableScopes).not.toContain("console:store.sql:write");
156
160
  });
161
+
162
+ test("issuer scopes drop role grants that are not in the catalog", () => {
163
+ const auth = createDefaultGateAuthStores();
164
+ setRoleGrants(auth.roles, "role_member", ["member", "booking:create"]);
165
+ const projection = projectAccessPanel({
166
+ manifest: {
167
+ oke: "1.0",
168
+ app: "keel-like",
169
+ flows: {},
170
+ gates: {
171
+ member: { kind: "policy", scopes: ["member"] },
172
+ "task:write": { kind: "policy", scopes: ["task:write"] },
173
+ },
174
+ },
175
+ roles: auth.roles,
176
+ apiKeys: auth.apiKeys,
177
+ operators: createOperatorStore(),
178
+ invites: createOperatorInviteStore(),
179
+ identities: [
180
+ {
181
+ id: "user_demo",
182
+ email: "demo@example.com",
183
+ name: "Demo",
184
+ status: "active",
185
+ scopes: ["member", "task:write"],
186
+ },
187
+ ],
188
+ roleMembers: auth.roleMembers,
189
+ actorScopes: ["console:*"],
190
+ now: () => 0,
191
+ });
192
+ expect(projection.userPlane.users?.[0]?.scopes).toEqual(["member", "task:write"]);
193
+ expect(projection.userPlane.users?.[0]?.scopes).not.toContain("booking:create");
194
+ });
157
195
  });
158
196
 
159
197
  describe("effectivePermissions", () => {
@@ -213,7 +251,7 @@ describe("keyBlastRadius", () => {
213
251
  flow: "bookings.create",
214
252
  trigger: "http",
215
253
  plane: "user",
216
- principal: "key_demo",
254
+ principal: "u",
217
255
  gates: [],
218
256
  cache: "none",
219
257
  effects: [],
@@ -221,14 +259,14 @@ describe("keyBlastRadius", () => {
221
259
  durationMs: 10,
222
260
  startedAt: 100,
223
261
  endedAt: 110,
224
- dimensions: { ip: "203.0.113.10" },
262
+ dimensions: { api_key: "key_demo", ip: "203.0.113.10" },
225
263
  },
226
264
  {
227
265
  id: "r2",
228
266
  flow: "bookings.create",
229
267
  trigger: "http",
230
268
  plane: "user",
231
- principal: "key_demo",
269
+ principal: "u",
232
270
  gates: [],
233
271
  cache: "none",
234
272
  effects: [],
@@ -236,7 +274,7 @@ describe("keyBlastRadius", () => {
236
274
  durationMs: 10,
237
275
  startedAt: 200,
238
276
  endedAt: 210,
239
- dimensions: { source_ip: "198.51.100.7" },
277
+ dimensions: { api_key: "key_demo", source_ip: "198.51.100.7" },
240
278
  },
241
279
  ];
242
280
  const ttl = 7 * 60 * 1000;
@@ -262,14 +300,25 @@ describe("accessCreateKey / revoke", () => {
262
300
  plane: "user",
263
301
  name: "k",
264
302
  scopes: ["member"],
265
- creatorId: "op1",
266
- creatorScopes: ["console:*"],
303
+ creatorId: "user_demo",
304
+ creatorScopes: ["member"],
267
305
  catalog: ["member", "booking:create", "console:store.sql:read"],
268
306
  now: () => 1,
269
307
  });
270
308
  expect(created.secret.startsWith("oke_")).toBe(true);
271
309
  expect(created.row.scopes).toEqual(["member"]);
272
310
 
311
+ await expect(
312
+ accessCreateKey(store, {
313
+ plane: "user",
314
+ name: "wide",
315
+ scopes: ["booking:create"],
316
+ creatorId: "user_demo",
317
+ creatorScopes: ["member"],
318
+ catalog: ["member", "booking:create"],
319
+ }),
320
+ ).rejects.toThrow();
321
+
273
322
  await expect(
274
323
  accessCreateKey(store, {
275
324
  plane: "operator",
@@ -311,11 +360,70 @@ describe("accessCreateKey / revoke", () => {
311
360
  });
312
361
  });
313
362
 
363
+ describe("resolveAccessKeyIssuer", () => {
364
+ test("user-plane create without creatorUserId fails; scopes cannot exceed the user", async () => {
365
+ const { resolveAccessKeyIssuer } = await import("./flows.ts");
366
+ const state = {
367
+ identities: [
368
+ {
369
+ id: "user_u",
370
+ email: "u@example.com",
371
+ name: "U",
372
+ status: "active" as const,
373
+ scopes: ["member"],
374
+ },
375
+ ],
376
+ roleMembers: new Map<string, string[]>(),
377
+ roles: createRoleStore(),
378
+ operators: { roles: new Map() },
379
+ };
380
+ expect(resolveAccessKeyIssuer(state as never, "op1", { plane: "user" })).toEqual({
381
+ error: "creatorUserId is required for user-plane keys",
382
+ });
383
+ const issued = resolveAccessKeyIssuer(state as never, "op1", {
384
+ plane: "user",
385
+ creatorUserId: "user_u",
386
+ });
387
+ expect(issued).toEqual({ creatorId: "user_u", creatorScopes: ["member"] });
388
+ });
389
+
390
+ test("user-plane ceiling includes role grants, not only identity.scopes", async () => {
391
+ const { resolveAccessKeyIssuer } = await import("./flows.ts");
392
+ const roles = createRoleStore();
393
+ upsertRole(roles, {
394
+ id: "role_member",
395
+ name: "member",
396
+ plane: "user",
397
+ description: "",
398
+ });
399
+ setRoleGrants(roles, "role_member", ["member", "task:write"]);
400
+ const issued = resolveAccessKeyIssuer(
401
+ {
402
+ identities: [
403
+ {
404
+ id: "user_u",
405
+ email: "u@example.com",
406
+ name: "U",
407
+ status: "active" as const,
408
+ scopes: ["member"],
409
+ },
410
+ ],
411
+ roleMembers: new Map([["role_member", ["user_u"]]]),
412
+ roles,
413
+ operators: { roles: new Map() },
414
+ } as never,
415
+ "op1",
416
+ { plane: "user", creatorUserId: "user_u" },
417
+ );
418
+ expect(issued).toEqual({ creatorId: "user_u", creatorScopes: ["member", "task:write"] });
419
+ });
420
+ });
421
+
314
422
  describe("helpers", () => {
315
- test("expandAccessCeiling adds application scopes for console:*", () => {
423
+ test("expandAccessCeiling expands console:* to console catalog only", () => {
316
424
  const held = expandAccessCeiling(["console:*"], ["console:store.sql:read", "booking:create"]);
317
425
  expect(held.has("console:store.sql:read")).toBe(true);
318
- expect(held.has("booking:create")).toBe(true);
426
+ expect(held.has("booking:create")).toBe(false);
319
427
  });
320
428
 
321
429
  test("isKeyUnused90d", () => {
@@ -19,6 +19,7 @@ import {
19
19
  revokeApiKey,
20
20
  revokeFamily,
21
21
  rotateApiKey,
22
+ updateApiKey,
22
23
  setRoleGrants,
23
24
  scopesForRoles,
24
25
  type ApiKeyStore,
@@ -81,6 +82,10 @@ export interface AccessKeyRow {
81
82
  readonly id: string;
82
83
  readonly name: string;
83
84
  readonly plane: "user" | "operator";
85
+ /** Stored issuer — user id or operator id. Frozen after mint. */
86
+ readonly creatorId: string;
87
+ /** Issuer ceiling stored on the key — edit scopes stay at or under this. */
88
+ readonly creatorScopes: readonly string[];
84
89
  readonly scopes: readonly string[];
85
90
  readonly createdAt: number;
86
91
  readonly lastUsedAt: number | null;
@@ -205,7 +210,7 @@ export function projectAccessPanel(options: ProjectAccessOptions): AccessPanelPr
205
210
  });
206
211
 
207
212
  const operators = projectOperators(options);
208
- const users = projectUsers(options);
213
+ const users = projectUsers(options, catalog);
209
214
  const roles = projectRoles(options);
210
215
  const keys = projectKeys(options, now);
211
216
  const invites = projectInvites(options, now);
@@ -497,6 +502,36 @@ export async function accessRotateKey(
497
502
  };
498
503
  }
499
504
 
505
+ /**
506
+ * Update key metadata. Scope changes re-attenuate against stored creatorScopes.
507
+ *
508
+ * @param store - API key store
509
+ * @param input - Patch
510
+ */
511
+ export function accessUpdateKey(
512
+ store: ApiKeyStore,
513
+ input: {
514
+ readonly keyId: string;
515
+ readonly name?: string;
516
+ readonly scopes?: readonly string[];
517
+ readonly expiresAt?: number | null;
518
+ readonly rateLimit?: { max: number; per: string } | null;
519
+ readonly ipAllowlist?: readonly string[];
520
+ },
521
+ ): AccessKeyRow | null {
522
+ const existing = store.keys.get(input.keyId);
523
+ if (!existing) return null;
524
+ const row = updateApiKey(store, input.keyId, {
525
+ ceiling: existing.creatorScopes,
526
+ name: input.name,
527
+ scopes: input.scopes,
528
+ expiresAt: input.expiresAt,
529
+ rateLimit: input.rateLimit,
530
+ ipAllowlist: input.ipAllowlist,
531
+ });
532
+ return row ? toKeyRow(row, () => Date.now()) : null;
533
+ }
534
+
500
535
  /**
501
536
  * Create an operator invitation.
502
537
  *
@@ -528,10 +563,9 @@ export class AccessGrantError extends Error {
528
563
  /**
529
564
  * Expand held scopes for Access administration.
530
565
  *
531
- * `console:*` covers every `console:…` pair and, for the Access admin
532
- * surface, every application scope in the catalog so operators can manage
533
- * the user plane without holding application scopes on their own principal
534
- * (which Gates would flag as a plane violation).
566
+ * `console:*` covers every `console:…` pair in the catalog. Application
567
+ * scopes are never added user-plane keys are attenuated to the issuer's
568
+ * own grants (`scopesForRoles`), not the operator catalog.
535
569
  *
536
570
  * @param held - Actor scopes
537
571
  * @param catalog - Manifest Module:Action pairs
@@ -540,17 +574,7 @@ export function expandAccessCeiling(
540
574
  held: Iterable<string>,
541
575
  catalog: readonly string[],
542
576
  ): Set<string> {
543
- const out = expandHeldScopes(held, catalog);
544
- let star = false;
545
- for (const s of held) {
546
- if (s === "console:*") star = true;
547
- }
548
- if (star) {
549
- for (const scope of catalog) {
550
- if (isApplicationScope(scope)) out.add(scope);
551
- }
552
- }
553
- return out;
577
+ return expandHeldScopes(held, catalog);
554
578
  }
555
579
 
556
580
  function projectOperators(options: ProjectAccessOptions): AccessOperatorRow[] {
@@ -574,14 +598,35 @@ function projectOperators(options: ProjectAccessOptions): AccessOperatorRow[] {
574
598
  return rows.sort((a, b) => a.email.localeCompare(b.email));
575
599
  }
576
600
 
577
- function projectUsers(options: ProjectAccessOptions): AccessUserRow[] {
601
+ /**
602
+ * User-plane issuer ceiling — identity scopes plus role grants.
603
+ * Create must use this union; the list already did.
604
+ *
605
+ * @param options - User + roles
606
+ */
607
+ export function accessUserCeiling(options: {
608
+ readonly userId: string;
609
+ readonly identityScopes?: Iterable<string>;
610
+ readonly roles: RoleStore;
611
+ readonly roleMembers: ReadonlyMap<string, readonly string[]>;
612
+ }): string[] {
613
+ const roleIds = roleIdsForMember(options.roleMembers, options.userId);
614
+ const fromRoles = [...scopesForRoles(options.roles, roleIds, "user")];
615
+ const fromIdentity = [...(options.identityScopes ?? [])];
616
+ return [...new Set([...fromRoles, ...fromIdentity])].sort((a, b) => a.localeCompare(b));
617
+ }
618
+
619
+ function projectUsers(options: ProjectAccessOptions, catalog: readonly string[]): AccessUserRow[] {
620
+ const allowed = new Set(catalog);
578
621
  return options.identities
579
622
  .map((identity) => {
580
623
  const roleIds = roleIdsForMember(options.roleMembers, identity.id);
581
- const fromRoles = [...scopesForRoles(options.roles, roleIds, "user")];
582
- const scopes = [...new Set([...fromRoles, ...identity.scopes])].sort((a, b) =>
583
- a.localeCompare(b),
584
- );
624
+ const scopes = accessUserCeiling({
625
+ userId: identity.id,
626
+ identityScopes: identity.scopes,
627
+ roles: options.roles,
628
+ roleMembers: options.roleMembers,
629
+ }).filter((scope) => allowed.has(scope));
585
630
  return {
586
631
  id: identity.id,
587
632
  email: identity.email,
@@ -637,6 +682,8 @@ function toKeyRow(
637
682
  readonly id: string;
638
683
  readonly name: string;
639
684
  readonly plane: "user" | "operator";
685
+ readonly creatorId: string;
686
+ readonly creatorScopes: readonly string[];
640
687
  readonly scopes: readonly string[];
641
688
  readonly createdAt: number;
642
689
  readonly lastUsedAt: number | null;
@@ -652,6 +699,8 @@ function toKeyRow(
652
699
  id: key.id,
653
700
  name: key.name,
654
701
  plane: key.plane,
702
+ creatorId: key.creatorId,
703
+ creatorScopes: [...key.creatorScopes].sort((a, b) => a.localeCompare(b)),
655
704
  scopes: [...key.scopes].sort((a, b) => a.localeCompare(b)),
656
705
  createdAt: key.createdAt,
657
706
  lastUsedAt: key.lastUsedAt,
@@ -717,9 +766,7 @@ function roleIdsForMember(
717
766
  }
718
767
 
719
768
  function runTouchesKey(run: WideEvent, keyId: string): boolean {
720
- if (run.principal === keyId) return true;
721
769
  const dims = run.dimensions;
722
- if (dims.principal === keyId) return true;
723
770
  if (dims.api_key === keyId || dims.apiKey === keyId || dims.key === keyId) {
724
771
  return true;
725
772
  }
@@ -137,6 +137,7 @@ export function createConsoleApp(options: CreateConsoleAppOptions = {}): Console
137
137
  accessCreateKey: routes.access.createKey,
138
138
  accessRevokeKey: routes.access.revokeKey,
139
139
  accessRotateKey: routes.access.rotateKey,
140
+ accessUpdateKey: routes.access.updateKey,
140
141
  accessSetRoleGrants: routes.access.setRoleGrants,
141
142
  diffList: routes.diff.list,
142
143
  clockList: routes.clock.list,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Default invoke-as ladder — ten rungs, historic demo + member ids.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { Manifest } from "../../manifest/types.ts";
7
+ import { createRoleStore } from "../../auth/index.ts";
8
+ import {
9
+ DEV_IDENTITY_LADDER,
10
+ KEEL_SCOPES,
11
+ defaultDevIdentities,
12
+ defaultMemberIdentityIds,
13
+ demoScopesFromManifest,
14
+ isDefaultIdentitySeed,
15
+ refreshSeededIdentities,
16
+ scopesForDevIdentityRung,
17
+ seedKeelAccessRoles,
18
+ } from "./dev-identities.ts";
19
+
20
+ const KEEL_LIKE: Manifest = {
21
+ oke: "1.0",
22
+ app: "keel-like",
23
+ gates: {
24
+ member: { kind: "policy", scopes: ["member"] },
25
+ "task:write": { kind: "policy", scopes: ["task:write"] },
26
+ "comment:write": { kind: "policy", scopes: ["comment:write"] },
27
+ "files:write": { kind: "policy", scopes: ["files:write"] },
28
+ "project:admin": { kind: "policy", scopes: ["project:admin"] },
29
+ "member:admin": { kind: "policy", scopes: ["member:admin"] },
30
+ "webhook:admin": { kind: "policy", scopes: ["webhook:admin"] },
31
+ },
32
+ };
33
+
34
+ describe("DEV_IDENTITY_LADDER", () => {
35
+ test("is ten people from owner to guest and keeps demo + member", () => {
36
+ expect(DEV_IDENTITY_LADDER).toHaveLength(10);
37
+ expect(DEV_IDENTITY_LADDER[0]).toMatchObject({
38
+ id: "user_demo",
39
+ email: "demo@example.com",
40
+ role: "owner",
41
+ });
42
+ expect(DEV_IDENTITY_LADDER[7]).toMatchObject({
43
+ id: "user_member",
44
+ email: "member@example.com",
45
+ role: "member",
46
+ });
47
+ expect(DEV_IDENTITY_LADDER[9]?.role).toBe("guest");
48
+ expect(new Set(DEV_IDENTITY_LADDER.map((p) => p.role)).size).toBe(10);
49
+ });
50
+ });
51
+
52
+ describe("defaultDevIdentities", () => {
53
+ test("demo follows the Manifest; member stays member-only", () => {
54
+ const rows = defaultDevIdentities({
55
+ oke: "1.0",
56
+ app: "keel-like",
57
+ gates: {
58
+ member: { kind: "policy", scopes: ["member"] },
59
+ "task:write": { kind: "policy", scopes: ["task:write"] },
60
+ "booking:create": { kind: "policy", scopes: ["booking:create"] },
61
+ },
62
+ });
63
+ const demo = rows.find((row) => row.id === "user_demo");
64
+ const member = rows.find((row) => row.id === "user_member");
65
+ const guest = rows.find((row) => row.id === "user_guest");
66
+ expect(demo?.scopes).toEqual(["booking:create", "member", "task:write"]);
67
+ expect(member?.scopes).toEqual(["member"]);
68
+ expect(guest?.scopes).toEqual([]);
69
+ expect(demo?.scopes).not.toEqual(member?.scopes);
70
+ });
71
+
72
+ test("keel profiles step down from owner to guest", () => {
73
+ const catalog = demoScopesFromManifest(KEEL_LIKE);
74
+ const owner = scopesForDevIdentityRung(0, catalog);
75
+ const admin = scopesForDevIdentityRung(1, catalog);
76
+ const member = scopesForDevIdentityRung(7, catalog);
77
+ const guest = scopesForDevIdentityRung(9, catalog);
78
+ expect(owner).toEqual(catalog);
79
+ expect(admin).toEqual(catalog);
80
+ expect(scopesForDevIdentityRung(2, catalog).length).toBeLessThan(admin.length);
81
+ expect(member).toEqual(["member"]);
82
+ expect(guest).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("refreshSeededIdentities", () => {
87
+ test("replaces the default ladder and ignores a custom list", () => {
88
+ const seeded = defaultDevIdentities();
89
+ refreshSeededIdentities(seeded, KEEL_LIKE);
90
+ expect(seeded.find((row) => row.id === "user_demo")?.scopes).toContain("task:write");
91
+ expect(isDefaultIdentitySeed(seeded)).toBe(true);
92
+
93
+ const custom = [{ ...seeded[0]!, id: "user_custom" }];
94
+ refreshSeededIdentities(custom, KEEL_LIKE);
95
+ expect(custom).toHaveLength(1);
96
+ });
97
+ });
98
+
99
+ describe("keel scope seed", () => {
100
+ test("keel Manifest unions every declared Gate scope onto owner", () => {
101
+ const rows = defaultDevIdentities({ oke: "1.0", app: "keel" });
102
+ const demo = rows.find((row) => row.id === "user_demo");
103
+ expect(demo?.scopes).toEqual([...KEEL_SCOPES].sort((a, b) => a.localeCompare(b)));
104
+ expect(rows.find((row) => row.id === "user_admin")?.scopes).toEqual([...KEEL_SCOPES].sort());
105
+ expect(rows.find((row) => row.id === "user_member")?.scopes).toEqual(["member"]);
106
+ expect(rows.find((row) => row.id === "user_guest")?.scopes).toEqual([]);
107
+ });
108
+
109
+ test("seedKeelAccessRoles writes every Keel scope onto owner", () => {
110
+ const roles = createRoleStore();
111
+ const roleMembers = new Map<string, string[]>();
112
+ seedKeelAccessRoles(roles, roleMembers);
113
+ expect([...(roles.grants.get("role_owner") ?? [])].sort()).toEqual(
114
+ [...KEEL_SCOPES].sort((a, b) => a.localeCompare(b)),
115
+ );
116
+ expect(KEEL_SCOPES.every((scope) => roles.grants.get("role_owner")?.has(scope))).toBe(true);
117
+ expect(roleMembers.get("role_owner")).toEqual(["user_demo"]);
118
+ });
119
+ });
120
+
121
+ describe("defaultMemberIdentityIds", () => {
122
+ test("omits guest", () => {
123
+ expect(defaultMemberIdentityIds()).not.toContain("user_guest");
124
+ expect(defaultMemberIdentityIds()).toContain("user_demo");
125
+ expect(defaultMemberIdentityIds()).toContain("user_member");
126
+ });
127
+ });