Files
template-app/README.md
T

194 lines
12 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 (`next 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.
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 `/_next/static`; do not set Next.js `assetPrefix` from a
deployment-time value. 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
- `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-next`
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).
## What is in here
| Path | What |
|---|---|
| `apps/web` | the Next.js 16 shell: routes, layouts 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/favicon.svg` and the two logo files it names.
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). `apps/web/proxy.ts` does not run for `application/octet-stream`
bodies: Next.js would otherwise cut them at 10 MB. 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.
## 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).