okengine 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/flow.mdx +6 -0
  3. package/site/content/docs/elements/gate.mdx +55 -0
  4. package/site/content/docs/elements/store.mdx +13 -15
  5. package/site/content/docs/elements/vault.mdx +3 -0
  6. package/site/content/docs/reference/client.mdx +3 -0
  7. package/site/content/docs/reference/fx.mdx +29 -18
  8. package/site/content/docs/reference/security.md +1 -1
  9. package/src/auth/api-key-sql.test.ts +47 -0
  10. package/src/auth/api-key-sql.ts +254 -0
  11. package/src/auth/api-keys.ts +273 -14
  12. package/src/auth/auth.test.ts +56 -0
  13. package/src/auth/config.ts +5 -5
  14. package/src/auth/index.ts +25 -1
  15. package/src/auth/schema.ts +8 -0
  16. package/src/cli/attach-host-console.test.ts +37 -0
  17. package/src/cli/attach-host-console.ts +6 -0
  18. package/src/compiler/effects-infer.ts +17 -2
  19. package/src/console/server/access.test.ts +116 -8
  20. package/src/console/server/access.ts +70 -23
  21. package/src/console/server/app.ts +1 -0
  22. package/src/console/server/dev-identities.test.ts +127 -0
  23. package/src/console/server/dev-identities.ts +229 -0
  24. package/src/console/server/flows-invoke.test.ts +21 -4
  25. package/src/console/server/flows.ts +113 -58
  26. package/src/console/server/gates.test.ts +24 -0
  27. package/src/console/server/gates.ts +8 -21
  28. package/src/console/server/index.ts +1 -0
  29. package/src/console/server/live.test.ts +26 -1
  30. package/src/console/server/live.ts +4 -2
  31. package/src/console/server/operator-db.ts +2 -0
  32. package/src/console/server/state.ts +29 -55
  33. package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
  34. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  35. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  36. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  37. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  38. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  39. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  40. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  41. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  42. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  43. package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
  44. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  45. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  46. package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
  47. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  48. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  49. package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
  50. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  51. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  52. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  53. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  54. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  55. package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
  56. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  57. package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
  58. package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
  59. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  60. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  61. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  62. package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
  63. package/src/console/ui-next/dist/index.html +11 -9
  64. package/src/console/ui-next/index.html +3 -3
  65. package/src/console/ui-next/src/client.ts +167 -0
  66. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  67. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  68. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  69. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  70. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  71. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  72. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  73. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  74. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  75. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  76. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  77. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  78. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  79. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  80. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  81. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  82. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  83. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  84. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  85. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  86. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  87. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  88. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  89. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  90. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  91. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  92. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  93. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  94. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  95. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  96. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  97. package/src/console/ui-next/src/router.tsx +10 -1
  98. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  99. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  100. package/src/console/xss.gate.test.ts +1 -0
  101. package/src/elements/gate/declare.ts +2 -0
  102. package/src/elements/store/cache.ts +1 -1
  103. package/src/elements/store/runtime.ts +14 -0
  104. package/src/elements/store/schema-decl.ts +99 -41
  105. package/src/elements/store/sql-select.types.test.ts +71 -0
  106. package/src/elements/store/sql-session.ts +54 -32
  107. package/src/elements/store.ts +3 -0
  108. package/src/index.ts +1 -0
  109. package/src/kernel/api-key-host-persist.test.ts +143 -0
  110. package/src/kernel/app.ts +14 -2
  111. package/src/kernel/auth-resolve.ts +10 -5
  112. package/src/kernel/boot.test.ts +4 -4
  113. package/src/kernel/fx-auth-keys.test.ts +88 -0
  114. package/src/kernel/fx-auth-keys.ts +198 -0
  115. package/src/kernel/fx.test.ts +17 -1
  116. package/src/kernel/fx.ts +36 -18
  117. package/src/kernel/pipeline-api-key.test.ts +192 -7
  118. package/src/kernel/pipeline.ts +4 -2
  119. package/src/manifest/types.ts +9 -3
  120. package/src/release/build-lib.ts +1 -0
  121. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  122. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  123. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  124. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  125. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  126. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  127. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  128. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  129. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  130. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  131. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  132. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  133. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  134. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  135. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  136. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  137. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  138. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  139. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  140. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  141. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Default invoke-as identities — ten rungs from owner to guest.
