# Task 1831 — data portal class-derived allowlist (design)

Date: 2026-07-20. Task: [`.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md`](../../../../.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md).

## Problem

The portal only shows what the client uploaded. `processFiles`
(`skills/data-portal/template/functions/api/files.ts:11-24`) reads
`manifest WHERE ownerId = ?`, and `manifest` holds one row per `/api/upload`
object. There is no device-to-portal path at all, so an account can receive
documents from a client but cannot hand any back.

A fixed folder list cannot solve it. On SiteDesk account
`098a18a3-1741-447f-9778-65470645d57d` the deliverables sit in both `output/`
and `quotes/`, and `quotes/` is projected per account from the brand's vertical
ontology (`platform/scripts/lib/account-schema-owned-dirs.py:288-296`), not
shipped in the template. A different vertical projects different buckets.

Provenance cannot be recovered either. `upsertFileArtifact`
(`platform/ui/app/lib/file-index.ts:294-307`) stores no writer, and the watcher
runs agent writes and operator uploads through one path
(`platform/ui/app/lib/file-watcher.ts:30-34`).

## Approach

Key on each folder's declared class, not its name.

Exposed = the dirs named in the account's `<!-- ontology-buckets -->` region,
plus `output`, intersected with the ```` ```allowed-top-level ```` block. All
three are read from `accounts/<accountId>/SCHEMA.md`, which is generated per
account. A new vertical projects new buckets and they appear with no code change.

## Data flow

```
DEVICE (each heartbeat)                    D1                     PORTAL
  read SCHEMA.md
  resolve exposed dirs
  walk those dirs only  ── metadata ──▶  directory  ──── read ───▶ folder tree
                                                                      │
                                          click a file ───────────────┘
                                                  │
                        HEAD (alive?) ◀───────────┤
                                                  │
                        signed link, 5 min  ──────┘
                                  │
  verify signature ◀──────────────┘
  re-resolve exposed dirs
  serve bytes
```

Bytes never enter D1 or R2. Listing therefore survives the device being offline;
download does not, and says so plainly.

## Components

| File | Responsibility |
|---|---|
| `plugins/cloudflare/bin/schema-exposed-dirs.mjs` | `SCHEMA.md` text in, exposed dir names out. Pure, no I/O. |
| `plugins/cloudflare/bin/portal-index-push.mjs` | walk exposed dirs, full-replace the account's `directory` rows |
| `.../template/functions/api/_lib/portal-sign.mjs` | mint + verify the signed link, portal side (sits beside `passcode.mjs`, same precedent) |
| `plugins/cloudflare/skills/data-portal/schema.sql` | `directory` table, `people.accountId` column |
| `.../template/functions/api/files.ts` | folder entries beside today's uploads |
| `.../template/functions/api/download.ts` | uploads to R2 unchanged; indexed files get a signed link |
| `.../template/portal.js`, `index.html` | breadcrumb folder navigation |
| `platform/ui/server/routes/portal-fetch.ts` | verify signature, re-resolve exposure, serve |

## Parsing contract

Pinned against the generator, because two details are easy to get wrong and both
fail silently:

- The ontology region lists `` - `<dir>/` - one folder per <Label> record. `` with
  an **ASCII hyphen**; the plugin-owned region uses an **em dash**
  (`account-schema-owned-dirs.py:294` vs `:281`). Parsing on the em dash matches
  nothing and reads as "this account has no buckets".
- An absent ontology region is normal, not corrupt: `merge()` strips both regions
  before regenerating, and a brand with no declared vertical never gets one. It
  yields `output` alone.
- A dir claimed by a plugin is described only in the plugin-owned region
  (`:287`), so it will not parse as a domain bucket and drops out of the exposed
  set. Instrumented, not fixed, here.

## Authentication

One secret per account, generated at portal assembly, written to the account's
secrets file and set as the Pages secret `PORTAL_FETCH_SECRET`. The install
origin is set alongside it as `PORTAL_INSTALL_ORIGIN`.

The portal signs `accountId`, `relPath` and an expiry with HMAC-SHA256 via Web
Crypto and puts the signature in the link. The install verifies it. The secret
itself never travels, and each grant covers one file for five minutes.

The install re-resolves the exposed dir list from `SCHEMA.md` on every request
rather than trusting the path it was handed. A valid signature over
`documents/private.pdf` is still refused. That check is the security boundary;
the signature only proves the request came from the portal.

**The two ends do not share a module.** No production code in `platform/ui`
imports from `platform/plugins` today — only one test does
(`server/lib/calendar-ics.test.ts:12`) — and this sprint will not invent that
coupling for fifteen lines of HMAC. The portal mints with
`_lib/portal-sign.mjs`; `portal-fetch.ts` carries its own verify. Both call Web
Crypto, which Node 22 and Workers both provide, so the algorithm is identical by
construction. A parity test mints with the portal module and verifies with the
install's function, which catches drift at the only place it could occur.

## Error handling

| Condition | Response |
|---|---|
| device unreachable (HEAD fails) | 503 `device-offline` |
| signature absent, altered, or expired | 401 |
| path outside the exposed dirs | 404, regardless of signature |
| path outside the account partition | 404 |
| `SCHEMA.md` missing or unparseable | expose nothing |

The last one deliberately inverts `fs-schema-guard.sh:76-78`, which fails open so
an unseeded legacy account is not write-blocked. A read surface facing a client
must fail closed. The code carries that reason inline.

## Testing

Unit only. No portal is deployed anywhere and no account exists on this Mac, so
nothing can be driven end to end.

- **Resolver.** SiteDesk-shaped text yields `output,quotes`. No ontology region
  yields `output`. A dir absent from the allowed block is dropped. A region
  written with an em dash yields zero buckets, which asserts the hyphen contract
  rather than assuming it. Missing file yields nothing.
- **Walker.** Fixture tree carrying `output/`, `quotes/`, `documents/`,
  `uploads/`, `.claude/` produces rows for the first two only, at any depth.
  Delete leaves no stale row; rename produces one row at the new path; a
  repeated run is identical.
- **Listing.** Account A never sees account B's rows. Prefix filtering
  synthesises directories from paths. A `people` row with no `accountId` returns
  an empty list, not an error.
- **Signing.** Round trip verifies. Expired fails. Tampered path fails. Tampered
  account fails. Parity: a token minted by the portal module verifies with the
  install's own function, so the two implementations cannot drift unnoticed.
- **Fetch route.** Well-signed request for a non-exposed path is refused. Path
  traversal is refused. Exposed path serves with the right content type.
- **Regression.** The existing 139 tests across 10 files stay green.
  `typecheck:templates` stays at exit 0.

## Out of scope

The upload ingestion sweep; client write-back, delete or rename; mirroring bytes
into R2; recording write provenance (the SiteDesk quote engine writes through its
own tool, not `Write`/`Edit`, so `quotes/` would never register); fixing the
plugin-owned name collision; search; the `/data` admin browser.

## Dependencies

`people.accountId` is new, so existing enrolments are re-run — the upsert on
`ownerId` makes that a re-run, not a migration. Each portal-serving account needs
a seeded `SCHEMA.md` or it exposes nothing. Downloads need the install reachable
at its tunnel hostname; listing does not.

The heartbeat spawn this copies
(`plugins/scheduling/mcp/src/scripts/check-due-events.ts:797`) has never been
observed running on a Pi, which is Task 1828. `portal-index-push.mjs` is
therefore runnable standalone, so it stays operable while 1828 is open.
