@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.
Files changed (38) hide show
  1. package/build/_app/immutable/chunks/BOm0dWBE.js +1 -0
  2. package/build/_app/immutable/chunks/{Cznn1SM_.js → BjiIDQc5.js} +1 -1
  3. package/build/_app/immutable/chunks/{CgeUbXx3.js → CraBVUa8.js} +1 -1
  4. package/build/_app/immutable/chunks/Dc8lKBnh.js +35 -0
  5. package/build/_app/immutable/chunks/{bWMB_8ZZ.js → kV8hsQzl.js} +1 -1
  6. package/build/_app/immutable/chunks/{DXJnbMgi.js → tI1T3IGL.js} +5 -5
  7. package/build/_app/immutable/entry/{app.CnZytEwL.js → app.CqnCbx3-.js} +2 -2
  8. package/build/_app/immutable/entry/start.BHO_MY7Q.js +1 -0
  9. package/build/_app/immutable/nodes/{0.IUeUbFYR.js → 0.B0qC983k.js} +1 -1
  10. package/build/_app/immutable/nodes/{1.ppCNgQoG.js → 1.BALgKrlb.js} +1 -1
  11. package/build/_app/immutable/nodes/{10.PFTL9me3.js → 10.Dg8SyeBS.js} +1 -1
  12. package/build/_app/immutable/nodes/{15.CveXa_3e.js → 15.UGiFEthu.js} +1 -1
  13. package/build/_app/version.json +1 -1
  14. package/build/index.html +11 -10
  15. package/dist/embed/{AnalyticsView-Bwa627Xe.js → AnalyticsView-g7iZKS88.js} +4 -4
  16. package/dist/embed/{AnalyticsView-Bwa627Xe.js.map → AnalyticsView-g7iZKS88.js.map} +1 -1
  17. package/dist/embed/{RedUiEmbedRoot-DWA6FS7Q.js → RedUiEmbedRoot-B0chjnkm.js} +7385 -7284
  18. package/dist/embed/RedUiEmbedRoot-B0chjnkm.js.map +1 -0
  19. package/dist/embed/{index-client-CXG6tBhB.js → index-client-DqhuzUPo.js} +2 -2
  20. package/dist/embed/{index-client-CXG6tBhB.js.map → index-client-DqhuzUPo.js.map} +1 -1
  21. package/dist/embed/{index-W_9pCMZk.js → index-dKxVhrdf.js} +161 -156
  22. package/dist/embed/{index-W_9pCMZk.js.map → index-dKxVhrdf.js.map} +1 -1
  23. package/dist/embed/index.js +1 -1
  24. package/dist/embed/{sigma.esm-BtRwWMN5.js → sigma.esm-BFfaH_Ia.js} +2 -2
  25. package/dist/embed/{sigma.esm-BtRwWMN5.js.map → sigma.esm-BFfaH_Ia.js.map} +1 -1
  26. package/package.json +3 -3
  27. package/src/lib/ConnectDropdown.svelte +4 -4
  28. package/src/lib/Workspace.svelte +2 -2
  29. package/src/lib/connections.svelte.ts +72 -38
  30. package/src/lib/reddb/boot-params.ts +30 -27
  31. package/src/lib/reddb/client.ts +20 -14
  32. package/src/lib/reddb/connection-bootstrap.ts +62 -19
  33. package/src/lib/reddb/connection-string.ts +108 -0
  34. package/src/lib/reddb/index.ts +2 -0
  35. package/build/_app/immutable/chunks/1hmJRUuc.js +0 -35
  36. package/build/_app/immutable/chunks/B1QR04ZE.js +0 -1
  37. package/build/_app/immutable/entry/start.Br592ucD.js +0 -1
  38. 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.6",
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.3.6",
51
- "@reddb-io/ui-mcp": "0.3.6",
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().replace(/\/$/, '')
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">Instance URL</label>
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="red://localhost"
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>
@@ -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, makeCustomConnection, setConnectionProvider } from './connections.svelte'
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.tryConnect(makeCustomConnection(target))
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
- return parseConnectionInput(input).url;
142
+ if (!input.trim()) return "";
143
+ return parseConnectionString(input).url;
141
144
  }
142
145
 
143
- /** Parse only explicit Connect input; Open Contract URLs still reject secrets. */
144
- export function parseConnectionInput(input: string): {
145
- url: string;
146
- credentials?: { username: string; password: string };
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
- const protocol =
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
- const preset = makeCustomConnection(boot.target);
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.tryConnect(makeCustomConnection(boot.endpoint));
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
- /** Browser-reachable reddb HTTP endpoint, e.g. `http://host:5055`. */
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().replace(/\/$/, "");
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
- url = new URL(cs);
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
- "Open Contract cs is not a valid URL"
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 the public Open Contract, carrying `token` only in the hash. */
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) query.set(CS_KEY, normalizeCs(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 = params.get(CS_KEY);
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
- out.endpoint = v;
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
  }
@@ -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) || message.includes("route not found");
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 (older server), infers
590
- * from lightweight, side-effect-free GET probes. Any non-404 failure
591
- * (network, auth, 5xx) fails safe to the empty map, so the UI hides controls
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
- return capabilitiesFromSignal(raw);
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 to inference; anything else (a
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 stripTokenFromHistory(options: UrlBootstrapReadOptions): void {
178
+ function stripSecretsFromHistory(options: UrlBootstrapReadOptions): void {
174
179
  if (!options.consume || !options.location || !options.history) return;
175
- const hash = options.location.hash;
176
- if (!hash) return;
177
-
178
- const params = new URLSearchParams(
179
- hash.startsWith("#") ? hash.slice(1) : hash
180
- );
181
- if (!params.has("token")) return;
182
- params.delete("token");
183
-
184
- const nextHash = params.toString();
185
- const nextUrl = `${options.location.pathname}${options.location.search}${
186
- nextHash ? `#${nextHash}` : ""
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(options.history.state, "", nextUrl);
223
+ options.history.replaceState(
224
+ options.history.state,
225
+ "",
226
+ `${pathname}${q}${h}`
227
+ );
190
228
  } catch {
191
- /* best-effort URL cleanup */
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
- if (contract.token) stripTokenFromHistory(options);
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
- return null;
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
+ }
@@ -17,3 +17,5 @@ export * from "./secure-store";
17
17
  export * from "./web-encrypted-store";
18
18
  export * from "./tauri-encrypted-store";
19
19
  export * from "./desktop-error";
20
+
21
+ export * from "./connection-string";