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.
§01The pieces
| 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/ |
§02What 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 |
§03Principles that make leaving cheap
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.
frontend/apps-docs/ holds its own HTML, stylesheet and script and links between its pages relatively. It can be served from any origin.
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.
Intent, source and progress are persisted, so the worker can run as its own process and survive restarts; idempotency keys make every retry safe.
Each dependency in §02 gets a small interface with an in-PitchStation adapter today. Leaving means writing the other adapter, not rewriting Apps.
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.
§04Ports 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 |
§05Staged 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 |
§06What 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.
§07Open 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.
§08What 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
- In Route 53, create hosted zones
apps.pitchstation.aiandapps-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. - At Namecheap (Advanced DNS for pitchstation.ai), add NS records for the hosts
appsandapps-appointing at each zone's four name servers, then remove the old wildcard A records there. - Create an IAM user limited to
route53:ChangeResourceRecordSetsandroute53:ListResourceRecordSetson those two zones (plusroute53:GetChange), and put its key in production's.env.productionasAPP_DNS_AWS_ACCESS_KEY_ID/APP_DNS_AWS_SECRET_ACCESS_KEY, withAPP_DNS_PROVIDER=route53andAPP_DNS_ZONES=apps.pitchstation.ai=<id>,apps-ap.pitchstation.ai=<id>. - 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
- Create the IAM policy
PitchStationAppHostProvisionerfromscripts/iam/pitchstation-apps-host-policy.jsonand a userpitchstation-appswith only that policy;aws configure --profile pitchstation-appson the operator machine. - 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 readPITCHSTATION_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. - When a developer releases a host:
scripts/pitchstation-app-host.sh terminate ID.
§09What 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 |