3
+ *
4
+ * `user_demo` (owner) and `user_member` keep their historic ids so Call API
5
+ * tests and Access fixtures stay stable. Owner scopes follow the Manifest;
6
+ * lower rungs intersect a named profile with that catalog. Keel (`app ===
7
+ * "keel"`) always unions {@link KEEL_SCOPES} so seeding is not missing a
8
+ * declared Gate. No leftover app catalog is invented for other apps.
9
+ */
10
+
11
+ import { setRoleGrants, upsertRole, type RoleStore } from "../../auth/index.ts";
12
+ import type { Manifest } from "../../manifest/types.ts";
13
+
14
+ /**
15
+ * Every Keel user-plane scope — `gate.policy("member")` plus `gate.scope`.
16
+ * Seeded onto the owner → guest ladder when the Manifest app is `keel`.
17
+ */
18
+ export const KEEL_SCOPES = [
19
+ "member",
20
+ "task:write",
21
+ "comment:write",
22
+ "files:write",
23
+ "project:admin",
24
+ "member:admin",
25
+ "webhook:admin",
26
+ ] as const;
27
+
28
+ /** One seeded development identity. */
29
+ export interface SeededDevIdentity {
30
+ readonly id: string;
31
+ readonly email: string;
32
+ readonly name: string;
33
+ readonly status: "active";
34
+ readonly scopes: readonly string[];
35
+ }
36
+
37
+ /** Stable person for one authorization rung. */
38
+ export interface DevIdentityPerson {
39
+ readonly id: string;
40
+ readonly email: string;
41
+ readonly name: string;
42
+ /** Workspace / Access role label, owner → guest. */
43
+ readonly role: string;
44
+ }
45
+
46
+ /**
47
+ * Ten people, owner → guest. Demo and Member keep historic ids.
48
+ */
49
+ export const DEV_IDENTITY_LADDER = [
50
+ { id: "user_demo", email: "demo@example.com", name: "Demo User", role: "owner" },
51
+ { id: "user_admin", email: "admin@example.com", name: "Admin", role: "admin" },
52
+ {
53
+ id: "user_workspace",
54
+ email: "workspace@example.com",
55
+ name: "Workspace Admin",
56
+ role: "workspace_admin",
57
+ },
58
+ { id: "user_pm", email: "pm@example.com", name: "Project Manager", role: "project_manager" },
59
+ { id: "user_lead", email: "lead@example.com", name: "Lead", role: "lead" },
60
+ { id: "user_dev", email: "developer@example.com", name: "Developer", role: "developer" },
61
+ { id: "user_writer", email: "writer@example.com", name: "Contributor", role: "contributor" },
62
+ { id: "user_member", email: "member@example.com", name: "Member", role: "member" },
63
+ { id: "user_commenter", email: "commenter@example.com", name: "Commenter", role: "commenter" },
64
+ { id: "user_guest", email: "guest@example.com", name: "Guest", role: "guest" },
65
+ ] as const satisfies readonly DevIdentityPerson[];
66
+
67
+ /** One Keel ladder role and the scopes it holds. */
68
+ export type KeelLadderRole = (typeof DEV_IDENTITY_LADDER)[number]["role"];
69
+
70
+ /**
71
+ * Owner → guest grants. Owner and admin hold {@link KEEL_SCOPES}.
72
+ */
73
+ export const KEEL_LADDER_GRANTS: Readonly<Record<KeelLadderRole, readonly string[]>> = {
74
+ owner: KEEL_SCOPES,
75
+ admin: KEEL_SCOPES,
76
+ workspace_admin: [
77
+ "member",
78
+ "task:write",
79
+ "comment:write",
80
+ "files:write",
81
+ "project:admin",
82
+ "member:admin",
83
+ ],
84
+ project_manager: ["member", "task:write", "comment:write", "files:write", "project:admin"],
85
+ lead: ["member", "task:write", "comment:write", "project:admin"],
86
+ developer: ["member", "task:write", "comment:write", "files:write"],
87
+ contributor: ["member", "task:write", "comment:write"],
88
+ member: ["member"],
89
+ commenter: ["member", "comment:write"],
90
+ guest: [],
91
+ };
92
+
93
+ /** `*` = every catalog scope (owner). Other rungs follow {@link KEEL_LADDER_GRANTS}. */
94
+ const LADDER_SCOPE_PROFILES: readonly (readonly string[] | "*")[] = [
95
+ "*",
96
+ ...DEV_IDENTITY_LADDER.slice(1).map((person) => KEEL_LADDER_GRANTS[person.role]),
97
+ ];
98
+
99
+ /**
100
+ * Whether this Manifest is the Keel example (full scope seed applies).
101
+ *
102
+ * @param manifest - Optional Manifest
103
+ */
104
+ export function isKeelManifest(manifest?: Manifest | null): boolean {
105
+ return manifest?.app === "keel";
106
+ }
107
+
108
+ /**
109
+ * Application scopes implied by a Manifest — gate `scopes`, scoped gate
110
+ * ids, and flow gate refs. Always includes `member`.
111
+ *
112
+ * @param manifest - Optional Manifest
113
+ */
114
+ export function demoScopesFromManifest(manifest?: Manifest | null): string[] {
115
+ const scopes = new Set<string>(["member"]);
116
+ if (isKeelManifest(manifest)) {
117
+ for (const scope of KEEL_SCOPES) scopes.add(scope);
118
+ }
119
+ if (!manifest) return [...scopes].sort();
120
+ for (const [gateId, gate] of Object.entries(manifest.gates ?? {})) {
121
+ for (const scope of gate.scopes ?? []) scopes.add(scope);
122
+ if (gateId.includes(":") && !gateId.startsWith("rate:")) scopes.add(gateId);
123
+ }
124
+ for (const flow of Object.values(manifest.flows ?? {})) {
125
+ for (const gate of flow.gates ?? []) {
126
+ if (gate.includes(":") && !gate.startsWith("rate:")) scopes.add(gate);
127
+ }
128
+ }
129
+ return [...scopes].sort();
130
+ }
131
+
132
+ /**
133
+ * Scopes for one ladder rung — owner is the full catalog; others intersect
134
+ * their profile. `member` stays on every signed-in rung.
135
+ *
136
+ * @param rung - 0 (owner) … 9 (guest)
137
+ * @param catalog - Manifest-derived scopes
138
+ */
139
+ export function scopesForDevIdentityRung(rung: number, catalog: readonly string[]): string[] {
140
+ const profile = LADDER_SCOPE_PROFILES[rung];
141
+ if (profile === undefined) return [];
142
+ if (profile === "*") return [...catalog].sort((a, b) => a.localeCompare(b));
143
+ const allowed = new Set(catalog);
144
+ return [...new Set(profile.filter((scope) => scope === "member" || allowed.has(scope)))].sort(
145
+ (a, b) => a.localeCompare(b),
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Default development identities for the invoke-as picker.
151
+ *
152
+ * @param manifest - Optional Manifest used to derive application scopes
153
+ */
154
+ export function defaultDevIdentities(manifest?: Manifest | null): SeededDevIdentity[] {
155
+ const catalog = demoScopesFromManifest(manifest);
156
+ return DEV_IDENTITY_LADDER.map((person, rung) => ({
157
+ id: person.id,
158
+ email: person.email,
159
+ name: person.name,
160
+ status: "active",
161
+ scopes: scopesForDevIdentityRung(rung, catalog),
162
+ }));
163
+ }
164
+
165
+ /**
166
+ * Whether the live list is still the built-in ladder (safe to refresh).
167
+ *
168
+ * @param identities - Live identity list
169
+ */
170
+ export function isDefaultIdentitySeed(identities: readonly { readonly id: string }[]): boolean {
171
+ if (identities.length !== DEV_IDENTITY_LADDER.length) return false;
172
+ const ids = new Set(identities.map((row) => row.id));
173
+ return DEV_IDENTITY_LADDER.every((person) => ids.has(person.id));
174
+ }
175
+
176
+ /**
177
+ * Signed-in ladder ids (guest excluded) — default `role_member` holders.
178
+ */
179
+ export function defaultMemberIdentityIds(): string[] {
180
+ return DEV_IDENTITY_LADDER.filter((person) => person.role !== "guest").map((person) => person.id);
181
+ }
182
+
183
+ /**
184
+ * Replace the default ladder when a Manifest arrives.
185
+ *
186
+ * @param identities - Live identity list
187
+ * @param manifest - New Manifest
188
+ */
189
+ export function refreshSeededIdentities(
190
+ identities: Array<{
191
+ id: string;
192
+ email: string;
193
+ name: string;
194
+ status: "active" | "disabled";
195
+ scopes: readonly string[];
196
+ }>,
197
+ manifest: Manifest | null,
198
+ ): void {
199
+ if (!isDefaultIdentitySeed(identities)) return;
200
+ const next = defaultDevIdentities(manifest);
201
+ identities.splice(0, identities.length, ...next);
202
+ }
203
+
204
+ /**
205
+ * Seed Access roles for Keel — every {@link KEEL_SCOPES} pair lands on the
206
+ * owner / admin rung; lower rungs use {@link KEEL_LADDER_GRANTS}.
207
+ *
208
+ * @param roles - Role store
209
+ * @param roleMembers - roleId → principal ids
210
+ */
211
+ export function seedKeelAccessRoles(roles: RoleStore, roleMembers: Map<string, string[]>): void {
212
+ for (const person of DEV_IDENTITY_LADDER) {
213
+ const roleId = `role_${person.role}`;
214
+ upsertRole(roles, {
215
+ id: roleId,
216
+ name: person.role,
217
+ plane: "user",
218
+ description:
219
+ person.role === "owner"
220
+ ? "Full workspace authorization"
221
+ : person.role === "guest"
222
+ ? "Least authorization — no write scopes"
223
+ : `Keel ${person.role.replaceAll("_", " ")}`,
224
+ });
225
+ setRoleGrants(roles, roleId, KEEL_LADDER_GRANTS[person.role]);
226
+ roleMembers.set(roleId, person.role === "guest" ? [] : [person.id]);
227
+ }
228
+ roleMembers.set("role_member", defaultMemberIdentityIds());
229
+ }
@@ -15,12 +15,29 @@ import { http } from "../../kernel/triggers.ts";
15
15
  import { field, store } from "../../elements/store.ts";
16
16
  import { PII_MASK } from "../../elements/store/classify.ts";
17
17
  import { createTestApp } from "../../test/create-test-app.ts";
18
+ import type { Manifest } from "../../manifest/types.ts";
18
19
  import { FLOWS_TEST_MANIFEST } from "../ui-next/src/features/flows/fixture.ts";
19
20
  import { bindHostInvokeUserFlow } from "./invoke-user-flow.ts";
20
21
  import { startConsoleApp } from "./serve.ts";
21
22
  import { setManifest } from "./state.ts";
22
23
  import type { InvokeUserFlowInput } from "./invoke-user-flow.ts";
23
24
 
25
+ /** Fixture plus the booking gate the host actually enforces. */
26
+ const BOOKING_INVOKE_MANIFEST: Manifest = {
27
+ ...FLOWS_TEST_MANIFEST,
28
+ gates: {
29
+ member: { kind: "policy", scopes: ["member"] },
30
+ "booking:create": { kind: "policy", scopes: ["booking:create"] },
31
+ },
32
+ flows: {
33
+ ...FLOWS_TEST_MANIFEST.flows,
34
+ "bookings.create": {
35
+ ...FLOWS_TEST_MANIFEST.flows!["bookings.create"]!,
36
+ gates: ["member", "booking:create"],
37
+ },
38
+ },
39
+ };
40
+
24
41
  const memoryDrivers = {
25
42
  store: { kv: { dev: "memory", test: "memory", prod: "memory" } },
26
43
  signal: { dev: "memory", test: "memory", prod: "memory" },
@@ -147,11 +164,11 @@ describe("console flows invoke", () => {
147
164
  secret: "test-secret-flows",
148
165
  silentClaim: true,
149
166
  production: false,
150
- manifest: FLOWS_TEST_MANIFEST,
167
+ manifest: BOOKING_INVOKE_MANIFEST,
151
168
  });
152
169
  const host = await bootInvokeHost();
153
170
  try {
154
- setManifest(handle.state, FLOWS_TEST_MANIFEST);
171
+ setManifest(handle.state, BOOKING_INVOKE_MANIFEST);
155
172
  handle.state.invokeUserFlow = bindHostInvokeUserFlow(host);
156
173
  const auth = await claimOperator(handle);
157
174
 
@@ -214,12 +231,12 @@ describe("console flows invoke", () => {
214
231
  secret: "test-secret-flows-adv",
215
232
  silentClaim: true,
216
233
  production: false,
217
- manifest: FLOWS_TEST_MANIFEST,
234
+ manifest: BOOKING_INVOKE_MANIFEST,
218
235
  });
219
236
  const host = await bootInvokeHost();
220
237
  const seen: InvokeUserFlowInput[] = [];
221
238
  try {
222
- setManifest(handle.state, FLOWS_TEST_MANIFEST);
239
+ setManifest(handle.state, BOOKING_INVOKE_MANIFEST);
223
240
  const bound = bindHostInvokeUserFlow(host);
224
241
  handle.state.invokeUserFlow = async (input) => {
225
242
  seen.push(input);
@@ -36,6 +36,7 @@ import { PUBLIC_CONSOLE_FLOWS } from "./public-flows.ts";
36
36
  import { resolveInvokeAs } from "./invoke-user-flow.ts";
37
37
  import { resolveRlsIdentity } from "../../elements/store.ts";
38
38
  import type { RlsIdentity } from "../../drivers/pg-rls.ts";
39
+ import { accessUserCeiling } from "./access.ts";
39
40
  import { isKnownConsoleSqlGate, StoreFileNotFoundError, tenancyDeclared } from "./store.ts";
40
41
  import {
41
42
  PG_STAT_STATEMENTS_NOT_CREATED,
@@ -1164,6 +1165,22 @@ const GatesPowersOut = z.object({
1164
1165
  deniedFlowIds: z.array(z.string()),
1165
1166
  });
1166
1167
 
1168
+ const AccessKeyRowOut = z.object({
1169
+ id: z.string(),
1170
+ name: z.string(),
1171
+ plane: z.enum(["user", "operator"]),
1172
+ creatorId: z.string(),
1173
+ creatorScopes: z.array(z.string()),
1174
+ scopes: z.array(z.string()),
1175
+ createdAt: z.number(),
1176
+ lastUsedAt: z.number().nullable(),
1177
+ expiresAt: z.number().nullable(),
1178
+ revokedAt: z.number().nullable(),
1179
+ rateLimit: z.object({ max: z.number(), per: z.string() }).nullable(),
1180
+ ipAllowlist: z.array(z.string()),
1181
+ unused90d: z.boolean(),
1182
+ });
1183
+
1167
1184
  const AccessPlaneSection = z.object({
1168
1185
  plane: z.enum(["user", "operator"]),
1169
1186
  operators: z
@@ -1202,21 +1219,7 @@ const AccessPlaneSection = z.object({
1202
1219
  memberCount: z.number(),
1203
1220
  }),
1204
1221
  ),
1205
- keys: z.array(
1206
- z.object({
1207
- id: z.string(),
1208
- name: z.string(),
1209
- plane: z.enum(["user", "operator"]),
1210
- scopes: z.array(z.string()),
1211
- createdAt: z.number(),
1212
- lastUsedAt: z.number().nullable(),
1213
- expiresAt: z.number().nullable(),
1214
- revokedAt: z.number().nullable(),
1215
- rateLimit: z.object({ max: z.number(), per: z.string() }).nullable(),
1216
- ipAllowlist: z.array(z.string()),
1217
- unused90d: z.boolean(),
1218
- }),
1219
- ),
1222
+ keys: z.array(AccessKeyRowOut),
1220
1223
  invites: z
1221
1224
  .array(
1222
1225
  z.object({
@@ -1236,21 +1239,7 @@ const AccessListOut = z.object({
1236
1239
  operatorPlane: AccessPlaneSection,
1237
1240
  userPlane: AccessPlaneSection,
1238
1241
  hygiene: z.object({
1239
- unusedKeys: z.array(
1240
- z.object({
1241
- id: z.string(),
1242
- name: z.string(),
1243
- plane: z.enum(["user", "operator"]),
1244
- scopes: z.array(z.string()),
1245
- createdAt: z.number(),
1246
- lastUsedAt: z.number().nullable(),
1247
- expiresAt: z.number().nullable(),
1248
- revokedAt: z.number().nullable(),
1249
- rateLimit: z.object({ max: z.number(), per: z.string() }).nullable(),
1250
- ipAllowlist: z.array(z.string()),
1251
- unused90d: z.boolean(),
1252
- }),
1253
- ),
1242
+ unusedKeys: z.array(AccessKeyRowOut),
1254
1243
  neverSignedInOperators: z.array(
1255
1244
  z.object({
1256
1245
  id: z.string(),
@@ -1317,25 +1306,28 @@ const AccessCreateKeyIn = z.object({
1317
1306
  plane: z.enum(["user", "operator"]),
1318
1307
  name: z.string().min(1),
1319
1308
  scopes: z.array(z.string()),
1309
+ /** Required for user-plane keys — issuer whose grants are the ceiling. */
1310
+ creatorUserId: z.string().min(1).optional(),
1311
+ expiresAt: z.number().nullable().optional(),
1312
+ rateLimit: z.object({ max: z.number(), per: z.string() }).nullable().optional(),
1313
+ ipAllowlist: z.array(z.string()).optional(),
1314
+ });
1315
+
1316
+ const AccessUpdateKeyIn = z.object({
1317
+ keyId: z.string().min(1),
1318
+ name: z.string().min(1).optional(),
1319
+ scopes: z.array(z.string()).optional(),
1320
1320
  expiresAt: z.number().nullable().optional(),
1321
1321
  rateLimit: z.object({ max: z.number(), per: z.string() }).nullable().optional(),
1322
1322
  ipAllowlist: z.array(z.string()).optional(),
1323
1323
  });
1324
1324
 
1325
+ const AccessUpdateKeyOut = z.object({
1326
+ key: AccessKeyRowOut,
1327
+ });
1328
+
1325
1329
  const AccessCreateKeyOut = z.object({
1326
- key: z.object({
1327
- id: z.string(),
1328
- name: z.string(),
1329
- plane: z.enum(["user", "operator"]),
1330
- scopes: z.array(z.string()),
1331
- createdAt: z.number(),
1332
- lastUsedAt: z.number().nullable(),
1333
- expiresAt: z.number().nullable(),
1334
- revokedAt: z.number().nullable(),
1335
- rateLimit: z.object({ max: z.number(), per: z.string() }).nullable(),
1336
- ipAllowlist: z.array(z.string()),
1337
- unused90d: z.boolean(),
1338
- }),
1330
+ key: AccessKeyRowOut,
1339
1331
  /** Raw secret — returned exactly once. */
1340
1332
  secret: z.string(),
1341
1333
  });
@@ -1347,19 +1339,7 @@ const AccessRevokeKeyIn = z.object({
1347
1339
  });
1348
1340
 
1349
1341
  const AccessRevokeKeyOut = z.object({
1350
- key: z.object({
1351
- id: z.string(),
1352
- name: z.string(),
1353
- plane: z.enum(["user", "operator"]),
1354
- scopes: z.array(z.string()),
1355
- createdAt: z.number(),
1356
- lastUsedAt: z.number().nullable(),
1357
- expiresAt: z.number().nullable(),
1358
- revokedAt: z.number().nullable(),
1359
- rateLimit: z.object({ max: z.number(), per: z.string() }).nullable(),
1360
- ipAllowlist: z.array(z.string()),
1361
- unused90d: z.boolean(),
1362
- }),
1342
+ key: AccessKeyRowOut,
1363
1343
  blastRadius: AccessKeyBlastOut,
1364
1344
  });
1365
1345
 
@@ -1855,6 +1835,7 @@ export function createConsoleBindings(state: ConsoleState): {
1855
1835
  readonly createKey: AnyFlowDef;
1856
1836
  readonly revokeKey: AnyFlowDef;
1857
1837
  readonly rotateKey: AnyFlowDef;
1838
+ readonly updateKey: AnyFlowDef;
1858
1839
  readonly setRoleGrants: AnyFlowDef;
1859
1840
  };
1860
1841
  readonly diff: {
@@ -1928,6 +1909,7 @@ export function createConsoleBindings(state: ConsoleState): {
1928
1909
  const accessCreateKeyFlow = createAccessCreateKey(state);
1929
1910
  const accessRevokeKeyFlow = createAccessRevokeKey(state);
1930
1911
  const accessRotateKeyFlow = createAccessRotateKey(state);
1912
+ const accessUpdateKeyFlow = createAccessUpdateKey(state);
1931
1913
  const accessSetRoleGrantsFlow = createAccessSetRoleGrants(state);
1932
1914
  const diffList = createDiffList(state);
1933
1915
  const pluginsList = createPluginsList(state);
@@ -1990,6 +1972,7 @@ export function createConsoleBindings(state: ConsoleState): {
1990
1972
  bindHttp(http.post("/console/access/keys"), accessCreateKeyFlow),
1991
1973
  bindHttp(http.post("/console/access/keys/revoke"), accessRevokeKeyFlow),
1992
1974
  bindHttp(http.post("/console/access/keys/rotate"), accessRotateKeyFlow),
1975
+ bindHttp(http.post("/console/access/keys/update"), accessUpdateKeyFlow),
1993
1976
  bindHttp(http.post("/console/access/roles/grants"), accessSetRoleGrantsFlow),
1994
1977
  bindHttp(http.get("/console/diff"), diffList),
1995
1978
  bindHttp(http.get("/console/plugins"), pluginsList),
@@ -2059,6 +2042,7 @@ export function createConsoleBindings(state: ConsoleState): {
2059
2042
  createKey: accessCreateKeyFlow,
2060
2043
  revokeKey: accessRevokeKeyFlow,
2061
2044
  rotateKey: accessRotateKeyFlow,
2045
+ updateKey: accessUpdateKeyFlow,
2062
2046
  setRoleGrants: accessSetRoleGrantsFlow,
2063
2047
  },
2064
2048
  diff: { list: diffList },
@@ -3854,6 +3838,46 @@ function actorScopesOf(state: ConsoleState, operatorId: string): string[] {
3854
3838
  return [...new Set([...fromRoles, "console:*"])];
3855
3839
  }
3856
3840
 
3841
+ /**
3842
+ * Derive the issuer for a Console-minted key.
3843
+ * Operator-plane: the acting operator. User-plane: required `creatorUserId`.
3844
+ *
3845
+ * @param state - Console state
3846
+ * @param operatorId - Acting operator
3847
+ * @param input - Create payload
3848
+ */
3849
+ export function resolveAccessKeyIssuer(
3850
+ state: ConsoleState,
3851
+ operatorId: string,
3852
+ input: { readonly plane: "user" | "operator"; readonly creatorUserId?: string },
3853
+ ):
3854
+ | { readonly creatorId: string; readonly creatorScopes: readonly string[] }
3855
+ | { readonly error: string } {
3856
+ if (input.plane === "operator") {
3857
+ return { creatorId: operatorId, creatorScopes: actorScopesOf(state, operatorId) };
3858
+ }
3859
+ const userId = input.creatorUserId;
3860
+ if (!userId) {
3861
+ return { error: "creatorUserId is required for user-plane keys" };
3862
+ }
3863
+ const identity = state.identities.find((row) => row.id === userId);
3864
+ const roleIds = [...state.roleMembers.entries()]
3865
+ .filter(([, members]) => members.includes(userId))
3866
+ .map(([id]) => id);
3867
+ if (!identity && roleIds.length === 0) {
3868
+ return { error: `unknown user: ${userId}` };
3869
+ }
3870
+ return {
3871
+ creatorId: userId,
3872
+ creatorScopes: accessUserCeiling({
3873
+ userId,
3874
+ identityScopes: identity?.scopes,
3875
+ roles: state.roles,
3876
+ roleMembers: state.roleMembers,
3877
+ }),
3878
+ };
3879
+ }
3880
+
3857
3881
  function createAccessList(state: ConsoleState) {
3858
3882
  return flow("console.access.list", {
3859
3883
  plane: "operator",
@@ -3916,12 +3940,16 @@ function createAccessCreateKey(state: ConsoleState) {
3916
3940
  do: async (input: z.infer<typeof AccessCreateKeyIn>, fx) => {
3917
3941
  if (!fx.operator.id) return fail("AuthFailed", {});
3918
3942
  try {
3943
+ const issued = resolveAccessKeyIssuer(state, fx.operator.id, input);
3944
+ if ("error" in issued) {
3945
+ return fail("AccessGrantDenied", { reason: issued.error });
3946
+ }
3919
3947
  const created = await state.accessCreateKey({
3920
3948
  plane: input.plane,
3921
3949
  name: input.name,
3922
3950
  scopes: input.scopes,
3923
- creatorId: fx.operator.id,
3924
- creatorScopes: actorScopesOf(state, fx.operator.id),
3951
+ creatorId: issued.creatorId,
3952
+ creatorScopes: issued.creatorScopes,
3925
3953
  expiresAt: input.expiresAt,
3926
3954
  rateLimit: input.rateLimit,
3927
3955
  ipAllowlist: input.ipAllowlist,
@@ -4004,6 +4032,33 @@ function createAccessRotateKey(state: ConsoleState) {
4004
4032
  });
4005
4033
  }
4006
4034
 
4035
+ function createAccessUpdateKey(state: ConsoleState) {
4036
+ return flow("console.access.updateKey", {
4037
+ plane: "operator",
4038
+ in: AccessUpdateKeyIn,
4039
+ out: AccessUpdateKeyOut,
4040
+ errors: { AuthFailed, AccessKeyNotFound, AccessGrantDenied },
4041
+ do: async (input: z.infer<typeof AccessUpdateKeyIn>, fx) => {
4042
+ if (!fx.operator.id) return fail("AuthFailed", {});
4043
+ try {
4044
+ const key = await state.accessUpdateKey(input);
4045
+ if (!key) {
4046
+ return fail("AccessKeyNotFound", { keyId: input.keyId });
4047
+ }
4048
+ fx.log.info("console.access.updateKey", {
4049
+ keyId: key.id,
4050
+ operatorId: fx.operator.id,
4051
+ });
4052
+ return { key: { ...key } };
4053
+ } catch (err) {
4054
+ return fail("AccessGrantDenied", {
4055
+ reason: err instanceof Error ? err.message : "grant denied",
4056
+ });
4057
+ }
4058
+ },
4059
+ });
4060
+ }
4061
+
4007
4062
  function createAccessSetRoleGrants(state: ConsoleState) {
4008
4063
  return flow("console.access.setRoleGrants", {
4009
4064
  plane: "operator",
@@ -78,6 +78,14 @@ async function liveRuntime(): Promise<{
78
78
  };
79
79
  }
80
80
 
81
+ describe("createDefaultGateAuthStores", () => {
82
+ test("user roles grant member only — no leftover app catalog", () => {
83
+ const auth = createDefaultGateAuthStores();
84
+ expect([...(auth.roles.grants.get("role_member") ?? [])]).toEqual(["member"]);
85
+ expect([...(auth.roles.grants.get("role_staff") ?? [])]).toEqual(["member"]);
86
+ });
87
+ });
88
+
81
89
  describe("isApplicationScope", () => {
82
90
  test("console:* is not application; everything else is", () => {
83
91
  expect(isApplicationScope("console:store.sql:write")).toBe(false);
@@ -91,6 +99,21 @@ describe("projectGatesPanel", () => {
91
99
  const auth = createDefaultGateAuthStores();
92
100
  // Poison an operator role with an application scope.
93
101
  setRoleGrants(auth.roles, "role_ops", ["console:store.sql:read", "booking:create"]);
102
+ auth.apiKeys.keys.set("key_fixture", {
103
+ id: "key_fixture",
104
+ plane: "user",
105
+ hash: "fixture",
106
+ name: "Fixture",
107
+ scopes: ["member"],
108
+ expiresAt: null,
109
+ rateLimit: null,
110
+ ipAllowlist: [],
111
+ creatorId: "user_demo",
112
+ creatorScopes: ["member"],
113
+ createdAt: 0,
114
+ lastUsedAt: null,
115
+ revokedAt: null,
116
+ });
94
117
 
95
118
  const projection = projectGatesPanel({
96
119
  manifest: MANIFEST,
@@ -318,6 +341,7 @@ describe("simulateGates — evaluate only", () => {
318
341
  test("role principal resolves scopes from RoleStore", async () => {
319
342
  const { runtime, close } = await liveRuntime();
320
343
  const auth = createDefaultGateAuthStores();
344
+ setRoleGrants(auth.roles, "role_member", ["member", "booking:create"]);
321
345
  try {
322
346
  const ok = await simulateGates({
323
347
  flowId: "bookings.create",
@@ -33,6 +33,7 @@ import { statusForFailure } from "../../compiler/response.ts";
33
33
  import { gateDenialFailure } from "../../kernel/index.ts";
34
34
  import { diffManifest } from "../../manifest/diff.ts";
35
35
  import type { Gate as ManifestGate, Manifest, ManifestChange } from "../../manifest/types.ts";
36
+ import { defaultMemberIdentityIds } from "./dev-identities.ts";
36
37
 
37
38
  /** Identity slice needed for Gates (avoids circular import with state). */
38
39
  export interface GatesIdentity {
@@ -413,8 +414,8 @@ export function createDefaultGateAuthStores(): {
413
414
  plane: "operator",
414
415
  description: "Console operators",
415
416
  });
416
- setRoleGrants(roles, "role_member", ["member", "booking:create"]);
417
- setRoleGrants(roles, "role_staff", ["member", "booking:create", "staff", "reports:export"]);
417
+ setRoleGrants(roles, "role_member", ["member"]);
418
+ setRoleGrants(roles, "role_staff", ["member"]);
418
419
  setRoleGrants(roles, "role_ops", [
419
420
  "console:store.sql:read",
420
421
  "console:store.sql:write",
@@ -422,24 +423,9 @@ export function createDefaultGateAuthStores(): {
422
423
  ]);
423
424
 
424
425
  const apiKeys = createApiKeyStore();
425
- apiKeys.keys.set("key_demo", {
426
- id: "key_demo",
427
- plane: "user",
428
- hash: "demo",
429
- name: "Demo key",
430
- scopes: ["member", "booking:create"],
431
- expiresAt: null,
432
- rateLimit: null,
433
- ipAllowlist: [],
434
- creatorId: "user_demo",
435
- creatorScopes: ["member", "booking:create"],
436
- createdAt: 0,
437
- lastUsedAt: null,
438
- revokedAt: null,
439
- });
440
426
 
441
427
  const roleMembers = new Map<string, string[]>([
442
- ["role_member", ["user_demo", "user_member"]],
428
+ ["role_member", defaultMemberIdentityIds()],
443
429
  ["role_staff", []],
444
430
  ["role_ops", []],
445
431
  ]);
@@ -706,7 +692,7 @@ function resolvePrincipalContext(options: SimulateGatesOptions): {
706
692
  return {
707
693
  ctx: {
708
694
  auth: { userId: null, scopes: new Set(), verified: false },
709
- operator: { id: `key:${id}` },
695
+ operator: { id: key.creatorId },
710
696
  meta: { ip: options.meta?.ip },
711
697
  },
712
698
  };
@@ -714,12 +700,13 @@ function resolvePrincipalContext(options: SimulateGatesOptions): {
714
700
  return {
715
701
  ctx: {
716
702
  auth: {
717
- userId: `key:${id}`,
703
+ userId: key.creatorId,
718
704
  scopes: new Set(key.scopes),
719
705
  verified: true,
706
+ apiKeyId: key.id,
720
707
  },
721
708
  operator: { id: null },
722
- meta: { ip: options.meta?.ip, userId: `key:${id}` },
709
+ meta: { ip: options.meta?.ip, userId: key.creatorId },
723
710
  },
724
711
  };
725
712
  }
@@ -6,6 +6,7 @@ export {
6
6
  accessCreateKey,
7
7
  accessRevokeKey,
8
8
  accessRotateKey,
9
+ accessUpdateKey,
9
10
  accessSetRoleGrants,
10
11
  effectivePermissions,
11
12
  expandAccessCeiling,