Use this template — an agent builds and ships your app. The repo name + description you enter become the v1 spec. https://git.open-platform.sh/plat/_app-template
  • TypeScript 96.3%
  • Go Template 1.9%
  • Dockerfile 1.8%
Find a file Use this template
plat d984e4902b
All checks were successful
guard-ppt / no-ppt (push) Successful in 0s
sync-env / sync-env (push) Has been skipped
check / check (push) Successful in 53s
fix(docker): install from the committed lockfile so CI builds are reproducible
Every app generated from this template fails `bun run build` in CI while
building fine on a developer's machine.

The build stage copied only package.json and ran a bare `bun install`, so CI
resolved every caret range fresh and ignored the committed bun.lock. All the
deps here are unpinned (`better-auth: ^1.1.16` and friends), so CI silently
built against newer minors than the lockfile.

better-auth has since tightened OAuthMappedUser: the genericOAuth mapProfile
callback must no longer return `id`. Against the newer resolve, src/auth.ts:181
fails to compile:

  src/auth.ts(181,11): error TS2322: Type '(p: Record<string, unknown>) => {
  id: string; ... }' is not assignable to type '(profile: GenericOAuthUserInfo)
  => OAuthMappedUser | Promise<OAuthMappedUser>'. Types of property 'id' are
  incompatible. Type 'string' is not assignable to type 'undefined'.

Reproduced outside CI: with bun.lock present `tsc` exits 0; installing from
package.json alone resolves typescript 5.9.3 / @types/node 22.20.1 / a newer
better-auth and reproduces the exact TS2322 above. Copying bun.lock and adding
--frozen-lockfile restores exit 0.

