Files
template-app/README.md
T
2026-11-14 09:56:45 +00:00

277 lines
18 KiB
Markdown

<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# Example App
A multi-tenant application on ConusAI Cloud, generated from the ConusAI App Template. Sign-in,
organizations, workspaces and projects with roles and invitations, audit, usage metering,
observability, background jobs, i18n and an accessible branded UI are already here. What you write
is domain modules under `modules/`.
Agents and new developers: read [AGENTS.md](AGENTS.md) first. Claude Code reads it through
[CLAUDE.md](CLAUDE.md), with the committed permissions, format-on-edit hook and the support-report skill in
`.claude/`; Codex and OpenCode read AGENTS.md directly.
## Run it locally
```bash
pnpm install
pnpm dev
```
That is the whole of it, and it needs no `.env`. `pnpm dev` (`scripts/dev.mjs`) starts, on this machine:
1. Postgres in Docker, with the same three roles a linked managed database exposes (`compose.dev.yaml`,
`dev/postgres-roles.sql`), on `127.0.0.1:55433`;
2. the **fake platform** of `@conusai-kit/testing` on `127.0.0.1:4010`: Blob, OCR, the AI gateway, audit
and usage, in memory. A structured model request is answered with the smallest value its schema accepts,
so what you build on the model runs to its end;
3. the migrate job, then the **demo data** (`pnpm seed`, below);
4. the worker and the web shell (`vite dev`) on `http://127.0.0.1:3000`.
It prints how to sign in as each demo person and stops everything but Postgres on Ctrl+C
(`docker compose -f compose.yaml -f compose.dev.yaml down` stops that too; add `-v` to drop the data).
A value you want different is a variable in your shell, not a file: `DEV_WEB_PORT=3200 pnpm dev`; the three
`DEV_*_PORT` variables move the ports, and `POSTGRES_URL`, `POSTGRES_READER_URL` and `POSTGRES_MIGRATE_URL`
(all three) use a Postgres of your own. `pnpm dev:web` runs the shell alone against a stack that is already up.
Workspace and project creation refresh TanStack loaders and close the completed panel without
remounting the frame or interrupting an Agent draft.
Document review, export, and confirmation actions stay disabled until their browser handlers are
attached, including after a decision reloads the current server state.
### Chat, Support, and Guides
The default Postgres starter selects `agent`, `guides`, and `support`. The **Agent** action is in the
frame on workspace and project pages. It streams through TanStack AI and the ConusAI gateway,
stores conversations in the app database, and keeps the AI disclosure and approval rules.
**Help** contains Guides and Support in the same side panel; phones use a full-screen sheet.
The Notes tour starts only when requested from Help or the setup checklist. After privacy choices
are resolved, the Agent keeps its header in view while the conversation scrolls and the composer receives focus.
`pnpm dev` uses a simulated gateway reply and runs the code guides locally. Support shows that it
is not connected: real conversations require a configured `CONUSAI_SUPPORT_CHANNEL` and Cloud
service permissions. Selection requests scopes; it does not grant them or establish service readiness.
The Postgres starter declares `/app/.conusai/guides-bundle.json` for native code-guide publication
from the image (ADR-045); Cloud publication permissions and review still apply. Production verifies
the native service contract and release identity before provider calls.
Keep UI code on the kit: TanStack Start owns routes, `@conusai-kit/ui/data` owns TanStack DB
collections and writes, and `@conusai-kit/agent` owns the durable TanStack AI conversation. Do not add
a second chat store or provider SDK. The Agent currently requires the Postgres profile; alternate
profiles retain the support limits documented in `profiles/` and `modules/agent/README.md`.
The module record retains custody after an included module is turned off; source/profile pruning
refuses adopted modules rather than deleting their data ownership.
The same stack as containers, for a closer look at what is deployed:
```bash
cp .env.example .env # then set AUTH_SESSION_SECRET
docker compose -f compose.yaml -f compose.dev.yaml up --build
```
After deploying a built image, verify the public shell in Chromium:
```bash
pnpm exec playwright install chromium
node scripts/verify-shell.mjs https://your-app.example.test
```
This checks that theme and English/Lithuanian controls work in both directions and survive reloads.
The image serves content-hashed assets at `/assets`, and the build fixes their addresses: the Start
build ignores `CONUSAI_ASSET_PREFIX`. Its compiled chunk URLs must agree with the URLs the server renders,
or the page can appear complete while its controls never become interactive. Update and redeploy the image
to apply a template fix; recreating the project or its database is unnecessary.
### The demo data
`pnpm seed` (`apps/worker/src/seed.ts`) writes it, with the kit's own use cases and nothing else: people come
into being as they do at sign-in, and everything after that is a procedure called as the person who may call
it, so the permission checks, the invitation flow and the tenancy walls apply. Running it twice changes
nothing. It refuses a production run.
| Person | `/auth/dev-login?email=` | Acme Review | Northwind |
|---|---|---|---|
| Ada Lovelace | `ada@example.test` | owner | admin |
| Grace Hopper | `grace@example.test` | member | owner |
| Linus Torvalds | `linus@example.test` | viewer | none |
Two organizations, so the organization switcher has something to switch between; **Northwind** carries a
tenant brand (its own name and colours on every page), **Acme Review** the app's. Each has a `general`
workspace with a demo project, and Acme Review's `notes` project holds two notes.
### The platform, and what refuses to start
The platform's variables (`CONUSAI_API_URL`, `CONUSAI_API_TOKEN`, `CONUSAI_PROJECT_ID`, and `CONUSAI_ORG_ID` in
production) are injected into every service on ConusAI Cloud. A process that does not have them **does not
start**: `loadAppEnv` (`config/src/env.ts`) throws a `PlatformEnvError` that names the variable. The fake
platform is used only when you ask for it, with `CONUSAI_FAKE_PLATFORM=1`, and only in a development or test
run (`NODE_ENV=development` or `test`; a process with no `NODE_ENV` is a production one). The app refuses
that switch in a production run, and refuses it when `CONUSAI_API_URL` is not on this machine or the compose
network. `pnpm dev`, `compose.dev.yaml` and `pnpm e2e` set it; nothing else does.
### Health, readiness and observability
A boot-time deployment-contract refusal is different from a store that becomes unavailable.
Unsupported MariaDB/MongoDB composition is checked before the lazy runtime opens a store:
`ProfileCompositionError` preserves every adopted module and its data-rights hooks. Such a
process answers `/api/ready` with 503, `ready: false`, `checks.contract: false` and a safe
configuration reason; other dynamic routes, including health, answer a plain 503. Responses
are `no-store` with `Retry-After: 30`. A valid process retains the ordinary liveness/readiness
behavior below. Unexpected registry/import/runtime failures are not relabeled as configuration.
- `GET /api/health` is liveness: the process is up, and no connection is opened to answer it.
- `GET /api/ready` is readiness, and what `compose.yaml` and `conusai.json` (`health_check`) both probe: the
runtime role answers, the global reader answers, and every migration this build expects has run. The platform
is reported, not required. The answer is booleans and nothing else; the reason goes to the log.
- Errors, traces and logs are sent through `@conusai-sdk/observe-node` (web, with `@conusai-sdk/observe-start`
for the browser) when the platform provides `CONUSAI_DSN` (errors) and `OTEL_EXPORTER_OTLP_ENDPOINT` (logs and
traces). Without them the app runs, sends nothing, and logs one line at start-up that says which is off.
Secrets, tokens, connection strings and e-mail addresses are taken out before anything leaves the process
(`config/src/observability.ts`), and every log line and span carries the request's trace id.
- The browser SDK starts with `consent: "anonymous"` and no banner. Before serving EU visitors, switch to
`opt-in` with a banner: [consent in an app](../../docs/how-to/consent-in-an-app.md). The rules for cookies,
storage, AI disclosure, retention and data-subject rights are in the
[compliance library](../../docs/compliance/README.md).
## Sign-in: managed Identity or managed Better Auth
The app signs people in through OIDC (`AUTH_PROVIDER=oidc`, the default) with `@conusai-kit/auth`: the
server holds the tokens, the browser holds one sealed cookie. On ConusAI Cloud the issuer is a managed
identity service linked to the project, and you choose which one by the service you link. App code,
`conusai.json` and the variables are the same for both:
| Service type | Engine | People sign in with | Access to this app |
|---|---|---|---|
| `identity` | Zitadel | its hosted login (password, passkey, MFA) | a role `user` or `admin` on the project, given by e-mail address: the console's identity card or `POST …/services/{id}/identity/grants` |
| `better-auth` | Better Auth | an invitation e-mail's one-use link (sign-up is closed); passkeys after verification | an allowed grant on the person's subject: `conusai services identity invite`, then `grant` |
1. Create the service in the project's workspace (console **Services**, or the services API with
`{"service_type": "better-auth", "parameters": {"backing_postgres_service": <id>}}`). Better Auth keeps
its accounts in a database of its own on that Postgres service, never in the app's.
2. Link it to the project (console **Linked Services**, or `conusai services link`). A project links one
identity service: linking the second engine beside the first is refused. `conusai.json` declares
`{"type": "identity"}`, which either engine satisfies.
3. Deploy. Either engine injects `OIDC_ISSUER`, `OIDC_WEB_CLIENT_ID` and `OIDC_AUDIENCE`. `identity`
(Zitadel) adds `OIDC_NATIVE_CLIENT_ID`. Better Auth adds `OIDC_SCOPES`, `AUTH_PROVIDER_GENERATION`,
`OIDC_RESOURCE` (equal to `OIDC_AUDIENCE`) and the server-only status credential
`OIDC_RESOURCE_STATUS_URL`, `OIDC_INTROSPECTION_CLIENT_ID` and `OIDC_INTROSPECTION_CLIENT_SECRET`, with
which the app re-checks a person's grant at least every 60 seconds, and `OIDC_BACKCHANNEL_URL`, the
issuer's private address the app server sends its own OIDC requests to
([the full table](../../docs/reference/identity-link-variables.md)). A link that issued an admission
credential also injects `OIDC_ADMISSION_TOKEN` (server-only) and `OIDC_LINK_ID`: an invitation the app makes then asks the platform to admit the invited address,
which the issuer mails its onboarding link. You set only `AUTH_SESSION_SECRET`.
4. Give people access (the table above). The issuer decides who may enter the app and as which of
`user` or `admin` (`principal.appAccess`); organizations, workspaces and permissions inside the app
are the app's own. With `APP_ORGANIZATION_SELF_SERVICE=false` only an `admin` may create an
organization; everyone else joins by invitation.
Better Auth delivers its invitation e-mails through the organization's e-mail provider on ConusAI Cloud
(console **Settings → Notifications**); without one an invitation answers 503. Its hosted `/login` page
does not have a sign-in form yet, so a person signs in with the link of their latest invitation e-mail
and then opens the app; send a new invitation to sign someone in again
([plan](../../docs/plans/2026-10-23-managed-better-auth.md)).
Changing the engine of an app that already has users is a migration that keeps every app user, not a new
link: `conusai services identity switch` ([spec §10](../../docs/plans/2026-10-23-managed-better-auth-spec.md#10-switching-zitadel--better-auth)).
`AUTH_PROVIDER=convex-better-auth` is something else: Better Auth embedded in the app's own Convex
deployment ([profiles/convex/README.md](profiles/convex/README.md)).
## What is in here
| Path | What |
|---|---|
| `apps/web` | the TanStack Start shell: routes and the one HTTP mount (`/api/v1/<procedure>`) |
| `apps/worker` | jobs, module cron, the outbox relay, and the migrate job |
| `modules/` | your domain modules, one folder each, and the module list in `index.ts` |
| `config/` | the deployment contract per process, the app brand, the feature flags |
| `migrations/postgres/` | forward-only SQL, starting with the kernel schema |
| `openapi.json` | the generated OpenAPI 3.1 document; `pnpm openapi` rewrites it |
| `conusai.json`, `compose.yaml`, `Dockerfile` | how ConusAI Cloud builds and runs the three processes |
| `compose.dev.yaml`, `scripts/dev.mjs`, `.env.example` | the local stack and every variable the app reads |
## The three Postgres roles
A linked managed Postgres gives this app three logins, and each process holds only what it needs:
| Process | Role | Variable |
|---|---|---|
| `migrate` | owner | `POSTGRES_MIGRATE_URL` (only the service that declares `postgres.migrate`) |
| `web`, `worker` | runtime, bound by row-level security | `POSTGRES_URL` |
| `web`, `worker` | global reader | `POSTGRES_READER_URL` |
The app refuses to start a web or worker process that was handed the owner's URL, and there is no
`SET ROLE` anywhere: the separation is three separate logins. The reader holds no table privilege
except the grants in `migrations/postgres` (which organizations a user is in, the projects the app
serves, an API key by its hash, and the outbox the relay drains), built from what each kit module
declares it reads across tenants.
The platform's cluster links now use the same three roles: `POSTGRES_URL` names the runtime
role, and the administrator credential stays in the provisioning path. Cluster URLs list running
data nodes with `target_session_attrs=read-write`; see
[managed Postgres roles: cluster topology](../../docs/architecture/managed-postgres-roles.md#cluster-topology).
Verify the app driver's multi-host compatibility and live failover before using a cluster in
production; the three-role credential contract alone does not establish those behaviors.
## Writing your domain
Add modules; do not edit the shell. [docs/domain-start.md](docs/domain-start.md) says exactly where to add a
module, an entity, a migration, a job, a page, a message catalogue, a permission, an audit event and a usage
meter, with the commands.
## Making it yours
1. `config/src/brand.ts`: the product name, the logos and any `--brand-*` override. Attribution is
off by default.
2. `apps/web/public/`: your own logo files, which it names. Until then the tab icon is the ConusAI mark from `@conusai-kit/brand`.
3. `apps/web/src/ui/messages.ts`: the app's own messages, in both locales.
4. Then write your first module: [docs/domain-start.md](docs/domain-start.md), and the rules in [AGENTS.md](AGENTS.md).
## Licence
The scaffold and the `@conusai-kit/*` packages are licensed under the ConusAI Template Licence
(`LICENSE-TEMPLATE`, a placeholder until counsel decides; the files it covers are listed in
`TEMPLATE-FILES` and carry an SPDX header); the `@conusai-sdk/*` packages are Apache-2.0. Your modules
are yours, and `LICENSE` is yours to fill in. Third-party software is in `THIRD-PARTY-NOTICES`
(`node scripts/licence.mjs --check` checks the layout).
## Large uploads and the worker's clock
A file of 10 to 100 MB is not a JSON document, so the `files` module takes it in three calls the page makes
through `uploadFile` of `@conusai-kit/ui`: `files.file.beginUpload` (type and size checked first), one
`files.file.putPart` per piece (the piece is the body of a `PUT /api/v1/files.file.putPart`, held to the
size and the type it declared as it streams, with a progress bar on the page), and `files.file.completeUpload`
(the platform's SHA-256 of the stored bytes). The server entry (`apps/web/src/server.ts`) reads no body, so a
piece reaches the handler as it arrives. The default limit is 50 MiB (`FILES_POLICY`).
`apps/worker/src/schedules.ts` is the module cron of the app. It turns on `files.recover`, every five
minutes, for every project of every active organization (the worker lists the projects through the global
reader); without it a crashed upload would stay a pending row. It also turns on the account sweeps and the
hourly `agent.runs.expire`, which deletes the finished agent runs past `AGENT_RUN_RETENTION_DAYS` (90 by
default). The mariadb and mongodb workers install the same schedules of the modules installed on their
profile (`apps/worker/src/dialect/schedules.ts`).
## The example modules
- `modules/documents` is a generic document-review module and the pattern for a product's own: upload
(the `files` module) -> an OCR job through `@conusai-sdk/ocr` (`submit`, `wait`) -> a model, through the
kit's `ModelGateway` with structured output, reads each page by the class the reader gave it (a scan or a table as text and page image, drawings
and empty pages not at all and reported on the document, `DOCUMENTS_EXCLUDED_PAGE_CLASSES`) and
proposes lines -> code checks that every quote is on the page it cites, and marks the lines it cannot
verify `needs_review` -> the lines are approval requests (the `proposals` module) with their page, box and
verbatim quote and the reader's block id -> a person approves, edits or rejects each one -> the Excel export holds the approved
lines and nothing else. OCR results and model replies are cached by content hash, and a model that
refuses, runs out of tokens or answers outside the schema ends the document with a typed failure. It has
no customer vocabulary: what a line means, and the file format a customer's other systems import,
belong to the product's own module (the prompt and schema in `adapters/model-extractor.ts` and a second
exporter are the seams). `evals/` scores the extraction on a golden set: see [evals/README.md](evals/README.md).
The model is `AI_MODEL` (default `anthropic/claude-sonnet-5`).
- `modules/branding` is the organization's own brand: a display name, two colours, two logos, applied
by the organization layout on the server and previewed in both themes with the contrast of each colour.
- `modules/notes` is the small example that uses every seam of a module: notes with attachments, the
attachment uploaded through the `files` module (the platform's Blob store), an audit event for every
change and a usage fact per note, a feature flag (`notes.attachments`), both languages, pages the web
shell mounts from `modules/index.ts`, and isolation fixtures in its own folder. It is the one to copy.
`pnpm e2e` runs them in a browser against the whole app (Docker for Postgres, Chromium).