diff --git a/landing/app/open/fragment.test.ts b/landing/app/open/fragment.test.ts index 8a9f754..0c00f04 100644 --- a/landing/app/open/fragment.test.ts +++ b/landing/app/open/fragment.test.ts @@ -32,10 +32,10 @@ describe("readFragment", () => { // Every route the app understands must also be listed here before a link to // it can work; an unlisted one has to fail closed rather than hop. it.each([ - "#settings?section=mcp", "#plugin/install?id=evil", "#snippet/install?id=evil", "#notification/1", + "#pluginInstall?id=evil", "#connect?host=evil.example", "#JOIN?s=" + UUID + "&t=tok3n", "#join/../verified?u=" + UUID, @@ -83,3 +83,39 @@ describe("readFragment", () => { }); }); }); + +describe("the navigate routes", () => { + it("builds a notification target with and without an entry id", () => { + expect(readFragment("#notification?n=invite%3A42")).toEqual({ + url: "voltius://notification?n=invite%3A42", + code: null, + }); + expect(readFragment("#notification")).toEqual({ + url: "voltius://notification", + code: null, + }); + }); + + it("rejects an entry id past the length the app accepts", () => { + expect(readFragment(`#notification?n=${"a".repeat(201)}`)).toBeNull(); + expect(readFragment(`#notification?n=${"a".repeat(200)}`)).not.toBeNull(); + }); + + it("passes a settings section the app can render", () => { + expect(readFragment("#settings?section=integrations")).toEqual({ + url: "voltius://settings?section=integrations", + code: null, + }); + }); + + it.each(["#settings?section=mcp", "#settings?section=__proto__", "#settings"])( + "rejects %s", + (hash) => { + expect(readFragment(hash)).toBeNull(); + }, + ); + + it("builds a billing target with no query string", () => { + expect(readFragment("#billing")).toEqual({ url: "voltius://billing", code: null }); + }); +}); diff --git a/landing/app/open/fragment.ts b/landing/app/open/fragment.ts index 12a26ab..0ac9352 100644 --- a/landing/app/open/fragment.ts +++ b/landing/app/open/fragment.ts @@ -12,9 +12,24 @@ const isSessionId = (value: string): boolean => UUID_RE.test(value); // reject a malformed one: an unparseable fragment must skip the hop entirely // and render the invalid state, not bounce the user out to a scheme URL the // client silently drops. +// Mirrors `SETTINGS_SECTIONS` in voltius (src/stores/uiStore.ts). A section the +// app cannot render must fail here rather than open an empty modal. +const SETTINGS_SECTIONS = new Set([ + "appearance", "account", "sync", "vaults", "plugins", "integrations", "terminal", + "sftp", "portForwarding", "hosts", "shortcuts", "diagnostics", "about", +]); + +// Mirrors `MAX_ENTRY_ID` in voltius (src/services/deepLinkUrl.ts). +const MAX_ENTRY_ID = 200; + const ROUTE_VALIDATORS: Record boolean> = { join: (params) => isSessionId(params.get("s") ?? "") && !!params.get("t"), verified: (params) => isSessionId(params.get("u") ?? ""), + // The inbox id is opaque — entries are re-derived from server state — so only + // its length is checked; an id the app no longer holds just opens the centre. + notification: (params) => (params.get("n") ?? "").length <= MAX_ENTRY_ID, + settings: (params) => SETTINGS_SECTIONS.has(params.get("section") ?? ""), + billing: () => true, }; export type Target = {