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,84 @@
1
+ /**
2
+ * Access page URL search — selected key + sheet action.
3
+ */
4
+
5
+ import { useNavigate, useSearch } from "@tanstack/react-router";
6
+ import { useCallback } from "react";
7
+
8
+ /** Sheet opened from the URL. */
9
+ export type AccessAction = "create" | "edit" | "refresh" | "revoke" | "rotate";
10
+
11
+ /** Search params for `/access`. */
12
+ export interface AccessSearch {
13
+ readonly q?: string;
14
+ readonly key?: string;
15
+ readonly action?: AccessAction;
16
+ }
17
+
18
+ /**
19
+ * Validate Access search params from the router.
20
+ *
21
+ * @param search - Raw search object
22
+ */
23
+ export function validateAccessSearch(search: Record<string, unknown>): AccessSearch {
24
+ const q = typeof search.q === "string" && search.q.length > 0 ? search.q : undefined;
25
+ const key = typeof search.key === "string" && search.key.length > 0 ? search.key : undefined;
26
+ const action =
27
+ search.action === "create" ||
28
+ search.action === "edit" ||
29
+ search.action === "refresh" ||
30
+ search.action === "revoke" ||
31
+ search.action === "rotate"
32
+ ? search.action
33
+ : undefined;
34
+ return {
35
+ ...(q !== undefined ? { q } : {}),
36
+ ...(key !== undefined ? { key } : {}),
37
+ ...(action !== undefined ? { action } : {}),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Read + write the Access page selection from the URL.
43
+ */
44
+ export function useAccessSelection(): {
45
+ readonly query: string;
46
+ readonly selectedKey: string | null;
47
+ readonly action: AccessAction | null;
48
+ readonly setQuery: (q: string) => void;
49
+ readonly setSelectedKey: (key: string | null) => void;
50
+ readonly setAction: (action: AccessAction | null) => void;
51
+ } {
52
+ const search = useSearch({ strict: false }) as AccessSearch;
53
+ const navigate = useNavigate();
54
+
55
+ const query = typeof search.q === "string" ? search.q : "";
56
+ const selectedKey = typeof search.key === "string" ? search.key : null;
57
+ const action = search.action ?? null;
58
+
59
+ const patch = useCallback(
60
+ (next: Partial<AccessSearch>) => {
61
+ void navigate({
62
+ to: ".",
63
+ search: (prev: Record<string, unknown>) => validateAccessSearch({ ...prev, ...next }),
64
+ replace: true,
65
+ });
66
+ },
67
+ [navigate],
68
+ );
69
+
70
+ return {
71
+ query,
72
+ selectedKey,
73
+ action,
74
+ setQuery: (q) => {
75
+ patch({ q: q.length > 0 ? q : undefined });
76
+ },
77
+ setSelectedKey: (key) => {
78
+ patch({ key: key ?? undefined, action: undefined });
79
+ },
80
+ setAction: (next) => {
81
+ patch({ action: next ?? undefined });
82
+ },
83
+ };
84
+ }
@@ -18,6 +18,7 @@ describe("sanitizeReturnTo", () => {
18
18
  expect(sanitizeReturnTo("/vault?name=STRIPE_KEY&action=rotate")).toBe(
19
19
  "/vault?name=STRIPE_KEY&action=rotate",
20
20
  );
21
+ expect(sanitizeReturnTo("/access?key=key_1")).toBe("/access?key=key_1");
21
22
  expect(sanitizeReturnTo("/observability?window=7d")).toBe("/observability?window=7d");
22
23
  expect(sanitizeReturnTo("/monitoring?window=7d")).toBe("/observability?window=7d");
23
24
  });
@@ -107,6 +108,10 @@ describe("afterAuthLocation", () => {
107
108
  to: "/vault",
108
109
  search: { name: "STRIPE_KEY", action: "rotate-master" },
109
110
  });
111
+ expect(afterAuthLocation("/access?key=key_1")).toEqual({
112
+ to: "/access",
113
+ search: { key: "key_1" },
114
+ });
110
115
  expect(afterAuthLocation("/observability?window=7d&run=r1")).toEqual({
111
116
  to: "/observability",
112
117
  search: { window: "7d", run: "r1" },
@@ -2,7 +2,7 @@
2
2
  * Post-auth return path — keep the operator on the module they were in
3
3
  * after a session expires, instead of always landing on `/overview`.
4
4
  *
5
- * Only `/overview`, `/flows`, `/store`, `/vault`, and `/observability` (plus their
5
+ * Only `/overview`, `/flows`, `/store`, `/vault`, `/access`, and `/observability` (plus their
6
6
  * search) are legal. `/monitoring` is rewritten to `/observability`. Anything else
7
7
  * is dropped so `?next=` cannot be an open redirect.
8
8
  */
@@ -15,12 +15,20 @@ import {
15
15
  type ObservabilitySearch,
16
16
  } from "../observability/state/observability-selection.ts";
17
17
  import { validateVaultSearch, type VaultSearch } from "../vault/state/vault-selection.ts";
18
+ import { validateAccessSearch, type AccessSearch } from "../access/state/access-selection.ts";
18
19
 
19
20
  /** Default shell module when no safe return path is present. */
20
21
  export const DEFAULT_AFTER_AUTH = "/overview" as const;
21
22
 
22
23
  /** Shell pathnames that may be restored after login. */
23
- const SHELL_PATHS = new Set(["/overview", "/flows", "/store", "/vault", "/observability"]);
24
+ const SHELL_PATHS = new Set([
25
+ "/overview",
26
+ "/flows",
27
+ "/store",
28
+ "/vault",
29
+ "/access",
30
+ "/observability",
31
+ ]);
24
32
 
25
33
  /** Retired live module — rewritten to {@link CANONICAL_OBSERVABILITY}. */
26
34
  const LEGACY_MONITORING = "/monitoring";
@@ -40,6 +48,7 @@ export type AfterAuthLocation =
40
48
  | { readonly to: "/flows"; readonly search: UnitsSearch }
41
49
  | { readonly to: "/store"; readonly search: StoreSearch }
42
50
  | { readonly to: "/vault"; readonly search: VaultSearch }
51
+ | { readonly to: "/access"; readonly search: AccessSearch }
43
52
  | { readonly to: "/observability"; readonly search: ObservabilitySearch };
44
53
 
45
54
  /**
@@ -111,6 +120,9 @@ export function afterAuthLocation(value: unknown): AfterAuthLocation {
111
120
  if (url.pathname === "/vault") {
112
121
  return { to: "/vault", search: validateVaultSearch(raw) };
113
122
  }
123
+ if (url.pathname === "/access") {
124
+ return { to: "/access", search: validateAccessSearch(raw) };
125
+ }
114
126
  if (url.pathname === CANONICAL_OBSERVABILITY) {
115
127
  return { to: "/observability", search: validateObservabilitySearch(raw) };
116
128
  }
@@ -123,6 +135,7 @@ export type AfterAuthNavigate = {
123
135
  (opts: { to: "/flows"; search: UnitsSearch }): unknown;
124
136
  (opts: { to: "/store"; search: StoreSearch }): unknown;
125
137
  (opts: { to: "/vault"; search: VaultSearch }): unknown;
138
+ (opts: { to: "/access"; search: AccessSearch }): unknown;
126
139
  (opts: { to: "/observability"; search: ObservabilitySearch }): unknown;
127
140
  };
128
141
 
@@ -144,6 +157,9 @@ export function goAfterAuth(navigate: AfterAuthNavigate, value: unknown): void {
144
157
  case "/vault":
145
158
  void navigate({ to: "/vault", search: dest.search });
146
159
  return;
160
+ case "/access":
161
+ void navigate({ to: "/access", search: dest.search });
162
+ return;
147
163
  case "/observability":
148
164
  void navigate({ to: "/observability", search: dest.search });
149
165
  return;
@@ -6,7 +6,7 @@ import { authLabelClassName } from "@/components/auth-card";
6
6
  import { ConsoleChrome } from "@/components/console-chrome";
7
7
 
8
8
  /**
9
- * 404 for any path that is not `/`, `/overview`, `/flows`, `/store`, `/vault`, or `/observability`.
9
+ * 404 for any path that is not `/`, `/overview`, `/flows`, `/store`, `/vault`, `/access`, or `/observability`.
10
10
  */
11
11
  export function NotFoundPage() {
12
12
  return (
@@ -25,6 +25,7 @@ describe("consoleDocumentTitle", () => {
25
25
  `Observability · ${CONSOLE_DOCUMENT_TITLE}`,
26
26
  );
27
27
  expect(consoleDocumentTitle("/vault")).toBe(`Vault · ${CONSOLE_DOCUMENT_TITLE}`);
28
+ expect(consoleDocumentTitle("/access")).toBe(`Access · ${CONSOLE_DOCUMENT_TITLE}`);
28
29
  expect(consoleDocumentTitle("/monitoring")).toBe(`Observability · ${CONSOLE_DOCUMENT_TITLE}`);
29
30
  });
30
31
 
@@ -7,7 +7,7 @@ export const CONSOLE_DOCUMENT_TITLE = "okengine Console";
7
7
 
8
8
  /** Operator-facing `meta name="description"` / Open Graph copy. */
9
9
  export const CONSOLE_DOCUMENT_DESCRIPTION =
10
- "Operator Console for okengine — Flows, Store, Observability, and Vault.";
10
+ "Operator Console for okengine — Flows, Store, Observability, Vault, and Access.";
11
11
 
12
12
  const PAGE_TITLES: Record<string, string> = {
13
13
  "/overview": "Overview",
@@ -15,6 +15,7 @@ const PAGE_TITLES: Record<string, string> = {
15
15
  "/store": "Store",
16
16
  "/observability": "Observability",
17
17
  "/vault": "Vault",
18
+ "/access": "Access",
18
19
  "/monitoring": "Observability",
19
20
  };
20
21
 
@@ -12,6 +12,7 @@ describe("last-module-search", () => {
12
12
  expect(isConsoleModulePath("/overview")).toBe(true);
13
13
  expect(isConsoleModulePath("/flows")).toBe(true);
14
14
  expect(isConsoleModulePath("/observability")).toBe(true);
15
+ expect(isConsoleModulePath("/access")).toBe(true);
15
16
  expect(isConsoleModulePath("/monitoring")).toBe(false);
16
17
  expect(isConsoleModulePath("/units")).toBe(false);
17
18
  expect(isConsoleModulePath("/")).toBe(false);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Remember each Console module's last URL search so sidebar hops
3
- * restore Overview / Flows / Store / Observability / Vault instead of wiping them.
3
+ * restore Overview / Flows / Store / Observability / Vault / Access instead of wiping them.
4
4
  */
5
5
 
6
6
  /** Authenticated Console modules that own URL search. */
@@ -10,6 +10,7 @@ export const CONSOLE_MODULE_PATHS = [
10
10
  "/store",
11
11
  "/observability",
12
12
  "/vault",
13
+ "/access",
13
14
  ] as const;
14
15
 
15
16
  /** Pathname for a Console module. */
@@ -21,6 +21,7 @@ describe("consoleShortcut", () => {
21
21
  expect(consoleShortcut("store")).toEqual(modChord("3"));
22
22
  expect(consoleShortcut("observability")).toEqual(modChord("4"));
23
23
  expect(consoleShortcut("vault")).toEqual(modChord("5"));
24
+ expect(consoleShortcut("access")).toEqual(modChord("6"));
24
25
  });
25
26
 
26
27
  test("digits map to module paths", () => {
@@ -29,6 +30,7 @@ describe("consoleShortcut", () => {
29
30
  expect(MODULE_DIGIT_PATH["3"]).toBe("/store");
30
31
  expect(MODULE_DIGIT_PATH["4"]).toBe("/observability");
31
32
  expect(MODULE_DIGIT_PATH["5"]).toBe("/vault");
33
+ expect(MODULE_DIGIT_PATH["6"]).toBe("/access");
32
34
  });
33
35
  });
34
36
 
@@ -63,6 +63,7 @@ export type ConsoleShortcutId =
63
63
  | "flows"
64
64
  | "store"
65
65
  | "vault"
66
+ | "access"
66
67
  | "observability";
67
68
 
68
69
  const MODULE_DIGIT: Readonly<
@@ -73,6 +74,7 @@ const MODULE_DIGIT: Readonly<
73
74
  store: "3",
74
75
  observability: "4",
75
76
  vault: "5",
77
+ access: "6",
76
78
  };
77
79
 
78
80
  /**
@@ -94,6 +96,7 @@ export const MODULE_DIGIT_PATH: Readonly<Record<string, ConsoleModulePath>> = {
94
96
  "3": "/store",
95
97
  "4": "/observability",
96
98
  "5": "/vault",
99
+ "6": "/access",
97
100
  };
98
101
 
99
102
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Console router — code-based TanStack Router (Vite SPA).
3
- * Pre-auth `/` and authenticated shell `/overview` | `/flows` | `/store` | `/vault` | `/observability`.
3
+ * Pre-auth `/` and authenticated shell `/overview` | `/flows` | `/store` | `/vault` | `/access` | `/observability`.
4
4
  * `/monitoring` is a one-shot search-preserving redirect to `/observability`.
5
5
  * Authenticated pages are `lazyRouteComponent` so Vite splits them out of the entry.
6
6
  * Any other path is a 404 — no legacy rewrites.
@@ -30,6 +30,7 @@ import { validateStoreSearch } from "./features/store/state/store-selection.ts";
30
30
  import { validateUnitsSearch } from "./features/units/state/units-selection.ts";
31
31
  import { validateObservabilitySearch } from "./features/observability/state/observability-selection.ts";
32
32
  import { validateVaultSearch } from "./features/vault/state/vault-selection.ts";
33
+ import { validateAccessSearch } from "./features/access/state/access-selection.ts";
33
34
  import { DocumentTitle } from "./components/document-title.tsx";
34
35
  import { ShellLayout } from "./components/shell/shell-layout.tsx";
35
36
 
@@ -120,6 +121,13 @@ const vaultRoute = createRoute({
120
121
  component: lazyRouteComponent(() => import("./features/vault/vault-page.tsx"), "VaultPage"),
121
122
  });
122
123
 
124
+ const accessRoute = createRoute({
125
+ getParentRoute: () => authenticatedRoute,
126
+ path: "/access",
127
+ validateSearch: validateAccessSearch,
128
+ component: lazyRouteComponent(() => import("./features/access/access-page.tsx"), "AccessPage"),
129
+ });
130
+
123
131
  const observabilityRoute = createRoute({
124
132
  getParentRoute: () => authenticatedRoute,
125
133
  path: "/observability",
@@ -146,6 +154,7 @@ const routeTree = rootRoute.addChildren([
146
154
  flowsRoute,
147
155
  storeRoute,
148
156
  vaultRoute,
157
+ accessRoute,
149
158
  observabilityRoute,
150
159
  monitoringRedirectRoute,
151
160
  ]),
@@ -7,6 +7,7 @@
7
7
  import { AUTH_TABLES } from "../../auth/tables.ts";
8
8
  import { resolveAuthSchema } from "../../auth/schema.ts";
9
9
  import type { DeclaredColumn, Table } from "../../manifest/types.ts";
10
+ import { KEEL_LADDER_GRANTS, KEEL_SCOPES } from "../server/dev-identities.ts";
10
11
 
11
12
  const PII_COLUMNS = new Set([
12
13
  "email",
@@ -163,9 +164,63 @@ export const UI_NEXT_SEED_APP_SYSTEM_TABLES: Record<string, Table> = {
163
164
  const NOW = 1_723_622_400_000;
164
165
  const LATER = NOW + 30 * 24 * 60 * 60 * 1000;
165
166
 
166
- const PEOPLE = [
167
- { id: "user_demo", email: "demo@example.com", name: "Demo User", status: "active" },
168
- { id: "user_member", email: "member@example.com", name: "Member", status: "active" },
167
+ /** Ten-rung owner → guest ladder (demo + member keep historic ids). */
168
+ const LADDER = [
169
+ {
170
+ id: "user_demo",
171
+ email: "demo@example.com",
172
+ name: "Demo User",
173
+ status: "active",
174
+ role: "owner",
175
+ },
176
+ { id: "user_admin", email: "admin@example.com", name: "Admin", status: "active", role: "admin" },
177
+ {
178
+ id: "user_workspace",
179
+ email: "workspace@example.com",
180
+ name: "Workspace Admin",
181
+ status: "active",
182
+ role: "workspace_admin",
183
+ },
184
+ {
185
+ id: "user_pm",
186
+ email: "pm@example.com",
187
+ name: "Project Manager",
188
+ status: "active",
189
+ role: "project_manager",
190
+ },
191
+ { id: "user_lead", email: "lead@example.com", name: "Lead", status: "active", role: "lead" },
192
+ {
193
+ id: "user_dev",
194
+ email: "developer@example.com",
195
+ name: "Developer",
196
+ status: "active",
197
+ role: "developer",
198
+ },
199
+ {
200
+ id: "user_writer",
201
+ email: "writer@example.com",
202
+ name: "Contributor",
203
+ status: "active",
204
+ role: "contributor",
205
+ },
206
+ {
207
+ id: "user_member",
208
+ email: "member@example.com",
209
+ name: "Member",
210
+ status: "active",
211
+ role: "member",
212
+ },
213
+ {
214
+ id: "user_commenter",
215
+ email: "commenter@example.com",
216
+ name: "Commenter",
217
+ status: "active",
218
+ role: "commenter",
219
+ },
220
+ { id: "user_guest", email: "guest@example.com", name: "Guest", status: "active", role: "guest" },
221
+ ] as const;
222
+
223
+ const STORY = [
169
224
  { id: "idn_aria", email: "aria@keel.dev", name: "Aria Chen", status: "active" },
170
225
  { id: "idn_ben", email: "ben@keel.dev", name: "Ben Okonkwo", status: "active" },
171
226
  { id: "idn_cai", email: "cai@keel.dev", name: "Cai Moreno", status: "active" },
@@ -185,6 +240,8 @@ const PEOPLE = [
185
240
  { id: "idn_zio", email: "zio@keel.dev", name: "Zio Hart", status: "active" },
186
241
  ] as const;
187
242
 
243
+ const PEOPLE = [...LADDER, ...STORY];
244
+
188
245
  /**
189
246
  * Seeded rows for app-plane system tables (never secret values).
190
247
  */
@@ -209,49 +266,43 @@ export const UI_NEXT_SEED_APP_SYSTEM_ROWS: Readonly<Record<string, readonly obje
209
266
  updated_at: NOW,
210
267
  })),
211
268
  [AUTH_TABLES.identityRoles]: [
212
- ...PEOPLE.map((p) => ({
269
+ ...LADDER.filter((p) => p.role !== "guest").map((p) => ({
270
+ id: `ir_${p.id}_${p.role}`,
271
+ identity_id: p.id,
272
+ role_id: `role_${p.role}`,
273
+ })),
274
+ ...STORY.map((p) => ({
213
275
  id: `ir_${p.id}_member`,
214
276
  identity_id: p.id,
215
277
  role_id: "role_member",
216
278
  })),
217
- { id: "ir_idn_aria_staff", identity_id: "idn_aria", role_id: "role_staff" },
218
- { id: "ir_user_demo_staff", identity_id: "user_demo", role_id: "role_staff" },
219
- ],
220
- [AUTH_TABLES.roles]: [
221
- { id: "role_member", name: "member", plane: "user", description: "Signed-in workspace member" },
222
- { id: "role_staff", name: "staff", plane: "user", description: "May accept triage" },
223
- {
224
- id: "role_issue_write",
225
- name: "issue:write",
226
- plane: "user",
227
- description: "May create and update issues",
228
- },
229
- {
230
- id: "role_team_admin",
231
- name: "team:admin",
232
- plane: "user",
233
- description: "May create teams and close cycles",
234
- },
235
- ],
236
- [AUTH_TABLES.roleGrants]: [
237
- { id: "rg_1", role_id: "role_member", action: "member" },
238
- { id: "rg_2", role_id: "role_staff", action: "member" },
239
- { id: "rg_3", role_id: "role_staff", action: "triage:accept" },
240
- { id: "rg_4", role_id: "role_issue_write", action: "issue:write" },
241
- { id: "rg_5", role_id: "role_issue_write", action: "project:admin" },
242
- { id: "rg_6", role_id: "role_issue_write", action: "comment:write" },
243
- { id: "rg_7", role_id: "role_issue_write", action: "files:write" },
244
- { id: "rg_8", role_id: "role_team_admin", action: "team:admin" },
245
- { id: "rg_9", role_id: "role_team_admin", action: "member:admin" },
246
- { id: "rg_10", role_id: "role_team_admin", action: "webhook:admin" },
279
+ { id: "ir_idn_aria_pm", identity_id: "idn_aria", role_id: "role_project_manager" },
247
280
  ],
281
+ [AUTH_TABLES.roles]: Object.keys(KEEL_LADDER_GRANTS).map((role) => ({
282
+ id: `role_${role}`,
283
+ name: role,
284
+ plane: "user",
285
+ description:
286
+ role === "owner"
287
+ ? "Full workspace authorization"
288
+ : role === "guest"
289
+ ? "Least authorization — no write scopes"
290
+ : `Keel ${role.replaceAll("_", " ")}`,
291
+ })),
292
+ [AUTH_TABLES.roleGrants]: Object.entries(KEEL_LADDER_GRANTS).flatMap(([role, actions]) =>
293
+ actions.map((action, i) => ({
294
+ id: `rg_${role}_${i + 1}`,
295
+ role_id: `role_${role}`,
296
+ action,
297
+ })),
298
+ ),
248
299
  [AUTH_TABLES.apiKeys]: [
249
300
  {
250
301
  id: "key_keel_ci",
251
302
  plane: "user",
252
303
  hash: "sha256$keel_ci",
253
304
  name: "Keel CI",
254
- scopes: "member,issue:write",
305
+ scopes: "member,task:write",
255
306
  expires_at: null,
256
307
  created_at: NOW,
257
308
  last_used_at: NOW - 3_600_000,
@@ -279,7 +330,7 @@ export const UI_NEXT_SEED_APP_SYSTEM_ROWS: Readonly<Record<string, readonly obje
279
330
  created_at: NOW,
280
331
  expires_at: LATER,
281
332
  last_active_at: NOW,
282
- scopes: "member,issue:write",
333
+ scopes: KEEL_LADDER_GRANTS.project_manager.join(","),
283
334
  audience: "oke-app",
284
335
  },
285
336
  {
@@ -291,7 +342,7 @@ export const UI_NEXT_SEED_APP_SYSTEM_ROWS: Readonly<Record<string, readonly obje
291
342
  created_at: NOW,
292
343
  expires_at: LATER,
293
344
  last_active_at: NOW,
294
- scopes: "member,issue:write",
345
+ scopes: KEEL_SCOPES.join(","),
295
346
  audience: "oke-app",
296
347
  },
297
348
  ],
@@ -41,6 +41,20 @@ const SEED_SPACES = [
41
41
  ] as const;
42
42
 
43
43
  const SEED_MEMBERS = [
44
+ {
45
+ id: "mem_demo",
46
+ space_id: "space_eng",
47
+ name: "Demo User",
48
+ email: "demo@example.com",
49
+ role: "owner",
50
+ },
51
+ {
52
+ id: "mem_admin",
53
+ space_id: "space_eng",
54
+ name: "Avery Cole",
55
+ email: "admin@keel.dev",
56
+ role: "admin",
57
+ },
44
58
  {
45
59
  id: "mem_aria",
46
60
  space_id: "space_eng",
@@ -48,6 +62,13 @@ const SEED_MEMBERS = [
48
62
  email: "aria@keel.dev",
49
63
  role: "project_manager",
50
64
  },
65
+ {
66
+ id: "mem_lead",
67
+ space_id: "space_eng",
68
+ name: "Jules Hart",
69
+ email: "jules@keel.dev",
70
+ role: "lead",
71
+ },
51
72
  {
52
73
  id: "mem_ben",
53
74
  space_id: "space_eng",
@@ -55,6 +76,13 @@ const SEED_MEMBERS = [
55
76
  email: "ben@keel.dev",
56
77
  role: "developer",
57
78
  },
79
+ {
80
+ id: "mem_eli",
81
+ space_id: "space_eng",
82
+ name: "Eli Park",
83
+ email: "eli@keel.dev",
84
+ role: "contributor",
85
+ },
58
86
  {
59
87
  id: "mem_cai",
60
88
  space_id: "space_des",
@@ -62,6 +90,20 @@ const SEED_MEMBERS = [
62
90
  email: "cai@keel.dev",
63
91
  role: "member",
64
92
  },
93
+ {
94
+ id: "mem_member",
95
+ space_id: "space_des",
96
+ name: "Member",
97
+ email: "member@example.com",
98
+ role: "member",
99
+ },
100
+ {
101
+ id: "mem_commenter",
102
+ space_id: "space_gtm",
103
+ name: "Kit Rowe",
104
+ email: "kit@keel.dev",
105
+ role: "commenter",
106
+ },
65
107
  {
66
108
  id: "mem_dia",
67
109
  space_id: "space_gtm",
@@ -69,13 +111,6 @@ const SEED_MEMBERS = [
69
111
  email: "dia@keel.dev",
70
112
  role: "guest",
71
113
  },
72
- {
73
- id: "mem_eli",
74
- space_id: "space_eng",
75
- name: "Eli Park",
76
- email: "eli@keel.dev",
77
- role: "developer",
78
- },
79
114
  ] as const;
80
115
 
81
116
  const SEED_GOALS = [
@@ -640,14 +675,7 @@ function generateKeelVolume(): {
640
675
  email: `${p.id}@keel.dev`,
641
676
  role: "member",
642
677
  }));
643
- const emails = [
644
- "aria@keel.dev",
645
- "ben@keel.dev",
646
- "cai@keel.dev",
647
- "dia@keel.dev",
648
- "eli@keel.dev",
649
- ...members.map((m) => m.email),
650
- ];
678
+ const emails = [...SEED_MEMBERS.map((m) => m.email), ...members.map((m) => m.email)];
651
679
 
652
680
  const projects = Array.from({ length: GENERATED.projects }, (_, i) => ({
653
681
  id: `proj_g_${String(i + 1).padStart(2, "0")}`,
@@ -21,6 +21,7 @@ const FEATURES_ROOT = `${UI_ROOT}/features`;
21
21
  * already covers it; the list makes coverage explicit in CI output.
22
22
  */
23
23
  export const CONSOLE_UI_FEATURE_DIRS = [
24
+ "access",
24
25
  "auth",
25
26
  "flows",
26
27
  "not-found",
@@ -19,6 +19,8 @@ export interface GatePolicyContext {
19
19
  readonly userId: string | null;
20
20
  readonly scopes: ReadonlySet<string>;
21
21
  readonly verified?: boolean;
22
+ /** Authenticating API key id when the principal is a key; otherwise omitted. */
23
+ readonly apiKeyId?: string | null;
22
24
  };
23
25
  /** Operator-plane principal (`fx.operator`). */
24
26
  readonly operator: {
@@ -63,7 +63,7 @@ export function tier1KeysForReads(
63
63
  * @param effects - Write flow effects
64
64
  */
65
65
  export function resourcesTouchedByWrites(effects: Effects): ResourceRef[] {
66
- return [...(effects.writes ?? [])];
66
+ return (effects.writes ?? []).filter((r): r is ResourceRef => isStoreResourceRef(r));
67
67
  }
68
68
 
69
69
  /**
@@ -239,6 +239,13 @@ export interface StoreRuntime {
239
239
  * @param ref - `kv:name`
240
240
  */
241
241
  kvNamespace(ref: ResourceRef): Promise<KvNamespace>;
242
+ /**
243
+ * Shared primary SQL connection (`sharedSqlConn`).
244
+ *
245
+ * Host auth persist (`oke_api_keys`) and durable KV borrow this same
246
+ * connection — never a second open against `store.sql()` / `DATABASE_URL`.
247
+ */
248
+ primarySql(): Promise<SqlConnection | undefined>;
242
249
  /**
243
250
  * Register a declaration (usually done at module load).
244
251
  *
@@ -545,6 +552,13 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
545
552
  if (!ns) throw new Error(`Unknown kv ref: ${ref}`);
546
553
  return ns;
547
554
  },
555
+ async primarySql() {
556
+ const sqlDriver = options.drivers.sql;
557
+ if (!sqlDriver) return undefined;
558
+ const firstSql = Object.values(options.sql ?? [])[0];
559
+ const url = options.sqlUrl ?? firstSql?.primary.url;
560
+ return sharedSqlConn(sqlDriver, "primary", { url });
561
+ },
548
562
  onWriteEffects(effects) {
549
563
  return cache.invalidateFromEffects(effects);
550
564
  },