@reddb-io/ui 0.3.0 → 0.3.2

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 (121) hide show
  1. package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
  5. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  6. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  7. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  8. package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
  9. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  10. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  11. package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
  12. package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
  13. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  14. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  15. package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
  16. package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
  17. package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
  18. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  19. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  20. package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
  21. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  22. package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
  23. package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
  24. package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
  25. package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
  26. package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
  27. package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
  28. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  29. package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
  30. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  31. package/build/_app/version.json +1 -1
  32. package/build/index.html +18 -15
  33. package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
  34. package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
  35. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
  36. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
  37. package/dist/embed/event-BvRbkLjD.js +46 -0
  38. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  39. package/dist/embed/index-0hA141D2.js +85 -0
  40. package/dist/embed/index-0hA141D2.js.map +1 -0
  41. package/dist/embed/index-BVxMkfHe.js +8 -0
  42. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  43. package/dist/embed/index-BY6fc1oQ.js +856 -0
  44. package/dist/embed/index-BY6fc1oQ.js.map +1 -0
  45. package/dist/embed/index-client-BHUUkI8H.js +3304 -0
  46. package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
  47. package/dist/embed/index.js +1 -1
  48. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
  49. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
  50. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  51. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  52. package/package.json +7 -3
  53. package/src/app.css +86 -20
  54. package/src/lib/AnalyticsView.svelte +209 -0
  55. package/src/lib/AskView.svelte +255 -0
  56. package/src/lib/ClusterView.svelte +415 -95
  57. package/src/lib/CommandPalette.svelte +2 -0
  58. package/src/lib/ConnectDropdown.svelte +60 -3
  59. package/src/lib/DevConsole.svelte +155 -0
  60. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  61. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  62. package/src/lib/ErrorBoundary.svelte +78 -0
  63. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  64. package/src/lib/GlobalProgressBar.svelte +9 -0
  65. package/src/lib/LoadingOverlay.svelte +109 -0
  66. package/src/lib/MetricChart.svelte +85 -0
  67. package/src/lib/QueryEditor.svelte +48 -7
  68. package/src/lib/ResultsPane.svelte +76 -8
  69. package/src/lib/SchemaTree.svelte +25 -1
  70. package/src/lib/SecurityView.svelte +176 -65
  71. package/src/lib/SettingsView.svelte +1111 -254
  72. package/src/lib/StatusBar.svelte +19 -1
  73. package/src/lib/Topbar.svelte +29 -1
  74. package/src/lib/Workspace.svelte +136 -5
  75. package/src/lib/activity.svelte.ts +44 -35
  76. package/src/lib/collection-preview.ts +14 -0
  77. package/src/lib/connections.svelte.ts +167 -4
  78. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  79. package/src/lib/embed/index.ts +34 -1
  80. package/src/lib/index.ts +10 -0
  81. package/src/lib/loading.svelte.ts +142 -0
  82. package/src/lib/permission-gate.ts +120 -0
  83. package/src/lib/policies.ts +136 -0
  84. package/src/lib/query-tabs.svelte.ts +132 -53
  85. package/src/lib/reddb/analytics.ts +208 -0
  86. package/src/lib/reddb/ask.ts +123 -0
  87. package/src/lib/reddb/client.ts +292 -73
  88. package/src/lib/reddb/cluster-status.ts +306 -0
  89. package/src/lib/reddb/desktop-error.ts +89 -0
  90. package/src/lib/reddb/dev-console.ts +186 -0
  91. package/src/lib/reddb/index.ts +5 -0
  92. package/src/lib/reddb/local-url-provider.ts +28 -1
  93. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  94. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  95. package/src/lib/router.svelte.ts +12 -0
  96. package/src/lib/settings-authoring-client.ts +336 -0
  97. package/src/lib/settings-sections.ts +292 -196
  98. package/src/lib/tabs.svelte.ts +5 -0
  99. package/src/lib/workspace-session.ts +81 -0
  100. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  101. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  102. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  103. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  104. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  105. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  106. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  107. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  108. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  109. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  110. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  111. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  112. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  113. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  114. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  115. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  116. package/dist/embed/index-DGhgtnaK.js +0 -577
  117. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  118. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  119. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  120. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  121. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { BitsConfig } from 'bits-ui'
