# Start your domain Where to add a module, an entity, a migration, a permission, a procedure, an audit event, a usage meter, a job, a page and a message catalogue, with the commands. Everything below was done once, in a copy of this template, as written here: the module is called `widgets`, it passed `pnpm kit gate`, and every code block is a file of it. You write modules. You do not edit `apps/`: the web shell and the worker find a module from `modules/index.ts` and its manifest. (Rules the gates enforce: [AGENTS.md](../AGENTS.md).) ## Before you start ```bash pnpm install pnpm dev # Postgres, the fake platform, the demo data, the worker and the web shell ``` Sign in at the address it prints (`http://127.0.0.1:3000/auth/dev-login?email=ada@example.test`). The demo data has Acme Review with a `general` workspace and a project called `Team notes`; a project of your own is made from the workspace page (`//general`) by choosing your module in the **Module** list. ## Where each thing goes | You add | Where | Command | |---|---|---| | A module | `modules//`, listed in `modules/index.ts` | `pnpm new:module [--uses files,proposals]` | | An entity (a table) | `modules//contract/entities.ts`, `defineEntity` | | | A migration | `modules//migrations/NNNN_name.sql`, forward only | `pnpm vitest run modules/ -u` writes the first | | A permission | `permissions` of `modules//module.ts` | | | A procedure | contract in `contract/procedures.ts`, handler in `adapters/procedures.ts`, rules in `core/` | | | Its isolation fixture | `modules//__tests__/isolation.ts` | `pnpm test:isolation` | | An audit event | `.audit(".")` on the contract, and `audit: [...]` in the manifest | | | A usage meter | `.usage("")` on the contract, and `usage: { meters: [...] }` in the manifest | | | A job | `jobs` of the manifest, enqueued by a write procedure | | | A page | `ui/pages.tsx`, one line in `modulePages` of `modules/index.ts`, a `ui.nav` entry | | | A message catalogue | `modules//i18n/en.ts` and `lt.ts`, declared as `i18n: { en, lt }` | | | The API document | `openapi.json` | `pnpm openapi` | ## 1. The module ```bash pnpm new:module widgets --uses files ``` `--uses files` because the job below reads the scope of its run through the files module's token (step 7); use `proposals` for a module that proposes changes a person approves. The command writes `modules/widgets/` (`module.ts`, `index.ts`, `contract/`, `core/`, `i18n/`, a test), adds the module to `modules/package.json` and the catalogue when it needs a kit module, and appends it to `domainModules` in `modules/index.ts`. A module with no procedure passes the gate as it is; the steps below fill it in. ## 2. An entity ```ts // modules/widgets/contract/entities.ts // The tables of the widgets module. One, project-owned: `tenancy: "project"` gives the table its // `org_id`, `workspace_id` and `project_id` columns and the row-level security policy on `org_id`. import { defineEntity, idSchema } from "@conusai-kit/data" import { z } from "zod" export const widgets = defineEntity({ name: "widget", table: "widgets_widget", tenancy: "project", fields: { name: z.string().min(1).max(120), createdBy: idSchema, }, indexes: [["createdBy"]], }) /** Every table this module owns, in the order a migration creates them. */ export const WIDGETS_ENTITIES = Object.freeze([widgets]) ``` `tenancy: "project"` gives the table `org_id`, `workspace_id` and `project_id` and the row-level security policy; use `"workspace"` or `"org"` for rows that belong higher up. Fields are JSON types. ## 3. A migration ```bash pnpm vitest run modules/widgets -u ``` The test in `__tests__/migration.test.ts` snapshots `schemaStatements()` of the module's entities into `migrations/0001_widgets.sql`, so the SQL is generated from the entity and cannot drift from it: ```ts // modules/widgets/__tests__/migration.test.ts // The module's migration is its own file and exactly `schemaStatements()` over its entities (decision // AT-D20: the entity definitions are the one source of a column, so a hand-written column cannot drift // from the schema the module validates its rows with). `pnpm vitest run modules/widgets -u` writes // 0001_widgets.sql from it the first time; once that file has run, it is never edited -- a later change to // the table is a new numbered file written by hand, and this test then compares the first file alone. import { schemaStatements } from "@conusai-kit/data-kysely" import { describe, expect, it } from "vitest" import { WIDGETS_ENTITIES, widgetsModule } from "../index.js" /** The statements for these tables alone: `schemaStatements` leads with the kernel's own. */ function tablesOf(entities: Parameters[0]): string { const kernel = schemaStatements([]).length return `${schemaStatements(entities) .slice(kernel) .map((statement) => `${statement};`) .join("\n\n")}\n` } describe("the migration of the widgets module", () => { it("is its own file, exactly its table", async () => { await expect(tablesOf(WIDGETS_ENTITIES)).toMatchFileSnapshot("../migrations/0001_widgets.sql") }) it("is declared by the manifest, so the migrate job finds it, and the table is row-level secured", () => { expect(widgetsModule.migrations).toEqual({ postgres: "migrations" }) const sql = tablesOf(WIDGETS_ENTITIES) expect(sql).toContain('create table "widgets_widget"') expect(sql).toContain('alter table "widgets_widget" force row level security') }) }) ``` Review the file and commit it. The manifest says where the folder is (`migrations: { postgres: "migrations" }`), and the migrate job applies it after the app's own, in file-name order, and refuses a file that changed after it ran. A later change to a table is a new numbered file you write (`0002_...sql`); `/api/ready` stays unready until the database has every file this build ships. ## 4. A permission, and a procedure Permissions are `..` with the lowest role that holds them, declared in the manifest (step 7 shows it whole). A procedure declares its kind, scope and permission, takes slugs and never an id of a parent, and is a contract first: ```ts // modules/widgets/contract/procedures.ts // The procedure contracts of the widgets module: schemas and names, no handler and no port. Each // declares its kind, its scope and its permission; the kernel checks all three before a handler runs. // The input carries slugs, never an id of a parent: the resolver's own reads are the membership check. import { procedure } from "@conusai-kit/core" import { z } from "zod" import { WIDGET_CREATED, WIDGETS_CREATED_METER } from "./constants.js" const inProject = { org: z.string(), workspace: z.string(), project: z.string() } export const widgetViewSchema = z.object({ widgetId: z.string(), name: z.string(), createdAt: z.string(), }) export type WidgetView = z.output export const widgetList = procedure("widgets.widget.list") .kind("read") .scope("project") .permission("widgets.widget.read") .input(z.object(inProject)) .output(z.object({ widgets: z.array(widgetViewSchema) })) .contract() export const widgetCreate = procedure("widgets.widget.create") .kind("write") .scope("project") .permission("widgets.widget.write") .input(z.object({ ...inProject, name: z.string().min(1).max(120) })) .output(widgetViewSchema) // The transport records both once the call has succeeded: one audit event and one usage fact. .audit(WIDGET_CREATED) .usage(WIDGETS_CREATED_METER) .contract() ``` The handler resolves its ports from the container the transport built and calls a use case. A `read` handler is refused every write port by name -- the list handler below asks for the data source and nothing else: ```ts // modules/widgets/adapters/procedures.ts // The handlers of the module's procedures and its job: each resolves its ports from the container the // transport built and calls one use case. No rule lives here. import { type Container, implement, type JobContext } from "@conusai-kit/core" import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data" import { FILES_SCOPE } from "@conusai-kit/files" import { widgetCreate, widgetList } from "../contract/index.js" import { createWidget, listWidgets, tidyWidget, type WidgetScope, type WidgetsDeps } from "../core/index.js" // A `read` procedure cannot resolve a write port: the kernel refuses `JOB_QUEUE` to it by name. The two // handlers therefore ask for exactly what they use. const readDeps = (container: Container): Pick => ({ data: container.resolve(DATA_SOURCE) }) const writeDeps = (container: Container): WidgetsDeps => ({ ...readDeps(container), jobs: container.resolve(JOB_QUEUE), }) export const widgetListImpl = implement(widgetList, ({ ctx }) => listWidgets(readDeps(ctx.container), ctx.scope)) export const widgetCreateImpl = implement(widgetCreate, ({ input, ctx }) => createWidget(writeDeps(ctx.container), ctx.scope, { name: input.name }), ) export const WIDGET_PROCEDURES = [widgetListImpl, widgetCreateImpl] /** * The job. The worker rebuilds the scope the job was enqueued in and binds it as `FILES_SCOPE`, the * kernel's name for "the scope of this run": a module needs no binding of its own to read it. */ export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise { const scope = context.container.resolve(FILES_SCOPE) as WidgetScope await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId) } ``` The use cases are pure TypeScript over those ports, so they run against the in-memory data source with no database: ```ts // modules/widgets/core/widgets.ts // What the widgets module does, as pure TypeScript over the ports in this file: no driver, no SDK, no // framework (the boundary gate refuses them), so every rule here is tested against the in-memory // data source with no database. import type { TenantScope } from "@conusai-kit/core" import type { DataSource, JobQueue } from "@conusai-kit/data" import type { z } from "zod" import { TIDY_JOB, type WidgetView, widgets } from "../contract/index.js" /** A project scope: the only level a widget can live at. */ export type WidgetScope = TenantScope & { workspaceId: string; projectId: string } export interface WidgetsDeps { readonly data: DataSource readonly jobs: JobQueue } type WidgetRow = z.output const view = (row: WidgetRow): WidgetView => ({ widgetId: row.id, name: row.name, createdAt: row.createdAt }) export async function listWidgets( deps: Pick, scope: WidgetScope, ): Promise<{ widgets: WidgetView[] }> { // `forScope` is the only way to a repository: the tenant predicate is part of every query it runs. const page = await deps.data .forScope(scope) .of(widgets) .list({ order: [{ field: "createdAt", direction: "desc" }], limit: 200 }) return { widgets: page.items.map(view) } } export async function createWidget( deps: WidgetsDeps, scope: WidgetScope, input: { name: string }, ): Promise { const row = await deps.data.forScope(scope).of(widgets).insert({ name: input.name, createdBy: scope.actor.userId }) // Long work is a job: the request enqueues it and answers. The key makes a repeated enqueue one job. await deps.jobs.enqueue(scope, TIDY_JOB, { widgetId: row.id }, { key: `tidy:${row.id}` }) return view(row) } /** The job: collapses the whitespace in a widget's name. A widget deleted since is nothing to do. */ export async function tidyWidget(deps: Pick, scope: WidgetScope, widgetId: string): Promise { const repo = deps.data.forScope(scope).of(widgets) const row = await repo.get(widgetId) if (!row) return const name = row.name.replace(/\s+/g, " ").trim() if (name !== row.name) await repo.update(row.id, { name }, row.version) } ``` ### Its isolation fixture The generated suite (`pnpm test:isolation`) calls every tenant-scoped procedure as a member of one organization with another's slugs and ids and expects a refusal. A procedure with no fixture fails it by name: ```ts // modules/widgets/__tests__/isolation.ts // What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this // folder: the rows it seeds in each tenant, and one fixture per tenant-scoped procedure. The suite finds // this file by its place, so there is no line to add outside the module; a procedure with no fixture // fails the suite by name. // // A fixture is built from the *other* tenant's ids: the call is made as a member of one organization // with another's slugs and ids, and has to be refused -- for being another tenant's, not for naming // nothing, which is why `seed` leaves a widget in every tenant. import type { TransportCaller } from "@conusai-kit/core" import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing" /** What a module's isolation file exports: the shape the suite reads, so it needs no import of the suite. */ export interface ModuleIsolation { seed( call: TransportCaller, inProject: { org: string; workspace: string; project: string }, ): Promise> fixtures: Record } const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug }) export const isolation: ModuleIsolation = { async seed(call, inProject) { const widget = (await call.callByName("widgets.widget.create", { ...inProject, name: "Seed widget" })) as { widgetId: string } return { widgetId: widget.widgetId } }, fixtures: { "widgets.widget.list": { input: (t) => inProjectOf(t) }, "widgets.widget.create": { input: (t) => ({ ...inProjectOf(t), name: "Intruder" }) }, }, } ``` ## 5. An audit event and a usage meter Both are named on the procedure's contract (`.audit(...)`, `.usage(...)` in step 4) and declared in the manifest (`audit`, `usage.meters` in step 7). The web transport records them once the call has succeeded: one event in the platform's audit log, which the organization's administrators read at `//audit`, and one usage fact against the ConusAI organization that owns the deployment, with the organization's slug as the customer. No content goes in an audit event. ```ts // modules/widgets/contract/constants.ts // The module's names, in a file with no import: a browser bundle can take them without loading the // data layer the schemas live beside. /** An audit event (spec §9.4), declared in the manifest and named by the procedure that causes it. */ export const WIDGET_CREATED = "widgets.widget_created" /** A usage meter: one fact per widget created, metered against the organization that owns it. */ export const WIDGETS_CREATED_METER = "widgets_created" /** The job that tidies a widget after it is created. A request enqueues it and answers. */ export const TIDY_JOB = "widgets.tidy" ``` For an event that must happen exactly once even if the request's process dies -- a note's audit event and usage fact are the example -- publish a domain event in the same transaction and subscribe to it: `modules/notes` (`core/subscribers.ts`) is the worked pattern. ## 6. A job Long work is a job. A write procedure enqueues it and answers (`createWidget` in step 4 does); the handler runs in the worker, in a span of its own, with the scope the job was enqueued in: ```ts // the handler, in adapters/procedures.ts export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise { const scope = context.container.resolve(FILES_SCOPE) as WidgetScope await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId) } ``` `FILES_SCOPE` is the kernel's name for "the scope of this run", bound once for every module. The test runs it as the worker would (`app.runJobs()` in `test/modules/widgets/widgets-flow.test.ts`). ## 7. The manifest ```ts // modules/widgets/module.ts // The widgets module's manifest (spec §4.1): the one place it registers anything. `modules/index.ts` // lists the manifest; nothing in apps/ changes. import { defineModule } from "@conusai-kit/core" import { repositoryBindings } from "@conusai-kit/data" import { z } from "zod" import { runTidy, WIDGET_PROCEDURES } from "./adapters/procedures.js" import { TIDY_JOB, WIDGET_CREATED, WIDGETS_CREATED_METER, WIDGETS_ENTITIES } from "./contract/index.js" import { en, lt } from "./i18n/index.js" const tables = repositoryBindings(...WIDGETS_ENTITIES) export const widgetsModule = defineModule({ id: "widgets", version: "0.1.0", kit: "^0.1.0", requires: ["tenancy", "files"], // .., with the lowest role that holds it. permissions: { "widgets.widget.read": "viewer", "widgets.widget.write": "editor", }, procedures: WIDGET_PROCEDURES, repositories: { postgres: tables, mariadb: tables, mongodb: tables, convex: tables }, // Forward-only SQL, one numbered file per change, run by the migrate job after the app's own. migrations: { postgres: "migrations" }, jobs: { [TIDY_JOB]: { payload: z.object({ widgetId: z.string() }), handler: runTidy } }, audit: [WIDGET_CREATED], usage: { meters: [WIDGETS_CREATED_METER] }, // The project-level entry the organization home and the project home list; `widgets` is the segment // the web shell's module mount routes to `widgetsPage` (ui/pages.tsx), listed in modules/index.ts. ui: { nav: [{ scope: "project", label: "widgets.title", href: "widgets", permission: "widgets.widget.read" }] }, i18n: { en, lt }, }) ``` ## 8. A page A server component per path under `////widgets`, with no route file: ```tsx // modules/widgets/ui/pages.tsx // The widgets module's pages, as the one server component the web shell mounts under // `////widgets` (apps/web's module mount reads it from `modules/index.ts`). // Every read is a procedure through the same caller the API uses; every write is a client component. import type { Container, TransportCaller } from "@conusai-kit/core" import type { I18n } from "@conusai-kit/i18n" import type { ProjectPage } from "@conusai-kit/ui" import type { ReactElement } from "react" import { widgetList } from "../contract/index.js" import { WidgetForm } from "./widget-form.js" export type WidgetsPage = ProjectPage export const widgetsPage: WidgetsPage = async ({ slugs, caller, i18n }): Promise => { const t = (key: string, values?: Record) => i18n.message(`widgets.${key}`, values) const { widgets } = await caller.call(widgetList, slugs) return ( <>

