---
name: data-portal
description: Stand up a per-account gated file-drop portal on its own custom domain, where named people each sign in with their own passcode and can upload, list, download and delete only their own files. Files land in one R2 bucket under a per-person prefix and every upload writes a D1 manifest row the agent later ingests. Use when an account needs somewhere for named people to drop photos, invoices or spreadsheets — "set up a file drop for <client>", "somewhere for <person> to send us their invoices", "a place clients can upload documents".
---

# Data portal

Assemble and deploy one account's file-drop portal. This is an outcome contract, not a copy of the
Pages mechanics. The deploy, custom-domain attach, token discipline, and live done-gate are owned by
`site-deploy`; the R2 store rules are owned by `references/r2-object-storage.md`. Read those
references with `plugin-read` before issuing their commands.

## What this skill produces, and what it does not

It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
and a D1 database holding the manifest, the enrolment records, and the sessions. It does not carry a
dropped file onto the device: the `portal-uploads-pull` loop in the admin server does that, every
minute, writing each un-ingested manifest row's object to `uploads/<ownerName>/` and setting
`ingested = 1` only once the bytes are on disk at the right size. This skill delivers the manifest
that loop consumes. Nothing here or there ingests a pulled file into the graph.

## Why the portal is on Pages and not the install

Uploads must keep succeeding while the device is offline, which is the same reason the booking page
went to Pages. The install's magic-link gate cannot be reused: it gates `/api/chat` on the install
and its sessions live in memory, so it does not cross origins.

## Provisioning is two sessions, and the order matters

The storage broker registers a bucket to **whoever creates it**, and that ownership is set on create
and never reassigned. There is no delegation.

1. **In the client account's own session**, create the bucket with `storage-r2-bucket-create`, named
   from `bin/cf-store-name.sh <accountId> <purpose>`. It registers to that account.
2. **In the admin session**, do everything else: the D1 database, the assembly, recording the Pages
   project to the client account with `storage-pages-adopt`, and then the deploy.

If the admin creates the bucket instead, it registers to the house, and the client account's own
ingestion sweep is denied `cross-account` against its own files — permanently, because ownership is
never reassigned. The portal would appear to work while nothing on the client side could read what
it stored.

## Enrol the people first

Collect from the operator the portal's custom domain and the list of people, then write
`<accountDir>/data-portal.json` carrying `portalDomain`, `portalDbName`, `bucketName`, and a
`people` array of `{ name, ownerId, prefix }`.

For each person:

```bash
node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
  --account <accountId> [--folders <a,b,c>] | npx wrangler d1 execute <portalDbName> --remote --file=-
```

The script mints the passcode and prints it **once to stderr**, so it reaches the operator's terminal
and never the pipe; stdout carries the `INSERT` alone. Do not hand-roll this: the passcode's length
and alphabet are pinned in `template/functions/api/_lib/passcode.mjs`, the script is what applies
them, and a passcode minted any other way is unpinned by definition. The script refuses an `ownerId`
the isolation gate would reject, so a bad name fails here rather than after the row lands.

The statement is an upsert on `ownerId`, which is what makes rotation a re-run rather than a
constraint error.

`--account` binds the person to the sub-account whose deliverable folders they may read, and it is
required. A person with no account resolves to an empty folder list, which is indistinguishable from
a sub-account that genuinely has nothing to share, so the script refuses rather than mint a row that
fails silently later. Anyone enrolled before this existed has no account bound and sees nothing; run
enrolment again for them, which the upsert makes a re-run rather than a migration.

`--folders` is optional and narrows what one person sees to a named set of top-level folders, comma
separated (`--folders quotes,output`). Each name is a folder, never a path, and must match
`[A-Za-z0-9_-]+`. Omitting it, or passing an empty value, grants the account's full exposed set,
which is the existing behaviour, so people enrolled before this existed are unaffected. Two people on
one account can hold different grants and each sees only their own. Re-enrolment replaces the grant,
the same upsert that rotates the passcode. The grant lives only in the portal's D1, so it narrows
below the account's exposed set and never widens past it: a portal compromise returns a person to the
account set, which the install re-derives and enforces regardless.

**A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
the script for that person; there is no separate editor.

Rotation also revokes: a session records a digest of the person's passcode hash at sign-in, and the
session lookup requires it to still match, so re-running enrolment kills every live session for that
person immediately. There is no cleanup step to remember and no window to wait out — which is the
point, because a leaked passcode is exactly the case where a procedure you have to remember fails.
Deleting the person's row revokes their sessions the same way.

