@reddb-io/ui 0.3.0 → 0.3.1

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 (96) hide show
  1. package/build/_app/immutable/assets/0.DgbuWrJy.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/85-Fh5Zp.js +404 -0
  5. package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
  6. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  7. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  8. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  9. package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
  10. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  11. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  12. package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
  13. package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
  14. package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
  15. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  16. package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
  17. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  18. package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
  19. package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
  20. package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
  21. package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
  22. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
  23. package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
  24. package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
  25. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  26. package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
  27. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  28. package/build/_app/version.json +1 -1
  29. package/build/index.html +18 -15
  30. package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
  31. package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
  32. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
  33. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
  34. package/dist/embed/index-0hA141D2.js +85 -0
  35. package/dist/embed/index-0hA141D2.js.map +1 -0
  36. package/dist/embed/index-client-BeiP-va5.js +3303 -0
  37. package/dist/embed/index-client-BeiP-va5.js.map +1 -0
  38. package/dist/embed/index-w1Qo_uW0.js +677 -0
  39. package/dist/embed/index-w1Qo_uW0.js.map +1 -0
  40. package/dist/embed/index.js +1 -1
  41. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
  43. package/package.json +5 -3
  44. package/src/app.css +86 -20
  45. package/src/lib/AnalyticsView.svelte +209 -0
  46. package/src/lib/AskView.svelte +255 -0
  47. package/src/lib/ClusterView.svelte +415 -95
  48. package/src/lib/CommandPalette.svelte +2 -0
  49. package/src/lib/GlobalProgressBar.svelte +9 -0
  50. package/src/lib/MetricChart.svelte +85 -0
  51. package/src/lib/QueryEditor.svelte +48 -7
  52. package/src/lib/ResultsPane.svelte +76 -8
  53. package/src/lib/SchemaTree.svelte +25 -1
  54. package/src/lib/SecurityView.svelte +176 -65
  55. package/src/lib/SettingsView.svelte +1111 -254
  56. package/src/lib/Topbar.svelte +29 -1
  57. package/src/lib/Workspace.svelte +18 -3
  58. package/src/lib/activity.svelte.ts +44 -35
  59. package/src/lib/collection-preview.ts +14 -0
  60. package/src/lib/connections.svelte.ts +73 -4
  61. package/src/lib/index.ts +3 -0
  62. package/src/lib/permission-gate.ts +120 -0
  63. package/src/lib/policies.ts +136 -0
  64. package/src/lib/query-tabs.svelte.ts +132 -53
  65. package/src/lib/reddb/analytics.ts +208 -0
  66. package/src/lib/reddb/ask.ts +123 -0
  67. package/src/lib/reddb/client.ts +159 -58
  68. package/src/lib/reddb/cluster-status.ts +306 -0
  69. package/src/lib/reddb/index.ts +3 -0
  70. package/src/lib/reddb/local-url-provider.ts +32 -1
  71. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  72. package/src/lib/router.svelte.ts +12 -0
  73. package/src/lib/settings-authoring-client.ts +336 -0
  74. package/src/lib/settings-sections.ts +292 -196
  75. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  76. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  77. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  78. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  81. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  82. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  83. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  84. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  85. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  86. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  87. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  88. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  90. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  91. package/dist/embed/index-DGhgtnaK.js +0 -577
  92. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  93. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  94. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  95. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  96. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -2,7 +2,7 @@
2
2
  import { Kbd } from '@reddb-io/ui-kit'
3
3
  import ConnectDropdown from './ConnectDropdown.svelte'
4
4
  import ActivityIndicator from './ActivityIndicator.svelte'
5
- import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2 } from 'lucide-svelte'
5
+ import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2, LineChart, Sparkles } from 'lucide-svelte'
6
6
  import { theme } from './theme.svelte'
7
7
  import { useRouter } from './router.svelte'
8
8
 
@@ -14,10 +14,12 @@
14
14
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true }))
15
15
  }
16
16
 
17
+ const onAsk = $derived(router.view === 'ask')
17
18
  const onQuery = $derived(router.view === 'query')
18
19
  const onCollections = $derived(router.view === 'collections')
19
20
  const onCluster = $derived(router.view === 'cluster')
20
21
  const onSecurity = $derived(router.view === 'security')
22
+ const onAnalytics = $derived(router.view === 'analytics')
21
23
  const onSettings = $derived(router.view === 'settings')
22
24
  </script>
23
25
 
@@ -38,6 +40,19 @@
38
40
  <div class="w-px h-[18px] bg-line-2"></div>
39
41
  {/if}
40
42
  <nav class="flex items-center gap-0.5">
