Release documentation pass, first tranche: - Reattach the `s` JSDoc to its export; it previously attached to a private helper and the flagship schema symbol published with no docs. - Include docs/threat-model.md and docs/store-provisioning.md in the JSR publish; published guides already link to both. - Refresh docs/reference/access.md with the shared encrypted field surface. - Correct docs that contradicted behavior: passkey enrollment origin gating (stable or local-only), astro schemaDir (reserved, src only), testing polling option, boot-progress example helper, shared field-key write rules. - Document the page-reload path of enableSyncBackup / enrollSyncTicket / restore flows where the returned promise never settles. - State the permanent group-creator authority on groupAccess, removeMember's lazy rekey, and share's upsert semantics. - Document LofiAppConfig properties, above all credentialOrigins. - Export AuthenticateOptions from the root entrypoint; remove stray comment lines that rendered inside module docs.
- commit
- 591e3f2497cb781e21cbd1e2ceecc87708f22dd2
- parent
- eb6fdba0ccb5424a1ddcea1095cf44c3799b81b5
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | deno.json | 2 | ++ |
| M | docs/ | 26 | +++++++++++++++++++++----- |
| M | package/ | 25 | +++++++++++++++++++++++++ |
| M | package/ | 9 | ++++++++- |
| M | package/ | 6 | +++++- |
| M | package/ | 3 | ++- |
| M | package/ | 16 | +++++++++++++++- |
| M | package/ | 14 | ++++++++------ |
| M | package/ | 1 | + |
| M | package/ | 1 | - |
| M | package/ | 21 | +++++++++++++++++++-- |
| M | package/ | 24 | ++++++++++++------------ |
| M | package/ | 6 | +++++- |
@@ -122,8 +122,10 @@
122122 "docs/permissions.md",123123 "docs/reference",124124 "docs/recipes",125+ "docs/store-provisioning.md",125126 "docs/sync-and-recovery.md",126127 "docs/testing.md",128+ "docs/threat-model.md",127129 "docs/troubleshooting.md",128130 "apps/reference/deno.json",129131 "apps/reference/public",@@ -23,15 +23,31 @@ phrase-reveal guard. That credential cannot restore an account.
2323 2424 Import from `@nzip/lofi/access`:2525 26-- Schema helpers: `sharedGrantTable`, `groupMembershipTable`.27-- Policy templates: `privateAccess`, `sharedAccess`, `groupAccess`, `defineAccessPolicies`.26+- Schema helpers: `sharedGrantTable`, `groupMembershipTable`, `sharedFieldDirectoryTable`,27+ `sharedFieldKeyTable`.28+- Policy templates: `privateAccess`, `sharedAccess`, `groupAccess`, `sharedFieldAccess`,29+ `defineAccessPolicies`.2830 - Identities: `sharingIdentity`, `encodeSharingIdentity`, `decodeSharingIdentity`.2931 - Operations: `createSharingOperations`, `createGroupOperations`.30-- Fixed roles: `reader`, `contributor`, `writer`, `admin`.32+- Shared-field key lifecycle: `bootstrapGroupFieldKey`, `rotateGroupFieldKey`,33+ `wrapHeldKeysForMember`, `reconcileSharedFieldKeys`.34+- Fixed roles: `reader`, `contributor`, `writer`, `admin` (exposed as `groupRoles`, with the35+ role-to-capability mapping in `groupRoleCapabilities`).3136 3237 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"`.38+provide `createGroup`, `addMember`, `changeRole`, `removeMember`, `leaveGroup`, `listMembers`, and39+`reconcileSharedFieldKeys`. Collaboration operations reject local-only use with40+`AccessError.code === "sync-required"`.41+42+## Shared encrypted fields43+44+Columns declared with `s.sharedEncryptedText` and `s.sharedEncryptedJson` (from `@nzip/lofi/schema`)45+hold ciphertext readable by every current group member. The access side hosts the key material:46+`sharedFieldDirectoryTable` and `sharedFieldKeyTable` store the directory and per-member wrapped47+keys, `sharedFieldAccess` (or the `fieldKeys` option of `groupAccess`) compiles their policies, and48+the lifecycle helpers create, wrap, and rotate group field keys. Rotation is lazy: removing a member49+re-keys future writes, while content encrypted under generations the member already holds remains50+readable to them. See [Permission templates](../permissions.md) for the hosting walkthrough.3551 3652 In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the3753 same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup`@@ -84,9 +84,17 @@ export type ShareLevel = "read" | "edit";
8484 8585 /** Direct-share operations bound to one resource and grant table pair. */8686 export type SharingOperations<Resource, Grant> = {87+ /**88+ * Grants the recipient access at `level`. Upserts: when a grant already89+ * exists for that recipient, its level is changed in place — including90+ * silently downgrading `edit` to `read`.91+ */8792 share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise<Grant>;93+ /** Deletes every grant for the recipient on the resource; no-op when none exist. */8894 revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;95+ /** Lists the current grants on one resource. */8996 listShares(resourceId: string): Promise<Grant[]>;97+ /** Lists resources other accounts have shared with the current account. */9098 sharedWithMe(): Promise<Resource[]>;9199 };92100 @@ -335,19 +343,36 @@ export function createGroupOperations<
335343 336344 /** Fixed-role group creation and membership operations. */337345 export type GroupOperations<Group, GroupInit, Member> = {346+ /** Creates the group row and the creator's admin membership. */338347 createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;348+ /**349+ * Adds the recipient at `role`; rejects when they are already a member350+ * (use {@linkcode GroupOperations.changeRole} instead). For groups hosting351+ * shared columns, key delivery is best-effort — a member without a352+ * directory entry stays pending until353+ * {@linkcode GroupOperations.reconcileSharedFieldKeys} runs.354+ */339355 addMember(340356 groupId: string,341357 recipient: SharingIdentity | string,342358 role: GroupRole,343359 ): Promise<Member>;360+ /** Replaces an existing member's role capabilities; rejects for non-members. */344361 changeRole(345362 groupId: string,346363 recipient: SharingIdentity | string,347364 role: GroupRole,348365 ): Promise<Member>;366+ /**367+ * Deletes the membership; no-op for non-members. Shared-field keys rotate368+ * lazily: future writes seal under a generation the removed member never369+ * receives, but content sealed under generations they already hold remains370+ * readable to them.371+ */349372 removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void>;373+ /** Deletes the current account's own membership; no-op for non-members. */350374 leaveGroup(groupId: string): Promise<void>;375+ /** Lists the memberships of one group. */351376 listMembers(groupId: string): Promise<Member[]>;352377 /** Repairs missing field-key wraps for a group hosting shared columns;353378 * throws a configuration error when the operations were created without@@ -90,6 +90,11 @@ export function sharedAccess(config: {
9090 * (or match its column shape), and each resource table must carry the `groupId`9191 * column referencing the group table.9292 *93+ * Group-creator authority is permanent: the creator can always update their94+ * group row, and through it restore their own admin membership even after95+ * being demoted or removed. Choose this template only when that trust96+ * property fits — a group cannot durably expel its creator.97+ *9398 * 9499 * ```ts95100 * import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";@@ -114,7 +119,9 @@ export function groupAccess(config: {
114119 groupId: string;115120 /** Wrapped-field-key table for groups hosting shared encrypted columns116121 * (declared with `sharedFieldKeyTable`). Readable by each row's recipient117- * or any member; writable by a member signing as themselves. */122+ * or any member; inserted by a member signing as themselves; updated only123+ * by the row's sender; deleted by the sender, the recipient, or a group124+ * admin. */118125 fieldKeys?: AccessTable;119126 }): GroupAccessTemplate {120127 return {@@ -13,7 +13,11 @@ import { join, resolve } from "node:path";
1313 export type LofiAstroOptions = {1414 /** Project root. Defaults to the directory from which Astro was invoked. */1515 root?: string;16- /** Directory containing the Jazz schema and permissions. */16+ /**17+ * Reserved. The generated integration always reads the Jazz schema and18+ * permissions from `src`; any other value throws.19+ * @default "src"20+ */1721 schemaDir?: string;1822 };1923 @@ -26,7 +26,8 @@ export type { BootProgress, BootProgressPhase } from "../runtime/boot-progress.t
2626 *2727 * const boot = useBootProgress();2828 * if (boot.phase === "downloading" && boot.totalBytes) {29- * return <p>Downloading the app · {progressLabel(boot)}</p>;29+ * const percent = Math.round((boot.loadedBytes / boot.totalBytes) * 100);30+ * return <p>Downloading the app · {percent}%</p>;3031 * }3132 * ```3233 *@@ -13,10 +13,21 @@
1313 * ```1414 */1515 export type LofiAppConfig<Schema = unknown> = {16+ /** Display name, used for the PWA and as the passkey label. Must be non-empty. */1617 name: string;18+ /** Name of the local database the runtime opens. Must be non-empty. */1719 databaseName: string;20+ /** The app's Jazz schema, as returned by the `/lofi/schema` define entry points. */1821 schema: Schema;22+ /** Storage durability contract. `durable` is the only supported mode. */1923 storage: "durable";24+ /**25+ * Allowlist of hostnames considered committed-stable for enrolling device26+ * credentials: exact hostnames or `*.` suffix patterns. A deployed HTTPS27+ * host not on this list classifies as `unverified` and passkey enrollment28+ * refuses there — with the list omitted or empty, enrollment works only on29+ * localhost. Add the production hostname before shipping passkey features.30+ */2031 credentialOrigins?: readonly string[];2132 /**2233 * Recoverable passkeys are scoped to this relying-party ID. Pin the canonical@@ -77,7 +88,9 @@ export class LofiConfigurationError extends Error {
7788 * Defines the application-facing values the versioned runtime needs.7889 *7990 * The returned value retains the exact schema type so author code can select80- * tables without importing or editing framework internals.91+ * tables without importing or editing framework internals. Calling it also92+ * registers the configuration globally so runtime entry points (`bootLofi`,93+ * `getRuntime`) can resolve it — import the defining module before booting.8194 *8295 * 8396 * ```ts@@ -95,6 +108,7 @@ export class LofiConfigurationError extends Error {
95108 *96109 * config The author-owned app configuration, including the raw Jazz schema.97110 * The same configuration, typed to the exact schema for table selection.111+ * {LofiConfigurationError} When `name` or `databaseName` is empty.98112 */99113 export function defineLofiApp<const Schema>(100114 config: LofiAppConfig<Schema>,@@ -1,5 +1,4 @@
11 /**2-// Package-owned identity and device-credential runtime.32 * Device credential auth for local-first apps.43 *54 * Local-first identity is device-local and cryptographic — there is no central@@ -23,7 +22,9 @@ export type PrfSupport = "available" | "not-reported" | "unknown" | "unavailable
2322 /**2423 * How stable the current origin is for enrolling a passkey. A passkey is bound2524 * to the origin hostname (its RP ID); enrolling on an origin that later changes26- * silently breaks the credential, so enrollment is gated on `stable`.25+ * silently breaks the credential, so enrollment is allowed only on `stable`26+ * origins and on loopback hosts during development (`local-only`), and refused27+ * on `unverified` and `blocked` origins.2728 */2829 export type CredentialOriginReport = {2930 status: "stable" | "local-only" | "unverified" | "blocked";@@ -290,7 +291,8 @@ export async function getAuthCapability(
290291 291292 /**292293 * Enrolls a resident, user-verifying device passkey with the PRF extension293- * enabled. Refuses unless the current origin is `stable` (pass `rpId` to test).294+ * enabled. Refuses with an `AuthError("origin-rejected")` unless the current295+ * origin is `stable` or a loopback `local-only` host (pass `rpId` to test).294296 * The returned `portable` flag says whether the credential roams across devices;295297 * `label` names it in the user's password manager. Returns the opaque id.296298 */@@ -522,6 +524,6 @@ export async function decryptAtRest(
522524 // Account identity is not derived from a credential in lofi. The account is a523525 // local-first secret (see `session.ts`), backed up by a recovery phrase and,524526 // optionally, protected at rest by the PRF key above. Deriving the *account*525-// from a passkey was removed: it cannot preserve a pre-existing local-only526-// account's data, since a derived key is a different account. This module stays527-// a device-credential + at-rest-key primitive, nothing more.527+// from a passkey cannot preserve a pre-existing local-only account's data,528+// since a derived key is a different account. This module stays a529+// device-credential + at-rest-key primitive, nothing more.@@ -277,6 +277,11 @@ export async function revealRecoveryPhrase(): Promise<string> {
277277 * turns on and the existing local data pushes up under the same identity. Pair278278 * it with { revealRecoveryPhrase} so the user has a way back in. No-op when279279 * no Jazz app is configured.280+ *281+ * In browsers with `SharedWorker` support, first-time election reloads the page282+ * to reopen the runtime on the elected namespace; the returned promise never283+ * settles there, and the app resumes after reload. Do not put follow-up UI284+ * behind the `await` — read the post-reload session state instead.280285 */281286 export async function enableSyncBackup(): Promise<Session> {282287 // The documented no-op: without a sync location there is nothing to sync@@ -321,6 +326,10 @@ export type EnrollSyncTicketOptions = {
321326 * requires the proof-of-possession exchange — the enrolled credential stops322327 * being a pure bearer string. Against a node without the exchange, the ticket323328 * enrolls as pasted, exactly as before.329+ *330+ * Enrollment ends by electing sync, so the { enableSyncBackup} reload331+ * caveat applies: on `SharedWorker`-capable browsers the page reloads and the332+ * returned promise never settles.324333 */325334 export async function enrollSyncTicket(326335 ticket: string,@@ -411,7 +420,10 @@ async function replaceAccountSecret(
411420 /**412421 * Reconstructs an account from its recovery phrase, elects sync, and replaces413422 * the active runtime. Throws {@link RecoveryError} for malformed phrases and414- * {@link AccountReplacementError} when confirmation is required.423+ * {@link AccountReplacementError} when confirmation is required. On424+ * `SharedWorker`-capable browsers the replacement reloads the page and the425+ * returned promise never settles; the app resumes on the restored account426+ * after reload.415427 */416428 export async function restoreFromRecoveryPhrase(417429 phrase: string,@@ -420,7 +432,12 @@ export async function restoreFromRecoveryPhrase(
420432 return await replaceAccountSecret(fromRecoveryPhrase(phrase), options);421433 }422434 423-/** Restores a passkey-backed secret and recreates Jazz on that stable principal. */435+/**436+ * Restores a passkey-backed secret and recreates Jazz on that stable principal.437+ * The {@link restoreFromRecoveryPhrase} reload caveat applies: on438+ * `SharedWorker`-capable browsers the page reloads and the returned promise439+ * never settles.440+ */424441 export async function restoreFromPasskey(425442 options: AccountReplacementOptions = {},426443 ): Promise<Session> {@@ -99,6 +99,18 @@ export type SchemaDsl =
9999 remove: typeof remove;100100 };101101 102+// The define entry points additionally record every encrypted column into103+// the table/column registry the access layer consults; behavior and types104+// are otherwise the pinned originals.105+function withEncryptedColumnRegistration<106+ F extends (definition: never, ...rest: never[]) => unknown,107+>(define: F): F {108+ return ((definition: unknown, ...rest: unknown[]) => {109+ registerEncryptedColumns(definition);110+ return (define as unknown as (...args: unknown[]) => unknown)(definition, ...rest);111+ }) as unknown as F;112+}113+102114 /**103115 * The lofi schema surface. Use in `src/schema.ts` and `src/permissions.ts` in104116 * place of a raw `jazz-tools` import; Jazz member names and behavior are@@ -112,18 +124,6 @@ export type SchemaDsl =
112124 * `WriteHandle`, observed in UI through `useWrite` and `usePendingWrites`113125 * from `@nzip/lofi/preact`.114126 */115-// The define entry points additionally record every encrypted column into116-// the table/column registry the access layer consults; behavior and types117-// are otherwise the pinned originals.118-function withEncryptedColumnRegistration<119- F extends (definition: never, ...rest: never[]) => unknown,120->(define: F): F {121- return ((definition: unknown, ...rest: unknown[]) => {122- registerEncryptedColumns(definition);123- return (define as unknown as (...args: unknown[]) => unknown)(definition, ...rest);124- }) as unknown as F;125-}126-127127 export const s: SchemaDsl = {128128 ...schema,129129 defineSchema: withEncryptedColumnRegistration(schema.defineSchema),@@ -6,7 +6,11 @@ export interface ReadinessOptions {
66 description?: string;77 /** Defaults to 10 seconds. */88 timeoutMs?: number;9- /** Uses Playwright's event-loop polling; no fixed sleep is introduced. */9+ /**10+ * `"raf"` re-checks on every animation frame; a number re-checks at that11+ * fixed interval in milliseconds.12+ * @default "raf"13+ */1014 polling?: "raf" | number;1115 }1216