Enterprise Edition: working notes for agents
Where the Enterprise Edition stands on 2026-10-13, where its code lives in the Seed and hosting repos, how an instance is operated from the hosting console, the gotchas agents have hit, and the open work. Start here before touching EE; the design, security model and security review are linked.

Enterprise Edition: working notes for agents

Written 2026-10-13 by cc@yacht, last updated 2026-10-13 21:30 UTC after the first-run fixes. Sources: the hosting repo (seed-hypermedia/host, local ~/Code/SeedHost) at 4141410, the Seed branch feat/enterprise-edition at e68801f9d plus the fix branch fix/ee-first-run, the Deck handoff, and the live test instance. Update this page in place when the facts change; do not start a second one.

Companion pages: the design (why, phases, proposed changes), the security and privacy model (what is private from whom; partly stale, see below) and the security review of 2026-10-13 (ranked findings and the fix order).

What it is, in one paragraph

An Enterprise Edition (EE) instance is one organization's Seed on one DigitalOcean droplet: Caddy, the web app, an isolated daemon, the identity vault, and optionally notify and agents. Every page needs a member session, the daemon never talks to another node, and accounts exist only through invitations. Seed staff create and operate instances from the hosting console (host.seed.hyper.media, page /admin/enterprise), which drives each droplet over SSH with the Seed deploy engine. The organization gets a managed address <name>.hyper.media and can put its own domain in front of it with a CNAME.

