@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.
- package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
- package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
- package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
- package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
- package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
- package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
- package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
- package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
- package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
- package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
- package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
- package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
- package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
- package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
- package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
- package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
- package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
- package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
- package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +18 -15
- package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
- package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
- package/dist/embed/event-BvRbkLjD.js +46 -0
- package/dist/embed/event-BvRbkLjD.js.map +1 -0
- package/dist/embed/index-0hA141D2.js +85 -0
- package/dist/embed/index-0hA141D2.js.map +1 -0
- package/dist/embed/index-BVxMkfHe.js +8 -0
- package/dist/embed/index-BVxMkfHe.js.map +1 -0
- package/dist/embed/index-BY6fc1oQ.js +856 -0
- package/dist/embed/index-BY6fc1oQ.js.map +1 -0
- package/dist/embed/index-client-BHUUkI8H.js +3304 -0
- package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
- package/dist/embed/window-ZR8_mM8o.js +1889 -0
- package/dist/embed/window-ZR8_mM8o.js.map +1 -0
- package/package.json +7 -3
- package/src/app.css +86 -20
- package/src/lib/AnalyticsView.svelte +209 -0
- package/src/lib/AskView.svelte +255 -0
- package/src/lib/ClusterView.svelte +415 -95
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/ConnectDropdown.svelte +60 -3
- package/src/lib/DevConsole.svelte +155 -0
- package/src/lib/ErrorBoundary.fixture.svelte +11 -0
- package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
- package/src/lib/ErrorBoundary.svelte +78 -0
- package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
- package/src/lib/GlobalProgressBar.svelte +9 -0
- package/src/lib/LoadingOverlay.svelte +109 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/StatusBar.svelte +19 -1
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +136 -5
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +167 -4
- package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
- package/src/lib/embed/index.ts +34 -1
- package/src/lib/index.ts +10 -0
- package/src/lib/loading.svelte.ts +142 -0
- package/src/lib/permission-gate.ts +120 -0
- package/src/lib/policies.ts +136 -0
- package/src/lib/query-tabs.svelte.ts +132 -53
- package/src/lib/reddb/analytics.ts +208 -0
- package/src/lib/reddb/ask.ts +123 -0
- package/src/lib/reddb/client.ts +292 -73
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/desktop-error.ts +89 -0
- package/src/lib/reddb/dev-console.ts +186 -0
- package/src/lib/reddb/index.ts +5 -0
- package/src/lib/reddb/local-url-provider.ts +28 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/src/lib/tabs.svelte.ts +5 -0
- package/src/lib/workspace-session.ts +81 -0
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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>
|
package/src/lib/embed/index.ts
CHANGED
|
@@ -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
|
+
}
|