File size: 8,613 Bytes
ea2c336 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | // ---------------------------------------------------------------------------
// 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 };
}
|