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 };
}