mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

viewer.ts

Language TypeScript4.5 KB112 lines

apps/dashboard/src/viewer.ts

  1. /**
  2. * Who is looking at the board, and what they are allowed to read.
  3. * Runtime-neutral: no `node:fs`, so the Worker can import it.
  4. *
  5. * Cloudflare Access is not enough on its own. Access authenticates a person
  6. * against Google Workspace and decides whether they may load a URL. It has no
  7. * view inside the response, so it cannot show one person their briefing and
  8. * hide another's. A page carrying every briefing with the others `hidden` is
  9. * styling, not privacy: anyone through the Access door could read them all
  10. * from view-source.
  11. *
  12. * So Access supplies the identity and this file decides the content. The board is
  13. * rendered once per viewer and each variant contains only what that viewer may
  14. * read; nothing is filtered in the browser, because anything sent to a browser
  15. * has been disclosed.
  16. */
  17. import { personFromEmail, type Person } from '../../../config/meridian-people.js'
  18. export interface Viewer {
  19. /** The address Access asserted, lowercased. Null when unidentified. */
  20. email: string | null
  21. /** Roster match, or null for an authenticated address nobody has claimed. */
  22. person: Person | null
  23. /**
  24. * Which rendered variant this viewer gets. A roster name, or 'company'.
  25. * Also the KV key suffix, so it must stay stable and URL-safe.
  26. */
  27. key: string
  28. /** May read the executive briefing. Exactly one person on the roster. */
  29. seesExec: boolean
  30. /** May read Leadership Team to-dos, and the company brief that names them. */
  31. seesLeadership: boolean
  32. /** Whose personal briefing to include. Null means none. */
  33. ownBriefing: string | null
  34. }
  35. /**
  36. * The fallback view: company numbers, queues, data health. No personal briefing,
  37. * no Leadership detail, no exec.
  38. *
  39. * This is what an authenticated @meridiandisplay.example address that is not on the
  40. * roster receives: a new hire, or a mailbox nobody has added yet. It fails closed
  41. * on the private parts while staying useful, because the point of the board is
  42. * that everybody has an idea of where the company is at.
  43. */
  44. export const COMPANY_VIEWER: Viewer = {
  45. email: null,
  46. person: null,
  47. key: 'company',
  48. seesExec: false,
  49. seesLeadership: false,
  50. ownBriefing: null,
  51. }
  52. /**
  53. * Resolve an authenticated address to a viewer.
  54. *
  55. * Every permission defaults to false (rule 13). An unknown address, an absent
  56. * one, and a malformed one all land on the company view. There is no branch
  57. * here that grants anything without an exact roster match, which is what makes
  58. * the exec brief's single reader enforceable rather than aspirational.
  59. *
  60. * An off-roster address keeps its email. It still gets the company view and
  61. * every permission still false, but the address Access asserted is carried
  62. * rather than thrown away, because the inbox grant lists key on it: shared
  63. * desks such as `help@` are granted by address, and a viewer with
  64. * `email: null` would make those lines present, reviewed, intended and inert.
  65. *
  66. * This does not widen anything. An off-roster address gains access only where a
  67. * human has written its line in a list that ships closed.
  68. */
  69. export function viewerFor(email: string | null | undefined): Viewer {
  70. const normalized = (email ?? '').trim().toLowerCase()
  71. const person = personFromEmail(email)
  72. // No address at all is a different thing from an address nobody claimed: the
  73. // first can hold no grant, the second can be granted one by name.
  74. if (!person) return normalized ? { ...COMPANY_VIEWER, email: normalized } : COMPANY_VIEWER
  75. return {
  76. email: (email ?? '').trim().toLowerCase(),
  77. person,
  78. key: person.name,
  79. seesExec: person.seesExec === true,
  80. seesLeadership: person.seesLeadership === true,
  81. ownBriefing: person.name,
  82. }
  83. }
  84. /**
  85. * Which briefing key holds the company brief this viewer may read.
  86. *
  87. * Two exist because prose cannot be redacted after a model writes it. `company`
  88. * names Leadership to-dos by title; `company-allhands` is generated from a
  89. * payload with them removed. Serving the wrong one is the whole leak, so the
  90. * choice lives here rather than at the call site.
  91. */
  92. export function companyBriefingKey(viewer: Viewer): 'company' | 'company-allhands' {
  93. return viewer.seesLeadership ? 'company' : 'company-allhands'
  94. }
  95. /**
  96. * Every variant that has to be rendered for a sync.
  97. *
  98. * Includes 'company' so an unrecognised address always has something to be
  99. * served. Order is stable so the output is diffable.
  100. */
  101. export function allViewers(people: Person[]): Viewer[] {
  102. return [
  103. COMPANY_VIEWER,
  104. ...people.map((p) => viewerFor(p.email)).filter((v) => v.person !== null),
  105. ]
  106. }