clone

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

main
lofi / package / access / operations.ts
operations.ts 15.9K
1import type { QueryBuilder, TableProxy } from "jazz-tools";2import { syncing } from "../runtime/config.ts";3import { type DurableWrite, settleDurableWrite } from "../runtime/durability.ts";4import { getRuntime, updateRuntimeDiagnostics } from "../runtime/runtime.ts";5import { AccessError } from "./errors.ts";6import {7 bootstrapGroupFieldKey,8 reconcileSharedFieldKeys,9 rotateGroupFieldKey,10 type SharedFieldLifecycleContext,11 wrapHeldKeysForMember,12} from "./shared-field-lifecycle.ts";13import { activeAppId } from "../runtime/config.ts";14import {15 decodeSharingIdentity,16 decodeSharingIdentityDetails,17 type SharingIdentity,18} from "./identity.ts";19import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";2021/** Minimum row shape accepted by collaboration operations. */22export type Identified = { id: string };23/** Jazz table shape required by the access operation helpers. */24export type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {25 where(input: unknown): QueryBuilder<Row>;26};27/** Conventional direct-share grant row. */28export type SharedGrantRow = Identified & {29 resourceId: string;30 user_id: string;31 can_edit: boolean;32};33/** Conventional fixed-role group membership row. */34export type GroupMembershipRow = Identified & {35 groupId: string;36 user_id: string;37 role: GroupRole;38 can_create: boolean;39 can_edit_any: boolean;40 can_manage: boolean;41};4243function requireSync(operation: string): void {44 if (!syncing()) {45 throw new AccessError(46 "sync-required",47 `${operation} requires managed sync and an account that has elected to sync. Private resources remain available local-only.`,48 );49 }50}5152// Share and membership writes settle through the shared runtime settlement53// core, so access operations use the same diagnostics accounting as table54// mutations and stay visible to the inspector. Access writes await the global55// tier inline: a share is not "done" until the server accepted it.56async function settle<T>(write: DurableWrite<T>): Promise<T> {57 try {58 const result = await settleDurableWrite(write, updateRuntimeDiagnostics, "await", {59 onGlobal: () =>60 updateRuntimeDiagnostics((diagnostics) => diagnostics.lastWriteDurability = "global"),61 });62 return result;63 } catch (cause) {64 updateRuntimeDiagnostics((diagnostics) => diagnostics.lastWriteDurability = "failed");65 throw new AccessError(66 "mutation-rejected",67 "Jazz rejected the access change. Refresh permissions and membership, then try again.",68 { cause },69 );70 }71}7273function assertRole(role: string): asserts role is GroupRole {74 if (!(groupRoles as readonly string[]).includes(role)) {75 throw new AccessError(76 "invalid-role",77 `Unknown group role ${JSON.stringify(role)}; choose reader, contributor, writer, or admin.`,78 );79 }80}8182/** Access level assigned by a direct share. */83export type ShareLevel = "read" | "edit";8485/** Direct-share operations bound to one resource and grant table pair. */86export 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 */92 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. */94 revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;95 /** Lists the current grants on one resource. */96 listShares(resourceId: string): Promise<Grant[]>;97 /** Lists resources other accounts have shared with the current account. */98 sharedWithMe(): Promise<Resource[]>;99};100101/** Table pair consumed by {@link createSharingOperations}. */102export type SharingOperationsConfig<103 Resource extends Identified,104 ResourceInit,105 Grant extends SharedGrantRow,106 GrantInit,107> = {108 /** The resource table whose rows are shared with other accounts. */109 resource: AccessRuntimeTable<Resource, ResourceInit>;110 /** The resource's grant table, declared with `sharedGrantTable` (or matching111 * its column shape) and covered by a `sharedAccess` policy template. */112 grants: AccessRuntimeTable<Grant, GrantInit>;113};114115/** Creates direct-share operations that wait for local and global durability. */116export function createSharingOperations<117 Resource extends Identified,118 ResourceInit,119 Grant extends SharedGrantRow,120 GrantInit,121>(122 config: SharingOperationsConfig<Resource, ResourceInit, Grant, GrantInit>,123): SharingOperations<Resource, Grant> {124 const findGrant = async (resourceId: string, userId: string): Promise<Grant | null> => {125 const { db } = await getRuntime();126 return await db.one(config.grants.where({ resourceId, user_id: userId } as never));127 };128129 return {130 async share(131 resourceId: string,132 recipient: SharingIdentity | string,133 level: ShareLevel,134 ): Promise<Grant> {135 requireSync("Sharing");136 const userId = decodeSharingIdentity(recipient);137 const { db } = await getRuntime();138 const existing = await findGrant(resourceId, userId);139 if (existing) {140 await settle(141 db.update(config.grants, existing.id, { can_edit: level === "edit" } as never),142 );143 return { ...existing, can_edit: level === "edit" };144 }145 const inserted = db.insert(config.grants, {146 resourceId,147 user_id: userId,148 can_edit: level === "edit",149 } as GrantInit);150 return await settle(inserted);151 },152153 async revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void> {154 requireSync("Revoking a share");155 const userId = decodeSharingIdentity(recipient);156 const { db } = await getRuntime();157 const rows = await db.all(config.grants.where({ resourceId, user_id: userId } as never));158 await Promise.all(rows.map((row) => settle(db.delete(config.grants, row.id))));159 },160161 async listShares(resourceId: string): Promise<Grant[]> {162 requireSync("Listing shares");163 const { db } = await getRuntime();164 return await db.all(config.grants.where({ resourceId } as never));165 },166167 async sharedWithMe(): Promise<Resource[]> {168 requireSync("Listing resources shared with you");169 const { db } = await getRuntime();170 const userId = db.getAuthState().session?.user_id;171 if (!userId) {172 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");173 }174 const grants = await db.all(config.grants.where({ user_id: userId } as never));175 const resources = await Promise.all(176 grants.map((grant) => db.one(config.resource.where({ id: grant.resourceId } as never))),177 );178 const available: Resource[] = [];179 for (const row of resources) if (row !== null) available.push(row as Resource);180 return available;181 },182 };183}184185/** Tables consumed by {@link createGroupOperations}. */186export type GroupOperationsConfig<187 Group extends Identified,188 GroupInit,189 Member extends GroupMembershipRow,190 MemberInit,191> = {192 /** The group table whose rows anchor membership and group-owned resources. */193 groups: AccessRuntimeTable<Group, GroupInit>;194 /** The group's membership table, declared with `groupMembershipTable` (or195 * matching its column shape) and covered by a `groupAccess` policy template. */196 members: AccessRuntimeTable<Member, MemberInit>;197 /** The group's wrapped-field-key table (declared with `sharedFieldKeyTable`),198 * for groups hosting shared encrypted columns. Provide it together with199 * `directory`; the operations then mint, wrap, and rotate field keys200 * automatically. */201 fieldKeys?: AccessRuntimeTable<Identified, unknown>;202 /** The app's shared-field key directory table (declared with203 * `sharedFieldDirectoryTable`), for groups hosting shared encrypted columns.204 * Provide it together with `fieldKeys`. */205 directory?: AccessRuntimeTable<Identified, unknown>;206};207208/** Creates fixed-role group membership operations for a declared table pair. */209export function createGroupOperations<210 Group extends Identified,211 GroupInit,212 Member extends GroupMembershipRow,213 MemberInit,214>(215 config: GroupOperationsConfig<Group, GroupInit, Member, MemberInit>,216): GroupOperations<Group, GroupInit, Member> {217 const findMember = async (groupId: string, userId: string): Promise<Member | null> => {218 const { db } = await getRuntime();219 return await db.one(config.members.where({ groupId, user_id: userId } as never));220 };221222 // The lifecycle context resolves lazily per call: the runtime, principal,223 // and app id all belong to the moment of the operation.224 const lifecycleContext = async (): Promise<SharedFieldLifecycleContext | null> => {225 if (!config.fieldKeys || !config.directory) return null;226 const { db } = await getRuntime();227 const userId = db.getAuthState().session?.user_id;228 if (!userId) {229 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");230 }231 const groupTable = (config.groups as unknown as { _table?: string })._table;232 if (!groupTable) {233 throw new AccessError("configuration", "The group table does not expose its name.");234 }235 return {236 db: db as never,237 appId: activeAppId(),238 userId,239 groupTable,240 fieldKeys: config.fieldKeys as never,241 directory: config.directory as never,242 members: config.members as never,243 };244 };245246 return {247 async createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }> {248 requireSync("Creating a group");249 const { db } = await getRuntime();250 const userId = db.getAuthState().session?.user_id;251 if (!userId) {252 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");253 }254 const group = await settle(db.insert(config.groups, values));255 try {256 const membership = await settle(db.insert(config.members, {257 groupId: group.id,258 user_id: userId,259 ...groupRoleCapabilities("admin"),260 } as MemberInit));261 const lifecycle = await lifecycleContext();262 if (lifecycle) await bootstrapGroupFieldKey(lifecycle, group.id);263 return { group, membership };264 } catch (cause) {265 // The creator's direct delete authority on their own group row266 // authorizes this rollback, so a failed first-admin insert no longer267 // strands an undeletable orphan group.268 await settle(db.delete(config.groups, group.id)).catch(() => undefined);269 throw cause;270 }271 },272273 async addMember(274 groupId: string,275 recipient: SharingIdentity | string,276 role: GroupRole,277 ): Promise<Member> {278 requireSync("Adding a group member");279 assertRole(role);280 const details = decodeSharingIdentityDetails(recipient);281 const userId = details.userId;282 const existing = await findMember(groupId, userId);283 if (existing) {284 throw new AccessError(285 "mutation-rejected",286 "That account is already a member of this group.",287 );288 }289 const { db } = await getRuntime();290 const membership = await settle(291 db.insert(config.members, {292 groupId,293 user_id: userId,294 ...groupRoleCapabilities(role),295 } as MemberInit),296 );297 // Key delivery is best-effort here: a member without a directory entry298 // sits in the documented pending state until a key-holding device runs299 // reconcileSharedFieldKeys. The membership itself never waits on keys.300 const lifecycle = await lifecycleContext();301 if (lifecycle) {302 await wrapHeldKeysForMember(lifecycle, groupId, userId, details.fingerprint)303 .catch(() => undefined);304 }305 return membership;306 },307308 async changeRole(309 groupId: string,310 recipient: SharingIdentity | string,311 role: GroupRole,312 ): Promise<Member> {313 requireSync("Changing a group role");314 assertRole(role);315 const userId = decodeSharingIdentity(recipient);316 const member = await findMember(groupId, userId);317 if (!member) {318 throw new AccessError("not-found", "That account is not a member of this group.");319 }320 const { db } = await getRuntime();321 const capabilities = groupRoleCapabilities(role);322 await settle(db.update(config.members, member.id, capabilities as never));323 return { ...member, ...capabilities };324 },325326 async removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void> {327 requireSync("Removing a group member");328 const removedUserId = decodeSharingIdentity(recipient);329 const member = await findMember(groupId, removedUserId);330 if (!member) return;331 const { db } = await getRuntime();332 await settle(db.delete(config.members, member.id));333 // Lazy rekey: future writes seal under a generation the removed member334 // never receives; already-held generations remain readable to them.335 const lifecycle = await lifecycleContext();336 if (lifecycle) {337 await rotateGroupFieldKey(lifecycle, groupId, removedUserId).catch(() => undefined);338 }339 },340341 async leaveGroup(groupId: string): Promise<void> {342 requireSync("Leaving a group");343 const { db } = await getRuntime();344 const userId = db.getAuthState().session?.user_id;345 if (!userId) {346 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");347 }348 const member = await findMember(groupId, userId);349 if (!member) return;350 await settle(db.delete(config.members, member.id));351 },352353 async listMembers(groupId: string): Promise<Member[]> {354 requireSync("Listing group members");355 const { db } = await getRuntime();356 return await db.all(config.members.where({ groupId } as never));357 },358359 async reconcileSharedFieldKeys(groupId: string): Promise<number> {360 requireSync("Repairing shared-field keys");361 const lifecycle = await lifecycleContext();362 if (!lifecycle) {363 throw new AccessError(364 "configuration",365 "These group operations were created without fieldKeys/directory tables; declare " +366 "them to host shared encrypted columns.",367 );368 }369 return await reconcileSharedFieldKeys(lifecycle, groupId);370 },371 };372}373374/** Fixed-role group creation and membership operations. */375export type GroupOperations<Group, GroupInit, Member> = {376 /** Creates the group row and the creator's admin membership. */377 createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;378 /**379 * Adds the recipient at `role`; rejects when they are already a member380 * (use {@linkcode GroupOperations.changeRole} instead). For groups hosting381 * shared columns, key delivery is best-effort — a member without a382 * directory entry stays pending until383 * {@linkcode GroupOperations.reconcileSharedFieldKeys} runs.384 */385 addMember(386 groupId: string,387 recipient: SharingIdentity | string,388 role: GroupRole,389 ): Promise<Member>;390 /** Replaces an existing member's role capabilities; rejects for non-members. */391 changeRole(392 groupId: string,393 recipient: SharingIdentity | string,394 role: GroupRole,395 ): Promise<Member>;396 /**397 * Deletes the membership; no-op for non-members. Shared-field keys rotate398 * lazily: future writes seal under a generation the removed member never399 * receives, but content sealed under generations they already hold remains400 * readable to them.401 */402 removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void>;403 /** Deletes the current account's own membership; no-op for non-members. */404 leaveGroup(groupId: string): Promise<void>;405 /** Lists the memberships of one group. */406 listMembers(groupId: string): Promise<Member[]>;407 /** Repairs missing field-key wraps for a group hosting shared columns;408 * throws a configuration error when the operations were created without409 * `fieldKeys`/`directory` tables. Returns the number of wraps inserted. */410 reconcileSharedFieldKeys(groupId: string): Promise<number>;411};