The `ownerId` must match `[a-z0-9-]+`. This is not cosmetic: the whole isolation rule is that a key
starts with `<ownerId>/`, so an `ownerId` containing a separator would let one person read another's
files. The gate enforces it too, but do not enrol a name it will reject.

## Assemble the canonical tree

Copy `template/` to `<accountDir>/sites/<project>/` and fill `wrangler.toml`'s `__PROJECT_NAME__`,
`__D1_DATABASE_NAME__`, `__D1_DATABASE_ID__`, and `__R2_BUCKET_NAME__`. **Leave no `__...__`
placeholder in the assembled tree.**

`sites/<project>/` is the only location the broker will publish: `resolveSiteDir` resolves
`<accountDir>/sites/<name>` and refuses anything else with `invalid-site`. The tree is flat: `index.html`, `portal.css`, `portal.js`,
`functions/` and `wrangler.toml` all sit at its root, with no nested asset level, and
`pages_build_output_dir` reads `"."` to match. The deploy publishes whatever
directory `pages_build_output_dir` declares, so the two must agree; a nested asset level that the
config does not declare publishes the site one level too deep, which serves 404 at the domain root.

The deploy serves everything in that tree except a fixed ignore list — `functions/`, `_worker.js`,
`_redirects`, `_headers`, `_routes.json`, `node_modules`, `.git`, `.DS_Store`, `.wrangler` — and
`pages deploy` reads no `.assetsignore`, so anything else you leave there is public. Nothing else
belongs in it. `schema.sql` therefore lives beside `template/` in the skill, not inside it. Name the D1 database
with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
`platform/plugins/cloudflare/skills/data-portal/schema.sql` to the remote database from there. It is
`IF NOT EXISTS` throughout, so re-applying is safe.

Safe, but not a migration: `IF NOT EXISTS` adds no column to a table that already exists. A portal
whose `directory` table predates the `isDir` column keeps failing every index push, because the push
names the column in its `INSERT`. Add it once by hand against that database:

```bash
npx wrangler d1 execute <portalDbName> --remote \
  --command "ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0"
```

No backfill follows it. The push replaces every row for the account each cycle.

The copied `portal.css` carries the template's default palette, which is nobody's brand. Apply the
install's before handing the tree to the deploy:

```bash
node platform/plugins/cloudflare/bin/portal-brand-css.mjs \
  --css <accountDir>/pages/<project>/public/portal.css
```

It rewrites that stylesheet's one `:root` block from `consent.palette` in the install's brand
manifest and touches nothing else in the file. Do not set the colours by hand: the script validates
every value against the same rule the bundler applies to brand colours, refuses without writing
anything if the stylesheet or a value is not what it expects, and leaves the template defaults in
place for a brand that declares no palette — which is the case a hand-edit turns into an empty
declaration nobody sees until the page loads.

## Bind the portal to the device

The portal shows the sub-account's deliverable folders as well as what the client uploaded. The
folder names and sizes live in D1, so the tree renders whether or not the device is up; the bytes do
not, so opening a file has to reach the install. That needs two things the template cannot know.

The shared tree has two row kinds. `renderEntries` in `portal.js` gives a `directory` entry a folder
icon in the accent colour and a file entry a document icon in the muted grey, both inline lucide SVG,
so a client tells a folder from a file at a glance.

Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's PUBLIC origin
(`https://<public-tunnel-hostname>`), never the admin or operator hostname. The admin
origin is behind the remote-auth gate, so the edge's signed HEAD probe of a device file
gets a 401 there and every download 503s. The public host admits `/api/portal/fetch` by
allowlist and the route verifies the signature itself. Then mint one secret and set it on
both ends:

```bash
SECRET="$(node -e 'console.log([...crypto.getRandomValues(new Uint8Array(32))].map(b=>b.toString(16).padStart(2,"0")).join(""))')"
mkdir -p <accountDir>/secrets
( umask 077; printf 'PORTAL_FETCH_SECRET=%s\n' "$SECRET" >> <accountDir>/secrets/data-portal.env )
printf '%s' "$SECRET" | npx wrangler pages secret put PORTAL_FETCH_SECRET --project-name <project>
```

The device reads `<accountDir>/secrets/data-portal.env` per request, so the secret is **per account**
rather than one value for the whole install. That is what makes the account binding inside the
signature mean anything: a shared key would let any portal sign for any other account on the same
install. Rotating it is a re-run of both lines, with no restart.

