Ship recoverable collaborative accounts
- commit
- 21bf7a5ac0a39c3d0d3b13751655a573134a6055
- parent
- 6c20c75dd05e6b0aa173d91088adae7f8c201ab3
- parent
- 6c6eb607d14e17c9c25d8422d81f86949fd65e6a
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -4,6 +4,7 @@
44 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",55 "@nzip/lofi": "jsr:@nzip/lofi@0.2.0",66 "@nzip/lofi/": "jsr:@nzip/lofi@0.2.0/",7+ "@nzip/lofi/access": "jsr:@nzip/lofi@0.2.0/access",78 "@nzip/lofi/astro": "jsr:@nzip/lofi@0.2.0/astro",89 "@nzip/lofi/build": "jsr:@nzip/lofi@0.2.0/build",910 "@nzip/lofi/dev": "jsr:@nzip/lofi@0.2.0/dev",@@ -17,6 +18,7 @@
1718 "astro/config": "npm:astro@7.0.9/config",1819 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",1920 "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",21+ "jazz-tools/passkey-backup": "npm:jazz-tools@2.0.0-alpha.53/passkey-backup",2022 "jazz-tools/passphrase": "npm:jazz-tools@2.0.0-alpha.53/passphrase",2123 "preact": "npm:preact@10.29.7",2224 "preact/hooks": "npm:preact@10.29.7/hooks",Binary file.
@@ -1,4 +1,12 @@
1-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">2- <rect width="64" height="64" rx="16" fill="#18231f" />3- <path d="M19 18v28h27v-8H28V18z" fill="#d5f26a" />1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">2+ <title id="title">lofi leaf mark</title>3+ <rect width="512" height="512" rx="112" fill="#18231f" />4+ <path5+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"6+ fill="#d5f26a"7+ />8+ <path9+ d="M164 184h38v127h105v38H164ZM168 319l30 22-92 125-30-22Z"10+ fill="#18231f"11+ />412 </svg>Binary file.
Binary file.
Binary file.
@@ -10,7 +10,7 @@ export const app = defineLofiApp({
1010 // Identity is local-first: first boot opens a private, on-device account with1111 // no sign-in. When a managed Jazz app is configured (JAZZ_APP_ID / JAZZ_SERVER_URL —1212 // see `deno task jazz:provision`), the AccountGate island lets the user elect to13- // back up and sync it, and recover it from a phrase. See the framework's13+ // back up and sync it, and recover it from a passkey or phrase. See the framework's1414 // `docs/sync-and-recovery.md` guide.1515 //1616 // Hostnames you have committed to keeping stable, for the optional device-credential@@ -18,6 +18,9 @@ export const app = defineLofiApp({
1818 // binds to its origin; left empty, the served origin is trusted. Pin your permanent1919 // hostname(s) here before relying on a credential across a host change.2020 credentialOrigins: [] as readonly string[],21+ // Pin this to the canonical production hostname before users create recoverable22+ // passkeys. Omit it in local development to use the current hostname.23+ // passkey: { rpId: "app.example.com" },2124 sync: { adapter: "jazz" as const },2225 // Source/home link shown in the starter footer. Point it at your own repo.2326 repositoryUrl: "https://github.com/FelineStateMachine/lofi",@@ -2,15 +2,20 @@ import { useCallback, useEffect, useState } from "preact/hooks";
22 import {33 confirmPhraseAccess,44 createBackupPasskey,5+ createRecoverablePasskeyBackup,56 enableSyncBackup,7+ isAccountReplacementError,68 isAuthError,9+ isRecoverablePasskeyError,710 isRecoveryError,8- readSession,11+ readAccountSession,12+ restoreFromPasskey,913 restoreFromRecoveryPhrase,1014 revealRecoveryPhrase,1115 type Session,1216 stopSyncBackup,1317 } from "@nzip/lofi";18+import { encodeSharingIdentity } from "@nzip/lofi/access";1419 1520 /**1621 * Author-owned account example. lofi is local-first: the app already opened on a@@ -24,7 +29,14 @@ import {
2429 * phrase only matters once there is somewhere to sync to.2530 */2631 27-type Busy = "enable" | "stop" | "reveal" | "restore" | "protect";32+type Busy =33+ | "enable"34+ | "stop"35+ | "reveal"36+ | "restore"37+ | "passkey-restore"38+ | "recoverable"39+ | "protect";2840 2941 // Turns any thrown value into a sentence a person can act on.3042 function describe(error: unknown): string {@@ -39,6 +51,7 @@ function describe(error: unknown): string {
3951 }4052 }4153 if (isRecoveryError(error)) return error.message;54+ if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;4255 return error instanceof Error ? error.message : String(error);4356 }4457 @@ -49,12 +62,14 @@ export default function AccountGate() {
4962 const [phrase, setPhrase] = useState<string | null>(null);5063 const [phraseInput, setPhraseInput] = useState("");5164 const [restoring, setRestoring] = useState(false);65+ const [confirmReplacement, setConfirmReplacement] = useState(false);66+ const sharingIdentity = session?.user_id ? encodeSharingIdentity(session.user_id) : null;5267 // Set when a backup proceeded without a passkey guard (WebAuthn unavailable),5368 // so the phrase block can say so honestly rather than imply a confirmation.5469 const [unguarded, setUnguarded] = useState(false);5570 5671 useEffect(() => {57- setSession(readSession());72+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));5873 }, []);5974 6075 const run = useCallback(@@ -113,11 +128,29 @@ export default function AccountGate() {
113128 </header>114129 <p>115130 This account replicates to your managed Jazz app and can be recovered with its phrase.116- {session.phraseGuarded117- ? " Revealing the phrase asks for your passkey first."118- : " Reveal the phrase again any time to save it on another device."}131+ {session.passkeyRecoverable132+ ? " Its account-recovery passkey can restore the same identity where your passkey provider makes it available."133+ : " Create an account-recovery passkey if this browser supports one."}119134 </p>135+ {sharingIdentity && (136+ <p class="account-note">137+ Share identity: <code data-sharing-identity>{sharingIdentity}</code>138+ </p>139+ )}120140 <div class="account-actions">141+ {!session.passkeyRecoverable && (142+ <button143+ type="button"144+ disabled={disabled}145+ onClick={() =>146+ run("recoverable", async () => {147+ await createRecoverablePasskeyBackup();148+ return readAccountSession();149+ })}150+ >151+ {busy === "recoverable" ? "Creating…" : "Create account-recovery passkey"}152+ </button>153+ )}121154 <button122155 type="button"123156 disabled={disabled}@@ -142,10 +175,10 @@ export default function AccountGate() {
142175 onClick={() =>143176 run("protect", async () => {144177 setUnguarded(!(await createBackupPasskey()));145- return readSession();178+ return readAccountSession();146179 })}147180 >148- {busy === "protect" ? "Setting up…" : "Protect phrase with a passkey"}181+ {busy === "protect" ? "Setting up…" : "Add local phrase-reveal guard"}149182 </button>150183 )}151184 <button@@ -175,7 +208,8 @@ export default function AccountGate() {
175208 <p>176209 You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it177210 up to sync across your devices and recover it if this one is lost. Your existing data comes178- along. You will create a passkey first, so revealing your recovery phrase asks for it.211+ along. A recoverable passkey is the quick return path; the recovery phrase remains the212+ portable bearer-secret fallback.179213 </p>180214 <div class="account-actions">181215 <button@@ -183,7 +217,13 @@ export default function AccountGate() {
183217 disabled={disabled}184218 onClick={() =>185219 run("enable", async () => {186- setUnguarded(!(await createBackupPasskey()));220+ try {221+ await createRecoverablePasskeyBackup();222+ setUnguarded(false);223+ } catch (cause) {224+ if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") throw cause;225+ setUnguarded(true);226+ }187227 setPhrase(await revealRecoveryPhrase());188228 return await enableSyncBackup();189229 })}@@ -193,15 +233,42 @@ export default function AccountGate() {
193233 <button194234 type="button"195235 class="account-secondary"196- disabled={disabled}236+ disabled={disabled || !confirmReplacement}237+ onClick={() => {238+ setRestoring(true);239+ run("passkey-restore", () =>240+ restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {241+ setConfirmReplacement(false);242+ return next;243+ }));244+ }}245+ >246+ {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}247+ </button>248+ <button249+ type="button"250+ class="account-secondary"251+ disabled={disabled || !confirmReplacement}197252 onClick={() => {198253 setError(null);199- setRestoring((value) => !value);254+ setRestoring((value) =>255+ !value256+ );200257 }}201258 >202259 {restoring ? "Cancel" : "Restore from recovery phrase"}203260 </button>204261 </div>262+ <label class="account-note">263+ <input264+ type="checkbox"265+ checked={confirmReplacement}266+ disabled={disabled}267+ onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}268+ />{" "}269+ I understand restore replaces this device's current local account and unsynced data may be270+ lost.271+ </label>205272 {phraseBlock}206273 {restoring && (207274 <div class="account-field">@@ -225,9 +292,12 @@ export default function AccountGate() {
225292 run(226293 "restore",227294 () =>228- restoreFromRecoveryPhrase(phraseInput).then((next) => {295+ restoreFromRecoveryPhrase(phraseInput, {296+ confirmLocalReplacement: confirmReplacement,297+ }).then((next) => {229298 setRestoring(false);230299 setPhraseInput("");300+ setConfirmReplacement(false);231301 return next;232302 }),233303 )}@@ -6,6 +6,7 @@
66 "exports": {77 ".": "./package/runtime/mod.ts",88 "./astro": "./package/astro/mod.ts",9+ "./access": "./package/access/mod.ts",910 "./build": "./package/commands/build.ts",1011 "./create": "./package/create.ts",1112 "./dev": "./package/commands/dev.ts",@@ -24,7 +25,9 @@
2425 "astro/config": "npm:astro@7.0.9/config",2526 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",2627 "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",28+ "jazz-tools/passkey-backup": "npm:jazz-tools@2.0.0-alpha.53/passkey-backup",2729 "jazz-tools/passphrase": "npm:jazz-tools@2.0.0-alpha.53/passphrase",30+ "jazz-tools/testing": "npm:jazz-tools@2.0.0-alpha.53/testing",2831 "playwright": "npm:playwright@1.61.1",2932 "preact": "npm:preact@10.29.7",3033 "preact/hooks": "npm:preact@10.29.7/hooks",@@ -41,8 +44,9 @@
4144 },4245 "tasks": {4346 "build": "deno task --cwd apps/reference build",44- "check": "deno fmt --check . && deno lint && deno task test:unit",45- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package apps/reference/tests",47+ "check": "deno fmt --check . && deno lint && deno task test:unit && deno task test:access",48+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/runtime package/testing package/tooling apps/reference/tests",49+ "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",4650 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",4751 "test": "deno task test:unit",4852 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",@@ -67,9 +71,11 @@
6771 "docs/README.md",6872 "docs/auth-identity.md",6973 "docs/auth.md",74+ "docs/decisions/passkey-backup-alpha53.md",7075 "docs/data-and-ui.md",7176 "docs/deployment.md",7277 "docs/getting-started.md",78+ "docs/examples",7379 "docs/permissions.md",7480 "docs/reference",7581 "docs/sync-and-recovery.md",@@ -38,6 +38,9 @@ The root [README](../README.md) provides the shortest product overview and comma
3838 - [Configuration](reference/configuration.md)3939 - [Generated project layout](reference/project-layout.md)4040 - [Identity and recovery model](auth-identity.md)41+- [Account and access API](reference/access.md)42+- [Shared example](examples/shared.md)43+- [Group example](examples/group.md)4144 - [Advanced device-auth primitive](auth.md)4245 4346 ## The author boundary@@ -0,0 +1,38 @@
1+# lofi brand assets2+3+The visual system treats **lofi** as both a name and a quiet abbreviation for **lo**cal **fi**rst.4+The two halves remain one uninterrupted word and are distinguished by color rather than spacing or5+punctuation.6+7+## Assets8+9+| File | Use |10+| ----------------------------- | ------------------------------------------------------------------------------ |11+| `lofi-mark.svg` | Monochrome leaf mark. It inherits `currentColor`. |12+| `lofi-mark-color.svg` | Fixed acid-lime leaf mark on a transparent background. |13+| `lofi-titlemark-on-dark.svg` | Lime `lo` and cream `fi` for deep-green or other dark backgrounds. |14+| `lofi-titlemark-on-light.svg` | Forest `lo` and jade `fi` for cream, white, or other light backgrounds. |15+| `lofi-hero-art.png` | Text-free pocket-greenhouse illustration for README and documentation layouts. |16+| `lofi-github-banner.png` | Wide repository/social banner with the complete identity and product metaphor. |17+18+## Palette19+20+| Role | Color |21+| --------------------- | --------- |22+| Deep forest | `#18231f` |23+| Acid lime | `#d5f26a` |24+| Soft cream | `#f3faf6` |25+| Muted mint | `#99e5bd` |26+| Secondary forest | `#22332c` |27+| Light-background jade | `#39725c` |28+29+The leaf is intentionally low-technology and organic. The self-contained greenhouse communicates30+that an app remains useful locally; the smaller connected device makes sync visible but secondary.31+Risograph grain and restrained print registration provide the music-genre reference without adding32+literal headphones, records, or music notes.33+34+## Raster generation direction35+36+The raster assets use the approved solid leaf and heavy rounded wordmark, a sparse technical-grid37+composition, and warm two-ink risograph texture. The hero omits all lettering. The banner renders38+the exact lowercase word `lofi` once, with `lo` in acid lime and `fi` in soft cream.Binary file.
Binary file.
@@ -0,0 +1,21 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">2+ <title id="title">lofi lime leaf mark</title>3+ <defs>4+ <mask id="lofi-mark-color-cutout">5+ <rect width="512" height="512" fill="white" />6+ <path7+ d="M183 184v146h124M183 330 91 455"8+ fill="none"9+ stroke="black"10+ stroke-linecap="round"11+ stroke-linejoin="round"12+ stroke-width="38"13+ />14+ </mask>15+ </defs>16+ <path17+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"18+ fill="#d5f26a"19+ mask="url(#lofi-mark-color-cutout)"20+ />21+</svg>@@ -0,0 +1,21 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">2+ <title id="title">lofi leaf mark</title>3+ <defs>4+ <mask id="lofi-mark-cutout">5+ <rect width="512" height="512" fill="white" />6+ <path7+ d="M183 184v146h124M183 330 91 455"8+ fill="none"9+ stroke="black"10+ stroke-linecap="round"11+ stroke-linejoin="round"12+ stroke-width="38"13+ />14+ </mask>15+ </defs>16+ <path17+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"18+ fill="currentColor"19+ mask="url(#lofi-mark-cutout)"20+ />21+</svg>@@ -0,0 +1,38 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 360" role="img" aria-labelledby="title">2+ <title id="title">lofi titlemark for dark backgrounds</title>3+ <defs>4+ <mask id="lofi-title-dark-cutout">5+ <rect width="512" height="512" fill="white" />6+ <path7+ d="M183 184v146h124M183 330 91 455"8+ fill="none"9+ stroke="black"10+ stroke-linecap="round"11+ stroke-linejoin="round"12+ stroke-width="38"13+ />14+ </mask>15+ </defs>16+ <g transform="translate(8 20) scale(.625)">17+ <path18+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"19+ fill="#d5f26a"20+ mask="url(#lofi-title-dark-cutout)"21+ />22+ </g>23+ <g fill="#d5f26a">24+ <rect x="375" y="58" width="54" height="242" rx="12" />25+ <path26+ d="M658 210a108 92 0 1 1-216 0 108 92 0 0 1 216 0Zm-55 0a53 46 0 1 0-106 0 53 46 0 0 0 106 0Z"27+ fill-rule="evenodd"28+ />29+ </g>30+ <g fill="none" stroke="#f3faf6" stroke-linecap="round" stroke-linejoin="round" stroke-width="54">31+ <path d="M765 300V146c0-62 33-89 95-89" />32+ <path d="M728 155h126" />33+ </g>34+ <g fill="#f3faf6">35+ <rect x="925" y="137" width="54" height="163" rx="12" />36+ <circle cx="952" cy="80" r="29" />37+ </g>38+</svg>@@ -0,0 +1,38 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 360" role="img" aria-labelledby="title">2+ <title id="title">lofi titlemark for light backgrounds</title>3+ <defs>4+ <mask id="lofi-title-light-cutout">5+ <rect width="512" height="512" fill="white" />6+ <path7+ d="M183 184v146h124M183 330 91 455"8+ fill="none"9+ stroke="black"10+ stroke-linecap="round"11+ stroke-linejoin="round"12+ stroke-width="38"13+ />14+ </mask>15+ </defs>16+ <g transform="translate(8 20) scale(.625)">17+ <path18+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"19+ fill="#18231f"20+ mask="url(#lofi-title-light-cutout)"21+ />22+ </g>23+ <g fill="#18231f">24+ <rect x="375" y="58" width="54" height="242" rx="12" />25+ <path26+ d="M658 210a108 92 0 1 1-216 0 108 92 0 0 1 216 0Zm-55 0a53 46 0 1 0-106 0 53 46 0 0 0 106 0Z"27+ fill-rule="evenodd"28+ />29+ </g>30+ <g fill="none" stroke="#39725c" stroke-linecap="round" stroke-linejoin="round" stroke-width="54">31+ <path d="M765 300V146c0-62 33-89 95-89" />32+ <path d="M728 155h126" />33+ </g>34+ <g fill="#39725c">35+ <rect x="925" y="137" width="54" height="163" rx="12" />36+ <circle cx="952" cy="80" r="29" />37+ </g>38+</svg>@@ -19,8 +19,9 @@ multi-device account is one opt-in click — not a wall the user hits before the
1919 2. **Backed up & syncing (elected).** When a managed Jazz app is configured, the user elects to back2020 up and sync. Replication turns on, the existing local data pushes up under the _same_ identity,2121 and a recovery phrase becomes available.22-3. **Recovered (on another device).** Entering the recovery phrase reconstructs the exact account23- secret, so the account — and the data that synced up — comes back down.22+3. **Recovered (in a fresh browser).** A recoverable passkey or the recovery phrase reconstructs the23+ exact account secret. The old runtime is shut down, the new principal is opened with sync, and24+ data that reached the sync provider flows back down.2425 2526 The **AccountGate** island (`src/islands/AccountGate.tsx`) drives states 2 and 3. It renders only2627 when a Jazz app is configured, because a recovery phrase only matters once there is somewhere to@@ -49,47 +50,57 @@ The same 32-byte secret encodes as a 24-word recovery phrase (`RecoveryPhrase.fr
4950 recovery phrase = words( account secret ) # the phrase *is* the account5051 ```5152 52-- **Back up** → create a passkey, then reveal the phrase once and write it down. It is the exact53- same account, offline and portable.54-- **Recover** → type the phrase on any device → the same secret → the same account → its synced data55- flows back down, decrypted (Jazz E2E-encrypts synced data keyed on the account).53+- **Back up** → create a recoverable passkey and reveal the phrase once. Both represent the exact54+ same account secret.55+- **Recover with a passkey** → the browser/provider releases the resident credential's secret after56+ user verification → the same account opens.57+- **Recover with the phrase** → type the words on a compatible device → the same account opens. The58+ phrase is the portable bearer-secret fallback and does not depend on a passkey provider.5659 5760 Restoring replaces whatever account the device currently holds, so the UI confirms before discarding5861 a local-only account's un-backed-up data.5962 60-### Passkey confirmation on reveal63+### Two passkey credentials with different jobs6164 62-Backing up enrols a passkey (`enrollDeviceCredential`), and revealing the phrase afterwards runs a63-user-verifying passkey ceremony (`confirmPhraseAccess`) — so the recovery phrase, a bearer secret,64-is not handed out without confirming the person is present. Stated honestly: this is a65-**confirmation**, not encryption. The account secret still lives in device storage so the app can66-open instantly; the passkey does not encrypt it. A device where WebAuthn is unavailable can still67-back up, and the UI says the reveal was unconfirmed. (For real at-rest encryption of the secret, the68-PRF primitive exported by `@nzip/lofi` is available — see below.)65+`createRecoverablePasskeyBackup` uses Jazz's passkey-backup implementation to place the 32-byte66+account secret in a resident, user-verifying platform credential. `restoreFromPasskey` can recover67+the account from that credential. Availability follows the configured RP-ID and the browser,68+operating system, and passkey provider; lofi does not promise universal cross-provider or69+cross-platform portability.70+71+The older `createBackupPasskey` API is different: it enrolls a credential that only confirms a later72+`confirmPhraseAccess` ceremony on the same browser. It does not contain a recoverable account73+backup. The reference UI labels it a **local phrase-reveal guard** so the two credentials cannot be74+confused.6975 7076 ### Boot flow7177 7278 - **First boot** → local-only account opens immediately. No gate.73-- **Back up & sync** (a click) → create a passkey → reveal the recovery phrase to save → turn on74- sync → the runtime recreates and the account replicates.79+- **Back up & sync** → create a recoverable passkey when supported → reveal the recovery phrase to80+ save → turn on sync → the runtime recreates and the account replicates.7581 - **Return boots** → the same cached secret opens the same account, synced or not.76-- **A new device** → restore from the recovery phrase → the account and its synced data appear.82+- **A fresh browser** → explicitly confirm replacement → restore from passkey or phrase → the old83+ subscriptions, stores, workers, cached clients, and Jazz client shut down → the restored account84+ starts and synced rows can arrive.7785 - **Stop syncing** → detaches the network and returns to local-only; the account and data are7886 untouched, and electing again resumes against the same account.7987 80-The framework side is exported by `@nzip/lofi` (`readSession`, `createBackupPasskey`,81-`confirmPhraseAccess`, `revealRecoveryPhrase`, `enableSyncBackup`, `stopSyncBackup`,82-`restoreFromRecoveryPhrase`); the gate is the author-owned example you can restyle or replace.88+The framework side is exported by `@nzip/lofi` (`readAccountSession`,89+`createRecoverablePasskeyBackup`, `restoreFromPasskey`, `revealRecoveryPhrase`, `enableSyncBackup`,90+`stopSyncBackup`, `restoreFromRecoveryPhrase`). The gate is the author-owned example you can restyle91+or replace.8392 8493 ## Custody and recovery — stated plainly8594 86-- **No recovery service.** lofi never holds recoverable account material. Recovery is the recovery87- phrase, held by the user. Sync replicates data to the managed Jazz app under an account only the88- key can open.89-- **Lose every copy of the phrase _and_ the device → lose the account.** That is the honest cost of90- no-central-custody.91-- **This is not a server-assisted account-backup ceremony.** The account is a local-first secret the92- user backs up themselves; nothing on the server can reconstruct it.95+- **lofi does not run a recovery service.** The user holds the phrase; a recoverable passkey is held96+ by the browser/platform provider. The Jazz sync service cannot reconstruct the account secret.97+- **Provider sync has boundaries.** A passkey may sync inside one provider ecosystem, but RP-ID,98+ account, browser, operating-system, and provider rules decide availability. Keep the phrase.99+- **Lose every secret-bearing device, recoverable passkey, and phrase → lose the account.** Synced100+ ciphertext alone does not restore identity authority.101+102+The exact pinned dependency audit and trust assumptions are retained in103+[the passkey decision record](decisions/passkey-backup-alpha53.md).93104 94105 ## Provisioning sync95106 @@ -0,0 +1,56 @@
1+# Jazz `BrowserPasskeyBackup` security review2+3+Date: 2026-07-17\4+Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\5+Decision: retain the pin for Wave 26+7+## Contract reviewed8+9+The current Jazz local-first-auth documentation describes `jazz-tools/passkey-backup` as a10+browser-only, encrypted-at-rest vault for the same 32-byte local-first secret used by11+`BrowserAuthSecretStore`. Restore must return that secret after a user-verifying WebAuthn ceremony;12+the live Jazz client must then be replaced. The recovery phrase remains the portable bearer-secret13+fallback.14+15+The exact installed implementation was reviewed at16+`jazz-tools/2.0.0-alpha.53/dist/runtime/passkey-backup.js` and its packaged tests, not inferred from17+a newer release.18+19+| Assumption | Exact alpha.53 behavior | Lofi boundary |20+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |21+| At-rest protection | The 32-byte secret is supplied as the resident credential user handle. It is released by `credentials.get()` after the authenticator assertion; Jazz does not add application-level wrapping. | We rely on the browser/platform passkey provider to protect credential material at rest. We do not claim lofi controls or can audit provider encryption. |22+| RP-ID | `appHostname` becomes both `rp.id` during backup and `rpId` during restore. | Production apps should pin one canonical hostname. A changed or preview hostname cannot see the old passkey and is reported as an RP-ID mismatch when the browser exposes that distinction. |23+| Resident credential | `residentKey: "required"` and `requireResidentKey: true`. Restore uses discoverable lookup without an allow-list. | A non-resident authenticator does not satisfy the contract. |24+| User verification | Backup requests `userVerification: "required"` and `credentialProtectionPolicy: "userVerificationRequired"`. Restore again requires verification and checks both UP and UV bits before reading `userHandle`. | Cancellation and failed verification are non-secret, retryable UI errors. Lofi never treats credential presence alone as authority. |25+| Provider sync | The implementation requests `authenticatorAttachment: "platform"`; whether the passkey roams is decided by the OS/browser/provider. | Same-provider recovery is expected where that provider syncs passkeys. Universal iOS/Android/browser/provider portability is not promised. Cross-device QR flows may require the original device and are not loss recovery. |26+27+## Decision28+29+Alpha.53 implements the documented resident, RP-scoped, user-verifying secret round trip and its30+packaged tests cover secret length, credential options, RP-ID, UP/UV enforcement, invalid handles,31+and round-trip restore. No dependency upgrade is required for the reviewed contract.32+33+Lofi exposes this as a **recoverable account backup**, distinct from its earlier **guard-only34+credential**. The earlier credential proves user presence before showing a locally stored recovery35+phrase; it does not contain the Jazz secret and cannot restore an account. Existing guard-only36+credentials remain usable for phrase reveal but are labeled as legacy/local and never presented as37+account backups.38+39+## Trust and fallback40+41+- The passkey provider and browser are trusted to protect and, where supported, sync the resident42+ credential.43+- The canonical RP-ID is part of the recovery boundary and must remain stable.44+- The recovery phrase is the portable fallback. It is the account secret encoded as words; anyone45+ holding it can act as the account.46+- Restoring a different secret requires explicit confirmation while the current account is47+ local-only, because unsynced local rows cannot be recovered from Jazz sync.48+- Principal replacement detaches lofi stores/subscriptions, saves the restored secret for the app49+ ID, calls Jazz `db.logout()` to stop workers and cached clients, recreates the runtime with sync50+ elected, and exposes the restored `session.user_id` for verification.51+52+## Evidence sources53+54+- Jazz, “Local-first auth”: <https://jazz.tools/docs/auth/local-first-auth>55+- Jazz, “Lifecycle”: <https://jazz.tools/docs/auth/lifecycle>56+- Pinned package source and tests in the resolved Deno npm cache for `jazz-tools@2.0.0-alpha.53`@@ -2,7 +2,7 @@
22 33 Status: **framework contributor contract with retained M1/M2 history**\44 Scope: **product promises, M1 feasibility, and M2 DevX graduation**\5-Last reviewed: **2026-07-15**5+Last reviewed: **2026-07-17**66 77 > Application developers should start with the [developer documentation](README.md). Milestone,88 > issue, and evidence language below is retained for framework contributors and should not be read@@ -41,11 +41,20 @@ deno task dev
4141 4242 The JSR surface is one package, `@nzip/lofi`, with one version and publish operation. Its supported4343 public surface includes the configured browser runtime, Preact integration, Astro integration,44-generator, command subpaths, and `./testing`. Framework implementations remain package-owned.44+generator, command subpaths, `./access`, and `./testing`. Framework implementations remain45+package-owned.4546 4647 From there the developer makes a retained local write, reloads it, works offline, runs checks,4748 builds, previews, and opens the same stable secure origin on a physical device.4849 50+The pull-request release gate runs that journey against a generated checkout, including managed sync51+opt-in, local-row migration, recoverable-passkey restore in a fresh browser profile, stable52+principal equality, synced-row download, recovery-phrase fallback, and return to an offline managed53+replica. The same `deno task check` invocation includes Jazz permission-harness coverage for54+private, direct-shared, and fixed-role group policies. Browser failure evidence remains sanitized55+and is retained only when the gate fails. Virtual-authenticator results are not physical-device or56+cross-provider portability claims.57+4958 ### M1 graduation journey5059 5160 M1 validates the stack and contract from a repository checkout, without pretending that the M2@@ -0,0 +1,44 @@
1+# Group-owned resource example2+3+Define a group, its membership relation, and resources that reference it:4+5+```ts6+import { schema as s } from "jazz-tools";7+import { groupMembershipTable } from "@nzip/lofi/access";8+9+export const app = s.defineApp({10+ workspaces: s.table({ name: s.string() }),11+ workspaceMembers: groupMembershipTable("workspaces"),12+ documents: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),13+});14+```15+16+```ts17+import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";18+import { app } from "./schema.ts";19+20+export default defineAccessPolicies(app, [groupAccess({21+ groups: app.workspaces,22+ members: app.workspaceMembers,23+ resources: app.documents,24+ groupId: "workspaceId",25+})]);26+```27+28+```ts29+import { createGroupOperations } from "@nzip/lofi/access";30+import { app } from "./schema.ts";31+32+const groups = createGroupOperations({33+ groups: app.workspaces,34+ members: app.workspaceMembers,35+});36+const { group } = await groups.createGroup({ name: "Release team" });37+await groups.addMember(group.id, recipientIdentity, "contributor");38+await groups.changeRole(group.id, recipientIdentity, "writer");39+await groups.removeMember(group.id, recipientIdentity);40+```41+42+Readers can read. Contributors can create and edit their own rows. Writers can edit any group row.43+Admins can do everything writers can and manage membership. `leaveGroup` removes the current44+principal's membership. Managed sync is required for every group operation.@@ -0,0 +1,40 @@
1+# Direct sharing example2+3+Define the raw resource and the conventional grant table:4+5+```ts6+import { schema as s } from "jazz-tools";7+import { sharedGrantTable } from "@nzip/lofi/access";8+9+export const app = s.defineApp({10+ notes: s.table({ title: s.string(), body: s.string() }),11+ noteGrants: sharedGrantTable("notes"),12+});13+```14+15+Apply the narrow policy:16+17+```ts18+import { defineAccessPolicies, sharedAccess } from "@nzip/lofi/access";19+import { app } from "./schema.ts";20+21+export default defineAccessPolicies(app, [22+ sharedAccess({ resource: app.notes, grants: app.noteGrants }),23+]);24+```25+26+Create typed operations and pass the recipient's non-secret, app-scoped sharing identity:27+28+```ts29+import { createSharingOperations } from "@nzip/lofi/access";30+import { app } from "./schema.ts";31+32+const notes = createSharingOperations({ resource: app.notes, grants: app.noteGrants });33+await notes.share(note.id, recipientIdentity, "read");34+await notes.share(note.id, recipientIdentity, "edit");35+await notes.revoke(note.id, recipientIdentity);36+```37+38+Managed sync is required. A read grant permits reads, an edit grant permits reads and updates, and39+only the owner can delete or manage grants. Invitation delivery and user discovery remain app40+concerns.@@ -51,11 +51,70 @@ Treat a permission change like a data migration:
5151 Managed sync configuration is required to publish schema and permission changes. Local-only mode is5252 still useful for UI work, but it does not prove a cloud permission policy was deployed correctly.5353 54-## Collaboration is not implied54+## Choose one narrow access template5555 56-The starter demonstrates private, creator-owned data. It does not generate sharing, invitations,57-roles, revocation, or organization membership. Those features require a deliberate Jazz policy and58-product flow; do not infer them from the presence of sync.56+The starter remains private by default. When a resource has a concrete collaboration model,57+`@nzip/lofi/access` compiles three narrow templates through Jazz's own policy builder:58+59+| Template | Who can read | Who can mutate | Sync |60+| --------------- | -------------------------------- | --------------------------------------------------- | -------- |61+| `privateAccess` | Row creator | Row creator | Optional |62+| `sharedAccess` | Creator plus explicit recipients | Creator; recipients with editable grants may update | Required |63+| `groupAccess` | Group members | Determined by the fixed group role | Required |64+65+The schema remains raw Jazz:66+67+```ts68+import { schema as s } from "jazz-tools";69+import { groupMembershipTable, sharedGrantTable } from "@nzip/lofi/access";70+71+const schema = {72+ notes: s.table({ title: s.string() }),73+ noteGrants: sharedGrantTable("notes"),74+ workspaces: s.table({ name: s.string() }),75+ workspaceMembers: groupMembershipTable("workspaces"),76+ documents: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),77+};78+export const app = s.defineApp(schema);79+```80+81+Then compose policies:82+83+```ts84+export default defineAccessPolicies(app, [85+ sharedAccess({ resource: app.notes, grants: app.noteGrants }),86+ groupAccess({87+ groups: app.workspaces,88+ members: app.workspaceMembers,89+ resources: app.documents,90+ groupId: "workspaceId",91+ }),92+]);93+```94+95+This is not a general schema facade. Use `s.table()`, `s.defineApp()`, and `s.definePermissions()`96+directly whenever the templates do not fit. `defineAccessPolicies` also accepts a final raw-policy97+callback for a small extension without hiding Jazz.98+99+## Fixed group roles100+101+| Role | Read | Create | Edit own | Edit any | Manage members |102+| ------------- | ---- | ------ | -------- | -------- | -------------- |103+| `reader` | yes | no | no | no | no |104+| `contributor` | yes | yes | yes | no | no |105+| `writer` | yes | yes | yes | yes | no |106+| `admin` | yes | yes | yes | yes | yes |107+108+The helper stores the role label plus fixed capability bits because the pinned Jazz alpha rejects109+role-string comparisons inside relationship policies. Only the four role names are accepted by the110+typed operations; custom roles belong in a raw Jazz policy.111+112+Direct shares and group membership use a non-secret `lofi1:<app-id>:<jazz-user-id>` sharing113+identity. It is safe to copy for this purpose but is not a directory, invitation, or sign-in token.114+The package deliberately does not discover users or deliver invitations.115+116+See the focused [Shared](examples/shared.md) and [Group](examples/group.md) examples and the117+[access API reference](reference/access.md).59118 60119 When adding collaboration, test at least:61120 @@ -0,0 +1,45 @@
1+# Account and access API2+3+## Recoverable accounts4+5+The root `@nzip/lofi` export owns principal replacement and runtime cleanup:6+7+| API | Purpose |8+| -------------------------------------------- | ------------------------------------------------------------------------------------- |9+| `readAccountSession()` | Opens the runtime and returns the stable `user_id` and account state. |10+| `createRecoverablePasskeyBackup()` | Backs up the active Jazz secret to a resident, user-verifying passkey. |11+| `restoreFromPasskey(options)` | Restores that secret, saves it for the app ID, replaces the runtime, and elects sync. |12+| `revealRecoveryPhrase()` | Returns the portable bearer-secret fallback. Do not persist or log it. |13+| `restoreFromRecoveryPhrase(phrase, options)` | Performs the same replacement lifecycle from the phrase. |14+15+Both restore APIs require `confirmLocalReplacement: true` when a different local-only account would16+be discarded. `RecoverablePasskeyError` maps unsupported browsers, cancellation, missing17+credentials, RP-ID mismatch, and verification failure to non-secret, actionable messages.18+19+`createBackupPasskey()` and `confirmPhraseAccess()` are retained compatibility APIs for a local20+phrase-reveal guard. That credential cannot restore an account.21+22+## Access templates23+24+Import from `@nzip/lofi/access`:25+26+- Schema helpers: `sharedGrantTable`, `groupMembershipTable`.27+- Policy templates: `privateAccess`, `sharedAccess`, `groupAccess`, `defineAccessPolicies`.28+- Identities: `sharingIdentity`, `encodeSharingIdentity`, `decodeSharingIdentity`.29+- Operations: `createSharingOperations`, `createGroupOperations`.30+- Fixed roles: `reader`, `contributor`, `writer`, `admin`.31+32+Sharing operations provide `share`, `revoke`, `listShares`, and `sharedWithMe`. Group operations33+provide `createGroup`, `addMember`, `changeRole`, `removeMember`, `leaveGroup`, and `listMembers`.34+Collaboration operations reject local-only use with `AccessError.code === "sync-required"`.35+36+In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the37+same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup`38+therefore serializes the creator-owned group write and first-admin membership write, and attempts a39+compensating group delete if the membership is rejected. This is secure and leaves no usable40+unadministered group in the tested path, but it is not database-transaction atomic. Do not describe41+it as atomic until Jazz can validate that relationship against staged transaction rows.42+43+The templates compile ordinary Jazz policies and accept a final raw-policy callback. They do not44+replace `s.table`, `s.defineApp`, or raw `s.definePermissions`; use those directly when the fixed45+models do not fit.@@ -11,12 +11,17 @@
1111 | `schema` | Typed application schema exported by `src/schema.ts` |1212 | `storage` | Durable storage policy; generated projects use `"durable"` |1313 | `credentialOrigins` | Stable hostnames allowed for optional WebAuthn/device-credential use |14+| `passkey.rpId` | Canonical production hostname for recoverable account passkeys |1415 | `sync.adapter` | Generated sync adapter selection |1516 | `repositoryUrl` | Source/home link displayed by the starter |1617 1718 Choose `databaseName` and stable credential origins before shipping. Changing them later can change1819 which local database or WebAuthn relying party the browser opens.1920 21+Omitting `passkey.rpId` uses `location.hostname`. That is useful in local development, but each22+preview hostname becomes a separate passkey namespace. Pin the stable production RP-ID before23+offering passkey backup to real users.24+2025 ## Environment configuration2126 2227 | Name | Classification | Behavior |@@ -39,10 +39,10 @@ or remove both to return to local-only mode.
3939 stateDiagram-v24040 [*] --> LocalOnly: First boot4141 state "Local-only<br/>Private on-device account<br/>Durable local data" as LocalOnly42- LocalOnly --> Syncing: User opts in and saves phrase42+ LocalOnly --> Syncing: User creates recoverable passkey and saves phrase4343 state "Syncing<br/>Same account identity<br/>Local and global durability" as Syncing4444 Syncing --> LocalOnly: Stop syncing45- Syncing --> Recovered: Enter phrase on another device45+ Syncing --> Recovered: Use passkey or enter phrase4646 state "Recovered<br/>Same identity restored<br/>Synced data downloads" as Recovered4747 Recovered --> Syncing4848 ```@@ -52,6 +52,12 @@ secret.
5252 5353 ## Recovery guarantees and limits5454 55+- The recoverable passkey stores the same account secret inside a resident, user-verifying56+ credential. Restoring it replaces the Jazz client and confirms the same `session.user_id`.57+- Passkey availability is provider- and RP-ID-dependent. Pin `passkey.rpId` to the canonical58+ production hostname. A passkey created for a preview hostname cannot move to another RP-ID.59+- iCloud Keychain, Google Password Manager, and third-party managers have different platform60+ boundaries. lofi does not promise universal iOS/Android/browser/provider portability.5561 - The recovery phrase is the account authority. Anyone with all the words can act as that account.5662 - lofi does not retain material that can reconstruct the account for the user.5763 - Losing both the device and every copy of the phrase loses the account.@@ -59,6 +65,8 @@ secret.
5965 only in storage on a lost device.6066 - Restoring replaces the current device account, so the generated UI confirms before abandoning an6167 unsynced local identity.68+- Older lofi guard-only credentials protect phrase reveal on one device; they do not contain the69+ Jazz secret and are not recoverable account backups.6270 6371 Read [Identity and recovery model](auth-identity.md) for the detailed state machine and custody6472 model.@@ -1,7 +1,8 @@
11 # Testing a lofi app22 33 The generated template separates fast deterministic tests from opt-in browser scenarios that need a4-running app, Chromium, and—for convergence—managed sync.4+running app, Chromium, and—for convergence—managed sync. Framework release checks also exercise5+recoverable accounts and access policies; generated applications reuse those package-owned seams.56 67 ```mermaid78 flowchart TB@@ -101,6 +102,25 @@ Browser fixtures can save artifacts under `test-results/`. Snapshot callbacks sh
101102 booleans, state names, or sanitized identifiers—not task text, environment values, recovery phrases,102103 or other user data.103104 105+## Framework recovery and permission evidence106+107+The framework repository's `deno task check` includes the access-security suite. It uses Jazz's108+permission-test app for owner, recipient, unrelated-user, revoke, fixed-role, membership, removal,109+and self-leave rejection cases, plus a real local Jazz server for offline grant/revoke and110+membership reconciliation.111+112+`deno task test:golden` extends the existing generated-app runner with a two-profile journey. A113+Chromium virtual authenticator creates and exports a resident credential, a fresh browser process114+imports it, and the real WebAuthn assertion restores the same Jazz principal and synced rows. The115+runner pins the exported credential as the assertion's allow-list because headless Chromium cannot116+show its resident-credential account chooser. It also proves recovery-phrase fallback when the117+credentials API is unavailable and proves that stopped sync retains the managed local replica118+through a production offline reload.119+120+This is automated virtual-authenticator evidence, not physical iOS or Android evidence. It does not121+prove passkey-provider portability, mobile installed-PWA lifecycle behavior, or transport122+convergence without the local Jazz server used by the journey.123+104124 ## Physical-device checks105125 106126 Browser automation does not prove installed-PWA storage and lifecycle behavior on iOS or Android.@@ -93,3 +93,21 @@ server.
9393 9494 The phrase restores account authority, not unsynced device storage. Data returns only if it reached9595 managed sync before the original device was lost or cleared.96+97+## Passkey restore was cancelled or cannot find an account98+99+- **Cancelled:** retry the user-verification prompt, or continue with the recovery phrase.100+- **No recoverable passkey:** confirm the passkey exists in the active provider. A legacy101+ phrase-reveal guard is not an account backup.102+- **Different app hostname / RP-ID:** open the canonical production hostname used during backup.103+- **Verification failed:** unlock the platform/password-manager authenticator and retry.104+- **Unsupported browser/provider:** use the recovery phrase. Provider availability is not portable105+ across every iOS, Android, browser, and password-manager combination.106+107+The package maps these states without printing credential IDs, account secrets, or phrases.108+109+## Sharing says managed sync is required110+111+Private resources work local-only. Direct shares and groups require both a configured Jazz server112+and the current account's explicit sync election. Back up and enable sync in `AccountGate`, then113+retry. A configured deployment alone does not opt the current account into sync.@@ -0,0 +1,40 @@
1+import { schema as s } from "jazz-tools";2+import {3+ AccessError,4+ createSharingOperations,5+ decodeSharingIdentity,6+ encodeSharingIdentity,7+ sharedGrantTable,8+} from "./mod.ts";9+import { assert } from "../runtime/test-assert.ts";10+11+const app = s.defineApp({12+ docs: s.table({ title: s.string() }),13+ grants: sharedGrantTable("docs"),14+});15+16+Deno.test("sharing identities round-trip only inside the configured app", () => {17+ const identity = encodeSharingIdentity("stable-jazz-principal");18+ assert(19+ decodeSharingIdentity(identity) === "stable-jazz-principal",20+ "identity did not round-trip",21+ );22+ let code = "";23+ try {24+ decodeSharingIdentity("lofi1:different-app:principal");25+ } catch (error) {26+ code = error instanceof AccessError ? error.code : "unexpected";27+ }28+ assert(code === "invalid-identity", "cross-app identity was accepted");29+});30+31+Deno.test("shared operations reject managed-sync work before touching a local-only runtime", async () => {32+ const operations = createSharingOperations({ resource: app.docs, grants: app.grants });33+ let code = "";34+ try {35+ await operations.share("resource", encodeSharingIdentity("recipient"), "read");36+ } catch (error) {37+ code = error instanceof AccessError ? error.code : "unexpected";38+ }39+ assert(code === "sync-required", "local-only sharing did not report its sync precondition");40+});@@ -0,0 +1,244 @@
1+import { schema as s } from "jazz-tools";2+import { createPolicyTestApp } from "jazz-tools/testing";3+import {4+ defineAccessPolicies,5+ groupAccess,6+ groupMembershipTable,7+ groupRoleCapabilities,8+ privateAccess,9+ sharedAccess,10+ sharedGrantTable,11+} from "./mod.ts";12+import { assert } from "../runtime/test-assert.ts";13+14+const schema = {15+ privateDocs: s.table({ title: s.string() }),16+ sharedDocs: s.table({ title: s.string() }),17+ sharedDocGrants: sharedGrantTable("sharedDocs"),18+ workspaces: s.table({ name: s.string() }),19+ workspaceMembers: groupMembershipTable("workspaces"),20+ groupDocs: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),21+};22+const app = s.defineApp(schema);23+const permissions = defineAccessPolicies(app, [24+ privateAccess({ resource: app.privateDocs }),25+ sharedAccess({ resource: app.sharedDocs, grants: app.sharedDocGrants }),26+ groupAccess({27+ groups: app.workspaces,28+ members: app.workspaceMembers,29+ resources: app.groupDocs,30+ groupId: "workspaceId",31+ }),32+]);33+34+type ThrowExpectation = {35+ toThrow(expected?: unknown): void;36+ not: { toThrow(expected?: unknown): void };37+};38+39+function expectLike(value: unknown): ThrowExpectation {40+ const run = (): unknown => {41+ if (typeof value !== "function") throw new Error("expectLike requires a callback");42+ try {43+ value();44+ return undefined;45+ } catch (error) {46+ return error;47+ }48+ };49+ const matches = (error: unknown, expected: unknown): boolean => {50+ if (expected === undefined) return error !== undefined;51+ const message = error instanceof Error ? error.message : String(error);52+ return typeof expected === "string"53+ ? message.includes(expected)54+ : error instanceof (expected as typeof Error);55+ };56+ return {57+ toThrow(expected?: unknown) {58+ const error = run();59+ if (!matches(error, expected)) {60+ throw new Error(`expected callback to throw ${String(expected)}`);61+ }62+ },63+ not: {64+ toThrow(expected?: unknown) {65+ const error = run();66+ if (error !== undefined && matches(error, expected)) {67+ throw new Error(`expected callback not to throw ${String(expected)}`, { cause: error });68+ }69+ },70+ },71+ };72+}73+74+const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });75+76+Deno.test("official Jazz harness enforces private and direct-share templates", async () => {77+ const testApp = await createPolicyTestApp(app, permissions, expectLike);78+ try {79+ const owner = testApp.as(session("owner"));80+ const reader = testApp.as(session("reader"));81+ const editor = testApp.as(session("editor"));82+ const stranger = testApp.as(session("stranger"));83+84+ const privateDoc = await owner.insert(app.privateDocs, { title: "private" }).wait({85+ tier: "global",86+ });87+ assert(88+ (await owner.all(app.privateDocs.where({ id: privateDoc.id }))).length === 1,89+ "owner lost private row",90+ );91+ assert(92+ (await stranger.all(app.privateDocs.where({ id: privateDoc.id }))).length === 0,93+ "unrelated user read private row",94+ );95+ stranger.expectDenied((db) => db.update(app.privateDocs, privateDoc.id, { title: "denied" }));96+97+ const sharedDoc = await owner.insert(app.sharedDocs, { title: "shared" }).wait({98+ tier: "global",99+ });100+ const readGrant = await owner.insert(app.sharedDocGrants, {101+ resourceId: sharedDoc.id,102+ user_id: "reader",103+ can_edit: false,104+ }).wait({ tier: "global" });105+ await owner.insert(app.sharedDocGrants, {106+ resourceId: sharedDoc.id,107+ user_id: "editor",108+ can_edit: true,109+ }).wait({ tier: "global" });110+111+ assert(112+ (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 1,113+ "reader could not read shared row",114+ );115+ assert(116+ (await editor.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 1,117+ "editor could not read shared row",118+ );119+ assert(120+ (await stranger.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 0,121+ "stranger read shared row",122+ );123+ reader.expectDenied((db) => db.update(app.sharedDocs, sharedDoc.id, { title: "denied" }));124+ editor.expectAllowed((db) => db.update(app.sharedDocs, sharedDoc.id, { title: "allowed" }));125+ stranger.expectDenied((db) => db.delete(app.sharedDocs, sharedDoc.id));126+127+ await owner.delete(app.sharedDocGrants, readGrant.id).wait({ tier: "global" });128+ assert(129+ (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 0,130+ "revoked reader retained access",131+ );132+ } finally {133+ await testApp.shutdown();134+ }135+});136+137+Deno.test("official Jazz harness enforces fixed group roles and membership lifecycle", async () => {138+ const testApp = await createPolicyTestApp(app, permissions, expectLike);139+ try {140+ const admin = testApp.as(session("admin"));141+ const reader = testApp.as(session("reader"));142+ const contributor = testApp.as(session("contributor"));143+ const writer = testApp.as(session("writer"));144+ const stranger = testApp.as(session("stranger"));145+146+ const group = await admin.insert(app.workspaces, {147+ name: "workspace",148+ }).wait({149+ tier: "global",150+ });151+ assert(152+ (await admin.all(app.workspaces.where({ id: group.id }))).length === 1,153+ "group creator could not read the created group",154+ );155+ const initialAdmin = await admin.insert(app.workspaceMembers, {156+ groupId: group.id,157+ user_id: "admin",158+ ...groupRoleCapabilities("admin"),159+ }).wait({ tier: "global" });160+ assert(initialAdmin.role === "admin", "creator did not bootstrap as first admin");161+ const groupId = group.id;162+ const memberships = await Promise.all([163+ admin.insert(app.workspaceMembers, {164+ groupId,165+ user_id: "reader",166+ ...groupRoleCapabilities("reader"),167+ }).wait({168+ tier: "global",169+ }),170+ admin.insert(app.workspaceMembers, {171+ groupId,172+ user_id: "contributor",173+ ...groupRoleCapabilities("contributor"),174+ })175+ .wait(176+ { tier: "global" },177+ ),178+ admin.insert(app.workspaceMembers, {179+ groupId,180+ user_id: "writer",181+ ...groupRoleCapabilities("writer"),182+ }).wait({183+ tier: "global",184+ }),185+ ]);186+ assert(187+ (await admin.all(app.workspaceMembers.where({ groupId, user_id: "admin", role: "admin" })))188+ .length === 1,189+ "admin membership was not visible to the admin",190+ );191+ const adminDoc = await admin.insert(app.groupDocs, { workspaceId: groupId, title: "admin row" })192+ .wait({193+ tier: "global",194+ });195+196+ reader.expectDenied((db) =>197+ db.insert(app.groupDocs, { workspaceId: groupId, title: "reader cannot create" })198+ );199+ contributor.expectAllowed((db) =>200+ db.insert(app.groupDocs, { workspaceId: groupId, title: "contributor own" })201+ );202+ contributor.expectDenied((db) => db.update(app.groupDocs, adminDoc.id, { title: "not own" }));203+ writer.expectAllowed((db) => db.update(app.groupDocs, adminDoc.id, { title: "writer" }));204+ admin.expectAllowed((db) => db.update(app.groupDocs, adminDoc.id, { title: "admin" }));205+ stranger.expectDenied((db) =>206+ db.insert(app.groupDocs, { workspaceId: groupId, title: "non-member" })207+ );208+ assert(209+ (await stranger.all(app.groupDocs.where({ workspaceId: groupId }))).length === 0,210+ "non-member read group rows",211+ );212+213+ admin.expectAllowed((db) =>214+ db.update(app.workspaceMembers, memberships[0].id, groupRoleCapabilities("writer"))215+ );216+ reader.expectDenied((db) =>217+ db.update(app.workspaceMembers, memberships[0].id, groupRoleCapabilities("admin"))218+ );219+ admin.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[1].id));220+ writer.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[2].id));221+ stranger.expectDenied((db) => db.delete(app.workspaceMembers, memberships[2].id));222+ } finally {223+ await testApp.shutdown();224+ }225+});226+227+Deno.test("access templates reject malformed relationship tables with actionable errors", () => {228+ const malformed = s.defineApp({229+ docs: s.table({ title: s.string() }),230+ grants: s.table({ resourceId: s.string(), user_id: s.string(), can_edit: s.boolean() }),231+ });232+ let message = "";233+ try {234+ defineAccessPolicies(malformed, [235+ sharedAccess({ resource: malformed.docs, grants: malformed.grants }),236+ ]);237+ } catch (error) {238+ message = error instanceof Error ? error.message : String(error);239+ }240+ assert(241+ message.includes("grants") && message.includes("resourceId"),242+ "configuration error was not actionable",243+ );244+});@@ -0,0 +1,137 @@
1+import { createDb, schema as s } from "jazz-tools";2+import { deploy, startLocalJazzServer } from "jazz-tools/testing";3+import {4+ defineAccessPolicies,5+ groupAccess,6+ groupMembershipTable,7+ groupRoleCapabilities,8+ sharedAccess,9+ sharedGrantTable,10+} from "./mod.ts";11+import { assert } from "../runtime/test-assert.ts";12+13+const app = s.defineApp({14+ sharedDocs: s.table({ title: s.string() }),15+ sharedDocGrants: sharedGrantTable("sharedDocs"),16+ workspaces: s.table({ name: s.string() }),17+ workspaceMembers: groupMembershipTable("workspaces"),18+ workspaceDocs: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),19+});20+const permissions = defineAccessPolicies(app, [21+ sharedAccess({ resource: app.sharedDocs, grants: app.sharedDocGrants }),22+ groupAccess({23+ groups: app.workspaces,24+ members: app.workspaceMembers,25+ resources: app.workspaceDocs,26+ groupId: "workspaceId",27+ }),28+]);29+30+function secret(fill: number): string {31+ let binary = "";32+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);33+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");34+}35+36+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {37+ let timer: ReturnType<typeof setTimeout> | undefined;38+ try {39+ return await Promise.race([40+ operation,41+ new Promise<never>((_resolve, reject) => {42+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);43+ }),44+ ]);45+ } finally {46+ if (timer !== undefined) clearTimeout(timer);47+ }48+}49+50+Deno.test("real sync persists offline grant, revoke, and membership changes after reconnect", async () => {51+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });52+ await deploy({53+ appId: server.appId,54+ serverUrl: server.url,55+ adminSecret: server.adminSecret,56+ schema: app,57+ permissions,58+ });59+ const alice = await createDb({60+ appId: server.appId,61+ serverUrl: server.url,62+ secret: secret(1),63+ userBranch: "main",64+ driver: { type: "memory" },65+ });66+ const bob = await createDb({67+ appId: server.appId,68+ serverUrl: server.url,69+ secret: secret(2),70+ userBranch: "main",71+ driver: { type: "memory" },72+ });73+ try {74+ const aliceId = alice.getAuthState().session?.user_id;75+ const bobId = bob.getAuthState().session?.user_id;76+ assert(Boolean(aliceId && bobId && aliceId !== bobId), "test principals were not isolated");77+78+ const shared = await alice.insert(app.sharedDocs, { title: "shared" }).wait({ tier: "global" });79+ await alice.disconnect();80+ const grant = alice.insert(app.sharedDocGrants, {81+ resourceId: shared.id,82+ user_id: bobId!,83+ can_edit: false,84+ });85+ assert(grant.value.user_id === bobId, "offline grant did not apply locally");86+ await alice.reconnect();87+ await within(grant.wait({ tier: "global" }), "grant global durability");88+ assert(89+ (await alice.all(app.sharedDocGrants.where({ id: grant.value.id }), { tier: "global" }))90+ .length === 1,91+ "reconnected grant did not persist globally",92+ );93+94+ await alice.disconnect();95+ const revoke = alice.delete(app.sharedDocGrants, grant.value.id);96+ await alice.reconnect();97+ await within(revoke.wait({ tier: "global" }), "revoke global durability");98+ assert(99+ (await alice.all(app.sharedDocGrants.where({ id: grant.value.id }), { tier: "global" }))100+ .length === 0,101+ "reconnected revoke did not persist globally",102+ );103+104+ const workspace = await alice.insert(app.workspaces, {105+ name: "workspace",106+ }).wait({ tier: "global" });107+ const initialAdmin = await alice.insert(app.workspaceMembers, {108+ groupId: workspace.id,109+ user_id: aliceId!,110+ ...groupRoleCapabilities("admin"),111+ }).wait({ tier: "global" });112+ assert(113+ initialAdmin.role === "admin",114+ "group bootstrap did not create the first admin membership",115+ );116+ await alice.disconnect();117+ const membership = alice.insert(app.workspaceMembers, {118+ groupId: workspace.id,119+ user_id: bobId!,120+ ...groupRoleCapabilities("reader"),121+ });122+ assert(membership.value.user_id === bobId, "offline membership did not apply locally");123+ await alice.reconnect();124+ await within(membership.wait({ tier: "global" }), "membership global durability");125+ assert(126+ (await alice.all(app.workspaceMembers.where({ id: membership.value.id }), { tier: "global" }))127+ .length === 1,128+ "reconnected membership did not persist globally",129+ );130+ } finally {131+ await Promise.allSettled([132+ within(alice.logout(), "Alice cleanup", 3_000),133+ within(bob.logout(), "Bob cleanup", 3_000),134+ ]);135+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);136+ }137+});@@ -0,0 +1,18 @@
1+export type AccessErrorCode =2+ | "configuration"3+ | "invalid-identity"4+ | "sync-required"5+ | "mutation-rejected"6+ | "not-found"7+ | "invalid-role";8+9+export class AccessError extends Error {10+ override readonly name = "AccessError";11+ constructor(readonly code: AccessErrorCode, message: string, options?: ErrorOptions) {12+ super(message, options);13+ }14+}15+16+export function isAccessError(error: unknown): error is AccessError {17+ return error instanceof AccessError;18+}@@ -0,0 +1,39 @@
1+import { appId } from "../runtime/config.ts";2+import { getRuntime } from "../runtime/runtime.ts";3+import { AccessError } from "./errors.ts";4+5+declare const sharingIdentityBrand: unique symbol;6+export type SharingIdentity = string & { readonly [sharingIdentityBrand]: true };7+8+const prefix = "lofi1";9+10+export function encodeSharingIdentity(userId: string): SharingIdentity {11+ if (!userId.trim() || userId.includes(":")) {12+ throw new AccessError("invalid-identity", "Jazz returned an invalid sharing identity.");13+ }14+ return `${prefix}:${appId}:${userId}` as SharingIdentity;15+}16+17+export function decodeSharingIdentity(identity: string): string {18+ const [version, identityAppId, userId, extra] = identity.split(":");19+ if (version !== prefix || identityAppId !== appId || !userId || extra !== undefined) {20+ throw new AccessError(21+ "invalid-identity",22+ "That sharing identity belongs to another app or is malformed. Ask the recipient to copy it again from this app.",23+ );24+ }25+ return userId;26+}27+28+/** Returns the non-secret, app-scoped identity users may copy for shares. */29+export async function sharingIdentity(): Promise<SharingIdentity> {30+ const runtime = await getRuntime();31+ const userId = runtime.db.getAuthState().session?.user_id;32+ if (!userId) {33+ throw new AccessError(34+ "invalid-identity",35+ "The current Jazz principal is not ready. Wait for account startup and try again.",36+ );37+ }38+ return encodeSharingIdentity(userId);39+}@@ -0,0 +1,35 @@
1+/** Narrow private/direct-share/group access templates over raw Jazz schemas. */2+export { AccessError, type AccessErrorCode, isAccessError } from "./errors.ts";3+export {4+ decodeSharingIdentity,5+ encodeSharingIdentity,6+ type SharingIdentity,7+ sharingIdentity,8+} from "./identity.ts";9+export {10+ createGroupOperations,11+ createSharingOperations,12+ type GroupMembershipRow,13+ type GroupOperations,14+ type SharedGrantRow,15+ type ShareLevel,16+ type SharingOperations,17+} from "./operations.ts";18+export {19+ type AccessTemplate,20+ defineAccessPolicies,21+ groupAccess,22+ type GroupAccessTemplate,23+ privateAccess,24+ type PrivateAccessTemplate,25+ type RawAccessPolicyExtension,26+ sharedAccess,27+ type SharedAccessTemplate,28+} from "./policies.ts";29+export {30+ groupMembershipTable,31+ type GroupRole,32+ groupRoleCapabilities,33+ groupRoles,34+ sharedGrantTable,35+} from "./schema.ts";@@ -0,0 +1,260 @@
1+import type { QueryBuilder, TableProxy } from "jazz-tools";2+import { syncing } from "../runtime/config.ts";3+import { getRuntime } from "../runtime/runtime.ts";4+import { AccessError } from "./errors.ts";5+import { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";6+import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";7+8+type Identified = { id: string };9+type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {10+ where(input: unknown): QueryBuilder<Row>;11+};12+export type SharedGrantRow = Identified & {13+ resourceId: string;14+ user_id: string;15+ can_edit: boolean;16+};17+export type GroupMembershipRow = Identified & {18+ groupId: string;19+ user_id: string;20+ role: GroupRole;21+ can_create: boolean;22+ can_edit_any: boolean;23+ can_manage: boolean;24+};25+26+type DurableWrite<T> = { wait(options: { tier: "local" | "global" }): Promise<T> };27+28+function requireSync(operation: string): void {29+ if (!syncing()) {30+ throw new AccessError(31+ "sync-required",32+ `${operation} requires managed sync and an account that has elected to sync. Private resources remain available local-only.`,33+ );34+ }35+}36+37+async function settle<T>(write: DurableWrite<T>): Promise<T> {38+ try {39+ await write.wait({ tier: "local" });40+ return await write.wait({ tier: "global" });41+ } catch (cause) {42+ throw new AccessError(43+ "mutation-rejected",44+ "Jazz rejected the access change. Refresh permissions and membership, then try again.",45+ { cause },46+ );47+ }48+}49+50+function assertRole(role: string): asserts role is GroupRole {51+ if (!(groupRoles as readonly string[]).includes(role)) {52+ throw new AccessError(53+ "invalid-role",54+ `Unknown group role ${JSON.stringify(role)}; choose reader, contributor, writer, or admin.`,55+ );56+ }57+}58+59+export type ShareLevel = "read" | "edit";60+61+export type SharingOperations<Resource, Grant> = {62+ share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise<Grant>;63+ revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;64+ listShares(resourceId: string): Promise<Grant[]>;65+ sharedWithMe(): Promise<Resource[]>;66+};67+68+export function createSharingOperations<69+ Resource extends Identified,70+ ResourceInit,71+ Grant extends SharedGrantRow,72+ GrantInit,73+>(config: {74+ resource: AccessRuntimeTable<Resource, ResourceInit>;75+ grants: AccessRuntimeTable<Grant, GrantInit>;76+}): SharingOperations<Resource, Grant> {77+ const findGrant = async (resourceId: string, userId: string): Promise<Grant | null> => {78+ const { db } = await getRuntime();79+ return await db.one(config.grants.where({ resourceId, user_id: userId } as never));80+ };81+82+ return {83+ async share(84+ resourceId: string,85+ recipient: SharingIdentity | string,86+ level: ShareLevel,87+ ): Promise<Grant> {88+ requireSync("Sharing");89+ const userId = decodeSharingIdentity(recipient);90+ const { db } = await getRuntime();91+ const existing = await findGrant(resourceId, userId);92+ if (existing) {93+ await settle(94+ db.update(config.grants, existing.id, { can_edit: level === "edit" } as never),95+ );96+ return { ...existing, can_edit: level === "edit" };97+ }98+ const inserted = db.insert(config.grants, {99+ resourceId,100+ user_id: userId,101+ can_edit: level === "edit",102+ } as GrantInit);103+ return await settle(inserted);104+ },105+106+ async revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void> {107+ requireSync("Revoking a share");108+ const userId = decodeSharingIdentity(recipient);109+ const { db } = await getRuntime();110+ const rows = await db.all(config.grants.where({ resourceId, user_id: userId } as never));111+ await Promise.all(rows.map((row) => settle(db.delete(config.grants, row.id))));112+ },113+114+ async listShares(resourceId: string): Promise<Grant[]> {115+ requireSync("Listing shares");116+ const { db } = await getRuntime();117+ return await db.all(config.grants.where({ resourceId } as never));118+ },119+120+ async sharedWithMe(): Promise<Resource[]> {121+ requireSync("Listing resources shared with you");122+ const { db } = await getRuntime();123+ const userId = db.getAuthState().session?.user_id;124+ if (!userId) {125+ throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");126+ }127+ const grants = await db.all(config.grants.where({ user_id: userId } as never));128+ const resources = await Promise.all(129+ grants.map((grant) => db.one(config.resource.where({ id: grant.resourceId } as never))),130+ );131+ const available: Resource[] = [];132+ for (const row of resources) if (row !== null) available.push(row as Resource);133+ return available;134+ },135+ };136+}137+138+export function createGroupOperations<139+ Group extends Identified,140+ GroupInit,141+ Member extends GroupMembershipRow,142+ MemberInit,143+>(config: {144+ groups: AccessRuntimeTable<Group, GroupInit>;145+ members: AccessRuntimeTable<Member, MemberInit>;146+}): GroupOperations<Group, GroupInit, Member> {147+ const findMember = async (groupId: string, userId: string): Promise<Member | null> => {148+ const { db } = await getRuntime();149+ return await db.one(config.members.where({ groupId, user_id: userId } as never));150+ };151+152+ return {153+ async createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }> {154+ requireSync("Creating a group");155+ const { db } = await getRuntime();156+ const userId = db.getAuthState().session?.user_id;157+ if (!userId) {158+ throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");159+ }160+ const group = await settle(db.insert(config.groups, values));161+ try {162+ const membership = await settle(db.insert(config.members, {163+ groupId: group.id,164+ user_id: userId,165+ ...groupRoleCapabilities("admin"),166+ } as MemberInit));167+ return { group, membership };168+ } catch (cause) {169+ await settle(db.delete(config.groups, group.id)).catch(() => undefined);170+ throw cause;171+ }172+ },173+174+ async addMember(175+ groupId: string,176+ recipient: SharingIdentity | string,177+ role: GroupRole,178+ ): Promise<Member> {179+ requireSync("Adding a group member");180+ assertRole(role);181+ const userId = decodeSharingIdentity(recipient);182+ const existing = await findMember(groupId, userId);183+ if (existing) {184+ throw new AccessError(185+ "mutation-rejected",186+ "That account is already a member of this group.",187+ );188+ }189+ const { db } = await getRuntime();190+ return await settle(191+ db.insert(config.members, {192+ groupId,193+ user_id: userId,194+ ...groupRoleCapabilities(role),195+ } as MemberInit),196+ );197+ },198+199+ async changeRole(200+ groupId: string,201+ recipient: SharingIdentity | string,202+ role: GroupRole,203+ ): Promise<Member> {204+ requireSync("Changing a group role");205+ assertRole(role);206+ const userId = decodeSharingIdentity(recipient);207+ const member = await findMember(groupId, userId);208+ if (!member) {209+ throw new AccessError("not-found", "That account is not a member of this group.");210+ }211+ const { db } = await getRuntime();212+ const capabilities = groupRoleCapabilities(role);213+ await settle(db.update(config.members, member.id, capabilities as never));214+ return { ...member, ...capabilities };215+ },216+217+ async removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void> {218+ requireSync("Removing a group member");219+ const member = await findMember(groupId, decodeSharingIdentity(recipient));220+ if (!member) return;221+ const { db } = await getRuntime();222+ await settle(db.delete(config.members, member.id));223+ },224+225+ async leaveGroup(groupId: string): Promise<void> {226+ requireSync("Leaving a group");227+ const { db } = await getRuntime();228+ const userId = db.getAuthState().session?.user_id;229+ if (!userId) {230+ throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");231+ }232+ const member = await findMember(groupId, userId);233+ if (!member) return;234+ await settle(db.delete(config.members, member.id));235+ },236+237+ async listMembers(groupId: string): Promise<Member[]> {238+ requireSync("Listing group members");239+ const { db } = await getRuntime();240+ return await db.all(config.members.where({ groupId } as never));241+ },242+ };243+}244+245+export type GroupOperations<Group, GroupInit, Member> = {246+ createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;247+ addMember(248+ groupId: string,249+ recipient: SharingIdentity | string,250+ role: GroupRole,251+ ): Promise<Member>;252+ changeRole(253+ groupId: string,254+ recipient: SharingIdentity | string,255+ role: GroupRole,256+ ): Promise<Member>;257+ removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void>;258+ leaveGroup(groupId: string): Promise<void>;259+ listMembers(groupId: string): Promise<Member[]>;260+};@@ -0,0 +1,233 @@
1+import { schema as s } from "jazz-tools";2+import type { CompiledPermissions } from "jazz-tools";3+import { AccessError } from "./errors.ts";4+5+type AccessTable = {6+ readonly _table: string;7+ readonly _schema: Record<string, { columns?: unknown[] }>;8+};9+10+export type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable };11+export type SharedAccessTemplate = {12+ readonly kind: "shared";13+ readonly resource: AccessTable;14+ readonly grants: AccessTable;15+};16+export type GroupAccessTemplate = {17+ readonly kind: "group";18+ readonly groups: AccessTable;19+ readonly members: AccessTable;20+ readonly resources: readonly AccessTable[];21+ readonly groupId: string;22+};23+export type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate;24+25+export function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate {26+ return { kind: "private", ...config };27+}28+29+export function sharedAccess(config: {30+ resource: AccessTable;31+ grants: AccessTable;32+}): SharedAccessTemplate {33+ return { kind: "shared", ...config };34+}35+36+export function groupAccess(config: {37+ groups: AccessTable;38+ members: AccessTable;39+ resources: AccessTable | readonly AccessTable[];40+ groupId: string;41+}): GroupAccessTemplate {42+ return {43+ kind: "group",44+ groups: config.groups,45+ members: config.members,46+ resources: Array.isArray(config.resources) ? config.resources : [config.resources],47+ groupId: config.groupId,48+ };49+}50+51+type Column = {52+ name?: string;53+ references?: string;54+ column_type?: { type?: string; variants?: string[] };55+};56+57+function columns(table: AccessTable): Column[] {58+ const definition = table._schema[table._table];59+ return (definition?.columns ?? []) as Column[];60+}61+62+function requireColumn(63+ table: AccessTable,64+ name: string,65+ type: string,66+ references?: string,67+): Column {68+ const column = columns(table).find((candidate) => candidate.name === name);69+ if (!column || column.column_type?.type !== type || column.references !== references) {70+ const reference = references ? ` referencing ${references}` : "";71+ throw new AccessError(72+ "configuration",73+ `Access table ${table._table} must contain ${name} as ${type}${reference}. Use the matching @nzip/lofi/access table helper or correct the raw Jazz schema.`,74+ );75+ }76+ return column;77+}78+79+function validateTemplate(template: AccessTemplate): void {80+ if (template.kind === "shared") {81+ requireColumn(template.grants, "resourceId", "Uuid", template.resource._table);82+ requireColumn(template.grants, "user_id", "Text");83+ requireColumn(template.grants, "can_edit", "Boolean");84+ }85+ if (template.kind === "group") {86+ requireColumn(template.members, "groupId", "Uuid", template.groups._table);87+ requireColumn(template.members, "user_id", "Text");88+ requireColumn(template.members, "role", "Text");89+ requireColumn(template.members, "can_create", "Boolean");90+ requireColumn(template.members, "can_edit_any", "Boolean");91+ requireColumn(template.members, "can_manage", "Boolean");92+ for (const resource of template.resources) {93+ requireColumn(resource, template.groupId, "Uuid", template.groups._table);94+ }95+ }96+}97+98+type RuleBuilder = {99+ where(input: unknown): unknown;100+ always(): unknown;101+};102+type TablePolicy = {103+ allowRead: RuleBuilder;104+ allowInsert: RuleBuilder;105+ allowUpdate: RuleBuilder;106+ allowDelete: RuleBuilder;107+ exists: { where(input: unknown): unknown };108+};109+type RawAccessPolicyContext = {110+ policy: Record<string, TablePolicy>;111+ session: { user_id: unknown };112+ anyOf(conditions: readonly unknown[]): unknown;113+ allOf(conditions: readonly unknown[]): unknown;114+ allowedTo: {115+ update(fkColumn: string): unknown;116+ };117+};118+119+export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;120+121+/**122+ * Compiles the three narrow templates through Jazz's own policy builder. The123+ * optional callback is the raw Jazz-policy escape hatch for app-specific rules.124+ */125+export function defineAccessPolicies<TApp extends object>(126+ app: TApp,127+ templates: readonly AccessTemplate[],128+ raw?: RawAccessPolicyExtension,129+): CompiledPermissions {130+ if (templates.length === 0) {131+ throw new AccessError("configuration", "defineAccessPolicies requires at least one template.");132+ }133+ for (const template of templates) validateTemplate(template);134+135+ return s.definePermissions(app, (jazzContext) => {136+ const { policy, session, anyOf, allOf, allowedTo } =137+ jazzContext as unknown as RawAccessPolicyContext;138+ for (const template of templates) {139+ if (template.kind === "private") {140+ const resource = policy[template.resource._table];141+ resource.allowInsert.always();142+ resource.allowRead.where({ $createdBy: session.user_id });143+ resource.allowUpdate.where({ $createdBy: session.user_id });144+ resource.allowDelete.where({ $createdBy: session.user_id });145+ continue;146+ }147+ if (template.kind === "shared") {148+ const resource = policy[template.resource._table];149+ const grants = policy[template.grants._table];150+ const ownerOf = (resourceId: unknown) =>151+ resource.exists.where({ id: resourceId, $createdBy: session.user_id });152+ resource.allowInsert.always();153+ resource.allowRead.where((row: Record<string, unknown>) =>154+ anyOf([155+ { $createdBy: session.user_id },156+ grants.exists.where({ resourceId: row.id, user_id: session.user_id }),157+ ])158+ );159+ resource.allowUpdate.where((row: Record<string, unknown>) =>160+ anyOf([161+ { $createdBy: session.user_id },162+ grants.exists.where({163+ resourceId: row.id,164+ user_id: session.user_id,165+ can_edit: true,166+ }),167+ ])168+ );169+ resource.allowDelete.where({ $createdBy: session.user_id });170+ grants.allowRead.where((grant: Record<string, unknown>) =>171+ anyOf([{ user_id: session.user_id }, ownerOf(grant.resourceId)])172+ );173+ grants.allowInsert.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));174+ grants.allowUpdate.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));175+ grants.allowDelete.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));176+ continue;177+ }178+179+ const groups = policy[template.groups._table];180+ const members = policy[template.members._table];181+ const isMember = (groupId: unknown) =>182+ members.exists.where({ groupId, user_id: session.user_id });183+ const hasCapability = (groupId: unknown, capability: string) =>184+ members.exists.where({ groupId, user_id: session.user_id, [capability]: true });185+ const isAdmin = (groupId: unknown) => hasCapability(groupId, "can_manage");186+ groups.allowRead.where((group: Record<string, unknown>) =>187+ anyOf([isMember(group.id), { $createdBy: session.user_id }])188+ );189+ groups.allowInsert.always();190+ groups.allowUpdate.where((group: Record<string, unknown>) =>191+ anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])192+ );193+ groups.allowDelete.where((group: Record<string, unknown>) => isAdmin(group.id));194+ members.allowRead.where(195+ anyOf([196+ { user_id: session.user_id },197+ allowedTo.update("groupId"),198+ ]),199+ );200+ members.allowInsert.where(allowedTo.update("groupId"));201+ members.allowUpdate.where(allowedTo.update("groupId"));202+ members.allowDelete.where(203+ anyOf([allowedTo.update("groupId"), { user_id: session.user_id }]),204+ );205+ for (const table of template.resources) {206+ const resource = policy[table._table];207+ resource.allowRead.where((row: Record<string, unknown>) => isMember(row[template.groupId]));208+ resource.allowInsert.where((row: Record<string, unknown>) =>209+ hasCapability(row[template.groupId], "can_create")210+ );211+ resource.allowUpdate.where((row: Record<string, unknown>) =>212+ anyOf([213+ hasCapability(row[template.groupId], "can_edit_any"),214+ allOf([215+ { $createdBy: session.user_id },216+ hasCapability(row[template.groupId], "can_create"),217+ ]),218+ ])219+ );220+ resource.allowDelete.where((row: Record<string, unknown>) =>221+ anyOf([222+ hasCapability(row[template.groupId], "can_edit_any"),223+ allOf([224+ { $createdBy: session.user_id },225+ hasCapability(row[template.groupId], "can_create"),226+ ]),227+ ])228+ );229+ }230+ }231+ raw?.({ policy, session, anyOf, allOf, allowedTo });232+ });233+}@@ -0,0 +1,66 @@
1+import { schema as s } from "jazz-tools";2+import type { BooleanColumn, DefinedTable, RefColumn, StringColumn } from "jazz-tools";3+4+function requireTableName(name: string, helper: string): void {5+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {6+ throw new TypeError(7+ `${helper} requires a declared Jazz table name, received ${JSON.stringify(name)}`,8+ );9+ }10+}11+12+/** Creates the conventional relationship table used by `sharedAccess`. */13+export function sharedGrantTable<const Resource extends string>(14+ resource: Resource,15+): DefinedTable<{16+ resourceId: RefColumn<Resource>;17+ user_id: StringColumn;18+ can_edit: BooleanColumn;19+}> {20+ requireTableName(resource, "sharedGrantTable");21+ return s.table({22+ resourceId: s.ref(resource),23+ user_id: s.string(),24+ can_edit: s.boolean(),25+ });26+}27+28+/** Fixed Wave 2 group roles. Custom role systems remain a raw Jazz escape hatch. */29+export const groupRoles = ["reader", "contributor", "writer", "admin"] as const;30+export type GroupRole = (typeof groupRoles)[number];31+32+export function groupRoleCapabilities(role: GroupRole): {33+ readonly role: GroupRole;34+ readonly can_create: boolean;35+ readonly can_edit_any: boolean;36+ readonly can_manage: boolean;37+} {38+ return {39+ role,40+ can_create: role !== "reader",41+ can_edit_any: role === "writer" || role === "admin",42+ can_manage: role === "admin",43+ } as const;44+}45+46+/** Creates the conventional relationship table used by `groupAccess`. */47+export function groupMembershipTable<const Group extends string>(48+ group: Group,49+): DefinedTable<{50+ groupId: RefColumn<Group>;51+ user_id: StringColumn;52+ role: StringColumn;53+ can_create: BooleanColumn;54+ can_edit_any: BooleanColumn;55+ can_manage: BooleanColumn;56+}> {57+ requireTableName(group, "groupMembershipTable");58+ return s.table({59+ groupId: s.ref(group),60+ user_id: s.string(),61+ role: s.string(),62+ can_create: s.boolean(),63+ can_edit_any: s.boolean(),64+ can_manage: s.boolean(),65+ });66+}@@ -19,6 +19,7 @@ const runtimeFiles = [
1919 "inspector.ts",2020 "lifecycle.ts",2121 "mod.ts",22+ "passkey-recovery.ts",2223 "probe.ts",2324 "pwa.ts",2425 "recovery.ts",@@ -29,6 +30,15 @@ const runtimeFiles = [
2930 "ui-mutation.ts",3031 ] as const;3132 33+const accessFiles = [34+ "errors.ts",35+ "identity.ts",36+ "mod.ts",37+ "operations.ts",38+ "policies.ts",39+ "schema.ts",40+] as const;41+3242 const preactFiles = [3343 "DeviceStatus.tsx",3444 "mod.ts",@@ -48,6 +58,7 @@ async function readPackageFile(path: string): Promise<string> {
4858 4959 function renderConfig(projectRoot: string): string {5060 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");61+ const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");5162 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");5263 return `import preact from "@astrojs/preact";5364 import { defineConfig } from "astro/config";@@ -86,6 +97,7 @@ export default defineConfig({
8697 resolve: {8798 alias: [8899 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },100+ { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },89101 { find: "@nzip/lofi", replacement: ${JSON.stringify(runtimeEntry)} },90102 ],91103 noExternal: ["@astrojs/preact"],@@ -108,6 +120,7 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
108120 const path = join(toolingDir, "astro.config.ts");109121 const packageDir = join(toolingDir, "package");110122 await Deno.mkdir(join(packageDir, "runtime"), { recursive: true });123+ await Deno.mkdir(join(packageDir, "access"), { recursive: true });111124 await Deno.mkdir(join(packageDir, "preact"), { recursive: true });112125 for (const file of runtimeFiles) {113126 await Deno.writeTextFile(@@ -121,6 +134,12 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
121134 await readPackageFile(`../preact/${file}`),122135 );123136 }137+ for (const file of accessFiles) {138+ await Deno.writeTextFile(139+ join(packageDir, "access", file),140+ await readPackageFile(`../access/${file}`),141+ );142+ }124143 const configSource = renderConfig(projectRoot);125144 let current = "";126145 try {@@ -17,6 +17,8 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1717 if (!source.includes(expected)) throw new Error(`generated config omitted ${expected}`);1818 }1919 await Deno.stat(`${root}/.lofi/package/preact/DeviceStatus.tsx`);20+ await Deno.stat(`${root}/.lofi/package/runtime/passkey-recovery.ts`);21+ await Deno.stat(`${root}/.lofi/package/access/mod.ts`);2022 } finally {2123 await Deno.remove(root, { recursive: true });2224 }@@ -60,9 +60,11 @@ on-device account with no sign-in. To enable managed sync and account backup, ru
6060 6161 ## Accounts: back up & recover6262 63-With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local account64-and recover it from a 24-word recovery phrase. The account they already have carries over — electing65-to sync never changes its identity. The framework implementation is provided by \`@nzip/lofi\`.63+With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local account,64+restore it with a recoverable passkey, or use the portable 24-word recovery phrase fallback. The65+account they already have carries over — electing to sync never changes its identity. Passkey restore66+is scoped to the app's stable relying-party ID; provider syncing is not universally portable. The67+framework implementation is provided by \`@nzip/lofi\`.6668 6769 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:6870 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -95,6 +97,7 @@ Point them at any other static host by editing those two tasks.
9597 // verbatim, so the template is the single source of truth for project config.9698 const lofiImportTargets: Record<string, { subpath: string; localPath: string }> = {9799 "@nzip/lofi": { subpath: "", localPath: "runtime/mod.ts" },100+ "@nzip/lofi/access": { subpath: "access", localPath: "access/mod.ts" },98101 "@nzip/lofi/astro": { subpath: "astro", localPath: "astro/mod.ts" },99102 "@nzip/lofi/build": { subpath: "build", localPath: "commands/build.ts" },100103 "@nzip/lofi/dev": { subpath: "dev", localPath: "commands/dev.ts" },@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 10,110- `expected one package prefix, two integrations, six commands, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 11,110+ `expected one package prefix, access, two integrations, six commands, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.2.0/")),@@ -138,6 +138,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
138138 assertEquals(packageManifest.version, LOFI_VERSION);139139 assertEquals(Object.keys(packageManifest.exports).sort(), [140140 ".",141+ "./access",141142 "./astro",142143 "./build",143144 "./create",@@ -150,6 +151,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
150151 "./testing",151152 ]);152153 assertEquals(packageManifest.exports["."], "./package/runtime/mod.ts");154+ assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");153155 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");154156 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");155157 assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");@@ -5,6 +5,12 @@ export type LofiAppConfig<Schema = unknown> = {
55 schema: Schema;66 storage: "durable";77 credentialOrigins?: readonly string[];8+ /**9+ * Recoverable passkeys are scoped to this relying-party ID. Pin the canonical10+ * production hostname before users create backups. When omitted, the current11+ * hostname is used, which is convenient locally but preview-host specific.12+ */13+ passkey?: { rpId?: string };814 sync: { adapter: "jazz" };915 repositoryUrl?: string;1016 };@@ -49,14 +49,22 @@ export function syncing(): boolean {
4949 return syncAvailable && syncElected();5050 }5151 52-export function databaseConfig(secret: string): DbConfig {52+export function databaseConfig(53+ secret: string,54+ accountNamespace: string,55+ mode: "local" | "managed" = syncing() ? "managed" : "local",56+ connect = mode === "managed" && syncing(),57+): DbConfig {5358 const app = getLofiApp();5459 return {5560 appId,5661 // The same local-first secret opens the same account whether or not a server5762 // is attached, so electing to sync later preserves all existing data.58- ...(syncing() && serverUrl ? { serverUrl } : {}),63+ ...(connect && serverUrl ? { serverUrl } : {}),5964 secret,60- driver: { type: "persistent", dbName: `${app.databaseName}-${appId}` },65+ driver: {66+ type: "persistent",67+ dbName: `${app.databaseName}-${appId}-${accountNamespace}-${mode}`,68+ },6169 };6270 }@@ -47,24 +47,34 @@ export {
4747 export {4848 getRuntime,4949 getRuntimeDiagnostics,50+ getRuntimePrincipal,5051 type LofiRuntime,5152 recreateRuntime,53+ reloadBrowserRuntime,5254 runtimeRecreatedEvent,5355 shutdownRuntime,5456 subscribeRuntimeDiagnostics,5557 } from "./runtime.ts";5658 export {59+ type AccountReplacementOptions,5760 confirmPhraseAccess,5861 createBackupPasskey,62+ createRecoverablePasskeyBackup,5963 enableSyncBackup,64+ isAccountReplacementError,6065 isAuthError,66+ isRecoverablePasskeyError,6167 isRecoveryError,68+ type PasskeyBackupReceipt,69+ readAccountSession,6270 readSession,71+ restoreFromPasskey,6372 restoreFromRecoveryPhrase,6473 revealRecoveryPhrase,6574 type Session,6675 stopSyncBackup,6776 } from "./session.ts";77+export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";6878 export {6979 type TableHandle,7080 type TableRow,@@ -0,0 +1,141 @@
1+import { getLofiApp } from "./app.ts";2+3+type PasskeyBackupErrorCode =4+ | "not-supported"5+ | "invalid-secret"6+ | "create-failed"7+ | "get-failed"8+ | "no-credential"9+ | "invalid-credential"10+ | "verification-failed";11+12+export type RecoverablePasskeyErrorCode =13+ | "cancelled"14+ | "unsupported"15+ | "credential-missing"16+ | "rp-id-mismatch"17+ | "verification-failed"18+ | "invalid-credential"19+ | "backup-failed"20+ | "restore-failed";21+22+const messages: Record<RecoverablePasskeyErrorCode, string> = {23+ cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",24+ unsupported:25+ "This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",26+ "credential-missing":27+ "No recoverable passkey is available for this app and account. Check your passkey provider or use the recovery phrase.",28+ "rp-id-mismatch":29+ "This passkey belongs to a different app hostname. Open the canonical app address or use the recovery phrase.",30+ "verification-failed":31+ "The authenticator did not verify you. Unlock the passkey provider and try again, or use the recovery phrase.",32+ "invalid-credential":33+ "That credential is not a recoverable lofi account passkey. Choose the account-recovery passkey or use the recovery phrase.",34+ "backup-failed":35+ "The account could not be backed up to a passkey. The current account is unchanged; save the recovery phrase and try again.",36+ "restore-failed":37+ "The passkey could not restore this account. The current account is unchanged; use the recovery phrase or try again.",38+};39+40+export class RecoverablePasskeyError extends Error {41+ override readonly name = "RecoverablePasskeyError";42+ constructor(readonly code: RecoverablePasskeyErrorCode, options?: ErrorOptions) {43+ super(messages[code], options);44+ }45+}46+47+export interface PasskeyBackupAdapter {48+ backup(secret: string, displayName: string): Promise<void>;49+ restore(): Promise<string>;50+}51+52+type VendorPasskeyError = Error & { code: PasskeyBackupErrorCode; cause?: unknown };53+54+function isVendorPasskeyError(error: unknown): error is VendorPasskeyError {55+ return error instanceof Error && error.name === "PasskeyBackupError" &&56+ typeof (error as { code?: unknown }).code === "string";57+}58+59+function causeName(error: VendorPasskeyError): string {60+ const cause = error.cause;61+ return cause && typeof cause === "object" && "name" in cause ? String(cause.name) : "";62+}63+64+export function mapPasskeyBackupError(65+ error: unknown,66+ operation: "backup" | "restore",67+): RecoverablePasskeyError {68+ if (error instanceof RecoverablePasskeyError) return error;69+ if (!isVendorPasskeyError(error)) {70+ return new RecoverablePasskeyError(71+ operation === "backup" ? "backup-failed" : "restore-failed",72+ );73+ }74+ const vendorCode: PasskeyBackupErrorCode = error.code;75+ const domName = causeName(error);76+ if (vendorCode === "not-supported") return new RecoverablePasskeyError("unsupported");77+ if (vendorCode === "no-credential") return new RecoverablePasskeyError("credential-missing");78+ if (vendorCode === "verification-failed") {79+ return new RecoverablePasskeyError("verification-failed");80+ }81+ if (vendorCode === "invalid-credential" || vendorCode === "invalid-secret") {82+ return new RecoverablePasskeyError("invalid-credential");83+ }84+ if (domName === "SecurityError") return new RecoverablePasskeyError("rp-id-mismatch");85+ if (domName === "NotAllowedError" || domName === "AbortError") {86+ return new RecoverablePasskeyError("cancelled");87+ }88+ return new RecoverablePasskeyError(89+ operation === "backup" ? "backup-failed" : "restore-failed",90+ );91+}92+93+export function configuredPasskeyRpId(): string {94+ const configured = getLofiApp().passkey?.rpId?.trim();95+ if (configured) return configured;96+ return globalThis.location?.hostname ?? "localhost";97+}98+99+export function createPasskeyBackupAdapter(): PasskeyBackupAdapter {100+ const app = getLofiApp();101+ let implementation: Promise<PasskeyBackupAdapter> | undefined;102+ const resolve = () =>103+ implementation ??= import("jazz-tools/passkey-backup").then(({ BrowserPasskeyBackup }) =>104+ new BrowserPasskeyBackup({105+ appName: app.name,106+ appHostname: configuredPasskeyRpId(),107+ })108+ );109+ return {110+ async backup(secret, displayName) {111+ await (await resolve()).backup(secret, displayName);112+ },113+ async restore() {114+ return await (await resolve()).restore();115+ },116+ };117+}118+119+/** Runs the vendor backup boundary with lofi's stable error contract. */120+export async function backupSecretWithPasskey(121+ adapter: PasskeyBackupAdapter,122+ secret: string,123+ displayName: string,124+): Promise<void> {125+ try {126+ await adapter.backup(secret, displayName);127+ } catch (error) {128+ throw mapPasskeyBackupError(error, "backup");129+ }130+}131+132+/** Runs the vendor restore boundary with lofi's stable error contract. */133+export async function restoreSecretWithPasskey(134+ adapter: PasskeyBackupAdapter,135+): Promise<string> {136+ try {137+ return await adapter.restore();138+ } catch (error) {139+ throw mapPasskeyBackupError(error, "restore");140+ }141+}@@ -0,0 +1,67 @@
1+import {2+ backupSecretWithPasskey,3+ mapPasskeyBackupError,4+ restoreSecretWithPasskey,5+} from "./passkey-recovery.ts";6+import { assert } from "./test-assert.ts";7+8+function domError(name: string): DOMException {9+ return new DOMException("vendor detail must not reach the UI", name);10+}11+12+Deno.test("passkey backup errors become actionable non-secret recovery errors", () => {13+ const cases = [14+ [new PasskeyBackupError("not-supported"), "unsupported"],15+ [new PasskeyBackupError("no-credential"), "credential-missing"],16+ [new PasskeyBackupError("verification-failed"), "verification-failed"],17+ [new PasskeyBackupError("invalid-credential"), "invalid-credential"],18+ [new PasskeyBackupError("get-failed", domError("SecurityError")), "rp-id-mismatch"],19+ [new PasskeyBackupError("get-failed", domError("NotAllowedError")), "cancelled"],20+ ] as const;21+ for (const [input, code] of cases) {22+ const mapped = mapPasskeyBackupError(input, "restore");23+ assert(mapped.code === code, `${input.code} mapped to ${mapped.code}, expected ${code}`);24+ assert(25+ !mapped.message.includes("vendor detail"),26+ "vendor detail leaked into public error text",27+ );28+ }29+});30+31+Deno.test("passkey adapter backs up and restores the exact account secret", async () => {32+ const expected = "account-secret";33+ let backedUp: { secret: string; displayName: string } | undefined;34+ const adapter = {35+ backup(secret: string, displayName: string) {36+ backedUp = { secret, displayName };37+ return Promise.resolve();38+ },39+ restore() {40+ return Promise.resolve(expected);41+ },42+ };43+ await backupSecretWithPasskey(adapter, expected, "Account recovery");44+ assert(backedUp?.secret === expected, "backup changed the account secret");45+ assert(backedUp?.displayName === "Account recovery", "backup changed the display name");46+ assert(47+ await restoreSecretWithPasskey(adapter) === expected,48+ "restore changed the account secret",49+ );50+});51+52+Deno.test("unknown passkey failures distinguish backup from restore without leaking causes", () => {53+ assert(54+ mapPasskeyBackupError(new Error("secret payload"), "backup").code === "backup-failed",55+ "unknown backup failure was misclassified",56+ );57+ assert(58+ mapPasskeyBackupError(new Error("secret payload"), "restore").code === "restore-failed",59+ "unknown restore failure was misclassified",60+ );61+});62+class PasskeyBackupError extends Error {63+ override readonly name = "PasskeyBackupError";64+ constructor(readonly code: string, override readonly cause?: unknown) {65+ super(code);66+ }67+}@@ -49,6 +49,40 @@ export function recreateResource<T>(
4949 state.value = null;5050 state.promise = null;5151 if (previous) await destroy(previous);52+ // Browser SharedWorker port shutdown completes on a later task even after53+ // the Db shutdown promise resolves. Yield once before opening the replacement54+ // so the new persistent client does not attach to the retiring broker.55+ if (previous) await new Promise((resolve) => setTimeout(resolve, 50));56+ const value = await create();57+ state.value = value;58+ state.promise = Promise.resolve(value);59+ return value;60+ });61+ const tracked = recreation.finally(() => {62+ if (state.recreation === tracked) state.recreation = null;63+ });64+ state.recreation = tracked;65+ return tracked;66+}67+68+/**69+ * Replaces a serialized resource after an async preparation step. Callers are70+ * held behind the recreation promise while preparation and replacement run.71+ */72+export function replaceResource<T>(73+ state: SerializedResourceState<T>,74+ prepare: () => Promise<void>,75+ create: () => Promise<T>,76+ destroy: (value: T) => Promise<void>,77+): Promise<T> {78+ if (state.recreation) return state.recreation;79+ const recreation = enqueue(state, async () => {80+ await prepare();81+ const previous = state.value;82+ state.value = null;83+ state.promise = null;84+ if (previous) await destroy(previous);85+ if (previous) await new Promise((resolve) => setTimeout(resolve, 50));5286 const value = await create();5387 state.value = value;5488 state.promise = Promise.resolve(value);@@ -5,6 +5,7 @@ import {
55 createSerializedResourceState,66 getResource,77 recreateResource,8+ replaceResource,89 shutdownResource,910 } from "./resource-lifecycle.ts";1011 import { assert, assertCount } from "./test-assert.ts";@@ -40,6 +41,34 @@ test("concurrent recreation is single-flight and shutdown is idempotent", async
4041 assertCount(destroyed, 2, "concurrent shutdown must destroy the replacement once");4142 });4243 44+test("principal replacement prepares, destroys, then creates behind one serialized gate", async () => {45+ const state = createSerializedResourceState<{ id: number }>();46+ const events: string[] = [];47+ await getResource(state, () => Promise.resolve({ id: 1 }));48+49+ const replaced = await replaceResource(50+ state,51+ () => {52+ events.push("secret-saved");53+ return Promise.resolve();54+ },55+ () => {56+ events.push("client-created");57+ return Promise.resolve({ id: 2 });58+ },59+ (value) => {60+ events.push(`client-${value.id}-logged-out`);61+ return Promise.resolve();62+ },63+ );64+65+ assert(replaced.id === 2, "replacement did not become active");66+ assert(67+ events.join(",") === "secret-saved,client-1-logged-out,client-created",68+ `principal replacement ordering changed: ${events.join(",")}`,69+ );70+});71+4372 test("HMR disposal before dependency resolution never attaches an obsolete adapter", async () => {4473 const lifecycle = new AdapterLifecycle<{ id: number }, { id: number }>();4574 let resolveDependency: ((value: { id: number }) => void) | undefined;@@ -1,5 +1,7 @@
1+/// <reference path="./env.d.ts" />12 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";23 // Package-owned Jazz runtime.4+import { getLofiApp } from "./app.ts";35 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";46 import { appId, databaseConfig, syncing } from "./config.ts";57 import { assertDurableBrowser } from "./device-capabilities.ts";@@ -14,6 +16,7 @@ import {
1416 createSerializedResourceState,1517 getResource,1618 recreateResource,19+ replaceResource,1720 type SerializedResourceState,1821 shutdownResource,1922 } from "./resource-lifecycle.ts";@@ -35,9 +38,13 @@ type RuntimeSlot = {
3538 client: SerializedResourceState<Db>;3639 diagnostics: RuntimeDiagnostics;3740 diagnosticListeners: Set<() => void>;41+ principal: string | null;42+ serverConfigured: boolean;3843 };3944 4045 const slotName = "__LOFI_ALPHA53_RUNTIME__";46+const migrateLocalRowsKey = `lofi:migrate-local-rows:${appId}`;47+const managedRuntimeKey = `lofi:managed-runtime:${appId}`;4148 const browserGlobal = globalThis as typeof globalThis & { [slotName]?: RuntimeSlot };4249 4350 function slot(): RuntimeSlot {@@ -45,6 +52,8 @@ function slot(): RuntimeSlot {
4552 client: createSerializedResourceState<Db>(),4653 diagnostics: createDiagnostics(),4754 diagnosticListeners: new Set(),55+ principal: null,56+ serverConfigured: false,4857 };4958 return browserGlobal[slotName];5059 }@@ -63,11 +72,74 @@ async function resolveAccountSecret(): Promise<string> {
6372 return await BrowserAuthSecretStore.getOrCreateSecret({ appId });6473 }6574 75+async function accountNamespace(secret: string): Promise<string> {76+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));77+ return [...new Uint8Array(digest).slice(0, 8)]78+ .map((byte) => byte.toString(16).padStart(2, "0"))79+ .join("");80+}81+82+type RuntimeTable = {83+ _table: string;84+ _schema: Record<string, { columns?: Array<{ name?: string }> }>;85+ where(input: Record<string, unknown>): unknown;86+};87+88+function runtimeTables(): RuntimeTable[] {89+ return Object.values(getLofiApp().schema as Record<string, unknown>).filter((value) =>90+ value && typeof value === "object" && "_table" in value && "where" in value91+ ) as RuntimeTable[];92+}93+94+async function awaitLocalReady(db: Db): Promise<void> {95+ const firstTable = runtimeTables()[0];96+ if (firstTable) await db.all(firstTable.where({}) as never, { tier: "local" });97+}98+99+async function migrateLocalRows(db: Db, secret: string, namespace: string): Promise<void> {100+ if (typeof localStorage === "undefined" || localStorage.getItem(migrateLocalRowsKey) !== "1") {101+ return;102+ }103+ const local = await createDb(databaseConfig(secret, namespace, "local"));104+ await awaitLocalReady(local);105+ for (const table of runtimeTables()) {106+ const rows = await local.all(table.where({}) as never, { tier: "local" }) as Array<107+ Record<string, unknown> & { id: string }108+ >;109+ const columns = table._schema[table._table]?.columns ?? [];110+ for (const row of rows) {111+ const values: Record<string, unknown> = {};112+ for (const column of columns) {113+ if (column.name && column.name in row) values[column.name] = row[column.name];114+ }115+ await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "global" });116+ }117+ }118+ localStorage.removeItem(migrateLocalRowsKey);119+ // Keep the read-only source connection until navigation. In alpha.53,120+ // shutting down either browser Db can retire the shared broker used by its121+ // sibling; the page lifecycle releases both ports safely.122+}123+66124 async function createClient(state: RuntimeSlot): Promise<Db> {67125 try {68126 assertDurableBrowser();69127 const secret = await resolveAccountSecret();70- const db = await createDb(databaseConfig(secret));128+ const namespace = await accountNamespace(secret);129+ const managedNamespace = typeof localStorage !== "undefined" &&130+ localStorage.getItem(managedRuntimeKey) === "1";131+ const connect = syncing();132+ const db = await createDb(133+ databaseConfig(secret, namespace, managedNamespace || connect ? "managed" : "local", connect),134+ );135+ // Browser `createDb()` returns before its persistent worker bridge has136+ // necessarily attached. A local-tier query is the public readiness barrier;137+ // without it, an immediate post-replacement mutation can see no attainable138+ // durability tier and be rejected even though OPFS is configured.139+ await awaitLocalReady(db);140+ if (syncing()) await migrateLocalRows(db, secret, namespace);141+ state.principal = db.getAuthState().session?.user_id ?? null;142+ state.serverConfigured = connect;71143 state.diagnostics.storageState = "persistent-driver-open";72144 state.diagnostics.clientsCreated += 1;73145 state.diagnostics.activeClients += 1;@@ -85,6 +157,15 @@ async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {
85157 notifyDiagnostics(state);86158 }87159 160+async function replaceClient(state: RuntimeSlot, db: Db): Promise<void> {161+ // The secret store was updated before this point. Shut down the old worker and162+ // client without `logout()`, which would clear local account state that must163+ // carry forward into the restored/sync-enabled runtime.164+ await db.shutdown();165+ state.diagnostics.activeClients -= 1;166+ notifyDiagnostics(state);167+}168+88169 const adapter = new AdapterLifecycle<Db, LofiRuntime>();89170 let recreationPromise: Promise<LofiRuntime> | null = null;90171 let shutdownPromise: Promise<void> | null = null;@@ -131,6 +212,16 @@ export function getRuntimeDiagnostics(): RuntimeDiagnostics {
131212 return { ...slot().diagnostics };132213 }133214 215+/** The stable Jazz principal currently opened by the package runtime. */216+export function getRuntimePrincipal(): string | null {217+ return slot().principal;218+}219+220+/** Whether the active Jazz client was created with a managed transport to reconnect. */221+export function runtimeCanReconnect(): boolean {222+ return slot().serverConfigured;223+}224+134225 export function subscribeRuntimeDiagnostics(listener: () => void): () => void {135226 const listeners = slot().diagnosticListeners;136227 listeners.add(listener);@@ -161,6 +252,48 @@ export function recreateRuntime(): Promise<LofiRuntime> {
161252 return tracked;162253 }163254 255+/**256+ * Fully tears down a browser persistent worker and reloads the document. Jazz257+ * alpha.53 cannot attach a second OPFS worker reliably in the same document258+ * after shutdown; navigation is the supported clean-runtime boundary.259+ */260+export async function reloadBrowserRuntime(): Promise<never> {261+ await shutdownRuntime();262+ globalThis.location.reload();263+ return await new Promise<never>(() => {});264+}265+266+/**267+ * Replaces the local-first secret and every runtime object tied to the old268+ * principal. Consumers and the old Jazz client close before the secret is269+ * replaced; a fresh runtime then opens on the restored principal.270+ */271+export function replaceRuntimePrincipal(secret: string): Promise<LofiRuntime> {272+ if (recreationPromise) return recreationPromise;273+ const state = slot();274+ const operation = (async () => {275+ if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {276+ await shutdownRuntime();277+ await BrowserAuthSecretStore.saveSecret(secret, { appId });278+ globalThis.location.reload();279+ return await new Promise<LofiRuntime>(() => {});280+ }281+ await adapter.release((runtime) => runtime.shutdown());282+ const db = await replaceResource(283+ state.client,284+ () => BrowserAuthSecretStore.saveSecret(secret, { appId }),285+ () => createClient(state),286+ (current) => replaceClient(state, current),287+ );288+ return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));289+ })();290+ const tracked = operation.finally(() => {291+ if (recreationPromise === tracked) recreationPromise = null;292+ });293+ recreationPromise = tracked;294+ return tracked;295+}296+164297 export function shutdownRuntime(): Promise<void> {165298 if (shutdownPromise) return shutdownPromise;166299 const state = slot();@@ -1,5 +1,5 @@
11 /**2-// Package-owned account session runtime.2+ * Package-owned account session runtime.33 * Account session for lofi's local-first identity: the glue between the account44 * secret the runtime opens and the user's choice to back it up and sync.55 *@@ -18,13 +18,30 @@
1818 */1919 2020 import { BrowserAuthSecretStore } from "jazz-tools";21+import { getLofiApp } from "./app.ts";2122 import { appId, setSyncElected, syncAvailable, syncElected, syncing } from "./config.ts";2223 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";2324 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";24-import { recreateRuntime, runtimeRecreatedEvent } from "./runtime.ts";25+import {26+ backupSecretWithPasskey,27+ createPasskeyBackupAdapter,28+ RecoverablePasskeyError,29+ restoreSecretWithPasskey,30+} from "./passkey-recovery.ts";31+import {32+ getRuntime,33+ getRuntimePrincipal,34+ recreateRuntime,35+ reloadBrowserRuntime,36+ replaceRuntimePrincipal,37+ runtimeCanReconnect,38+ runtimeRecreatedEvent,39+} from "./runtime.ts";2540 2641 /** A snapshot of the account: what is possible and what the user has chosen. */2742 export type Session = {43+ /** Stable, app-scoped Jazz sharing identity for the active account. */44+ user_id: string | null;2845 /** Whether a managed Jazz app is configured, so backup + sync is possible at all. */2946 syncAvailable: boolean;3047 /** Whether the user has elected to back up and sync this account on this device. */@@ -37,12 +54,17 @@ export type Session = {
3754 * it does **not** encrypt the account secret held on the device.3855 */3956 phraseGuarded: boolean;57+ /** Whether this device created a passkey containing a recoverable account backup. */58+ passkeyRecoverable: boolean;4059 };4160 4261 // A per-device flag recording that the user enrolled a passkey to guard the4362 // recovery phrase. The passkey itself is a resident credential on the device;4463 // this only remembers to ask for it before revealing the phrase.4564 const phraseGuardKey = `lofi:phrase-passkey:${appId}`;65+const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;66+const managedRuntimeKey = `lofi:managed-runtime:${appId}`;67+const migrateLocalRowsKey = `lofi:migrate-local-rows:${appId}`;4668 4769 function phraseGuarded(): boolean {4870 if (typeof localStorage === "undefined") return false;@@ -63,16 +85,43 @@ function setPhraseGuarded(guarded: boolean): void {
6385 }6486 }6587 88+function passkeyRecoverable(): boolean {89+ if (typeof localStorage === "undefined") return false;90+ try {91+ return localStorage.getItem(recoverablePasskeyKey) === "1";92+ } catch {93+ return false;94+ }95+}96+97+function setPasskeyRecoverable(value: boolean): void {98+ if (typeof localStorage === "undefined") return;99+ try {100+ if (value) localStorage.setItem(recoverablePasskeyKey, "1");101+ else localStorage.removeItem(recoverablePasskeyKey);102+ } catch {103+ // The credential remains the source of truth if local metadata cannot persist.104+ }105+}106+66107 /** Reads the current session. Synchronous — it never prompts or touches the network. */67108 export function readSession(): Session {68109 return {110+ user_id: getRuntimePrincipal(),69111 syncAvailable,70112 backedUp: syncElected(),71113 syncing: syncing(),72114 phraseGuarded: phraseGuarded(),115+ passkeyRecoverable: passkeyRecoverable(),73116 };74117 }75118 119+/** Resolves the runtime before returning a session with a stable `user_id`. */120+export async function readAccountSession(): Promise<Session> {121+ await getRuntime();122+ return readSession();123+}124+76125 // The account secret the runtime is already using. `getOrCreateSecret` is a77126 // read-through: the runtime created it on first boot, so this returns that same78127 // secret rather than minting a new one.@@ -105,6 +154,31 @@ export async function createBackupPasskey(label?: string): Promise<boolean> {
105154 }106155 }107156 157+export type PasskeyBackupReceipt = { user_id: string; rpId: string };158+159+/**160+ * Stores the current 32-byte local-first secret inside Jazz's resident,161+ * user-verifying passkey backup. Unlike `createBackupPasskey`, this credential162+ * can restore the account and is not merely a local phrase-reveal guard.163+ */164+export async function createRecoverablePasskeyBackup(165+ displayName?: string,166+): Promise<PasskeyBackupReceipt> {167+ const runtime = await getRuntime();168+ const user_id = runtime.db.getAuthState().session?.user_id;169+ if (!user_id) throw new RecoverablePasskeyError("backup-failed");170+ await backupSecretWithPasskey(171+ createPasskeyBackupAdapter(),172+ await currentSecret(),173+ displayName?.trim() || `${getLofiApp().name} account ${user_id.slice(0, 8)}`,174+ );175+ setPasskeyRecoverable(true);176+ return {177+ user_id,178+ rpId: getLofiApp().passkey?.rpId?.trim() || globalThis.location?.hostname || "localhost",179+ };180+}181+108182 /**109183 * Runs the user-verifying passkey ceremony that must precede revealing the phrase110184 * on a guarded device. A no-op when the device is not guarded. Throws `cancelled`@@ -132,8 +206,23 @@ export async function revealRecoveryPhrase(): Promise<string> {
132206 * no Jazz app is configured.133207 */134208 export async function enableSyncBackup(): Promise<Session> {209+ const canReconnect = typeof localStorage !== "undefined" &&210+ localStorage.getItem(managedRuntimeKey) === "1";135211 setSyncElected(true);136- await recreateRuntime();212+ if (canReconnect && runtimeCanReconnect()) {213+ await (await getRuntime()).db.reconnect();214+ } else {215+ try {216+ localStorage.setItem(managedRuntimeKey, "1");217+ if (!canReconnect) localStorage.setItem(migrateLocalRowsKey, "1");218+ } catch {219+ // Runtime mode remains authoritative when local metadata cannot persist.220+ }221+ if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {222+ return await reloadBrowserRuntime();223+ }224+ await recreateRuntime();225+ }137226 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));138227 return readSession();139228 }@@ -144,8 +233,9 @@ export async function enableSyncBackup(): Promise<Session> {
144233 * electing to sync again resumes against the same account.145234 */146235 export async function stopSyncBackup(): Promise<Session> {236+ const runtime = await getRuntime();147237 setSyncElected(false);148- await recreateRuntime();238+ await runtime.db.disconnect();149239 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));150240 return readSession();151241 }@@ -160,15 +250,56 @@ export async function stopSyncBackup(): Promise<Session> {
160250 * This overwrites whatever account this device currently holds, so a caller161251 * should confirm the intent before discarding a local-only account's data.162252 */163-export async function restoreFromRecoveryPhrase(phrase: string): Promise<Session> {164- const secret = fromRecoveryPhrase(phrase);165- await BrowserAuthSecretStore.saveSecret(secret, { appId });253+export type AccountReplacementOptions = {254+ /** Explicit acknowledgement that a different local-only account may be discarded. */255+ confirmLocalReplacement?: boolean;256+};257+258+export class AccountReplacementError extends Error {259+ override readonly name = "AccountReplacementError";260+ readonly code = "confirmation-required";261+ constructor() {262+ super(263+ "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",264+ );265+ }266+}267+268+async function replaceAccountSecret(269+ secret: string,270+ options: AccountReplacementOptions,271+): Promise<Session> {272+ const current = await currentSecret();273+ if (current !== secret && !syncElected() && !options.confirmLocalReplacement) {274+ throw new AccountReplacementError();275+ }166276 setSyncElected(true);167- await recreateRuntime();277+ try {278+ localStorage.setItem(managedRuntimeKey, "1");279+ } catch {280+ // The restored runtime is still authoritative if metadata cannot persist.281+ }282+ await replaceRuntimePrincipal(secret);168283 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));169284 return readSession();170285 }171286 287+export async function restoreFromRecoveryPhrase(288+ phrase: string,289+ options: AccountReplacementOptions = {},290+): Promise<Session> {291+ return await replaceAccountSecret(fromRecoveryPhrase(phrase), options);292+}293+294+/** Restores a passkey-backed secret and recreates Jazz on that stable principal. */295+export async function restoreFromPasskey(296+ options: AccountReplacementOptions = {},297+): Promise<Session> {298+ const secret = await restoreSecretWithPasskey(createPasskeyBackupAdapter());299+ setPasskeyRecoverable(true);300+ return await replaceAccountSecret(secret, options);301+}302+172303 /** True when an error is a recovery-phrase problem the user can fix and retry. */173304 export function isRecoveryError(error: unknown): error is RecoveryError {174305 return error instanceof RecoveryError;@@ -178,3 +309,11 @@ export function isRecoveryError(error: unknown): error is RecoveryError {
178309 export function isAuthError(error: unknown): error is AuthError {179310 return error instanceof AuthError;180311 }312+313+export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {314+ return error instanceof RecoverablePasskeyError;315+}316+317+export function isAccountReplacementError(error: unknown): error is AccountReplacementError {318+ return error instanceof AccountReplacementError;319+}@@ -1,17 +1,17 @@
11 {22 ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "17e468b307c1310c32157d8993c206d24cf1c6e012826073f1bfdda1cda9a8d3",5- "public/apple-touch-icon.png": "7e1ef330b6bd071474b2db4f7e294ee8dc29046ec7e02484e0ff765f8dff89af",6- "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",7- "public/icon-192.png": "0006987d46922e4ed70730c1e0b94ac343e3c035fcfc872b56c656c94ca9e730",8- "public/icon-512.png": "7900faacd2df9421da5494a4db4e0d4a76b106ec4ddf7f876353472ceeea5ca3",9- "public/icon-maskable-512.png": "bac5120e97173ac04bca40f0dce480b6aa2ce6b9f7d4c780227e1974f483f736",4+ "deno.json": "e6f98cb7dfc571284a74960d5604ba115c1b642c588a4023a17eafe9a50d67b6",5+ "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",6+ "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",7+ "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",8+ "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",9+ "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",11- "README.md": "6cbf9ce46b9e91b97bf57bd2b33a3191dfdd19ce2650a9f2397cb949383ef2fa",12- "src/app.ts": "be92b21b00f9b737aef6676928573c02f058700058cdf7d8838df5a4933dd26f",11+ "README.md": "38115d6a07c89acedcdf2801a2d9cb80d3083fde1269583db6db29b15a526982",12+ "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1313 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",14- "src/islands/AccountGate.tsx": "8dcb98ba3f2f7cb1b0a7494b7ac6cec02d65bab1f78fe6e4be88349e6a8227d2",14+ "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",1515 "src/islands/TaskList.tsx": "b38d32ecc08c60e1bef3c70e3e96914df1a2037419253324c4ac48443531f7e7",1616 "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",1717 "src/layouts/Shell.astro": "7bad8419d4ad8f89294c8c145fd2d710cde43f3bc6ebc40fd95793786bac5f1e",@@ -30,6 +30,7 @@ export {
3030 export { ReadinessError, type ReadinessOptions, waitForReady } from "./readiness.ts";3131 export type { ValueFreeState } from "./safety.ts";3232 export {33+ type VirtualAuthenticatorCredential,3334 type VirtualAuthenticatorHandle,3435 type VirtualAuthenticatorOptions,3536 withVirtualAuthenticator,@@ -29,10 +29,25 @@ export interface VirtualAuthenticatorOptions {
2929 readonly automaticPresenceSimulation?: boolean;3030 }3131 32+/** Serializable CDP credential used to copy one virtual passkey between browser profiles. */33+export interface VirtualAuthenticatorCredential {34+ readonly credentialId: string;35+ readonly isResidentCredential: boolean;36+ readonly rpId?: string;37+ readonly privateKey: string;38+ readonly userHandle?: string;39+ readonly signCount: number;40+ readonly largeBlob?: string;41+}42+3243 /** A handle to an installed virtual authenticator; call {@link dispose} to remove it. */3344 export interface VirtualAuthenticatorHandle {3445 /** The CDP-assigned id of the virtual authenticator. */3546 readonly authenticatorId: string;47+ /** Return credentials from this virtual authenticator. Treat private keys as test secrets. */48+ credentials(): Promise<VirtualAuthenticatorCredential[]>;49+ /** Install a previously exported virtual credential in this test profile. */50+ addCredential(credential: VirtualAuthenticatorCredential): Promise<void>;3651 /** Remove the virtual authenticator and detach the CDP session. Idempotent. */3752 dispose(): Promise<void>;3853 }@@ -47,7 +62,7 @@ export async function withVirtualAuthenticator(
4762 options: VirtualAuthenticatorOptions = {},4863 ): Promise<VirtualAuthenticatorHandle> {4964 const session = await page.context().newCDPSession(page);50- await session.send("WebAuthn.enable");65+ await session.send("WebAuthn.enable", { enableUI: true });5166 const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", {5267 options: {5368 protocol: options.protocol ?? "ctap2",@@ -62,6 +77,15 @@ export async function withVirtualAuthenticator(
6277 let disposed = false;6378 return {6479 authenticatorId,80+ async credentials(): Promise<VirtualAuthenticatorCredential[]> {81+ const result = await session.send("WebAuthn.getCredentials", { authenticatorId }) as {82+ credentials: VirtualAuthenticatorCredential[];83+ };84+ return result.credentials;85+ },86+ async addCredential(credential: VirtualAuthenticatorCredential): Promise<void> {87+ await session.send("WebAuthn.addCredential", { authenticatorId, credential });88+ },6589 async dispose(): Promise<void> {6690 if (disposed) return;6791 disposed = true;@@ -3,17 +3,22 @@
33 import { basename, dirname, join, resolve } from "node:path";44 import { fileURLToPath, pathToFileURL } from "node:url";55 import { type Browser, type BrowserContext, chromium, type Page } from "playwright";6+import { deploy, startLocalJazzServer } from "jazz-tools/testing";67 import { LOFI_VERSION } from "../package/version.ts";78 import { waitForReady } from "../package/testing/readiness.ts";89 import { redactDiagnosticText } from "../package/testing/safety.ts";910 import { sanitizeTraceArchive } from "../package/testing/trace.ts";11+import {12+ type VirtualAuthenticatorCredential,13+ withVirtualAuthenticator,14+} from "../package/testing/webauthn.ts";1015 1116 type PackageSource = "local" | "registry";1217 type StageLog = { command?: string; stdout: string; stderr: string };1318 1419 const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");1520 const artifactsRoot = join(repositoryRoot, "test-results", "golden");16-const privateValues = ["golden-online-task", "golden-offline-task"];21+const privateValues = ["golden-online-task", "golden-offline-task", "golden-synced-task"];1722 const logs = new Map<string, StageLog>();1823 const timings = new Map<string, number>();1924 let activeStage = "setup";@@ -132,10 +137,12 @@ class ManagedProcess {
132137 readonly name: string,133138 args: readonly string[],134139 readonly cwd: string,140+ environment: Readonly<Record<string, string>> = {},135141 ) {136142 this.child = new Deno.Command(Deno.execPath(), {137143 args: [...args],138144 cwd,145+ env: { ...environment },139146 stdout: "piped",140147 stderr: "piped",141148 }).spawn();@@ -208,6 +215,23 @@ class ManagedProcess {
208215 }209216 }210217 218+async function configureGoldenSync(projectRoot: string) {219+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });220+ const cacheBuster = `?golden=${Date.now()}`;221+ const [{ app }, { default: permissions }] = await Promise.all([222+ import(`${pathToFileURL(join(projectRoot, "src", "schema.ts")).href}${cacheBuster}`),223+ import(`${pathToFileURL(join(projectRoot, "src", "permissions.ts")).href}${cacheBuster}`),224+ ]);225+ await deploy({226+ appId: server.appId,227+ serverUrl: server.url,228+ adminSecret: server.adminSecret,229+ schema: app,230+ permissions,231+ });232+ return server;233+}234+211235 function allocatePort(): number {212236 const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });213237 const port = (listener.addr as Deno.NetAddr).port;@@ -341,7 +365,7 @@ async function assertGeneratedBoundary(
341365 const lofiImports = Object.entries(config.imports).filter(([name]) =>342366 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")343367 );344- assert(lofiImports.length === 11, `expected 11 lofi imports, received ${lofiImports.length}`);368+ assert(lofiImports.length === 12, `expected 12 lofi imports, received ${lofiImports.length}`);345369 const expected = source === "registry"346370 ? `jsr:@nzip/lofi@${version}`347371 : pathToFileURL(join(repositoryRoot, "package")).href;@@ -495,6 +519,33 @@ const taskAppReady = () => {
495519 return status !== null && /item\(s\)/.test(status.textContent ?? "");496520 };497521 522+async function selectVirtualCredential(page: Page, credentialId: string): Promise<void> {523+ await page.evaluate((encodedId) => {524+ const normalized = encodedId.replaceAll("-", "+").replaceAll("_", "/");525+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);526+ const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));527+ const credentials = navigator.credentials;528+ const original = credentials.get.bind(credentials);529+ Object.defineProperty(credentials, "preventSilentAccess", {530+ configurable: true,531+ value: () => Promise.resolve(),532+ });533+ Object.defineProperty(credentials, "get", {534+ configurable: true,535+ value: (options?: CredentialRequestOptions) => {536+ if (!options?.publicKey) return original(options);537+ return original({538+ ...options,539+ publicKey: {540+ ...options.publicKey,541+ allowCredentials: [{ id: bytes, type: "public-key", transports: ["internal"] }],542+ },543+ });544+ },545+ });546+ }, credentialId);547+}548+498549 async function main() {499550 const { source, version } = parseArgs(Deno.args);500551 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {@@ -502,13 +553,19 @@ async function main() {
502553 });503554 const temporaryRoot = await Deno.makeTempDir({ prefix: "lofi-golden-" });504555 const port = allocatePort();505- const origin = `http://127.0.0.1:${port}/`;556+ // `localhost` is a valid WebAuthn RP-ID; an IP address is not portable across557+ // authenticators even though Chromium treats it as a secure context.558+ const origin = `http://localhost:${port}/`;506559 let projectRoot: string | undefined;507560 let dev: ManagedProcess | undefined;508561 let preview: ManagedProcess | undefined;509562 let browser: Browser | undefined;510563 let context: BrowserContext | undefined;511564 let page: Page | undefined;565+ let syncServer: Awaited<ReturnType<typeof startLocalJazzServer>> | undefined;566+ let recoveryCredential: VirtualAuthenticatorCredential | undefined;567+ let recoveryPhrase = "";568+ let restoredIdentity = "";512569 const browserDiagnostics: string[] = [];513570 try {514571 projectRoot = await stage("create", () => generateProject(source, version, temporaryRoot));@@ -526,15 +583,26 @@ async function main() {
526583 runCommand("test", Deno.execPath(), ["task", "test"], projectRoot!).then(() => undefined),527584 );528585 529- dev = new ManagedProcess("dev", [530- "task",586+ syncServer = await stage("managed sync", () => configureGoldenSync(projectRoot!));587+ const syncEnvironment = {588+ JAZZ_APP_ID: syncServer.appId,589+ JAZZ_SERVER_URL: syncServer.url,590+ };591+592+ dev = new ManagedProcess(531593 "dev",532- "--",533- "--host",534- "127.0.0.1",535- "--port",536- String(port),537- ], projectRoot);594+ [595+ "task",596+ "dev",597+ "--",598+ "--host",599+ "127.0.0.1",600+ "--port",601+ String(port),602+ ],603+ projectRoot,604+ syncEnvironment,605+ );538606 await stage(539607 "development readiness",540608 async () => {@@ -546,6 +614,7 @@ async function main() {
546614 context = await browser.newContext();547615 await context.tracing.start({ screenshots: false, snapshots: false, sources: false });548616 page = await context.newPage();617+ const profileAAuthenticator = await withVirtualAuthenticator(page);549618 page.on(550619 "console",551620 (message) => browserDiagnostics.push(`console ${message.type()}: ${message.text()}`),@@ -581,13 +650,155 @@ async function main() {
581650 );582651 assert(worker === "development-disabled", `development worker state was ${worker}`);583652 });653+ await stage("passkey backup", async () => {654+ await page!.getByRole("button", { name: "Back up & enable sync" }).click();655+ await page!.getByRole("heading", { name: "Backed up & syncing" }).waitFor({656+ state: "visible",657+ timeout: 30_000,658+ });659+ await page!.getByRole("button", { name: "Show recovery phrase" }).click();660+ await page!.locator(".account-words").waitFor({ state: "visible" });661+ recoveryPhrase = (await page!.locator(".account-words li").allTextContents()).join(" ");662+ assert(recoveryPhrase.split(" ").length === 24, "backup did not reveal a 24-word phrase");663+ privateValues.push(recoveryPhrase);664+ restoredIdentity = (await page!.locator("[data-sharing-identity]").textContent())?.trim() ??665+ "";666+ assert(restoredIdentity.startsWith("lofi1:"), "profile A did not expose a sharing identity");667+ const credentials = await profileAAuthenticator.credentials();668+ assert(669+ credentials.length === 1,670+ `expected one recoverable passkey, received ${credentials.length}`,671+ );672+ recoveryCredential = credentials[0];673+ await waitForReady(page!, taskAppReady, undefined, {674+ description: "task store after sync runtime recreation",675+ timeoutMs: 30_000,676+ });677+ await page!.fill("#new-task", privateValues[2]);678+ await page!.press("#new-task", "Enter");679+ await page!.locator(".task", { hasText: privateValues[2] }).waitFor({ state: "visible" });680+ });681+ await profileAAuthenticator.dispose();682+683+ await stage("two-profile passkey restore", async () => {684+ const profileBBrowser = await chromium.launch({ headless: true });685+ const profileB = await profileBBrowser.newContext();686+ const profileBPage = await profileB.newPage();687+ const profileBAuthenticator = await withVirtualAuthenticator(profileBPage);688+ try {689+ await profileBPage.goto(origin, { waitUntil: "domcontentloaded" });690+ await waitForReady(profileBPage, taskAppReady, undefined, {691+ description: "fresh profile B local account",692+ timeoutMs: 30_000,693+ });694+ await profileBAuthenticator.addCredential(recoveryCredential!);695+ assert(696+ (await profileBAuthenticator.credentials()).length === 1,697+ "profile B virtual authenticator did not retain the recovery credential",698+ );699+ await selectVirtualCredential(profileBPage, recoveryCredential!.credentialId);700+ await profileBPage.getByRole("checkbox", { name: /I understand restore replaces/ }).check();701+ await profileBPage.getByRole("button", { name: "Use passkey" }).click();702+ try {703+ await profileBPage.getByRole("heading", { name: "Backed up & syncing" }).waitFor({704+ state: "visible",705+ timeout: 5_000,706+ });707+ } catch (cause) {708+ const accountText = await profileBPage.locator(".account").innerText().catch(() => "");709+ const elected = await profileBPage.evaluate(() =>710+ Object.entries(localStorage).some(([key, value]) =>711+ key.startsWith("lofi:sync-elected:") && value === "1"712+ )713+ );714+ throw new Error(715+ `profile B did not complete passkey restore (sync elected: ${elected}): ${accountText}`,716+ { cause },717+ );718+ }719+ const identityB = (await profileBPage.locator("[data-sharing-identity]").textContent())720+ ?.trim() ?? "";721+ assert(722+ identityB === restoredIdentity,723+ "passkey restore opened a different session.user_id",724+ );725+ await waitForReady(profileBPage, taskAppReady, undefined, {726+ description: "profile B restored task store",727+ timeoutMs: 30_000,728+ });729+ await profileBPage.locator(".task", { hasText: privateValues[2] }).waitFor({730+ state: "visible",731+ timeout: 30_000,732+ });733+ await profileBPage.locator(".task", { hasText: privateValues[0] }).waitFor({734+ state: "visible",735+ timeout: 30_000,736+ });737+ } finally {738+ await profileBAuthenticator.dispose().catch(() => undefined);739+ await profileB.close();740+ await profileBBrowser.close();741+ }742+ });743+744+ await stage("phrase fallback after unavailable passkey", async () => {745+ const profileCBrowser = await chromium.launch({ headless: true });746+ const profileC = await profileCBrowser.newContext();747+ await profileC.addInitScript(() => {748+ Object.defineProperty(navigator, "credentials", { configurable: true, value: undefined });749+ });750+ const profileCPage = await profileC.newPage();751+ try {752+ await profileCPage.goto(origin, { waitUntil: "domcontentloaded" });753+ await waitForReady(profileCPage, taskAppReady, undefined, {754+ description: "fresh profile C local account",755+ timeoutMs: 30_000,756+ });757+ await profileCPage.getByRole("checkbox", { name: /I understand restore replaces/ }).check();758+ await profileCPage.getByRole("button", { name: "Use passkey" }).click();759+ await profileCPage.locator("#recovery-input").waitFor({760+ state: "visible",761+ timeout: 10_000,762+ });763+ await profileCPage.fill("#recovery-input", recoveryPhrase);764+ await profileCPage.getByRole("button", { name: "Restore account" }).click();765+ await profileCPage.getByRole("heading", { name: "Backed up & syncing" }).waitFor({766+ state: "visible",767+ timeout: 30_000,768+ });769+ const identityC = (await profileCPage.locator("[data-sharing-identity]").textContent())770+ ?.trim() ?? "";771+ assert(772+ identityC === restoredIdentity,773+ "phrase fallback opened a different session.user_id",774+ );775+ } finally {776+ await profileC.close();777+ await profileCBrowser.close();778+ }779+ });780+ await stage("return to local-only", async () => {781+ await page!.getByRole("button", { name: "Stop syncing" }).click();782+ await page!.getByRole("heading", { name: "Back up & sync" }).waitFor({ state: "visible" });783+ await waitForReady(page!, taskAppReady, undefined, {784+ description: "task store after returning local-only",785+ timeoutMs: 30_000,786+ });787+ await page!.locator(".task", { hasText: privateValues[2] }).waitFor({ state: "visible" });788+ });584789 await stage("development shutdown", () => dev!.stop());585790 dev = undefined;586791 587792 await stage(588793 "build",589794 () =>590- runCommand("build", Deno.execPath(), ["task", "build"], projectRoot!).then(() => undefined),795+ runCommand(796+ "build",797+ Deno.execPath(),798+ ["task", "build"],799+ projectRoot!,800+ syncEnvironment,801+ ).then(() => undefined),591802 );592803 await stage("production output", () => assertProductionOutput(projectRoot!));593804 preview = new ManagedProcess(@@ -629,7 +840,7 @@ async function main() {
629840 await waitForReady(page!, taskAppReady, undefined, {630841 description: "offline cold page ready",631842 });632- await page!.locator(".task", { hasText: privateValues[0] }).waitFor({ state: "visible" });843+ await page!.locator(".task", { hasText: privateValues[2] }).waitFor({ state: "visible" });633844 await page!.fill("#new-task", privateValues[1]);634845 await page!.press("#new-task", "Enter");635846 await page!.locator(".task", { hasText: privateValues[1] }).waitFor({ state: "visible" });@@ -645,7 +856,7 @@ async function main() {
645856 );646857 const taskStatus = document.querySelector('[role="status"]')?.textContent ?? "";647858 return /item\(s\)/.test(taskStatus) &&648- syncLabel?.nextElementSibling?.textContent?.trim() === "local-only";859+ syncLabel?.nextElementSibling?.textContent?.trim() === "available — not yet backed up";649860 },650861 undefined,651862 { description: "public local-only durability and sync state" },@@ -663,7 +874,10 @@ async function main() {
663874 });664875 assert(state.worker === "ready", `production service worker ended in ${state.worker}`);665876 assert(/item\(s\)/.test(state.status), "public retained-row state is missing");666- assert(state.sync === "local-only", `unexpected public sync state ${state.sync}`);877+ assert(878+ state.sync === "available — not yet backed up",879+ `unexpected public sync state ${state.sync}`,880+ );667881 });668882 await context.tracing.stop();669883 await stage("preview shutdown", () => preview!.stop());@@ -686,6 +900,7 @@ async function main() {
686900 await Promise.allSettled([dev?.stop(), preview?.stop()]);687901 await context?.close().catch(() => undefined);688902 await browser?.close().catch(() => undefined);903+ await syncServer?.stop().catch(() => undefined);689904 await Deno.remove(temporaryRoot, { recursive: true }).catch(() => undefined);690905 }691906 }