@reddb-io/ui 0.3.6 → 0.4.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/build/_app/immutable/chunks/BOm0dWBE.js +1 -0
- package/build/_app/immutable/chunks/{Cznn1SM_.js → BjiIDQc5.js} +1 -1
- package/build/_app/immutable/chunks/{CgeUbXx3.js → CraBVUa8.js} +1 -1
- package/build/_app/immutable/chunks/Dc8lKBnh.js +35 -0
- package/build/_app/immutable/chunks/{bWMB_8ZZ.js → kV8hsQzl.js} +1 -1
- package/build/_app/immutable/chunks/{DXJnbMgi.js → tI1T3IGL.js} +5 -5
- package/build/_app/immutable/entry/{app.CnZytEwL.js → app.CqnCbx3-.js} +2 -2
- package/build/_app/immutable/entry/start.BHO_MY7Q.js +1 -0
- package/build/_app/immutable/nodes/{0.IUeUbFYR.js → 0.B0qC983k.js} +1 -1
- package/build/_app/immutable/nodes/{1.ppCNgQoG.js → 1.BALgKrlb.js} +1 -1
- package/build/_app/immutable/nodes/{10.PFTL9me3.js → 10.Dg8SyeBS.js} +1 -1
- package/build/_app/immutable/nodes/{15.CveXa_3e.js → 15.UGiFEthu.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +11 -10
- package/dist/embed/{AnalyticsView-Bwa627Xe.js → AnalyticsView-g7iZKS88.js} +4 -4
- package/dist/embed/{AnalyticsView-Bwa627Xe.js.map → AnalyticsView-g7iZKS88.js.map} +1 -1
- package/dist/embed/{RedUiEmbedRoot-DWA6FS7Q.js → RedUiEmbedRoot-B0chjnkm.js} +7385 -7284
- package/dist/embed/RedUiEmbedRoot-B0chjnkm.js.map +1 -0
- package/dist/embed/{index-client-CXG6tBhB.js → index-client-DqhuzUPo.js} +2 -2
- package/dist/embed/{index-client-CXG6tBhB.js.map → index-client-DqhuzUPo.js.map} +1 -1
- package/dist/embed/{index-W_9pCMZk.js → index-dKxVhrdf.js} +161 -156
- package/dist/embed/{index-W_9pCMZk.js.map → index-dKxVhrdf.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-BtRwWMN5.js → sigma.esm-BFfaH_Ia.js} +2 -2
- package/dist/embed/{sigma.esm-BtRwWMN5.js.map → sigma.esm-BFfaH_Ia.js.map} +1 -1
- package/package.json +3 -3
- package/src/lib/ConnectDropdown.svelte +4 -4
- package/src/lib/Workspace.svelte +2 -2
- package/src/lib/connections.svelte.ts +72 -38
- package/src/lib/reddb/boot-params.ts +30 -27
- package/src/lib/reddb/client.ts +20 -14
- package/src/lib/reddb/connection-bootstrap.ts +62 -19
- package/src/lib/reddb/connection-string.ts +108 -0
- package/src/lib/reddb/index.ts +2 -0
- package/build/_app/immutable/chunks/1hmJRUuc.js +0 -35
- package/build/_app/immutable/chunks/B1QR04ZE.js +0 -1
- package/build/_app/immutable/entry/start.Br592ucD.js +0 -1
- package/dist/embed/RedUiEmbedRoot-DWA6FS7Q.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "RedDB UI components and standalone web application.",
|
|
6
6
|
"type": "module",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@aflsolutions/graphology-communities-leiden": "^1.1.1",
|
|
50
|
-
"@reddb-io/ui-kit": "0.
|
|
51
|
-
"@reddb-io/ui-mcp": "0.
|
|
50
|
+
"@reddb-io/ui-kit": "0.4.1",
|
|
51
|
+
"@reddb-io/ui-mcp": "0.4.1",
|
|
52
52
|
"@sigma/edge-curve": "^3.1.0",
|
|
53
53
|
"@sigma/node-border": "^3.0.0",
|
|
54
54
|
"@tauri-apps/api": "^2.5.0",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
let inputEl: HTMLInputElement | undefined = $state()
|
|
38
38
|
|
|
39
39
|
async function attempt(targetUrl: string, signIn = false) {
|
|
40
|
-
const cleaned = targetUrl.trim()
|
|
40
|
+
const cleaned = targetUrl.trim()
|
|
41
41
|
if (!cleaned) return
|
|
42
42
|
let parsed: ReturnType<typeof parseConnectionInput>
|
|
43
43
|
const secret = password
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
aria-label="Connect to reddb"
|
|
212
212
|
>
|
|
213
213
|
<form onsubmit={submit}>
|
|
214
|
-
<label for="connect-url" class="type-label block mb-1.5">
|
|
214
|
+
<label for="connect-url" class="type-label block mb-1.5">Connection string</label>
|
|
215
215
|
<input
|
|
216
216
|
id="connect-url"
|
|
217
217
|
bind:this={inputEl}
|
|
@@ -219,11 +219,11 @@
|
|
|
219
219
|
disabled={status === 'probing'}
|
|
220
220
|
oninput={() => { username = ''; password = '' }}
|
|
221
221
|
spellcheck="false"
|
|
222
|
-
placeholder="
|
|
222
|
+
placeholder="https://user:password@host or Base64"
|
|
223
223
|
class="w-full h-9 px-3 mb-1 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent placeholder:text-fg-3"
|
|
224
224
|
/>
|
|
225
225
|
|
|
226
|
-
<div class="mb-2 text-[10px] font-mono text-fg-3">reachable here: {supportedHint}</div>
|
|
226
|
+
<div class="mb-2 text-[10px] font-mono text-fg-3">URL or Base64-encoded URL · reachable here: {supportedHint}</div>
|
|
227
227
|
|
|
228
228
|
<details class="mb-3">
|
|
229
229
|
<summary class="cursor-pointer text-xs text-fg-2">Sign in to the database</summary>
|
package/src/lib/Workspace.svelte
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import SecurityView from './SecurityView.svelte'
|
|
22
22
|
import SettingsView from './SettingsView.svelte'
|
|
23
23
|
import Appearance from './Appearance.svelte'
|
|
24
|
-
import { connection,
|
|
24
|
+
import { connection, setConnectionProvider } from './connections.svelte'
|
|
25
25
|
import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
|
|
26
26
|
import { theme, type Theme } from './theme.svelte'
|
|
27
27
|
import { skins } from './skins/skins.svelte'
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
if (route) target = raw.split('?')[0]
|
|
163
163
|
} catch {}
|
|
164
164
|
|
|
165
|
-
await connection.
|
|
165
|
+
await connection.connectString(target)
|
|
166
166
|
if (route) navigateToBootRoute(route)
|
|
167
167
|
})
|
|
168
168
|
)
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// - Embedded and managed Web boot targets store nothing.
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
parseConnectionString,
|
|
13
|
+
type ConnectionCredentials,
|
|
12
14
|
BROWSER_TRANSPORTS,
|
|
13
15
|
DESKTOP_TRANSPORTS,
|
|
14
16
|
DesktopError,
|
|
@@ -137,46 +139,18 @@ const historyStore = new SurfaceHistoryStore({
|
|
|
137
139
|
* host[:port] → http://host:port|5055
|
|
138
140
|
*/
|
|
139
141
|
export function normalizeUrl(input: string): string {
|
|
140
|
-
|
|
142
|
+
if (!input.trim()) return "";
|
|
143
|
+
return parseConnectionString(input).url;
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
/**
|
|
144
|
-
export function parseConnectionInput(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
let raw = input.trim().replace(/\/$/, "");
|
|
149
|
-
if (!raw) return { url: raw };
|
|
150
|
-
if (!/:\/\//.test(raw)) raw = "http://" + raw;
|
|
151
|
-
const scheme = raw.slice(0, raw.indexOf(":")).toLowerCase();
|
|
152
|
-
const alias = ["red", "red+tcp", "red+wire", "reds", "red+tls"].includes(
|
|
153
|
-
scheme
|
|
154
|
-
);
|
|
155
|
-
if (!alias && scheme !== "http" && scheme !== "https") return { url: raw };
|
|
146
|
+
/** Backwards-compatible explicit-input API, backed by the shared URI/Base64 parser. */
|
|
147
|
+
export function parseConnectionInput(
|
|
148
|
+
input: string
|
|
149
|
+
): ReturnType<typeof parseConnectionString> {
|
|
150
|
+
if (!input.trim()) return { url: "" };
|
|
156
151
|
try {
|
|
157
|
-
|
|
158
|
-
scheme === "reds" || scheme === "red+tls" ? "https" : "http";
|
|
159
|
-
const endpoint = new URL(
|
|
160
|
-
alias ? raw.replace(/^[^:]+:/, `${protocol}:`) : raw
|
|
161
|
-
);
|
|
162
|
-
let credentials: { username: string; password: string } | undefined;
|
|
163
|
-
if (endpoint.username || endpoint.password) {
|
|
164
|
-
if (!endpoint.username || !endpoint.password) throw new Error();
|
|
165
|
-
credentials = {
|
|
166
|
-
username: decodeURIComponent(endpoint.username),
|
|
167
|
-
password: decodeURIComponent(endpoint.password),
|
|
168
|
-
};
|
|
169
|
-
endpoint.username = "";
|
|
170
|
-
endpoint.password = "";
|
|
171
|
-
}
|
|
172
|
-
// RedWire ports are not HTTP API ports. Keep the UI's existing alias.
|
|
173
|
-
if (alias) endpoint.port = "5055";
|
|
174
|
-
return {
|
|
175
|
-
url: endpoint.href.replace(/\/$/, ""),
|
|
176
|
-
...(credentials ? { credentials } : {}),
|
|
177
|
-
};
|
|
152
|
+
return parseConnectionString(input);
|
|
178
153
|
} catch {
|
|
179
|
-
// URL/decode errors can contain the original credential-bearing input.
|
|
180
154
|
throw new Error(
|
|
181
155
|
"Invalid connection string. Check the address and provide both username and password."
|
|
182
156
|
);
|
|
@@ -448,8 +422,34 @@ class ConnectionStore {
|
|
|
448
422
|
): Promise<string | null> {
|
|
449
423
|
const boot = await resolve();
|
|
450
424
|
const route = boot.route ?? null;
|
|
425
|
+
if (boot.error) {
|
|
426
|
+
this.disconnect();
|
|
427
|
+
this.probe = { reachable: false, error: boot.error };
|
|
428
|
+
return route;
|
|
429
|
+
}
|
|
451
430
|
if (!boot.target) return route;
|
|
452
|
-
|
|
431
|
+
if (!boot.token) {
|
|
432
|
+
await this.connectString(boot.target);
|
|
433
|
+
return route;
|
|
434
|
+
}
|
|
435
|
+
let preset: ConnectionPreset;
|
|
436
|
+
try {
|
|
437
|
+
const parsed = parseConnectionString(boot.target);
|
|
438
|
+
if (parsed.credentials)
|
|
439
|
+
throw new Error(
|
|
440
|
+
"Choose either connection credentials or a session token."
|
|
441
|
+
);
|
|
442
|
+
preset = makeCustomConnection(parsed.url);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
this.disconnect();
|
|
445
|
+
this.active = makeCustomConnection("");
|
|
446
|
+
this.probe = {
|
|
447
|
+
reachable: false,
|
|
448
|
+
error:
|
|
449
|
+
error instanceof Error ? error.message : "Invalid connection string.",
|
|
450
|
+
};
|
|
451
|
+
return route;
|
|
452
|
+
}
|
|
453
453
|
if (boot.token) {
|
|
454
454
|
markManagedWebHandoff();
|
|
455
455
|
const provider = getConnectionProvider();
|
|
@@ -470,6 +470,39 @@ class ConnectionStore {
|
|
|
470
470
|
await this.tryConnect(preset);
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/** Accept a plain URI or Base64 URI, keeping credentials out of connection metadata. */
|
|
474
|
+
async connectString(
|
|
475
|
+
input: string,
|
|
476
|
+
credentials?: ConnectionCredentials
|
|
477
|
+
): Promise<boolean> {
|
|
478
|
+
try {
|
|
479
|
+
const parsed = parseConnectionString(input);
|
|
480
|
+
if (parsed.credentials && credentials)
|
|
481
|
+
throw new Error(
|
|
482
|
+
"Use credentials in either the connection string or the sign-in fields."
|
|
483
|
+
);
|
|
484
|
+
const auth = credentials ?? parsed.credentials;
|
|
485
|
+
const preset = makeCustomConnection(parsed.url);
|
|
486
|
+
if (!this.canReach(parsed.url))
|
|
487
|
+
throw new Error(
|
|
488
|
+
unreachableConnectHint(parsed.url) ??
|
|
489
|
+
"This connection transport is not available here."
|
|
490
|
+
);
|
|
491
|
+
return auth
|
|
492
|
+
? await this.login(preset, auth.username, auth.password)
|
|
493
|
+
: await this.tryConnect(preset);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
this.disconnect();
|
|
496
|
+
this.active = makeCustomConnection("");
|
|
497
|
+
this.probe = {
|
|
498
|
+
reachable: false,
|
|
499
|
+
error:
|
|
500
|
+
error instanceof Error ? error.message : "Invalid connection string.",
|
|
501
|
+
};
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
473
506
|
async login(
|
|
474
507
|
preset: ConnectionPreset,
|
|
475
508
|
username: string,
|
|
@@ -478,6 +511,7 @@ class ConnectionStore {
|
|
|
478
511
|
this.active = preset;
|
|
479
512
|
this.#client = null;
|
|
480
513
|
this.connected = false;
|
|
514
|
+
this.#capabilities = { ...EMPTY_SERVER_CAPABILITIES };
|
|
481
515
|
this.probe = { reachable: false };
|
|
482
516
|
try {
|
|
483
517
|
const targetProvider = getConnectionProvider();
|
|
@@ -585,7 +619,7 @@ class ConnectionStore {
|
|
|
585
619
|
if (!boot) return null;
|
|
586
620
|
const route = boot.to ?? boot.view ?? null;
|
|
587
621
|
if (!boot.endpoint) return route;
|
|
588
|
-
await this.
|
|
622
|
+
await this.connectString(boot.endpoint);
|
|
589
623
|
return route;
|
|
590
624
|
}
|
|
591
625
|
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeConnectionString,
|
|
3
|
+
parseConnectionString,
|
|
4
|
+
} from "./connection-string";
|
|
5
|
+
|
|
1
6
|
// Open Contract URL pre-configuration. A Surface can seed the loaded Core with
|
|
2
7
|
// a browser-reachable reddb endpoint (`cs`), an initial app route (`to`), and a
|
|
3
8
|
// short-lived token carried only in the URL hash. The Core currently consumes
|
|
@@ -6,7 +11,7 @@
|
|
|
6
11
|
|
|
7
12
|
/** Parsed Open Contract from the app URL. */
|
|
8
13
|
export interface OpenContract {
|
|
9
|
-
/**
|
|
14
|
+
/** Ephemeral plain/Base64 HTTP connection URI; may contain credentials. */
|
|
10
15
|
cs?: string;
|
|
11
16
|
/** Initial app route, e.g. `/cluster` or `/c/users/p/table`. */
|
|
12
17
|
to?: string;
|
|
@@ -84,7 +89,7 @@ function looksLikeFilesystemPath(value: string): boolean {
|
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
function normalizeCs(input: string): string {
|
|
87
|
-
const cs = input.trim()
|
|
92
|
+
const cs = input.trim();
|
|
88
93
|
if (!cs) throw new OpenContractError("EMPTY_CS", "Open Contract cs is empty");
|
|
89
94
|
if (looksLikeFilesystemPath(cs)) {
|
|
90
95
|
throw new OpenContractError(
|
|
@@ -93,32 +98,19 @@ function normalizeCs(input: string): string {
|
|
|
93
98
|
);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
let url: URL;
|
|
97
101
|
try {
|
|
98
|
-
|
|
102
|
+
const decoded = decodeConnectionString(cs);
|
|
103
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(decoded)) throw new Error();
|
|
104
|
+
if (looksLikeFilesystemPath(decoded)) throw new Error();
|
|
105
|
+
const parsed = parseConnectionString(decoded);
|
|
106
|
+
if (!/^https?:\/\//.test(parsed.url)) throw new Error();
|
|
99
107
|
} catch {
|
|
100
108
|
throw new OpenContractError(
|
|
101
109
|
"MALFORMED_URL",
|
|
102
|
-
"
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
107
|
-
throw new OpenContractError(
|
|
108
|
-
"UNSUPPORTED_SCHEME",
|
|
109
|
-
"Open Contract cs must use http:// or https://"
|
|
110
|
+
"Enter a valid HTTP connection string or its Base64 encoding."
|
|
110
111
|
);
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
for (const key of url.searchParams.keys()) {
|
|
114
|
-
if (SECRET_KEY.test(key)) {
|
|
115
|
-
throw new OpenContractError(
|
|
116
|
-
"SECRET_IN_CS",
|
|
117
|
-
"Open Contract secrets must be carried in the app URL hash, not inside cs"
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
114
|
return cs;
|
|
123
115
|
}
|
|
124
116
|
|
|
@@ -164,7 +156,7 @@ export function parseOpenContract(
|
|
|
164
156
|
);
|
|
165
157
|
const out: OpenContract = {};
|
|
166
158
|
|
|
167
|
-
const cs = query.get(CS_KEY);
|
|
159
|
+
const cs = hashParams.get(CS_KEY) ?? query.get(CS_KEY);
|
|
168
160
|
if (cs !== null) out.cs = normalizeCs(cs);
|
|
169
161
|
|
|
170
162
|
const to = query.get(TO_KEY);
|
|
@@ -176,12 +168,16 @@ export function parseOpenContract(
|
|
|
176
168
|
return out;
|
|
177
169
|
}
|
|
178
170
|
|
|
179
|
-
/** Encode
|
|
171
|
+
/** Encode Open Contract; credentials and tokens belong only in the fragment. */
|
|
180
172
|
export function encodeOpenContract(contract: OpenContract): string {
|
|
181
173
|
const query = new URLSearchParams();
|
|
182
174
|
const hash = new URLSearchParams();
|
|
183
175
|
|
|
184
|
-
if (contract.cs)
|
|
176
|
+
if (contract.cs) {
|
|
177
|
+
const cs = normalizeCs(contract.cs);
|
|
178
|
+
const parsed = parseConnectionString(cs);
|
|
179
|
+
(parsed.credentials ? hash : query).set(CS_KEY, cs);
|
|
180
|
+
}
|
|
185
181
|
if (contract.to) query.set(TO_KEY, normalizeRoute(contract.to));
|
|
186
182
|
if (contract.token) hash.set(TOKEN_KEY, contract.token);
|
|
187
183
|
|
|
@@ -200,10 +196,12 @@ export function parseBootParams(search: string, hash = ""): BootParams {
|
|
|
200
196
|
const params = new URLSearchParams(search ?? "");
|
|
201
197
|
const out: BootParams = {};
|
|
202
198
|
|
|
203
|
-
const cs =
|
|
199
|
+
const cs =
|
|
200
|
+
new URLSearchParams(hash.replace(/^#/, "")).get(CS_KEY) ??
|
|
201
|
+
params.get(CS_KEY);
|
|
204
202
|
if (cs !== null) {
|
|
205
203
|
try {
|
|
206
|
-
out.endpoint = normalizeCs(cs);
|
|
204
|
+
out.endpoint = parseConnectionString(normalizeCs(cs)).url;
|
|
207
205
|
} catch {
|
|
208
206
|
// Invalid Open Contract endpoints are rejected by omitting the endpoint;
|
|
209
207
|
// the full parser still throws for callers that need diagnostics.
|
|
@@ -223,7 +221,12 @@ export function parseBootParams(search: string, hash = ""): BootParams {
|
|
|
223
221
|
if (out.endpoint) break;
|
|
224
222
|
const v = params.get(key);
|
|
225
223
|
if (v && !SECRET_KEY.test(key)) {
|
|
226
|
-
|
|
224
|
+
try {
|
|
225
|
+
const parsed = parseConnectionString(v);
|
|
226
|
+
out.endpoint = parsed.credentials ? parsed.url : v;
|
|
227
|
+
} catch {
|
|
228
|
+
/* Invalid legacy endpoint: omit. */
|
|
229
|
+
}
|
|
227
230
|
break;
|
|
228
231
|
}
|
|
229
232
|
}
|
package/src/lib/reddb/client.ts
CHANGED
|
@@ -223,17 +223,10 @@ function isUnsupportedCollectionMetadataRoute(e: unknown): boolean {
|
|
|
223
223
|
return message.includes("route not found: GET /collections/");
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
227
|
-
* Generalised "the server doesn't have this route" check (#22). `json()`
|
|
228
|
-
* throws `<METHOD> <path> → <status> <body>`, so a 404 or a "route not found"
|
|
229
|
-
* body both signal an unsupported route. Distinct from auth/5xx/network errors
|
|
230
|
-
* — but capability negotiation treats *any* probe failure as unsupported
|
|
231
|
-
* (fail safe), so this is only used to tell "route absent" from "signal
|
|
232
|
-
* present" when reading the stable `/capabilities` endpoint.
|
|
233
|
-
*/
|
|
226
|
+
/** Missing or unimplemented discovery routes may use compatibility probes. */
|
|
234
227
|
function isUnsupportedRoute(e: unknown): boolean {
|
|
235
228
|
const message = e instanceof Error ? e.message : String(e);
|
|
236
|
-
return / → 404\b/.test(message)
|
|
229
|
+
return / → (404|405|501)\b/.test(message);
|
|
237
230
|
}
|
|
238
231
|
|
|
239
232
|
function unsupportedCollectionMetadataError(baseUrl: string): Error {
|
|
@@ -586,17 +579,30 @@ export class RedClient {
|
|
|
586
579
|
|
|
587
580
|
/**
|
|
588
581
|
* Resolve the server's capability map (#22). Prefers the stable
|
|
589
|
-
* `/capabilities` signal; when that route is absent
|
|
590
|
-
* from lightweight, side-effect-free
|
|
591
|
-
*
|
|
582
|
+
* `/capabilities` signal; when that route is absent or returns structured
|
|
583
|
+
* discovery without UI feature flags, infers from lightweight, side-effect-free
|
|
584
|
+
* GET probes. Compatibility follows working APIs, not the server version.
|
|
585
|
+
* Other failures (network, authentication, unexpected HTTP status) fail safe
|
|
586
|
+
* to the empty map, so the UI hides controls
|
|
592
587
|
* rather than showing ones the server can't honour.
|
|
593
588
|
*/
|
|
594
589
|
async capabilities(): Promise<ServerCapabilities> {
|
|
595
590
|
try {
|
|
596
591
|
const raw = await this.json<RawCapabilities>("/capabilities");
|
|
597
|
-
|
|
592
|
+
if (
|
|
593
|
+
!raw ||
|
|
594
|
+
typeof raw !== "object" ||
|
|
595
|
+
Array.isArray(raw) ||
|
|
596
|
+
raw.ok === false
|
|
597
|
+
)
|
|
598
|
+
return { ...EMPTY_SERVER_CAPABILITIES };
|
|
599
|
+
// A flat declaration is authoritative, including explicit false/empty
|
|
600
|
+
// flags. Structured discovery reports build/deployment state instead;
|
|
601
|
+
// prove optional UI routes with read-only probes before enabling them.
|
|
602
|
+
if ("features" in raw || "capabilities" in raw)
|
|
603
|
+
return capabilitiesFromSignal(raw);
|
|
598
604
|
} catch (e) {
|
|
599
|
-
// Only an absent route falls through
|
|
605
|
+
// Only an absent/unimplemented route falls through; anything else (a
|
|
600
606
|
// flaky network, an auth wall, a 5xx) hides everything.
|
|
601
607
|
if (!isUnsupportedRoute(e)) return { ...EMPTY_SERVER_CAPABILITIES };
|
|
602
608
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { parseConnectionString } from "./connection-string";
|
|
1
2
|
import { parseOpenContract } from "./boot-params";
|
|
2
3
|
|
|
3
4
|
export const DEFAULT_PERSISTED_CONNECTION_KEY = "red-ui:connection";
|
|
4
5
|
|
|
5
6
|
export interface ConnectionBootstrap {
|
|
6
7
|
target: string | null;
|
|
8
|
+
error?: string;
|
|
7
9
|
token?: string;
|
|
8
10
|
route?: string;
|
|
9
11
|
}
|
|
@@ -11,6 +13,7 @@ export interface ConnectionBootstrap {
|
|
|
11
13
|
export type ConnectionBootstrapPayload =
|
|
12
14
|
| {
|
|
13
15
|
target?: unknown;
|
|
16
|
+
error?: unknown;
|
|
14
17
|
token?: unknown;
|
|
15
18
|
route?: unknown;
|
|
16
19
|
}
|
|
@@ -70,11 +73,13 @@ function normalizeBootstrap(
|
|
|
70
73
|
const target = clean(payload.target);
|
|
71
74
|
const token = clean(payload.token);
|
|
72
75
|
const route = clean(payload.route);
|
|
76
|
+
const error = clean(payload.error);
|
|
73
77
|
|
|
74
|
-
if (!target && !token && !route) return null;
|
|
78
|
+
if (!target && !token && !route && !error) return null;
|
|
75
79
|
|
|
76
80
|
return {
|
|
77
81
|
target: target ?? null,
|
|
82
|
+
...(error ? { error } : {}),
|
|
78
83
|
...(token ? { token } : {}),
|
|
79
84
|
...(route ? { route } : {}),
|
|
80
85
|
};
|
|
@@ -170,25 +175,58 @@ function liveUrlBootstrapOptions(): UrlBootstrapReadOptions {
|
|
|
170
175
|
};
|
|
171
176
|
}
|
|
172
177
|
|
|
173
|
-
function
|
|
178
|
+
function stripSecretsFromHistory(options: UrlBootstrapReadOptions): void {
|
|
174
179
|
if (!options.consume || !options.location || !options.history) return;
|
|
175
|
-
const hash = options.location
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
const { pathname, search, hash } = options.location;
|
|
181
|
+
const query = new URLSearchParams(search);
|
|
182
|
+
const fragment = new URLSearchParams(hash.replace(/^#/, ""));
|
|
183
|
+
let queryChanged = false;
|
|
184
|
+
let hashChanged = false;
|
|
185
|
+
for (const [params, isHash] of [
|
|
186
|
+
[query, false],
|
|
187
|
+
[fragment, true],
|
|
188
|
+
] as const) {
|
|
189
|
+
for (const key of [...params.keys()]) {
|
|
190
|
+
if (
|
|
191
|
+
/token|secret|password|passwd|auth|credential|api[-_]?key|bearer/i.test(
|
|
192
|
+
key
|
|
193
|
+
)
|
|
194
|
+
) {
|
|
195
|
+
params.delete(key);
|
|
196
|
+
if (isHash) hashChanged = true;
|
|
197
|
+
else queryChanged = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const key of ["cs", "endpoint", "connection", "red_url", "red"]) {
|
|
201
|
+
const value = params.get(key);
|
|
202
|
+
if (value === null) continue;
|
|
203
|
+
let safe: string | undefined;
|
|
204
|
+
try {
|
|
205
|
+
if (key === "cs") parseOpenContract(`?cs=${encodeURIComponent(value)}`);
|
|
206
|
+
safe = parseConnectionString(value).url;
|
|
207
|
+
} catch {
|
|
208
|
+
/* Remove invalid input. */
|
|
209
|
+
}
|
|
210
|
+
// Fragment connections are single-use; query connections retain only their endpoint.
|
|
211
|
+
if (isHash || safe !== value) {
|
|
212
|
+
params.delete(key);
|
|
213
|
+
if (!isHash && safe) params.set(key, safe);
|
|
214
|
+
if (isHash) hashChanged = true;
|
|
215
|
+
else queryChanged = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!queryChanged && !hashChanged) return;
|
|
220
|
+
const q = queryChanged ? (query.size ? `?${query}` : "") : search;
|
|
221
|
+
const h = hashChanged ? (fragment.size ? `#${fragment}` : "") : hash;
|
|
188
222
|
try {
|
|
189
|
-
options.history.replaceState(
|
|
223
|
+
options.history.replaceState(
|
|
224
|
+
options.history.state,
|
|
225
|
+
"",
|
|
226
|
+
`${pathname}${q}${h}`
|
|
227
|
+
);
|
|
190
228
|
} catch {
|
|
191
|
-
/*
|
|
229
|
+
/* Best-effort cleanup in restricted embedding contexts. */
|
|
192
230
|
}
|
|
193
231
|
}
|
|
194
232
|
|
|
@@ -199,7 +237,7 @@ export function readUrlOpenContractBootstrap(
|
|
|
199
237
|
): ConnectionBootstrapPayload {
|
|
200
238
|
try {
|
|
201
239
|
const contract = parseOpenContract(search, hash);
|
|
202
|
-
|
|
240
|
+
|
|
203
241
|
if (!contract.cs && !contract.to && !contract.token) return null;
|
|
204
242
|
return {
|
|
205
243
|
target: contract.cs,
|
|
@@ -207,7 +245,12 @@ export function readUrlOpenContractBootstrap(
|
|
|
207
245
|
route: contract.to,
|
|
208
246
|
};
|
|
209
247
|
} catch {
|
|
210
|
-
|
|
248
|
+
// An explicit invalid contract must not silently fall through to an old database.
|
|
249
|
+
return {
|
|
250
|
+
error: "Enter a valid HTTP connection string or its Base64 encoding.",
|
|
251
|
+
};
|
|
252
|
+
} finally {
|
|
253
|
+
stripSecretsFromHistory(options);
|
|
211
254
|
}
|
|
212
255
|
}
|
|
213
256
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Connection input is ephemeral; only `url` may become connection metadata. */
|
|
2
|
+
export interface ConnectionCredentials {
|
|
3
|
+
username: string;
|
|
4
|
+
password: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ParsedConnectionString {
|
|
7
|
+
url: string;
|
|
8
|
+
credentials?: ConnectionCredentials;
|
|
9
|
+
}
|
|
10
|
+
export class ConnectionStringError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
message = "Enter a valid connection URL or its Base64 encoding."
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConnectionStringError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const MAX_INPUT_LENGTH = 16_384;
|
|
19
|
+
const SECRET_KEY =
|
|
20
|
+
/token|secret|password|passwd|auth|credential|api[-_]?key|bearer/i;
|
|
21
|
+
|
|
22
|
+
/** Decode exactly one layer, accepting standard Base64 and Base64url, with or without padding. */
|
|
23
|
+
export function decodeConnectionString(input: string): string {
|
|
24
|
+
const raw = input.trim();
|
|
25
|
+
if (!raw || raw.length > MAX_INPUT_LENGTH) throw new ConnectionStringError();
|
|
26
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return raw;
|
|
27
|
+
const prefixed = /^base64(?:url)?:/i.test(raw);
|
|
28
|
+
const value = prefixed ? raw.replace(/^base64(?:url)?:/i, "") : raw;
|
|
29
|
+
// Hostnames with a dot or host:port are unambiguous plain inputs.
|
|
30
|
+
if (!prefixed && !/^[\w+/=-]+$/.test(value)) return raw;
|
|
31
|
+
let decodedText = false;
|
|
32
|
+
try {
|
|
33
|
+
if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) throw new Error();
|
|
34
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
35
|
+
const unpadded = normalized.replace(/=+$/, "");
|
|
36
|
+
if (
|
|
37
|
+
unpadded.length % 4 === 1 ||
|
|
38
|
+
(normalized.includes("=") && normalized.length % 4 !== 0)
|
|
39
|
+
)
|
|
40
|
+
throw new Error();
|
|
41
|
+
const binary = atob(unpadded + "=".repeat((4 - (unpadded.length % 4)) % 4));
|
|
42
|
+
if (btoa(binary).replace(/=+$/, "") !== unpadded) throw new Error();
|
|
43
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
44
|
+
Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
|
45
|
+
);
|
|
46
|
+
decodedText = true;
|
|
47
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(decoded)) throw new Error();
|
|
48
|
+
return decoded;
|
|
49
|
+
} catch {
|
|
50
|
+
// Undotted DNS labels are also valid plain inputs. A successfully decoded
|
|
51
|
+
// non-URI is rejected (including double encoding), rather than retried.
|
|
52
|
+
if (
|
|
53
|
+
!prefixed &&
|
|
54
|
+
!decodedText &&
|
|
55
|
+
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(raw)
|
|
56
|
+
)
|
|
57
|
+
return raw;
|
|
58
|
+
throw new ConnectionStringError();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Browser connections use HTTP. RedWire aliases retain the existing HTTP bridge port convention. */
|
|
63
|
+
export function parseConnectionString(input: string): ParsedConnectionString {
|
|
64
|
+
let raw = decodeConnectionString(input);
|
|
65
|
+
if (/^(file|unix|red\+unix):\/\//i.test(raw)) return { url: raw };
|
|
66
|
+
if (!raw.includes("://")) raw = "http://" + raw;
|
|
67
|
+
const match = /^([a-z][a-z0-9+.-]*):\/\/(.+)$/i.exec(raw);
|
|
68
|
+
if (!match) throw new ConnectionStringError();
|
|
69
|
+
const scheme = match[1].toLowerCase();
|
|
70
|
+
const wire = ["red", "red+tcp", "red+wire", "reds", "red+tls"].includes(
|
|
71
|
+
scheme
|
|
72
|
+
);
|
|
73
|
+
const secure = ["https", "reds", "red+tls", "red+https"].includes(scheme);
|
|
74
|
+
if (!wire && !["http", "https", "red+http", "red+https"].includes(scheme)) {
|
|
75
|
+
throw new ConnectionStringError(
|
|
76
|
+
"This connection requires an HTTP or HTTPS endpoint."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(`${secure ? "https" : "http"}://${match[2]}`);
|
|
81
|
+
if (!url.hostname || /\s/.test(url.hostname) || url.hash) throw new Error();
|
|
82
|
+
for (const key of url.searchParams.keys())
|
|
83
|
+
if (SECRET_KEY.test(key)) {
|
|
84
|
+
throw new ConnectionStringError(
|
|
85
|
+
"Use URI credentials or the sign-in fields, not secrets in query parameters."
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
let credentials: ConnectionCredentials | undefined;
|
|
89
|
+
if (url.username || url.password) {
|
|
90
|
+
if (!url.username || !url.password)
|
|
91
|
+
throw new ConnectionStringError(
|
|
92
|
+
"A connection string needs both a username and a password."
|
|
93
|
+
);
|
|
94
|
+
credentials = {
|
|
95
|
+
username: decodeURIComponent(url.username),
|
|
96
|
+
password: decodeURIComponent(url.password),
|
|
97
|
+
};
|
|
98
|
+
url.username = "";
|
|
99
|
+
url.password = "";
|
|
100
|
+
}
|
|
101
|
+
if (wire) url.port = "5055";
|
|
102
|
+
const endpoint = url.href.replace(/\/$/, "");
|
|
103
|
+
return { url: endpoint, ...(credentials ? { credentials } : {}) };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof ConnectionStringError) throw error;
|
|
106
|
+
throw new ConnectionStringError();
|
|
107
|
+
}
|
|
108
|
+
}
|