The secret never travels. The portal signs one account, one file path and a five-minute expiry with
it, and the install verifies that signature. It is a Pages **secret**, never a `[vars]` entry: the
assembled tree is published as-is, so a secret in `wrangler.toml` is one deploy from being served.

The signature is not the security boundary. The install re-derives which folders are exposed from the
account's own `SCHEMA.md` on every request, so a correctly signed link for `documents/` is refused
anyway. That check is what contains a stale index or a compromised project, and it is why both exist.

## Publish the folder index

The device publishes file and folder metadata — names, sizes, times, never bytes:

```bash
node platform/plugins/cloudflare/bin/portal-index-push.mjs \
  --account-dir <accountDir> --account <accountId> --db <portalDbName>
```

Which folders it publishes is not configured here and is not a list in the code. It is derived from
that account's own `accounts/<accountId>/SCHEMA.md`: the buckets named in its
`<!-- ontology-buckets -->` region, plus `output`, intersected with its `allowed-top-level` block. A
brand with a different vertical publishes different folders with no change to this skill. Operator
data (`documents/`, `contacts/`, `projects/`), the client's own `uploads/`, plugin workspaces and
everything platform-internal are in neither source and are never published.

Every walked folder gets a row of its own, not only the files inside it. An exposed folder holding
nothing at any depth is therefore a real entry: it renders in the tree and opens to an empty listing,
rather than reading as absent to the person who has to put the first file into it. A folder row
carries no bytes, so the download path refuses it and logs `result=is-directory`. A folder named in
the schema but absent from the device's disk still publishes nothing, so the tree never offers a
folder the device does not have.

An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and it is the opposite of
what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
while a surface facing a client must fail closed.

An operator can publish extra folders that are not ontology buckets by listing them in
`exposeFolders` in the account's `data-portal.json`, for example a workflow folder like
`inbound-invoices`. Each name must be a single folder matching `[A-Za-z0-9_-]+` and is refused if it
is a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or
dot-prefixed. These extras are added on top of the schema-derived set and, unlike ontology buckets,
are not intersected with the `allowed-top-level` block. An empty or absent list behaves exactly as
before.

The admin server runs this every sixty seconds for every account carrying a `data-portal.json`
. It used to be an hourly spawn from the scheduling heartbeat, which had never been
observed running on a device and left the client's tree up to an hour stale. Run it once by hand
after assembly so the client has something to see on their first sign-in, and check the output names
the folders you expect:

```
[portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes extraFolders=1 exposed=inbound-invoices,output,quotes
[portal-index] op=stage account=<id> generation=7 inserted=41
[portal-index] op=flip account=<id> generation=7
[portal-index] op=verify account=<id> rowsAfter=41 expected=41
```

The publish stages every row at the next generation, moves a pointer in one statement, then sweeps
the superseded rows. That flip is the atomic point: a client listing mid-publish reads the old tree
or the new one and never a half-built one. Before this the replace was delete-then-insert, so the
tree was empty for the length of the insert run, which was tolerable hourly and is not at sixty
seconds.

`exposed=none` on an account that has deliverables means the schema was missing or its ontology
region was absent — not that the walk found nothing. `extraFolders=<n>` counts the folders the
operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in
`exposed=` means the guard dropped every one (a system or dot name, or a bad spelling).

## How a client browses, downloads and uploads

Signed in, the panel has a pinned header that stays put while the file lists
scroll under it. The header carries the folder breadcrumbs, an up control that
moves to the parent folder (disabled at the root), a refresh control, and an
upload icon. Filenames render on one line: the stem truncates with an ellipsis
and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
are still told apart.

Refresh re-reads the index in the folder that is already open. The page also
re-reads itself every sixty seconds while its tab is visible, which is what
makes a file the device published appear without the client clicking anything.
That poll was deliberately absent while the push was hourly, when it would have
been waste; it arrived with the sixty-second push. It is skipped for a hidden
tab and while a read is already in flight, so a background tab costs nothing and
a slow response cannot stack requests. The refresh button is inert while its own
request is in flight, and the status line clears on success or keeps the failure
message on screen.

Delete and upload both re-read the list when they finish. If that re-read
fails, the status keeps the outcome and adds to it, so a client reads
`Deleted. The list could not be reloaded.` rather than a bare load error that
would leave them unsure whether the file went. The rows on screen are then
known to be stale, and refresh is how they clear that.

A shared (device-held) file downloads three ways, all through the same signed
`/api/download?path=` route: a left click, a right-click that opens a one-item
Download menu, and a drag out of the browser onto the desktop. The drag uses
`/api/download?path=...&redirect=1`, which 302s to the signed link so the browser
saves the file rather than a JSON body. The three download methods share the one
`op=fetch-through` log line, so a broken method shows as a missing line under the
same signal.

