index.ts
Language TypeScript83.9 KB1892 lines
/*** The deployed dashboard: a Cloudflare Worker that syncs on a schedule and* serves the page.** A `fetch` handler that pulled live data on every request would hit four APIs* per page load, take ten seconds, and burn the Plinth rate limit the moment two* people opened it at once. So the cron does the work and writes the result to* KV; requests are a KV read and nothing else.** That also means a request can never fail because a source is down. The page* shows the last good sync with its `syncedAt` timestamp, and the source pills* report which sources were failing when it ran, which is the honest answer,* and the one this whole board is built around.** Authentication is Cloudflare Access in front of the Worker, authenticating* against Google Workspace. The page carries client company names and order* numbers, so it must not be public, and Access means no user table, no login* form, no session handling and no password resets. access.ts verifies the* assertion Access attaches. A page served without signing in means Access is* misconfigured.*/import { collect, headline, type Snapshot } from '../../dashboard/src/collect.js'import { render, routeTabBar } from '../../dashboard/src/render.js'import { buildBriefingPayload } from '../../dashboard/src/briefing-payload.js'import { rowFrom, type HistoryRow } from '../../dashboard/src/history.js'import { renderVariants, variantKey } from '../../dashboard/src/variants.js'import { EXPORT_CSS, THEME_CSS } from '../../dashboard/src/theme.js'import { esc } from '../../dashboard/src/html.js'import { WORK_KEY_RE } from '../../dashboard/src/work-key.js'import { ownListBlock, parseOwnOutcome, stampOwnList } from '../../dashboard/src/own-list-view.js'import { hasOwnList, ownListPersonFor } from '../../../config/own-list.js'import { readOwnList, saveOwn } from './own-list.js'import {ATTACHMENT_DOWNLOAD_CAP,fetchAttachment,fetchBody,mimeParam,parseServiceAccountSecret,readMailbox,sendMessage,type GmailCredential,type MailboxRead,type ServiceAccount,} from '@meridian/gmail-client'import {INBOX_CSS,INBOX_MAX_PER_MAILBOX,composePage,confirmPage,inboxPage,messagePage,newMessagePage,SEARCH_RESULT_CAP,searchResultsPage,sendStates,sentPage,signaturesPage,type InboxData,type SearchHit,} from '../../dashboard/src/inbox-view.js'import { hasOnboarding, onboardingFor } from '../../../config/onboarding.js'import {exportToday,parseWindow,recapPage,recapTextAll,} from '../../dashboard/src/onboarding-export.js'import { checkinAuthorFor, readableCheckinAuthors } from '../../../config/checkin.js'import {checkinBlock,parseOutcome,stampCheckin,type CheckinStore,} from '../../dashboard/src/checkin-view.js'import { readCheckinsFor, saveCheckin } from './checkin.js'import {INBOX_MAILBOXES,MARKETING_SENDERS,canReadInbox,buildSearchQuery,isOperational,readableMailboxes,sendableMailboxes,} from '../../../config/inbox.js'import {SEND_KEY,handleCompose,handleSend,handleSignaturesPost,listSent,readRecord,readReplied,readSignatures,type SendDeps,type SendEnv,} from './inbox-send.js'import { viewerFor } from '../../dashboard/src/viewer.js'import { identify } from './access.js'import type { Briefings } from '../../dashboard/src/briefings.js'import { localDay, localStamp } from '@meridian/ninety-client'import { errorMessage } from '@meridian/shared'export interface Env {DASHBOARD: KVNamespaceNINETY_TOKEN: stringBITRIX_WEBHOOK_URL: stringPLINTH_API_KEY: string/*** Gmail, for the consolidated inbox. One service account for all seven* mailboxes, authorized once in Google Admin for domain-wide delegation.* `GMAIL_SA_KEY` is the `private_key` from the key JSON; `GMAIL_SA_EMAIL` is* its `client_email`.** Both optional: with neither set, every mailbox reports as failed with a* reason and the rest of the board is untouched. A half-configured inbox must* not take the sync down with it.*/GMAIL_SA_EMAIL?: stringGMAIL_SA_KEY?: string/*** Per-mailbox OAuth refresh tokens, for when domain-wide delegation is not* available (it needs a Workspace super-admin). One shared client, one token* per mailbox, minted by `pnpm inbox:auth`.** Precedence: a mailbox with its token set uses it; a mailbox without one* falls back to the service account, so tokens can be retired one at a time* with no code change once delegation is in place.*/GMAIL_CLIENT_ID?: stringGMAIL_CLIENT_SECRET?: stringGMAIL_REFRESH_HELLO?: stringGMAIL_REFRESH_ORDERDESK?: stringGMAIL_REFRESH_STUDIO?: stringGMAIL_REFRESH_ACCOUNTS?: stringGMAIL_REFRESH_HELP?: stringGMAIL_REFRESH_SERVICE?: stringGMAIL_REFRESH_EXEC?: string/*** Fathom, one key per person. Every one is optional: with none set, meeting* commitments read "not tracked" rather than zero, and with one revoked the* others still sync while the board names the dark key.** Declared here only to be findable. collect() reads them by name out of* config/fathom-keys.ts, through the cast at the collect() call below, so* adding a person means editing that file and running `wrangler secret put`;* a line added here alone does nothing. The names must match* config/fathom-keys.ts exactly.*/FATHOM_KEY_SAM?: stringFATHOM_KEY_INES?: stringFATHOM_KEY_THEO?: string/*** D1, for the end-of-day note and the private list: the only SQL on this* board and the only binding here that is not a credential.** Optional by design (rule 14). A Worker deployed before `wrangler d1 create`* has been run must still serve the board, with the note card saying, in those words,* that the store is not set up. The alternative is an exception on the read* path or, far worse, an empty log, which reads as "they have never written* one" and is the prime invariant broken in the place it costs most.* See apps/worker/migrations/.*/CHECKINS?: D1Database/** Optional. Widens the Plinth order window; defaults to 60 days. */PLINTH_WINDOW_DAYS?: string/*** Cloudflare Access team domain, e.g. `meridiandisplay.cloudflareaccess.com`.* Not a secret (it is in every login URL), but the Worker refuses every* request without it, because it cannot verify an assertion it cannot check.*/ACCESS_TEAM_DOMAIN?: string/*** The Access application's Audience (AUD) tag. Pins assertions to THIS* application; without it a token minted for another app on the same Access* team would be accepted here.*/ACCESS_AUD?: stringDEBUG?: string}// Every KV key the board writes. There is deliberately no whole-board page key:// every page lives under `dashboard:html:<viewer>` and is chosen by verified// identity. A copy of client data that no code path serves is not a fallback,// it is an extra place to leak from.const KEY = {payload: 'dashboard:payload',history: 'dashboard:history',/*** The model-written briefings, pushed from a laptop by `pnpm push`.** They arrive from outside because the only place a model is called is* `analysis/brief.py`, which runs locally. This Worker does not call one, so it* has no way to author a briefing; it can only render one somebody else wrote.* Without this key the deployed board renders the deterministic briefing and* says the written one was not generated, which is honest and also permanent.*/briefings: 'dashboard:briefings',lastError: 'sync:lastError',/** Metadata-only message list, rewritten every sync. No bodies, ever. */inboxMessages: 'inbox:messages',/*** The marketing sender list, board-owned state (rule 12). The write goes to* the board's own KV and never to a source system, and losing this key* degrades to MARKETING_SENDERS in config rather than to nothing.*/inboxFilters: 'inbox:filters',/*** The board's own read-marks, kept board-side so that no mailbox has to be* re-consented for gmail.modify. Same bar as inbox:filters: board KV, never* Gmail; losing the key degrades to Gmail's real unread state; the button* that set a mark can unset it. A map of "mailbox/id" → epoch ms, pruned past* the list window so it cannot grow without bound.*/inboxRead: 'inbox:read',/*** User notes on orders and tasks: "why this is where it is", typed by a* person, shared with everyone, keyed `order:MD26…` / `task:<id>`. Board KV* only; losing the key degrades to a board with no annotations, never to* broken; a note is edited or removed from the same button that wrote it.*/notes: 'board:notes',/*** Who asked for a sync and when, for the Sync now button: the last manual* run's start time and who pressed it.** It is a cooldown, not a lock. Two people clicking ten seconds apart would* each run a full sync and each burn the Plinth and Bitrix rate limits; Bitrix* answers that with QUERY_LIMIT_EXCEEDED, which would land in sync:lastError* and read as a broken source rather than as two eager clicks. Every write in* sync() is idempotent, so the risk is the rate limit, not corruption.** Losing the key degrades to "the next click is allowed". Nothing depends on* it being present.*/syncRequest: 'sync:request',/*** The send keys live in inbox-send.ts as SEND_KEY and are named here so this* map stays the one place every key the board owns is listed.** inbox:signatures per-mailbox HTML signatures. Losing it means replies go* out unsigned and the page says "not set"; reversible* from the page that wrote it.* inbox:replied the persistent replied mark, board-owned so the* 15-minute rewrite of inbox:messages cannot erase it.* Losing it costs the chips, not the replies: those are* in Gmail's Sent folder.* inbox:sent:<n> one key per send attempt, append-only. The only state* here the page cannot undo, because it records* something that cannot be undone.*/signatures: SEND_KEY.signatures,replied: SEND_KEY.replied,sentPrefix: SEND_KEY.sentPrefix,} as const/** History lives in KV rather than on disk. Same rows, same day-over-day deltas. */const MAX_HISTORY = 120/*** The pushed briefings, or null.** Null is a normal answer, not an error: before the first push, and on any day* nobody ran `pnpm morning`, there is no written briefing and the page says so.* A corrupt value is treated the same way: losing the prose is survivable,* failing the whole sync over it is not.*/async function readBriefings(env: Env): Promise<Briefings | null> {const raw = await env.DASHBOARD.get(KEY.briefings)if (!raw) return nulltry {const parsed: unknown = JSON.parse(raw)if (!parsed || typeof parsed !== 'object') return nullconst b = parsed as Briefingsreturn b.briefings && typeof b.briefings === 'object' ? b : null} catch {return null}}async function readHistory(env: Env): Promise<HistoryRow[]> {const raw = await env.DASHBOARD.get(KEY.history)if (!raw) return []try {const parsed: unknown = JSON.parse(raw)return Array.isArray(parsed) ? (parsed as HistoryRow[]) : []} catch {// A corrupt history must not take the sync down. Losing yesterday's delta// is survivable; losing today's board is not.return []}}/*** One sync. Returns the rendered page so the caller can decide what to do with* a failure rather than having it swallowed here.** `writeHistory` is the one thing that differs between the two crons, and it* is false for the 15-minute one on purpose. `history.ts` keeps exactly one row per* America/Chicago day and its header says why: "re-running the build at 9:00* does not make 'yesterday' mean 'an hour ago'". Ninety-six rows a day would* turn the day-over-day arrows (the most useful thing on the page) from* "since yesterday morning" into "since the last quarter hour", without any* number on the page changing to show it (rule 6). So the morning cron writes* the day's baseline and every other sync leaves it alone.** Reading history is unaffected: the 15-minute renders still draw their arrows* against the same morning row.*/export async function sync(env: Env,{ writeHistory = true }: { writeHistory?: boolean } = {},): Promise<{ html: string; snap: Snapshot }> {const days = Number(env.PLINTH_WINDOW_DAYS ?? '60') || 60const snap = await collect(env as unknown as Record<string, string | undefined>, days)const h = headline(snap)const day = localDay(new Date(snap.syncedAt))const today = rowFrom(h, new Date(snap.syncedAt))// A run where a source died reports zeros, and a zero recorded today becomes// tomorrow's "yesterday", so the morning after an outage the briefing would// announce that the design queue grew by 53 overnight. A broken run neither// writes history nor reads it. Same rule as the local build; see main.ts.const sourcesOk = snap.leads.ok && snap.orderLines.ok && snap.tasks.okconst prior = sourcesOk ? await readHistory(env) : []// The briefings were written from a LAPTOP sync, not from this one, so the two// will normally carry different timestamps. That is not smoothed over:// `briefingAge()` labels the gap on the page, which is the honest reading:// the prose describes the numbers it was written from, and those are not the// numbers beside it after this cron runs.//// The alternative was for `pnpm push` to send only the briefings and let the// next cron pick them up, which would leave the board briefing-less for a day.// So the push sends the laptop's rendered page too, and this render is what// takes over once the numbers move.const briefings = await readBriefings(env)// One page per viewer. Each contains only what that person may read; nothing is// filtered in the browser, because anything sent to a browser has been// disclosed. See apps/dashboard/src/variants.ts.const variants = renderVariants(snap, h, prior, day, briefings)const html = variants.get('company') ?? render(snap, h, prior, day, briefings)const payload = buildBriefingPayload(snap)await Promise.all([...[...variants].map(([key, page]) => env.DASHBOARD.put(variantKey(key), page)),env.DASHBOARD.put(KEY.payload, JSON.stringify(payload)),sourcesOk && writeHistory? env.DASHBOARD.put(KEY.history,JSON.stringify([...prior.filter((r) => r.day !== today.day), today].slice(-MAX_HISTORY)),): Promise.resolve(),])// The inbox is written separately, not folded into the variants. It renders at// request time so the filter buttons take effect immediately rather than// tomorrow, so all the cron owes it is fresh message metadata.//// Its failure is contained: `readInboxes` never throws, and a Gmail outage or// a dead token must not stop the board's five real sources from publishing.await env.DASHBOARD.put(KEY.inboxMessages, JSON.stringify(await readInboxes(env)))const failures = [!snap.leads.ok && `leads: ${snap.leads.error}`,!snap.orderLines.ok && `orders: ${snap.orderLines.error}`,!snap.tasks.ok && `tasks: ${snap.tasks.error}`,!snap.ninety.ok && `ninety: ${snap.ninety.error}`,].filter(Boolean)await env.DASHBOARD.put(KEY.lastError,failures.length ? JSON.stringify({ at: snap.syncedAt, failures }) : '',)return { html, snap }}/*** The shell for the inbox and the other request-time pages.** A separate page, not a tab inside the pre-rendered board; see the header of* inbox-view.ts for why. It reuses the board's stylesheet so it does not look* like a different product, and links back so the tab bar's "Inbox" entry has a* way home.** `showOnboarding` mirrors the board's own decision. The bar on this page is* the board's bar, so an entry the board omits for this viewer is omitted here* too; otherwise the Onboarding tab appears to vanish when you open the inbox.*/const pageShell = (body: string,showOnboarding = false,title = 'Inbox',current: 'inbox' | 'none' = 'inbox',): string => `<!doctype html><html lang="en"><head><script>(function(){try{var m=document.cookie.match(/(?:^|; )theme=([a-z]+)/);var v=m?m[1]:'system';if(v==='dark'||(v==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.setAttribute('data-theme','dark');}catch(e){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Meridian Display: ${esc(title)}</title><style>${THEME_CSS}${INBOX_CSS}${EXPORT_CSS}</style></head><body><div class="page-rule" aria-hidden="true"></div><div class="page"><header class="masthead" style="padding:14px 0 0"><div class="eyebrow" style="margin:0">Meridian Display · Operations</div></header>${routeTabBar(current, showOnboarding)}<main id="main">${body}</main></div></body></html>`const NOT_SYNCED_YET = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Meridian Display: not synced yet</title><style>body{font:16px/1.6 system-ui,sans-serif;max-width:46em;margin:4em auto;padding:0 1.5em}</style></head><body><h1>Not synced yet</h1><p>The scheduled sync has not run since this Worker was deployed, so there is noboard to show. This is deliberately not a page of zeros: an empty board anda board that has never run must never look the same.</p><p>Wait for the next scheduled run, or trigger one now:</p><form method="post" action="/sync"><button type="submit">Sync now</button></form><p style="font-size:14px;color:#555">A full run reads all five sources and the sevenmailboxes and takes about half a minute. It does not write the written briefing;that is a weekday-morning job on a laptop.</p></body></html>`/*** One answer for every failure to identify.** Deliberately says nothing about why. A response that distinguished "no* assertion" from "wrong audience" from "unknown address" would let someone* outside map the configuration one request at a time. The reason is logged when* DEBUG is on; it is never returned.*/const DENIED_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Meridian Display: not authorised</title><style>body{font:16px/1.6 system-ui,sans-serif;max-width:40em;margin:4em auto;padding:0 1.5em}</style></head><body><h1>Not authorised</h1><p>This board could not confirm who you are. Sign in through the Meridian DisplayGoogle Workspace account you normally use.</p><p>If you are signed in and still see this, Cloudflare Access is not configured infront of this Worker. Treat that as an incident rather than aconvenience, because the board carries client names and order numbers.</p></body></html>`/*** A function, not a shared constant. A Response body is a stream and is consumed* when it is returned, so handing the same object to a second request would send* an empty body: a 403 that looks like a broken page rather than a refusal.*/const denied = (): Response =>new Response(DENIED_HTML, {status: 403,headers: PAGE_HEADERS,})async function health(env: Env): Promise<Response> {const [payload, lastError, inbox] = await Promise.all([env.DASHBOARD.get(KEY.payload),env.DASHBOARD.get(KEY.lastError),env.DASHBOARD.get(KEY.inboxMessages),])const syncedAt = payload ? (JSON.parse(payload) as { generatedAt?: string }).generatedAt : nullconst ageMinutes = syncedAt ? Math.round((Date.now() - Date.parse(syncedAt)) / 60_000) : null/*** Per-mailbox read status: names and errors, never messages.** This endpoint is identity-free by design, so what it may carry is tightly* bounded: our own mailbox addresses, whether each one read, and the reason* when it did not. No sender, no subject, no content of anyone's mail.** It is the only way to check a Gmail setup without deploying: the board* itself cannot be opened at localhost:8787 (no Access sits in front of* `wrangler dev`, so the Worker correctly refuses to identify anyone). Setup* you cannot verify is setup people repeat blindly until it happens to work.*/const inboxStatus = inbox? (() => {const d = JSON.parse(inbox) as InboxDatareturn {syncedAt: d.syncedAt,read: d.mailboxes.filter((m) => m.ok).map((m) => m.mailbox),failed: d.mailboxes.filter((m) => !m.ok).map((m) => ({ mailbox: m.mailbox, error: m.error })),// Can each mailbox send? Names and booleans, still no mail. It is the// only way to check freshly minted tokens before deploying, and null// (Google reported no scopes) must be visible as its own answer// rather than read as false.canSend: Object.fromEntries(d.mailboxes.map((m) => [m.mailbox, m.canSend ?? null])),// Counts only: how many messages are held and how many the window// holds. No sender, no subject. "Does the cap cover the window?" is// otherwise unanswerable without reading the whole blob.volume: Object.fromEntries(d.mailboxes.map((m) => [m.mailbox,{held: m.messages.length,// Counted by paging the id list, not Gmail's resultSizeEstimate,// which is capped near the page size and so reports ~501 for// any larger window.windowTotal: m.windowTotal ?? null,truncated: m.truncated ?? false,failedFetches: m.failedFetches ?? 0,// What Gmail actually said about the refusals: error text about// our own requests, not message content.failureSample: m.failureSample ?? [],},]),),}})(): nullreturn Response.json({ok: Boolean(syncedAt) && !lastError,// syncedAt stays a UTC ISO string: this is a machine endpoint and an// instant is the right thing to hand a machine. syncedAtLocal is beside// it because a person reads this page too, and a bare Z-suffixed// timestamp reads as the wrong hour to someone in Chicago.syncedAt,syncedAtLocal: syncedAt ? `${localStamp(syncedAt)} CT` : null,ageMinutes,accessConfigured: Boolean(env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD),failures: lastError ? (JSON.parse(lastError) as { failures: string[] }).failures : [],// null until the first sync writes it, which is different from "no// mailboxes configured" and reads differently here.inbox: inboxStatus,},{ status: syncedAt ? 200 : 503 },)}/*** Response headers for the board.** The CSP is the valuable one. The page is entirely self-contained (no external* stylesheet, script, image or font, verified by a test in render.test.ts), so* `default-src 'none'` costs nothing and removes the payoff from an injection* that got past escaping. With no `connect-src` and no `img-src` to a remote* host, a script that somehow ran could not send a briefing anywhere.** `'unsafe-inline'` is unavoidable: the page inlines its own CSS and one small* script precisely so it works with no network. That is the trade: inline code* still runs, but it has nowhere to send anything.** `frame-ancestors 'none'` blocks clickjacking, which matters more than usual for* a page whose whole job is displaying content one person may see and another* may not.*/export const PAGE_HEADERS = {'content-type': 'text/html; charset=utf-8','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " +// form-action 'self': the page's buttons are plain form POSTs rather than// fetch(), so the board works with JavaScript off and needs no// `connect-src` at all. 'self' is the narrowest value that permits a// same-origin POST; the page still cannot submit anywhere else.// font-src data: the brand fonts are embedded in the page as data: URIs.// Without it, default-src 'none' blocks them, and the failure is invisible// on any machine with the fonts installed, because the browser resolves the// family name from the OS and renders the intended face anyway."img-src data:; font-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",'x-content-type-options': 'nosniff','x-frame-options': 'DENY','referrer-policy': 'same-origin','x-robots-tag': 'noindex, nofollow',} as const/*** The open-message page only: identical to PAGE_HEADERS plus `frame-src 'self'`,* so the one page that embeds the message frame may do so and no other page can* grow an iframe by accident.*/export const MESSAGE_PAGE_HEADERS = {...PAGE_HEADERS,'content-security-policy':PAGE_HEADERS['content-security-policy'].replace("img-src data:", "img-src data:; frame-src 'self'"),} as const/*** Headers for /inbox/message/frame, the only response that ever contains a* sender's HTML. Its CSP is the outer defence (the iframe's missing* `allow-scripts` is the inner one): no scripts of any kind, no form targets,* no frames-within-frames, no fetch. Images, styles and fonts are what an* email needs to look like an email, so exactly those load.* frame-ancestors 'self' (not 'none') because being framed by the board is* this response's job, and nobody else's.*/const FRAME_HEADERS = {'content-type': 'text/html; charset=utf-8','content-security-policy':"default-src 'none'; img-src https: http: data: cid:; style-src 'unsafe-inline' https:; " +"font-src https: data:; script-src 'none'; form-action 'none'; base-uri 'none'; " +"frame-ancestors 'self'",'x-content-type-options': 'nosniff','x-frame-options': 'SAMEORIGIN','referrer-policy': 'no-referrer','x-robots-tag': 'noindex, nofollow','cache-control': 'private, no-store',} as const/*** GMAIL_SA_KEY may be the bare private key or the whole downloaded key JSON.* People paste the file, and the file also carries the account email, so a* full-JSON paste needs no GMAIL_SA_EMAIL at all.* A malformed value returns null here and each mailbox reports the parse error.*/function serviceAccount(env: Env): ServiceAccount | { error: string } | null {if (!env.GMAIL_SA_KEY) return nulltry {const parsed = parseServiceAccountSecret(env.GMAIL_SA_KEY)const email = env.GMAIL_SA_EMAIL || parsed.emailif (!email) {return { error: 'GMAIL_SA_KEY holds a bare key and GMAIL_SA_EMAIL is not set; one of the two must carry the account email' }}return { email, privateKey: parsed.privateKey }} catch (err) {return { error: errorMessage(err) }}}/*** The credential for one mailbox: its own refresh token when set, the service* account when not, an error naming what is missing when neither.*/function credentialFor(env: Env, m: (typeof INBOX_MAILBOXES)[number]): GmailCredential | { error: string } {const secrets = env as unknown as Record<string, string | undefined>const refreshToken = secrets[m.secret]if (refreshToken) {if (!env.GMAIL_CLIENT_ID || !env.GMAIL_CLIENT_SECRET) {return { error: `${m.secret} is set but GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET are not; a token is only usable with its client` }}return { clientId: env.GMAIL_CLIENT_ID, clientSecret: env.GMAIL_CLIENT_SECRET, refreshToken }}const sa = serviceAccount(env)if (!sa) return { error: `no credential: set the ${m.secret} secret (pnpm inbox:auth) or configure the service account` }return sa}/*** Read every mailbox. Each one independently, each failure its own.** A missing secret is reported the same way a rejected token is, because from* the reader's side they are the same fact: that inbox is not on the page and* here is why. Silently skipping an unconfigured mailbox would make a* half-finished setup look complete.*/async function readInboxes(env: Env): Promise<InboxData> {const mailboxes = await Promise.all(INBOX_MAILBOXES.map(async (m): Promise<MailboxRead> => {const cred = credentialFor(env, m)// canSend null, not false: a mailbox with no credential never had its// scopes reported, and "we could not ask" is not "it cannot send".if ('error' in cred) {return {mailbox: m.email, ok: false, error: cred.error, messages: [], canSend: null,windowTotal: null, truncated: false, failedFetches: 0, failureSample: [],}}return readMailbox(m.email, cred, { maxMessages: INBOX_MAX_PER_MAILBOX })}),)return { syncedAt: new Date().toISOString(), mailboxes }}/** The filter list, falling back to the committed seed when KV holds nothing. */async function readFilters(env: Env): Promise<string[]> {const raw = await env.DASHBOARD.get(KEY.inboxFilters)if (!raw) return [...MARKETING_SENDERS]try {const parsed = JSON.parse(raw) as unknown// A malformed value falls back to the SEED, not to an empty list. Empty// would un-hide every newsletter at once, which reads as a broken filter// rather than as a bad read.return Array.isArray(parsed)? parsed.filter((x): x is string => typeof x === 'string'): [...MARKETING_SENDERS]} catch {return [...MARKETING_SENDERS]}}/*** The viewer's theme, from the cookie the board's control sets.** Server-stamped so a dark reader never gets a white flash and a no-JS reader* still gets their choice. 'system' stamps nothing; the page's pre-paint* script resolves it against prefers-color-scheme, and without JS system means* light, which is the safe default on a page whose brand ramp is light.*/function themeFrom(req: Request): 'dark' | 'light' | 'system' {const m = (req.headers.get('cookie') ?? '').match(/(?:^|;\s*)theme=([a-z]+)/)return m?.[1] === 'dark' ? 'dark' : m?.[1] === 'light' ? 'light' : 'system'}/** Stamp data-theme into a stored page at serve time: one KV copy, any theme. */function stampTheme(html: string, theme: 'dark' | 'light' | 'system'): string {if (theme !== 'dark') return htmlreturn html.replace('<html lang="en">', '<html lang="en" data-theme="dark">')}/*** The board's read-marks. Lost or malformed falls back to empty, which here* means "trust Gmail's own unread state" (rule 12). Contrast readFilters, whose* safe fallback is the seed, not empty.*/async function readReadMarks(env: Env): Promise<Record<string, number>> {const raw = await env.DASHBOARD.get(KEY.inboxRead)if (!raw) return {}try {const parsed = JSON.parse(raw) as unknownif (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter((e): e is [string, number] => typeof e[1] === 'number',),)}return {}} catch {return {}}}/** Marks older than the list window are dead weight; prune on every write. */const READ_MARK_MAX_AGE_MS = 35 * 24 * 60 * 60 * 1000export interface BoardNote {text: stringby: stringat: string}/** A lost or malformed notes key degrades to a board with no annotations. */async function readNotes(env: Env): Promise<Record<string, BoardNote>> {const raw = await env.DASHBOARD.get(KEY.notes)if (!raw) return {}try {const parsed = JSON.parse(raw) as unknownif (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter((e): e is [string, BoardNote] =>!!e[1] && typeof (e[1] as BoardNote).text === 'string' && typeof (e[1] as BoardNote).by === 'string',),)}return {}} catch {return {}}}/*** Notes ride into the pre-rendered page at serve time, like the theme stamp:* the pages in KV are yesterday's render, and a note typed a minute ago must* show on the next load without waiting for a cron. The payload is a JSON* <script> the page's own script reads and renders via textContent. The note* text is user-written and must never be parsed as HTML, which is also why* `<` is escaped here: `</script>` inside a note would otherwise end the tag* and put the rest of the note into the document as markup.*/export function injectNotes(html: string, notes: Record<string, BoardNote>): string {const json = JSON.stringify(notes).replace(/</g, '\\u003c')// Anchor on the last closing-body tag, not the first. Page text cannot// contain it (esc() encodes <), but a comment in the page's own shipped// script can, and a first-occurrence replace would splice the JSON into the// middle of that script.const at = html.lastIndexOf('</body>')if (at === -1) return htmlreturn `${html.slice(0, at)}<script id="board-notes" type="application/json">${json}</script>${html.slice(at)}`}const NOTE_TEXT_CAP = 2000/** Tabs the note form may bounce back to; anything else lands on the board. */const NOTE_BACK_TABS = new Set(['board', 'my-day', 'queue', 'briefing', 'elsewhere', 'health'])/*** The collaborators the reply handlers take, bound to this Env.** Injected rather than imported so the handlers can be tested against a fake* Gmail and a fake clock. `sendMessage` is the one function in this repo that* reaches a system we cannot take an action back from; a test suite that could* only reach it through a real deploy would be a test suite that never ran it.*/function sendDeps(env: Env): SendDeps {return {fetchBody: (mailbox, id, cred) => fetchBody(mailbox, id, cred),sendMessage: (mailbox, cred, msg) => sendMessage(mailbox, cred, msg),credentialFor: (mailbox) => {const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)return conf ? credentialFor(env, conf) : { error: `unknown mailbox: ${mailbox}` }},now: () => Date.now(),}}/** A one-line refusal page in the board's own shell. Never a bare string. */const inboxNote = (req: Request,showOnboarding: boolean,title: string,message: string,): Response =>new Response(stampTheme(pageShell(`<section class="section"><div class="section-head"><h2>${title}</h2></div><div class="card" style="padding:22px 28px"><p class="empty">${message}</p><p class="note"><a href="/inbox">Back to the inbox</a></p></div></section>`,showOnboarding,),themeFrom(req),),{ status: 200, headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },)/*** How long after a manual sync before another is allowed.** A full sync measures about two minutes, most of it Gmail reads paced to stay* inside the per-user quota. The cooldown has to outlast a run, or a second* click lands mid-sync and the two compete for the same rate limits, which is* the thing it exists to prevent. 150 seconds covers a run with margin.*/const SYNC_COOLDOWN_MS = 150_000export interface SyncRequest {/** ISO, when the run was asked for. */at: string/** Who pressed it. Display only; every reader of the board may press it. */by: string/*** Set by the minute cron before it starts, so a request runs once. Two* invocations overlapping would each launch a full sync and compete for the* very rate limits the cooldown exists to protect.*/claimed?: boolean}async function readSyncRequest(env: Env): Promise<SyncRequest | null> {const raw = await env.DASHBOARD.get(KEY.syncRequest)if (!raw) return nulltry {const p = JSON.parse(raw) as SyncRequestreturn typeof p?.at === 'string' && typeof p.by === 'string' ? p : null} catch {return null}}/*** How stale a request may be and still be worth running.** A press from an hour ago is not a request now: the person has closed the tab* and the quarter-hour cron has run four times since. Running it then would be* a sync nobody asked for, arriving with no explanation.** Fifteen minutes rather than ten because KV propagation eats into it: a* request can take over three minutes to become visible to the minute cron.* The window has to outlast that delay by enough margin that a slow* propagation never silently drops a press.*/const SYNC_REQUEST_TTL_MS = 15 * 60_000/*** Run a sync somebody asked for, if one is pending. The Sync now button's other* half.** Why this is not in the route: a fetch handler's `ctx.waitUntil` does not get* the budget a scheduled one does, and a two-minute sync does not fit in it.* Run from the route, the sync is cut off silently: nothing is written, not* even `dashboard:payload`, and `sync:lastError` stays empty. The identical* sync() completes from a cron. So the button records the request and this* runs it where it can finish.*/export async function runRequestedSync(env: Env, now: number = Date.now()): Promise<boolean> {const req = await readSyncRequest(env)if (!req || req.claimed) return falseconst age = now - Date.parse(req.at)if (!Number.isFinite(age) || age < 0 || age > SYNC_REQUEST_TTL_MS) return false// Claim before running. This cron fires every minute and a sync takes two, so// without the claim the second minute would start a duplicate mid-run.await env.DASHBOARD.put(KEY.syncRequest, JSON.stringify({ ...req, claimed: true }))await sync(env, { writeHistory: false }).then(() => undefined).catch((err) =>env.DASHBOARD.put(KEY.lastError,JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),),)return true}export type SyncDecision =| { run: true; record: SyncRequest }| { run: false; sinceMs: number }/*** May this press start a sync?** A pure function, because the route it serves cannot be reached by a test: /sync* sits behind Cloudflare Access and the suite has no way to mint an assertion for* it. Same reason inbox-send.ts exists. The decision is the part worth pinning;* the route around it is four lines.** Fails open. A malformed or missing record means "go ahead": the cooldown* protects an upstream rate limit, and losing it costs one extra sync. Failing* closed would let one bad byte permanently disable the only manual control on* the board, which is a far worse outcome than a duplicated read.*/export function decideSync(prior: SyncRequest | null, by: string, now: number): SyncDecision {const record: SyncRequest = { at: new Date(now).toISOString(), by }if (!prior) return { run: true, record }const since = now - Date.parse(prior.at)// A future timestamp (clock skew, a hand-edited key) is not a reason to// refuse; it is a reason to distrust the record.if (!Number.isFinite(since) || since < 0 || since >= SYNC_COOLDOWN_MS) {return { run: true, record }}return { run: false, sinceMs: since }}/*** The line the masthead shows about a manual sync, or ''.** Stamped at serve time because the page in KV was rendered by a cron and* cannot know a sync started after it. Same mechanism as the theme stamp and* the notes injection.** It reports elapsed time rather than claiming completion. This route cannot* know whether the run finished (the minute cron runs it), and a "done" it did* not observe is exactly the kind of unobserved claim the board must never* make (rule 1). The reader sees the sync was asked for, and the Synced* timestamp beside it changes when it actually lands.*/export function syncStateHtml(req: SyncRequest | null, now: number): string {if (!req) return ''const age = now - Date.parse(req.at)if (!Number.isFinite(age) || age < 0) return ''// Past the cooldown it is just history; the Synced timestamp already says it.if (age > SYNC_COOLDOWN_MS) return ''const secs = Math.max(1, Math.round(age / 1000))// Both estimates are measured, not padded. A run takes about two minutes,// mostly the paced Gmail reads. Starting takes up to a few minutes because// KV is eventually consistent: a key written by the route is not// immediately visible to the minute cron, which can fire more than once// before it sees the request. A progress note that under-promises is a note// people stop believing.const started = req.claimedreturn `<span class="sync-state">Sync requested ${secs}s ago by ${esc(req.by)}: ${started? 'running now; another minute or two': 'queued; it starts within a few minutes and then takes a minute or two'}. Reload to see it land.</span>`}/** Replace the placeholder the masthead renders. No-op if it is absent. */export function stampSyncState(html: string, req: SyncRequest | null, now: number): string {return html.replace('<span id="sync-state"></span>', syncStateHtml(req, now))}async function readInboxData(env: Env): Promise<InboxData> {const raw = await env.DASHBOARD.get(KEY.inboxMessages)if (!raw) return { syncedAt: null, mailboxes: [] }try {return JSON.parse(raw) as InboxData} catch {return { syncedAt: null, mailboxes: [] }}}export default {async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {// Which cron fired decides what runs. The morning and 15-minute crons both// run the full sync: order statuses move all day, and a board showing this// morning's counts at 3pm is a board people stop trusting.//// Every source, Bitrix included, runs on the same 15 minutes. A slower// per-source cadence would oblige the page to date each source separately// ("as of 14:00" rather than "live"); one clock for every source is simpler// to trust. Revisiting that trade means taking on that labelling, not just// a code path.//// The difference between the two is history, not data. Only the morning// run records the day's row, so the day-over-day arrows keep comparing// mornings rather than quarter-hours. See sync().//// No model is called on either path. The written briefings are authored on// a laptop by analysis/brief.py and read from KV here, so refreshing the// numbers 96 times a day costs nothing in model spend.//// The minute cron is the Sync now button's runner, not a fourth sync. It// checks for a pending request and does nothing at all when there is none,// which is almost every minute. See runRequestedSync for why the button// cannot simply run the work itself.if (event.cron === '* * * * *') {ctx.waitUntil(runRequestedSync(env).catch((err) =>env.DASHBOARD.put(KEY.lastError,JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),),),)return}const morningRun = event.cron === '0 11 * * 1-5'ctx.waitUntil(sync(env, { writeHistory: morningRun }).then(() => undefined).catch((err) =>env.DASHBOARD.put(KEY.lastError,JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),),),)},/*** Every route answers within its own request. `_ctx` is unused: work that* outlives a response (the Sync now button's sync) runs from the minute cron* instead, because a fetch handler's waitUntil cannot hold it. See* runRequestedSync.*/async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {const url = new URL(req.url)// Liveness first, and deliberately identity-free: it carries a timestamp and// the names of failing sources, no client data, and an uptime monitor cannot// hold an Access session. Everything below this line requires identity.if (url.pathname === '/health') return health(env)// Who is asking: verified from the signed Access assertion, never from the// email header (see access.ts). A request that cannot be identified is// refused outright rather than being shown the company view: an unverifiable// request is either Access misconfigured or something going around it, and// neither should be answered with client names and order numbers.const identity = await identify(req,env.ACCESS_TEAM_DOMAIN,env.ACCESS_AUD,(why) => { if (env.DEBUG === 'true') console.log(`access denied: ${why}`) },)if (!identity) return denied()const viewer = viewerFor(identity.email)// The briefing payload names every person's to-dos, Leadership Team included,// so it is restricted to the readers who may see those, not to anyone who// got through Access.if (url.pathname === '/payload.json') {if (!viewer.seesLeadership) return denied()const payload = await env.DASHBOARD.get(KEY.payload)return payload? new Response(payload, { headers: { 'content-type': 'application/json; charset=utf-8' } }): new Response('{"error":"not synced yet"}', { status: 503, headers: { 'content-type': 'application/json' } })}// Sync now: re-reads all five sources and the seven mailboxes on demand.//// No model is called, and that needs no flag: sync() never calls one. The// written briefings are authored by analysis/brief.py on a laptop and only// ever read from KV here, so "sync without the AI" is simply what a sync is.//// writeHistory:false is load-bearing. Only the weekday-morning cron records// the day's history row, so the day-over-day arrows keep comparing mornings// (see sync()). A button that wrote history would let any click silently// redefine what "since yesterday" means, and nothing on the page would show// that it had happened.//// Anyone through the Access door may press it: the person who notices// stale data is rarely the one who administers the board. The cooldown, not// a permission, is what protects the upstream rate limits.if (url.pathname === '/sync' && req.method === 'POST') {const now = Date.now()const decision = decideSync(await readSyncRequest(env),viewer.person?.name ?? viewer.email ?? 'someone',now,)// Records the request and does not run it; the minute cron picks it up.// A sync started here with ctx.waitUntil would be cut off silently (see// runRequestedSync).if (decision.run) {await env.DASHBOARD.put(KEY.syncRequest, JSON.stringify(decision.record))}// 303 with a cache-buster: the board is served `private, max-age=60`, so// without a throwaway param the reload hands back the cached page and the// button looks like it did nothing. Same reason the note form does it.const backRaw = String(new URL(req.url).searchParams.get('back') ?? '')const hash = NOTE_BACK_TABS.has(backRaw) ? `#${backRaw}` : ''return new Response(null, {status: 303,headers: { location: `/?t=${now.toString(36)}${hash}` },})}// Theme preference: a cookie, set server-side so it works with JS off and// is readable at serve time. POST-303, same shape as every action here.if (url.pathname === '/theme' && req.method === 'POST') {const form = await req.formData()const v = String(form.get('theme') ?? 'system')const value = v === 'dark' || v === 'light' ? v : 'system'const back = req.headers.get('referer')// Same-origin only: a Location echoed from headers is an open redirect.const to = back && new URL(back, url.origin).origin === url.origin ? new URL(back).pathname : '/'return new Response(null, {status: 303,headers: {location: to,'set-cookie': `theme=${value}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`,},})}// User notes on orders and tasks. Anyone through the Access door may write// one: the note is signed with who wrote it, and the whole point is that// leadership reads context anybody on the team can leave. Empty text// removes the note: the same control that writes a mark can unwrite it.if (url.pathname === '/note' && req.method === 'POST') {const form = await req.formData()const key = String(form.get('key') ?? '').trim()const text = String(form.get('text') ?? '').trim().slice(0, NOTE_TEXT_CAP)const by = viewer.person?.name ?? viewer.emailif (WORK_KEY_RE.test(key) && by) {const notes = await readNotes(env)if (text) notes[key] = { text, by, at: new Date().toISOString() }else delete notes[key]await env.DASHBOARD.put(KEY.notes, JSON.stringify(notes))}const backRaw = String(form.get('back') ?? '').trim()const hash = NOTE_BACK_TABS.has(backRaw) ? backRaw : 'queue'// The board page is cached private for 60s; the throwaway query param// makes the post-save load a cache miss so the note is visible at once.return new Response(null, {status: 303,headers: { location: `/?t=${Date.now().toString(36)}#${hash}` },})}// The end-of-day note: three boxes, one person, once a day.//// Who may post is not who may read, and neither list is `seesLeadership`.// `checkinAuthorFor` returns the roster name the row is filed under, or// null, and null is a refusal, not a company-view fallback. Without that,// anyone through the Access door could write a note under somebody else's// name, because the name comes from the address and never from the form.//// Nothing is trusted from the body except the three answers. The person is// the verified identity, the day is this Worker's clock, and a form// claiming a different day is refused rather than written somewhere else.// See decideCheckin.if (url.pathname === '/checkin' && req.method === 'POST') {const person = checkinAuthorFor(viewer)if (!person) return denied()const form = await req.formData()const result = await saveCheckin(env, {person,// Non-null in practice: the grant is keyed by this address. Recorded// beside the roster name so the two can be compared later.authorEmail: viewer.email ?? '',claimedDay: String(form.get('day') ?? '').trim(),did: String(form.get('did') ?? ''),unsure: String(form.get('unsure') ?? ''),need: String(form.get('need') ?? ''),})// 303 so a refresh cannot repost, and so the form works with scripting// off. `t` busts the 60-second private cache; without it the reload// hands back the page from before the write and the button looks dead,// the same reason the note and sync buttons carry one. The outcome rides// in the query string and is re-validated on the way back in.return new Response(null, {status: 303,headers: {location: `/?t=${Date.now().toString(36)}&checkin=${result.outcome}#onboarding`,},})}/*** The private list (rule 15).** One path with an `action` field, not four routes. Every action shares the* same identity gate, the same `AND person = ?` scoping and the same stale* check, and four paths would be four places to forget one of them. The* inbox filter route already branches this way.** The person is re-derived from the viewer and never read from the form.* There is no grant list guarding this table (config/own-list.ts says why* the grant-list rule does not reach a store with one reader), so this line* and the scoping clause in the SQL are the whole of the access control.** No nonce and no confirmation page, deliberately. The nonce-and-confirm* bar is for an irreversible outbound action, where the cost lands on a* client and cannot be recalled; /inbox/send is the one thing here* that clears it. Nothing on this list leaves the building and nothing here* is irreversible: completing is undone by reopening, from the same card,* in one click. The defence against a cross-site post is the one the rest* of the board uses: Access, CSP form-action 'self', SameSite=Lax.*/if (url.pathname === '/own' && req.method === 'POST') {const person = ownListPersonFor(viewer)if (!person || !hasOwnList(viewer)) return denied()const form = await req.formData()const result = await saveOwn(env, {person,// Non-null in practice: a roster match is what produced `person`.// Recorded beside the name so the two can be compared later.authorEmail: viewer.email ?? '',action: String(form.get('action') ?? ''),id: String(form.get('id') ?? ''),seen: String(form.get('seen') ?? ''),text: String(form.get('text') ?? ''),due: String(form.get('due') ?? ''),priority: String(form.get('priority') ?? ''),link: String(form.get('link') ?? ''),})// 303 so a refresh cannot repost and the form works with scripting off.// `t` busts the 60-second private cache; without it the reload hands// back the page from before the write and the button looks dead. The// fragment is a literal from the allow-list, never echoed from the body.return new Response(null, {status: 303,headers: {location: `/?t=${Date.now().toString(36)}&own=${result.outcome}#my-day`,},})}/*** The training recap: a few hand-written bullets on what a new hire has* been trained on, rendered from config/onboarding.ts.** Gated on seeing the tab, not on reading the end-of-day notes. The recap* prose and the Accomplished line are sentences in config/onboarding.ts, a* reviewed file that diffs and deploys, written for exactly this audience.* The trainee's own words live in D1 behind CHECKIN_READERS, a shorter list* than this page's, and this module cannot see them: it imports* config/onboarding.js and nothing else, and a test asserts it.** The audience includes the trainee, deliberately: the recap says nothing* they would not already hear in person.** A viewer with no plan gets the same refusal as any other surface they* cannot see, not an empty document: a recap of nothing reads as "nothing* has been covered".*/if (url.pathname === '/onboarding/export' && req.method === 'GET') {if (!hasOnboarding(viewer)) return denied()const plans = onboardingFor(viewer)const raw = url.searchParams.get('days')const win = parseWindow(raw, exportToday())// A retired parameter, acknowledged on the page rather than ignored,// because links carrying it exist.const staleDetail = url.searchParams.has('detail')// The plain-text twin, for somebody who would rather have the file than// the page. Served as text/plain and inline. It is three lines of prose// to copy, not a download, and a browser that offers to save it instead// of showing it has made the one useful thing harder.if ((url.searchParams.get('format') ?? '') === 'txt') {return new Response(recapTextAll(plans, win), {headers: {'content-type': 'text/plain; charset=utf-8','content-disposition': 'inline','cache-control': 'private, no-store','x-content-type-options': 'nosniff',},})}const current = win.days === null ? 'all' : String(win.days)return new Response(stampTheme(pageShell(recapPage(plans, win, current, staleDetail), true, 'Training recap', 'none'),themeFrom(req),),{ headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// The consolidated inbox. Other people's mail, so the permission check is// the first thing here and it fails closed: `canReadInbox` reads// INBOX_READERS in config/inbox.ts, and a viewer not on it gets the same// denial as /payload.json. An access list guessed on someone's behalf is the// default that gets asserted and forgotten (rule 13), and here the cost of// guessing wrong is a colleague's mail.if (url.pathname === '/inbox' || url.pathname.startsWith('/inbox/')) {if (!canReadInbox(viewer)) return denied()const readable = readableMailboxes(viewer)// Every page below carries the board's tab bar, and the bar's Onboarding// entry is this viewer's to have or not, decided once, here.const shell = (body: string): string => pageShell(body, hasOnboarding(viewer))if (url.pathname === '/inbox' && req.method === 'GET') {// The recipient filter. Validated against this viewer's grants, so a// pasted URL cannot select a mailbox the viewer may not read; an// unknown or ungranted value quietly means "all", never an error page.const rawSel = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const selected = readable.includes(rawSel) ? rawSel : nullconst [data, filters, marks, replied] = await Promise.all([readInboxData(env),readFilters(env),readReadMarks(env),readReplied(env as unknown as SendEnv),])return new Response(stampTheme(shell(inboxPage(data,filters,INBOX_MAILBOXES,readable,viewer.person?.name ?? viewer.email ?? 'unknown',selected,new Set(Object.keys(marks)),// The replied marks are read from the board's own key, not// derived from the message list, which is exactly why they// survive the 15-minute rewrite of inbox:messages.new Set(Object.keys(replied)),// The New message button, only for someone who may send from// something. Same list the Reply button consults.sendableMailboxes(viewer).length > 0,),),themeFrom(req),),{ headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// Live search: the one read that goes to Gmail on the request path.//// The 30-day window runs to several thousand messages across the seven// mailboxes, far more than the board holds, and nobody finds an email by// scrolling 2,400 rows. So the board keeps a recent slice for browsing// and search reaches everything.//// It costs a token refresh, a list and a batch per mailbox, on a request// somebody is waiting for, so `countWindow: false` stops the id walk as// soon as the cap is met rather than paging to the end to produce a total// nobody asked for.if (url.pathname === '/inbox/search' && req.method === 'GET') {const raw = (url.searchParams.get('q') ?? '').trim()const rawBox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()// Same rule as the list view: a pasted mailbox this viewer may not read// quietly means "all of mine", never an error and never that mailbox.const scopedTo = readable.includes(rawBox) ? rawBox : nullif (!raw) return new Response(null, { status: 303, headers: { location: '/inbox' } })const targets = INBOX_MAILBOXES.filter((m) =>scopedTo ? m.email === scopedTo : readable.includes(m.email),)const query = buildSearchQuery(raw)const hits: SearchHit[] = await Promise.all(targets.map(async (m): Promise<SearchHit> => {const cred = credentialFor(env, m)if ('error' in cred) {return { mailbox: m.email, ok: false, error: cred.error, messages: [], truncated: false }}const r = await readMailbox(m.email, cred, {query,maxMessages: SEARCH_RESULT_CAP,countWindow: false,})return {mailbox: m.email,ok: r.ok,error: r.error,messages: r.messages,truncated: r.truncated,}}),)return new Response(stampTheme(shell(searchResultsPage(raw, hits, INBOX_MAILBOXES, scopedTo)), themeFrom(req)),// no-store: these rows are message metadata from someone's mailbox and// must not sit in any cache, shared or not, same rule as the list.{ headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// Read one message. Fetched from Gmail on the click, never from KV; the// board stores metadata and does not accumulate seven people's mail. Costs// a token refresh plus one Gmail call, for the message actually opened.if (url.pathname === '/inbox/message' && req.method === 'GET') {const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const id = (url.searchParams.get('id') ?? '').trim()// The mailbox must be one this viewer may read. Without this check the id// in the query string is enough to read any mailbox the Worker holds a// token for, which would make the per-mailbox grants decorative.if (!mailbox || !id || !readable.includes(mailbox)) return denied()const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }const [body, marks, data, sigs, replied] = await Promise.all(['error' in cred ? Promise.resolve({ error: cred.error }) : fetchBody(mailbox, id, cred),readReadMarks(env),readInboxData(env),readSignatures(env as unknown as SendEnv),readReplied(env as unknown as SendEnv),])return new Response(stampTheme(shell(messagePage(body, conf?.label ?? mailbox, `${mailbox}/${id}` in marks, {states: sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs),// A fresh nonce per render. Two Reply clicks are two replies;// a refresh of the same compose form is one, and the send// route refuses the second.nonce: crypto.randomUUID(),sigs,replied: replied[`${mailbox}/${id}`] ?? null,}),),themeFrom(req),),{// no-store: a message body must not sit in any cache, shared or not.// MESSAGE_PAGE_HEADERS: the one CSP that permits the same-origin frame.headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' },},)}// The frame: the only response a sender's HTML ever rides in. Same access// checks as the page that embeds it: a direct hit on this URL is just a// narrower view of the same message the viewer may already read.if (url.pathname === '/inbox/message/frame' && req.method === 'GET') {const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const id = (url.searchParams.get('id') ?? '').trim()if (!mailbox || !id || !readable.includes(mailbox)) return denied()const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }const body = 'error' in cred ? { error: cred.error } : await fetchBody(mailbox, id, cred)if ('error' in body || !body.html) {// The parent page already rendered the text fallback or the error;// an empty frame document keeps this route from ever inventing one.return new Response('<!doctype html><meta charset="utf-8">', { headers: FRAME_HEADERS })}// <base target="_blank"> so the email's links escape the sandboxed frame// into a new tab instead of dying inside it. Prepended, not injected into// the sender's markup; the parser hoists it into <head> regardless.return new Response(`<!doctype html><meta charset="utf-8"><base target="_blank">${body.html}`, {headers: FRAME_HEADERS,})}// ---------------------------------------------------------------------// Sending: the one place this board writes to a system outside itself// (rule 12).//// Every route below checks the send grant (canSendFrom, directly or// through sendableMailboxes) and never readableMailboxes():// the whole roster may read every mailbox, and none of that is permission// to write to a client as somebody else. Refusals are 403 through the// same denied() as everything else. The page never renders a control the// viewer cannot use, so a 403 here means a hand-built request.// ---------------------------------------------------------------------// A new message: the compose box. Gated on the send grant and nothing// else: reading seven mailboxes is not permission to write to// a stranger as one of them. There is no message to read here, so this is// the one outbound surface that touches no original at all.if (url.pathname === '/inbox/new' && req.method === 'GET') {const sendable = sendableMailboxes(viewer)if (!sendable.length) return denied()const [data, sigs] = await Promise.all([readInboxData(env),readSignatures(env as unknown as SendEnv),])return new Response(stampTheme(shell(newMessagePage(sendStates(data, INBOX_MAILBOXES, sendable, sigs),crypto.randomUUID(),sigs,// Their own mailbox preselected when they may send from it,// the same default the reply form uses.viewer.email && sendable.includes(viewer.email) ? viewer.email : null,),),themeFrom(req),),// MESSAGE_PAGE_HEADERS: the form frames the signature preview.{ headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// An attachment, straight from Gmail to the browser.//// The read grant on that mailbox is checked exactly as /inbox/message// checks it: without it the id in a query string would be enough to pull// a file out of any mailbox the Worker holds a token for.//// Addressed by index, not by Gmail's attachment id. The message is// re-read and re-walked here, so the id used to fetch is one this request// observed rather than one a URL carried; a URL-supplied attachment id// would let a request name a part of a message it never opened.//// Always a download, never a render. The content type is discarded and// replaced with application/octet-stream, with `Content-Disposition:// attachment` and nosniff on top. An attachment is arbitrary bytes from// whoever emailed us; served with its declared type on the board's own// origin, an HTML or SVG one would run as this board. That is the same// threat the message frame exists to contain (see body.ts), and this is// the cheaper answer to it: nothing here is ever a document.if (url.pathname === '/inbox/attachment' && req.method === 'GET') {const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const id = (url.searchParams.get('id') ?? '').trim()const n = Number(url.searchParams.get('n') ?? '')if (!mailbox || !id || !readable.includes(mailbox)) return denied()if (!Number.isInteger(n) || n < 0) return denied()const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }if ('error' in cred) return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(cred.error))const body = await fetchBody(mailbox, id, cred)if ('error' in body) {return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(body.error))}const att = body.attachments[n]if (!att) {return inboxNote(req,hasOnboarding(viewer),'That attachment is not on this message',`The message has ${body.attachments.length} ${body.attachments.length === 1 ? 'part' : 'parts'} with a filename, and this link asked for number ${n + 1}. Nothing was downloaded.`,)}if (!att.attachmentId) {return inboxNote(req,hasOnboarding(viewer),'Gmail returned no handle for that part','There is nothing to fetch with, so it cannot be downloaded here. The message itself is in Gmail.',)}if (att.size !== null && att.size > ATTACHMENT_DOWNLOAD_CAP) {return inboxNote(req,hasOnboarding(viewer),'That attachment is too big for this board',`It is ${att.size.toLocaleString()} bytes, over the ${ATTACHMENT_DOWNLOAD_CAP.toLocaleString()}-byte ceiling this board can hold in memory. Nothing was downloaded; open it in Gmail.`,)}const got = await fetchAttachment(mailbox, id, att.attachmentId, cred)if ('error' in got) {return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(got.error))}return new Response(got.bytes, {headers: {'content-type': 'application/octet-stream',// mimeParam strips CR, LF, quote and backslash, because the filename came// from whoever sent the mail, and it is going into a header.'content-disposition': `attachment; filename="${mimeParam(att.name || 'attachment')}"`,'content-length': String(got.bytes.byteLength),'content-security-policy': "default-src 'none'; sandbox",'x-content-type-options': 'nosniff','referrer-policy': 'no-referrer','x-robots-tag': 'noindex, nofollow','cache-control': 'private, no-store',},})}// The compose form on its own page: the no-JavaScript path, and where// the Reply link points before the page's script upgrades it to a modal.if (url.pathname === '/inbox/compose' && req.method === 'GET') {const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const id = (url.searchParams.get('id') ?? '').trim()if (!mailbox || !id || !readable.includes(mailbox)) return denied()const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }const [body, data, sigs] = await Promise.all(['error' in cred ? Promise.resolve({ error: cred.error }) : fetchBody(mailbox, id, cred),readInboxData(env),readSignatures(env as unknown as SendEnv),])return new Response(stampTheme(shell(composePage(body,conf?.label ?? mailbox,sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs),crypto.randomUUID(),sigs,),),themeFrom(req),),// MESSAGE_PAGE_HEADERS: the form frames the signature preview.{ headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// The confirmation page. Sends nothing and writes nothing; it re-reads// the original and shows what the send would actually do. Not a 303,// because a preview is not an action: refreshing re-renders it.if (url.pathname === '/inbox/compose' && req.method === 'POST') {const form = await req.formData()const out = await handleCompose(viewer, env as unknown as SendEnv, form, readable, sendDeps(env))if (out.kind === 'denied') return denied()if (out.kind === 'error') {return inboxNote(req, hasOnboarding(viewer), 'Nothing was sent', esc(out.message))}if (out.kind === 'redirect') {return new Response(null, { status: 303, headers: { location: out.location } })}const [data, sigs] = await Promise.all([readInboxData(env),readSignatures(env as unknown as SendEnv),])const states = sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs)const st = states.find((x) => x.email === out.outgoing.from)if (!st) return denied()return new Response(stampTheme(shell(confirmPage(out.outgoing, out.orig, st, out.nonce, sigs, out.staged)),themeFrom(req),),{ headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// The send. Irreversible, so: the nonce is claimed first, the record is// written before Gmail is called, and the answer is a 303 so a refresh// reloads the outcome page rather than reposting.if (url.pathname === '/inbox/send' && req.method === 'POST') {const form = await req.formData()const out = await handleSend(viewer, env as unknown as SendEnv, form, readable, sendDeps(env))if (out.kind === 'denied') return denied()if (out.kind === 'error') return inboxNote(req, hasOnboarding(viewer), 'Nothing was sent', esc(out.message))if (out.kind === 'confirm') return denied()return new Response(null, { status: 303, headers: { location: out.location } })}// What this board has sent. Readable by anyone who may read the inbox;// the point of the record is that a shared mailbox can see who answered.if (url.pathname === '/inbox/sent' && req.method === 'GET') {const refNonce = (url.searchParams.get('ref') ?? '').trim()const [listed, ref] = await Promise.all([listSent(env as unknown as SendEnv),refNonce ? readRecord(env as unknown as SendEnv, refNonce) : Promise.resolve(null),])return new Response(stampTheme(shell(sentPage(listed.rows, listed.truncated, ref, url.searchParams.get('dup') === '1'),),themeFrom(req),),{ headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}// Signatures. Read by anyone with the inbox; written only by someone who// may send as that mailbox; a signature is what goes out over its name.if (url.pathname === '/inbox/signatures' && req.method === 'GET') {const sigs = await readSignatures(env as unknown as SendEnv)const rejectedBox = (url.searchParams.get('rejected') ?? '').trim().toLowerCase()const over = Number(url.searchParams.get('over') ?? '')return new Response(stampTheme(shell(signaturesPage(INBOX_MAILBOXES,sigs,sendableMailboxes(viewer),rejectedBox && Number.isFinite(over) && over > 0? { mailbox: rejectedBox, over }: null,),),themeFrom(req),),// The page frames each signature preview.{ headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },)}if (url.pathname === '/inbox/signatures' && req.method === 'POST') {const form = await req.formData()const out = await handleSignaturesPost(viewer,env as unknown as SendEnv,form,() => Date.now(),)if (out.kind === 'denied') return denied()return new Response(null, { status: 303, headers: { location: out.location } })}// The signature frame: the only response a signature's HTML rides in,// exactly as /inbox/message/frame is for a sender's. Same headers, same// reasoning: it is user-written markup and must never reach a board page.if (url.pathname === '/inbox/signatures/frame' && req.method === 'GET') {const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()const sigs = await readSignatures(env as unknown as SendEnv)const html = sigs.ok ? sigs.map[mailbox]?.html : undefinedif (!INBOX_MAILBOXES.some((m) => m.email === mailbox) || !html) {return new Response('<!doctype html><meta charset="utf-8">', { headers: FRAME_HEADERS })}return new Response(`<!doctype html><meta charset="utf-8"><base target="_blank">${html}`, {headers: FRAME_HEADERS,})}// Mark read / unread: writes the board's own inbox:read key, never Gmail,// so the tokens stay read-only. Same shape as the filter endpoints: the// message must exist in a mailbox this viewer may read, or (for unread)// already carry a mark to remove.if (url.pathname === '/inbox/read' && req.method === 'POST') {const form = await req.formData()const mailbox = String(form.get('mailbox') ?? '').trim().toLowerCase()const id = String(form.get('id') ?? '').trim()const op = String(form.get('op') ?? '')const key = `${mailbox}/${id}`if (mailbox && id && (op === 'read' || op === 'unread') && readable.includes(mailbox)) {const [data, marks] = await Promise.all([readInboxData(env), readReadMarks(env)])const known = data.mailboxes.find((m) => m.mailbox === mailbox)?.messages.some((x) => x.id === id)if (op === 'read' ? known : key in marks) {const now = Date.now()const next = Object.fromEntries(Object.entries(marks).filter(([k, at]) => k !== key && now - at < READ_MARK_MAX_AGE_MS),)if (op === 'read') next[key] = nowawait env.DASHBOARD.put(KEY.inboxRead, JSON.stringify(next))}}// 303 like the filter buttons: refresh-safe, works with JavaScript off,// and only a mailbox this viewer may read is echoed into the redirect.const backRaw = String(form.get('back') ?? '').trim().toLowerCase()const back = readable.includes(backRaw) ? `/inbox?mailbox=${encodeURIComponent(backRaw)}` : '/inbox'return new Response(null, { status: 303, headers: { location: back } })}const action =url.pathname === '/inbox/filter' ? 'add' : url.pathname === '/inbox/unfilter' ? 'remove' : nullif (action && req.method === 'POST') {const form = await req.formData()const sender = String(form.get('sender') ?? '').trim().toLowerCase()// The sender must be one we actually saw, in a mailbox this viewer may// read. Without that check this endpoint writes arbitrary strings into// KV on behalf of anyone through the Access door, and lets a reader act// on a mailbox they cannot see.const data = await readInboxData(env)const known = new Set(data.mailboxes.filter((m) => readable.includes(m.mailbox)).flatMap((m) => m.messages.map((x) => x.sender)),)const filters = await readFilters(env)// An operational sender cannot be filtered: shipment tracking, failed// payments and meeting notices (see KNOWN_NOT_MARKETING). matchesFilter// already refuses to hide them, so a stray entry would be inert; refusing// the write too means the list never contains a lie about what is hidden.if (action === 'add' && isOperational(sender)) {const opBack = String(form.get('mailbox') ?? '').trim().toLowerCase()return new Response(null, {status: 303,headers: { location: readable.includes(opBack) ? `/inbox?mailbox=${encodeURIComponent(opBack)}` : '/inbox' },})}if (sender && (known.has(sender) || (action === 'remove' && filters.includes(sender)))) {const next =action === 'add'? [...new Set([...filters, sender])].sort(): filters.filter((f) => f !== sender)await env.DASHBOARD.put(KEY.inboxFilters, JSON.stringify(next))}// 303 so a refresh does not repost, and so the button works with no// JavaScript at all. The redirect returns to the same mailbox view the// click came from, but only a mailbox this viewer may read is echoed// into the location header; anything else falls back to /inbox. A// redirect built from unvalidated form input is an open redirect.const backRaw = String(form.get('mailbox') ?? '').trim().toLowerCase()const back = readable.includes(backRaw) ? `/inbox?mailbox=${encodeURIComponent(backRaw)}` : '/inbox'return new Response(null, { status: 303, headers: { location: back } })}return new Response('Not found', { status: 404, headers: PAGE_HEADERS })}// This viewer's page, and no fallback to a richer one. If their variant is// missing the answer is "not synced yet", never the company page dressed up// as theirs and never somebody else's. A fallback that quietly widened access// would be indistinguishable from working.//// Whose end-of-day notes this viewer may read: usually nobody, and then D1// is not touched at all. The query runs only for the readers and the// author; everyone else's page costs no D1 read.const checkinPeople = readableCheckinAuthors(viewer)// The viewer's own private list, and the name comes from the roster via the// verified identity, never from the query string, the form or a header.// There is no plural read here on purpose (see apps/worker/src/own-list.ts).const ownPerson = ownListPersonFor(viewer)const [html, notes, syncReq, checkins, own] = await Promise.all([env.DASHBOARD.get(variantKey(viewer.key)),readNotes(env),readSyncRequest(env),checkinPeople.length? readCheckinsFor(env, checkinPeople): Promise.resolve({} as Record<string, CheckinStore>),ownPerson ? readOwnList(env, ownPerson) : Promise.resolve(null),])// Stamped at serve time, like the theme, the notes and the sync line. The// page in KV was rendered by a cron that could not know which day it is// when somebody opens it, let alone what was written this evening. The// placeholder is only in the markup for a viewer with a grant, and this// call re-checks the grant anyway: a page with no markers is returned// untouched, and a viewer with no grant stamps an empty string over them.const block = checkinPeople.length? checkinBlock(viewer, checkins, new Date(), parseOutcome(url.searchParams.get('checkin'))): ''// Also stamped at serve time. Two reasons it cannot be pre-rendered, and// the second is the one that matters: a line added a minute ago must show// on the next load, and no private text may ever be written into KV. What// sits in KV is the placeholder.const ownBlock = own? ownListBlock(viewer,own,new Date(),parseOwnOutcome(url.searchParams.get('own')),// Whether "on my board" above this card is a dash rather than a// number, so the card says the right sentence about what it is not// counted in. The same expression peopleBoard() in collect.ts uses// for `untracked`, which is what makes `load` null in the first// place. It is read from the roster here because the Worker has the// viewer and not the view model.!viewer.person || viewer.person.coveredBy.length === 0,): ''return new Response(html? stampOwnList(stampCheckin(stampSyncState(injectNotes(stampTheme(html, themeFrom(req)), notes), syncReq, Date.now()),block,),ownBlock,): NOT_SYNCED_YET,{status: html ? 200 : 503,headers: {...PAGE_HEADERS,// The page is a snapshot, not live. A short cache keeps a team all// opening it at 9:10 off the KV read path. Private, so no shared cache// can hold one person's variant and hand it to another.'cache-control': 'private, max-age=60',},},)},}