The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 |
| /** Workspace membership and install consent are checked separately by the host. */ |
| 5 | export interface ColineRecordAccess { |
| 6 | workspace: "none" | "read" | "write"; |
| 7 | readers: string[]; |
| 8 | writers: string[]; |
| 9 | } |
| 10 | export const DEFAULT_RECORD_ACCESS: ColineRecordAccess = { |
| 11 | workspace: "write", |
| 12 | readers: [], |
| 13 | writers: [], |
| 14 | }; |
| 15 |
| 16 | /** The stored form of a record creator: `user:<id>`, `visitor:<id>` or `app`. */ |
| 17 | export function actorKey(actor: ColineActorRef): string { |
| 18 | if (actor.kind === "user") return `user:${actor.userId}`; |
| 19 | if (actor.kind === "visitor") return `visitor:${actor.visitorId}`; |
| 20 | return "app"; |
| 21 | } |
| 22 |
| 23 | /** Parse a stored creator. Visitor details are not stored on records. */ |
| 24 | export function parseActorKey(key: string): ColineActorRef { |
| 25 | if (key.startsWith("user:")) return { kind: "user", userId: key.slice(5) }; |
| 26 | if (key.startsWith("visitor:")) { |
| 27 | return { kind: "visitor", visitorId: key.slice(8), signedIn: false, name: null, email: null }; |
| 28 | } |
| 29 | return { kind: "app" }; |
| 30 | } |
| 31 |
| 32 | /** Whether two actors are the same principal. */ |
| 33 | export function sameActor(a: ColineActorRef, b: ColineActorRef): boolean { |
| 34 | return actorKey(a) === actorKey(b); |
| 35 | } |
| 36 |
| 37 | /** |
| 38 | * Record access for members and app logic. |
| 39 | * |
| 40 | * The owner is the exact creating principal: a user, the app, or a site |
| 41 | * visitor. Visitors never pass this check, not even for records they |
| 42 | * created. Their access is decided by the collection's declared rules |
| 43 | * (`canVisitorAccess`), so a site can never reach a record through the |
| 44 | * per-record ACL. |
| 45 | */ |
| 46 | export function canAccessRecord( |
| 47 | access: ColineRecordAccess, |
| 48 | creator: ColineActorRef, |
| 49 | caller: ColineActorRef, |
| 50 | action: "read" | "write" | "manage", |
| 51 | ): boolean { |
| 52 | if (caller.kind === "visitor") return false; |
| 53 | if (sameActor(creator, caller)) return true; |
| 54 | if (action === "manage") return false; |
| 55 | if (access.workspace === "write") return true; |
| 56 | const userId = caller.kind === "user" ? caller.userId : null; |
| 57 | if (!userId) return false; |
| 58 | if (access.writers.includes(userId)) return true; |
| 59 | return action === "read" && (access.workspace === "read" || access.readers.includes(userId)); |
| 60 | } |
| 61 |
| 62 | /** |
| 63 | * Whether a collection rule admits a visitor. `creator` is the record's |
| 64 | * creator for update and delete, and null for create and for queries |
| 65 | * (where `owner` narrows results to the visitor's own records instead). |
| 66 | */ |
| 67 | export function canVisitorAccess( |
| 68 | subject: CollectionAccessSubject, |
| 69 | visitor: { visitorId: string; signedIn: boolean }, |
| 70 | creator: ColineActorRef | null, |
| 71 | ): boolean { |
| 72 | switch (subject) { |
| 73 | case "public": |
| 74 | case "visitor": |
| 75 | return true; |
| 76 | case "signedIn": |
| 77 | return visitor.signedIn; |
| 78 | case "owner": |
| 79 | return creator?.kind === "visitor" && creator.visitorId === visitor.visitorId; |
| 80 | case "team": |
| 81 | return false; |
| 82 | } |
| 83 | } |
| 84 |
| 85 | export interface ColineRecordWriteOptions { |
| 86 | expectedVersion?: number; |
| 87 | } |
| 88 | export interface ColineRecordInsertOptions { |
| 89 | id?: string; |
| 90 | access?: ColineRecordAccess; |
| 91 | } |
| 92 |