@reddb-io/ui 0.1.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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// LocalUrlProvider — the OSS implementation of ConnectionProvider. Wraps
|
|
2
|
+
// a list of well-known presets and a recent-history backing store. The
|
|
3
|
+
// browser usage backs history with localStorage; tests inject an
|
|
4
|
+
// in-memory store via the options bag.
|
|
5
|
+
|
|
6
|
+
import { RedClient, type RedClientOptions } from "./client";
|
|
7
|
+
import {
|
|
8
|
+
NotConnectedError,
|
|
9
|
+
UnknownConnectionError,
|
|
10
|
+
UnreachableConnectionError,
|
|
11
|
+
type ActiveConnection,
|
|
12
|
+
type Connection,
|
|
13
|
+
type ConnectionProvider,
|
|
14
|
+
type Identity,
|
|
15
|
+
} from "./connection-provider";
|
|
16
|
+
import { BROWSER_TRANSPORTS } from "./transports";
|
|
17
|
+
import type { Transport } from "./types";
|
|
18
|
+
import { hasBootEndpoint, hasBootRoute, type BootParams } from "./boot-params";
|
|
19
|
+
|
|
20
|
+
export interface HistoryEntry {
|
|
21
|
+
url: string;
|
|
22
|
+
label: string;
|
|
23
|
+
last_used: number;
|
|
24
|
+
rtt_ms?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HistoryStore {
|
|
28
|
+
load(): HistoryEntry[];
|
|
29
|
+
save(entries: HistoryEntry[]): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LocalUrlProviderOptions {
|
|
33
|
+
/** Built-in connections, surfaced first in `list()`. */
|
|
34
|
+
presets?: Connection[];
|
|
35
|
+
/** Backing store for the recent-URL history. Defaults to in-memory. */
|
|
36
|
+
history?: HistoryStore;
|
|
37
|
+
/** Hook for tests / Tauri to swap the HTTP client implementation. */
|
|
38
|
+
clientFactory?: (url: string, opts?: RedClientOptions) => RedClient;
|
|
39
|
+
/** Max history entries retained. */
|
|
40
|
+
historyMax?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Wire transports this Surface can reach (#34, ADR-0003). Defaults to the
|
|
43
|
+
* browser-reachable set (http/https + coerced red://); a Tauri Surface passes
|
|
44
|
+
* the desktop set so native unix/embedded connections are offered.
|
|
45
|
+
*/
|
|
46
|
+
transports?: Transport[];
|
|
47
|
+
/**
|
|
48
|
+
* Non-secret boot config seeded by the Surface (#36, ADR-0005) — endpoint +
|
|
49
|
+
* initial view from the app URL. Surfaced via `bootParams()` so the Core can
|
|
50
|
+
* auto-connect without the Connect flow. Tokens must never be placed here.
|
|
51
|
+
*/
|
|
52
|
+
bootParams?: BootParams;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HandoffTokenConsumer {
|
|
56
|
+
useHandoffToken(url: string, token: string): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HISTORY_MAX_DEFAULT = 8;
|
|
60
|
+
|
|
61
|
+
function inMemoryStore(): HistoryStore {
|
|
62
|
+
let entries: HistoryEntry[] = [];
|
|
63
|
+
return {
|
|
64
|
+
load: () => entries.slice(),
|
|
65
|
+
save: (next) => {
|
|
66
|
+
entries = next.slice();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function localStorageHistory(key: string): HistoryStore {
|
|
72
|
+
return {
|
|
73
|
+
load() {
|
|
74
|
+
if (typeof localStorage === "undefined") return [];
|
|
75
|
+
try {
|
|
76
|
+
const raw = localStorage.getItem(key);
|
|
77
|
+
if (!raw) return [];
|
|
78
|
+
const parsed = JSON.parse(raw) as HistoryEntry[];
|
|
79
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
save(entries) {
|
|
85
|
+
if (typeof localStorage === "undefined") return;
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(key, JSON.stringify(entries));
|
|
88
|
+
} catch {
|
|
89
|
+
/* quota or disabled — drop silently */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class LocalUrlProvider implements ConnectionProvider {
|
|
96
|
+
private readonly presets: Connection[];
|
|
97
|
+
private readonly history: HistoryStore;
|
|
98
|
+
private readonly clientFactory: (
|
|
99
|
+
url: string,
|
|
100
|
+
opts?: RedClientOptions
|
|
101
|
+
) => RedClient;
|
|
102
|
+
private readonly historyMax: number;
|
|
103
|
+
private readonly supportedTransports: Transport[];
|
|
104
|
+
private readonly boot: BootParams | null;
|
|
105
|
+
private readonly handoffTokens = new Map<string, string>();
|
|
106
|
+
private active: ActiveConnection | null = null;
|
|
107
|
+
|
|
108
|
+
constructor(opts: LocalUrlProviderOptions = {}) {
|
|
109
|
+
this.presets = opts.presets?.slice() ?? [];
|
|
110
|
+
this.history = opts.history ?? inMemoryStore();
|
|
111
|
+
this.historyMax = opts.historyMax ?? HISTORY_MAX_DEFAULT;
|
|
112
|
+
this.clientFactory =
|
|
113
|
+
opts.clientFactory ??
|
|
114
|
+
((url, clientOpts) => new RedClient(url, clientOpts));
|
|
115
|
+
this.supportedTransports = opts.transports?.slice() ?? [
|
|
116
|
+
...BROWSER_TRANSPORTS,
|
|
117
|
+
];
|
|
118
|
+
this.boot =
|
|
119
|
+
opts.bootParams &&
|
|
120
|
+
(hasBootEndpoint(opts.bootParams) || hasBootRoute(opts.bootParams))
|
|
121
|
+
? { ...opts.bootParams }
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
transports(): Transport[] {
|
|
126
|
+
return this.supportedTransports.slice();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
bootParams(): BootParams | null {
|
|
130
|
+
return this.boot ? { ...this.boot } : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
useHandoffToken(url: string, token: string): void {
|
|
134
|
+
const clean = token.trim();
|
|
135
|
+
if (!clean) return;
|
|
136
|
+
this.handoffTokens.set(this.handoffKey(url), clean);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async list(): Promise<Connection[]> {
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const out: Connection[] = [];
|
|
142
|
+
for (const p of this.presets) {
|
|
143
|
+
if (seen.has(p.url)) continue;
|
|
144
|
+
seen.add(p.url);
|
|
145
|
+
out.push(p);
|
|
146
|
+
}
|
|
147
|
+
for (const h of this.history.load()) {
|
|
148
|
+
if (seen.has(h.url)) continue;
|
|
149
|
+
seen.add(h.url);
|
|
150
|
+
out.push({ id: h.url, label: h.label, url: h.url, role: "primary" });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Connect by id. The id matches a preset id, a history URL, or a raw URL
|
|
157
|
+
* the caller wants to attempt ad-hoc — that last case is what makes this
|
|
158
|
+
* a *URL* provider rather than a registry. Unknown raw strings that
|
|
159
|
+
* don't look like URLs throw UnknownConnectionError.
|
|
160
|
+
*/
|
|
161
|
+
async connect(id: string): Promise<ActiveConnection> {
|
|
162
|
+
const target = this.resolve(id);
|
|
163
|
+
if (!target) throw new UnknownConnectionError(id);
|
|
164
|
+
|
|
165
|
+
const token = this.consumeHandoffToken(target.url);
|
|
166
|
+
const client = this.clientFactory(
|
|
167
|
+
target.url,
|
|
168
|
+
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
|
|
169
|
+
);
|
|
170
|
+
const ping = await client.ping();
|
|
171
|
+
if (!ping.ok) throw new UnreachableConnectionError(target.id, ping.error);
|
|
172
|
+
|
|
173
|
+
const active: ActiveConnection = {
|
|
174
|
+
connection: target,
|
|
175
|
+
client,
|
|
176
|
+
rtt_ms: ping.rtt_ms,
|
|
177
|
+
};
|
|
178
|
+
this.active = active;
|
|
179
|
+
this.record(target, ping.rtt_ms);
|
|
180
|
+
return active;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async whoami(): Promise<Identity> {
|
|
184
|
+
if (!this.active) throw new NotConnectedError();
|
|
185
|
+
const w = await this.active.client.whoami();
|
|
186
|
+
return {
|
|
187
|
+
authenticated: w.authenticated,
|
|
188
|
+
username: w.username,
|
|
189
|
+
role: w.role,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Test/UI helper: remove a URL from history. Not part of the contract. */
|
|
194
|
+
forget(url: string): void {
|
|
195
|
+
const next = this.history.load().filter((e) => e.url !== url);
|
|
196
|
+
this.history.save(next);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private resolve(id: string): Connection | null {
|
|
200
|
+
const preset = this.presets.find((p) => p.id === id);
|
|
201
|
+
if (preset) return preset;
|
|
202
|
+
|
|
203
|
+
const hit = this.history.load().find((e) => e.url === id);
|
|
204
|
+
if (hit)
|
|
205
|
+
return { id: hit.url, label: hit.label, url: hit.url, role: "primary" };
|
|
206
|
+
|
|
207
|
+
if (/^[a-z+]+:\/\//i.test(id)) {
|
|
208
|
+
let host = id;
|
|
209
|
+
try {
|
|
210
|
+
host = new URL(id).host || id;
|
|
211
|
+
} catch {
|
|
212
|
+
/* fall through */
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
label: host,
|
|
217
|
+
url: id,
|
|
218
|
+
role: "primary",
|
|
219
|
+
description: "Ad-hoc URL.",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private handoffKey(url: string): string {
|
|
227
|
+
return url.replace(/\/$/, "");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private consumeHandoffToken(url: string): string | null {
|
|
231
|
+
const key = this.handoffKey(url);
|
|
232
|
+
const token = this.handoffTokens.get(key) ?? null;
|
|
233
|
+
this.handoffTokens.delete(key);
|
|
234
|
+
return token;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private record(c: Connection, rtt_ms: number): void {
|
|
238
|
+
const existing = this.history.load().filter((e) => e.url !== c.url);
|
|
239
|
+
const next: HistoryEntry[] = [
|
|
240
|
+
{ url: c.url, label: c.label, last_used: Date.now(), rtt_ms },
|
|
241
|
+
...existing,
|
|
242
|
+
].slice(0, this.historyMax);
|
|
243
|
+
this.history.save(next);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EncryptedStore — deep module for credential-grade key/value storage.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations are shipped:
|
|
5
|
+
*
|
|
6
|
+
* - WebEncryptedStore: derives an AES-GCM key from a session-scoped
|
|
7
|
+
* master password (PBKDF2/SHA-256, 200k iterations) and writes
|
|
8
|
+
* {ciphertext, nonce, salt} to localStorage.
|
|
9
|
+
*
|
|
10
|
+
* - TauriEncryptedStore: defers to the OS keychain (Keychain on
|
|
11
|
+
* macOS, Credential Manager on Windows, libsecret on Linux) via
|
|
12
|
+
* a tiny set of `invoke` commands implemented in Rust.
|
|
13
|
+
*
|
|
14
|
+
* Both implementations share the same surface, the same error vocabulary,
|
|
15
|
+
* and the same tamper-detection guarantees.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface EncryptedStore {
|
|
19
|
+
put(key: string, value: string): Promise<void>
|
|
20
|
+
get(key: string): Promise<string | null>
|
|
21
|
+
delete(key: string): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SecureStoreError extends Error {
|
|
25
|
+
readonly code: SecureStoreErrorCode
|
|
26
|
+
constructor(code: SecureStoreErrorCode, message: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'SecureStoreError'
|
|
29
|
+
this.code = code
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SecureStoreErrorCode =
|
|
34
|
+
/** Decryption of stored ciphertext failed — wrong key or tampered payload. */
|
|
35
|
+
| 'DECRYPT_FAILED'
|
|
36
|
+
/** Stored envelope is missing required fields or is structurally invalid. */
|
|
37
|
+
| 'CORRUPT_ENVELOPE'
|
|
38
|
+
/** Backing store (localStorage, keychain) refused the operation. */
|
|
39
|
+
| 'BACKEND_FAILURE'
|
|
40
|
+
/** Store accessed before unlock/key derivation. */
|
|
41
|
+
| 'LOCKED'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Server-version capability negotiation (#22). One UI build talks to many
|
|
2
|
+
// reddb server versions: rather than assume every endpoint exists, the UI
|
|
3
|
+
// resolves a capability map for the active connection and HIDES controls the
|
|
4
|
+
// server can't honour (absent, not greyed — the permission-aware rule).
|
|
5
|
+
//
|
|
6
|
+
// Resolution prefers a STABLE server signal (`GET /capabilities`) over
|
|
7
|
+
// inferring from 404s, and FAILS SAFE — any uncertainty resolves to "not
|
|
8
|
+
// supported" so a newer UI never assumes a capability a server lacks.
|
|
9
|
+
|
|
10
|
+
/** The feature flags the UI gates controls on. Extend as features are added. */
|
|
11
|
+
export interface ServerCapabilities {
|
|
12
|
+
/** Version control (commits / diffs) — `/repo/commits`, `/collections/:n/vcs`. */
|
|
13
|
+
vcs: boolean
|
|
14
|
+
/** Cluster topology + status — `/cluster/status`. */
|
|
15
|
+
clusterStatus: boolean
|
|
16
|
+
/** Per-collection metadata route — `GET /collections/:name`. */
|
|
17
|
+
collectionMetadata: boolean
|
|
18
|
+
/** Server-sent change stream — `/changes/stream`. */
|
|
19
|
+
cdcStream: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fail-safe baseline: nothing is assumed present until proven. */
|
|
23
|
+
export const EMPTY_SERVER_CAPABILITIES: ServerCapabilities = Object.freeze({
|
|
24
|
+
vcs: false,
|
|
25
|
+
clusterStatus: false,
|
|
26
|
+
collectionMetadata: false,
|
|
27
|
+
cdcStream: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** Shape of the optional stable `/capabilities` signal. */
|
|
31
|
+
export interface RawCapabilities {
|
|
32
|
+
ok?: boolean
|
|
33
|
+
/** Flat feature names, e.g. ["vcs","cluster_status"]. */
|
|
34
|
+
features?: string[]
|
|
35
|
+
/** Or an explicit flag map, e.g. { vcs: true }. */
|
|
36
|
+
capabilities?: Record<string, boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Server feature names → our capability keys. Several aliases per key so we
|
|
40
|
+
// tolerate naming drift across server versions without a UI release.
|
|
41
|
+
const FEATURE_ALIASES: Record<string, keyof ServerCapabilities> = {
|
|
42
|
+
vcs: 'vcs',
|
|
43
|
+
versioning: 'vcs',
|
|
44
|
+
version_control: 'vcs',
|
|
45
|
+
cluster: 'clusterStatus',
|
|
46
|
+
cluster_status: 'clusterStatus',
|
|
47
|
+
collection_metadata: 'collectionMetadata',
|
|
48
|
+
collections_metadata: 'collectionMetadata',
|
|
49
|
+
cdc: 'cdcStream',
|
|
50
|
+
changes_stream: 'cdcStream',
|
|
51
|
+
cdc_stream: 'cdcStream',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a capability map from the stable `/capabilities` signal. Unknown
|
|
56
|
+
* feature names are ignored (a server advertising features this UI version
|
|
57
|
+
* doesn't model is harmless); known ones flip their flag on. The explicit
|
|
58
|
+
* `capabilities` map, when present, takes precedence over the `features` list.
|
|
59
|
+
*/
|
|
60
|
+
export function capabilitiesFromSignal(raw: RawCapabilities): ServerCapabilities {
|
|
61
|
+
const out: ServerCapabilities = { ...EMPTY_SERVER_CAPABILITIES }
|
|
62
|
+
for (const name of raw.features ?? []) {
|
|
63
|
+
const key = FEATURE_ALIASES[name?.toLowerCase?.() ?? '']
|
|
64
|
+
if (key) out[key] = true
|
|
65
|
+
}
|
|
66
|
+
for (const [name, on] of Object.entries(raw.capabilities ?? {})) {
|
|
67
|
+
const key = FEATURE_ALIASES[name.toLowerCase()]
|
|
68
|
+
if (key) out[key] = on === true
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type EncryptedStore, SecureStoreError } from './secure-store'
|
|
2
|
+
|
|
3
|
+
export type TauriInvoke = <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
|
4
|
+
|
|
5
|
+
export interface TauriEncryptedStoreOptions {
|
|
6
|
+
/** Service name passed to the OS keychain. */
|
|
7
|
+
service?: string
|
|
8
|
+
/** Bound to the running Tauri runtime — pass `invoke` from `@tauri-apps/api/core`. */
|
|
9
|
+
invoke: TauriInvoke
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tauri-backed encrypted store. Defers entirely to the OS keychain via
|
|
14
|
+
* three commands implemented in Rust (see apps/desktop/src-tauri):
|
|
15
|
+
*
|
|
16
|
+
* keychain_set { service, key, value } → void
|
|
17
|
+
* keychain_get { service, key } → string | null
|
|
18
|
+
* keychain_delete { service, key } → void
|
|
19
|
+
*
|
|
20
|
+
* No master password prompt: trust is rooted in the OS user session.
|
|
21
|
+
*/
|
|
22
|
+
export class TauriEncryptedStore implements EncryptedStore {
|
|
23
|
+
private readonly invoke: TauriInvoke
|
|
24
|
+
private readonly service: string
|
|
25
|
+
|
|
26
|
+
constructor(opts: TauriEncryptedStoreOptions) {
|
|
27
|
+
if (!opts.invoke) {
|
|
28
|
+
throw new SecureStoreError('BACKEND_FAILURE', 'tauri invoke fn not provided')
|
|
29
|
+
}
|
|
30
|
+
this.invoke = opts.invoke
|
|
31
|
+
this.service = opts.service ?? 'io.reddb.red-ui'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async put(key: string, value: string): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
await this.invoke<void>('keychain_set', { service: this.service, key, value })
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new SecureStoreError('BACKEND_FAILURE', `keychain_set failed: ${stringifyError(e)}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async get(key: string): Promise<string | null> {
|
|
43
|
+
try {
|
|
44
|
+
const v = await this.invoke<string | null>('keychain_get', { service: this.service, key })
|
|
45
|
+
return v ?? null
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new SecureStoreError('BACKEND_FAILURE', `keychain_get failed: ${stringifyError(e)}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async delete(key: string): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
await this.invoke<void>('keychain_delete', { service: this.service, key })
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new SecureStoreError('BACKEND_FAILURE', `keychain_delete failed: ${stringifyError(e)}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringifyError(e: unknown): string {
|
|
61
|
+
if (e instanceof Error) return e.message
|
|
62
|
+
if (typeof e === 'string') return e
|
|
63
|
+
try { return JSON.stringify(e) } catch { return String(e) }
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Transport reachability (#34, ADR-0003). Which transports a connection can use
|
|
2
|
+
// is bounded by what the *red-ui client* speaks — NOT by the browser, and NOT by
|
|
3
|
+
// what the reddb server exposes. reddb itself offers many wire transports (HTTP
|
|
4
|
+
// + SSE/NDJSON, RedWire on :5050, gRPC, Postgres-wire, and a WebSocket ingest
|
|
5
|
+
// endpoint `WS /ws/ingest/{collection}` — see ../reddb/docs/api/). The red-ui
|
|
6
|
+
// client, however, currently speaks only HTTP: RedClient is fetch-based (REST),
|
|
7
|
+
// and live changes use SSE/REST polling over the same http(s) connection. So the
|
|
8
|
+
// Connect UI offers http(s) only because that is the client's surface today — a
|
|
9
|
+
// `ws`/`wss` (or `grpc`) transport belongs here only once the client actually
|
|
10
|
+
// consumes reddb's WebSocket/gRPC endpoint, not before (that would be a phantom
|
|
11
|
+
// option that fails when picked).
|
|
12
|
+
//
|
|
13
|
+
// `red://`/`reds://` are not distinct client transports either: normalizeUrl
|
|
14
|
+
// rewrites them to `http(s)://host:5055` (the documented `red://localhost`
|
|
15
|
+
// production tunnel — red-ui's HTTP convention, distinct from reddb's native
|
|
16
|
+
// RedWire on :5050), so they classify as their coerced http(s) transport and are
|
|
17
|
+
// reachable wherever http(s) is. Streaming rides over the http(s) connection, so
|
|
18
|
+
// it needs no separate transport member. Only unix-socket / embedded-file
|
|
19
|
+
// connections are reachable solely from a Tauri Surface, so those are the ones a
|
|
20
|
+
// browser Surface hides. The Connect UI offers only the transports the active
|
|
21
|
+
// provider declares reachable, so the user never picks one that fails. This
|
|
22
|
+
// module is the pure scheme↔transport mapping + reachability sets; the provider
|
|
23
|
+
// declares its set and the UI consults it.
|
|
24
|
+
|
|
25
|
+
import type { Transport } from "./types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* User-typed connection-string scheme → the wire transport it resolves to.
|
|
29
|
+
* `red`/`reds` (and their `red+tcp`/`red+wire`/`red+tls` aliases) are sugar for
|
|
30
|
+
* `http(s)://host:5055` via normalizeUrl, so they map to `http`/`https` — they
|
|
31
|
+
* carry no distinct wire transport in any Surface today. Native tcp/tls remains
|
|
32
|
+
* a possible future Desktop capability (ADR-0003) but is not materialized yet.
|
|
33
|
+
*/
|
|
34
|
+
const SCHEME_TRANSPORT: Record<string, Transport> = {
|
|
35
|
+
http: "http",
|
|
36
|
+
https: "https",
|
|
37
|
+
red: "http",
|
|
38
|
+
"red+tcp": "http",
|
|
39
|
+
"red+wire": "http",
|
|
40
|
+
reds: "https",
|
|
41
|
+
"red+tls": "https",
|
|
42
|
+
"red+http": "http",
|
|
43
|
+
"red+https": "https",
|
|
44
|
+
"red+unix": "unix",
|
|
45
|
+
file: "unix",
|
|
46
|
+
unix: "unix",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The transport a user-typed connection string would use. Defaults to `http`
|
|
51
|
+
* for a bare `host:port` (matching normalizeUrl, which prepends http://).
|
|
52
|
+
* Returns `null` for a scheme this client doesn't recognise at all.
|
|
53
|
+
*/
|
|
54
|
+
export function transportForUrl(input: string): Transport | null {
|
|
55
|
+
const raw = input.trim();
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
const m = raw.match(/^([a-z+]+):\/\//i);
|
|
58
|
+
if (!m) return "http"; // bare host:port → http (normalizeUrl prepends it)
|
|
59
|
+
return SCHEME_TRANSPORT[m[1].toLowerCase()] ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Browser-reachable transports: `http`/`https` only (ADR-0003). `red://`/`reds://`
|
|
64
|
+
* are still reachable here because they coerce to `http(s)://host:5055` and so
|
|
65
|
+
* classify as `http`/`https` — the documented `red://localhost` production
|
|
66
|
+
* tunnel keeps working — but the browser advertises no distinct red(s):// or
|
|
67
|
+
* file:// transport. Unix-socket / embedded-file connections are genuinely
|
|
68
|
+
* unreachable from a browser and are hidden.
|
|
69
|
+
*/
|
|
70
|
+
export const BROWSER_TRANSPORTS: readonly Transport[] = ["http", "https"];
|
|
71
|
+
|
|
72
|
+
/** A Tauri Surface adds the native unix-socket / embedded-file transport. */
|
|
73
|
+
export const DESKTOP_TRANSPORTS: readonly Transport[] = [
|
|
74
|
+
"http",
|
|
75
|
+
"https",
|
|
76
|
+
"unix",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a user-typed URL's transport is reachable from a provider that
|
|
81
|
+
* declares `supported`. An unrecognised scheme is never reachable.
|
|
82
|
+
*/
|
|
83
|
+
export function isUrlReachable(
|
|
84
|
+
input: string,
|
|
85
|
+
supported: readonly Transport[]
|
|
86
|
+
): boolean {
|
|
87
|
+
const t = transportForUrl(input);
|
|
88
|
+
return t !== null && supported.includes(t);
|
|
89
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type NodeRole = 'primary' | 'replica' | 'embedded'
|
|
2
|
+
|
|
3
|
+
export type Transport = 'tcp' | 'tls' | 'unix' | 'http' | 'https'
|
|
4
|
+
|
|
5
|
+
export interface ConnectionTarget {
|
|
6
|
+
transport: Transport
|
|
7
|
+
host: string
|
|
8
|
+
port?: number
|
|
9
|
+
path?: string
|
|
10
|
+
database?: string
|
|
11
|
+
username?: string
|
|
12
|
+
password?: string
|
|
13
|
+
role?: NodeRole
|
|
14
|
+
params: Record<string, string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NodeStats {
|
|
18
|
+
id: string
|
|
19
|
+
role: NodeRole
|
|
20
|
+
host: string
|
|
21
|
+
bytesUsed: number
|
|
22
|
+
bytesAvailable: number
|
|
23
|
+
keyCount: number
|
|
24
|
+
lagBytes?: number
|
|
25
|
+
uptimeSeconds: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Topology {
|
|
29
|
+
primary: NodeStats
|
|
30
|
+
replicas: NodeStats[]
|
|
31
|
+
observedAt: string
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ConnectionTarget, NodeRole, Transport } from './types'
|
|
2
|
+
|
|
3
|
+
const TRANSPORTS: Record<string, Transport> = {
|
|
4
|
+
red: 'tcp',
|
|
5
|
+
'red+tls': 'tls',
|
|
6
|
+
'red+unix': 'unix',
|
|
7
|
+
'red+http': 'http',
|
|
8
|
+
'red+https': 'https',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseRedUri(uri: string): ConnectionTarget {
|
|
12
|
+
const match = uri.match(/^([a-z+]+):\/\/(.+)$/i)
|
|
13
|
+
if (!match) throw new Error(`Invalid red:// URI: ${uri}`)
|
|
14
|
+
const [, scheme, rest] = match
|
|
15
|
+
|
|
16
|
+
const transport = TRANSPORTS[scheme.toLowerCase()]
|
|
17
|
+
if (!transport) throw new Error(`Unsupported scheme: ${scheme}`)
|
|
18
|
+
|
|
19
|
+
if (transport === 'unix') {
|
|
20
|
+
const [path, query = ''] = rest.split('?')
|
|
21
|
+
return {
|
|
22
|
+
transport,
|
|
23
|
+
host: 'localhost',
|
|
24
|
+
path: decodeURIComponent(path),
|
|
25
|
+
params: parseQuery(query),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const url = new URL(`http://${rest}`)
|
|
30
|
+
const params = parseQuery(url.search.slice(1))
|
|
31
|
+
const role = params.role as NodeRole | undefined
|
|
32
|
+
const database = url.pathname.replace(/^\//, '') || undefined
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
transport,
|
|
36
|
+
host: url.hostname,
|
|
37
|
+
port: url.port ? Number(url.port) : undefined,
|
|
38
|
+
database,
|
|
39
|
+
username: url.username ? decodeURIComponent(url.username) : undefined,
|
|
40
|
+
password: url.password ? decodeURIComponent(url.password) : undefined,
|
|
41
|
+
role,
|
|
42
|
+
params,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseQuery(qs: string): Record<string, string> {
|
|
47
|
+
const out: Record<string, string> = {}
|
|
48
|
+
if (!qs) return out
|
|
49
|
+
for (const pair of qs.split('&')) {
|
|
50
|
+
const [k, v = ''] = pair.split('=')
|
|
51
|
+
if (k) out[decodeURIComponent(k)] = decodeURIComponent(v)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|