| // --------------------------------------------------------------------------- | |
| // 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<string, unknown>; | |
| const rawEntries = Array.isArray(b.entries) ? b.entries : []; | |
| const entries: ShareEntry[] = []; | |
| const seen = new Set<string>(); | |
| for (const item of rawEntries) { | |
| if (!item || typeof item !== "object") continue; | |
| const e = item as Record<string, unknown>; | |
| 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<string>(); | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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 }; | |
| } | |