clone

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

feat/prf-provision-custody
lofi / package / access / operations.ts
operations.ts 10.2K
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 { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";7import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";89/** Minimum row shape accepted by collaboration operations. */10export type Identified = { id: string };11/** Jazz table shape required by the access operation helpers. */12export type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {13 where(input: unknown): QueryBuilder<Row>;14};15/** Conventional direct-share grant row. */16export type SharedGrantRow = Identified & {17 resourceId: string;18 user_id: string;19 can_edit: boolean;20};21/** Conventional fixed-role group membership row. */22export type GroupMembershipRow = Identified & {23 groupId: string;24 user_id: string;25 role: GroupRole;26 can_create: boolean;27 can_edit_any: boolean;28 can_manage: boolean;29};3031function requireSync(operation: string): void {32 if (!syncing()) {33 throw new AccessError(34 "sync-required",35 `${operation} requires managed sync and an account that has elected to sync. Private resources remain available local-only.`,36 );37 }38}3940// Share and membership writes settle through the shared runtime settlement41// core, so access operations use the same diagnostics accounting as table42// mutations and stay visible to the inspector. Access writes await the global43// tier inline: a share is not "done" until the server accepted it.44async function settle<T>(write: DurableWrite<T>): Promise<T> {45 try {46 const result = await settleDurableWrite(write, updateRuntimeDiagnostics, "await", {47 onGlobal: () =>48 updateRuntimeDiagnostics((diagnostics) => diagnostics.lastWriteDurability = "global"),49 });50 return result;51 } catch (cause) {52 updateRuntimeDiagnostics((diagnostics) => diagnostics.lastWriteDurability = "failed");53 throw new AccessError(54 "mutation-rejected",55 "Jazz rejected the access change. Refresh permissions and membership, then try again.",56 { cause },57 );58 }59}6061function assertRole(role: string): asserts role is GroupRole {62 if (!(groupRoles as readonly string[]).includes(role)) {63 throw new AccessError(64 "invalid-role",65 `Unknown group role ${JSON.stringify(role)}; choose reader, contributor, writer, or admin.`,66 );67 }68}6970/** Access level assigned by a direct share. */71export type ShareLevel = "read" | "edit";7273/** Direct-share operations bound to one resource and grant table pair. */74export type SharingOperations<Resource, Grant> = {75 share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise<Grant>;76 revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;77 listShares(resourceId: string): Promise<Grant[]>;78 sharedWithMe(): Promise<Resource[]>;79};8081/** Creates direct-share operations that wait for local and global durability. */82export function createSharingOperations<83 Resource extends Identified,84 ResourceInit,85 Grant extends SharedGrantRow,86 GrantInit,87>(config: {88 resource: AccessRuntimeTable<Resource, ResourceInit>;89 grants: AccessRuntimeTable<Grant, GrantInit>;90}): SharingOperations<Resource, Grant> {91 const findGrant = async (resourceId: string, userId: string): Promise<Grant | null> => {92 const { db } = await getRuntime();93 return await db.one(config.grants.where({ resourceId, user_id: userId } as never));94 };9596 return {97 async share(98 resourceId: string,99 recipient: SharingIdentity | string,100 level: ShareLevel,101 ): Promise<Grant> {102 requireSync("Sharing");103 const userId = decodeSharingIdentity(recipient);104 const { db } = await getRuntime();105 const existing = await findGrant(resourceId, userId);106 if (existing) {107 await settle(108 db.update(config.grants, existing.id, { can_edit: level === "edit" } as never),109 );110 return { ...existing, can_edit: level === "edit" };111 }112 const inserted = db.insert(config.grants, {113 resourceId,114 user_id: userId,115 can_edit: level === "edit",116 } as GrantInit);117 return await settle(inserted);118 },119120 async revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void> {121 requireSync("Revoking a share");122 const userId = decodeSharingIdentity(recipient);123 const { db } = await getRuntime();124 const rows = await db.all(config.grants.where({ resourceId, user_id: userId } as never));125 await Promise.all(rows.map((row) => settle(db.delete(config.grants, row.id))));126 },127128 async listShares(resourceId: string): Promise<Grant[]> {129 requireSync("Listing shares");130 const { db } = await getRuntime();131 return await db.all(config.grants.where({ resourceId } as never));132 },133134 async sharedWithMe(): Promise<Resource[]> {135 requireSync("Listing resources shared with you");136 const { db } = await getRuntime();137 const userId = db.getAuthState().session?.user_id;138 if (!userId) {139 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");140 }141 const grants = await db.all(config.grants.where({ user_id: userId } as never));142 const resources = await Promise.all(143 grants.map((grant) => db.one(config.resource.where({ id: grant.resourceId } as never))),144 );145 const available: Resource[] = [];146 for (const row of resources) if (row !== null) available.push(row as Resource);147 return available;148 },149 };150}151152/** Creates fixed-role group membership operations for a declared table pair. */153export function createGroupOperations<154 Group extends Identified,155 GroupInit,156 Member extends GroupMembershipRow,157 MemberInit,158>(config: {159 groups: AccessRuntimeTable<Group, GroupInit>;160 members: AccessRuntimeTable<Member, MemberInit>;161}): GroupOperations<Group, GroupInit, Member> {162 const findMember = async (groupId: string, userId: string): Promise<Member | null> => {163 const { db } = await getRuntime();164 return await db.one(config.members.where({ groupId, user_id: userId } as never));165 };166167 return {168 async createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }> {169 requireSync("Creating a group");170 const { db } = await getRuntime();171 const userId = db.getAuthState().session?.user_id;172 if (!userId) {173 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");174 }175 const group = await settle(db.insert(config.groups, values));176 try {177 const membership = await settle(db.insert(config.members, {178 groupId: group.id,179 user_id: userId,180 ...groupRoleCapabilities("admin"),181 } as MemberInit));182 return { group, membership };183 } catch (cause) {184 // The creator's direct delete authority on their own group row185 // authorizes this rollback, so a failed first-admin insert no longer186 // strands an undeletable orphan group.187 await settle(db.delete(config.groups, group.id)).catch(() => undefined);188 throw cause;189 }190 },191192 async addMember(193 groupId: string,194 recipient: SharingIdentity | string,195 role: GroupRole,196 ): Promise<Member> {197 requireSync("Adding a group member");198 assertRole(role);199 const userId = decodeSharingIdentity(recipient);200 const existing = await findMember(groupId, userId);201 if (existing) {202 throw new AccessError(203 "mutation-rejected",204 "That account is already a member of this group.",205 );206 }207 const { db } = await getRuntime();208 return await settle(209 db.insert(config.members, {210 groupId,211 user_id: userId,212 ...groupRoleCapabilities(role),213 } as MemberInit),214 );215 },216217 async changeRole(218 groupId: string,219 recipient: SharingIdentity | string,220 role: GroupRole,221 ): Promise<Member> {222 requireSync("Changing a group role");223 assertRole(role);224 const userId = decodeSharingIdentity(recipient);225 const member = await findMember(groupId, userId);226 if (!member) {227 throw new AccessError("not-found", "That account is not a member of this group.");228 }229 const { db } = await getRuntime();230 const capabilities = groupRoleCapabilities(role);231 await settle(db.update(config.members, member.id, capabilities as never));232 return { ...member, ...capabilities };233 },234235 async removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void> {236 requireSync("Removing a group member");237 const member = await findMember(groupId, decodeSharingIdentity(recipient));238 if (!member) return;239 const { db } = await getRuntime();240 await settle(db.delete(config.members, member.id));241 },242243 async leaveGroup(groupId: string): Promise<void> {244 requireSync("Leaving a group");245 const { db } = await getRuntime();246 const userId = db.getAuthState().session?.user_id;247 if (!userId) {248 throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");249 }250 const member = await findMember(groupId, userId);251 if (!member) return;252 await settle(db.delete(config.members, member.id));253 },254255 async listMembers(groupId: string): Promise<Member[]> {256 requireSync("Listing group members");257 const { db } = await getRuntime();258 return await db.all(config.members.where({ groupId } as never));259 },260 };261}262263/** Fixed-role group creation and membership operations. */264export type GroupOperations<Group, GroupInit, Member> = {265 createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;266 addMember(267 groupId: string,268 recipient: SharingIdentity | string,269 role: GroupRole,270 ): Promise<Member>;271 changeRole(272 groupId: string,273 recipient: SharingIdentity | string,274 role: GroupRole,275 ): Promise<Member>;276 removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void>;277 leaveGroup(groupId: string): Promise<void>;278 listMembers(groupId: string): Promise<Member[]>;279};