Use this template — an agent builds and ships your app. The repo name + description you enter become the v1 spec. https://app-template-open-platform.open-platform.sh
  • TypeScript 96.3%
  • CSS 2.6%
  • Go Template 0.5%
  • HTML 0.3%
  • Dockerfile 0.3%
Find a file Use this template
ci-bot 30f82c4a7f
All checks were successful
guard-ppt / no-ppt (push) Successful in 1s
sync-env / sync-env (push) Successful in 1s
check / check (push) Successful in 32s
chart: every pod carries a priority class and a real request (#27)
2026-09-21 16:18:45 +00:00
.forgejo/workflows the template is the repo Forgejo flags template, under any owner (#26) 2026-09-01 17:43:51 +00:00
charts/app chart: every pod carries a priority class and a real request (#27) 2026-09-21 16:18:45 +00:00
contract template 0.8.0: anon, user, admin — three tiers decided in Forgejo, carried by the edge; the door, hardened (#21) 2026-08-25 19:23:10 +00:00
design design: the default tokens match the brand — one green, www paper/ink 2026-08-11 16:02:55 -05:00
src check: design conformance in CI (raw colors fail before review) (#22) 2026-08-25 23:34:24 +00:00
web template 0.8.0: anon, user, admin — three tiers decided in Forgejo, carried by the edge; the door, hardened (#21) 2026-08-25 19:23:10 +00:00
wiki template 0.8.0: anon, user, admin — three tiers decided in Forgejo, carried by the edge; the door, hardened (#21) 2026-08-25 19:23:10 +00:00
.dockerignore template 0.4 foundation: React SPA + tokens v2 theme engine + viewer model 2026-08-03 22:16:51 -05:00
.gitignore template 0.7.0: tests gate, placeholder refused, contract committed from day one, demos removed (#14) 2026-08-19 20:07:22 +00:00
.prettierignore Initial _app-template: src, charts/app, Dockerfile, release workflow 2026-06-01 00:34:43 +00:00
biome.json template 0.4 foundation: React SPA + tokens v2 theme engine + viewer model 2026-08-03 22:16:51 -05:00
bun.lock template 0.4 foundation: React SPA + tokens v2 theme engine + viewer model 2026-08-03 22:16:51 -05:00
Dockerfile template 0.4 foundation: React SPA + tokens v2 theme engine + viewer model 2026-08-03 22:16:51 -05:00
package.json bun run check — the CI check job, runnable locally (#24) 2026-08-26 01:11:02 +00:00
README.md the template is the repo Forgejo flags template, under any owner (#26) 2026-09-01 17:43:51 +00:00
tsconfig.json Initial _app-template: src, charts/app, Dockerfile, release workflow 2026-06-01 00:34:43 +00:00
vite.config.ts template 0.4 foundation: React SPA + tokens v2 theme engine + viewer model 2026-08-03 22:16:51 -05: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.


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 behind requireUser/requireManage in 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-supplied X-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 → requireUser answers 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's edgeTokenSecretName (a Secret with key token holding the value the edge injects as X-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 requireUser still 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 behind canManage(viewer).
  • Local dev: there's no edge on your laptop. PLAT_DEV_USER=you bun run dev gives you an admin viewer — honored only outside NODE_ENV=production.
  • Same code, either mode: getViewer/requireUser/requireManage are the whole story in both modes — in app mode the identity comes from the better-auth session (the session user carries perm: "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: true in the org's ground (share cards) or in local dev. Every /api route refuses it unless the exact path is in PUBLIC_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 with config: { audit: false }; enrich with config: { 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-fallback log 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 $value and a dark $extensions["plat.dark"]. The stylesheet follows the system scheme by default; an explicit choice pins data-theme="light" | "dark".
  • Named themes live in design/themes/*.json — complete palettes (slate a Linear-blue-gray dark, dune a warm sandstone light, contrast a 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-accent etc.) as live var() 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 every var(…) 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 (read web/src/ui/CONVENTIONS.md before 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 calls publish("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-auto container (DataTable already does; anything you hand-roll must too), form-control rows wrap instead of colliding, and flex/grid children that must shrink carry min-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:

  1. contract/openapi.json is committed from day one (the template ships its own). Whenever you change an /api route, run bun run contract:emit and commit the result in the same PR — the check gate reports DRIFT otherwise.
  2. On release the platform publishes a typed client @<org>/<app>-client, diffs the contract, enforces semver honesty (breaking ⇒ major ⇒ upgrade issues for every consumer).
  3. Consuming a sibling: bun add @<org>/<provider>-client — your tsc --noEmit becomes 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 Job
  • design.serve / design.url: org design authority / consumer wiring
  • allowedHosts: 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.