@reddb-io/ui 0.3.5 → 0.4.0
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/Bf0kgHHV.js +1 -0
- package/build/_app/immutable/chunks/{BVwWA3xx.js → CQUazLlV.js} +6 -6
- package/build/_app/immutable/chunks/{CqNT6kY9.js → DEXJoNrJ.js} +5 -5
- package/build/_app/immutable/chunks/{zWSbcoVP.js → DkihsTT_.js} +1 -1
- package/build/_app/immutable/chunks/DxlrWGyf.js +35 -0
- package/build/_app/immutable/chunks/{B0VvmBoI.js → kA3Jql_0.js} +1 -1
- package/build/_app/immutable/entry/{app.CaFS7aa2.js → app.DsWyVHHY.js} +2 -2
- package/build/_app/immutable/entry/start.JTS2dMkh.js +1 -0
- package/build/_app/immutable/nodes/{0.BbSvq8nk.js → 0.B3B9dr1W.js} +2 -2
- package/build/_app/immutable/nodes/{1.CRP48TvS.js → 1.BRHK3-aW.js} +1 -1
- package/build/_app/immutable/nodes/{10.CXsR87Il.js → 10.DO83djGy.js} +1 -1
- package/build/_app/immutable/nodes/{15.C0aUvI2r.js → 15.BMQezBh7.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +11 -10
- package/dist/embed/{AnalyticsView-CZV8ntM5.js → AnalyticsView-C_h8ScpB.js} +4 -4
- package/dist/embed/{AnalyticsView-CZV8ntM5.js.map → AnalyticsView-C_h8ScpB.js.map} +1 -1
- package/dist/embed/{RedUiEmbedRoot-Cn7wCgaP.js → RedUiEmbedRoot-BlduSCNM.js} +7721 -7600
- package/dist/embed/RedUiEmbedRoot-BlduSCNM.js.map +1 -0
- package/dist/embed/{index-BEbHY5Se.js → index-BSBRHtH4.js} +4 -4
- package/dist/embed/{index-BEbHY5Se.js.map → index-BSBRHtH4.js.map} +1 -1
- package/dist/embed/{index-client-DxIHaYUc.js → index-client-DelkELqi.js} +2 -2
- package/dist/embed/{index-client-DxIHaYUc.js.map → index-client-DelkELqi.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-BXKPmNCu.js → sigma.esm-D9VpQHRm.js} +2 -2
- package/dist/embed/{sigma.esm-BXKPmNCu.js.map → sigma.esm-D9VpQHRm.js.map} +1 -1
- package/package.json +3 -3
- package/src/lib/ConnectDropdown.svelte +27 -13
- package/src/lib/Workspace.svelte +2 -2
- package/src/lib/connections.svelte.ts +78 -19
- package/src/lib/reddb/boot-params.ts +30 -27
- 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/BW1DXKuJ.js +0 -1
- package/build/_app/immutable/chunks/Bl--JRJL.js +0 -35
- package/build/_app/immutable/entry/start.CHBGG1k_.js +0 -1
- package/dist/embed/RedUiEmbedRoot-Cn7wCgaP.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.0",
|
|
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.0",
|
|
51
|
+
"@reddb-io/ui-mcp": "0.4.0",
|
|
52
52
|
"@sigma/edge-curve": "^3.1.0",
|
|
53
53
|
"@sigma/node-border": "^3.0.0",
|
|
54
54
|
"@tauri-apps/api": "^2.5.0",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
connection,
|
|
5
5
|
makeCustomConnection,
|
|
6
|
+
parseConnectionInput,
|
|
6
7
|
openDatabaseFile,
|
|
7
8
|
unreachableConnectHint,
|
|
8
9
|
} from './connections.svelte'
|
|
@@ -36,29 +37,42 @@
|
|
|
36
37
|
let inputEl: HTMLInputElement | undefined = $state()
|
|
37
38
|
|
|
38
39
|
async function attempt(targetUrl: string, signIn = false) {
|
|
39
|
-
const cleaned = targetUrl.trim()
|
|
40
|
+
const cleaned = targetUrl.trim()
|
|
40
41
|
if (!cleaned) return
|
|
41
|
-
|
|
42
|
+
let parsed: ReturnType<typeof parseConnectionInput>
|
|
43
|
+
const secret = password
|
|
44
|
+
password = ''
|
|
45
|
+
try {
|
|
46
|
+
parsed = parseConnectionInput(cleaned)
|
|
47
|
+
url = parsed.url
|
|
48
|
+
if (parsed.credentials && signIn) {
|
|
49
|
+
throw new Error('Use credentials in either the connection string or the sign-in fields.')
|
|
50
|
+
}
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
url = ''
|
|
53
|
+
status = 'error'
|
|
54
|
+
error = cause instanceof Error ? cause.message : 'Invalid connection string.'
|
|
55
|
+
return
|
|
56
|
+
}
|
|
42
57
|
// Transport reachability guard (#34): never attempt a transport this
|
|
43
58
|
// Surface can't materialize — fail with a clear message instead of an
|
|
44
59
|
// opaque transport-unsupported error from the client.
|
|
45
|
-
if (!connection.canReach(
|
|
60
|
+
if (!connection.canReach(parsed.url)) {
|
|
46
61
|
status = 'error'
|
|
47
62
|
// A file:// target on the web gets an honest, actionable explanation
|
|
48
63
|
// (#123) instead of the generic transport-unsupported line.
|
|
49
64
|
error =
|
|
50
|
-
unreachableConnectHint(
|
|
65
|
+
unreachableConnectHint(parsed.url) ??
|
|
51
66
|
`This app can't reach that transport here — supported: ${supportedHint}`
|
|
52
67
|
return
|
|
53
68
|
}
|
|
54
69
|
status = 'probing'
|
|
55
70
|
error = null
|
|
56
71
|
rtt = null
|
|
57
|
-
const preset = makeCustomConnection(
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
? await connection.login(preset, username.trim(), secret)
|
|
72
|
+
const preset = makeCustomConnection(parsed.url)
|
|
73
|
+
const credentials = parsed.credentials ?? (signIn ? { username: username.trim(), password: secret } : undefined)
|
|
74
|
+
const ok = credentials
|
|
75
|
+
? await connection.login(preset, credentials.username, credentials.password)
|
|
62
76
|
: await connection.tryConnect(preset)
|
|
63
77
|
if (ok) {
|
|
64
78
|
status = 'ok'
|
|
@@ -197,19 +211,19 @@
|
|
|
197
211
|
aria-label="Connect to reddb"
|
|
198
212
|
>
|
|
199
213
|
<form onsubmit={submit}>
|
|
200
|
-
<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>
|
|
201
215
|
<input
|
|
202
216
|
id="connect-url"
|
|
203
217
|
bind:this={inputEl}
|
|
204
218
|
bind:value={url}
|
|
205
219
|
disabled={status === 'probing'}
|
|
206
|
-
oninput={() => { password = '' }}
|
|
220
|
+
oninput={() => { username = ''; password = '' }}
|
|
207
221
|
spellcheck="false"
|
|
208
|
-
placeholder="
|
|
222
|
+
placeholder="https://user:password@host or Base64"
|
|
209
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"
|
|
210
224
|
/>
|
|
211
225
|
|
|
212
|
-
<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>
|
|
213
227
|
|
|
214
228
|
<details class="mb-3">
|
|
215
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,25 +139,22 @@ const historyStore = new SurfaceHistoryStore({
|
|
|
137
139
|
* host[:port] → http://host:port|5055
|
|
138
140
|
*/
|
|
139
141
|
export function normalizeUrl(input: string): string {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (!/:\/\//.test(raw)) raw = "http://" + raw;
|
|
144
|
-
|
|
145
|
-
const m = raw.match(/^([a-z+]+):\/\/(.+)$/i);
|
|
146
|
-
if (!m) return raw;
|
|
147
|
-
const [, scheme, rest] = m;
|
|
148
|
-
const s = scheme.toLowerCase();
|
|
142
|
+
if (!input.trim()) return "";
|
|
143
|
+
return parseConnectionString(input).url;
|
|
144
|
+
}
|
|
149
145
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
return
|
|
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: "" };
|
|
151
|
+
try {
|
|
152
|
+
return parseConnectionString(input);
|
|
153
|
+
} catch {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Invalid connection string. Check the address and provide both username and password."
|
|
156
|
+
);
|
|
157
157
|
}
|
|
158
|
-
return raw;
|
|
159
158
|
}
|
|
160
159
|
|
|
161
160
|
export function makeCustomConnection(input: string): ConnectionPreset {
|
|
@@ -423,8 +422,34 @@ class ConnectionStore {
|
|
|
423
422
|
): Promise<string | null> {
|
|
424
423
|
const boot = await resolve();
|
|
425
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
|
+
}
|
|
426
430
|
if (!boot.target) return route;
|
|
427
|
-
|
|
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
|
+
}
|
|
428
453
|
if (boot.token) {
|
|
429
454
|
markManagedWebHandoff();
|
|
430
455
|
const provider = getConnectionProvider();
|
|
@@ -445,6 +470,39 @@ class ConnectionStore {
|
|
|
445
470
|
await this.tryConnect(preset);
|
|
446
471
|
}
|
|
447
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
|
+
|
|
448
506
|
async login(
|
|
449
507
|
preset: ConnectionPreset,
|
|
450
508
|
username: string,
|
|
@@ -453,6 +511,7 @@ class ConnectionStore {
|
|
|
453
511
|
this.active = preset;
|
|
454
512
|
this.#client = null;
|
|
455
513
|
this.connected = false;
|
|
514
|
+
this.#capabilities = { ...EMPTY_SERVER_CAPABILITIES };
|
|
456
515
|
this.probe = { reachable: false };
|
|
457
516
|
try {
|
|
458
517
|
const targetProvider = getConnectionProvider();
|
|
@@ -560,7 +619,7 @@ class ConnectionStore {
|
|
|
560
619
|
if (!boot) return null;
|
|
561
620
|
const route = boot.to ?? boot.view ?? null;
|
|
562
621
|
if (!boot.endpoint) return route;
|
|
563
|
-
await this.
|
|
622
|
+
await this.connectString(boot.endpoint);
|
|
564
623
|
return route;
|
|
565
624
|
}
|
|
566
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
|
}
|
|
@@ -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
|
+
}
|