Host services on slug subdomains, with the Chat preview as the first customer #5

Open
opened 2026-08-25 16:57:49 +09:00 by floot · 2 comments
Owner

Today the only way to see a UI change running is to apply it to the host, which
moves the live URL and restarts the daemon. That makes the main site the test
environment, and it means a change reaches feat/hosted-endo-management on
faith rather than on evidence.

Proposal

Add a preview step between "built" and "approved":

  1. Build the candidate revision (see floot/endo#4 — the workflow should be
    building it before approval anyway). The build already lands a complete tree
    at /var/lib/endo/releases/<rev>/.
  2. Serve that release's chat output through the existing asset server, which
    mounts a filesystem under a freshly generated, unguessable capability path
    and returns a revoker.
  3. Look at it, then either approve the apply and land on
    feat/hosted-endo-management, or revoke the mount and drop the revision.

No second hostname, no daemon restart, and the live UI never moves. The preview
dies when its capability is revoked.

Open question

The chat bundle has to reach the daemon gateway. Served from a capability path
rather than the main origin, that may hit CORS or a hardcoded origin assumption —
worth testing before committing to this shape.

Scope

This covers front-end changes only. Anything touching the daemon itself still
needs a real apply to prove out, so the preview step should be optional rather
than a gate on every run.

Branch policy

Implied by the above: agent stays the proposal branch, and
feat/hosted-endo-management only takes a change that has been previewed or
applied successfully — so it stays the known-good line.

Today the only way to see a UI change running is to apply it to the host, which moves the live URL and restarts the daemon. That makes the main site the test environment, and it means a change reaches `feat/hosted-endo-management` on faith rather than on evidence. ## Proposal Add a preview step between "built" and "approved": 1. Build the candidate revision (see floot/endo#4 — the workflow should be building it before approval anyway). The build already lands a complete tree at `/var/lib/endo/releases/<rev>/`. 2. Serve that release's chat output through the existing asset server, which mounts a filesystem under a freshly generated, unguessable capability path and returns a revoker. 3. Look at it, then either approve the apply and land on `feat/hosted-endo-management`, or revoke the mount and drop the revision. No second hostname, no daemon restart, and the live UI never moves. The preview dies when its capability is revoked. ## Open question The chat bundle has to reach the daemon gateway. Served from a capability path rather than the main origin, that may hit CORS or a hardcoded origin assumption — worth testing before committing to this shape. ## Scope This covers front-end changes only. Anything touching the daemon itself still needs a real apply to prove out, so the preview step should be optional rather than a gate on every run. ## Branch policy Implied by the above: agent stays the proposal branch, and `feat/hosted-endo-management` only takes a change that has been previewed or applied successfully — so it stays the known-good line.
Author
Owner

Looked into feasibility. The open question resolves, but it takes the proposal
somewhere different.

The connection is not the problem

The Chat app takes its gateway from a query parameter (?gateway=…&agent=…,
packages/chat/main.js), not from its own origin, and ws-gateway.js performs
no Origin check. So a bundle served from anywhere can open the WebSocket and
reach the daemon. No CORS obstacle.

The obstacle is an isolation decision we should not undo

modules/reverse-proxy.nix says it plainly:

The asset server lives on its own origin so served (possibly untrusted)
content can never reach the agent-id capability that lives on the Chat origin.

Serving a candidate Chat build from the asset origin means putting the agent
nonce — a live capability — into a URL on the origin specifically chosen to be
the one that never sees it, and sharing localStorage there with every other
served mount. That defeats the reason the split exists.

Proposal instead: a preview ORIGIN, not a preview path

Give a preview its own subdomain, e.g. preview.<domain>, served exactly like
the main Chat vhost but rooted at a symlink the machine-admin caplet can point
at any BUILT release:

root * ${stateDir}/preview/current/packages/chat/dist

Then previewing revision X is: prebuild X (which floot/endo#4 already does
before approval), point the preview symlink at releases/X, and open
preview.<domain> with the usual params. Each preview is its own origin,
isolated from both the live Chat origin and the asset origin, and it goes away
by pointing the symlink elsewhere.

Costs more than the original sketch — a vhost, a symlink, a caplet verb — but
it does not spend an isolation property to buy convenience. It also composes
with the prebuild work: the release being previewed is the same artifact the
apply would activate, byte for byte.

The asset-server route stays right for previewing content that does NOT need
the agent capability.

Looked into feasibility. The open question resolves, but it takes the proposal somewhere different. ## The connection is not the problem The Chat app takes its gateway from a query parameter (`?gateway=…&agent=…`, `packages/chat/main.js`), not from its own origin, and `ws-gateway.js` performs no `Origin` check. So a bundle served from anywhere can open the WebSocket and reach the daemon. No CORS obstacle. ## The obstacle is an isolation decision we should not undo `modules/reverse-proxy.nix` says it plainly: > The asset server lives on its own origin so served (possibly untrusted) > content can never reach the agent-id capability that lives on the Chat origin. Serving a candidate Chat build from the asset origin means putting the `agent` nonce — a live capability — into a URL on the origin specifically chosen to be the one that never sees it, and sharing `localStorage` there with every other served mount. That defeats the reason the split exists. ## Proposal instead: a preview ORIGIN, not a preview path Give a preview its own subdomain, e.g. `preview.<domain>`, served exactly like the main Chat vhost but rooted at a symlink the machine-admin caplet can point at any BUILT release: ``` root * ${stateDir}/preview/current/packages/chat/dist ``` Then previewing revision X is: prebuild X (which floot/endo#4 already does before approval), point the preview symlink at `releases/X`, and open `preview.<domain>` with the usual params. Each preview is its own origin, isolated from both the live Chat origin and the asset origin, and it goes away by pointing the symlink elsewhere. Costs more than the original sketch — a vhost, a symlink, a caplet verb — but it does not spend an isolation property to buy convenience. It also composes with the prebuild work: the release being previewed is the same artifact the apply would activate, byte for byte. The asset-server route stays right for previewing content that does NOT need the agent capability.
Author
Owner

