<!-- Markdown rendering of architecture.html for AI agents.
     The HTML page is the same content with diagrams and styling;
     this view exists so an agent spends its context on the instructions. -->
# Built as a module, _ready to stand alone_

Apps started inside PitchStation because the first users were PitchStation users with prototypes to show. Its scope is growing, and it may later run as a separately hosted service or become a platform of its own. This page records how it is built today, the exact places it leans on the rest of PitchStation, and the staged design that lets it leave without breaking an app, a URL or a client.

## §01 The pieces

[Diagram: Clients reach the control plane only through the Apps API. The control plane drives each app host over SSH. The amber box lists everything Apps currently takes from the rest of PitchStation — the seams a separation has to cut.]

| Layer | What it is | Where |
| --- | --- | --- |
| API | Apps, targets, env, deploy, rollback, preview, stop; publishing configuration, plans, verification, operations, readiness, health and receipts | backend/routes/apps.js backend/routes/app-publishing.js |
| Registry | Apps, releases, encrypted env, budgets, placement grants, quotas | app-store.js |
| Tenancy | The one place a code path decides whether an app may use a host and loads its credential: the host's owner is the app's owner, or the app holds a pool grant. Placement, per-person quota and memory admission | app-targets.js |
| Guard rails and budgets | Compose rules checked on the upload and on the host's resolved model; per-app systemd slices through a platform override and `COMPOSE_FILE` | app-compose-policy.js · app-deployer.js |
| Operator surface | Host check and onboarding, live/pool approval, quotas, diagnostics and the Compose report, reconciliation — on the admin page, no restarts | routes/admin-app-hosts.js · app-host-onboarding.js · app-host-diagnostics.js · app-reconcile.js |
| Runtime view | Container status, redacted logs, usage against the budget; failed-step output | app-runtime-status.js · app-redact.js |
| Publishing configuration | Desired and applied domains and access, revisions, plans, per-host state, hostname claims | app-config-store.js · app-domain-service.js · app-policy-service.js · app-publishing-schema.js |
| Operations | Durable operations with steps, leases, fences, retry and cancel | app-operation-store.js · app-operation-worker.js · publishing-target-lock.js |
| Host adapter | Compose release plan; nginx vhost, certificates and the policy runtime; atomic apply and restore | app-deployer.js · app-host-config.js |
| Evidence | Readiness probes, publication monitor, certificate status | app-readiness.js · app-publication-probes.js · app-publication-monitor.js · app-certificate-status.js |
| Packaging | Directory → validated tar.gz (`.pitchstationignore`, local-database exclusions); Vite + React and Next.js (standalone, npm or pnpm, workspaces) scaffolds | app-source-bundle.js · app-framework-package.js |
| Host runtime | The code that runs on app hosts: the policy verifier; also the host reporter that onboarding installs, the certificate renew hook and, on hosts with restricted egress, the egress unit | backend/runtime/app-policy-server.cjs app-host-telemetry.js · app-host-config.js · app-host-egress.js |
| Clients | Web page, standalone client (generated), MCP tools, skill, OpenAPI, these docs | frontend/apps.html · mcp/pitchstation-apps/ · mcp/pitchstation-mcp-server.js · mcp/create-pitchstation-app/ · backend/config/openapi-apps.json · frontend/apps-docs/ |

## §02 What Apps borrows from PitchStation

Everything a separation must replace or connect. Measured from the code's imports and shared tables, not from intent.

| Dependency | Today | Coupling |
| --- | --- | --- |
| Identity and capability | `verifyToken` (JWT and `pst_` tokens) and `requireCapability('app.deploy')` from the core auth middleware; users live in the core user store | Every request |
| Target registry | Hosts are rows in the Websites module's `website_deploy_targets`, promoted with a `runtime` column; one host can serve sites and apps | Shared table |
| SSH transport | `runPlan`, remote/rsync runners, credential handling and per-target queues come from `website-deployer.js` | Code import |
| Secrets | Target keys and app env are encrypted with the server's `DEPLOY_SECRET` | Shared key |
| Hostname namespace | Claims are checked against website publications and base domains so a site and an app can never take the same name | Cross-product rule |
| Owner alerts | The publication monitor emails owners through the core notifier and user store | Code import |
| Storage | Apps tables live in `data/website-deploy.db` beside the Websites tables; backed up with everything else | Shared file |
| Hosting of the surface | `/apps.html`, these docs, the downloads, the OpenAPI file and the llms.txt entry are served by the main server | Deployment |

## §03 Principles that make leaving cheap

**The API is the boundary**

Every client uses only `/api/apps` over HTTPS, with the origin from `PITCHSTATION_URL`. Moving the API moves nothing in the clients but one variable.

**Docs travel as one folder**

`frontend/apps-docs/` holds its own HTML, stylesheet and script and links between its pages relatively. It can be served from any origin.

**The host protocol is pinned**

The policy verifier, its image digest, the neutral nginx default and the renewal hook are checked by hash on every apply. Hosts do not care where the control plane runs.

**Operations are durable**

Intent, source and progress are persisted, so the worker can run as its own process and survive restarts; idempotency keys make every retry safe.

**Seams become named ports**

Each dependency in §02 gets a small interface with an in-PitchStation adapter today. Leaving means writing the other adapter, not rewriting Apps.

**Contracts are tested at the seam**

The 27 September outage was a seam bug: the host view of a path differed from the control plane's. Every seam gets a contract test like the relocated-mount test that now guards it.

## §04 Ports to extract

| Port | Contract | Adapter inside PitchStation | Adapter when separate |
| --- | --- | --- | --- |
| Identity | `authenticate(request) → {userId, email, capabilities}` | Core auth middleware | Verify PitchStation-issued tokens via a published signing key (JWKS) or introspection; later, its own accounts with PitchStation as one sign-in provider |
| Entitlement | `may(user, "app.deploy")`, quotas per user | Capability grant | Plans and quotas (apps, targets, regions) |
| Targets | `listTargets(user)`, `targetWithSecret(id)` | Websites target registry | Own target table; self-serve host onboarding |
| Transport | `runRemote`, `runRsync` with capped output, deadlines and fences | Imported from the Websites deployer | Moved into the Apps package unchanged |
| Hostname registry | `claim` / `release` / `assertAvailable(hostname)` | Shared claims table | A small shared registry service, or disjoint base domains per product |
| Secrets | `seal(value)` / `open(blob)` | `DEPLOY_SECRET` | A managed key service |
| Notifications | `alert(owner, event)` | Core notifier | Own email and webhooks |

## §05 Staged path

| Stage | What changes | Risk | State |
| --- | --- | --- | --- |
| **A · Surface** | Dedicated docs folder; standalone client; MCP app tools; OpenAPI and every default on the canonical origin; clients take the origin from one variable | None to running apps | done 27 Sep 2026 |
| **B · Module boundary** | Move the Apps services and routes into one module directory with a `ports` file; a test forbids any other import from core; move Apps tables into their own SQLite file (copy, verify row counts, switch), leaving the hostname claims shared | Low: data move is verified and reversible | next |
| **C · Separately hosted** | The same code runs as its own container (API + worker) on its own host. Users keep one domain: the CDN routes `/api/apps/*` to the new origin (or it gets `apps-api.pitchstation.ai`). Identity through PitchStation-issued tokens. App hosts admit SSH from the new service instead of production. Either side can be down without taking the other with it. | Medium: identity and routing cut-over; rehearse on staging | when scope or load needs it |
| **D · Derived platform** | Own name and domain; own accounts with PitchStation as a sign-in provider; self-serve host onboarding; billing per app or host; wildcard-TLS and Caddy adapters (refused as `ADAPTER_UNSUPPORTED` today); multi-region placement; public status page | Product decision, not a refactor | option |

**Trigger for Stage C.** Any one of: Apps load (builds, uploads, SSH fan-out) measurably affects document sharing on the shared production host; a customer needs Apps without PitchStation accounts; or Apps needs a release cadence independent of PitchStation's.

## §06 What stays stable for users

- **App addresses** — `<slug>.apps.pitchstation.ai`, `.apps-ap.` and custom domains live on the app hosts, which do not move.
- **The API** — paths, bodies, ETags, idempotency and operation states. A separated service serves the same OpenAPI document.
- **The clients** — the Apps client, MCP tools and skill change at most one variable (the API origin).
- **Running apps** — no stage restarts an app; host routes and policies are only rewritten by an explicit apply.

## §07 Open decisions

- **Name and domain** if Apps becomes its own product; these docs keep the product name in page text only, so a rename is a text change.
- **Hostname registry**: one shared service for Websites and Apps, or separate base domains per product.
- **Billing model**: per app-month, per host, or bundled with a PitchStation plan.
- **Host fleet ownership**: who patches and monitors app hosts, and whether customers may bring their own.
- **Who can publish**: teammates publish on a shared host pool since 27 September (milestone 1). Milestone 2 (28 September) built the public boundary: an isolated host per developer inside a spend cap, strict hosts, egress controls, abuse reporting and takedown, terms. Still a product decision: whether to open sign-up, bill through Stripe, and accept bring-your-own hosts.
- **Legacy routes**: move the six pre-September apps onto the managed route proactively, or on their next change.
- **Custom-domain acceptance**: the first production custom domain is the acceptance test for ownership proofs.

## §08 What milestone 2 added

