- TypeScript 96.3%
- Go Template 1.9%
- Dockerfile 1.8%
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>
|
||
|---|---|---|
| .forgejo/workflows | ||
| charts/app | ||
| src | ||
| wiki | ||
| .gitignore | ||
| .prettierignore | ||
| biome.json | ||
| bun.lock | ||
| Dockerfile | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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.
- 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.
- Within a minute, PR #1 opens on the
primebranch, 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. - 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 itor⚠️ Ship with nitsauto-merges and releases v1 tohttps://<app>-<owner>.<domain>;❌ Blockers foundsends the agent back to fix things (bounded, then it hands to you). - To iterate after v1: open an issue describing what you want and add the
agent-worklabel. 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 togit.plat.localvalidates 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/mereturns{ user: { login, perm, manage } }straight from the headers./api/manageis an example manage-gated route — it returns403unlessX-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:
- 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. - 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.
-
/metrics—src/server.tsregistersfastify-metrics(prom-client under the hood) and exposes Prometheus text atGET /metrics. It carries default Node process metrics plus anhttp_request_duration_secondshistogram labelledmethod/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). -
ServiceMonitor —
charts/app/templates/servicemonitor.yamlrenders a Prometheus-OperatorServiceMonitorpointing Prometheus at the Service's namedhttpport/metrics, every 30s. It's double-guarded: it only renders whenmonitoring.enabled(defaulttrue) and the cluster actually has themonitoring.coreos.com/v1CRD — so a cluster without the operator still renders the chart cleanly. -
Default Grafana dashboard — FREE —
charts/app/templates/grafana-dashboard.yamlrenders a ConfigMap labelledgrafana_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:
- 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). - A Kubernetes Secret
app-authin each app namespace, containing:FORGEJO_CLIENT_IDFORGEJO_CLIENT_SECRETBETTER_AUTH_SECRET(random 32 bytes)BETTER_AUTH_URL(the public URL for that env)
- A mirror of
cert-manager/plat-local-ca-tlsinto 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
releaseflag, 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.
releaseflags MUST carry aretiredate — 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.opsflags are kill-switches;permissionflags are long-lived role gates;experimentflags 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 flaguserOverridable: trueto let each user toggle or reorder their own UI (ui.compact-navand 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.