3
3
  import Workspace from '../Workspace.svelte'
4
+ import ErrorBoundary from '../ErrorBoundary.svelte'
4
5
  import type { ConnectionProvider } from '../reddb'
5
6
  import type { Theme } from '../theme.svelte'
6
7
 
@@ -20,12 +21,16 @@
20
21
  </script>
21
22
 
22
23
  <BitsConfig defaultPortalTo={portalTarget}>
23
- <Workspace
24
- {connectionProvider}
25
- {initialRoute}
26
- showConnect={false}
27
- themeTarget={shadowHost}
28
- persistTheme={false}
29
- {initialTheme}
30
- />
24
+ <!-- Black-screen shield (#126): keep an embed render crash inside the boundary
25
+ rather than blanking the host's mount point. -->
26
+ <ErrorBoundary>
27
+ <Workspace
28
+ {connectionProvider}
29
+ {initialRoute}
30
+ showConnect={false}
31
+ themeTarget={shadowHost}
32
+ persistTheme={false}
33
+ {initialTheme}
34
+ />
35
+ </ErrorBoundary>
31
36
  </BitsConfig>
@@ -1,6 +1,7 @@
1
1
  import appCss from "../../app.css?inline";
2
2
  import type { Component } from "svelte";
3
- import type { ConnectionProvider } from "../reddb";
3
+ import type { ConnectionProvider, ConsoleSink } from "../reddb";
4
+ import { devConsole } from "../reddb/dev-console";
4
5
  import type { Theme } from "../theme.svelte";
5
6
  import { RED_UI_VERSION, readBuildInfo } from "../build-info";
6
7
 
@@ -18,6 +19,9 @@ export {
18
19
  RedClient,
19
20
  type Connection,
20
21
  type ConnectionProvider,
22
+ type ConsoleEntry,
23
+ type ConsoleEntryKind,
24
+ type ConsoleSink,
21
25
  type InjectedClientOptions,
22
26
  type RedClientOptions,
23
27
  } from "../reddb";
@@ -27,6 +31,14 @@ export interface RedUiEmbedOptions {
27
31
  initialRoute?: string;
28
32
  theme?: Theme;
29
33
  shadowRoot?: ShadowRootInit;
34
+ /**
35
+ * Host tap on red-ui's developer console. When set, every query/HTTP
36
+ * round-trip the embedded Core records — verb, target, duration, row count,
37
+ * sanitized payload — is also delivered to the host, so a host app (e.g.
38
+ * red-request) can mirror red-ui's RQL/SQL traffic into its own developer
39
+ * bar. Entries are already secret-redacted before they reach the sink.
40
+ */
41
+ consoleSink?: ConsoleSink;
30
42
  }
31
43
 
32
44
  export interface RedUiEmbedHandle {
@@ -167,11 +179,16 @@ export async function mountRedUi(
167
179
  },
168
180
  });
169
181
 