Pinning to the lockfile is the correct fix regardless of this particular type
change: a container build that re-resolves its dependency graph on every run is
not reproducible, and the failure mode is a build that breaks with no commit to
blame. Dependency bumps should be a deliberate lockfile change, reviewed like
any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:21:47 +00:00
.forgejo/workflows workflows: tag pushes spawn only release.yml 2026-07-23 10:18:31 -05:00
charts/app Merge pull request 'app chart: soft node-affinity steering web+worker pods to app-worker nodes' (#7) from feat/app-worker-node-affinity into main 2026-07-17 16:51:24 +00:00
src flags: previews auto-light boolean release flags (reviewers see dark features) 2026-07-18 13:02:44 -05:00
wiki wiki: apps document themselves — wiki/ seed + publish-on-merge workflow 2026-07-22 20:59:20 -05:00
.gitignore Initial _app-template: src, charts/app, Dockerfile, release workflow 2026-06-01 00:34:43 +00:00
.prettierignore Initial _app-template: src, charts/app, Dockerfile, release workflow 2026-06-01 00:34:43 +00:00
biome.json CI quality gate: biome + tsc --noEmit + boot-and-hit route smoke (#2) 2026-06-02 00:14:52 +00:00
bun.lock feature flags: platform-native layer (flags.ts) + flag-debt gate + build doctrine 2026-07-18 13:01:09 -05:00
Dockerfile fix(docker): install from the committed lockfile so CI builds are reproducible 2026-08-18 16:21:47 +00:00
package.json RBAC-3: trust platform forwardAuth headers (X-Plat-*) instead of in-app OAuth (plat/mitosis#30) (#3) 2026-07-01 17:44:03 +00:00
README.md wiki: apps document themselves — wiki/ seed + publish-on-merge workflow 2026-07-22 20:59:20 -05:00
tsconfig.json Initial _app-template: src, charts/app, Dockerfile, release workflow 2026-06-01 00:34:43 +00:00

plat _app-template

How apps get built here

This template is the front door to the platform's agentic build loop. You describe the app; an agent builds, tests, and ships it.

  1. Click "Use this template". The repo name and description ARE the v1 spec — write a real sentence ("a quick poll app: create a question, vote once, see live results"), not a placeholder. Better spec, better v1.
  2. Within a minute, PR #1 opens on the prime branch, titled 🏗️ v1: <app>. An agent is building v1 there in public — watch the commits and the live preview link it posts. Comment on the PR to steer it while the 🏗️ prefix is up.
  3. When the 🏗️ prefix drops, a validator takes over: it signs into the live preview with a real browser, exercises your core flows, and probes security. The platform posts exactly one verdict comment — ✅ Ship it or ⚠️ Ship with nits auto-merges and releases v1 to https://<app>-<owner>.<domain>; ❌ Blockers found sends the agent back to fix things (bounded, then it hands to you).
  4. To iterate after v1: open an issue describing what you want and add the agent-work label. An agent builds it on a branch, opens a PR with a live preview, and the same validator gates it. Issues are your roadmap; agents are your build crew.

Everything below describes what's inside the generated app.


App skeleton consumed by scripts/create-app.sh and the plat MCP server's create_app tool. Every new app generated from this template lands with:

  • Fastify 5 + TypeScript runtime (Node 22 LTS). OpenJS-Foundation-backed, battle-tested at Microsoft / Google / Alibaba / AWS scale.
  • Auto-generated OpenAPI 3.1 at /openapi.json (+ a Swagger UI at /openapi). Every typed route's TypeBox schema flows directly into the spec. There is no separately-authored manifest file. The code IS the contract.
  • Forgejo SSO — two selectable modes (see Authentication below): the default in-app better-auth OAuth flow (Sign in with Forgejo button, signed-in users see their name and a Sign out link), or platform header-trust where the platform edge authenticates and the app just reads verified headers.
  • SQLite session DB at /data/auth.db (PVC), or per-app Postgres when the app opts into DB-mode (see ADR-0012).
  • Local CA trust via NODE_EXTRA_CA_CERTS — the OAuth callback to git.plat.local validates against the platform's self-signed CA without any skip-verify flags.
  • Pino structured logging built in (Fastify's default).

Authentication — two modes

The template supports two auth modes, chosen by the PLAT_FORWARD_AUTH environment variable. The default (flag unset) is unchanged — existing apps are byte-for-byte identical until they opt in.

Default: in-app better-auth OAuth (PLAT_FORWARD_AUTH unset)

src/auth.ts runs a full Forgejo OAuth flow via better-auth: /api/auth/* handles sign-in/out, sessions live in the per-app Postgres, and /api/me reads the session cookie. This is what the platform provisions today (the app-auth Secret with FORGEJO_CLIENT_ID / FORGEJO_CLIENT_SECRET / BETTER_AUTH_SECRET / BETTER_AUTH_URL). Nothing about this path changed.

Header-trust: the platform edge authenticates (PLAT_FORWARD_AUTH=1)

Set PLAT_FORWARD_AUTH=1 and the app runs zero auth code of its own (RBAC-3, plat/mitosis#30). The platform edge — RBAC-2's Traefik forwardAuth access service — authenticates the caller against Forgejo, runs the repo-permission probe, and injects three verified headers before the request ever reaches the app:

Header Value Meaning
X-Plat-User Forgejo login the authenticated identity
X-Plat-Perm read | write | admin the caller's repo permission (collapsed)
X-Plat-Manage 1 when write+, else 0 the manage surface is unlocked

src/platform-auth.ts reads these; src/server.ts wires them in:

  • /api/me returns { user: { login, perm, manage } } straight from the headers.
  • /api/manage is an example manage-gated route — it returns 403 unless X-Plat-Manage=1. Copy this pattern for any admin/manage surface.

Why trusting the headers is safe — and why the edge is the gate. The headers are authoritative in-process for exactly two reasons, and both must hold:

  1. The app is reachable only through Traefik. In header-trust mode you MUST NOT expose the app on a route that bypasses the edge — there is no in-app re-verification (that is the entire point: no per-app auth code), so a bypass path would let a client set X-Plat-* itself.
  2. The middleware chain strips any client-supplied X-Plat-* on the way in, then forwardAuth re-injects the verified trio. A spoofed header from the browser never survives to the app.

Fail-closed. If X-Plat-User is absent, the app treats the request as unauthenticated (401) — never as an anonymous allow. So a misconfigured deploy that somehow skips the edge cannot leak the app. (Because of this, header-trust mode assumes the app requires a signed-in user; the forwardAuth service handles the anonymous-on-public-repo case at the edge, before the app is reached.)

Live E2E deferred. A full end-to-end validation needs a germinated platform with the forwardAuth edge enabled and an app deployed with PLAT_FORWARD_AUTH=1. The header-decision logic is unit-tested here (src/platform-auth.test.ts); wire the edge in a germinated cluster to validate the redirect/inject path.

The spec-from-code contract

Routes are declared with TypeBox schemas:

import { Type } from "@fastify/type-provider-typebox";

app.post(
  "/api/notes",
  {
    schema: {
      summary: "Create a note",
      tags: ["notes"],
      body: Type.Object({
        title: Type.String({ maxLength: 200 }),
        body: Type.Optional(Type.String()),
      }),
      response: {
        201: Type.Object({ id: Type.String(), createdAt: Type.String() }),
      },
    },
  },
  async (req, reply) => {
    // req.body is fully typed: { title: string, body?: string }
    const note = await createNote(req.body);
    return reply.status(201).send(note);
  },
);

That same schema drives: runtime validation, response serialization, AND the OpenAPI document. Add a route, restart the app, the new endpoint shows up at /openapi.json with full request/response types. The plat workflow service crawls /openapi.json from every app and uses it to generate typed forms in the visual builder. No drift between the code and the contract because there is no separate contract.

Platform-specific OpenAPI extensions

Use these x-plat-* fields on routes when needed:

  • x-plat-emits: array of CloudEvent types this route emits (the workflow registry picks these up for event-trigger discovery)
  • x-plat-auth: override the app's default auth mode for this route (forgejo-oauth | mcp-bearer | public)
  • x-plat-deprecated-by: link to the replacement endpoint when this one is being phased out; the workflow service surfaces a deprecation badge to pinning workflows

These are standard OpenAPI vendor extensions; any OpenAPI tool ignores them cleanly.

Metrics & dashboards

Every app ships with first-class observability — no per-app wiring required.

  • /metricssrc/server.ts registers fastify-metrics (prom-client under the hood) and exposes Prometheus text at GET /metrics. It carries default Node process metrics plus an http_request_duration_seconds histogram labelled method / route / status_code — request rate, latency and status all derive from that one histogram. The endpoint is unauthenticated (it's only reachable cluster-internally, scraped by Prometheus) and is kept out of /openapi.json (operational surface, not app contract — it's registered before @fastify/swagger, so the spec never sees it).

  • ServiceMonitorcharts/app/templates/servicemonitor.yaml renders a Prometheus-Operator ServiceMonitor pointing Prometheus at the Service's named http port /metrics, every 30s. It's double-guarded: it only renders when monitoring.enabled (default true) and the cluster actually has the monitoring.coreos.com/v1 CRD — so a cluster without the operator still renders the chart cleanly.

  • Default Grafana dashboard — FREEcharts/app/templates/grafana-dashboard.yaml renders a ConfigMap labelled grafana_dashboard: "1", which the kube-prometheus-stack Grafana sidecar auto-discovers and loads. The default board (scoped to the app's namespace) shows request rate by status, 5xx error rate, p95 latency, and process memory. Nothing to click — it appears in Grafana once the app is deployed.

Adding your own dashboard

The default is just one labelled ConfigMap; the Grafana sidecar loads every ConfigMap carrying grafana_dashboard: "1", so the default and your custom boards coexist. To ship an app-specific dashboard, drop a sibling template, e.g. charts/app/templates/grafana-dashboard-custom.yaml:

{{- if .Values.monitoring.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-dashboard-custom
  labels:
    grafana_dashboard: "1"
data:
  my-board.json: |
    { ...exported Grafana dashboard JSON... }
{{- end }}

Scope your panel queries to namespace="{{ "{{" }} .Release.Namespace {{ "}}" }}" the same way the default board does, and your app's metrics light up alongside the freebies.

Opting out

Set monitoring.enabled: false in the app's HelmRelease values to drop both the ServiceMonitor and the dashboard ConfigMap.

Per-app substrate the generator provisions

scripts/create-app.sh / mcp.create_app create these alongside the repo:

  1. A Forgejo OAuth application registered for the app, with redirect URIs for both prod (<app>.<owner>.plat.local) and dev (dev--<app>.<owner>.plat.local).
  2. A Kubernetes Secret app-auth in each app namespace, containing:
    • FORGEJO_CLIENT_ID
    • FORGEJO_CLIENT_SECRET
    • BETTER_AUTH_SECRET (random 32 bytes)
    • BETTER_AUTH_URL (the public URL for that env)
  3. A mirror of cert-manager/plat-local-ca-tls into the app namespace, mounted into the pod at /etc/ssl/certs/plat-local-ca.crt.

The chart's HelmRelease pulls all of these into the running pod via envFrom and a secret volume mount. In header-trust mode (PLAT_FORWARD_AUTH=1) none of the OAuth/BETTER_AUTH_* env is required — the app imports src/auth.ts lazily and only in the default mode, so a header-trust app needs no auth secrets.

What ships in the box

Path What
Dockerfile Multi-stage Node 22 build.
src/auth.ts better-auth wired to Forgejo via the generic OAuth plugin (default mode).
src/platform-auth.ts Header-trust auth: reads verified X-Plat-* from the edge (PLAT_FORWARD_AUTH=1).
src/server.ts Fastify app: /api/auth/*, /api/me, /api/manage, /version, /.
charts/app/ Helm chart: Deployment + Service + PVC. Chart name is renamed to the app name by CI.
.forgejo/workflows/release.yml Tag-driven build + chart push.
wiki/ The app's living docs — published to the repo's Wiki tab on every merge to main.

Replacing this with your real app

Start at src/server.ts. In the default mode the auth wiring in src/auth.ts doesn't need to change — it reads OAuth config from env. Call auth.api.getSession(...) to gate any route on a logged-in user; see /api/me for the shape. In header-trust mode (PLAT_FORWARD_AUTH=1) call identityFromHeaders(req.headers) from src/platform-auth.ts for the current user and gate manage surfaces with canManage(...); see /api/manage.

The static homepage in server.ts is meant to be replaced. Move HTML out to a template (Pug/EJS/React/etc.) when it gets non-trivial.

Feature flags — merge dark, release later (src/flags.ts)

Every app ships a lean, platform-native flag layer: declarations in src/flags.ts, runtime overrides in the app's own Postgres (one plat_flags table, auto-created), resolution env kill-switch → user → team → global → code default, and a small HTTP surface (GET /api/flags, PUT /api/flags/:key for managers, PUT /api/flags/:key/me for a user's own toggles, /.well-known/plat/flags discovery). No SDK, no external service; with no database configured it degrades to env + code defaults.

The build discipline this enables — read this if you're an agent building features here:

  • Prefer a flag over a dependency edge. Issue dependencies are for true CONTRACT dependencies (a sibling app consumes your API). Feature sequencing inside one app should NOT be an issue chain — build each feature now, merge it gated OFF behind a release flag, and let a later flip release it. PRs stay small, merge order stops mattering, and one slow feature never blocks the wave behind it.
  • Declare honestly. release flags MUST carry a retire date — the date the flag itself gets deleted. The suite's flag-debt test turns red past that date, which the platform converts into cleanup work automatically. ops flags are kill-switches; permission flags are long-lived role gates; experiment flags are A/B.
  • Role-tailored UX for free. In forwardAuth mode the caller's identity is already verified; scope overrides to team:<forgejo-team> to give org teams (roles) different defaults. Mark a flag userOverridable: true to let each user toggle or reorder their own UI (ui.compact-nav and ordering arrays like ["nav-a","nav-b"] are the intended pattern).
  • Gate at the edge of the surface, not deep in the logic. One flag check where the feature mounts (a route, a component, a nav entry) — not sprinkled conditionals. When the flag retires, the gate deletes cleanly.

Previews light release flags up automatically: a PR preview build (APP_VERSION = 0.0.0-pr.*) resolves boolean release flags to ON — the reviewer sees the dark feature; prod keeps the OFF default until the flip. Explicit overrides (env / user / team / global) still win in previews.