A client's own uploaded file drags out the same way, so both lists behave alike
rather than only the device-held one supporting it.

**Uploads go to a folder the client chooses.** Dropping files onto a
folder row sends them into that folder; dropping onto the open listing sends
them into the folder currently open; the modal states which folder it will use.
An upload with no folder chosen lands in the client's own uploads area, which
stays unexposed to the folder tree. The client still sees and downloads it from
their own list, through a signed link scoped to them.

A legal target is one the client can already see: it must pass their folder
grant and carry a row in the published index. The device re-derives that set
itself and refuses anything outside it, so a stale index or a compromised Pages
project cannot route a file into a folder the device does not expose. A refused
target leaves the file pending and logs the exact reason; it is never quietly
rerouted somewhere the client did not choose.

Several files may be dropped or picked at once and upload one after another.
Anything over 25 MiB is sliced into parts against a multipart session, up to
1 GB, with a progress bar; a file over the limit is refused before any bytes are
sent. Failures now read differently by cause: an expired session, a folder that
is not allowed, a file that is too large, and an unreachable server are four
messages, not one.

Once the device has collected an upload it deletes the portal's copy, so the
file lives in one place rather than two. A targeted file then appears in its
folder rather than in the uploads list; an untargeted one stays in the uploads
list and downloads through the device. Deleting a file the device already
collected removes it from the device too, within a minute; the client is told it
is pending rather than gone, because for that minute it is.

The modal dismisses on the close button, a click on the backdrop, or Escape.

## Deploy through site-deploy

`storage-pages-deploy` refuses a project with no owner on record, so record the ownership first. In
the admin session, run `storage-pages-adopt` with the project name and the client's account id. This
records the project-to-account mapping in the broker's house-held registry. It is not a write into
the client's account graph, and it needs no session inside the client account. It is house admin
only, so it runs from the admin session; the client account's own session is refused `not-house-admin`,
which is the boundary working, not a fault to route around. Only once the project has a recorded owner
can the deploy publish it.

Hand the assembled tree to `cloudflare:site-deploy`. Both bindings — D1 and R2 — are declarative
config in `wrangler.toml`, carried in the deploy payload under the Pages scope; the deploy needs no
R2 token. Only the bucket *creation* in step 1 needs R2 write, and the broker holds that.

Read `site-deploy` for its auth, custom-domain and DNS guidance, but **not** for the tree location.
Its canonical-tree section names `<accountDir>/pages/<project>/` and tells you to reconcile stray
trees into it; that section describes the house path, which publishes with an explicit positional
output dir. A portal publishes through `storage-pages-deploy`, which accepts `<accountDir>/sites/`
and nothing else. Leave the tree where you assembled it. Moving it to `pages/` to satisfy that
section is the one way to turn a working portal into `invalid-site`. The two skills disagree on this
today and the disagreement is tracked, not resolved.

## Done-gate

The portal is not done until all five hold on the live domain:

1. A cache-busted request to the portal domain returns `HTTP/2 200`.
2. Signing in with the right passcode returns `200`.
3. A wrong passcode returns `401`; the **sixth** rapid wrong passcode returns `429` (five are
   allowed per fifteen minutes, per person, per IP).
4. An upload as one person returns `200`, and the object is present in R2 under that person's
   prefix with exactly one manifest row at `ingested=0`. Re-uploading the same filename also
   returns `200`, replaces the object, and leaves exactly one row, now back at `ingested=0`.
5. That person's browser cannot list, download, or delete another person's file.

A `502` on `/api/upload` with `op=r2-put result=failed` on every attempt is the expected signature of
an R2 binding that did not deploy — check `wrangler.toml` reached the tree with its placeholder
filled.

Uploads are capped at 25 MiB and a larger one returns `413` with `result=too-large`.

## Observability

Every step of an upload emits one `[data-portal]` line correlated by `uploadId`, ending in
`op=complete` with `objectPresent` and `rowPresent` **re-read** rather than assumed. Passcodes,
hashes, and object bodies are never logged.

Read one upload's full lifeline by filtering the brand journal on its `uploadId`.

The standing audit that would reconcile R2 objects against manifest rows is not built yet. Until it
is, an object with no manifest row and a row with no object are both invisible: neither emits an
event at request time.

## Tool discipline

Never write a token into the tree, never commit one, never echo one. The bucket name and database
name come from `bin/cf-store-name.sh`, never hand-built.
