- commit
- a5a9cf18087a90c17e1a9101324f26cc49c816ce
- parent
- 21bf7a5ac0a39c3d0d3b13751655a573134a6055
- author
- Dami <dami.etoile@gmail.com>
@@ -40,7 +40,11 @@ task example with your own schema, permissions, hook, and UI.
4040 - **Identity from the first launch.** Each user begins with a private on-device account, even when4141 the app has no sync service configured.4242 - **Optional sync and recovery.** Users can make that same account portable when you connect the app43- to managed Jazz sync.43+ to managed Jazz sync, using a recoverable passkey when available and a recovery phrase as the44+ portable fallback.45+- **Narrow collaboration templates.** Private resources, direct sharing, and fixed-role groups sit46+ behind a small `@nzip/lofi/access` API while raw Jazz permissions remain available as an escape47+ hatch.4448 - **A narrow authoring surface.** Product code owns the schema, permissions, configuration, Preact4549 islands, styles, and tests. lofi owns storage, identity, sync, lifecycle, and PWA plumbing.4650 - **Local-first test helpers.** Playwright-backed fixtures cover offline writes, multiple clients,@@ -66,8 +70,10 @@ Provisioning creates a managed Jazz app and writes the public `JAZZ_APP_ID` and
6670 configuration to the project's `.env`. Server-only credentials remain outside client output.6771 6872 Once sync is configured, a user can choose to back up and sync their existing account, then recover69-it on another device with a 24-word recovery phrase. Enabling sync preserves data created while the70-app was local-only because the account identity does not change.73+it with a recoverable passkey or a 24-word recovery phrase. Passkey portability depends on the74+browser, provider, and configured RP-ID; the phrase remains the provider-independent fallback.75+Enabling sync preserves data created while the app was local-only because the account identity does76+not change.7177 7278 ## Where lofi fits7379 @@ -87,19 +93,23 @@ Keep these current constraints in mind:
8793 8894 A generated project keeps application source separate from the versioned framework package:8995 90-```mermaid91-flowchart TB92- Root["my-app/"] --> Config["deno.json"]93- Root --> Public["public/<br/>manifest · icons"]94- Root --> Src["src/"]95- Root --> Tests["tests/<br/>your tests + lofi testing contract"]96- Root --> Framework["@nzip/lofi<br/>versioned framework package"]97-98- Src --> Model["schema.ts + permissions.ts<br/>your model and access policy"]99- Src --> App["app.ts<br/>your application config"]100- Src --> UI["pages + islands + styles<br/>your product experience"]101- Src --> Layout["layouts/<br/>your document shell"]102- Framework --> Runtime["storage · identity · sync<br/>PWA · lifecycle · diagnostics"]96+```text97+my-app/98+├── deno.json # tasks and one pinned @nzip/lofi version99+├── public/ # manifest and replaceable product icons100+├── src/101+│ ├── app.ts # app, storage, sync, and passkey configuration102+│ ├── schema.ts # persisted data model103+│ ├── permissions.ts # private, shared, or group access policy104+│ ├── islands/ # interactive Preact product UI105+│ ├── layouts/ # document shell106+│ ├── pages/ # Astro routes107+│ └── styles/ # product styling108+├── tests/ # app tests and local-first browser journeys109+├── .lofi/ # generated package tooling (ignored)110+└── dist/ # generated production PWA (ignored)111+112+@nzip/lofi # versioned storage, identity, sync, PWA, and tooling103113 ```104114 105115 Product work stays in `schema.ts`, `permissions.ts`, `app.ts`, `pages/`, `layouts/`, `islands/`, and@@ -109,6 +119,18 @@ code without copying it into your source tree.
109119 See the [exact generated-project map](docs/reference/project-layout.md) for every source-controlled110120 path, its ownership category, and the separately regenerated `.lofi/` and `dist/` trees.111121 122+## Sharing and groups123+124+`@nzip/lofi/access` provides schema helpers, policy templates, app-scoped sharing identities, and125+operations for direct shares and fixed-role groups. Collaboration requires configured sync; the126+helpers fail explicitly in local-only mode rather than implying that another device can see local127+data.128+129+Start with the [account and access API](docs/reference/access.md), then copy the130+[direct-share](docs/examples/shared.md) or [group](docs/examples/group.md) example. The templates131+compile ordinary Jazz policies, so unusual authorization models can still use raw Jazz schema and132+permission APIs.133+112134 ## Testing local-first behavior113135 114136 `@nzip/lofi/testing` provides Playwright-backed helpers for behavior that is difficult to test with@@ -155,8 +177,9 @@ validated before it becomes part of the generated project.
155177 ## Identity and recovery model156178 157179 A new app opens immediately on a private, on-device account. When managed sync is configured, the158-user can back up and sync that same account and recover it elsewhere with the recovery phrase. The159-phrase is the portable backup; keeping it safe is the user's responsibility.180+user can back up and sync that same account and recover it with a recoverable passkey or the181+recovery phrase. The phrase remains the portable bearer-secret fallback; keeping it safe is the182+user's responsibility.160183 161184 See [Sync and recovery](docs/sync-and-recovery.md) for setup and shipping guidance, and162185 [Local-first accounts: open now, back up later](docs/auth-identity.md) for the detailed account@@ -16,7 +16,8 @@ Framework implementation stays package-owned while the application retains its p
1616 ## Package-owned framework1717 1818 - `@nzip/lofi` — storage, identity, synchronization, lifecycle, diagnostics, and framework adapter19-- `@nzip/lofi/preact` — optional framework UI such as `DeviceStatus`19+- `@nzip/lofi/access` — private, direct-share, and fixed-role group templates and operations20+- `@nzip/lofi/preact` — optional framework UI such as `DeviceStatus` and `PwaActions`2021 - `.lofi/` — ignored Astro/Jazz integration materialized by framework commands2122 - `dist/sw.js` — service worker generated by `deno task build`2223 @@ -41,5 +42,7 @@ Chromium); run it against a synced deployment with
4142 remotely, and no `src/app.ts` misdetection). These are generic, org/app-free scaffolding; releasing4243 the `@nzip/lofi` package itself is a separate JSR publish (repo root), not a site deploy.4344 44-Passkey backup and recovery are intentionally absent. The pinned Jazz alpha exposes a rejected45-credential design, so this reference uses a device-local identity and does not imply recoverability.45+When sync is configured, the author-owned `AccountGate` demonstrates opt-in backup, recoverable46+passkeys, recovery-phrase fallback, explicit account replacement, and app-scoped sharing identities47+using only public package APIs. Passkey availability remains browser-, provider-, origin-, and48+RP-ID-dependent; the UI does not imply universal portability.@@ -2,18 +2,18 @@
22 "imports": {33 "@astrojs/check": "npm:@astrojs/check@0.9.9",44 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",5- "@nzip/lofi": "jsr:@nzip/lofi@0.2.0",6- "@nzip/lofi/": "jsr:@nzip/lofi@0.2.0/",7- "@nzip/lofi/access": "jsr:@nzip/lofi@0.2.0/access",8- "@nzip/lofi/astro": "jsr:@nzip/lofi@0.2.0/astro",9- "@nzip/lofi/build": "jsr:@nzip/lofi@0.2.0/build",10- "@nzip/lofi/dev": "jsr:@nzip/lofi@0.2.0/dev",11- "@nzip/lofi/doctor": "jsr:@nzip/lofi@0.2.0/doctor",12- "@nzip/lofi/preview": "jsr:@nzip/lofi@0.2.0/preview",13- "@nzip/lofi/preact": "jsr:@nzip/lofi@0.2.0/preact",14- "@nzip/lofi/provision": "jsr:@nzip/lofi@0.2.0/provision",15- "@nzip/lofi/test": "jsr:@nzip/lofi@0.2.0/test",16- "@nzip/lofi/testing": "jsr:@nzip/lofi@0.2.0/testing",5+ "@nzip/lofi": "jsr:@nzip/lofi@0.3.0",6+ "@nzip/lofi/": "jsr:@nzip/lofi@0.3.0/",7+ "@nzip/lofi/access": "jsr:@nzip/lofi@0.3.0/access",8+ "@nzip/lofi/astro": "jsr:@nzip/lofi@0.3.0/astro",9+ "@nzip/lofi/build": "jsr:@nzip/lofi@0.3.0/build",10+ "@nzip/lofi/dev": "jsr:@nzip/lofi@0.3.0/dev",11+ "@nzip/lofi/doctor": "jsr:@nzip/lofi@0.3.0/doctor",12+ "@nzip/lofi/preview": "jsr:@nzip/lofi@0.3.0/preview",13+ "@nzip/lofi/preact": "jsr:@nzip/lofi@0.3.0/preact",14+ "@nzip/lofi/provision": "jsr:@nzip/lofi@0.3.0/provision",15+ "@nzip/lofi/test": "jsr:@nzip/lofi@0.3.0/test",16+ "@nzip/lofi/testing": "jsr:@nzip/lofi@0.3.0/testing",1717 "astro": "npm:astro@7.0.9",1818 "astro/config": "npm:astro@7.0.9/config",1919 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",@@ -3102,7 +3102,7 @@
31023102 },31033103 "workspace": {31043104 "dependencies": [3105- "jsr:@nzip/lofi@0.2.0",3105+ "jsr:@nzip/lofi@0.3.0",31063106 "npm:@astrojs/check@0.9.9",31073107 "npm:@astrojs/preact@6.0.1",31083108 "npm:astro@7.0.9",@@ -3113,7 +3113,7 @@
31133113 "npm:vite@8.0.1"31143114 ],31153115 "links": {3116- "jsr:@nzip/lofi@0.2.0": {3116+ "jsr:@nzip/lofi@0.3.0": {31173117 "dependencies": [31183118 "npm:@astrojs/check@0.9.9",31193119 "npm:@astrojs/preact@6.0.1",@@ -1,8 +1,8 @@
11 {22 "name": "@nzip/lofi",3- "version": "0.2.0",3+ "version": "0.3.0",44 "license": "MIT",5- "description": "A local-first mobile PWA meta-framework for Deno: declare a schema, build Preact islands, and ship an installable offline-capable app whose UI always hydrates from local data.",5+ "description": "A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, and private, direct-share, or group access templates.",66 "exports": {77 ".": "./package/runtime/mod.ts",88 "./astro": "./package/astro/mod.ts",@@ -44,7 +44,9 @@
4444 },4545 "tasks": {4646 "build": "deno task --cwd apps/reference build",47- "check": "deno fmt --check . && deno lint && deno task test:unit && deno task test:access",47+ "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access",48+ "doc": "deno run --allow-run tools/doc_lint.ts",49+ "docs:links": "deno run --allow-read tools/docs_links.ts",4850 "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",4951 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",5052 "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",@@ -69,6 +71,7 @@
6971 "LICENSE",7072 "README.md",7173 "docs/README.md",74+ "docs/assets/brand/lofi-github-banner.png",7275 "docs/auth-identity.md",7376 "docs/auth.md",7477 "docs/decisions/passkey-backup-alpha53.md",@@ -53,7 +53,7 @@ flowchart TB
5353 Schema["schema.ts"]5454 Permissions["permissions.ts"]5555 App["app.ts"]56- UI["pages, islands, styles"]56+ UI["pages, layouts, islands, styles"]5757 Public["public assets"]5858 Tests["tests"]5959 end@@ -71,8 +71,8 @@ flowchart TB
7171 Runtime --> Lifecycle7272 ```7373 74-- Change `src/schema.ts`, `src/permissions.ts`, `src/app.ts`, `src/pages/`, `src/islands/`, and75- `src/styles/`.74+- Change `src/schema.ts`, `src/permissions.ts`, `src/app.ts`, `src/pages/`, `src/layouts/`,75+ `src/islands/`, and `src/styles/`.7676 - Import documented runtime seams from `@nzip/lofi`; no framework implementation is copied into7777 `src/`.7878 - Change files under `public/` when customizing the icon or manifest. The package build generates@@ -12,7 +12,7 @@ lofi device auth is a small, honest **primitive**, not a mandated identity or re
1212 Local-first identity is device-local and cryptographic — there is no central store to authenticate1313 against — so this module does exactly three things: enroll a device passkey, authenticate with it,1414 and derive a credential-bound key to encrypt data **at rest**. The generated session runtime handles15-accounts, recovery phrases, and optional multi-device sync separately.15+accounts, recoverable-passkey backup, recovery phrases, and optional multi-device sync separately.1616 1717 The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lofi`.1818 @@ -29,10 +29,11 @@ The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lof
2929 HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or3030 authenticator does not return a PRF result, the derive path throws `prf-unavailable` rather than3131 inventing a key.32-- **No cross-device recovery claim.** A lost device is lost access — the same custody the33- device-local identity already has. This is intentional and consistent with the earlier rejection34- of the Jazz alpha's passkey-backup ceremony: lofi does not smuggle a recovery/escrow model in35- through the auth primitive.32+- **No recovery claim for this primitive.** `enrollDeviceCredential` and the PRF-derived key do not33+ contain the Jazz account secret, so they cannot restore an account. Account recovery uses the34+ separate `createRecoverablePasskeyBackup` / `restoreFromPasskey` flow documented in35+ [Sync and recovery](sync-and-recovery.md), with an RP-ID- and provider-independent phrase36+ fallback.3637 3738 ## Usage3839 @@ -104,28 +104,28 @@ required global runtime for a generated application.
104104 105105 ## Measurable promises106106 107-| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |108-| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------- | --------- |109-| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 source and release smoke | no | validated |110-| DX-CMD-01 | The public command surface is small. | create, dev, doctor, test, build, preview, plus explicit sync/schema/deploy tasks. | #2, #6, and M2 | yes | revised |111-| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 source and release smoke | no | validated |112-| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |113-| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |114-| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |115-| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |116-| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |117-| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |118-| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |119-| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |120-| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |121-| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |122-| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |123-| DX-AUTH-01 | Identity wording matches custody/recovery. | UI identifies the device-local key, blocks the rejected alpha passkey path, and defines any future phrase as an identity bearer secret rather than data backup. | #8 | yes | revised |124-| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | M1 proves a stable HTTPS PWA scope for OPFS; installed-app RP-ID preservation and identity isolation remain required before any replacement passkey ceremony. | #4 and #8 | yes | revised |125-| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | proposed |126-| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Airplane-mode launch renders shell and data. | feasibility #4; full M3 | no | proposed |127-| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |128-| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | #7 and M2 Layer 3 | no | validated |107+| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |108+| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------- | --------- |109+| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 source and release smoke | no | validated |110+| DX-CMD-01 | The public command surface is small. | create, dev, doctor, test, build, preview, plus explicit sync/schema/deploy tasks. | #2, #6, and M2 | yes | revised |111+| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 source and release smoke | no | validated |112+| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |113+| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |114+| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |115+| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |116+| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |117+| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |118+| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |119+| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |120+| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |121+| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |122+| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |123+| DX-AUTH-01 | Identity wording matches custody/recovery. | UI distinguishes the local account, recoverable passkey, local phrase guard, and phrase bearer secret; restore confirms replacement and states provider/RP-ID limits. | #8 history and generated golden | yes | validated |124+| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | RP-ID follows the exact stable hostname; credential retrieval succeeds there and a sibling RP is rejected without storing secret material. | #4, #8, M3, golden gate | yes | validated |125+| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | validated |126+| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Installed PWA retains a local write across reload and airplane-mode cold start. | feasibility #4 and M3 | no | validated |127+| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |128+| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | #7 and M2 Layer 3 | no | validated |129129 130130 ## Canonical command surface and output contract131131 @@ -292,9 +292,11 @@ Jazz version, Preact strategy, Astro/Deno path, durability policy, and identity
292292 runtime alternatives are removed from the final tree; their evidence remains in decisions and293293 merge-commit history.294294 295-M2 has graduated the single-package command surface, generator, development inspector, and296-readiness-first browser-testing helpers. Production PWA/device hardening remains M3 work; remaining297-runtime package seams and a second consumer remain M4 work.295+M2 graduated the single-package command surface, generator, development inspector, and296+readiness-first browser-testing helpers. M3 graduated representative mobile PWA validation, and the297+current generated-app release gate covers install/update output, package-owned runtime seams,298+recoverable account replacement, and collaboration policy checks. A second independent consumer299+remains later adoption work.298300 299301 ## M2 layered graduation300302 @@ -64,6 +64,7 @@ The final package map is intentionally small:
6464 | Import | Supported use |6565 | ----------------------------- | --------------------------------------------------- |6666 | `@nzip/lofi` | App configuration, runtime, session, stores, PWA |67+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |6768 | `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |6869 | `@nzip/lofi/astro` | Package-owned Astro/Vite integration |6970 | `@nzip/lofi/testing` | Local-first Playwright helpers |@@ -1,3 +1,4 @@
1+/** Stable categories for access configuration and collaboration failures. */12 export type AccessErrorCode =23 | "configuration"34 | "invalid-identity"@@ -6,13 +7,17 @@ export type AccessErrorCode =
67 | "not-found"78 | "invalid-role";89 10+/** Actionable failure raised by access templates and collaboration operations. */911 export class AccessError extends Error {12+ /** Stable error class name for diagnostics and error boundaries. */1013 override readonly name = "AccessError";14+ /** Creates an access error without including row contents or secret values. */1115 constructor(readonly code: AccessErrorCode, message: string, options?: ErrorOptions) {1216 super(message, options);1317 }1418 }1519 20+/** True when an error came from the lofi access surface. */1621 export function isAccessError(error: unknown): error is AccessError {1722 return error instanceof AccessError;1823 }@@ -2,11 +2,12 @@ import { appId } from "../runtime/config.ts";
22 import { getRuntime } from "../runtime/runtime.ts";33 import { AccessError } from "./errors.ts";44 5-declare const sharingIdentityBrand: unique symbol;6-export type SharingIdentity = string & { readonly [sharingIdentityBrand]: true };5+/** App-scoped, non-secret Jazz principal identifier safe to copy between users. */6+export type SharingIdentity = string & { readonly __lofiSharingIdentity: true };77 88 const prefix = "lofi1";99 10+/** Encodes a raw Jazz principal as a versioned identity scoped to the current app. */1011 export function encodeSharingIdentity(userId: string): SharingIdentity {1112 if (!userId.trim() || userId.includes(":")) {1213 throw new AccessError("invalid-identity", "Jazz returned an invalid sharing identity.");@@ -14,6 +15,7 @@ export function encodeSharingIdentity(userId: string): SharingIdentity {
1415 return `${prefix}:${appId}:${userId}` as SharingIdentity;1516 }1617 18+/** Validates an app-scoped sharing identity and returns its raw Jazz principal. */1719 export function decodeSharingIdentity(identity: string): string {1820 const [version, identityAppId, userId, extra] = identity.split(":");1921 if (version !== prefix || identityAppId !== appId || !userId || extra !== undefined) {@@ -1,4 +1,15 @@
1-/** Narrow private/direct-share/group access templates over raw Jazz schemas. */1+/**2+ * Narrow private, direct-share, and fixed-role group access templates over raw3+ * Jazz schemas.4+ *5+ * Use {@link privateAccess}, {@link sharedAccess}, or {@link groupAccess} to6+ * declare common authorization models. Runtime operations require managed sync7+ * for collaboration and throw {@link AccessError} when that precondition is not8+ * met. Raw Jazz policy callbacks remain available through9+ * {@link defineAccessPolicies}.10+ *11+ * @module12+ */213 export { AccessError, type AccessErrorCode, isAccessError } from "./errors.ts";314 export {415 decodeSharingIdentity,@@ -7,24 +18,30 @@ export {
718 sharingIdentity,819 } from "./identity.ts";920 export {21+ type AccessRuntimeTable,1022 createGroupOperations,1123 createSharingOperations,1224 type GroupMembershipRow,1325 type GroupOperations,26+ type Identified,1427 type SharedGrantRow,1528 type ShareLevel,1629 type SharingOperations,1730 } from "./operations.ts";1831 export {32+ type AccessTable,1933 type AccessTemplate,2034 defineAccessPolicies,2135 groupAccess,2236 type GroupAccessTemplate,2337 privateAccess,2438 type PrivateAccessTemplate,39+ type RawAccessPolicyContext,2540 type RawAccessPolicyExtension,41+ type RuleBuilder,2642 sharedAccess,2743 type SharedAccessTemplate,44+ type TablePolicy,2845 } from "./policies.ts";2946 export {3047 groupMembershipTable,@@ -5,15 +5,19 @@ import { AccessError } from "./errors.ts";
55 import { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";66 import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";77 8-type Identified = { id: string };9-type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {8+/** Minimum row shape accepted by collaboration operations. */9+export type Identified = { id: string };10+/** Jazz table shape required by the access operation helpers. */11+export type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {1012 where(input: unknown): QueryBuilder<Row>;1113 };14+/** Conventional direct-share grant row. */1215 export type SharedGrantRow = Identified & {1316 resourceId: string;1417 user_id: string;1518 can_edit: boolean;1619 };20+/** Conventional fixed-role group membership row. */1721 export type GroupMembershipRow = Identified & {1822 groupId: string;1923 user_id: string;@@ -56,8 +60,10 @@ function assertRole(role: string): asserts role is GroupRole {
5660 }5761 }5862 63+/** Access level assigned by a direct share. */5964 export type ShareLevel = "read" | "edit";6065 66+/** Direct-share operations bound to one resource and grant table pair. */6167 export type SharingOperations<Resource, Grant> = {6268 share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise<Grant>;6369 revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;@@ -65,6 +71,7 @@ export type SharingOperations<Resource, Grant> = {
6571 sharedWithMe(): Promise<Resource[]>;6672 };6773 74+/** Creates direct-share operations that wait for local and global durability. */6875 export function createSharingOperations<6976 Resource extends Identified,7077 ResourceInit,@@ -135,6 +142,7 @@ export function createSharingOperations<
135142 };136143 }137144 145+/** Creates fixed-role group membership operations for a declared table pair. */138146 export function createGroupOperations<139147 Group extends Identified,140148 GroupInit,@@ -242,6 +250,7 @@ export function createGroupOperations<
242250 };243251 }244252 253+/** Fixed-role group creation and membership operations. */245254 export type GroupOperations<Group, GroupInit, Member> = {246255 createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;247256 addMember(@@ -2,17 +2,21 @@ import { schema as s } from "jazz-tools";
22 import type { CompiledPermissions } from "jazz-tools";33 import { AccessError } from "./errors.ts";44 5-type AccessTable = {5+/** Minimum declared Jazz table metadata consumed by access templates. */6+export type AccessTable = {67 readonly _table: string;78 readonly _schema: Record<string, { columns?: unknown[] }>;89 };910 11+/** Owner-only resource policy template. */1012 export type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable };13+/** Direct-share resource and grant-table policy template. */1114 export type SharedAccessTemplate = {1215 readonly kind: "shared";1316 readonly resource: AccessTable;1417 readonly grants: AccessTable;1518 };19+/** Fixed-role group, membership, and resource policy template. */1620 export type GroupAccessTemplate = {1721 readonly kind: "group";1822 readonly groups: AccessTable;@@ -20,12 +24,15 @@ export type GroupAccessTemplate = {
2024 readonly resources: readonly AccessTable[];2125 readonly groupId: string;2226 };27+/** Any built-in access policy template accepted by {@link defineAccessPolicies}. */2328 export type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate;2429 30+/** Declares owner-only read and mutation policy for one resource table. */2531 export function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate {2632 return { kind: "private", ...config };2733 }2834 35+/** Declares owner plus explicit read/edit grants for one resource table. */2936 export function sharedAccess(config: {3037 resource: AccessTable;3138 grants: AccessTable;@@ -33,6 +40,7 @@ export function sharedAccess(config: {
3340 return { kind: "shared", ...config };3441 }3542 43+/** Declares fixed-role membership policy for one or more group-owned resources. */3644 export function groupAccess(config: {3745 groups: AccessTable;3846 members: AccessTable;@@ -95,18 +103,21 @@ function validateTemplate(template: AccessTemplate): void {
95103 }96104 }97105 98-type RuleBuilder = {106+/** Minimal Jazz rule builder exposed to raw access-policy extensions. */107+export type RuleBuilder = {99108 where(input: unknown): unknown;100109 always(): unknown;101110 };102-type TablePolicy = {111+/** Read and mutation policy builders for one declared table. */112+export type TablePolicy = {103113 allowRead: RuleBuilder;104114 allowInsert: RuleBuilder;105115 allowUpdate: RuleBuilder;106116 allowDelete: RuleBuilder;107117 exists: { where(input: unknown): unknown };108118 };109-type RawAccessPolicyContext = {119+/** Raw Jazz policy-builder context exposed to advanced policy extensions. */120+export type RawAccessPolicyContext = {110121 policy: Record<string, TablePolicy>;111122 session: { user_id: unknown };112123 anyOf(conditions: readonly unknown[]): unknown;@@ -116,6 +127,7 @@ type RawAccessPolicyContext = {
116127 };117128 };118129 130+/** Callback for app-specific rules that do not fit the built-in templates. */119131 export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;120132 121133 /**@@ -27,8 +27,10 @@ export function sharedGrantTable<const Resource extends string>(
2727 2828 /** Fixed Wave 2 group roles. Custom role systems remain a raw Jazz escape hatch. */2929 export const groupRoles = ["reader", "contributor", "writer", "admin"] as const;30+/** Fixed group roles supported by the built-in group policy template. */3031 export type GroupRole = (typeof groupRoles)[number];3132 33+/** Returns the persisted capability flags for a fixed group role. */3234 export function groupRoleCapabilities(role: GroupRole): {3335 readonly role: GroupRole;3436 readonly can_create: boolean;@@ -1,5 +1,15 @@
1+/**2+ * Astro and Vite integration used by generated lofi applications.3+ *4+ * {@link prepareLofiAstroConfig} materializes version-matched runtime aliases5+ * and Jazz configuration into the ignored `.lofi/` directory.6+ *7+ * @module8+ */9+110 import { join, resolve } from "node:path";211 12+/** Options for materializing the package-owned Astro configuration. */313 export type LofiAstroOptions = {414 /** Project root. Defaults to the directory from which Astro was invoked. */515 root?: string;@@ -66,6 +66,14 @@ account they already have carries over — electing to sync never changes its id
6666 is scoped to the app's stable relying-party ID; provider syncing is not universally portable. The6767 framework implementation is provided by \`@nzip/lofi\`.6868 69+## Sharing and groups70+71+The optional \`@nzip/lofi/access\` entrypoint provides private, direct-share, and fixed-role group72+templates over ordinary Jazz schemas. Collaboration requires configured sync and fails explicitly in73+local-only mode. Start with the74+[permissions guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/permissions.md) and the75+[access API reference](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/access.md).76+6977 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:7078 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.7179 @@ -99,9 +99,9 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
9999 );100100 }101101 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102- assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.2.0");103- assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.2.0/");104- assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.2.0/testing");102+ assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.3.0");103+ assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.3.0/");104+ assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.3.0/testing");105105 const lofiSpecifiers = Object.entries(config.imports)106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));@@ -110,7 +110,7 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
110110 `expected one package prefix, access, two integrations, six commands, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113- lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.2.0/")),113+ lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.0/")),114114 "generated lofi commands do not resolve through one exact package version",115115 );116116 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];@@ -1,4 +1,4 @@
1-import type { JSX } from "preact";1+import type { JSX, VNode } from "preact";22 import { useEffect, useState } from "preact/hooks";33 // Package-owned optional diagnostics UI.44 import { serverUrl } from "../runtime/config.ts";@@ -28,7 +28,8 @@ function Row({ label, value }: { label: string; value: string }): JSX.Element {
2828 2929 const available = (present: boolean) => (present ? "available" : "missing");3030 31-export default function DeviceStatus(): JSX.Element {31+/** Renders live storage, sync, auth, and PWA capability diagnostics. */32+export default function DeviceStatus(): VNode {3233 const { report, requestPersistence } = useDeviceCapabilities();3334 const [pwa, setPwa] = useState<PwaState>(getPwaState());3435 const [session, setSession] = useState<Session | null>(null);@@ -1,4 +1,4 @@
1-import type { JSX } from "preact";1+import type { VNode } from "preact";22 import { useEffect, useState } from "preact/hooks";33 import {44 type PwaController,@@ -8,15 +8,20 @@ import {
88 } from "../runtime/pwa.ts";99 1010 export { pwaFailureMessage } from "../runtime/pwa.ts";11+export type { PwaController, PwaFailureCode, PwaState } from "../runtime/pwa.ts";1112 13+/** Subscribes a Preact component to an isolated or shared PWA controller. */1214 export function usePwaState(controller: PwaController = pwaController): PwaState {1315 const [state, setState] = useState<PwaState>(controller.getState());1416 useEffect(() => controller.subscribe(setState), [controller]);1517 return state;1618 }1719 20+/** Optional controller and heading text for {@link PwaActions}. */1821 export interface PwaActionsProps {22+ /** Controller to observe; defaults to the package-wide PWA controller. */1923 readonly controller?: PwaController;24+ /** Heading rendered above install or update actions. */2025 readonly title?: string;2126 }2227 @@ -24,7 +29,7 @@ export interface PwaActionsProps {
2429 export function PwaActions({2530 controller = pwaController,2631 title = "Install & updates",27-}: PwaActionsProps): JSX.Element | null {32+}: PwaActionsProps): VNode | null {2833 const state = usePwaState(controller);2934 const visible = state.install === "available" || state.install === "manual-ios" ||3035 state.install === "accepted" || state.install === "dismissed" ||@@ -1,4 +1,23 @@
1-/** Optional Preact bindings and default framework diagnostics UI. */1+/**2+ * Optional Preact bindings for lofi device capabilities and PWA controls.3+ *4+ * These components are package-owned examples that application layouts may5+ * compose or replace with UI built on the same public runtime APIs.6+ *7+ * @module8+ */29 export { default as DeviceStatus } from "./DeviceStatus.tsx";3-export { PwaActions, type PwaActionsProps, pwaFailureMessage, usePwaState } from "./PwaActions.tsx";4-export { useDeviceCapabilities } from "./use-device-capabilities.ts";10+export {11+ PwaActions,12+ type PwaActionsProps,13+ type PwaController,14+ type PwaFailureCode,15+ pwaFailureMessage,16+ type PwaState,17+ usePwaState,18+} from "./PwaActions.tsx";19+export {20+ type DeviceCapabilitiesHook,21+ type DeviceCapabilityReport,22+ useDeviceCapabilities,23+} from "./use-device-capabilities.ts";@@ -5,12 +5,15 @@ import {
55 readDeviceCapabilityReport,66 requestPersistentStorage,77 } from "../runtime/device-capabilities.ts";8+export type { DeviceCapabilityReport } from "../runtime/device-capabilities.ts";89 10+/** State returned by {@link useDeviceCapabilities}. */911 export type DeviceCapabilitiesHook = {1012 report: DeviceCapabilityReport | null;1113 requestPersistence(): Promise<void>;1214 };1315 16+/** Reads browser capabilities and exposes an explicit persistence request. */1417 export function useDeviceCapabilities(): DeviceCapabilitiesHook {1518 const [report, setReport] = useState<DeviceCapabilityReport | null>(null);1619 useEffect(() => {@@ -74,7 +74,9 @@ export type EnrollOptions = AuthDependencies & {
7474 7575 /** A precise, non-leaking failure reason for a credential operation. */7676 export class AuthError extends Error {77+ /** Stable error class name for diagnostics and error boundaries. */7778 override readonly name = "AuthError";79+ /** Actionable category that callers can map to user-facing guidance. */7880 readonly code:7981 | "cancelled"8082 | "origin-rejected"@@ -83,6 +85,7 @@ export class AuthError extends Error {
8385 | "credential-missing"8486 | "unknown";8587 88+ /** Creates a device-credential error without including credential material. */8689 constructor(code: AuthError["code"], message?: string) {8790 super(message ?? `Device credential operation failed: ${code}.`);8891 this.code = code;@@ -1,3 +1,4 @@
1+/** Browser capabilities that determine whether lofi can provide its runtime guarantees. */12 export type DeviceCapabilityReport = {23 // Package-owned device capability checks.34 secureContext: boolean;@@ -12,6 +13,9 @@ export type DeviceCapabilityReport = {
1213 displayMode: "standalone" | "browser";1314 };1415 16+/** Capability report that excludes the separately requested persistence permission. */17+export type DurableCapabilityReport = Omit<DeviceCapabilityReport, "persistentPermission">;18+1519 type StorageManagerWithOpfs = StorageManager & {1620 getDirectory?: () => Promise<FileSystemDirectoryHandle>;1721 };@@ -40,10 +44,8 @@ async function readPrfCapability(
4044 }4145 }4246 43-export function durableCapabilityReport(): Omit<44- DeviceCapabilityReport,45- "persistentPermission"46-> {47+/** Reads synchronous browser capabilities needed by the durable Jazz driver. */48+export function durableCapabilityReport(): DurableCapabilityReport {4749 const storage = browserStorage();4850 const locks = typeof navigator === "undefined" ? undefined : navigator.locks;4951 const secureContext = globalThis.isSecureContext === true;@@ -67,6 +69,7 @@ export function durableCapabilityReport(): Omit<
6769 };6870 }6971 72+/** Reads the complete capability report without requesting new browser permission. */7073 export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityReport> {7174 const base = durableCapabilityReport();7275 const storage = browserStorage();@@ -85,6 +88,7 @@ export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityRepo
8588 }8689 }8790 91+/** Requests eviction protection, then returns the browser's authoritative capability report. */8892 export async function requestPersistentStorage(): Promise<DeviceCapabilityReport> {8993 const storage = browserStorage();9094 if (typeof storage?.persist !== "function") return await readDeviceCapabilityReport();@@ -96,11 +100,15 @@ export async function requestPersistentStorage(): Promise<DeviceCapabilityReport
96100 return await readDeviceCapabilityReport();97101 }98102 103+/** Raised when the browser cannot provide the durable local-storage contract. */99104 export class DurableStorageUnsupportedError extends Error {105+ /** Stable error class name for diagnostics and error boundaries. */100106 override name = "DurableStorageUnsupportedError";101- readonly report: ReturnType<typeof durableCapabilityReport>;107+ /** Capabilities observed when the runtime rejected startup. */108+ readonly report: DurableCapabilityReport;102109 103- constructor(report: ReturnType<typeof durableCapabilityReport> = durableCapabilityReport()) {110+ /** Creates an error that identifies every missing durable-driver capability. */111+ constructor(report: DurableCapabilityReport = durableCapabilityReport()) {104112 const missing = Object.entries({105113 secureContext: report.secureContext,106114 opfs: report.opfs,@@ -117,6 +125,7 @@ export class DurableStorageUnsupportedError extends Error {
117125 }118126 }119127 128+/** Throws unless the current browser can open lofi's persistent local driver. */120129 export function assertDurableBrowser(): void {121130 const report = durableCapabilityReport();122131 if (!report.durableDriverSupported) throw new DurableStorageUnsupportedError(report);@@ -1,15 +1,21 @@
11 /**2- * The supported `@nzip/lofi` browser/runtime surface.2+ * Build local-first browser applications with durable storage, reactive tables,3+ * optional Jazz sync, recoverable accounts, and an installable PWA lifecycle.34 *45 * Application code composes these APIs; storage, identity, sync, PWA, and56 * lifecycle implementations remain versioned package code.67 *8+ * Start with {@link defineLofiApp} and {@link bootLofi}. Use9+ * {@link LofiRuntime.store} to bind declared Jazz tables to application UI, and10+ * add account backup only after managed sync is configured.11+ *712 * @module813 */914 1015 export { defineLofiApp, type LofiAppConfig } from "./app.ts";1116 export {1217 type AuthCapability,18+ type AuthDependencies,1319 authenticateDeviceCredential,1420 AuthError,1521 classifyCredentialOrigin,@@ -17,14 +23,19 @@ export {
1723 decryptAtRest,1824 deriveAtRestKey,1925 derivePrfSecret,26+ type DeviceCredential,2027 encryptAtRest,2128 enrollDeviceCredential,29+ type EnrollOptions,2230 getAuthCapability,31+ type PrfSupport,2332 } from "./auth.ts";2433 export { bootLofi } from "./boot.ts";2534 export {2635 assertDurableBrowser,2736 type DeviceCapabilityReport,37+ type DurableCapabilityReport,38+ durableCapabilityReport,2839 DurableStorageUnsupportedError,2940 readDeviceCapabilityReport,3041 requestPersistentStorage,@@ -33,9 +44,11 @@ export {
3344 applyPwaUpdate,3445 createPwaController,3546 getPwaState,47+ type InstallEnvironment,3648 type InstallPromptEvent,3749 type PwaController,3850 pwaController,51+ type PwaControllerDependencies,3952 type PwaFailureCode,4053 pwaFailureMessage,4154 type PwaInstallState,@@ -44,6 +57,7 @@ export {
4457 requestPwaInstall,4558 subscribePwaState,4659 } from "./pwa.ts";60+export { type RuntimeDiagnostics } from "./diagnostics.ts";4761 export {4862 getRuntime,4963 getRuntimeDiagnostics,@@ -56,6 +70,7 @@ export {
5670 subscribeRuntimeDiagnostics,5771 } from "./runtime.ts";5872 export {73+ AccountReplacementError,5974 type AccountReplacementOptions,6075 confirmPhraseAccess,6176 createBackupPasskey,@@ -74,11 +89,13 @@ export {
7489 type Session,7590 stopSyncBackup,7691 } from "./session.ts";92+export { RecoveryError } from "./recovery.ts";7793 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";7894 export {7995 type TableHandle,8096 type TableRow,8197 type TableSnapshot,8298 type TableStore,99+ type TableStoreOptions,83100 } from "./table-store.ts";84101 export { settleUiMutation } from "./ui-mutation.ts";@@ -9,6 +9,7 @@ type PasskeyBackupErrorCode =
99 | "invalid-credential"1010 | "verification-failed";1111 12+/** Stable categories for recoverable passkey backup and restore failures. */1213 export type RecoverablePasskeyErrorCode =1314 | "cancelled"1415 | "unsupported"@@ -37,8 +38,11 @@ const messages: Record<RecoverablePasskeyErrorCode, string> = {
3738 "The passkey could not restore this account. The current account is unchanged; use the recovery phrase or try again.",3839 };3940 41+/** A non-secret, actionable recoverable-passkey failure. */4042 export class RecoverablePasskeyError extends Error {43+ /** Stable error class name for diagnostics and error boundaries. */4144 override readonly name = "RecoverablePasskeyError";45+ /** Creates a mapped passkey error with safe user-facing guidance. */4246 constructor(readonly code: RecoverablePasskeyErrorCode, options?: ErrorOptions) {4347 super(messages[code], options);4448 }@@ -1,5 +1,6 @@
11 /// <reference path="./env.d.ts" />22 // Package-owned PWA lifecycle.3+/** Service-worker lifecycle states exposed to application UI. */34 export type PwaWorkerState =45 | "development-disabled"56 | "unsupported"@@ -8,6 +9,7 @@ export type PwaWorkerState =
89 | "update-available"910 | "failed";1011 12+/** Browser installation states exposed to application UI. */1113 export type PwaInstallState =1214 | "installed"1315 | "available"@@ -17,8 +19,10 @@ export type PwaInstallState =
1719 | "manual-ios"1820 | "unavailable";1921 22+/** Stable categories for recoverable offline/PWA failures. */2023 export type PwaFailureCode = "registration" | "installation" | "precache" | "runtime-cache";2124 25+/** Current install, service-worker, and offline-cache state. */2226 export type PwaState = {2327 worker: PwaWorkerState;2428 install: PwaInstallState;@@ -28,11 +32,13 @@ export type PwaState = {
2832 };2933 };3034 35+/** Chromium install event retained until application UI requests the prompt. */3136 export type InstallPromptEvent = Event & {3237 prompt(): Promise<void>;3338 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;3439 };3540 41+/** Browser signals used to classify installed and manual-install experiences. */3642 export type InstallEnvironment = {3743 displayModeStandalone: boolean;3844 navigatorStandalone: boolean;@@ -53,11 +59,13 @@ const actionableFailureMessages: Record<PwaFailureCode, string> = {
5359 "A recently opened file was not saved for offline use. Reconnect and open it again before going offline.",5460 };5561 62+/** Returns actionable, non-technical recovery guidance for a PWA failure. */5663 export function pwaFailureMessage(code: PwaFailureCode): string {5764 return actionableFailureMessages[code];5865 }5966 60-export interface PwaControllerDependencies {67+/** Injectable browser surfaces used to test the PWA lifecycle deterministically. */68+export type PwaControllerDependencies = {6169 readonly eventTarget?: () => EventTarget;6270 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;6371 readonly installEnvironment?: () => InstallEnvironment;@@ -65,15 +73,16 @@ export interface PwaControllerDependencies {
6573 readonly documentBaseURI?: () => string;6674 readonly reload?: () => void;6775 readonly exposeState?: (state: PwaState) => void;68-}76+};6977 70-export interface PwaController {78+/** Stateful controller for browser installation and service-worker updates. */79+export type PwaController = {7180 getState(): PwaState;7281 subscribe(subscriber: (state: PwaState) => void): () => void;7382 requestInstall(): Promise<PwaInstallState>;7483 applyUpdate(): boolean;7584 initialize(): void;76-}85+};7786 7887 export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {7988 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";@@ -101,6 +110,7 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
101110 });102111 }103112 113+/** Creates an isolated PWA controller, primarily for custom integration and tests. */104114 export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {105115 const subscribers = new Set<(state: PwaState) => void>();106116 let deferredInstallPrompt: InstallPromptEvent | undefined;@@ -247,24 +257,29 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
247257 };248258 }249259 260+/** Shared controller used by the root runtime and optional Preact bindings. */250261 export const pwaController: PwaController = createPwaController({251262 exposeState(next) {252263 if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;253264 },254265 });255266 267+/** Returns the shared controller's current state snapshot. */256268 export function getPwaState(): PwaState {257269 return pwaController.getState();258270 }259271 272+/** Subscribes to shared PWA state and returns an idempotent unsubscribe function. */260273 export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {261274 return pwaController.subscribe(subscriber);262275 }263276 277+/** Requests the deferred browser installation prompt when one is available. */264278 export function requestPwaInstall(): Promise<PwaInstallState> {265279 return pwaController.requestInstall();266280 }267281 282+/** Activates a waiting service worker; returns false when no update is ready. */268283 export function applyPwaUpdate(): boolean {269284 return pwaController.applyUpdate();270285 }@@ -24,8 +24,11 @@ export const RECOVERY_PHRASE_WORDS = 24;
2424 2525 /** A precise, non-leaking failure reason for a recovery-phrase operation. */2626 export class RecoveryError extends Error {27+ /** Stable error class name for diagnostics and error boundaries. */2728 override readonly name = "RecoveryError";29+ /** Actionable category that callers can map to recovery guidance. */2830 readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";31+ /** Creates a phrase error without retaining the submitted phrase. */2932 constructor(code: RecoveryError["code"], message?: string) {3033 super(message ?? `Recovery phrase operation failed: ${code}.`);3134 this.code = code;@@ -21,6 +21,7 @@ import {
2121 shutdownResource,2222 } from "./resource-lifecycle.ts";2323 24+/** The shared, lazily opened Jazz client and its application-facing adapters. */2425 export type LofiRuntime = {2526 db: Db;2627 diagnostics: RuntimeDiagnostics;@@ -32,6 +33,7 @@ export type LofiRuntime = {
3233 shutdown(): Promise<void>;3334 };3435 36+/** Event dispatched after account, sync, or runtime replacement changes active state. */3537 export const runtimeRecreatedEvent = "lofi:runtime-recreated";3638 3739 type RuntimeSlot = {@@ -199,6 +201,7 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
199201 return runtime;200202 }201203 204+/** Opens or reuses the one package runtime for the current browser document. */202205 export function getRuntime(): Promise<LofiRuntime> {203206 if (recreationPromise) return recreationPromise;204207 const state = slot();@@ -208,6 +211,7 @@ export function getRuntime(): Promise<LofiRuntime> {
208211 );209212 }210213 214+/** Returns a value-only snapshot of runtime resource and durability counters. */211215 export function getRuntimeDiagnostics(): RuntimeDiagnostics {212216 return { ...slot().diagnostics };213217 }@@ -222,6 +226,7 @@ export function runtimeCanReconnect(): boolean {
222226 return slot().serverConfigured;223227 }224228 229+/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */225230 export function subscribeRuntimeDiagnostics(listener: () => void): () => void {226231 const listeners = slot().diagnosticListeners;227232 listeners.add(listener);@@ -233,6 +238,7 @@ export function subscribeRuntimeDiagnostics(listener: () => void): () => void {
233238 };234239 }235240 241+/** Replaces the active Jazz client while preserving the configured account secret. */236242 export function recreateRuntime(): Promise<LofiRuntime> {237243 if (recreationPromise) return recreationPromise;238244 const state = slot();@@ -294,6 +300,7 @@ export function replaceRuntimePrincipal(secret: string): Promise<LofiRuntime> {
294300 return tracked;295301 }296302 303+/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */297304 export function shutdownRuntime(): Promise<void> {298305 if (shutdownPromise) return shutdownPromise;299306 const state = slot();@@ -154,6 +154,7 @@ export async function createBackupPasskey(label?: string): Promise<boolean> {
154154 }155155 }156156 157+/** Non-secret confirmation that a recoverable passkey was created for an account and RP-ID. */157158 export type PasskeyBackupReceipt = { user_id: string; rpId: string };158159 159160 /**@@ -240,24 +241,19 @@ export async function stopSyncBackup(): Promise<Session> {
240241 return readSession();241242 }242243 243-/**244- * Recovers an account from its recovery phrase: the phrase reconstructs the245- * exact account secret, which replaces the one on this device. Sync is elected246- * so the recovered account's data syncs back down, and the runtime is recreated247- * to open it. Throws {@link RecoveryError} on a malformed phrase — the account is248- * never replaced with a fabricated secret.249- *250- * This overwrites whatever account this device currently holds, so a caller251- * should confirm the intent before discarding a local-only account's data.252- */244+/** Confirmation required before recovery may replace a different local-only account. */253245 export type AccountReplacementOptions = {254246 /** Explicit acknowledgement that a different local-only account may be discarded. */255247 confirmLocalReplacement?: boolean;256248 };257249 250+/** Raised when account recovery needs explicit acknowledgement of local replacement. */258251 export class AccountReplacementError extends Error {252+ /** Stable error class name for diagnostics and error boundaries. */259253 override readonly name = "AccountReplacementError";254+ /** Stable category for user-facing confirmation flows. */260255 readonly code = "confirmation-required";256+ /** Creates the non-secret replacement warning. */261257 constructor() {262258 super(263259 "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",@@ -284,6 +280,11 @@ async function replaceAccountSecret(
284280 return readSession();285281 }286282 283+/**284+ * Reconstructs an account from its recovery phrase, elects sync, and replaces285+ * the active runtime. Throws {@link RecoveryError} for malformed phrases and286+ * {@link AccountReplacementError} when confirmation is required.287+ */287288 export async function restoreFromRecoveryPhrase(288289 phrase: string,289290 options: AccountReplacementOptions = {},@@ -310,10 +311,12 @@ export function isAuthError(error: unknown): error is AuthError {
310311 return error instanceof AuthError;311312 }312313 314+/** True when an error came from a recoverable passkey backup or restore ceremony. */313315 export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {314316 return error instanceof RecoverablePasskeyError;315317 }316318 319+/** True when recovery requires explicit confirmation before replacing a local account. */317320 export function isAccountReplacementError(error: unknown): error is AccountReplacementError {318321 return error instanceof AccountReplacementError;319322 }@@ -12,6 +12,7 @@ export type TableRow = { id: string };
1212 */1313 export type TableHandle<T extends TableRow, Init> = TableProxy<T, Init> & QueryBuilder<T>;1414 15+/** Reactive table state including rows and the last observed durability tier. */1516 export type TableSnapshot<T extends TableRow> = {1617 status: "loading" | "ready" | "error";1718 rows: T[];@@ -24,6 +25,7 @@ type MutationHandle = {
2425 wait(options: { tier: "local" | "global" }): Promise<unknown>;2526 };2627 28+/** Runtime options controlling durability waits and diagnostics notifications. */2729 export type TableStoreOptions = {2830 /** Whether managed sync is configured; gates global-durability waits. */2931 syncConfigured?: boolean;@@ -53,6 +55,7 @@ export class TableStore<T extends TableRow, Init> {
5355 error: null,5456 };5557 58+ /** Creates a store over one declared Jazz table and shared runtime diagnostics. */5659 constructor(5760 db: Db,5861 table: TableHandle<T, Init>,@@ -72,8 +75,10 @@ export class TableStore<T extends TableRow, Init> {
7275 this.#diagnosticsChanged();7376 }7477 78+ /** Returns the current immutable snapshot. */7579 getSnapshot = (): TableSnapshot<T> => this.#snapshot;7680 81+ /** Subscribes to snapshot changes and opens the vendor subscription on first use. */7782 subscribe = (listener: Listener): () => void => {7883 this.#listeners.add(listener);7984 this.#diagnostics.activeConsumers = this.#listeners.size;@@ -92,24 +97,29 @@ export class TableStore<T extends TableRow, Init> {
9297 };9398 };9499 100+ /** Inserts a row and waits for local durability before resolving. */95101 async insert(values: Init): Promise<void> {96102 await this.#settle(this.#db.insert(this.#table, values));97103 }98104 105+ /** Updates a row and waits for local durability before resolving. */99106 async update(id: string, patch: Partial<Init>): Promise<void> {100107 await this.#settle(this.#db.update(this.#table, id, patch));101108 }102109 110+ /** Deletes a row and waits for local durability before resolving. */103111 async delete(id: string): Promise<void> {104112 await this.#settle(this.#db.delete(this.#table, id));105113 }106114 115+ /** Projects an asynchronous Jazz mutation rejection into diagnostics and store state. */107116 reportMutationError(event: MutationErrorEvent): void {108117 this.#diagnostics.mutationErrors += 1;109118 this.#diagnosticsChanged();110119 this.#fail(new Error(`${event.code}: ${event.reason}`));111120 }112121 122+ /** Releases vendor subscriptions and mutation listeners owned by this store. */113123 close(): void {114124 this.#listeners.clear();115125 this.#diagnostics.activeConsumers = 0;@@ -35,8 +35,8 @@ export const STARTER_FILES: readonly string[] = [
3535 * published package (an `https:` JSR URL fetched over the network).3636 */3737 export async function readStarterFile(relativePath: string): Promise<Uint8Array> {38- const url = import.meta.resolve(`../apps/reference/${relativePath}`);39- if (url.startsWith("file:")) return await Deno.readFile(new URL(url));38+ const url = new URL(`../apps/reference/${relativePath}`, import.meta.url);39+ if (url.protocol === "file:") return await Deno.readFile(url);4040 const response = await fetch(url);4141 if (!response.ok) {4242 throw new Error(`failed to read starter file ${relativePath}: HTTP ${response.status}`);@@ -1,14 +1,14 @@
11 {22 ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "e6f98cb7dfc571284a74960d5604ba115c1b642c588a4023a17eafe9a50d67b6",4+ "deno.json": "b5cc4cdf6520d2596255d9f1333958171f73e3b9619e91719c4aa15a763179e1",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",11- "README.md": "38115d6a07c89acedcdf2801a2d9cb80d3083fde1269583db6db29b15a526982",11+ "README.md": "4fde6743e26c947f74fa028c8ecc991ab7f0cb3ee99b8b8b1da01411665911f6",1212 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1313 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1414 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -31,12 +31,19 @@ export interface VirtualAuthenticatorOptions {
3131 3232 /** Serializable CDP credential used to copy one virtual passkey between browser profiles. */3333 export interface VirtualAuthenticatorCredential {34+ /** Base64url credential identifier assigned by the virtual authenticator. */3435 readonly credentialId: string;36+ /** Whether the credential is discoverable without an allow-list. */3537 readonly isResidentCredential: boolean;38+ /** Relying-party ID that may request this credential. */3639 readonly rpId?: string;40+ /** PKCS#8 private key used only inside the virtual test authenticator. */3741 readonly privateKey: string;42+ /** Optional base64url user handle associated with the credential. */3843 readonly userHandle?: string;44+ /** Signature counter maintained by the virtual authenticator. */3945 readonly signCount: number;46+ /** Optional base64url large-blob payload stored by the virtual authenticator. */4047 readonly largeBlob?: string;4148 }4249 @@ -1,5 +1,5 @@
11 /** The single source version for generated @nzip/lofi references. */2-export const LOFI_VERSION = "0.2.0";2+export const LOFI_VERSION = "0.3.0";33 44 /** The exact JSR package prefix emitted into generated projects. */55 export const LOFI_PACKAGE_PREFIX = `jsr:@nzip/lofi@${LOFI_VERSION}/`;@@ -0,0 +1,50 @@
1+const entrypoints = [2+ "package/runtime/mod.ts",3+ "package/astro/mod.ts",4+ "package/access/mod.ts",5+ "package/commands/build.ts",6+ "package/create.ts",7+ "package/commands/dev.ts",8+ "package/commands/doctor.ts",9+ "package/commands/preview.ts",10+ "package/preact/mod.ts",11+ "package/commands/provision.ts",12+ "package/testing/mod.ts",13+ "package/commands/run_tests.ts",14+] as const;15+16+const command = new Deno.Command(Deno.execPath(), {17+ args: ["doc", "--lint", ...entrypoints],18+ stdout: "piped",19+ stderr: "piped",20+});21+const result = await command.output();22+const decoder = new TextDecoder();23+const diagnostics = `${decoder.decode(result.stdout)}\n${decoder.decode(result.stderr)}`;24+const root = `${Deno.cwd()}/`;25+26+const blocks = diagnostics.split(/(?=^error\[)/m).filter((block) => block.startsWith("error["));27+const failures = blocks.filter((block) => {28+ const code = block.match(/^error\[([^\]]+)\]/)?.[1];29+ const locations = [...block.matchAll(/^\s*-->\s+([^:]+):\d+:\d+$/gm)].map((match) => match[1]);30+ if (code === "missing-jsdoc") return locations[0]?.startsWith(`${root}package/`) ?? true;31+ if (code === "private-type-ref") {32+ // `deno doc` cannot currently redirect npm dependency symbols, so a public33+ // Playwright, Preact, or Jazz type is reported as private even though34+ // `deno publish` resolves and fast-checks it. Keep local-to-local leaks fatal.35+ return locations.slice(1).some((path) => path.startsWith(`${root}package/`));36+ }37+ return true;38+});39+40+if (failures.length > 0) {41+ console.error(failures.join("\n"));42+ console.error(`Documentation lint failed with ${failures.length} local diagnostic(s).`);43+ Deno.exit(1);44+}45+46+const ignored = blocks.length - failures.length;47+console.log(48+ `Documentation lint passed for ${entrypoints.length} JSR entrypoints` +49+ (ignored > 0 ? ` (${ignored} external type-reference diagnostic(s) ignored).` : "."),50+);@@ -0,0 +1,44 @@
1+import { dirname, resolve } from "node:path";2+import { fileURLToPath } from "node:url";3+4+const root = fileURLToPath(new URL("../", import.meta.url));5+const markdownFiles = [6+ resolve(root, "README.md"),7+ resolve(root, "CONTRIBUTING.md"),8+ resolve(root, "apps/reference/README.md"),9+];10+11+async function collectMarkdown(directory: string): Promise<void> {12+ for await (const entry of Deno.readDir(directory)) {13+ const path = resolve(directory, entry.name);14+ if (entry.isDirectory) await collectMarkdown(path);15+ else if (entry.isFile && entry.name.endsWith(".md")) markdownFiles.push(path);16+ }17+}18+19+await collectMarkdown(resolve(root, "docs"));20+21+const broken: string[] = [];22+const linkPattern = /!?\[[^\]]*\]\((<[^>]+>|[^\s)]+)(?:\s+[^)]*)?\)/g;23+for (const markdown of markdownFiles) {24+ const source = await Deno.readTextFile(markdown);25+ for (const match of source.matchAll(linkPattern)) {26+ const raw = match[1].replace(/^<|>$/g, "");27+ if (raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;28+ const target = decodeURIComponent(raw.split("#", 1)[0]);29+ if (!target) continue;30+ try {31+ await Deno.stat(resolve(dirname(markdown), target));32+ } catch (error) {33+ if (!(error instanceof Deno.errors.NotFound)) throw error;34+ broken.push(`${markdown.slice(root.length + 1)} -> ${raw}`);35+ }36+ }37+}38+39+if (broken.length > 0) {40+ console.error(`Broken local Markdown links:\n${broken.map((link) => `- ${link}`).join("\n")}`);41+ Deno.exit(1);42+}43+44+console.log(`Validated local links in ${markdownFiles.length} Markdown files.`);