From 58c0b19279f6f9d84bdea7bbcd11a0279b2305b1 Mon Sep 17 00:00:00 2001
From: twilightsaurus <133054450+twilightsaurus@users.noreply.github.com>
Date: Tue, 20 Oct 2026 08:34:50 +0300
Subject: [PATCH] fix: warn before discarding Direct credentials
---
apps/desktop/package.json | 1 +
.../src/components/ConnectionDialog.svelte | 60 ++-
.../test/connection-dialog.dom.test.ts | 315 +++++++++++
.../test/connection-restart.dom.test.ts | 147 +++++
apps/desktop/vite.config.ts | 20 +-
.../architecture/processes/managed-runtime.md | 10 +-
docs/install-windows.md | 13 +
docs/roadmap.md | 13 +-
package-lock.json | 507 ++++++++++++++++++
9 files changed, 1073 insertions(+), 13 deletions(-)
create mode 100644 apps/desktop/test/connection-dialog.dom.test.ts
create mode 100644 apps/desktop/test/connection-restart.dom.test.ts
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index cfa1b40..65aedab 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -22,6 +22,7 @@
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^24.10.1",
+ "jsdom": "^30.1.3",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"typescript": "~6.0.3",
diff --git a/apps/desktop/src/components/ConnectionDialog.svelte b/apps/desktop/src/components/ConnectionDialog.svelte
index 72d9f9e..7137f8e 100644
--- a/apps/desktop/src/components/ConnectionDialog.svelte
+++ b/apps/desktop/src/components/ConnectionDialog.svelte
@@ -1,5 +1,6 @@
@@ -170,6 +191,7 @@
disabled={saving}
oninput={() => {
saved = false;
+ confirmingDiscard = false;
}}
/>
@@ -231,10 +253,38 @@
Saved. Restart Imp to use this connection.
{/if}
+ {#if confirmingDiscard}
+
+ Saving this mode removes the locally stored Direct URL and pairing token. Cancel keeps your saved
+ configuration and pending changes. Returning to Direct requires re-entering the URL and token; if
+ the token is no longer available, obtain a new token by deliberately rotating the server
+ credential. Changes take effect after restart.
+
+ {/if}
+
-
- {saving ? 'Saving…' : 'Save'}
-
+ {#if confirmingDiscard}
+ {
+ confirmingDiscard = false;
+ await tick();
+ saveButton?.focus();
+ }}>Cancel
+ void save(true)}>Remove credentials and save
+ {:else}
+
+ {saving ? 'Saving…' : 'Save'}
+
+ {/if}
Close
diff --git a/apps/desktop/test/connection-dialog.dom.test.ts b/apps/desktop/test/connection-dialog.dom.test.ts
new file mode 100644
index 0000000..c63e383
--- /dev/null
+++ b/apps/desktop/test/connection-dialog.dom.test.ts
@@ -0,0 +1,315 @@
+import { flushSync, mount, tick, unmount, type ComponentProps } from 'svelte';
+import { fromStore, writable } from 'svelte/store';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import ConnectionDialog from '../src/components/ConnectionDialog.svelte';
+import type { ConnectionSettings, ConnectionSettingsUpdate } from '../src/lib/tunnel.ts';
+
+const DIRECT: ConnectionSettings = {
+ mode: 'direct',
+ sshTarget: 'saved-host',
+ remoteUrl: 'wss://fixture.invalid/state',
+ hasPairingToken: true,
+};
+const mounted: object[] = [];
+afterEach(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ for (const component of mounted.splice(0)) await unmount(component);
+ document.body.replaceChildren();
+});
+
+async function setup(
+ settings = DIRECT,
+ onsave = vi.fn['onsave']>().mockResolvedValue(true),
+) {
+ const settingsStore = writable(settings);
+ const errorStore = writable(null);
+ const currentSettings = fromStore(settingsStore);
+ const currentError = fromStore(errorStore);
+ const onclose = vi.fn();
+ const target = document.createElement('div');
+ document.body.append(target);
+ flushSync(() => {
+ mounted.push(
+ mount(ConnectionDialog, {
+ target,
+ props: {
+ get settings() {
+ return currentSettings.current;
+ },
+ get saveError() {
+ return currentError.current;
+ },
+ loadError: null,
+ onsave,
+ onclose,
+ },
+ }),
+ );
+ });
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ expect(document.activeElement).toBe(target.querySelector('input[name="connection-mode"]:checked'));
+ return { onsave, onclose, settingsStore, errorStore };
+}
+
+function button(label: string): HTMLButtonElement {
+ const found = [...document.querySelectorAll('button')].find((item) => item.textContent?.trim() === label);
+ if (!found) throw new Error(`Missing button: ${label}`);
+ return found;
+}
+
+async function settle(): Promise {
+ await tick();
+ flushSync();
+}
+
+async function click(element: HTMLElement): Promise {
+ flushSync(() => element.click());
+ await settle();
+}
+
+async function choose(mode: string): Promise {
+ const radio = document.querySelector(`input[name="connection-mode"][value="${mode}"]`);
+ if (!radio) throw new Error(`Missing mode: ${mode}`);
+ await click(radio);
+}
+
+async function input(type: string, value: string): Promise {
+ const field = document.querySelector(`input[type="${type}"]`);
+ if (!field) throw new Error(`Missing input: ${type}`);
+ flushSync(() => {
+ field.value = value;
+ field.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await settle();
+}
+
+async function submit(): Promise {
+ flushSync(() =>
+ document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })),
+ );
+ await settle();
+}
+
+function warning(): HTMLElement | null {
+ return document.querySelector('#discard-direct-warning');
+}
+
+describe('ConnectionDialog credential-discard interactions', () => {
+ it.each(['local', 'external', 'managed'])('requires explicit approval for Direct → %s', async (mode) => {
+ const { onsave } = await setup();
+ await choose(mode);
+ if (mode === 'managed') await input('text', 'reviewed-host');
+ await click(button('Save'));
+
+ expect(onsave).not.toHaveBeenCalled();
+ expect(warning()?.getAttribute('role')).toBe('alert');
+ const message = warning()?.textContent?.replace(/\s+/g, ' ');
+ expect(message).toMatch(/locally stored.*Direct URL.*pairing token/);
+ expect(message).toMatch(/Cancel.*saved configuration.*pending changes/);
+ expect(message).toMatch(/token.*deliberately rotating.*server credential/);
+ expect(message).toMatch(/after restart/);
+ expect(document.activeElement).toBe(button('Cancel'));
+ const confirm = button('Remove credentials and save');
+ expect(confirm.type).toBe('button');
+ expect(confirm.getAttribute('aria-describedby')).toBe(warning()?.id);
+
+ await click(confirm);
+ expect(onsave).toHaveBeenCalledExactlyOnceWith(
+ mode === 'managed' ? { mode, sshTarget: 'reviewed-host' } : { mode },
+ );
+ expect(warning()).toBeNull();
+ expect(document.querySelector('[role="status"]')?.textContent).toContain('Restart Imp');
+ await vi.waitFor(() => {
+ flushSync();
+ expect(document.activeElement).toBe(button('Save'));
+ });
+ });
+
+ it('Cancel keeps pending edits and the saved settings without saving', async () => {
+ const { onsave } = await setup();
+ const pendingToken = 'p'.repeat(43);
+ await input('url', 'wss://pending.fixture.invalid/state');
+ await input('password', pendingToken);
+ await choose('managed');
+ await input('text', 'pending-host');
+ await submit();
+ await click(button('Cancel'));
+ expect(onsave).not.toHaveBeenCalled();
+ expect(warning()).toBeNull();
+ expect(document.querySelector('input[type="text"]')?.value).toBe('pending-host');
+ expect(document.querySelector('input[value="managed"]')?.checked).toBe(true);
+ expect(document.activeElement).toBe(button('Save'));
+ await submit();
+ expect(warning()).not.toBeNull();
+ expect(onsave).not.toHaveBeenCalled();
+ await click(button('Cancel'));
+ await choose('direct');
+ expect(document.querySelector('input[type="url"]')?.value).toBe(
+ 'wss://pending.fixture.invalid/state',
+ );
+ expect(document.querySelector('input[type="password"]')?.value).toBe(pendingToken);
+ expect(onsave).not.toHaveBeenCalled();
+ });
+
+ it('repeated submits cannot approve removal or duplicate a deferred save', async () => {
+ const deferred = Promise.withResolvers();
+ const { onsave, onclose } = await setup(DIRECT, vi.fn().mockReturnValue(deferred.promise));
+ await choose('managed');
+ await input('text', 'reviewed-host');
+ await submit();
+ await submit();
+ await submit();
+ expect(onsave).not.toHaveBeenCalled();
+ const confirm = button('Remove credentials and save');
+ flushSync(() => {
+ confirm.click();
+ confirm.click();
+ });
+ await settle();
+ await submit();
+ expect(onsave).toHaveBeenCalledExactlyOnceWith({ mode: 'managed', sshTarget: 'reviewed-host' });
+ expect(
+ [...document.querySelectorAll('button, input')].every(
+ (element) => (element as HTMLInputElement).disabled,
+ ),
+ ).toBe(true);
+ await click(button('Saving…'));
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ expect(onclose).not.toHaveBeenCalled();
+ deferred.resolve(true);
+ await settle();
+ expect(button('Save').disabled).toBe(false);
+ expect(document.querySelector('[role="status"]')).not.toBeNull();
+ });
+
+ it('mode changes invalidate approval and require a fresh warning', async () => {
+ const { onsave } = await setup();
+ await choose('local');
+ await submit();
+ const staleConfirm = button('Remove credentials and save');
+ flushSync(() => {
+ document.querySelector('input[value="external"]')!.click();
+ staleConfirm.click();
+ });
+ await settle();
+ expect(warning()).toBeNull();
+ expect(onsave).not.toHaveBeenCalled();
+ await submit();
+ expect(onsave).not.toHaveBeenCalled();
+ await click(button('Remove credentials and save'));
+ expect(onsave).toHaveBeenCalledExactlyOnceWith({ mode: 'external' });
+ });
+
+ it('SSH target edits invalidate approval and only a fresh confirmation saves the edited target', async () => {
+ const { onsave } = await setup();
+ await choose('managed');
+ await input('text', 'first-host');
+ await submit();
+ const staleConfirm = button('Remove credentials and save');
+ flushSync(() => {
+ const field = document.querySelector('input[type="text"]')!;
+ field.value = 'second-host';
+ field.dispatchEvent(new Event('input', { bubbles: true }));
+ staleConfirm.click();
+ });
+ await settle();
+ expect(warning()).toBeNull();
+ expect(onsave).not.toHaveBeenCalled();
+ await submit();
+ expect(onsave).not.toHaveBeenCalled();
+ await click(button('Remove credentials and save'));
+ expect(onsave).toHaveBeenCalledExactlyOnceWith({ mode: 'managed', sshTarget: 'second-host' });
+ });
+
+ it('failed saves show saveError and require confirmation again before retry', async () => {
+ const onsave = vi
+ .fn<(update: ConnectionSettingsUpdate) => Promise>()
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(true);
+ const { errorStore } = await setup(DIRECT, onsave);
+ await choose('local');
+ await submit();
+ await click(button('Remove credentials and save'));
+ flushSync(() => errorStore.set('Could not save native connection settings.'));
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Could not save native connection settings.',
+ );
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ expect(warning()).toBeNull();
+ await submit();
+ expect(onsave).toHaveBeenCalledTimes(1);
+ expect(warning()).not.toBeNull();
+ await click(button('Remove credentials and save'));
+ expect(onsave).toHaveBeenCalledTimes(2);
+ expect(onsave).toHaveBeenLastCalledWith({ mode: 'local' });
+ });
+
+ it.each(['Close', 'titlebar', 'Escape'])('%s dismisses the warning without saving', async (action) => {
+ const { onsave, onclose } = await setup();
+ await choose('local');
+ await submit();
+ if (action === 'Escape') {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ } else {
+ await click(
+ action === 'titlebar'
+ ? document.querySelector('[aria-label="Close connection settings"]')!
+ : button('Close'),
+ );
+ }
+ expect(onclose).toHaveBeenCalledOnce();
+ expect(onsave).not.toHaveBeenCalled();
+ });
+
+ it('refreshing settings cancels stale approval and resets pending fields', async () => {
+ const { onsave, settingsStore } = await setup();
+ await choose('managed');
+ await input('text', 'pending-host');
+ await submit();
+ flushSync(() => settingsStore.set({ ...DIRECT, sshTarget: 'refreshed-host' }));
+ expect(warning()).toBeNull();
+ expect(document.querySelector('input[value="direct"]')?.checked).toBe(true);
+ expect(onsave).not.toHaveBeenCalled();
+ await choose('managed');
+ expect(document.querySelector('input[type="text"]')?.value).toBe('refreshed-host');
+ await submit();
+ expect(onsave).not.toHaveBeenCalled();
+ await click(button('Remove credentials and save'));
+ expect(onsave).toHaveBeenCalledExactlyOnceWith({ mode: 'managed', sshTarget: 'refreshed-host' });
+ });
+
+ it('Direct → Direct omits an empty token and passes an explicit replacement unchanged', async () => {
+ const { onsave } = await setup();
+ await input('url', 'wss://replacement.invalid/state');
+ await submit();
+ expect(warning()).toBeNull();
+ expect(onsave).toHaveBeenNthCalledWith(1, {
+ mode: 'direct',
+ remoteUrl: 'wss://replacement.invalid/state',
+ });
+ const replacement = 'x'.repeat(43);
+ await input('password', replacement);
+ await submit();
+ expect(onsave).toHaveBeenNthCalledWith(2, {
+ mode: 'direct',
+ remoteUrl: 'wss://replacement.invalid/state',
+ pairingToken: replacement,
+ });
+ expect(warning()).toBeNull();
+ });
+
+ it.each([
+ { ...DIRECT, hasPairingToken: false },
+ { ...DIRECT, mode: 'external' as const },
+ ])(
+ 'ordinary saves do not warn without a saved Direct credential ($mode / $hasPairingToken)',
+ async (settings) => {
+ const { onsave } = await setup(settings);
+ await choose('local');
+ await submit();
+ expect(warning()).toBeNull();
+ expect(onsave).toHaveBeenCalledExactlyOnceWith({ mode: 'local' });
+ },
+ );
+});
diff --git a/apps/desktop/test/connection-restart.dom.test.ts b/apps/desktop/test/connection-restart.dom.test.ts
new file mode 100644
index 0000000..1bb02c8
--- /dev/null
+++ b/apps/desktop/test/connection-restart.dom.test.ts
@@ -0,0 +1,147 @@
+import { flushSync, mount, tick, unmount } from 'svelte';
+import { afterEach, expect, it, vi } from 'vitest';
+import type * as WindowApi from '@tauri-apps/api/window';
+import type * as CoreApi from '@tauri-apps/api/core';
+
+const native = vi.hoisted(() => ({ invoke: vi.fn(), setSize: vi.fn().mockResolvedValue(undefined) }));
+vi.mock('@tauri-apps/api/core', async (importOriginal) => ({
+ ...(await importOriginal()),
+ invoke: native.invoke,
+}));
+vi.mock('@tauri-apps/api/app', () => ({ getVersion: async () => '0.0.0-fixture' }));
+vi.mock('@tauri-apps/api/window', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getCurrentWindow: () => ({ setSize: native.setSize }),
+}));
+
+import App from '../src/App.svelte';
+import { createRuntimeClients } from '../src/lib/config.ts';
+import type { ConnectionSettings } from '../src/lib/tunnel.ts';
+
+let app: object | undefined;
+const sockets: FixtureSocket[] = [];
+
+// Only the browser socket and native APIs are replaced; App, Hud, store and clients are real.
+class FixtureSocket extends EventTarget {
+ readonly send = vi.fn();
+ readonly close = vi.fn();
+ readonly url: string;
+ constructor(url: string) {
+ super();
+ this.url = url;
+ sockets.push(this);
+ }
+}
+
+afterEach(async () => {
+ if (app) await unmount(app);
+ app = undefined;
+ sockets.splice(0);
+ document.body.replaceChildren();
+ localStorage.clear();
+ Reflect.deleteProperty(globalThis, '__TAURI_INTERNALS__');
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ native.invoke.mockReset();
+ native.setSize.mockClear();
+});
+
+async function click(selector: string) {
+ const element = document.querySelector(selector);
+ if (!element) throw new Error(`Missing control: ${selector}`);
+ flushSync(() => element.click());
+ await tick();
+ flushSync();
+}
+
+it('saving through App → Hud → ConnectionDialog keeps the active Direct client until restart', async () => {
+ vi.stubEnv('VITE_IMP_SOURCE', 'relay');
+ vi.stubGlobal('WebSocket', FixtureSocket);
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ Object.defineProperty(globalThis, '__TAURI_INTERNALS__', { configurable: true, value: {} });
+ localStorage.setItem('imp.display-mode', 'expanded');
+ const token = 'x'.repeat(43);
+ const direct: ConnectionSettings = {
+ mode: 'direct',
+ sshTarget: '',
+ remoteUrl: 'wss://active.fixture.invalid/state',
+ hasPairingToken: true,
+ };
+ native.invoke.mockImplementation(async (command: string) => {
+ if (command === 'connection_config') {
+ return { mode: 'direct', stateUrl: direct.remoteUrl, authenticationToken: token };
+ }
+ if (command === 'connection_settings') return direct;
+ if (command === 'save_connection_settings') {
+ return { mode: 'local', sshTarget: '', remoteUrl: '', hasPairingToken: false };
+ }
+ throw new Error(`Unexpected native command: ${command}`);
+ });
+
+ const runtime = await createRuntimeClients();
+ const start = vi.spyOn(runtime.source, 'start');
+ const stop = vi.spyOn(runtime.source, 'stop');
+ const target = document.createElement('div');
+ document.body.append(target);
+ flushSync(() => {
+ app = mount(App, { target, props: runtime });
+ });
+ await tick();
+ flushSync();
+ expect(sockets).toHaveLength(1);
+ const socket = sockets[0]!;
+ const initialStops = stop.mock.calls.length;
+ expect(start).toHaveBeenCalledOnce();
+ expect(socket.url).toBe(direct.remoteUrl);
+
+ await click('[aria-label="Settings"]');
+ await click('[data-connection-manager-trigger]');
+ expect(document.querySelector('[role="dialog"][aria-labelledby="connection-dialog-title"]')).not.toBeNull();
+ await vi.waitFor(() => {
+ flushSync();
+ expect(document.querySelector('input[name="connection-mode"][value="local"]')).not.toBeNull();
+ });
+ await click('input[name="connection-mode"][value="local"]');
+ flushSync(() =>
+ document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })),
+ );
+ await tick();
+ flushSync();
+ expect(native.invoke.mock.calls.filter(([command]) => command === 'save_connection_settings')).toHaveLength(
+ 0,
+ );
+ await click('button[aria-describedby="discard-direct-warning"]');
+ await vi.waitFor(() => {
+ flushSync();
+ expect(document.querySelector('[role="status"]')?.textContent).toContain('Saved. Restart Imp');
+ });
+
+ expect(native.invoke).toHaveBeenCalledWith('save_connection_settings', { update: { mode: 'local' } });
+ expect(native.invoke.mock.calls.filter(([command]) => command === 'save_connection_settings')).toHaveLength(
+ 1,
+ );
+ expect(native.invoke.mock.calls.filter(([command]) => command === 'connection_config')).toHaveLength(1);
+ expect(document.querySelector('[role="status"]')?.textContent).toContain('Saved. Restart Imp');
+ expect(sockets).toHaveLength(1);
+ expect(start).toHaveBeenCalledOnce();
+ expect(stop).toHaveBeenCalledTimes(initialStops);
+ expect(socket.close).not.toHaveBeenCalled();
+
+ await click('.form-actions button:last-child');
+ // The retained real client still authenticates with its original runtime credential after saving.
+ flushSync(() => socket.dispatchEvent(new Event('open')));
+ expect(socket.send).toHaveBeenCalledExactlyOnceWith(JSON.stringify({ type: 'auth', token }));
+ expect(sockets).toHaveLength(1);
+ expect(stop).toHaveBeenCalledTimes(initialStops);
+ expect(native.invoke.mock.calls.filter(([command]) => command === 'connection_config')).toHaveLength(1);
+ await unmount(app!);
+ app = undefined;
+ expect(stop).toHaveBeenCalledTimes(initialStops + 1);
+ expect(socket.close).toHaveBeenCalledOnce();
+});
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index 5f394f6..d262922 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -16,7 +16,23 @@ export default defineConfig({
target: 'esnext',
},
test: {
- environment: 'node',
- include: ['test/**/*.test.ts'],
+ projects: [
+ {
+ test: {
+ name: 'node',
+ environment: 'node',
+ include: ['test/**/*.test.ts'],
+ exclude: ['test/**/*.dom.test.ts'],
+ },
+ },
+ {
+ resolve: { conditions: ['browser'] },
+ test: {
+ name: 'dom',
+ environment: 'jsdom',
+ include: ['test/**/*.dom.test.ts'],
+ },
+ },
+ ],
},
});
diff --git a/docs/architecture/processes/managed-runtime.md b/docs/architecture/processes/managed-runtime.md
index 0185217..55b4429 100644
--- a/docs/architecture/processes/managed-runtime.md
+++ b/docs/architecture/processes/managed-runtime.md
@@ -259,7 +259,15 @@ Reading settings does not return the existing token.
Writes are canonical by mode. Local and External clear unrelated transport
material; Managed requires a non-empty trimmed SSH target; Direct requires the
strict `wss:` endpoint plus a canonical 256-bit pairing token. Switching away
-from Direct removes the stored Direct credential.
+from Direct removes the stored Direct URL and credential. When the persisted
+mode is Direct and a token exists, the connection dialog requires an inline
+confirmation before submitting a mode-away save. Cancel preserves the saved
+configuration and pending edits without submitting; Close and Escape never
+confirm. Changing the destination mode or SSH target, or a failed save, requires
+fresh confirmation. Returning to Direct requires the URL and token again;
+if the token is unavailable, deliberate server-side rotation obtains a new one.
+This renderer warning does not change native validation or canonical deletion,
+and saving Direct with a blank token still preserves the stored credential.
Configuration persistence stages a complete replacement before replacing
`tunnel.json`; failed validation or persistence leaves the previous usable
diff --git a/docs/install-windows.md b/docs/install-windows.md
index 8ac2a19..936b876 100644
--- a/docs/install-windows.md
+++ b/docs/install-windows.md
@@ -109,6 +109,19 @@ In **Settings -> Connection -> Direct**:
4. Save.
5. Restart Imp.
+The saved token is never displayed. Leave the token field blank when saving
+Direct settings to keep it; after server-side rotation, paste the replacement
+token, save, and restart Imp.
+
+If Direct has a saved token, saving another mode first warns that the locally
+stored Direct URL and token will be removed. **Cancel** keeps the saved
+configuration and your pending edits unchanged; **Remove credentials and save**
+saves the new mode and removes both. Returning to Direct requires re-entering
+the URL and token. If you no longer have the token, deliberately rotate it on
+the server to obtain a replacement; rotation invalidates the old token for
+other paired desktops too. Closing the dialog or pressing Escape does not
+confirm or save. Connection changes still take effect only after restarting Imp.
+
See [server connection selection](../deploy/README.md#connection-selection) for
automatic-path requirements. DNS/DDNS, router forwarding, and firewalls remain
operator-owned. Existing operator public edges and unsupported environments
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 916f62c..69ce614 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -772,18 +772,21 @@ These are retained priorities, not failed acceptance checks or a new slice:
In progress. Bounded scope:
-1. TinyFugue onboarding/startup integration (**only this PR**): explicit
+1. TinyFugue onboarding/startup integration (PR1): explicit
existing-file selection, early hook loading, one-level literal absolute/`~/`
include duplicate detection, manual setup when inspection is unsafe, and
honest operator confirmation of actual startup ordering.
-2. Warning before discarding saved Direct credentials.
+2. Warning before discarding saved Direct credentials (implemented in PR2):
+ inline confirmation before saving another mode removes the saved Direct URL
+ and token; cancel preserves pending edits without saving. Explicit token
+ replacement while staying Direct is unchanged.
3. Correct NAT preflight error classification.
4. Recovery of an exact-owned inactive legacy edge when `gateway.env` is missing.
5. Installation documentation and completion-message corrections.
-This PR does not change Direct provisioning, credentials, or the protocol, and
-does not claim live infrastructure acceptance. The remaining Slice 26 items are
-not implemented by this PR.
+PR2 does not change Direct provisioning, native credential persistence, or the
+protocol, and does not claim native desktop or live infrastructure verification.
+Items 3–5 remain unimplemented.
## Candidate work
diff --git a/package-lock.json b/package-lock.json
index 035beaf..c6a60d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^24.10.1",
+ "jsdom": "^30.1.3",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"typescript": "~6.0.3",
@@ -342,6 +343,59 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.7.tgz",
+ "integrity": "sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.3",
+ "@csstools/css-color-parser": "^4.2.6",
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "10.0.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.4.tgz",
+ "integrity": "sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -367,6 +421,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
+ "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -800,6 +867,146 @@
"node": ">=22"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
@@ -928,6 +1135,24 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2634,6 +2859,16 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bottleneck": {
"version": "2.19.5",
"resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
@@ -3222,6 +3457,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3235,6 +3484,20 @@
"node": ">=4"
}
},
+ "node_modules/data-urls": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
+ "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.2"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3253,6 +3516,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -3346,6 +3616,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-ci": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz",
@@ -4246,6 +4529,19 @@
"node": "^16.14.0 || >=18.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
@@ -4471,6 +4767,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -4588,6 +4891,79 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "30.1.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
+ "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.1.3",
+ "@asamuzakjp/dom-selector": "^10.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
+ "@exodus/bytes": "^1.16.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^8.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.12.1",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.2.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsdom/node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/jsdom/node_modules/undici": {
+ "version": "8.12.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/json-parse-better-errors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
@@ -5110,6 +5486,13 @@
"marked": ">=1 <16"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/meow": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
@@ -8090,6 +8473,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/semantic-release": {
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz",
@@ -8940,6 +9336,26 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz",
+ "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.18"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz",
+ "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8953,6 +9369,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
+ "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/traverse": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz",
@@ -9179,6 +9621,19 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
@@ -9186,6 +9641,41 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
+ "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.16.0",
+ "tr46": "^7.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9295,6 +9785,23 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",