Context
Keyfount derives passwords deterministically: the registrable domain (extracted via tldts in src/shared/domain.ts:26) is the Argon2id salt, so every subdomain of a site shares one password and an account cannot be reused across domains. AccountEntry (src/shared/types.ts:76) is keyed on (domain, username), which is also the sync identity. Full cross-repo design: docs/superpowers/specs/2026-05-28-subdomains-and-linked-domains-design.md (in the desktop repo).
Problem
- A subdomain cannot have its own password (e.g.
x.y.com vs w.y.com).
- An account saved for
x.com cannot be offered on a second site y.com.
- Matching is registrable-only at four call sites:
src/popup/vault.ts:37, src/content/Badge.tsx:476, src/entrypoints/content.ts:73 and :128, src/background/context-menus.ts:41.
Proposed approach
- Add optional
linkedDomains?: string[] to AccountEntry (src/shared/types.ts); it is match-only and never affects derivation. Identity stays (domain, username).
- Add
fullHost(), domainMatches(m, host) and matchAccounts(url, accounts) to src/shared/domain.ts implementing the rule: a registrable match domain matches every subdomain (broad, current behaviour); a full-host match domain matches the exact host only (narrow). Match set per account = {domain} ∪ linkedDomains; rank exact-host > registrable, then lastUsedAt.
- Replace the four registrable-only call sites with
matchAccounts.
src/background/accounts.ts: filter via the match rule (not e.domain === domain); add link/unlink helpers; recordAccount takes the chosen canonical domain.
- Save flow: per-account granularity toggle on a subdomain page — registrable by default, opt-in to full host.
- Account editor (
src/popup/components/AccountDetailScreen.tsx): linked-domains add/remove + "use an existing account here" on an unmatched site.
- Carry
linkedDomains through sync ops/snapshots (src/background/sync/*); tombstones unchanged.
Acceptance criteria
Related issues
Context
Keyfount derives passwords deterministically: the registrable domain (extracted via
tldtsinsrc/shared/domain.ts:26) is the Argon2id salt, so every subdomain of a site shares one password and an account cannot be reused across domains.AccountEntry(src/shared/types.ts:76) is keyed on(domain, username), which is also the sync identity. Full cross-repo design:docs/superpowers/specs/2026-05-28-subdomains-and-linked-domains-design.md(in the desktop repo).Problem
x.y.comvsw.y.com).x.comcannot be offered on a second sitey.com.src/popup/vault.ts:37,src/content/Badge.tsx:476,src/entrypoints/content.ts:73and:128,src/background/context-menus.ts:41.Proposed approach
linkedDomains?: string[]toAccountEntry(src/shared/types.ts); it is match-only and never affects derivation. Identity stays(domain, username).fullHost(),domainMatches(m, host)andmatchAccounts(url, accounts)tosrc/shared/domain.tsimplementing the rule: a registrable match domain matches every subdomain (broad, current behaviour); a full-host match domain matches the exact host only (narrow). Match set per account ={domain} ∪ linkedDomains; rank exact-host > registrable, thenlastUsedAt.matchAccounts.src/background/accounts.ts: filter via the match rule (note.domain === domain); add link/unlink helpers;recordAccounttakes the chosen canonical domain.src/popup/components/AccountDetailScreen.tsx): linked-domains add/remove + "use an existing account here" on an unmatched site.linkedDomainsthrough sync ops/snapshots (src/background/sync/*); tombstones unchanged.Acceptance criteria
matchAccountsunit-tested table-driven: broad, narrow, linked, precedence,localhost/chrome://→ no match.linkedDomains; tombstone identity unchanged.npm run lint,npm run typecheck,npm testand the coverage gate pass.Related issues
shared/modules this feature extends (cross-repo package extraction pending)