Where voltius:// stands today
The route framework landed with two routes:
| Route |
Trust class |
Carries |
Produced by |
Consumed by |
voltius://join?s=<sessionId>&t=<token> |
confirm |
a live session capability |
buildInviteLink() (src/services/inviteCode.ts:10), called from ShareMenu.tsx |
DeepLinkJoinModal.tsx |
voltius://verified?u=<users.id> |
silent |
nothing authorising |
the web portal, after the server has already consumed the token |
handleSilentIntent (src/services/deepLinkHandlers.ts) |
Everything under the routes is route-agnostic and free for new routes: the TRUST table and per-route parsers in deepLinkUrl.ts, the FIFO plus cold-start echo suppression in deepLinkStore.ts, the handle_cli_arguments rescue for warm links on Windows and Linux (src-tauri/src/lib.rs:404), and the dev-build self-registration at lib.rs:489.
The gap is that voltius:// is the only wire format. On a machine without Voltius installed, a link does nothing visible at all. Only the email-verification flow degrades gracefully, and only because the portal card already says "Email verified" before the button is reached.
Part 1 — an https fallback in front of every route
Make https the canonical shareable form and the custom scheme an implementation detail.
A landing page at https://voltius.app/open attempts the scheme immediately on load, then falls back to a download call-to-action if nothing takes over the tab.
The route and its parameters must live in the URL fragment, not the query string. A join link carries a live session token; putting it in a query string hands it to the web server's access log, any CDN in front of it, and the Referer header of anything the page later loads. A fragment is never sent to the server:
https://voltius.app/open#join?s=<sessionId>&t=<token>
The page reads location.hash, rebuilds voltius://join?… from it, and navigates. The token stays client-side for its whole life.
On mobile the browser hop can be skipped entirely with real universal links — .well-known/assetlinks.json plus android:autoVerify="true" for Android App Links, and apple-app-site-association for iOS and macOS. Windows and Linux have no equivalent, so the browser-to-scheme hop stays the mechanism there.
Work items:
web: the /open page, the fragment-to-scheme bridge, the download fallback.
web: .well-known/assetlinks.json (needs the release signing cert SHA-256) and apple-app-site-association.
voltius: buildInviteLink() emits the https form. Extend it into one builder taking a route plus parameters rather than forking a second function per route.
voltius: parseInviteCode() / resolveJoinInput() accept the https form alongside the scheme form and the bare sessionId:token, so pasted links keep working in both directions.
voltius: Android manifest intent filter for the App Link, alongside the existing scheme filter.
Part 2 — reworking email verification on top of the fallback
Today: the mail links to https://voltius.app/verify-email?token=X, the portal consumes the token, and the success card offers a button to voltius://verified?u=<user_id>. The user has to notice the button and click it.
Two ways to improve it, with different risk:
Option A — the portal auto-fires the scheme. Same flow, but the success card navigates to voltius://verified?u=… on mount instead of waiting for a click, keeping the button as the manual fallback. The token is still consumed server-side before the scheme is ever reached, so verified stays silent and inert. Small change, no new security surface. Worth doing regardless of Option B. — ✅ Shipped in VoltiusApp/web#9, live 2026-08-18; the card also degrades to "If Voltius did not open, use the button below." after 2.5s.
Option B — the mail link opens the app directly. With App Links and AASA in place, tapping the mail link on Android or iOS hands https://voltius.app/verify-email?token=X straight to Voltius, with no browser tab at all. This matters most on mobile, where the browser is fully occluding.
The trade-off is real and needs a decision before building it: the app now receives the raw single-use verification token rather than an inert user id, so the route stops being silent in the sense the framework defines. It is a weak capability — it verifies the email of exactly the account it was minted for and nothing else — but it is a capability, and any local process can fire the same URL at the app. Either the route is reclassified confirm (a sheet on an email-verification tap is poor UX) or the framework grows a documented third position for "carries a capability so weak that acting on it unprompted is safe", with verified as its first and carefully argued member.
Mail-scanner prefetching is worth noting but is not a regression: a scanner that fetches the https URL burns the token server-side today too.
Recommendation: ship A now, and gate B on the assetlinks/AASA work in Part 1, treating the trust-class question as its own review. A is shipped; B is still open.
Part 3 — routes the framework should grow next
The route-framework design already listed these as expected additive follow-ups. In rough value order:
notification/<id> — navigate. The inbox (teamInbox.ts) and the knock channel already exist; a push notification or email should be able to open the exact bell entry. No secret in the link.
invite?h=@handle — confirm. Every user now has a handle. A link that means "knock on me" reuses the whole unified-invite-flow path.
plugin/install?id=… — confirm, non-negotiably. It is code execution, and a deep link is unauthenticated input.
snippet/install?id=… — confirm. The marketplace and snippetShare.ts are already there.
settings?section=… — navigate. Notably section=integrations, which is where MCP client setup lives: today the user has to hunt for the panel. (This bullet originally said section=mcp. There is no mcp settings section — SETTINGS_SECTIONS in src/stores/uiStore.ts is the runtime source of truth and the SettingsSection type derives from it. Corrected 2026-08-19 so the next reader does not go looking for it.)
billing — navigate. utils/billing.ts already sends the user out to the portal with no way back.
connect — still excluded. A route that can name a host to connect to is the phishing-shaped one and needs its own threat review before anyone writes a parser for it.
Part 4 — vault invite links
The obvious ask is a Discord-style "anyone with this link joins the vault". It cannot work the way Discord's does, and it is worth writing down why so the idea stops resurfacing.
Discord's server holds the content, so a link can be a pure capability. A Voltius team vault is end-to-end encrypted: teamVaultSync.ts wraps the vault key per member with wrapSessionKeyForUser(rawKey, member.public_key) over X25519. There is no recipient public key at link-creation time, so no link can carry vault access.
What can work is a link that grants membership, with the key following separately:
voltius://team/join?g=<grant_id>&c=<one-time-code> # confirm class
- The host asks the server for a grant with a max use count, a TTL, and revocation. It must be a server-side object checked at redemption, not a self-contained token — a revoked-in-DB grant that still authorised has already bitten us once.
- The guest accepts the sheet, posts the code, and the server inserts a
team_members row carrying the guest's X25519 public key.
- The guest is a member but keyless until some existing key-holder runs
reconcileTeamVaultKeys (teamVaultSync.ts:235).
Step 3 is the part that makes this more than a link feature. The key-distribution window already leaves invitees keyless today; a link just makes it the default first experience for every new member. It should be closed in the same change: the server pushes a "new member awaiting key" event over the existing SSE channel so an online key-holder wraps immediately, and the guest sees an honest "waiting for a key" state rather than an empty vault.
That last part is #70, and the share-sheet surface that would produce the link is #68. This section is context for those two, not a competing plan.
Rules any new route has to follow
- Never put
account_id in a URL. Despite the name it is the KDF salt passed to derive_keys, and exposing it hands an attacker offline precompute against that user's password.
- Never put wrapped key material in a URL. URLs reach browser history, OS argv, and any local process listening on the scheme.
- A route carrying a capability is
confirm. A deep link is unauthenticated input: any local process and any web page the user visits can fire one.
- Extend the
TRUST and ROUTES tables and the single link builder. Do not fork a builder or a parser per route.
Where
voltius://stands todayThe route framework landed with two routes:
voltius://join?s=<sessionId>&t=<token>confirmbuildInviteLink()(src/services/inviteCode.ts:10), called fromShareMenu.tsxDeepLinkJoinModal.tsxvoltius://verified?u=<users.id>silenthandleSilentIntent(src/services/deepLinkHandlers.ts)Everything under the routes is route-agnostic and free for new routes: the
TRUSTtable and per-route parsers indeepLinkUrl.ts, the FIFO plus cold-start echo suppression indeepLinkStore.ts, thehandle_cli_argumentsrescue for warm links on Windows and Linux (src-tauri/src/lib.rs:404), and the dev-build self-registration atlib.rs:489.The gap is that
voltius://is the only wire format. On a machine without Voltius installed, a link does nothing visible at all. Only the email-verification flow degrades gracefully, and only because the portal card already says "Email verified" before the button is reached.Part 1 — an
httpsfallback in front of every routeMake
httpsthe canonical shareable form and the custom scheme an implementation detail.A landing page at
https://voltius.app/openattempts the scheme immediately on load, then falls back to a download call-to-action if nothing takes over the tab.The route and its parameters must live in the URL fragment, not the query string. A join link carries a live session token; putting it in a query string hands it to the web server's access log, any CDN in front of it, and the Referer header of anything the page later loads. A fragment is never sent to the server:
The page reads
location.hash, rebuildsvoltius://join?…from it, and navigates. The token stays client-side for its whole life.On mobile the browser hop can be skipped entirely with real universal links —
.well-known/assetlinks.jsonplusandroid:autoVerify="true"for Android App Links, andapple-app-site-associationfor iOS and macOS. Windows and Linux have no equivalent, so the browser-to-scheme hop stays the mechanism there.Work items:
web: the/openpage, the fragment-to-scheme bridge, the download fallback.web:.well-known/assetlinks.json(needs the release signing cert SHA-256) andapple-app-site-association.voltius:buildInviteLink()emits thehttpsform. Extend it into one builder taking a route plus parameters rather than forking a second function per route.voltius:parseInviteCode()/resolveJoinInput()accept thehttpsform alongside the scheme form and the baresessionId:token, so pasted links keep working in both directions.voltius: Android manifest intent filter for the App Link, alongside the existing scheme filter.Part 2 — reworking email verification on top of the fallback
Today: the mail links to
https://voltius.app/verify-email?token=X, the portal consumes the token, and the success card offers a button tovoltius://verified?u=<user_id>. The user has to notice the button and click it.Two ways to improve it, with different risk:
Option A — the portal auto-fires the scheme. Same flow, but the success card navigates to
voltius://verified?u=…on mount instead of waiting for a click, keeping the button as the manual fallback. The token is still consumed server-side before the scheme is ever reached, soverifiedstayssilentand inert. Small change, no new security surface. Worth doing regardless of Option B. — ✅ Shipped in VoltiusApp/web#9, live 2026-08-18; the card also degrades to "If Voltius did not open, use the button below." after 2.5s.Option B — the mail link opens the app directly. With App Links and AASA in place, tapping the mail link on Android or iOS hands
https://voltius.app/verify-email?token=Xstraight to Voltius, with no browser tab at all. This matters most on mobile, where the browser is fully occluding.The trade-off is real and needs a decision before building it: the app now receives the raw single-use verification token rather than an inert user id, so the route stops being
silentin the sense the framework defines. It is a weak capability — it verifies the email of exactly the account it was minted for and nothing else — but it is a capability, and any local process can fire the same URL at the app. Either the route is reclassifiedconfirm(a sheet on an email-verification tap is poor UX) or the framework grows a documented third position for "carries a capability so weak that acting on it unprompted is safe", withverifiedas its first and carefully argued member.Mail-scanner prefetching is worth noting but is not a regression: a scanner that fetches the
httpsURL burns the token server-side today too.Recommendation: ship A now, and gate B on the assetlinks/AASA work in Part 1, treating the trust-class question as its own review. A is shipped; B is still open.
Part 3 — routes the framework should grow next
The route-framework design already listed these as expected additive follow-ups. In rough value order:
notification/<id>—navigate. The inbox (teamInbox.ts) and the knock channel already exist; a push notification or email should be able to open the exact bell entry. No secret in the link.invite?h=@handle—confirm. Every user now has a handle. A link that means "knock on me" reuses the whole unified-invite-flow path.plugin/install?id=…—confirm, non-negotiably. It is code execution, and a deep link is unauthenticated input.snippet/install?id=…—confirm. The marketplace andsnippetShare.tsare already there.settings?section=…—navigate. Notablysection=integrations, which is where MCP client setup lives: today the user has to hunt for the panel. (This bullet originally saidsection=mcp. There is nomcpsettings section —SETTINGS_SECTIONSinsrc/stores/uiStore.tsis the runtime source of truth and theSettingsSectiontype derives from it. Corrected 2026-08-19 so the next reader does not go looking for it.)billing—navigate.utils/billing.tsalready sends the user out to the portal with no way back.connect— still excluded. A route that can name a host to connect to is the phishing-shaped one and needs its own threat review before anyone writes a parser for it.Part 4 — vault invite links
The obvious ask is a Discord-style "anyone with this link joins the vault". It cannot work the way Discord's does, and it is worth writing down why so the idea stops resurfacing.
Discord's server holds the content, so a link can be a pure capability. A Voltius team vault is end-to-end encrypted:
teamVaultSync.tswraps the vault key per member withwrapSessionKeyForUser(rawKey, member.public_key)over X25519. There is no recipient public key at link-creation time, so no link can carry vault access.What can work is a link that grants membership, with the key following separately:
team_membersrow carrying the guest's X25519 public key.reconcileTeamVaultKeys(teamVaultSync.ts:235).Step 3 is the part that makes this more than a link feature. The key-distribution window already leaves invitees keyless today; a link just makes it the default first experience for every new member. It should be closed in the same change: the server pushes a "new member awaiting key" event over the existing SSE channel so an online key-holder wraps immediately, and the guest sees an honest "waiting for a key" state rather than an empty vault.
That last part is #70, and the share-sheet surface that would produce the link is #68. This section is context for those two, not a competing plan.
Rules any new route has to follow
account_idin a URL. Despite the name it is the KDF salt passed toderive_keys, and exposing it hands an attacker offline precompute against that user's password.confirm. A deep link is unauthenticated input: any local process and any web page the user visits can fire one.TRUSTandROUTEStables and the single link builder. Do not fork a builder or a parser per route.