clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

docs: correct author-facing docs where they contradict behavior; fix JSR packaging gaps (#167)

FelineStateMachine committed 591e3f2
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>
13 files changed, +123 -31
Mdeno.json2++
Mdocs/reference/access.md26+++++++++++++++++++++-----
Mpackage/access/operations.ts25+++++++++++++++++++++++++
Mpackage/access/policies.ts9++++++++-
Mpackage/astro/mod.ts6+++++-
Mpackage/preact/use-boot-progress.ts3++-
Mpackage/runtime/app.ts16+++++++++++++++-
Mpackage/runtime/auth.ts14++++++++------
Mpackage/runtime/mod.ts1+
Mpackage/runtime/recovery.ts1-
Mpackage/runtime/session.ts21+++++++++++++++++++--
Mpackage/schema/mod.ts24++++++++++++------------
Mpackage/testing/readiness.ts6+++++-
M deno.json +2 -0
M docs/reference/access.md +21 -5
@@ -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`
M package/access/operations.ts +25 -0
@@ -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
M package/access/policies.ts +8 -1
M package/astro/mod.ts +5 -1
M package/preact/use-boot-progress.ts +2 -1
M package/runtime/app.ts +15 -1
@@ -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 `@nzip/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 * @example8396 * ```ts
@@ -95,6 +108,7 @@ export class LofiConfigurationError extends Error {
95108 *96109 * @param config The author-owned app configuration, including the raw Jazz schema.97110 * @returns The same configuration, typed to the exact schema for table selection.111+ * @throws {LofiConfigurationError} When `name` or `databaseName` is empty.98112 */99113 export function defineLofiApp<const Schema>(100114 config: LofiAppConfig<Schema>,
M package/runtime/auth.ts +8 -6
@@ -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.
M package/runtime/mod.ts +1 -0
M package/runtime/recovery.ts +0 -1
M package/runtime/session.ts +19 -2
@@ -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 {@link 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 {@link 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> {
M package/schema/mod.ts +12 -12
@@ -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),
M package/testing/readiness.ts +5 -1