Skip to content

Session origins: {name}.p.httpeers.net shell, demo, infra - #3

Merged
mkotelnikov merged 6 commits into
mainfrom
feat/session-domains
Sep 20, 2026
Merged

mkotelnikov merged 6 commits into
mainfrom
feat/session-domains

Conversation

@mkotelnikov

Copy link
Copy Markdown
Contributor

What

Session origins: every {name}.p.httpeers.net (123123.p.httpeers.net, foobar.p.httpeers.net, …) serves the same static shell under one wildcard certificate, and each name is its own origin (storage, cookies, ServiceWorker). A ghost app on *.httpeers.net frames relay.html, hands it a MessagePort (webrun-http-browser relay mode: newRemoteRelayChannel + initHttpService), and from then on every request inside the session — index.html first — is answered over the port.

  • apps/session-shell/ (new): relay.html, the worker relay-sw.js, a fallback index.html, and a ./client export, openSession({ handler }). The worker speaks the library's relay protocol, with these differences: it serves the app at the origin root, the first live registrant keeps the session, only relay.html may register, navigations must come from the session or a ghost origin (the referrer check), and it adds frame-ancestors to every response it makes. The relay page refuses a port from another session, and it bridges to registration.active because Firefox 155 leaves a second relay page uncontrolled.
  • apps/demos: the hub serves a small app at /spa, and the app page's Open in a new session shows it in a fresh session origin, with pinnedPeer as the port's handler. This replaces the same-origin ghost iframe.
  • packages/httpeers-ghost: pinnedPeer dropped every request body in Firefox because Firefox has no Request.prototype.body. It now buffers the body there.
  • deploy/: a *.p.httpeers.net Caddy block with its own DNS-01 wildcard certificate, frame-ancestors, and a Host rewrite to one storage prefix. The README adds the A *.p record (required: the ACME challenge turns p into an empty non-terminal), SAN checks, and the in-place Caddyfile change procedure.

Already live (applied from this branch; nothing merged)

  • Gandi: A *.p.httpeers.net → 163.172.46.87, TTL 3600.
  • Server: /opt/httpeers/Caddyfile replaced in place by this branch's version (backup Caddyfile.bak.20260918-before-session-domains), then caddy reload. No container was restarted. Let's Encrypt issued *.p.httpeers.net.
  • Bucket: the shell is published to p.httpeers.net/. The hub. and app. demo pages were republished from this branch (backups in the umbrella under worktrees/session-domains/backups/).

Verified

  • apps/session-shell, pnpm run browser-test, local, Chromium + Firefox: 21/21. Covers the protocol, POST bodies, a worker restart, shell-path bypass, a second ghost refused, a top-level visit refused, and the session freed once its ghost is gone.
  • pnpm run live-check, real domain, fresh names: 15/15. Covers DNS, the SAN and Let's Encrypt issuer, byte-identical files on two names, frame-ancestors, .site/ not served, another session refused (both browsers), and a foreign site unable to frame a session (the Firefox half is weaker: the frame just never answers).
  • apps/demos, session-smoke.mjs, real domains, Chromium + Firefox: 42/42. The mesh app renders in two session origins through their workers, and /api/hello reaches the hub. localStorage, cookies, IndexedDB and worker registrations are isolated between sessions and from the viewer. Parent DOM access and top navigation throw SecurityError. A top-level visit is refused. The existing live-smoke.mjs is still green.
  • CI steps pass locally: lint, build, typecheck, test, and a frozen install.

Decisions to review

  • Sessions stay under httpeers.net rather than on a separate registrable domain. That allows cookie tossing onto .httpeers.net; nothing relies on cookies today. To move them, change SESSION_ZONE/GHOST_ZONE together with the Caddy block and the DNS record.
  • Every published *.httpeers.net site may act as a ghost app. The allowlist is one label under httpeers.net, excluding p. It does not include localhost; a shell served from localhost accepts localhost parents, so local tests work.
  • No SPA fallback on the shell. Unknown paths return the fallback page with 404 (notFound), which differs from the design note's spa: true. With a connected session, the worker answers every path anyway.

Not verified: Safari/WebKit; the NXDOMAIN window without *.p (predicted by RFC 4592, not observed); cookie tossing.

🤖 Generated with Claude Code