{t("title")}

{t("intro")}

{widgets.length === 0 ? (

{t("empty")}

) : ( <>

{t("count", { count: widgets.length })}

    {widgets.map((widget) => (
  • {widget.name}
  • ))}
)} ) } ``` Add it to `modules/index.ts`, beside `notes`: ```ts export const modulePages: Readonly Promise>> = { notes: async () => (await import("./notes/ui/pages.js")).notesPage, widgets: async () => (await import("./widgets/ui/pages.js")).widgetsPage, } ``` A client component asks `useTransport()` of `@conusai-kit/ui` for `call`, which carries the session and the CSRF token. The `ui.nav` entry of the manifest makes the page appear on the project's home page and in the lists of the workspace and the organization. ```tsx // modules/widgets/ui/widget-form.tsx "use client" // Adds a widget. A client component asks `useTransport()` of @conusai-kit/ui for `call`: the one API // path every caller uses, with the session, the CSRF token and the problem+json refusals handled by // the shell. Every label is a message the server passed in. import { type ProjectSlugs, useTransport } from "@conusai-kit/ui" import { type FormEvent, type ReactElement, useId, useState } from "react" export interface WidgetFormLabels { readonly name: string readonly submit: string /** The app's `app.failed` message, with `{detail}` still in it. */ readonly failed: string } export function WidgetForm({ slugs, labels }: { slugs: ProjectSlugs; labels: WidgetFormLabels }): ReactElement { const { call, describe } = useTransport() const nameId = useId() const [problem, setProblem] = useState(undefined) const submit = async (event: FormEvent): Promise => { event.preventDefault() const name = String(new FormData(event.currentTarget).get("name") ?? "").trim() if (name.length === 0) return setProblem(undefined) try { await call("widgets.widget.create", { ...slugs, name }) window.location.reload() } catch (error) { setProblem(labels.failed.replace("{detail}", describe(error))) } } return (
{problem ? (

{problem}

) : null}
) } ``` ## 9. A message catalogue Every label is a message, in both languages. `lt.ts` is typed from `en.ts`, so a missing key is a type error, and the scaffolded test fails when the two differ. Lithuanian has four plural forms (`one`, `few`, `many`, `other`); the example has the three a count of things needs: ```ts // modules/widgets/i18n/en.ts // English messages of the widgets module (spec §10.4). Every string a page of this module shows is a key // here and in lt.ts; `__tests__/module.test.ts` fails when the two differ. export const en = { title: "Widgets", intro: "Widgets are the example this guide adds.", name: "Name", add: "Add widget", empty: "No widgets yet.", count: "{count, plural, one {# widget} other {# widgets}}", } as const ``` ```ts // modules/widgets/i18n/lt.ts // Lietuviški widgets modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile. import type { en } from "./en.js" type Messages = { [K in keyof T]: T[K] extends string ? string : Messages } export const lt: Messages = { title: "Valdikliai", intro: "Valdikliai yra pavyzdys, kurį prideda šis vadovas.", name: "Pavadinimas", add: "Pridėti valdiklį", empty: "Valdiklių dar nėra.", count: "{count, plural, one {# valdiklis} few {# valdikliai} other {# valdiklių}}", } ``` ## 10. Test and gate ```bash pnpm vitest run modules/widgets test/modules/widgets # no database pnpm format # Biome pnpm openapi # a new procedure changes openapi.json; a test fails on drift pnpm gate # typecheck, lint, boundaries, tests (isolation included), build pnpm kit gate # the same, in a clean copy, installed from the registry pnpm e2e # Chromium and axe against the whole app (Docker for Postgres) ``` ```ts // test/modules/widgets/widgets-flow.test.ts // The widgets module against the whole app on the in-memory data source // (test/support/notes-app.ts wires the registry, the queue and the outbox exactly as the apps do): a // widget is created, listed, tidied by its job, audited and metered -- and a viewer may read and not write. import { describe, expect, it } from "vitest" import { makeNotesApp } from "../../support/notes-app.js" describe("the widgets module", () => { it("creates a widget, lists it, and tidies its name in the job it enqueued", async () => { const app = makeNotesApp() const project = await app.seedProject("acme", "owner@acme.test") const created = (await project.call.callByName("widgets.widget.create", { ...project.slugs, name: " Left handed widget ", })) as { widgetId: string } expect(app.queue.jobs.map((job) => job.job)).toEqual(["widgets.tidy"]) expect(await app.runJobs()).toBe(1) const listed = (await project.call.callByName("widgets.widget.list", project.slugs)) as { widgets: Array<{ widgetId: string; name: string }> } expect(listed.widgets).toEqual([ expect.objectContaining({ widgetId: created.widgetId, name: "Left handed widget" }), ]) }) it("declares the audit event and the usage meter the transport records for a create", () => { const app = makeNotesApp() const route = app.registry.router["widgets.widget.create"] expect(route?.audit).toBe("widgets.widget_created") expect(route?.usage).toBe("widgets_created") expect(app.registry.audit["widgets.widget_created"]).toBe("widgets") }) it("lets a viewer read and refuses them a write", async () => { const app = makeNotesApp() const project = await app.seedProject("acme", "owner@acme.test") const viewer = await app.addMember(project, "viewer@acme.test", "viewer") await expect(viewer.call.callByName("widgets.widget.list", project.slugs)).resolves.toEqual({ widgets: [] }) await expect( viewer.call.callByName("widgets.widget.create", { ...project.slugs, name: "Nope" }), ).rejects.toMatchObject({ problem: { status: 404 } }) }) }) ``` Your tests are yours, and live in two places: `modules//__tests__/` for what needs only the module (use cases against `createMemoryDataSource`, the migration, the isolation fixtures), and `test/modules//` for what needs the whole app, such as the flow below. Both are exempt from the template's licence header and its `TEMPLATE-FILES` list (`node scripts/licence.mjs --check`); a file you add anywhere else under `test/` or `apps/` is a template file and fails that check. A test under `modules/` may not import `test/support/notes-app.ts`, which reaches the worker's system actor, and the boundary gate says so. `test/support/notes-app.ts` is the whole app on the in-memory data source, the one to build on. For a screen, copy the shape of `e2e/notes.spec.ts`: sign in with `signIn`, make an organization with `makeOrganization`, open `////widgets`, and call `violations(page)` on every state. ## Demo data for your module `apps/worker/src/seed.ts` writes the demo data of `pnpm dev` with the kit's own procedures. A step added to `seedDemo` that calls your module's procedures as one of the demo people is the one edit outside `modules/` that is meant for you; keep it idempotent (ask whether the row is there first).