Getting started¶
End-to-end walkthrough: from an empty directory to a running app, then from the bootstrapped template toward (a subset of) the example app. Assumes setup is done.
The stack has four generated services driven by jsonnet:
be: a fast API backendfe: a React frontendopa: an Open Policy Agent permissions servicerender: a node microservice that renders emails and PDFs
At a high level, you’ll be configuring in JSON, generating Python, and then building React.
Scaffold a new app¶
fsh template pulls the codegen template into a directory and starts a fresh
git repo (the template’s history is dropped, so origin doesn’t point at it):
fsh template sample
cd sample
uv sync
uv sync puts the codegen CLI on PATH.
Note
fsh template will automatically detect whether you have repositories like codegen cloned as siblings. If you do, it will use those in editable mode, if you don’t, it will add UV_NO_SOURCES=1 to your .env such that it is forced to pull from the registry.
To run multiple stacks in parallel, bake a port/DB offset in at pull time:
fsh template sample --offset 100
--offset N writes .env so every dev port shifts by N and the app targets
the app_N database. It’s frozen at pull time — changing it later means editing
.env and re-running DB setup.
Bootstrap the four sides¶
The freshly-pulled directory holds only the four bootstrap.jsonnet files (one
per side) plus the harness. just bootstrap scaffolds each side into be/,
fe/, opa/, render/:
just bootstrap
It runs the four *_root generators (be_root, fe_root, opa,
render_root), then just-pm sync to vendor the shared just modules, then a
per-side setup + lint-fix pass:
// be/bootstrap.jsonnet — the be_root toggles that decide what gets scaffolded.
{
name: "codegen-template-app",
module: "app",
opentelemetry: true, // telemetry block + init_telemetry(app)
files: true, // boto3 + fsh_lib.files upload helpers
auth: true, // auth.py stubs + auth.jwt(...) block
psycopg: true,
codegen_database: true,
pgqueuer: true, // background worker recipes
rate_limit: true, // rate_limit.slowapi(...) block
comms: true, // comms platform (requires pgqueuer)
fsh: true, // /fsh platform app: saved views, opt-ins
opa: true, // OPA gate + per-resource Rego schemas
samples: true, // a runnable User + Category CRUD out of the box
links: true, // /l/<code> short-link redirector
reports: true, // saved + system reports
oauth: true, // "Sign in with Google" (OIDC)
}
Every emitted file is if_exists="skip", so re-running just bootstrap is a
no-op until you pass --force to the underlying CLI. To see what a re-run
would change (templates drift silently under skip):
just bootstrap-diff
Generate the apps¶
After any *.jsonnet config edit, regenerate the BE + FE generated code:
just generate # aliases: just g / just gen -> be gen + fe gen
just validate # check the be + fe configs against their schemas
just generate covers in-repo generated code. The downstream contract
surfaces (OpenAPI, TS client, OPA sync, render regen) have their own recipes:
just spec # BE codegen -> specs/openapi.json -> FE TS client
just opa-spec # BE codegen -> opa sync + verify
just render-spec # BE codegen -> render regen
Warning
The FE yarn openapi-ts step (which produces the actual TypeScript client)
reads be/specs/openapi.json, which only exists once the BE has emitted it.
just spec handles the ordering; if you run the FE client step by hand, make
sure the BE spec is fresh first.
Run it¶
just dev # alias: just d
Sign in at http://localhost:5173/login with alice / wonderland. First run
needs the DB: just be db init.
From template to example (a walkthrough)¶
A freshly bootstrapped app has empty app config — its tracker app lists
resources: [], so the BE emits only project-scope plumbing (db wiring, auth
routes, the app skeleton). Below we build it toward the
example app, each step linking
its deep-dive guide.
After every config edit, re-run just generate (and just spec when the FE
needs the refreshed client).
1. Declare project plumbing in project.jsonnet¶
be/config/project.jsonnet holds cross-cutting platform features: databases,
auth, telemetry, rate-limiting, links, OPA, comms, reports, and the apps[]
list. Each is a helper import. Databases and apps[] are the minimum:
// be/config/project.jsonnet
local auth = import "be/auth/jwt.libsonnet";
local db = import "be/db/databases.libsonnet";
local opa = import "be/opa/opa.libsonnet";
local reports = import "be/reports/reports.libsonnet";
local telemetry = import "be/telemetry/telemetry.libsonnet";
{
databases: [
db.postgres("primary", { default: true }),
],
// ...
apps: [
{ config: import "tracker.jsonnet", prefix: "/tracker" },
],
}
Some features:
auth: auth.config({...})withauth.password(...)/auth.oauth(...)methods — auth.opa: opa.config({ roles_loader, bindings_loader })— permissions.comms: comms.platform({...})— typed messages, user templates, recipient groups — emails and templates.reports: reports.platform({...})withreport_fns— reports.telemetry.otel(...),rate_limit.slowapi(...),links.shortener(...)— each a single block; see infra for the deployment side.
The OPA block is just its two loaders:
// be/config/project.jsonnet — permissions wiring.
opa: opa.config({
roles_loader: "permissions.load_roles",
bindings_loader: "permissions.load_bindings",
}),
2. Add an app under a prefix¶
An app is a module with a resources[] list, mounted under a URL
prefix in apps[]. The template’s app starts empty; the example’s tracker
collects the core resources:
// be/config/tracker.jsonnet
{
version: "1",
module: "tracker",
resources: [
import "resources/project.jsonnet",
import "resources/task.jsonnet",
import "resources/user.jsonnet",
import "resources/role.jsonnet",
// ...
],
}
Split large apps into more { config, prefix } entries in project.jsonnet
(the example adds inventory, purchasing, vendors). See
resources for how apps and resources compose.
3. Add your first resource¶
A resource declares its model, primary key, route prefix, one or more representations (field projections) and operations (CRUD + more). The simplest is the shared name-dimension preset — one line yields full CRUD:
// be/config/resources/role.jsonnet — id + name catalogue, full CRUD.
(import "be/resources/presets.libsonnet").nameDimension("fsh.models.Role", "/roles")
A hand-rolled resource spells out its representations and operations. The example’s project resource shows the shape — representations built from fields helpers, an enum field, and a searchable list op with filters:
// be/config/resources/project.jsonnet (trimmed)
local fields = import "be/fields.libsonnet";
local list = import "be/operations/list.libsonnet";
{
model: "fsh.models.Project",
pk: { name: "id", type: "uuid" },
route_prefix: "/projects",
search: {},
representations: [
{
name: "list_item",
fields: [
fields.id(),
{ name: "name", type: "str" },
{ name: "slug", type: "str" },
{ name: "project_type", type: "enum", enum: "fsh.models.ProjectType" },
],
},
// ...
],
representation_roles: { default: "default" },
operations: [
{ name: "get", representation: "resource" },
list.searchable(
representation="list_item",
filters=[
{ name: "id", values: "self" },
{ name: "project_type", values: "enum", enum: "fsh.models.ProjectType" },
],
order=["name"],
default_order="name",
),
{ name: "create", fields: [/* ... */] },
{ name: "update", fields: [/* ... */] },
{ name: "delete" },
],
}
The enum field’s choices come from a Python StrEnum
(fsh.models.ProjectType) — see enums. The filter kinds
(self, ref, enum, bool) are covered in
search and filters.
4. Add an action and per-op guards¶
Beyond CRUD, resources declare actions — named operations backed by a dotted
Python callable, optionally gated by a can guard. The example’s task resource
adds a complete action plus per-op controls:
// be/config/resources/task.jsonnet (operations, trimmed)
local resource = import "be/resources/presets.libsonnet";
operations: [
{
name: "create",
rate_limit: "10/minute", // per-op override
pre: "tracker.hooks.normalise_task_title_on_create", // pre-write hook
fields: [/* ... */],
},
{ name: "delete" },
resource.action(
name="complete",
fn="tracker.actions.complete_task",
// Server-side visibility gate: available_actions omits `complete`
// from a row when this returns False — the FE drops it automatically.
can="tracker.guards.can_complete_task",
),
],
Actions, hooks, and guards are the subject of actions.
5. Layer features onto resources¶
With the platform wired in step 1, opt individual resources into each feature:
Permissions — set
representation_roles: { opa: "authz" }so a chosen representation is the projection sent to OPA on each point check (permissions):// be/config/resources/task.jsonnet representation_roles: { default: "default", opa: "authz", },
Comms — a
comms_targetmakes the resource a user-template target, its representation the variable surface (emails and templates):// be/config/resources/task.jsonnet comms_target: comms.target({ representation: "comms_summary", recipient_resolvers: { watchers: "tracker.comms_resolvers.task_watchers", project_lead: "tracker.comms_resolvers.task_project_lead", }, }),
Reports —
reports.opt_in(...)withreport_type+ areport_fnsource surfaces system/saved reports on the resource (reports):// be/config/resources/project.jsonnet reports: reports.opt_in({ types: ["csv", "json"], system_reports: [ reports.system({ slug: "all_projects", name: "All projects", report_type: "csv", source: reports.list_op_source({ base_fn: "project.list" }), }), ], }),
Auditing and commenting layer on the same way (both are not yet merged).
6. Wire the frontend¶
Once the BE shape is set, regenerate and fill the safe-to-edit FE scaffolds.
The generated client + hooks land under fe/src/_generated/ (never edited);
the scaffolds under fe/src/{fields,enums,actions} and fe/src/routes.ts are
scaffolded once (if_exists="skip") and are yours to fill:
just generate && just spec # emit types + client, then the FE scaffolds
Field catalogs (fields) — one file per resource; give each field a
cell. It won’t type-check until you do:// fe/src/fields/tracker/task.tsx import { defineFields } from "@fsh/components-library"; import type { ListItemTaskResource } from "../../_generated/types/tracker/task.gen"; export const taskFields = defineFields<ListItemTaskResource>()((field) => [ field("title", { header: "Title", cell: (r) => r.title, table: { sortable: true } }), field("completed", { header: "Completed", cell: (r) => r.completed }), ]);
Enum displays (enums) — labels + badge variants, typed against the generated enum so a new backend value fails to compile until you label it:
// fe/src/enums/projectType.ts import { defineEnumDisplay } from "@fsh/components-library"; import type { ProjectType } from "../_generated/types/tracker/project.gen"; export const projectType = defineEnumDisplay<ProjectType>({ product: { label: "Product", variant: "neutral" }, internal: { label: "Internal", variant: "neutral" }, });
Action drawers (actions) — every entry defaults to
null(opted out); replace anullwith{ label, icon, onAction }(or aviewoverlay) to surface it:// fe/src/actions/tracker/task.tsx export const taskActions = defineResourceActions(taskActionCatalog, { resource: "task", object: { view: null, update: null, delete: null, complete: null }, collection: { view: null, create: null }, bulk: {}, });
Routes — register pages in
fe/src/routes.tswithdefineArea, then link them from the sidebar inShell.tsx:// fe/src/routes.ts const assets = defineArea({ list: { path: "/assets", lazy: () => import("./pages/assets/AssetListPage") }, detail: { path: "/assets/$id", lazy: () => import("./pages/assets/AssetDetailPage") }, }); const areas = mergeAreas({ assets, categories, comms, settings });
Tables — column ordering, sorting, and defaults are tuned in table configuration.
Next steps¶
resources — apps, models, representations, operations.
fields — the field helpers and representation shapes.
enums — backend
StrEnum→ FE displays and filters.search and filters —
list.searchable, filter kinds, keyset paging.actions — actions, hooks,
canguards, FE drawers.permissions — the OPA gate, roles, bindings, per-resource projections.
auth — JWT sessions, password + OAuth/OIDC login.
emails and templates — the comms platform and user templates.
reports — saved + system reports,
report_fnsources, PDF/CSV/JSON.table configuration — FE table columns, sorting, defaults.
auditing — change history (not yet merged).
commenting — per-record comments (not yet merged).
infra — deployment, environments, and operational concerns.