viewer.ts
Language TypeScript4.5 KB112 lines
/*** Who is looking at the board, and what they are allowed to read.* Runtime-neutral: no `node:fs`, so the Worker can import it.** Cloudflare Access is not enough on its own. Access authenticates a person* against Google Workspace and decides whether they may load a URL. It has no* view inside the response, so it cannot show one person their briefing and* hide another's. A page carrying every briefing with the others `hidden` is* styling, not privacy: anyone through the Access door could read them all* from view-source.** So Access supplies the identity and this file decides the content. The board is* rendered once per viewer and each variant contains only what that viewer may* read; nothing is filtered in the browser, because anything sent to a browser* has been disclosed.*/import { personFromEmail, type Person } from '../../../config/meridian-people.js'export interface Viewer {/** The address Access asserted, lowercased. Null when unidentified. */email: string | null/** Roster match, or null for an authenticated address nobody has claimed. */person: Person | null/*** Which rendered variant this viewer gets. A roster name, or 'company'.* Also the KV key suffix, so it must stay stable and URL-safe.*/key: string/** May read the executive briefing. Exactly one person on the roster. */seesExec: boolean/** May read Leadership Team to-dos, and the company brief that names them. */seesLeadership: boolean/** Whose personal briefing to include. Null means none. */ownBriefing: string | null}/*** The fallback view: company numbers, queues, data health. No personal briefing,* no Leadership detail, no exec.** This is what an authenticated @meridiandisplay.example address that is not on the* roster receives: a new hire, or a mailbox nobody has added yet. It fails closed* on the private parts while staying useful, because the point of the board is* that everybody has an idea of where the company is at.*/export const COMPANY_VIEWER: Viewer = {email: null,person: null,key: 'company',seesExec: false,seesLeadership: false,ownBriefing: null,}/*** Resolve an authenticated address to a viewer.** Every permission defaults to false (rule 13). An unknown address, an absent* one, and a malformed one all land on the company view. There is no branch* here that grants anything without an exact roster match, which is what makes* the exec brief's single reader enforceable rather than aspirational.** An off-roster address keeps its email. It still gets the company view and* every permission still false, but the address Access asserted is carried* rather than thrown away, because the inbox grant lists key on it: shared* desks such as `help@` are granted by address, and a viewer with* `email: null` would make those lines present, reviewed, intended and inert.** This does not widen anything. An off-roster address gains access only where a* human has written its line in a list that ships closed.*/export function viewerFor(email: string | null | undefined): Viewer {const normalized = (email ?? '').trim().toLowerCase()const person = personFromEmail(email)// No address at all is a different thing from an address nobody claimed: the// first can hold no grant, the second can be granted one by name.if (!person) return normalized ? { ...COMPANY_VIEWER, email: normalized } : COMPANY_VIEWERreturn {email: (email ?? '').trim().toLowerCase(),person,key: person.name,seesExec: person.seesExec === true,seesLeadership: person.seesLeadership === true,ownBriefing: person.name,}}/*** Which briefing key holds the company brief this viewer may read.** Two exist because prose cannot be redacted after a model writes it. `company`* names Leadership to-dos by title; `company-allhands` is generated from a* payload with them removed. Serving the wrong one is the whole leak, so the* choice lives here rather than at the call site.*/export function companyBriefingKey(viewer: Viewer): 'company' | 'company-allhands' {return viewer.seesLeadership ? 'company' : 'company-allhands'}/*** Every variant that has to be rendered for a sync.** Includes 'company' so an unrecognised address always has something to be* served. Order is stable so the output is diffable.*/export function allViewers(people: Person[]): Viewer[] {return [COMPANY_VIEWER,...people.map((p) => viewerFor(p.email)).filter((v) => v.person !== null),]}