TL;DR

  • I built a self-hosted share host in early September because a Claude artifact link was private to me and I needed to hand a page to specific people who have no account on anything I run. Access control was the first feature, not an afterthought.
  • Authentication and authorization are split on purpose. oauth2-proxy with Google proves who you are and authorizes nothing. A small FastAPI app reads a per-folder ACL object from the bucket and decides what you can open. Grants change in about 15 seconds with no deploy.
  • Pages that take input don’t post to a database. Every checkbox and typed cell becomes a typed operation that lands as a merge commit on a JSON spec in GitLab, authored by the person who made it. Git is the config layer.
  • Every page has a comment rail. Comments have changed real work: a gear list, and the status page for my blue-team security harness.
  • Long-running jobs get a status page that rebuilds itself from the job’s own data. The security eval ran on one across three days. A restore job I was babysitting republished every three minutes from a systemd timer.

Access control came first

The problem was boring and specific. I make a lot of HTML with Claude: plans, reports, checklists. When I built this, an artifact link was mine alone. There was no way to say “this person can open it and nobody else.” Emailing HTML files around is how you end up with four versions of the same checklist.

So the first commit of the share host, on 2026-09-05, was mostly an access model. The request path is short:

Internet → Traefik → oauth2-proxy (Google) → share-web (FastAPI) → object storage

The part worth copying is the split:

  • oauth2-proxy proves identity and nothing else. It runs with email_domains = ["*"], so any Google account can get a session. That sounds alarming until you notice the session buys you nothing by itself.
  • share-web authorizes. Each “space” (a folder) carries its own ACL object in the bucket. A grant can be an exact address, an email domain, or a single file. There are view-only grants, and per-space admins who can moderate inside one space and nowhere else.

Because the ACL is an object and not config, sharing is an S3 write. No manifest edit, no pod restart. The app caches the ACL for about 15 seconds, so that is the worst-case delay for a grant or a revoke. The publishing CLI looks roughly like this (placeholders, obviously):

share.py new trip-planning --title "Trip planning" --email friend@example.com
share.py grant trip-planning --admin friend@example.com
share.py grant trip-planning --file budget.html --readonly --email parent@example.com
share.py revoke trip-planning --email friend@example.com

There is also a Manage access form in the UI for site owners. It writes the same ACL object, with an S3 conditional write (IfMatch) so a browser and the CLI can’t silently stomp each other. A stale revision gets a 409 and the form makes you reload. It never retries on its own.

Everything fails closed, and each of these has a test:

  • No identity header: 403.
  • Missing or corrupt ACL: grants nothing.
  • The index lists only what you can open.
  • The ACL object itself is never served.
  • .. in a path is rejected.
  • “Denied”, “unknown space” and “missing file” look identical from outside, so you can’t probe for what exists.

Why not my existing SSO? Its Google source had no enrollment flow, so an outside person couldn’t sign in at all. Turning on self-enrollment would have been a cluster-wide change, and I wasn’t going to widen access for every app behind the SSO to ship one new one. A dedicated oauth2-proxy kept the blast radius to this host. The session cookie is scoped to the exact share hostname, so it doesn’t leak to sibling apps either.

Shared HTML renders in a sandboxed iframe under a CSP with connect-src 'none'. A shared page can’t phone home, can’t fetch, can’t do anything except be looked at. That turned out to matter a lot for the next part.

Forms that write to a config layer

A static page that can’t make network requests also can’t submit a form. I wanted pages people could use: tick off a checklist, set a status, type a measurement. The answer was to make the page itself dumb and let the viewer carry the write.

A space can mark one of its pages as a live spec. The page is rendered from a JSON spec in its own GitLab project, and nobody ever edits the HTML. When a reader ticks a box, the page postMessages an intent to the viewer that framed it. The viewer only accepts it from its own frame and only with a fresh per-load nonce. It checks the shape, then calls the API as the reader:

  1. POST /api/c/<space>/<file>/mutate with a list of typed ops. Readers get six: set_status, add_row, remove_row, edit_cell, edit_field, add_log. Plain text only, never HTML.
  2. The service reads the spec at main, applies the ops, rebuilds the page, runs the generator’s checks, and refuses anything over 512 KiB.
  3. It commits to a share/<space>/<timestamp> branch with the reader as the commit author, opens a merge request, and merges it pinned to that exact commit SHA.
  4. If main moved underneath it, it throws the MR away and starts over from the fresh spec, up to three times. Every commit carries a request key, so a retry can’t add the same row twice.
  5. It uploads the new HTML and the viewer reloads the frame at the same scroll position.

