loopable / web /src /shell /shareModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea2c336 verified
Raw
History Blame Contribute Delete
8.61 kB
// ---------------------------------------------------------------------------
// 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 };
}