182
+ const removeSink = opts.consoleSink
183
+ ? devConsole.addSink(opts.consoleSink)
184
+ : undefined;
185
+
170
186
  return {
171
187
  host,
172
188
  shadowRoot,
173
189
  portalTarget,
174
190
  destroy: () => {
191
+ removeSink?.();
175
192
  void unmount(instance);
176
193
  shadowRoot.textContent = "";
177
194
  },
@@ -180,6 +197,7 @@ export async function mountRedUi(
180
197
 
181
198
  export class RedUiElement extends HTMLElement {
182
199
  #connectionProvider: ConnectionProvider | null = null;
200
+ #consoleSink: ConsoleSink | null = null;
183
201
  initialRoute: string | undefined;
184
202
  theme: Theme | undefined;
185
203
  #handle: RedUiEmbedHandle | null = null;
@@ -201,6 +219,20 @@ export class RedUiElement extends HTMLElement {
201
219
  if (this.isConnected) this.#mounting = this.#mount();
202
220
  }
203
221
 
222
+ get consoleSink(): ConsoleSink | null {
223
+ return this.#consoleSink;
224
+ }
225
+
226
+ /**
227
+ * Host tap on the embedded Core's developer console (see
228
+ * `RedUiEmbedOptions.consoleSink`). Setting it after mount remounts, like
229
+ * `connectionProvider` — hosts normally assign both before attach.
230
+ */
231
+ set consoleSink(sink: ConsoleSink | null) {
232
+ this.#consoleSink = sink;
233
+ if (this.isConnected) this.#mounting = this.#mount();
234
+ }
235
+
204
236
  connectedCallback() {
205
237
  if (!this.hasAttribute("version"))
206
238
  this.setAttribute("version", RED_UI_VERSION);
@@ -220,6 +252,7 @@ export class RedUiElement extends HTMLElement {
220
252
  connectionProvider: this.#connectionProvider,
221
253
  initialRoute: this.initialRoute,
222
254
  theme: this.theme,
255
+ consoleSink: this.#consoleSink ?? undefined,
223
256
  });
224
257
  }
225
258
  }
package/src/lib/index.ts CHANGED
@@ -7,7 +7,15 @@ export { default as CollectionHistory } from "./CollectionHistory.svelte";
7
7
  export { default as CommandPalette } from "./CommandPalette.svelte";
8
8
  export { default as ConnectDropdown } from "./ConnectDropdown.svelte";
9
9
  export { default as EmptyState } from "./EmptyState.svelte";
10
+ export { default as GlobalProgressBar } from "./GlobalProgressBar.svelte";
10
11
  export { default as LiveChanges } from "./LiveChanges.svelte";
12
+ export { default as LoadingOverlay } from "./LoadingOverlay.svelte";
13
+ export {
14
+ loading,
15
+ formatElapsed,
16
+ type LoadingStep,
17
+ type LoadingStepStatus,
18
+ } from "./loading.svelte";
11
19
  export { default as MasterPasswordDialog } from "./MasterPasswordDialog.svelte";
12
20
  export { default as PageHeader } from "./PageHeader.svelte";
13
21
  export { default as PendingChangesPanel } from "./PendingChangesPanel.svelte";
@@ -35,6 +43,8 @@ export * from "./collection-catalog";
35
43
  export * from "./collection-pages";
36
44
  export * from "./router.svelte";
37
45
  export * from "./settings-sections";
46
+ export * from "./settings-authoring-client";
47
+ export * from "./permission-gate";
38
48
  export * from "./cn";
39
49
  export * from "./connections.svelte";
40
50
  export * from "./shortcuts";
@@ -0,0 +1,142 @@
1
+ // Connect/boot loading model (#127). Slow opens (sidecar spawn, readiness,
2
+ // handshake, initial schema/stats fetch) get real, timestamped feedback: a
3
+ // started-at, an ordered log of steps (each with a label, optional detail, and
4
+ // its own start time + outcome), and a "current" step that stays on screen —
5
+ // with its elapsed time — while it's still running (a stalled step reads AS
6
+ // stalled). Live-or-empty applies: steps are appended by the actual connect
7
+ // sequence, never synthesized to fake progress.
8
+ //
9
+ // `$state` is compiled here (`.svelte.ts`), so the model is reactive and the
10
+ // overlay re-renders as steps land. Writes are wrapped in `untrack()` because
11
+ // the connect sequence commonly runs from inside a Svelte `$effect` (the
12
+ // layout's auto-refresh); an untracked write keeps our own mutations from
13
+ // re-firing that effect.
14
+
15
+ import { untrack } from "svelte";
16
+
17
+ export type LoadingStepStatus = "active" | "done" | "error";
18
+
19
+ export interface LoadingStep {
20
+ id: number;
21
+ label: string;
22
+ detail?: string;
23
+ /** Absolute wall-clock time the step was appended. */
24
+ startedAt: number;
25
+ /** Wall-clock duration once the step resolves; null while still active. */
26
+ durationMs: number | null;
27
+ status: LoadingStepStatus;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Format a millisecond elapsed value for the overlay badge and per-step timing.
33
+ * Sub-second reads as fractional seconds ("0.4s"); longer as one-decimal
34
+ * seconds ("12.3s"). Kept pure so the render is unit-testable without a clock.
35
+ */
36
+ export function formatElapsed(ms: number): string {
37
+ const clamped = Math.max(0, ms);
38
+ return `${(clamped / 1000).toFixed(1)}s`;
39
+ }
40
+
41
+ class LoadingStore {
42
+ /** Wall-clock time the current open began; null when nothing is opening. */
43
+ startedAt = $state<number | null>(null);
44
+ /** Headline for what's being opened, e.g. "Opening Docker primary". */
45
+ label = $state<string | null>(null);
46
+ /** Ordered, timestamped step log for the current open. */
47
+ steps = $state<LoadingStep[]>([]);
48
+ private nextId = 1;
49
+
50
+ /** True while an open is in progress. Gates the overlay. */
51
+ get active(): boolean {
52
+ return this.startedAt !== null;
53
+ }
54
+
55
+ /** The step currently on screen — the last appended one, if any. */
56
+ get current(): LoadingStep | null {
57
+ return this.steps.length ? this.steps[this.steps.length - 1] : null;
58
+ }
59
+
60
+ /** Elapsed time since the open began, for the ticking badge. */
61
+ elapsedMs(now = Date.now()): number {
62
+ return this.startedAt === null ? 0 : Math.max(0, now - this.startedAt);
63
+ }
64
+
65
+ /**
66
+ * Begin a fresh open. Resets the log and stamps the start. Any subsequent
67
+ * `step()` calls append to this open until `succeed`/`fail` finishes it.
68
+ */
69
+ begin(label: string, now = Date.now()): void {
70
+ untrack(() => {
71
+ this.startedAt = now;
72
+ this.label = label;
73
+ this.steps = [];
74
+ this.nextId = 1;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Append a step and mark it the current (active) one, closing out the
80
+ * previous active step as done. No-op if no open is in progress — steps are
81
+ * only ever real work inside an open.
82
+ */
83
+ step(label: string, detail?: string, now = Date.now()): void {
84
+ untrack(() => {
85
+ if (this.startedAt === null) return;
86
+ const closed = this.steps.map((s) =>
87
+ s.status === "active"
88
+ ? { ...s, status: "done" as const, durationMs: now - s.startedAt }
89
+ : s
90
+ );
91
+ this.steps = [
92
+ ...closed,
93
+ {
94
+ id: this.nextId++,
95
+ label,
96
+ ...(detail ? { detail } : {}),
97
+ startedAt: now,
98
+ durationMs: null,
99
+ status: "active",
100
+ },
101
+ ];
102
+ });
103
+ }
104
+
105
+ /** Finish the open successfully: close the active step and hide the overlay. */
106
+ succeed(now = Date.now()): void {
107
+ untrack(() => {
108
+ this.#closeActive(now);
109
+ this.startedAt = null;
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Finish the open with a failure: mark the active step errored and hide the
115
+ * overlay (the Connect flow surfaces the error separately).
116
+ */
117
+ fail(error?: string, now = Date.now()): void {
118
+ untrack(() => {
119
+ this.steps = this.steps.map((s) =>
120
+ s.status === "active"
121
+ ? {
122
+ ...s,
123
+ status: "error" as const,
124
+ durationMs: now - s.startedAt,
125
+ ...(error ? { error } : {}),
126
+ }
127
+ : s
128
+ );
129
+ this.startedAt = null;
130
+ });
131
+ }
132
+
133
+ #closeActive(now: number): void {
134
+ this.steps = this.steps.map((s) =>
135
+ s.status === "active"
136
+ ? { ...s, status: "done" as const, durationMs: now - s.startedAt }
137
+ : s
138
+ );
139
+ }
140
+ }
141
+
142
+ export const loading = new LoadingStore();
@@ -0,0 +1,120 @@
1
+ export interface PermissionResource {
2
+ kind: string;
3
+ name: string;
4
+ tenant?: string;
5
+ }
6
+
7
+ export interface PermissionCheck {
8
+ action: string;
9
+ resource: PermissionResource;
10
+ current_tenant?: string;
11
+ }
12
+
13
+ export interface PermissionDecision extends PermissionCheck {
14
+ allowed: boolean;
15
+ reason?: string;
16
+ }
17
+
18
+ export interface PermissionTransport {
19
+ authCan(checks: PermissionCheck[]): Promise<PermissionDecision[]>;
20
+ }
21
+
22
+ function keyFor(check: PermissionCheck): string {
23
+ const tenant = check.resource.tenant ?? "";
24
+ const currentTenant = check.current_tenant ?? "";
25
+ return [
26
+ check.action,
27
+ check.resource.kind,
28
+ check.resource.name,
29
+ tenant,
30
+ currentTenant,
31
+ ].join("\u001f");
32
+ }
33
+
34
+ function deny(check: PermissionCheck, reason: string): PermissionDecision {
35
+ return { ...check, allowed: false, reason };
36
+ }
37
+
38
+ export class PermissionGate {
39
+ readonly #transport: PermissionTransport;
40
+ readonly #cache = new Map<string, PermissionDecision>();
41
+ readonly #inFlight = new Map<string, Promise<PermissionDecision>>();
42
+
43
+ constructor(transport: PermissionTransport) {
44
+ this.#transport = transport;
45
+ }
46
+
47
+ cachedDecision(check: PermissionCheck): PermissionDecision | null {
48
+ return this.#cache.get(keyFor(check)) ?? null;
49
+ }
50
+
51
+ cachedCan(check: PermissionCheck): boolean {
52
+ return this.cachedDecision(check)?.allowed === true;
53
+ }
54
+
55
+ async decision(check: PermissionCheck): Promise<PermissionDecision> {
56
+ await this.preload([check]);
57
+ return (
58
+ this.cachedDecision(check) ??
59
+ deny(check, "auth.can did not return a decision")
60
+ );
61
+ }
62
+
63
+ async can(check: PermissionCheck): Promise<boolean> {
64
+ return (await this.decision(check)).allowed;
65
+ }
66
+
67
+ async preload(checks: readonly PermissionCheck[]): Promise<void> {
68
+ const missing = checks.filter((check) => !this.#cache.has(keyFor(check)));
69
+ if (missing.length === 0) return;
70
+
71
+ const unique = new Map<string, PermissionCheck>();
72
+ for (const check of missing) {
73
+ const key = keyFor(check);
74
+ if (!this.#inFlight.has(key)) unique.set(key, check);
75
+ }
76
+
77
+ if (unique.size > 0) {
78
+ const batch = [...unique.values()];
79
+ const batchPromise = this.#transport
80
+ .authCan(batch)
81
+ .then((results) => {
82
+ const byKey = new Map(
83
+ results.map((result) => [keyFor(result), result] as const)
84
+ );
85
+ for (const check of batch) {
86
+ const key = keyFor(check);
87
+ this.#cache.set(
88
+ key,
89
+ byKey.get(key) ?? deny(check, "auth.can omitted this check")
90
+ );
91
+ }
92
+ })
93
+ .catch((e) => {
94
+ const reason = (e as Error).message || "auth.can failed";
95
+ for (const check of batch) {
96
+ this.#cache.set(keyFor(check), deny(check, reason));
97
+ }
98
+ })
99
+ .finally(() => {
100
+ for (const check of batch) this.#inFlight.delete(keyFor(check));
101
+ });
102
+
103
+ for (const check of batch) {
104
+ const key = keyFor(check);
105
+ this.#inFlight.set(
106
+ key,
107
+ batchPromise.then(
108
+ () =>
109
+ this.#cache.get(key) ??
110
+ deny(check, "auth.can did not return a decision")
111
+ )
112
+ );
113
+ }
114
+ }
115
+
116
+ await Promise.all(
117
+ missing.map((check) => this.#inFlight.get(keyFor(check)))
118
+ );
119
+ }
120
+ }
@@ -0,0 +1,136 @@
1
+ import type { AuthPolicy } from "#reddb";
2
+ import type {
3
+ PermissionCheck,
4
+ PermissionDecision,
5
+ PermissionResource,
6
+ } from "./permission-gate";
7
+
8
+ export const POLICY_READ_CHECK: PermissionCheck = {
9
+ action: "policy:read",
10
+ resource: { kind: "policy", name: "*" },
11
+ };
12
+
13
+ export interface CapabilityMatrixRow {
14
+ id: string;
15
+ action: string;
16
+ resource: PermissionResource;
17
+ resourceLabel: string;
18
+ allowed: boolean;
19
+ reason?: string;
20
+ effects: string[];
21
+ principals: string[];
22
+ policies: string[];
23
+ }
24
+
25
+ function matrixKey(check: PermissionCheck): string {
26
+ return [
27
+ check.action,
28
+ check.resource.kind,
29
+ check.resource.name,
30
+ check.resource.tenant ?? "",
31
+ check.current_tenant ?? "",
32
+ ].join("\u001f");
33
+ }
34
+
35
+ function resourceLabel(resource: PermissionResource): string {
36
+ const base = `${resource.kind}:${resource.name}`;
37
+ return resource.tenant ? `${base} @ ${resource.tenant}` : base;
38
+ }
39
+
40
+ export function parsePolicyResource(
41
+ value: string | undefined,
42
+ tenant?: string
43
+ ): PermissionResource {
44
+ const trimmed = value?.trim();
45
+ if (!trimmed || trimmed === "*")
46
+ return { kind: "resource", name: "*", tenant };
47
+
48
+ const separator = trimmed.includes(":")
49
+ ? ":"
50
+ : trimmed.includes("/")
51
+ ? "/"
52
+ : null;
53
+ if (!separator) return { kind: "resource", name: trimmed, tenant };
54
+
55
+ const [kind, ...rest] = trimmed.split(separator);
56
+ const name = rest.join(separator) || "*";
57
+ return {
58
+ kind: kind || "resource",
59
+ name,
60
+ tenant,
61
+ };
62
+ }
63
+
64
+ export function capabilityMatrixChecksFromPolicies(
65
+ policies: readonly AuthPolicy[]
66
+ ): PermissionCheck[] {
67
+ const checks = [POLICY_READ_CHECK];
68
+ const seen = new Set(checks.map(matrixKey));
69
+
70
+ for (const policy of policies) {
71
+ const check: PermissionCheck = {
72
+ action: policy.action?.trim() || "*",
73
+ resource: parsePolicyResource(policy.resource, policy.tenant),
74
+ };
75
+ const key = matrixKey(check);
76
+ if (seen.has(key)) continue;
77
+ seen.add(key);
78
+ checks.push(check);
79
+ }
80
+
81
+ return checks;
82
+ }
83
+
84
+ export function assembleCapabilityMatrix(
85
+ policies: readonly AuthPolicy[],
86
+ decisions: readonly PermissionDecision[]
87
+ ): CapabilityMatrixRow[] {
88
+ const policyChecks = capabilityMatrixChecksFromPolicies(policies);
89
+ const decisionsByKey = new Map(
90
+ decisions.map((decision) => [matrixKey(decision), decision] as const)
91
+ );
92
+
93
+ return policyChecks.map((check) => {
94
+ const decision = decisionsByKey.get(matrixKey(check));
95
+ const related = policies.filter((policy) => {
96
+ const policyCheck: PermissionCheck = {
97
+ action: policy.action?.trim() || "*",
98
+ resource: parsePolicyResource(policy.resource, policy.tenant),
99
+ };
100
+ return matrixKey(policyCheck) === matrixKey(check);
101
+ });
102
+
103
+ return {
104
+ id: matrixKey(check),
105
+ action: check.action,
106
+ resource: check.resource,
107
+ resourceLabel: resourceLabel(check.resource),
108
+ allowed: decision?.allowed === true,
109
+ reason: decision?.reason,
110
+ effects: [
111
+ ...new Set(
112
+ related
113
+ .map((policy) => policy.effect)
114
+ .filter((effect): effect is string => !!effect)
115
+ ),
116
+ ],
117
+ principals: [
118
+ ...new Set(
119
+ related
120
+ .map((policy) => policy.principal)
121
+ .filter((principal): principal is string => !!principal)
122
+ ),
123
+ ],
124
+ policies: [
125
+ ...new Set(
126
+ related
127
+ .map(
128
+ (policy, index) =>
129
+ policy.name ?? policy.id ?? `policy ${index + 1}`
130
+ )
131
+ .filter((name): name is string => !!name)
132
+ ),
133
+ ],
134
+ };
135
+ });
136
+ }