// --------------------------------------------------------------------------- // shell/shareModel.ts — WAVE 20 items 18/23/26 (owner ruling R10, contract // C-SHARE): the manage-access editor's PURE half. // // Reading and editing a grant set is arithmetic over a list, and every way it // can be wrong is silent: // // · a grant stored against the wrong identifier binds to nobody (see the // mailbox amendment A-S4-5 — `people` may arrive as display names while // every server-side check compares login usernames); // · a PUT that REPLACES, given a list assembled by halves, revokes people // nobody meant to revoke — the API has no DELETE verb, so an omission IS a // revocation and every edit must carry the whole set forward; // · `mayAdminister` read loosely turns "you may edit this view" into "you may // decide who else can", which is the privilege escalation R10's server half // refuses in as many words. // // So the model is React-free and lives here, where `verify_login.py` — this // codebase's ACCESS gate — can run it under node with negative controls. The // dialog is a renderer over these functions and decides nothing. // --------------------------------------------------------------------------- /** The three shareable kinds. Closed vocabulary: the server 400s anything else. */ export const SHARE_KINDS = ["view", "folder", "database"] as const; export type ShareKind = (typeof SHARE_KINDS)[number]; /** The two roles, the same two words the view rail already speaks (R10). */ export const SHARE_ROLES = ["view", "edit"] as const; export type ShareRole = (typeof SHARE_ROLES)[number]; /** The wildcard entry: "everyone who can already open this surface". */ export const EVERYONE = "*"; export interface ShareEntry { user: string; role: ShareRole; } /** One assignable account. `name` is what a human reads; `user` is what binds. */ export interface SharePerson { user: string; name: string; } export interface ShareState { owner: string | null; entries: ShareEntry[]; /** This session's own role on the object: 'owner' | 'edit' | 'view' | null. */ role: string | null; /** Owner or admin — the only people the server lets change grants. */ mayAdminister: boolean; people: SharePerson[]; } const str = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); /** * `GET /api/v1/share/{kind}/{oid}` → a state the editor can render, FAIL-CLOSED. * * Two absences matter and they are not the same: * · a missing `entries` list is an object nobody has shared yet — an empty * editor, which is correct and ordinary; * · a missing `mayAdminister` is a server that did not answer the question, * and it reads as NO. Defaulting it to yes would open the editor for a * collaborator, who would then meet a 403 on save — or, worse, would meet a * server that had also been relaxed. */ export function parseShare(body: unknown): ShareState { const b = (body && typeof body === "object" ? body : {}) as Record; const rawEntries = Array.isArray(b.entries) ? b.entries : []; const entries: ShareEntry[] = []; const seen = new Set(); for (const item of rawEntries) { if (!item || typeof item !== "object") continue; const e = item as Record; const user = str(e.user).toLowerCase(); const role = str(e.role).toLowerCase(); // Same posture as the server's `_clean_entries`: a junk ROLE is dropped, never // coerced to a default — a row shown as "can view" that the store holds as // something else is a lie the editor would then save back. if (!user || !(SHARE_ROLES as readonly string[]).includes(role) || seen.has(user)) continue; seen.add(user); entries.push({ user, role: role as ShareRole }); } return { owner: str(b.owner).toLowerCase() || null, entries, role: str(b.role).toLowerCase() || null, mayAdminister: b.mayAdminister === true, people: parsePeople(b.people), }; } /** * The assignable accounts. * * ⚠ TWO SHAPES ACCEPTED, AND THE REASON IS A LIVE CONTRACT QUESTION (A-S4-5). * The route serves `users.assignable_people()`, which returns DISPLAY NAMES, * while every grant check compares LOGIN USERNAMES — so a picker that stores * what it was shown can write a grant that binds to nobody. An object entry * (`{username, name}`) resolves that; a bare string is read as a username, * because that is the only reading under which the current payload is correct. * Nothing here can repair a display name: no payload maps one to an account. */ export function parsePeople(raw: unknown): SharePerson[] { if (!Array.isArray(raw)) return []; const out: SharePerson[] = []; const seen = new Set(); for (const item of raw) { let user = ""; let name = ""; if (typeof item === "string") { user = item.trim(); name = user; } else if (item && typeof item === "object") { const p = item as Record; user = str(p.username) || str(p.user); name = str(p.name) || user; } const key = user.toLowerCase(); if (!key || seen.has(key)) continue; seen.add(key); out.push({ user: key, name: name || user }); } return out; } /** * Add a person, or change the role of one already listed. The whole list comes * back, because the PUT replaces: an editor that returned only its delta would * revoke everyone it forgot to mention. */ export function withEntry(entries: ShareEntry[], user: string, role: ShareRole): ShareEntry[] { const key = str(user).toLowerCase(); if (!key || !(SHARE_ROLES as readonly string[]).includes(role)) return entries; let found = false; const next = entries.map((e) => { if (e.user !== key) return e; found = true; return { user: key, role }; }); return found ? next : [...next, { user: key, role }]; } /** Revoke: the entry's ABSENCE is the revocation (there is no DELETE verb). */ export function withoutEntry(entries: ShareEntry[], user: string): ShareEntry[] { const key = str(user).toLowerCase(); return entries.filter((e) => e.user !== key); } /** The people not yet granted anything — what the "add someone" picker offers. */ export function addablePeople(people: SharePerson[], entries: ShareEntry[]): SharePerson[] { const taken = new Set(entries.map((e) => e.user)); return people.filter((p) => !taken.has(p.user)); } /** * One sentence naming who can reach this object, for the row that opens the * editor. Never a count on its own: "3 people" reads as reassurance, and the * fact that matters is whether one of them is EVERYONE. */ export function shareSummary(entries: ShareEntry[]): string { if (!entries.length) return "Not shared"; const everyone = entries.find((e) => e.user === EVERYONE); if (everyone) { return everyone.role === "edit" ? "Everyone can edit" : "Everyone can view"; } const editors = entries.filter((e) => e.role === "edit").length; const people = entries.length === 1 ? "1 person" : `${entries.length} people`; return editors ? `${people}, ${editors} can edit` : `${people} can view`; } /** The PUT body. One shape, one place — the editor never assembles it inline. */ export function sharePutBody(entries: ShareEntry[]): { entries: ShareEntry[] } { return { entries }; } /** * The shell↔rail channel for "open the access editor for this thing". * * A window event rather than a prop, because the two callers live in trees that * must not import each other: the views rail is host-neutral `customer-grid/` * and the dialog is the shell's. The name is a literal in both files until S3 * publishes it in `apiContract.ts` (amendment A-S4-4) — the constant is here so * only one file in this tree spells it. */ export const SHARE_OPEN_EVENT = "aios:share-open"; export interface ShareRequest { kind: ShareKind; id: string; /** What to call the thing in the dialog's title — the user's word for it. */ label: string; } /** Read a `SHARE_OPEN_EVENT` detail, dropping anything malformed (fail-closed: * an unknown kind is not a new namespace, it is a bug that must not open a * dialog that would 400 on save). */ export function parseShareRequest(detail: unknown): ShareRequest | null { if (!detail || typeof detail !== "object") return null; const d = detail as Record; const kind = str(d.kind).toLowerCase(); const id = str(d.id); if (!id || !(SHARE_KINDS as readonly string[]).includes(kind)) return null; return { kind: kind as ShareKind, id, label: str(d.label) || id }; }