- TypeScript 96.3%
- CSS 2.6%
- Go Template 0.5%
- HTML 0.3%
- Dockerfile 0.3%
| .forgejo/workflows | ||
| charts/app | ||
| contract | ||
| design | ||
| src | ||
| web | ||
| wiki | ||
| .dockerignore | ||
| .gitignore | ||
| .prettierignore | ||
| biome.json | ||
| bun.lock | ||
| Dockerfile | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
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.
What this template is
A full app, not a scaffold: a Fastify 5 + TypeBox backend whose typed routes ARE its OpenAPI contract, and a React 19 + Vite SPA with a designed component library, system light/dark + named themes, a command palette, and mobile handled everywhere — private by default, with an append-only audit trail at its foundation. It is built to feel like the tools you love (Linear, Vercel): instant, calm, keyboard-first, quietly beautiful.
| Layer | What ships |
|---|---|
| Security | Private by default and the app gates itself: sign-in door + 401s on data routes with no edge required (app mode, today's default); platform edge header-trust when enabled. Access is managed in Forgejo. |
| Audit | Append-only plat_audit trail; every mutating API call captured with the verified actor — denied attempts included. |
| Contract | TypeBox route schemas → live /openapi.json → committed contract → published typed org clients. The code IS the contract. |
| Design | W3C design tokens with light/dark modes + named themes, served at /tokens.css, org-overridable at runtime with zero PRs. |
| UI | web/src/ui/ — a component library the app OWNS (shadcn philosophy): primitives, overlays, schema-driven data table, resizable panels, writing surface, journeys. |
| Liveness | SSE invalidation stream (publish("topic") server-side → react-query refetch client-side) + optimistic mutations. |
| Flags | Platform-native feature flags (env → user → team → global → default) for merge-dark/release-later discipline. |
| Observability | /metrics Prometheus histogram + free Grafana dashboard via the chart's ServiceMonitor. |
Security — private by default
The model: nobody reaches this app unless Forgejo says they can read its
repo. New repos are private, so a fresh app is reachable by its collaborators
and nobody else. Making an app public, inviting a teammate, granting a team
write — all of it happens in Forgejo's access control (Settings → Collaborators on the backing repo), never in app code. The app's Home screen
shows every signed-in user exactly what access they have and (for managers)
deep-links to the Forgejo page that changes it.
Two auth modes, one rule — the app gates itself either way:
auth.mode: app— the chart default today. In-app Forgejo OAuth (src/auth.ts): anonymous visitors get the sign-in door, every data route answers 401 without a session, and no edge is required for any of it. Do not assume a platform edge is in front of this app — by default there is none. Anything private must be behindrequireUser/requireManagein this repo's code; "the edge handles it" has shipped real data leaks.auth.mode: platform— opt-in until the platform's forwardAuth edge is enabled fleet-wide (the default will flip back then). Header-trust mode (PLAT_FORWARD_AUTH=1): the edge authenticates every request against Forgejo, probes the caller's repo permission, and injects three verified headers; the middleware chain strips any client-suppliedX-Plat-*first, so they cannot be spoofed:
| 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+ |
the manage surface is unlocked |
In app code you never touch headers. src/viewer.ts is the whole API:
import { getViewer, requireUser, requireManage } from "./viewer.js";
app.get("/api/notes", { preHandler: requireUser, schema: { … } },
async (req) => {
// req.viewer is the VERIFIED identity: { user, perm, manage, teams }
return listNotes(req.viewer.user);
});
app.delete("/api/notes/:id", { preHandler: requireManage, … }, …);
- Fail closed. No verified identity →
requireUseranswers 401. A misconfigured deploy that skips the edge cannot leak data — it answers 401 to everyone, which is secure and visibly broken instead of silently public. - Defense in depth (optional, recommended for regulated tenants). Header
trust is a single boundary — the edge stripping client
X-Plat-*. Set the chart'sedgeTokenSecretName(a Secret with keytokenholding the value the edge injects asX-Plat-Edge-Token) and the app also requires that shared secret, compared in constant time, before trusting any identity. A request that reaches the app off the edge path — a bypass route, direct in-cluster pod access — fails closed even with hand-crafted headers. Unset keeps topology-only trust; setting it without the edge injecting the token fails every request closed, secure and visibly so. - Anonymous is explicit. When a repo is made public, the edge lets
anonymous requests through without identity headers. Routes guarded by
requireUserstill refuse them; a route that should serve anonymous readers simply omits the guard — privacy is the default you argue with. - The SPA knows who's looking.
useViewer()returns{ kind: "user", user, perm, manage }or{ kind: "anonymous" }— render manage surfaces behindcanManage(viewer). - Local dev: there's no edge on your laptop.
PLAT_DEV_USER=you bun run devgives you an admin viewer — honored only outsideNODE_ENV=production. - Same code, either mode:
getViewer/requireUser/requireManageare the whole story in both modes — in app mode the identity comes from the better-auth session (the session user carriesperm: "none"), in platform mode from the verified headers. App code never branches on the mode.
Who is asking: anon, user, admin
Three tiers, decided in Forgejo and carried by the edge — never by a list of names in app code (src/contracts/viewer.ts, wiki/Roles-and-Access.md):
- anon — no identity; reaches the app only when it is declared
public: truein the org's ground (share cards) or in local dev. Every/apiroute refuses it unless the exact path is inPUBLIC_READS(src/security.ts). - user — any verified Forgejo identity (a collaborator/team/org member on a private app; anyone signed in on a public one).
- admin — a Forgejo owner (org owners, repo admins), or a member of
PLAT_ADMIN_TEAMS.
Guards: requireUser, requireAdmin, requireManage (write+, the contributor capability), requireTeam("name") — at preValidation. The door in src/security.ts answers every anonymous /api//openapi request 401 before anything else can; src/anon-surface.test.ts walks every route as a stranger. Sign-in on a public app goes through the edge's /_plat/login on this host (src/contracts/edge-auth.ts). Headers/CSP, same-origin mutations and an anonymous rate limit come with it — CSP_EXTRA_SOURCES and ANON_RATE_LIMIT are the knobs.
Audit — the trail is part of the app
src/audit.ts maintains an append-only plat_audit table:
- Automatic: every mutating
/api/*response is recorded — verified actor, route template, HTTP status. 401/403 attempts included: who tried matters as much as who did. Opt a route out withconfig: { audit: false }; enrich withconfig: { audit: { subject: (req) => id } }. - Domain events:
await audit(req, "note.created", noteId, { title })— past-tense dotted names read best in the trail. - Read it at
GET /api/audit(manage-only, cursor-paged, filterable). There is no update or delete surface, and a test pins that. - Never lost, never fatal: without a database, entries land as structured
audit-fallbacklog lines in the pod stream. Auditing failure never breaks a request.
Design system — tokens, modes, themes
design/tokens.default.json is the vocabulary (W3C Design Tokens format).
It compiles at boot to /tokens.css, which every page links first:
- Modes: each color token carries a light
$valueand a dark$extensions["plat.dark"]. The stylesheet follows the system scheme by default; an explicit choice pinsdata-theme="light" | "dark". - Named themes live in
design/themes/*.json— complete palettes (slatea Linear-blue-gray dark,dunea warm sandstone light,contrasta maximal-contrast accessibility theme that follows the system). The compiler REFUSES an incomplete theme in CI. Add a theme by adding a file; it appears in the ⌘K theme switcher automatically. - Org override: the org's design app serves its own
/tokens.css, loaded after this app's — values propagate at runtime, zero PRs. New semantic tokens alias the original org-API roots (--color-accentetc.) as livevar()references, so an org override re-skins the entire component library. - In components you never write colors: Tailwind v4 utilities are mapped
onto the tokens in
web/src/styles/app.css(bg-surface,text-muted,border-line,bg-accent,text-good…) and the stock palette is wiped — a raw hex simply doesn't exist here. CI scans everyvar(…)the UI consumes against the vocabulary. - Theme choice persists in
localStorage(plat.theme) with a no-flash boot script; switching never animates (doctrine).
The web app (web/)
- Stack: React 19, Vite 8, Tailwind v4, Base UI (headless), TanStack Query/Table/Virtual, cmdk, react-resizable-panels, TipTap, sonner, react-router 7, lucide icons, self-hosted Geist Sans/Mono (OFL).
- Structure:
web/src/app/shell + palette + nav;web/src/ui/the component library (readweb/src/ui/CONVENTIONS.mdbefore writing one);web/src/lib/api/viewer/theme/live utilities;web/src/routes/pages;web/src/atlas/+web/src/demos/self-registering galleries. - The atlas (
/atlas): the app's own living design-system page — every component rendered live, in the active theme. Demos (/demo): working reference surfaces (schema-driven records table, panel workspace, writing surface, a journey). Each is one file; delete what you don't need. - ⌘K command palette: navigation, theme switching, app surfaces. Add
app-specific commands in
web/src/app/palette.tsx. - Data:
api<T>()+ TanStack Query; types come from@server/contracts/*(shared TypeBox schemas —Static<>on the client, runtime validation on the server,columnsFromSchema()for tables). - Live:
useLive()opens one SSE stream; server code callspublish("notes")after mutations and every client's["notes", …]queries refetch. The audit trail publishes"audit"— manage surfaces can watch actions land in real time. - Mobile: the shell becomes top bar (menu → nav drawer with the full
nav, identity and sign-out) + bottom tabs; overlays become sheets; tables
become card lists; inputs are 16px (no iOS zoom); touch targets are ≥44px.
The page NEVER scrolls horizontally — the base layer clips it, so wide
content must live in its own
overflow-x-autocontainer (DataTable already does; anything you hand-roll must too), form-control rows wrap instead of colliding, and flex/grid children that must shrink carrymin-width: 0. Before you ship a page, drive it at 390px — this is a template invariant, not an afterthought.
Dev workflow
bun install
PLAT_DEV_USER=you bun run dev # API on :8080 (dev viewer = you, admin)
bun run dev:web # Vite on :5173, proxies /api + /tokens.css
bun run build # tsc + vite build (what CI and Docker run)
bun run typecheck && bun run lint && bun run test
The server also serves the built SPA itself (bun run build then
bun run start) — Vite is a dev convenience, not a runtime dependency.
The template deploys itself the way every app stamped from it does: open a
PR and CI builds a preview at pr-<N>-app-template-open-platform.<domain>;
merge and release_app open-platform/app-template <version> ships it to
app-template-open-platform.<domain>. Try an idea on a branch, look at it
live, then decide whether it belongs in the template.
The spec-from-code contract
Routes are declared with TypeBox schemas:
import { Type } from "@fastify/type-provider-typebox";
app.post(
"/api/notes",
{
preHandler: requireUser,
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) => {
const note = await createNote(req.viewer, req.body);
await audit(req, "note.created", note.id);
publish("notes");
return reply.status(201).send(note);
},
);
That one schema drives runtime validation, response serialization, AND the
OpenAPI document at /openapi.json (Swagger UI at /openapi). Put shared
shapes in src/contracts/ and the SPA imports the same types. The handler
above is the whole house style: guard, do, audit, publish.
Platform-specific OpenAPI extensions
x-plat-emits: CloudEvent types this route emits (workflow registry)x-plat-auth: override the app's default auth mode for one route (forgejo-oauth|mcp-bearer|public)x-plat-deprecated-by: the replacement endpoint during a phase-out
List endpoints paginate with a cursor
Every list route takes limit (cap it) and an opaque cursor, and returns
{ items, nextCursor } — keyset pagination (where (created_at, id) < (…)),
never OFFSET, never a bare LIMIT 200 that silently truncates. The first org
shipped both styles side by side in one file; the cursor style is the one to
copy.
Consuming a sibling app (src/sibling.ts)
Hold urn: references and resolve them through sibling("@<org>/<provider>-client")
— it degrades to your fallback when the client is unpublished, slow, or down,
and exposes state so the UI can say so honestly. Never import a sibling
client directly at module top level, and never hand-roll fetch to a sibling's
URL.
Integration contracts (contract/)
The live /openapi.json is generated; the contract layer makes it a
reviewed, versioned artifact:
contract/openapi.jsonis committed from day one (the template ships its own). Whenever you change an/apiroute, runbun run contract:emitand commit the result in the same PR — the check gate reports DRIFT otherwise.- On release the platform publishes a typed client
@<org>/<app>-client, diffs the contract, enforces semver honesty (breaking ⇒ major ⇒ upgrade issues for every consumer). - Consuming a sibling:
bun add @<org>/<provider>-client— yourtsc --noEmitbecomes a contract test. Never hand-roll fetch against a sibling app.
One check, everywhere
bun run check is the CI check job, runnable anywhere: lint, typecheck, build, the tests, and design conformance, in that order. The crew runs it before every push — the platform's construction runs cannot finish while it is red or while the branch head is not on origin — and you can run it before opening a pull request for the same reason: what CI sees is what you saw.
Design conformance in CI
check runs node dist/design-lint.js: no raw color literal (#hex, rgb(), hsl()) in src/ or web/src/, and every var(--…) is reported when it is not in the compiled vocabulary (design/tokens.json, else design/tokens.default.json). Raw colors fail the check; unknown tokens only warn, because the org's design app may serve more. Test files, the token compiler (src/design.ts) and comment lines are exempt — they are not styling. The platform's lint_design applies the same rules at review with the org's live vocabulary; passing here means passing there.
Feature flags (src/flags.ts)
Declarations in code, runtime overrides in the app's Postgres, resolution
env → user → team → global → default, HTTP surface at /api/flags.
Merge features dark behind a release flag (previews light them up
automatically); release flags MUST carry a retire date — the flag-debt
test goes red past it. Flag flips are mutations, so the audit trail records
them with the actor. See the original build discipline notes in the flags
module header.
Metrics & dashboards
GET /metrics (Prometheus, cluster-internal) carries request
rate/latency/status via one histogram plus Node process metrics. The chart
renders a ServiceMonitor + a default Grafana dashboard ConfigMap
(grafana_dashboard: "1") — the board appears in Grafana with zero clicks.
Ship custom boards as sibling labelled ConfigMaps. Opt out with
monitoring.enabled: false.
Chart (charts/app/)
Deployment + Service + PVC + optional worker/migrate/versions + ServiceMonitor + dashboard. Notable values:
auth.mode:platform(default, fail-closed header-trust) |app(legacy in-app OAuth)repoUrl: backing repo web URL → in-app access deep links (platform-injected)worker.enabled,migrate.enabled: background Deployment / pre-upgrade Jobdesign.serve/design.url: org design authority / consumer wiringallowedHosts: domain-defined egress (Cilium default-deny when set)placement.preferAppWorker: soft affinity onto app-worker nodes
What ships in the box
| Path | What |
|---|---|
src/server.ts |
Fastify wiring: auth modes, metrics, audit, events, design, static, routes. |
src/viewer.ts |
The identity model: getViewer, requireUser, requireManage. |
src/audit.ts / audit-routes.ts |
Append-only audit core + manage-only read surface. |
src/events.ts |
SSE invalidation stream (publish). |
src/design.ts |
Token/theme compiler + /tokens.css /tokens.json /registry.json. |
src/flags.ts / flags-routes.ts |
Feature flags. |
src/contracts/ |
TypeBox schemas shared server ↔ SPA. |
src/static.ts |
SPA serving: immutable assets, boot-payload injection, honest 404s. |
web/ |
The SPA: shell, ui/ library, atlas, demos (see above). |
charts/app/ |
Helm chart (renamed to the app by CI). |
design/ |
Token vocabulary + named themes. |
wiki/ |
The app's living docs — published to the Wiki tab on merge. |
.forgejo/workflows/ |
check (quality gate) / release (tag → image+chart) / preview / wiki / guard. |
Replacing this with your real app
Start at src/server.ts: add typed routes with requireUser (the guard is
the default posture — drop it only for deliberately-public surfaces), put
shared schemas in src/contracts/, call audit() on domain events and
publish() after mutations. Then build the UI in web/src/routes/ out of
web/src/ui/ components — read web/src/ui/CONVENTIONS.md first, keep the
atlas honest, and replace the Home route's demo content with your app's real
front door. Delete demos you don't need (one file each). The design system,
auth, audit, flags, and contract plumbing are already done — spend your
effort on the product.