mkotelnikov and others added 6 commits September 20, 2026 10:15
A relay page and a ServiceWorker that answer every non-shell path of a
session origin -- index.html included -- over the MessagePort a ghost app
hands relay.html. The ghost side is webrun-http-browser's relay mode
(newRemoteRelayChannel + initHttpService), wrapped as openSession() in the
package's ./client export.

What the shell adds to the library's relay worker: routing at the origin's
root instead of /~<key>/, first-live-registrant-wins registration from
relay.html only, a referrer check on navigations, frame-ancestors on every
worker-made response, and an origin check on the relay's CONNECT that
refuses other sessions. The relay bridges to registration.active rather
than the page's controller: Firefox 155 leaves a second relay page
uncontrolled and the library's bridge then never answers.

Caddyfile: a *.p.httpeers.net block with its own wildcard certificate
through the DNS-01 snippet, frame-ancestors on the shell's files (a test
compares it with the worker's copy), and a Host rewrite so every session
name reads one storage prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Firefox has no Request.prototype.body (checked against 155), so forwarding
request.body sent every POST on with no body, silently. The body now
streams where the runtime can and is read whole where it cannot. A test
hides the property on one request to stand in for that browser.

The README states what contain is not -- a boundary against a hostile app,
measured 2026-09-15 -- and points untrusted apps at session origins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deploy.mjs writes the one p.httpeers.net prefix every session name reads:
refuses an unmounted bucket, creates the directory only with --create,
deletes only stale _shell/ files, writes relay.html last.

live-check.mjs checks fresh random names on the real domain: DNS, the
*.p.httpeers.net certificate and its issuer, byte-identical files with
frame-ancestors on two names, .site/ not served, and in Chromium and
Firefox that another session cannot hand a relay its port and a foreign
site cannot frame a session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub serves a small app at /spa and advertises it (kind "app"); the app
page's 'Open in a new session' opens it in a fresh <random>.p.httpeers.net
through the session shell's openSession, with httpeers-ghost's pinnedPeer as
the port's handler -- the app reaches the hub and nothing else. This
replaces the same-origin ghost iframe, whose frame could read the viewer's
storage and DOM.

session-smoke.mjs runs it on the real domains in Chromium and Firefox: the
app renders in two session origins, served by each session's worker, with
a root-absolute /api/hello reaching the hub; storage, cookies, IndexedDB
and worker registrations are isolated between the sessions and from the
viewer; parent DOM access and top navigation are blocked; a top-level
visit is refused.

The shared Vite config drops emitted assets nothing references: the
webrun-http-browser entry's new URL(..., import.meta.url) defaults made
Vite ship a dead 91 KB copy of the library with the app page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y model

deploy/README: the A *.p record and why it is required (the empty
non-terminal the ACME challenge creates), checking the *.p certificate
with a never-used name, applying a Caddyfile change in place with a
reload, and that p.httpeers.net is the shared session shell.

security-model: session origins as the provisioned own-origin variant,
what was measured for them, and how the demo builds its broker's calls
from the pinned peer alone. Root README: layout lists the demos and the
session shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n's own header

The rebase onto main crossed two changes this branch predates.

`@statewalker/webrun-http-browser` 0.5 (main moved every other package to it):
the session shell now asks for ^0.5.0 too, so the lockfile carries one copy
rather than 0.4.2 beside it. 0.5 only adds to 0.4.2's exports -- the relay mode
the shell is built on (`newRemoteRelayChannel`, `initHttpService`,
`callChannel`, `handleChannelCalls`, `sendHttpRequest`) is untouched, and its
own `SwHttpAdapter` recovery work is not on this path.

The membership token travels in `x-httpeers-token`, never `Authorization`
(5fa3dcf): `pinnedPeer` sets it through `setMeshToken`, so the session demo's
test reads the token from that header instead of asserting `Bearer TOKEN` on
`Authorization` -- which is now the app's own header and passes through.

Also from the rebase, in the commits themselves: the demos' join form is the
shared widget's, so `session-smoke.mjs` fills `.hp-join-input` and presses
`.hp-join-submit` (`#invite`/`#join` went away with the old form).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mkotelnikov
mkotelnikov merged commit c361301 into main Sep 20, 2026
3 checks passed
@mkotelnikov
mkotelnikov deleted the feat/session-domains branch September 20, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant