בס״ד

Server-side scoping for Mildred (and Chanie) — the #1 build before ANY sharing

docs/MILDRED_SERVER_SCOPING.md · last changed (pre-VM history) · rendered from GitHub master

Server-side scoping for Mildred (and Chanie) — the #1 build before ANY sharing

Locked 2026-06-03. Gates sharing the Mildred link. Above color + holdings.

✅ GATE SHIPPED 2026-07-01 ~7:35 PM ET (Fable session, via CF API with Sam's one-time Access:Edit token)

Prior finding (2026-07-01 afternoon, now FIXED — kept for history): the gate was OPEN

Pulled the actual Access config for ops.hookstreetservices.com (app 17a8e9c0-580b-4a1c-b937-e472de06e474, "Hook Street Ops" — covers the WHOLE host, no path scoping):
- Policy 1 "Sam only" → sam@hookstreetcapital.com ✓
- Policy 2 "Family" → chanietreitel@gmail.com AND mildred@hookstreetcapital.com ✗✗

That means Mildred's login TODAY reaches every portal path — /home.html, /hs-core.js (master key), /operating, /cameras, everything. Exactly the "one unscoped click" this doc forbids. She hasn't been told to log in (that's the only thing holding), but the lock is not on.

The fix (Sam approval needed — Access changes are his call; staged 2026-07-01):
1. CREATE a scoped Access app "Mildred — scoped board only", self_hosted_domains = /mildred.html, /mildred, /work, /mildred-briefings.json, + the 5 briefing files currently in mildred-briefings.json. Policy: allow sam@ + mildred@. (Session 24h, app-launcher hidden.)
2. EDIT the host-wide app's "Family" policy (c1a12fcd-ee40-4d5b-b50a-b8097164b89d): REMOVE mildred@, keep Chanie.
3. VERIFY as a Mildred-policy identity: /hs-core.js and /home.html → blocked; /work → loads. Then, and only then, her link is shareable.

Notes: CF Access matches the most-specific app per path, so the scoped app wins on her paths and the host app (then Sam+Chanie only) covers the rest. ⚠️ Ongoing constraint: every NEW briefing shared to her needs its path ADDED to the scoped app (or move shared briefings under a /m/ prefix later — the cleaner long-term shape this doc already proposes). Chanie's own exposure (she also has host-wide reach incl. hs-core.js) is a separate, lower-urgency decision — she's family, but the same path-scoping pattern applies if wanted.

The problem (verified, not theoretical)

Do NOT enable Mildred's login / share ?as=mildred until the data is withheld server-side. Interim: home.html shows a sticky "PREVIEW — not private — do not share" banner on any ?as= view, and Mildred scope is now default-deny (mildredAllowed===true only, never a lane heuristic — a kids-school card proved lane ≠ safe).

🔴 HARD BLOCKER added 2026-06-03 — mildred.html now carries the MASTER key

To keep mildred.html working after the read-gate deploy, the full-access x-ops-key
(opskey_da23b82…) was put into mildred.html's source. That's fine while it's
Sam-only preview, but it means her page holds a master key to the entire queue
the client-side filter is trivially bypassable by anyone who can view that page's source.
- DO NOT share mildred.html / ?as=mildred with the real Mildred until Phase 1
gives her a separate, restricted credential (a scoped token or endpoint that
returns ONLY her cards and never exposes the master OPS_READ_TOKEN).
- Phase 1 must mint a distinct MILDRED_READ_TOKEN (or use the CF Access JWT, Phase 2)
that maps server-side to the Mildred scope. The master key must never live in a page
any non-Sam person can open.

✅ PHASE 1 SHIPPED (2026-06-03) — Mildred's page no longer holds the master key

⚠️ Referer-gating is SOFT (logged 2026-06-03, not a blocker)

?scope=family / ?scope=business are gated by the Referer header (like /camera/snapshot). A Referer is trivially spoofable — anyone who knows the URL + sends a fake referer can read those scopes. They stop drive-by/anonymous access but are not private against a determined actor. For family logistics + Airbnb bookings on an un-authed kiosk that's an acceptable trade — but do not treat scope=family/business as truly private. The real lock, if ever wanted, is a per-page secret token (like Mildred's MILDRED_READ_TOKEN), not referer.

🚨 CF ACCESS PATH-SCOPING — THE GATE before sharing /work (locked 2026-06-03)

The app-layer is solved (/work uses its own restricted key, default-deny, no master key
in the page — verified). But the portal PAGES are protected only by CF Access, and the
Sam pages embed the master key
: home.html, operating-map.html, rethink.html,
triage.html, list.html, and hs-core.js all carry opskey_…. So adding Mildred's
email to the portal Access policy WITHOUT path-scoping lets her load any Sam page or just
GET /hs-core.js
and lift the master key → every sensitive surface. One unscoped click
undoes all the privacy work. mildred.html itself is clean (self-contained, no hs-core.js,
0 key) — the risk is her access to other paths, which ONLY CF Access controls.

Hard requirement

Mildred's email may reach /work (+ its own key-free assets) ONLY — never /, /home,
/operating, /rethink, /triage, /list, /cameras, /hs-core.js, or any Sam page/asset.

Recommended (bulletproof, fat-finger-proof): collapse Mildred under ONE path prefix

Move her page + its only portal asset under /m/ so a single Access app covers everything she
needs and nothing else:
- /m/ → her page (mildred.html), /m/briefings.json → her curated briefings. (/work 200-rewrites to /m/.)
- Then one Access app, path /m → Sam + Mildred. Everything else stays the Sam-only app.
- No key-bearing asset lives under /m/, so there is no path from her grant to the master key.
(I can do this restructure on request — it's the cleanest enabler. Alternative: a separate
subdomain e.g. work.hookstreetservices.com serving only her page, its own Access app.)

Exact CF click-path (Cloudflare Zero Trust dashboard)

  1. Zero Trust → Access → Applications.
  2. Open the existing ops.hookstreetservices.com app → confirm its policy allows Sam only
    (+ Chanie on the family paths if separate). Mildred must NOT be in this policy.
  3. Add an application → Self-hosted. Domain ops.hookstreetservices.com, Path: m
    (or work + a second app for the assets if not using /m/).
  4. Policy: Allow → emails = Sam + Mildred. Save.
  5. CF matches most-specific path: /m/* → Sam+Mildred; everything else → Sam-only.

Verification BEFORE sharing (do not skip)

As Mildred (or a test email in her policy only), confirm ALL of these are blocked / 403:
/, /operating, /home.html, /hs-core.js, /rethink, /cameras — and only /work
(or /m/) loads. If /hs-core.js returns 200 for her, STOP — the key is exposed.

🔒 HARD RULE

Do NOT send Mildred the /work link until the above is configured AND the /hs-core.js
block is verified for her.
App-layer scoping is done; this CF gate is the last lock.

The fix — two phases

Phase 1 — server-side scoped reads (necessary; code-only + deploys)

The data owner (the Apps Script) must filter BEFORE returning, so sensitive cards never leave the server.
1. Apps Script (command-inbox/start-here.gs): add QUEUE_MILDRED (and QUEUE_CHANIE) commands that return ONLY allowed cards — Mildred = mildredAllowed===true && !sensitive (default-deny); Chanie = household lanes, !sensitive. Same JSON shape as QUEUE_JSON.
2. Worker (ops-api/src/index.ts): add QUEUE_MILDRED/QUEUE_CHANIE to SAFE_COMMAND_PREFIXES (line 68).
3. home.html: ?as=mildredPERSONA.cmd='QUEUE_MILDRED'; ?as=chanieQUEUE_CHANIE. Drop client-side cardOk (now redundant — keep as belt-and-suspenders).
4. Deploys (need Sam): clasp push + UI New Version (Apps Script); npx wrangler deploy (Worker).

After Phase 1, Mildred's page never receives more than her scope. But the full QUEUE_JSON is still open, so a determined authed Mildred could request it directly → Phase 2.

Phase 2 — identity-gate the full queue (sufficient; needs CF config)

Make the unfiltered QUEUE_JSON require the caller's identity.
- The Worker already has CF Access ES256 JWT validation (index.ts ~lines 201–243) — it's half-built for exactly this.
- Put the Worker behind the same CF Access app via a custom-domain route (e.g. ops.hookstreetservices.com/api/* instead of *.workers.dev) so requests carry Cf-Access-Jwt-Assertion automatically. The Worker validates it, maps email → scope, and returns only that identity's allowed cards even for QUEUE_JSON. → Sam configures the route + Access app in the CF dashboard.
- Cheaper-but-weaker alt (NOT recommended): gate QUEUE_JSON behind OPS_READ_TOKEN. Rejected — the token would have to live in home.html's source, which any Access-authed user (incl. Mildred) can read. Not real isolation.

Bottom line

Source trail · docs/MILDRED_SERVER_SCOPING.md @ master · rendered 2026-07-02 7:23 PM EDT by scripts/build-docs.py · the .md in the repo is the truth; this page is the phone-readable view