diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts index f6cac0748..8707e2cb7 100644 --- a/src/services/deepLinkUrl.test.ts +++ b/src/services/deepLinkUrl.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { intentKey, isAttenuatedIntent, isConfirmIntent, isNavigateIntent, isSilentIntent, isUnpromptedIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID } from "./deepLinkUrl"; +import { intentKey, isAttenuatedIntent, isConfirmIntent, isNavigateIntent, isUnpromptedIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID, type SilentIntent } from "./deepLinkUrl"; const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; const TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; @@ -80,11 +80,17 @@ test("join is a confirm route and verified is an attenuated one", () => { const verified = parseDeepLink(`voltius://verified?u=${USER}`)!; expect(isConfirmIntent(parseDeepLink(`voltius://join?s=${SESSION}&t=${TOKEN}`)!)).toBe(true); expect(isAttenuatedIntent(verified)).toBe(true); - expect(isSilentIntent(verified)).toBe(false); // Attenuated still acts without a prompt; that is the whole point of the class. expect(isUnpromptedIntent(verified)).toBe(true); }); +test("the silent class is empty", () => { + // No runtime assertion can pin this: `isSilentIntent` returns false for every + // intent while the class has no members, so the typecheck is the only gate. + const silentIsEmpty: [SilentIntent] extends [never] ? true : false = true; + expect(silentIsEmpty).toBe(true); +}); + test("builds a join link in both forms", () => { const intent = { route: "join", sessionId: SESSION, token: TOKEN } as const; expect(buildDeepLink(intent, "scheme")).toBe(`voltius://join?s=${SESSION}&t=${TOKEN}`); @@ -184,7 +190,7 @@ test("parses a billing link, which takes no parameters", () => { expect(parseDeepLink("voltius://billing?section=account")).toEqual({ route: "billing" }); }); -test("the navigate routes are neither confirm nor silent", () => { +test("the navigate routes are not confirm routes", () => { for (const url of [ "voltius://notification", "voltius://settings?section=account", @@ -193,7 +199,6 @@ test("the navigate routes are neither confirm nor silent", () => { const intent = parseDeepLink(url)!; expect(isNavigateIntent(intent)).toBe(true); expect(isConfirmIntent(intent)).toBe(false); - expect(isSilentIntent(intent)).toBe(false); } }); diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts index f2b1e746f..ad12f4107 100644 --- a/src/services/deepLinkUrl.ts +++ b/src/services/deepLinkUrl.ts @@ -34,10 +34,10 @@ type Route = DeepLinkIntent["route"]; * person at the wrong moment, still costs the tapping user nothing. * * `verified` is the only member, and is classified on the strongest form it - * will carry rather than the weakest form it carries today. Today the portal + * could carry rather than the weakest form it carries today. Today the portal * spends the verification token server-side and the app receives an inert - * user id, which would also satisfy `silent`; once the mail link opens the app - * directly the app receives the raw token instead. That capability is a + * user id, which would also satisfy `silent`; a mail link that opened the app + * directly would hand it the raw token instead. That capability is a * single-use, short-lived token, bound server-side to one account, proving an * address is reachable — no session, no key material, no grant. So a hostile * `verified` link carries the *attacker's* own token: tapping it verifies the @@ -50,13 +50,16 @@ type Route = DeepLinkIntent["route"]; * a forged one could spend *against* the tapper — a session, a wrapped key, an * account-scoped grant. That route is `confirm`, not `attenuated`. * - * Open, and it gates putting these links in mail rather than this - * classification: enterprise rewriters (Outlook SafeLinks, Defender ATP) - * re-encode the link onto their own logging host. The tap then resolves - * against *that* host, so no App Link fires and the mail client's own browser - * follows the redirect — the app never sees the link at all, and whatever the - * rewriter kept of it sits in the gateway's logs. Whether a fragment survives - * the round trip is undocumented and untested here. + * The stronger form stays hypothetical because mailing the raw token was + * considered and rejected. Enterprise rewriters (Outlook SafeLinks, Defender + * ATP) re-encode a mailed link onto their own logging host, so the tap + * resolves against *that* host: no App Link fires, the mail client's own + * browser follows the redirect, and the app never sees the link at all. + * Whether a fragment even survives that round trip is undocumented, while the + * one rewriter behaviour that is documented mangles query structure. A carrier + * that can silently drop the token is not worth what it would buy, which is + * one browser tab: the portal already hands the app a `verified` link the + * moment it succeeds. * - `silent` routes carry no capability at all and run a side effect unprompted. * Currently unpopulated: it is the narrower claim `verified` used to make, * kept for a route that genuinely carries nothing.