| Piece | Where | Seam it adds |
| --- | --- | --- |
| Policy verifier v2 (sign-in page, cookie, access links) | `backend/runtime/app-policy-server.cjs`, versioned file on each host | Host protocol: reads version-1 policies; the runtime step replaces a known earlier verifier in place and refuses an unknown one. Keys are derived from `DEPLOY_SECRET`; the control plane stores none, and each host holds them only in the app's root-only applied policy file. |
| Strict hosts and new rules | `app-compose-policy.js`, the platform override | None new: the same checker at dry run and on the host |
| Egress policy | `app-host-egress.js`, one nftables table and a oneshot unit per host | Host protocol (never `nftables.service`, which flushes Docker's rules) |
| Abuse, takedown, terms | `app-abuse.js`, `/api/app-abuse-reports`, `/abuse.html` | A public, unauthenticated endpoint (rate-limited) |
| Backups | `app-backups.js`; control-plane copies in `/data/pitchstation/backups/apps` | Storage: copies leave the server with the platform's backup mirror (history/apps/<id> in the bucket); app hosts hold no cloud credential |
| Per-app DNS | `app-dns.js` (Route 53, SigV4 in-repo) | A zone-scoped DNS credential on the control plane |
| Developer hosts and spend | `app-host-requests.js`, `app-spend.js`, `scripts/pitchstation-app-host.sh` | The operator's scoped AWS profile (`scripts/iam/pitchstation-apps-host-policy.json`); the control plane holds no cloud credential |
| Moves and evacuation | `app-move.js` | Two host leases in sequence; routing through the normal operation path |
| Compose exemptions | `app-policy-exemptions.js` | None new: an admin-granted, audited list the one checker consults at the dry run, on the host, in moves and in the report; shared hosts ignore it |
| Host reporter (telemetry, growth proposals) | `app-host-telemetry.js`, `POST /api/app-hosts/:id/report`, a systemd timer that onboarding installs | A per-host report token: only its hash is stored; on the host it sits in a root-only header file, never on a command line. The route accepts nothing else. |

### Operator runbook: per-app DNS

1. In Route 53, create hosted zones `apps.pitchstation.ai` and `apps-ap.pitchstation.ai`. In each, recreate the wildcard the registrar holds today (`*.apps` → Oregon's Elastic IP, `*.apps-ap` → Singapore's) and any other record under those names.
2. At Namecheap (Advanced DNS for pitchstation.ai), add NS records for the hosts `apps` and `apps-ap` pointing at each zone's four name servers, then remove the old wildcard A records there.
3. Create an IAM user limited to `route53:ChangeResourceRecordSets` and `route53:ListResourceRecordSets` on those two zones (plus `route53:GetChange`), and put its key in production's `.env.production` as `APP_DNS_AWS_ACCESS_KEY_ID` / `APP_DNS_AWS_SECRET_ACCESS_KEY`, with `APP_DNS_PROVIDER=route53` and `APP_DNS_ZONES=apps.pitchstation.ai=<id>,apps-ap.pitchstation.ai=<id>`.
4. Admin page → Developer hosts → Per-app DNS → Read the records now: every app's record appears at its next routed operation.

### Operator runbook: developer hosts

1. Create the IAM policy `PitchStationAppHostProvisioner` from `scripts/iam/pitchstation-apps-host-policy.json` and a user `pitchstation-apps` with only that policy; `aws configure --profile pitchstation-apps` on the operator machine.
2. For each reservation on the admin page: `scripts/pitchstation-app-host.sh launch ID --approve` (`PITCHSTATION_TOKEN=` an admin's token, in the environment or in `~/.pitchstation/.env`; the script does not read `PITCHSTATION_APPS_TOKEN`, and the Apps setup's “Save the token” box rewrites that file). It launches, reports, onboards, checks and approves; without per-app DNS it prints the one wildcard record to create.
3. When a developer releases a host: `scripts/pitchstation-app-host.sh terminate ID`.

## §09 What milestones 3 and 4 added

Built from the development plan of 30 September 2026: faster deploys (prebuilt images, a build ceiling, deploy settings, packaging) and private previews (callback paths, caching, account sign-in, link use).

| Piece | Where | Seam it adds |
| --- | --- | --- |
| Image archives (prebuilt images) | `app-image-archives.js`; `PUT /api/apps/:id/images`; control-plane copies in `<APP_ARTIFACT_ROOT>/images` | Storage: up to `APP_IMAGE_MAX_MB` (1 GB) per archive, three kept per app plus the one the current release uses; left out of the backup mirror (they can be rebuilt from source). The upload streams through the platform's edge, so its body-size limit must allow it. |
| Build ceiling | `app-host-builder.js`; `pitchstation-build.slice` and the buildx builder `ps-builder` on each host | Host protocol: onboarding installs both (level `build`, never gating); a deploy uses the builder only when the host reports it installed |
| Deploy settings | `app-deploy-settings.js`; `PATCH /api/apps/:id/settings` | None new: commands run inside a service's container with `docker compose run --rm`, split into words; the host shell never sees them |
| Account sign-in for private apps | `routes/app-enter.js` (`/api/app-enter/:id`); policy verifier v3 | A browser round trip through PitchStation: the platform checks the person against the applied policy and hands the app a five-minute token signed with the app's link key. The host never calls PitchStation. |
| Link use | The verifier's `PSEVENT` log lines; host reporter v2 | None new: the reporter already posts every five minutes; it now adds link opens |