43
+ <a
44
+ href={router.href({ view: 'ask' })}
45
+ onclick={(e) => router.go({ view: 'ask' }, e)}
46
+ title="Ask — grounded natural-language query"
47
+ aria-current={onAsk ? 'page' : undefined}
48
+ class={[
49
+ 'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
50
+ onAsk ? 'bg-bg-2 text-accent' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
51
+ ].join(' ')}
52
+ >
53
+ <Sparkles class="size-3.5" />
54
+ <span>Ask</span>
55
+ </a>
41
56
  <a
42
57
  href={router.href({ view: 'query' })}
43
58
  onclick={(e) => router.go({ view: 'query' }, e)}
@@ -90,6 +105,19 @@
90
105
  <Shield class="size-3.5" />
91
106
  <span>Security</span>
92
107
  </a>
108
+ <a
109
+ href={router.href({ view: 'analytics' })}
110
+ onclick={(e) => router.go({ view: 'analytics' }, e)}
111
+ title="Analytics"
112
+ aria-current={onAnalytics ? 'page' : undefined}
113
+ class={[
114
+ 'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
115
+ onAnalytics ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
116
+ ].join(' ')}
117
+ >
118
+ <LineChart class="size-3.5" />
119
+ <span>Analytics</span>
120
+ </a>
93
121
  <a
94
122
  href={router.href({ view: 'settings' })}
95
123
  onclick={(e) => router.go({ view: 'settings' }, e)}
@@ -6,6 +6,7 @@
6
6
  // The SvelteKit Surface wraps this same component with a Kit-backed router.
7
7
  import { onMount, untrack } from 'svelte'
8
8
  import Splash from './Splash.svelte'
9
+ import GlobalProgressBar from './GlobalProgressBar.svelte'
9
10
  import Topbar from './Topbar.svelte'
10
11
  import StatusBar from './StatusBar.svelte'
11
12
  import CommandPalette from './CommandPalette.svelte'
@@ -14,6 +15,7 @@
14
15
  import PendingChangesPanel from './PendingChangesPanel.svelte'
15
16
  import CollectionsView from './CollectionsView.svelte'
16
17
  import ClusterView from './ClusterView.svelte'
18
+ import AskView from './AskView.svelte'
17
19
  import SecurityView from './SecurityView.svelte'
18
20
  import SettingsView from './SettingsView.svelte'
19
21
  import Appearance from './Appearance.svelte'
@@ -112,7 +114,9 @@
112
114
  queryTabs.setExecutor(null)
113
115
  return
114
116
  }
115
- queryTabs.setExecutor((sql) => runQueryPreferStream(client, sql))
117
+ queryTabs.setExecutor((sql, options) =>
118
+ runQueryPreferStream(client, sql, options)
119
+ )
116
120
  pendingChanges.setExecutor(async (changes) =>
117
121
  Promise.all(
118
122
  changes.map(async (c): Promise<CommitOutcome> => {
@@ -236,14 +240,25 @@
236
240
  {#if !booted}
237
241
  <Splash onDone={() => (booted = true)} />
238
242
  {:else}
239
- <div class="app grid grid-rows-[44px_32px_1fr] h-screen">
243
+ <div class="app relative grid grid-rows-[44px_32px_1fr] h-screen">
244
+ <GlobalProgressBar />
240
245
  <Topbar showConnect={showConnect} />
241
246
  <StatusBar />
242
247
  <main class="relative overflow-hidden z-[1]">
243
- {#if router.view === 'cluster'}
248
+ {#if router.view === 'ask'}
249
+ <AskView />
250
+ {:else if router.view === 'cluster'}
244
251
  <ClusterView />
245
252
  {:else if router.view === 'security'}
246
253
  <SecurityView />
254
+ {:else if router.view === 'analytics'}
255
+ <!-- Lazy: LayerChart + d3 are heavy, so the charting bundle only
256
+ loads when the user actually opens Analytics. -->
257
+ {#await import('./AnalyticsView.svelte')}
258
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">loading analytics…</div>
259
+ {:then { default: AnalyticsView }}
260
+ <AnalyticsView />
261
+ {/await}
247
262
  {:else if router.view === 'settings'}
248
263
  <SettingsView />
249
264
  {:else if router.view === 'appearance'}
@@ -16,61 +16,65 @@
16
16
  // without untrack, the effect would track those as dependencies and our
17
17
  // own writes would re-fire it — infinite loop (`effect_update_depth_exceeded`).
18
18
 
19
- import { untrack } from 'svelte'
19
+ import { untrack } from "svelte";
20
20
 
21
21
  export interface ActivityEntry {
22
- id: number
23
- label: string
24
- startedAt: number
25
- durationMs: number | null
26
- status: 'pending' | 'ok' | 'error'
27
- error?: string
22
+ id: number;
23
+ label: string;
24
+ startedAt: number;
25
+ durationMs: number | null;
26
+ status: "pending" | "ok" | "error";
27
+ error?: string;
28
28
  }
29
29
 
30
- const MAX_LOG = 20
30
+ const MAX_LOG = 20;
31
31
 
32
32
  class ActivityStore {
33
- inflight = $state(0)
34
- log = $state<ActivityEntry[]>([])
35
- private nextId = 1
33
+ inflight = $state(0);
34
+ active = $derived(this.inflight > 0);
35
+ log = $state<ActivityEntry[]>([]);
36
+ private nextId = 1;
36
37
 
37
38
  async track<T>(label: string, op: () => Promise<T>): Promise<T> {
38
- const id = this.nextId++
39
+ const id = this.nextId++;
39
40
  const entry: ActivityEntry = {
40
41
  id,
41
42
  label,
42
43
  startedAt: Date.now(),
43
44
  durationMs: null,
44
- status: 'pending',
45
- }
45
+ status: "pending",
46
+ };
46
47
  untrack(() => {
47
- this.log = [entry, ...this.log].slice(0, MAX_LOG)
48
- this.inflight += 1
49
- })
48
+ this.log = [entry, ...this.log].slice(0, MAX_LOG);
49
+ this.inflight += 1;
50
+ });
50
51
  try {
51
- const result = await op()
52
+ const result = await op();
52
53
  untrack(() =>
53
- this.update(id, { durationMs: Date.now() - entry.startedAt, status: 'ok' }),
54
- )
55
- return result
54
+ this.update(id, {
55
+ durationMs: Date.now() - entry.startedAt,
56
+ status: "ok",
57
+ })
58
+ );
59
+ return result;
56
60
  } catch (err) {
57
61
  untrack(() =>
58
62
  this.update(id, {
59
63
  durationMs: Date.now() - entry.startedAt,
60
- status: 'error',
64
+ status: "error",
61
65
  error: (err as Error).message,
62
- }),
63
- )
64
- throw err
66
+ })
67
+ );
68
+ throw err;
65
69
  } finally {
66
70
  untrack(() => {
67
- this.inflight = Math.max(0, this.inflight - 1)
68
- })
71
+ this.inflight = Math.max(0, this.inflight - 1);
72
+ });
69
73
  }
70
74
  }