Two switches have to agree before any of that happens. An operator allowlist in the deployment maps each space to the one project it may write. And the page’s spec names its editors by exact address. A read grant never implies a write grant, a domain entry in the editors list is dropped, and even owners have to be listed explicitly. Holding a GitLab token isn’t enough: the ACL lives in the bucket, and an ACL must never be able to aim a page at a repo the operator didn’t allowlist.

The payoff is that Git is the database. The history of a checklist is git log. Who changed what is the commit author. Rolling back a bad edit is a revert.

The most recent use is a bench sheet. I’m modeling a replacement power plug to 3D print, and I needed to take a stack of caliper measurements off the original. The share page is a table of blank cells. I type answers at the bench, each edit becomes a merge commit on a bench-sheets repo, and a small pull_answers.py folds the answer column back into the params.json the CAD model reads. A numeric answer goes in as a value. Anything that isn’t a number goes in flagged for a second look, with my note attached, instead of being guessed at.

Getting that first sheet wired up hit two speed bumps. My group bot token can’t mint project access tokens, so a human has to click that button in GitLab. And Claude Code’s auto-mode classifier blocked both writing the token secret and committing an edit on a live page as an unrequested commit, even after I’d said yes in chat. If you’ve read my writeup on 301 auto-mode denials, none of that will surprise you.

Comments that steer the work

Every page opens in a viewer with the artifact on the left and comment threads on the right. You can draw a box or drop a pin on a spot and comment on that spot. Threads resolve and fold away. Authors, owners and space admins can edit or delete comments, and an edit by anyone other than the author says so.

This is where the thing stopped being a file host. On the trip page, a comment like “the rope is mine, and the tarp is optional” turned into gear-list edits. The comment stayed visible and the change landed in Git.

The better example is the security work. When I was grading seven models on blue-team security tasks, the run’s progress lived on a share status page. At 09:35 on the first morning, a comment pointed out that the page named every test track without saying what any of them measured. By 13:27 the generator had a plain-language description of every track, and the commit message says where the request came from: status page describes every test track (share comment).

Status pages for multi-day jobs

The generator behind these pages has a tracker type: a status board, progress bars, open items, and a running log. A status page is just a script that builds a tracker spec from real data on a loop and republishes it.

For the security harness, status_page.py reads only the results directory and the run logs and never touches a model server. A refresh loop republished it, and a comment watcher kept an eye on the rail. The eval kicked off late on 2026-10-09. The final version of the page went up at 19:24 on 2026-10-12, and then the loop and the watcher were shut off. The harness itself had 112 frozen cases and a VM with no network card. The full writeup covers the method.

The other one I leaned on was a multi-day restore of a wireless test instrument. A systemd --user timer on my desk machine ran a read-only collector every three minutes: TCP probes, a couple of log reads over SMB, the agent’s log. The collector built the tracker spec and republished. Claude kept a narrative.json up to date with the headline, the current blocker and the next steps, and the agent’s findings fed the log. That space’s ACL was me and nobody else, which is a perfectly good setting. Sometimes the audience for a status page is the person who keeps getting up to check the bench.

One gotcha worth writing down: don’t use <meta refresh> on a share page. Inside the viewer, the page sits in a sandboxed iframe with an opaque origin, so the reload goes out without the session cookie. oauth2-proxy sends it off to Google, and inside the frame that ends in a 403 where your status page used to be. The fix is a reload script that only runs when the page is the top-level window.

What’s weak

  • Comments live in Postgres, not Git. The edit a comment caused is in the history, but the comment itself isn’t. For the security status page, the only link between the two is a timestamp I left in a code comment. If I care about provenance (and I clearly do), comments should probably be mirrored somewhere versioned.
  • One live page per space. It’s a deliberate limit that keeps the write path auditable, and it means a second form gets a second space.
  • The editors list has to be maintained by hand. Exact addresses only, by design, and that’s still a chore.

Claude has since added its own controls over who can open an artifact. I’m keeping the share host anyway, because by now the access list is the least interesting thing it does. These days I write the page with Claude, and a share link is how other people read it, comment on it and change it, with Git keeping score.