Deep-Link Scheme Protocol
Normative spec for the per-app custom URL schemes, locked by epic #4472 decision D1. Registration for ROOT and the developer configs was implemented by #4474; LEAF-generation stamping and the server-side redirect validation are implemented against this page by #4475.
The problem this fixes
Better Auth desktop sign-in is deep-link-only: the system browser finishes the sign-in and redirects to a custom-scheme URL that macOS routes to whichever app bundle registered that scheme. Before this protocol, ROOT (zudotext.app) registered no scheme while every generated LEAF claimed the same zudotext: (hardcoded in the stub-skeleton Info.plist.template). macOS resolves a multi-claimant scheme to an essentially arbitrary bundle, so the callback landed in a random LEAF whose auth flow was not pending — a silent failure with ROOT waiting forever.
The fix: every app claims exactly one scheme of its own, and no two distinct apps can ever map to the same scheme.
Scheme mapping (normative)
| App | Scheme | Example callback |
|---|---|---|
ROOT (zudotext.app) | zudotext | zudotext: |
LEAF <name> | zudotext-<name> | zudotext- |
Developer-config builds (tauri.conf.<name>.json) | same rule: zudotext-<name> | zudotext-, zudotext- |
The bare scheme
zudotextis reserved for ROOT. No LEAF or dev build may claim it.A LEAF scheme is the fixed prefix
zudotext-followed by the app name, unchanged. The app name must already satisfy the canonical app-name grammar (see below) — the mapping never rewrites it.
Grammar
App names are validated (not normalized — see next section) against the canonical pattern, enforced identically in three mirrored places:
APP_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/TypeScript:
APP_NAME_PATTERN/validateAppName()inpackages/app- scaffold/ src/ app- name. ts Rust:
is_valid_app_name()intauri-app/ core/ src/ generator/ app_ name. rs Renderer dialog:
APP_NAME_PATTERNvia the@takazudo/subpath exportapp- scaffold/ app- name
The scheme family is therefore:
SCHEME = ROOT_SCHEME | LEAF_SCHEME
ROOT_SCHEME = "zudotext"
LEAF_SCHEME = "zudotext-" APP_NAME
as one regex: /^zudotext(-[a-z0-9]+(-[a-z0-9]+)*)?$/Recovering the app name from a LEAF scheme is unambiguous: strip the fixed-length prefix zudotext- (never split on dashes — app names may contain dashes, e.g. zudotext-zt-notes → app zt-notes).
Every scheme in the family is valid per RFC 3986 §3.1 (scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )): the fixed prefix supplies the required leading letter, so even a digit-leading app name like 2do yields a valid scheme (zudotext-2do).
Normalization rules (normative)
The load-bearing design decision: there is no lossy normalization layer. The name→scheme mapping is prefix-concatenation over an already-validated name. Input that fails APP_NAME is rejected at generation time — never lowercased, stripped, truncated, or otherwise coerced into a valid name.
Why rejection instead of coercion: any lossy normalizer makes two distinct accepted inputs collide (ZT-Notes and zt-notes would both fold to zudotext-zt-notes), which would then require a collision-fallback policy, per-machine collision state, and a way to explain to the user why their app got a mangled scheme. Rejection keeps the mapping injective by construction, so:
Case — app names are lowercase by validation; no case folding is ever applied on the way in. On receipt, treat the scheme part of an incoming URL case-insensitively (RFC 3986 §3.1 — browsers and the OS may alter scheme case in transit;
ZUDOTEXT-must be accepted and treated asMODMSG: / / auth/ callback zudotext-modmsg). The path/query are untouched by this rule.Length — the scheme layer imposes no length limit of its own; a scheme is exactly
9 + len(name)characters ("zudotext-"is 9). Practical bounds come from the app-name layer (macOS caps the<name>.appfilename at 255 bytes, so a name is at most 251 chars and a scheme at most 260). If a hard cap is ever wanted, add it to the three app-name validators, not to the scheme mapper. Servers validating an incoming scheme string SHOULD apply a defensive cap of 260 chars before the regex — the maximum reachable scheme length, so no name that can produce an installable app is ever rejected by the cap.Illegal characters — anything outside
[a-z0-9-], plus leading dash, trailing dash, double dash, and the empty string: rejected upstream, never sanitized.Collision fallback — none exists, deliberately. Two different app names cannot produce the same scheme (injectivity), and two LEAVES with the same name cannot coexist (the assembler refuses when
~/Applications/<name>.appalready exists). The only remaining collision class is duplicate bundles of the same app (a stale copy in~/Downloads, a Time Machine restore) — that is a macOS LaunchServices ambiguity, not a mapping collision; see Verifying registration for remediation.Reserved name — the app name
zudotextis reserved; generators MUST reject it as a LEAF name (#4475 adds this check). Not because of a scheme collision — a hypotheticalzudotextLEAF would map tozudotext-zudotext— but because everything else about its identity collides with ROOT: its bundle identifier would becom.takazudo.zudotext, and runtime role detection (resolve_exe_app_name() == "zudotext") would make the LEAF believe it is ROOT and expose the generator surface.
Worked examples
| Input app name | Verdict | Scheme |
|---|---|---|
modmsg | valid | zudotext-modmsg |
zt-notes | valid | zudotext-zt-notes (name recovered by prefix-strip, not dash-split) |
notes2 | valid | zudotext-notes2 |
2do | valid | zudotext-2do (prefix supplies the RFC 3986 leading letter) |
a | valid | zudotext-a |
zudotext | rejected — reserved for ROOT | — |
ZTOffice | rejected (uppercase) — never case-folded to ztoffice | — |
zt_notes | rejected (underscore) — never rewritten to zt-notes | — |
zt--notes | rejected (double dash) | — |
-zt, zt- | rejected (leading/trailing dash) | — |
zt notes | rejected (space) | — |
メモ | rejected (non-ASCII) | — |
| `` (empty) | rejected | — |
Registration points
ROOT — tauri-app/tauri.conf.json
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["zudotext"]
}
}
}The Tauri v2 bundler reads plugins.deep-link.desktop.schemes at bundle time and bakes a CFBundleURLTypes entry into the produced Info.plist (with CFBundleURLName set to the bundle identifier). pnpm tauri:build (scripts/) produces ROOT's bundle via cargo tauri build --bundles app on this base config, so the built zudotext.app carries:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.takazudo.zudotext</string>
<key>CFBundleURLSchemes</key>
<array>
<string>zudotext</string>
</array>
</dict>
</array>The Rust side needs only tauri_plugin_deep_link::init() (already registered in tauri-) and the deep-link:default capability (already in tauri-). On macOS the plugin performs no runtime registration — the OS learns the scheme from Info.plist when the bundle is installed/scanned by LaunchServices.
Per-app developer configs — the config-merge hazard
cargo tauri build --config tauri.conf.<name>.json JSON-merges the override file onto the base tauri.conf.json: objects merge deep, arrays replace wholesale. Without an override of its own, every per-app config would silently inherit ROOT's ["zudotext"] and mint another bundle fighting over ROOT's scheme.
Rule: every tauri.conf.<name>.json MUST override plugins.deep-link.desktop.schemes with ["zudotext-<name>"]. This mirrors the existing bundle.resources: [] override (which uses the same array-replacement semantics to drop the stub-skeleton resources).
Current overrides: tauri.conf.writing.json → ["zudotext-writing"] (the writing dev entry embeds the full renderer including the auth flow, so a bundled writing.app genuinely receives callbacks), and tauri.conf.ztadmin.json → ["zudotext-ztadmin"] (the admin renderer has no deep-link consumer today; the override exists to keep the merged config from claiming ROOT's scheme).
Generated LEAVES — stub skeleton + child assembler (implemented by #4475)
Current state (the bug this protocol retires): tauri- hardcodes a CFBundleURLTypes entry claiming zudotext under CFBundleURLName com.takazudo.zudotext, and the assembler (tauri-) carries it through unchanged — which is exactly how seven installed LEAVES all ended up claiming ROOT's scheme.
#4475 MUST change both halves:
Assembler stamping —
CFBundleURLSchemes(→["zudotext-<name>"]) andCFBundleURLName(→com.takazudo.<name>, i.e.child_bundle_identifier(app_name)) join the identity keys the assembler overwrites per LEAF, alongsideCFBundleName/CFBundleDisplayName/CFBundleIdentifier/CFBundleExecutable/CFBundleIconFile. Both the ROOT runtime assembler (assembleChild) and the headlessstamp-leafCLI go through this transform.Template placeholder — the skeleton template must stop claiming
zudotext. Use the placeholder-identity schemezudotext-textapp(consistent with the template's placeholdercom.takazudo.textapp/ "Text App"), so even a raw un-stamped skeleton can never shadow ROOT.Reserved-name rejection — reject the app name
zudotextat generation entry (see rule 5 above).Existing installs — LEAVES already on disk keep their stale
zudotext:claim until regenerated/re-stamped (pre-release: acceptable;/ / /rebuilds the local set).l- demo- builds
Runtime self-identification
An app derives its own scheme at runtime from its .app stem, the same discriminator the role model uses (tauri-):
name = resolve_exe_app_name() // ".app" stem walk from current_exe()
scheme = if name == "zudotext" { "zudotext" } else { "zudotext-" + name }Dev-mode fallback: with no .app ancestor (e.g. cargo tauri dev), resolve_exe_app_name() resolves to ROOT (zudotext) unless ZUDOTEXT_APP_NAME overrides it — matching the role model's policy.
The two halves of that formula deliberately live on opposite sides of the bridge, so neither is duplicated:
Name — Rust.
app_mode::resolve_self_app_name()applies the dev-mode policy above; theapp_name_getcommand hands it to the renderer asBackendAPI.appName(). Browser-context adapters (mock, REST) report ROOT's name: they have no bundle, register no scheme, and never receive a deep link.Scheme — TypeScript.
appDeepLinkScheme()inpackages/is the mirror of Rust'sapp- scaffold/ src/ app- name. ts leaf_scheme()(tauri-), which stamps the same value into each LEAF'sapp/ core/ src/ generator/ child_ assembly. rs CFBundleURLSchemes.getAppDeepLinkScheme()in@takazudo/backend-bridgecomposes the two and caches the result for the process lifetime.
An unknown identity is an error, never ROOT. Every adapter answers with a name, so a failure to resolve one is genuine — and defaulting it to zudotext would silently reproduce the bug the protocol exists to prevent (a LEAF redirecting its callback to ROOT, or subscribing to a scheme it does not register). getAppDeepLinkScheme() rejects, sign-in surfaces the error, and a deep-link listener declines to register rather than bind to the wrong scheme. This is the same reasoning as the server's fail-closed rule for an invalid scheme parameter: only true absence may default.
macOS dev builds cannot receive deep links
On macOS, custom-scheme registration happens only via a bundle'sInfo.plist; a bare cargo tauri dev / pnpm writing:dev binary has no bundle and cannot be a deep-link target at all. End-to-end callback testing requires a built, installed bundle.
Auth-callback wire contract (server side, implemented by #4475)
The Better Auth desktop handoff (workers/) currently hardcodes the redirect target zudotext:. Under per-app schemes:
Callback URL shape —
<scheme>:. Only the scheme varies per app; path and params are fixed./ / auth/ callback? ott= …& state= … schemequery parameter —GET /auth/desktop-handoffgains an optionalschemeparameter carrying the caller's runtime-derived scheme.MUST match
/as a full-string, case-sensitive match (the server enforces canonical lowercase — it is generating a URL, not parsing one), after the defensive length cap (≤ 260 chars, see the Length rule above) applied before the regex.^zudotext(- [a- z0- 9]+(- [a- z0- 9]+)*)? $/ Invalid → 400, fail closed. Never fall back to a default on an invalid value — a malformed scheme reaching the redirect would be an open-redirect primitive.
Absent → default
zudotext(ROOT). This kept the pre-per-app client working during the cutover window. The desktop client now always sends its own scheme (see "Client side" below), so absence is a legacy-client path only — it stays for robustness, not because anything relies on it.
trustedOrigins —
workers/currently trusts the literalsync- server/ src/ better- auth. ts zudotext:origin only. #4475 must make every per-app scheme origin acceptable; whether the shipped Better Auth version supports wildcard custom-scheme origins (/ / zudotext-) or needs a dynamic*: / / trustedOriginsfunction is an implementation detail to verify there, not assume.The handoff page builds the redirect from the validated scheme; the
ottandstateembedding rules are unchanged.
Client side (implemented by #4508)
The server half above is inert unless the app names its own scheme on both legs of the handshake. Producer and consumer live in packages/:
Outbound —
beginBetterAuthSignIn(serverUrl, scheme)appends&scheme=<own scheme>to the handoff URL and remembers the scheme on the pending flow. The caller (tauri-adapter.ts'sauth.login) resolves it throughgetAppDeepLinkScheme().Inbound —
handleBetterAuthCallbackrejects a callback whose scheme is not the one it asked for, comparing case-insensitively per the Case rule above. It deliberately does not clear the pending flow on that rejection: a URL bearing another app's scheme is not this app's to consume, and consuming it would let a stray deep link cancel a sign-in still in flight.Listeners — the renderer's two
onOpenUrlsubscriptions (renderer/at boot scope,hooks/ use- auth- deep- link. ts use-deep-link-routing.tsinside<App>) both filter on the app's own scheme rather than a hardcodedzudotext:, so a LEAF hears its own callbacks and ignores every other app's.
Generic open links
Every registered app scheme can address a workspace item through the generic open route:
<scheme>://open?workspace=<workspaceId>&path=<relPath>&itemId=<idStable> | Parameter | Value |
|---|---|
workspace | The workspace id used by the app's current workspace. |
path | Relative path of the source Markdown file within the workspace. |
itemId | Optional stable item id (idStable) for a card or todo item. |
When the workspace matches, the app comes to the foreground and navigates to the source document. A link for another workspace is ignored with a non-blocking toast; the app does not switch workspaces automatically.
Authentication is separate: the Better Auth desktop handoff receives the runtime scheme and returns the OTT to that exact ROOT or LEAF callback.
Verifying registration
Inspect what a built bundle claims:
/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" \
/Applications/zudotext.app/Contents/Info.plistFind every bundle LaunchServices thinks claims a zudotext scheme:
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister \
-dump | grep -B 10 "zudotext"When two copies of the same app claim one scheme (stale copy, restored backup), macOS picks one arbitrarily — delete the extra bundle; LaunchServices re-scans on its own (or force it with lsregister -f <path-to-kept.app>).
Related
App Generation — the LEAF assembly pipeline this protocol's stamping step (#4475) extends
Thin-Launcher Contract — why every bundle is a stub with a stamped
Info.plist