71
75
 
72
76
  private update(id: number, patch: Partial<ActivityEntry>) {
73
- this.log = this.log.map((e) => (e.id === id ? { ...e, ...patch } : e))
77
+ this.log = this.log.map((e) => (e.id === id ? { ...e, ...patch } : e));
74
78
  }
75
79
 
76
80
  /** Panic button for when the indicator gets stuck out of sync with
@@ -79,13 +83,18 @@ class ActivityStore {
79
83
  reset() {
80
84
  untrack(() => {
81
85
  this.log = this.log.map((e) =>
82
- e.status === 'pending'
83
- ? { ...e, status: 'error', error: 'reset by user', durationMs: Date.now() - e.startedAt }
84
- : e,
85
- )
86
- this.inflight = 0
87
- })
86
+ e.status === "pending"
87
+ ? {
88
+ ...e,
89
+ status: "error",
90
+ error: "reset by user",
91
+ durationMs: Date.now() - e.startedAt,
92
+ }
93
+ : e
94
+ );
95
+ this.inflight = 0;
96
+ });
88
97
  }
89
98
  }
90
99
 
91
- export const activity = new ActivityStore()
100
+ export const activity = new ActivityStore();
@@ -0,0 +1,14 @@
1
+ import type { Capability } from "./renderers/types";
2
+
3
+ export function safeCollectionName(collection: string): string {
4
+ return collection.replace(/[^A-Za-z0-9_./-]/g, "");
5
+ }
6
+
7
+ export function collectionPreviewQuery(
8
+ collection: string,
9
+ capability: Capability | undefined
10
+ ): string {
11
+ const safe = safeCollectionName(collection);
12
+ if (capability === "kv") return `LIST KV ${safe} LIMIT 200`;
13
+ return `SELECT * FROM ${safe} LIMIT 200`;
14
+ }
@@ -173,13 +173,75 @@ export function makeCustomConnection(input: string): ConnectionPreset {
173
173
  // Transport reachability is a Surface property (#34, ADR-0003): a Tauri shell
174
174
  // can open native unix/embedded connections the browser can't. Detect the
175
175
  // Surface here so the default provider declares the right reachable set.
176
- function surfaceTransports(): Transport[] {
177
- const tauri =
176
+ function isTauriSurface(): boolean {
177
+ return (
178
178
  typeof window !== "undefined" &&
179
- ("__TAURI_INTERNALS__" in window || "__TAURI__" in window);
180
- return [...(tauri ? DESKTOP_TRANSPORTS : BROWSER_TRANSPORTS)];
179
+ ("__TAURI_INTERNALS__" in window || "__TAURI__" in window)
180
+ );
181
+ }
182
+
183
+ function surfaceTransports(): Transport[] {
184
+ return [...(isTauriSurface() ? DESKTOP_TRANSPORTS : BROWSER_TRANSPORTS)];
181
185
  }
182
186
 
187
+ /**
188
+ * Open a `file://` connection by asking the Tauri shell to spawn a local
189
+ * file-backed reddb sidecar; returns the `http://127.0.0.1:<port>` URL the
190
+ * HTTP client then talks to.
191
+ *
192
+ * Tauri-ness is checked HERE, at call time — not when the provider is built.
193
+ * In `tauri dev` this module can evaluate before Tauri injects its globals, so
194
+ * gating the resolver at construction left it unwired and `file://` fell
195
+ * through to a raw fetch ("Load failed"). At connect time the IPC bridge is
196
+ * always present. The browser has no filesystem/process access, so we fail
197
+ * with a clear message there instead.
198
+ */
199
+ async function tauriOpenEmbedded(fileUrl: string): Promise<string> {
200
+ if (!isTauriSurface()) {
201
+ throw new Error("file:// databases can only be opened in the desktop app");
202
+ }
203
+ const path = fileUrl.replace(/^file:\/\//i, "");
204
+ const { invoke } = await import("@tauri-apps/api/core");
205
+ return invoke<string>("open_embedded", { path });
206
+ }
207
+
208
+ /**
209
+ * Desktop HTTP for loopback targets goes through Tauri's rust-side fetch.
210
+ *
211
+ * An embedded file-backed reddb (and the bundled `red` generally) emits no CORS
212
+ * headers, so a cross-origin webview fetch from the app/dev origin to
213
+ * `127.0.0.1:<port>` is blocked ("Load failed"). Tauri's HTTP plugin issues the
214
+ * request from Rust, bypassing the webview's same-origin policy. Only loopback
215
+ * targets are rerouted; remote servers keep the plain webview fetch. In the
216
+ * browser this is always the plain fetch.
217
+ */
218
+ const desktopFetch: typeof fetch = async (input, init) => {
219
+ const url =
220
+ typeof input === "string"
221
+ ? input
222
+ : input instanceof URL
223
+ ? input.href
224
+ : input.url;
225
+ const isLoopback =
226
+ /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/i.test(url);
227
+ if (isTauriSurface() && isLoopback) {
228
+ try {
229
+ const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
230
+ return await tauriFetch(
231
+ input as URL | Request | string,
232
+ init as RequestInit
233
+ );
234
+ } catch (e) {
235
+ // Surface the real reason — the http plugin can reject with a bare
236
+ // string/object that would otherwise read as "undefined" upstream.
237
+ const reason =
238
+ e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
239
+ throw new Error(`rust-side fetch failed for ${url}: ${reason}`);
240
+ }
241
+ }
242
+ return fetch(input, init);
243
+ };
244
+
183
245
  function isHandoffTokenConsumer(
184
246
  candidate: ConnectionProvider
185
247
  ): candidate is ConnectionProvider & HandoffTokenConsumer {
@@ -198,6 +260,13 @@ export const provider: ConnectionProvider & {
198
260
  presets: PRESETS,
199
261
  history: historyStore,
200
262
  transports: surfaceTransports(),
263
+ // Always wired: the resolver itself decides (at call time) whether it's on a
264
+ // Tauri Surface, so a not-yet-injected global at module load can't disable it.
265
+ embeddedResolver: tauriOpenEmbedded,
266
+ // Route loopback HTTP through Tauri's rust-side fetch (CORS bypass for the
267
+ // embedded/local reddb); plain webview fetch everywhere else.
268
+ clientFactory: (url, opts) =>
269
+ new RedClient(url, { ...opts, fetch: desktopFetch }),
201
270
  // Boot-params pre-configuration (#36, ADR-0005): a Surface (the MCP App)
202
271
  // seeds the endpoint + initial route in the app URL; the Core reads it
203
272
  // through the provider. Query tokens are ignored and hash tokens are not
package/src/lib/index.ts CHANGED
@@ -7,6 +7,7 @@ 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";
11
12
  export { default as MasterPasswordDialog } from "./MasterPasswordDialog.svelte";
12
13
  export { default as PageHeader } from "./PageHeader.svelte";
@@ -35,6 +36,8 @@ export * from "./collection-catalog";
35
36
  export * from "./collection-pages";
36
37
  export * from "./router.svelte";
37
38
  export * from "./settings-sections";
39
+ export * from "./settings-authoring-client";
40
+ export * from "./permission-gate";
38
41
  export * from "./cn";
39
42
  export * from "./connections.svelte";
40
43
  export * from "./shortcuts";
@@ -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
+ }