Revised plan

Broader than the original sketch, and blocked on one decision.

Shape

A general slug-subdomain host, not a chat-specific preview: ${slug}.<domain>
serves a filesystem the site-host caplet has mounted under that slug. The Chat
preview becomes its first customer rather than the feature itself.

The slug is a ROUTING KEY, not authority. Holding the caplet is control;
knowing a slug gets you the content and nothing more. One controller manages
every mount — no per-site controller secret.

Why this needs a wildcard certificate

A subdomain is not secret. Caddy takes a Let's Encrypt certificate per name,
and every issued certificate is published to Certificate Transparency within
minutes — so a per-name certificate makes every slug publicly enumerable, and a
"random-named cap" would be a random name that anyone can look up. This is the
difference from the asset server, whose secret is in the PATH; paths never
appear in CT.

A wildcard for *.<domain> puts only the wildcard in CT. Individual slugs
never appear.

The DNS question

Wildcards can only be validated over DNS-01 — no HTTP challenge issues one — so
something must write a TXT record on renewal, roughly every 60 days. Options,
in order of machinery:

  1. A scoped DNS API token (recommended). Caddy renews silently forever. The
    token is limited to editing DNS on the one zone. Needs Caddy rebuilt with the
    provider plugin — the vendor hash is discoverable by proposing once and
    reading the mismatch out of apply.log, which now captures build output.
  2. Delegated validation (acme-dns). One CNAME at the real provider, once,
    pointing the challenge name at a small delegated zone; automation then only
    ever writes there. A compromise of this host cannot rewrite real DNS, only
    challenge records. Costs another service and an NS delegation.
  3. Manual certificates. Rejected: renewal every ~60 days with no automation
    possible (manual DNS-01 is manual by definition), and a missed renewal takes
    every slug site down at once. A timer could only remind, and a reminder is
    what gets ignored until previews break.

Blast radius

Leave <domain>, git.<domain> and assets.<domain> on the per-name automatic
certificates they use today. The wildcard covers ONLY slug hosts, so a lapsed
wildcard breaks previews rather than the machine's front door.

Status

Held pending the DNS decision — provider, and whether this host may hold a
scoped credential. Everything downstream (the vhost, the slug table, the caplet
verb, pointing a preview at a prebuilt release) is unaffected by that choice;
only the TLS line differs.

## Revised plan Broader than the original sketch, and blocked on one decision. ### Shape A general slug-subdomain host, not a chat-specific preview: `${slug}.<domain>` serves a filesystem the site-host caplet has mounted under that slug. The Chat preview becomes its first customer rather than the feature itself. The slug is a ROUTING KEY, not authority. Holding the caplet is control; knowing a slug gets you the content and nothing more. One controller manages every mount — no per-site controller secret. ### Why this needs a wildcard certificate A subdomain is not secret. Caddy takes a Let's Encrypt certificate per name, and every issued certificate is published to Certificate Transparency within minutes — so a per-name certificate makes every slug publicly enumerable, and a "random-named cap" would be a random name that anyone can look up. This is the difference from the asset server, whose secret is in the PATH; paths never appear in CT. A wildcard for `*.<domain>` puts only the wildcard in CT. Individual slugs never appear. ### The DNS question Wildcards can only be validated over DNS-01 — no HTTP challenge issues one — so something must write a TXT record on renewal, roughly every 60 days. Options, in order of machinery: 1. **A scoped DNS API token** (recommended). Caddy renews silently forever. The token is limited to editing DNS on the one zone. Needs Caddy rebuilt with the provider plugin — the vendor hash is discoverable by proposing once and reading the mismatch out of `apply.log`, which now captures build output. 2. **Delegated validation (acme-dns).** One CNAME at the real provider, once, pointing the challenge name at a small delegated zone; automation then only ever writes there. A compromise of this host cannot rewrite real DNS, only challenge records. Costs another service and an NS delegation. 3. **Manual certificates.** Rejected: renewal every ~60 days with no automation possible (manual DNS-01 is manual by definition), and a missed renewal takes every slug site down at once. A timer could only remind, and a reminder is what gets ignored until previews break. ### Blast radius Leave `<domain>`, `git.<domain>` and `assets.<domain>` on the per-name automatic certificates they use today. The wildcard covers ONLY slug hosts, so a lapsed wildcard breaks previews rather than the machine's front door. ### Status Held pending the DNS decision — provider, and whether this host may hold a scoped credential. Everything downstream (the vhost, the slug table, the caplet verb, pointing a preview at a prebuilt release) is unaffected by that choice; only the TLS line differs.
floot changed title from Preview a candidate revision at a capability URL before it lands on hosted-endo to Host services on slug subdomains, with the Chat preview as the first customer 2026-08-26 14:31:41 +09:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
floot/endo#5
No description provided.