@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.
Files changed (37) hide show
  1. package/build/_app/immutable/chunks/Bf0kgHHV.js +1 -0
  2. package/build/_app/immutable/chunks/{BVwWA3xx.js → CQUazLlV.js} +6 -6
  3. package/build/_app/immutable/chunks/{CqNT6kY9.js → DEXJoNrJ.js} +5 -5
  4. package/build/_app/immutable/chunks/{zWSbcoVP.js → DkihsTT_.js} +1 -1
  5. package/build/_app/immutable/chunks/DxlrWGyf.js +35 -0
  6. package/build/_app/immutable/chunks/{B0VvmBoI.js → kA3Jql_0.js} +1 -1
  7. package/build/_app/immutable/entry/{app.CaFS7aa2.js → app.DsWyVHHY.js} +2 -2
  8. package/build/_app/immutable/entry/start.JTS2dMkh.js +1 -0
  9. package/build/_app/immutable/nodes/{0.BbSvq8nk.js → 0.B3B9dr1W.js} +2 -2
  10. package/build/_app/immutable/nodes/{1.CRP48TvS.js → 1.BRHK3-aW.js} +1 -1
  11. package/build/_app/immutable/nodes/{10.CXsR87Il.js → 10.DO83djGy.js} +1 -1
  12. package/build/_app/immutable/nodes/{15.C0aUvI2r.js → 15.BMQezBh7.js} +1 -1
  13. package/build/_app/version.json +1 -1
  14. package/build/index.html +11 -10
  15. package/dist/embed/{AnalyticsView-CZV8ntM5.js → AnalyticsView-C_h8ScpB.js} +4 -4
  16. package/dist/embed/{AnalyticsView-CZV8ntM5.js.map → AnalyticsView-C_h8ScpB.js.map} +1 -1
  17. package/dist/embed/{RedUiEmbedRoot-Cn7wCgaP.js → RedUiEmbedRoot-BlduSCNM.js} +7721 -7600
  18. package/dist/embed/RedUiEmbedRoot-BlduSCNM.js.map +1 -0
  19. package/dist/embed/{index-BEbHY5Se.js → index-BSBRHtH4.js} +4 -4
  20. package/dist/embed/{index-BEbHY5Se.js.map → index-BSBRHtH4.js.map} +1 -1
  21. package/dist/embed/{index-client-DxIHaYUc.js → index-client-DelkELqi.js} +2 -2
  22. package/dist/embed/{index-client-DxIHaYUc.js.map → index-client-DelkELqi.js.map} +1 -1
  23. package/dist/embed/index.js +1 -1
  24. package/dist/embed/{sigma.esm-BXKPmNCu.js → sigma.esm-D9VpQHRm.js} +2 -2
  25. package/dist/embed/{sigma.esm-BXKPmNCu.js.map → sigma.esm-D9VpQHRm.js.map} +1 -1
  26. package/package.json +3 -3
  27. package/src/lib/ConnectDropdown.svelte +27 -13
  28. package/src/lib/Workspace.svelte +2 -2
  29. package/src/lib/connections.svelte.ts +78 -19
  30. package/src/lib/reddb/boot-params.ts +30 -27
  31. package/src/lib/reddb/connection-bootstrap.ts +62 -19
  32. package/src/lib/reddb/connection-string.ts +108 -0
  33. package/src/lib/reddb/index.ts +2 -0
  34. package/build/_app/immutable/chunks/BW1DXKuJ.js +0 -1
  35. package/build/_app/immutable/chunks/Bl--JRJL.js +0 -35
  36. package/build/_app/immutable/entry/start.CHBGG1k_.js +0 -1
  37. 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.5",
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.3.5",
51
- "@reddb-io/ui-mcp": "0.3.5",
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().replace(/\/$/, '')
40
+ const cleaned = targetUrl.trim()
40
41
  if (!cleaned) return
41
- url = cleaned
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(cleaned)) {
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(cleaned) ??
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(cleaned)
58
- const secret = password
59
- password = ''
60
- const ok = signIn
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">Instance URL</label>
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="red://localhost"
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>
@@ -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,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
- let raw = input.trim().replace(/\/$/, "");
141
- if (!raw) return raw;
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
- if (s === "red" || s === "red+tcp" || s === "red+wire") {
151
- const [host] = rest.split("/")[0].split(":");
152
- return `http://${host}:5055${rest.includes("/") ? "/" + rest.split("/").slice(1).join("/") : ""}`;
153
- }
154
- if (s === "reds" || s === "red+tls") {
155
- const [host] = rest.split("/")[0].split(":");
156
- return `https://${host}:5055`;
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
- 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
+ }
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.tryConnect(makeCustomConnection(boot.endpoint));
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
- /** 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
  }
@@ -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";