State on 2026-10-13

    Console: live on production and dev. Hosting repo main is deployed on both Render services (seed-host prod, seed-host-dev), with the enterprise migrations and env set. Prod tracks main now (it used to track a stale release branch).

    Seed side: branch feat/enterprise-edition, PR seed#1219, not merged. Eric is to merge it. Until then every instance deploys from deployUrl = raw.githubusercontent.com/seed-hypermedia/seed/feat/enterprise-edition/ops with Docker images tagged feat-enterprise-edition. After the merge, flip the console's Enterprise defaults to the main ops URL and tag latest, then run Configure on each instance.

    One test instance: ee-test-1 in the Seed team DigitalOcean account (droplet 159.203.91.42, nyc3, s-2vcpu-4gb, 50 GB volume). Managed host ee-test-1.hyper.media (redirects to the custom domain); custom domain ee1.botical.com (Eric's personal Cloudflare zone: CNAMEs for the host and for notify./agents. onto the managed host). Space z6Mktz3Fp3xnjJPpcWe3jW1crKQSZC7xQN3mqtwjwTHsiTEi. Runs branch fix/ee-first-run (5c98309). Members: Eric (writer) and the headless test accounts cc-e2e-1..4@example.com (cc-e2e-3 "CC Tester" and cc-e2e-4 "CC Tester 4" are writers, cc-e2e-5 "CC Reader 5" a reader; 1 and 2 are half-created orphans the vault cannot delete yet).

    Earlier instances botical (seed.botical.com) and ee1 were deleted on 2026-10-13; they lived in Eric's personal DO account. Final backups are gitignored under ~/Code/SeedHost/enterprise/backups/ on yacht; the former botical owner key is under ~/Code/SeedHost/enterprise/keys/botical/.

    The CLI is gone. enterprise/cli.ts, the registry file and the GitHub workflows were retired at Eric's request on 2026-10-13; everything is the console. Anything that still describes cli.ts create is stale.

    Two fix PRs against feat/enterprise-edition (2026-10-13 evening), both verified live on ee1.botical.com, which now runs branch fix/ee-first-run (images fix-ee-first-run, engine from that branch's ops URL):

      seed#1228 (engine and proxy): every in-app navigation on an EE node showed "Application Error" because Caddy's forwardauth forwarded Remix's ?_data=routes/$ to /hm/api/session (Remix answered 403); fix uri /hm/api/session? in the gate, the session route drops _data from the post-sign-in destination. Also: the deploy reloads Caddy when only the Caddyfile changed (and no longer skips such a change), and redirect_hostnames in config.json makes the managed host redirect to the custom domain instead of failing TLS (the former #1230, folded in).

      seed#1229 (invitation flow): links are now <site>/hm/sign-in?invite=…&email=…; the site starts the delegation with the invitation attached, the vault creates the account and completes that delegation, and the person lands on the site signed in and joined (the site subscription the Join button makes is created on arrival). No second sign-in, WRITER granted on the way; the vault's admin listing reports each invitation's accounts so the console can name readers too. Headless walkthrough passed: invite → account → home (Join button gone on its own) → People tab → new document published → comment posted.

      Hosting console main 9a5d960 (deployed prod + dev): accepts the new link form, says the managed address redirects, lists notify./agents. CNAMEs in the custom-domain step, and the Members list refetches every 10 s with each joined person's profile name linked to https://<host>/:profile/<accountUid> (profile pages are /:profile/<uid>, not /hm/<uid>).

    In flight on the Seed branch at writing time: e68801f9d (an invitation link creates the account without a second emailed code) with Docker images building in run 38439216997; 4ea10d797 (grants in the admin invitation listing, run 38437397620). Each needs a Configure job on ee-test-1 before the console's Members list can show grants.

Where the code is

Hosting repo (~/Code/SeedHost, GitHub seed-hypermedia/host)

Path

What

server/enterprise/ops.ts

Every operation: create, deploy, configure, resize, set-domain, backup, bootstrap-space, delete, invite, listInvitations, doctor, status, owner key, setup report

server/enterprise/jobs.ts

pg-boss job runner, one job at a time per instance; JobLogger streams output into enterprise_jobs.log

server/enterprise/engine.ts

Renders the shell sent over SSH: engine install script, deploy flags, relay flags/env, output parsers

server/enterprise/ssh.ts, digitalocean.ts, cloudflare.ts, domain.ts, probe.ts, plan.ts

SSH as root with ENTERPRISE_SSH_PRIVATE_KEY; DO API; hosting-zone DNS records; public-resolver checks; HTTP probes; cloud-init

server/enterprise/settings.ts, store.ts, types.ts

The one settings row and its defaults; instance and job rows; zod schemas shared with the client

server/routes/admin-enterprise.ts

/api/admin/enterprise/*, admin session required (admin = @seedhypermedia.com email)

server/routes/enterprise.ts, server/models/enterprise-email-relay.ts

The public email relay POST /api/enterprise/email

migrations/0003_enterprise.sql, scripts/migrate-enterprise.mjs

Tables enterprise_settings, enterprise_instances, enterprise_jobs; run by Render pre-deploy (npm run db:migrate:enterprise)

client/src/pages/EnterprisePage.tsx, EnterpriseInstancePage.tsx, EnterpriseSettingsPage.tsx, client/src/components/enterprise/*

The console UI; instance page is Overview, Members, Operations, with the rarer tools (custom domain, resize, configuration, organization space, delete) in an Actions menu

README.md "Enterprise Edition instances"

The operator-facing description; keep it current

Tests: npm test (72 unit tests pass at 860730b, including server/enterprise/enterprise.test.ts and the relay tests); npm run check for types.

Seed repo, branch feat/enterprise-edition

Area

What

backend

-p2p.isolated (deny-all connection gater, no bootstrap, relay, AutoNAT, peer exchange), -lndhub.disabled

frontend/apps/web

SEED_REQUIRE_SIGN_IN=1: /hm/api/session is Caddy's forward_auth target; /hm/api/auth checks vault membership; /hm/api/enterprise-bootstrap and /hm/api/enterprise-site-url (admin-secret gated)

vault

SEED_VAULT_INVITE_ONLY, SEED_VAULT_ADMIN_SECRET, admin API for invitations (role, emailed link, grants), invitation_grants table (schema v6), WRITER capability granted in syncVaultAccounts with the daemon's enterprise-owner key

ops/deploy.ts, ops/docker-compose.enterprise.yml, ops/dist/deploy.js

seed-deploy --profile enterprise; commands invite <email> [--role reader|writer] [--no-email], bootstrap-space, register, set-hostname, doctor, backup

hypermedia/build/self-hosting.md "Enterprise profile", hypermedia/apps/vault.md

The published docs on the branch

How an instance is operated

Create. The console validates the name (a DNS label, unique against instances, hosted sites and existing DNS records on the base domain), inserts the row as creating, and queues a job: volume, droplet (Ubuntu 24.04, cloud-init mounts the volume at /opt/seed), firewall, A <name>.<base> and CNAME *.<name>.<base> in the hosting Cloudflare zone, wait for public DNS, install docker, bun and the engine over SSH, deploy --profile enterprise --hostname https://<host> --tag <tag> --email <admin> --yes with the relay flags, wait for the site, bootstrap the organization space, public checks, status ready. Ten to fifteen minutes. A failed create leaves the row failed; Retry deploy repeats the second half, Delete cleans up whatever exists.

Custom domain. Three visible steps in the console: type the hostname, add CNAME <host> → <name>.<base> at the organization's provider (ALIAS or an A record at an apex), check on 1.1.1.1 and 8.8.8.8, then apply. Apply runs the engine's set-hostname, which rewrites the Caddyfile, gets a certificate and republishes the home document. Do this before inviting anyone: passkeys are bound to the hostname.

Members. Invite takes an email and Reader or Writer. The engine's invite asks the vault's admin API; the vault emails the one-time link https://<host>/vault?invite=<code>&email=<email> through the relay; a writer also gets a WRITER capability on the space once the account exists. The Members list reads the vault's admin listing directly (the console fetches vault_admin_secret from /opt/seed/config.json over SSH on each call; nothing is stored on the hosting service).

Email. Instances have no SMTP. The vault and notify post to POST https://host.seed.hyper.media/api/enterprise/email with bearer <name>.<base64url(HMAC-SHA256(ENTERPRISE_RELAY_SECRET, "enterprise-email:" + name))>. The same secret on Render serves both minting and verifying. Rate limits in the relay: 60 per 10 minutes and 500 per day per instance.

Other jobs. Configure (re-installs the engine from deployUrl, redeploys with new tag or toggles), Resize (CPU and RAM only, site offline during it), Backup (seed-deploy backup, archive stays on the droplet, downloadable through the console), Bootstrap space (idempotent), Delete (type the name; optional final backup; optional keep volume; removes the managed DNS records).

Root access for the organization. The owner key <uid>.hmkey.json is created on the host under /opt/seed/owner-key/ by bootstrap-space, downloadable from the console, imported at https://<host>/vault/settings with Import identity.

Credentials and access (pointers only, no values)

    Render services and the API key: see the cc memory render-seedhost-access on yacht and starlight (~/.config/seedhost/render-api-key). Both services carry DIGITAL_OCEAN_TOKEN (Seed team account), CLOUDFLARE_TOKEN and CLOUDFLARE_ZONE_ID (the hyper.media zone), ENTERPRISE_SSH_PRIVATE_KEY, ENTERPRISE_RELAY_SECRET.

    SSH to a droplet as root: ssh -i ~/.ssh/seedhost/enterprise root@<ip> from yacht or starlight. The default key is refused.

    ~/Code/SeedHost/enterprise/.env (gitignored) on yacht holds the Seed team DO token, the relay secret and a CLOUDFLARE_API_TOKEN for Eric's personal zones (botical.com), which the Render token cannot see.

    Admin sign-in to the console needs a @seedhypermedia.com mailbox code. Headless alternative for agents: insert a sessions row for the admin user in the prod Postgres and call the API with that bearer (recipe in the render-seedhost-access memory). Delete the row after.

    Seed's vault admin secret per instance: /opt/seed/config.json on the droplet, field vault_admin_secret. The file is 0644 (security review M7).

Gotchas agents have hit

    Any Render deploy restarts the host and fails every running enterprise job with "hosting service restarted". Sequence console deploys around jobs; a job that was mid-create leaves cloud resources behind, so use Delete and recreate.

    Engine updates reach a droplet only through install or configure jobs. invite, doctor, backup run the installed /usr/local/lib/seed/deploy.js. Order for an engine or image change: push the Seed branch, gh workflow run release-docker-images.yml --ref feat/enterprise-edition (about 18 minutes), run Configure on each instance, then deploy the console change that depends on it.

    After a custom domain is applied, the managed host stops answering over HTTPS. Verified 2026-10-13: https://ee-test-1.hyper.media fails the TLS handshake (Caddy only holds the certificate for ee1.botical.com), plain HTTP gives a 308. The console text says the managed address "stays as the alias", which is only true for DNS. Either add the managed host as a second Caddy site in set-hostname, or change the wording.

    DNS lookups from Render can hang. dns.Resolver sat silent for five minutes inside waitForDomain; a 12 second per-resolver deadline was added in e8b7a06. If a job goes quiet in a DNS step, suspect this first.

    Migrations that only change CREATE TABLE IF NOT EXISTS do nothing on an existing table. base_domain had to be added with ALTER TABLE ... ADD COLUMN IF NOT EXISTS plus a backfill; every loadSettings() threw on prod until then. Add columns explicitly.

    Callbacks passed from a class must be arrow properties. Ops pass log.raw unbound; a method lost this and crashed the whole host process. JobLogger uses arrow properties now; keep it that way.

    A React component defined inside render remounts on every keystroke. The custom-domain input lost focus per character until Step moved to module scope (34f936a).

    Instance names share the *.hyper.media namespace with hosted sites. Uniqueness is checked against the sites table and the zone's records; a name that is already a hosted site is refused.

    The vault with neither relay nor SMTP prints verification codes to container stdout and nobody gets mail (security review M4). The relay must be on, or SEED_SMTP_* set.

    Remix client navigations carry ?_data=routes/$. Anything that forwards the original query to another Remix route (Caddy forwardauth) breaks with 403. Strip the query on the subrequest.

    Caddy does not reload a bind-mounted Caddyfile and compose does not recreate the proxy for it; the engine now runs caddy reload when the rendered Caddyfile changed (PR #1230). Older engines: docker exec seed-proxy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile.

    The web app has its own copy of startAuth (frontend/apps/web/app/auth-session.ts), separate from the SDK's; change both when adding delegation parameters.

    raw.githubusercontent.com caches dist/deploy.js for a few minutes; a Configure job right after a push can install the previous engine. Check with grep -c <symbol> /usr/local/lib/seed/deploy.js on the droplet.

    A custom domain needs notify.<host> and agents.<host> records too when those services are on; without them account creation logs a failed notify registration and the site cannot reach agents.

    Publishing Seed docs: quote a title: that contains a colon, and keep mermaid labels free of parentheses; the global seed-cli must be 0.2.2 or newer to emit tables as table blocks.

Open work

Ordered by what unblocks the most. Items marked Eric need him.

    Eric: merge seed#1219; then flip Enterprise defaults to main ops URL and latest, run Configure on ee-test-1.

    Eric: redeem the writer invitation on ee1.botical.com and confirm the WRITER grant (docker logs seed-vault | grep grantInvitedRoles on the droplet, and the daemon's capabilities for the space).

    Hardening in the security review's fix order, item 1 first: close the pre-session write path (POST /ipfs/*, PublishBlobs) by publishing sign-in blobs server-side; then vault login rate limiting; refuse to start the vault without a mail sender; 0600 on config.json, vault.sqlite and backups; pin the engine and images.

    Fix the managed-host TLS gap after a custom domain (gotcha 3).

    Correct the security model page per the review's "Corrections" table (no -public-only, file modes, cookie name, invitation binding, upload caps, credential story, firewall detail, open paths, vault admin surface).

    Members: a way to remove a member and revoke sessions (review M2); the Members list currently shows invited, joined and writer-pending states only.

    Phase 2 from the design: READER role, members directory in the web app, SSO, private document creation behind a flag, audit log.

    The hosting service logs DNS reconcile errors for four dead custom domains every two seconds; unrelated to EE but noisy in the same logs.

Where to look for history

Deck handoff/TODO.md has the dated, commit-by-commit record of 2026-10-11 to 2026-10-13 (entries titled Enterprise Edition, ee-test-1, EE invites, EE console UX). The hosting repo's git log from 583ba1c to 860730b is the implementation sequence; the Seed branch log from ae58915e0 is the product sequence. Claude Code sessions on yacht on 2026-10-13 under ~/.claude/projects/-Users-ericvicenti-Code-SeedHost/ hold the transcripts; deck sessions enterprise finds them.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime