# AGENTS.md — <%= projectName %>

Node/Express backend service for the Alberta Digital Service Platform (ADSP).
Generated by `nx g @abgov/nx-adsp:express-service`.
<% if (pairedProject) { %>
## Paired frontend

This service is paired with **`<%= pairedProject %>`** — the frontend app it serves.
The frontend proxies `/api/` to `/<%= projectName %>/` on port 3333 (dev) and via nginx (production).
When working on a feature that spans both projects, read `apps/<%= pairedProject %>/AGENTS.md` for the frontend context.
<% } %>
## Running Nx commands (coding agents)

Run generators with `--no-interactive` **and** every required option supplied. With
`--no-interactive`, a missing required option errors instead of prompting, so an
interactive prompt never blocks your session (`CI=true` in the env does the same and
also skips the Nx Cloud prompt). This applies to `nx g` generators; `nx run <target>`
executors read options from `project.json` and do not prompt.

## ADSP knowledge (MCP)

This workspace is wired with the **`@abgov/adsp-sdk-mcp-server`** MCP server (see
`.mcp.json` at the workspace root). It gives grounded, sourced answers about ADSP
and the Node SDK. **Prefer these tools over recalling `@abgov/adsp-service-sdk`
APIs from memory** when integrating ADSP capabilities:

- `get_platform_quickstart` — the canonical `initializePlatform` usage pattern and
  a capabilities summary. Start here for "how do I use ADSP from this service?".
  Covers `serviceConfigurations` too — registration creates resources, not just
  declares a dependency, and there's both a `NamedConfiguration` and a
  plain-object variant.
- `get_service_configuration_schema` — fetches the live configuration schema for
  a platform service from configuration-service. Use this before writing a
  `serviceConfigurations` entry in `initializeService()` so its shape matches
  what that service actually expects, instead of guessing.
- `search_sdk_reference` — look up `@abgov/adsp-service-sdk` by symbol, module, or
  keyword; returns kind, description, option/return shape, example, deprecation.
- `search_adsp_docs` — keyword search across ADSP platform docs (getting started,
  architecture, service concepts, tutorials).
- `read_adsp_doc` — read the full content of a doc page found via search.

If these tools aren't available, your MCP client hasn't loaded `.mcp.json` yet —
Claude Code prompts to approve project MCP servers on first use; other clients may
need project-scoped MCP enabled. The server runs via `npx` and needs no credentials.

## Stack


- **Runtime**: Node.js + Express
- **Auth**: passport.js with tenant strategy from `@abgov/adsp-service-sdk`
- **Config**: `envalid` with `.env` file (see `src/environment.ts`)
- **SDK**: `@abgov/adsp-service-sdk` — provides service registration, auth,
  event publishing, configuration management, and service discovery
- **Tests**: Jest
<% if (database === 'postgres') { %>
- **Database**: PostgreSQL via Drizzle ORM (pure TypeScript, node-postgres driver)
<% } else if (database === 'mongo') { %>
- **Database**: MongoDB via Mongoose
<% } %>

## Key files

| File | Purpose |
|------|---------|
| `src/main.ts` | Composition root — SDK init, middleware, mounts routers, server start |
| `src/routes/example.ts` | Example `express.Router()` — starter resource; copy it per resource |
| `src/environment.ts` | Validated env config with defaults pre-set from ADSP tenant |
| `src/events.ts` | Domain event definitions — register in `initializeService({ events })` |
<% if (database === 'postgres') { %>
| `src/database.ts` | Drizzle `db` instance — import `{ db }` in route handlers |
| `src/db/schema.ts` | Drizzle table definitions — edit to add tables, then run `nx db:generate <%= projectName %>` |
| `src/migrate.ts` | Standalone migration runner — bundled to `migrate.js`, run as the deploy init container |
| `drizzle.config.ts` | drizzle-kit config (schema path, migrations output, connection) |
<% } else if (database === 'mongo') { %>
| `src/database.ts` | Mongoose connection helpers — `connectDatabase()` and `disconnectDatabase()` |
<% } %>

## Health and readiness checks

`/health` and (<% if (database !== 'none') { %>already, since this service has a database<% } else { %>if this service later gains a database or another required dependency<% } %>) `/health/ready` are two different questions, mapped to the two different OpenShift probes for a reason — conflating them into one endpoint means an outage in a downstream dependency gets treated as "restart this pod," which does not fix the outage and just adds a restart loop on top of it:

- **`/health` — liveness: is the process itself up.** Stays dependency-free on purpose. Don't add a check here for anything this service depends on but doesn't own.
<% if (database !== 'none') { %>
- **`/health/ready` — readiness: can this pod actually serve requests right now.** Already checks the database (`isDatabaseReady()` in `src/database.ts`) — a real round-trip query, not just a pool-state flag. **Extend this handler, not `/health`, for any other resource this service can't function without** — another required downstream API, a message broker, a required ADSP capability beyond what `healthCheck()` already covers — following the same reasoning: a dependency being down should hold traffic (503, readiness), not trigger a pod restart (liveness) that can't fix a problem outside this pod.
<% } else { %>
- If this service later gains a database or another hard dependency, add a `/health/ready` route that checks it (see `@abgov/nx-adsp:express-service --database postgres|mongo` for the shipped pattern) and point the deployment's `readinessProbe` at it instead of `/health` — don't check dependencies from the liveness route.
<% } %>

## SDK capabilities

`initializeService()` returns `capabilities`:

```typescript
const { logger, tenantStrategy, traceHandler, configurationHandler,
        healthCheck, directory, tokenProvider, eventService } = capabilities;
```

To resolve another ADSP service URL:

```typescript
const url = await directory.getServiceUrl(AdspId.parse('urn:ads:platform:file-service'));
```

To call another service with an access token:

```typescript
const token = await tokenProvider.getAccessToken();
```

## Service won't start (401/403)

If `nx serve` exits with `Failed to start: ADSP service registration did not
complete` (401/403 from `TenantService`/`ServiceRegistration`), the service's
Keycloak **service-account user** is missing required platform roles:

| Platform client | Role |
|---|---|
| `urn:ads:platform:tenant-service` | `platform-service` |
| `urn:ads:platform:event-service` | `event-sender` |
| `urn:ads:platform:configuration-service` | `configured-service` |

The generator assigns these when it creates the client, but that needs an admin
login (`manage-users`). If it printed a `WARNING: could not grant required
platform role(s)` during generation, fix it by either:

1. signing in with the admin scope and **re-running the generator** (its
   existing-client path back-fills the roles):
   ```bash
   npx @abgov/adsp-cli login --tenant <tenant> --scope adsp-cli-admin
   ```
2. or adding those three client roles to the `<%= projectName %>` service-account
   user in the ADSP admin portal.

## Project structure & code factoring

`main.ts` is the **composition root** — it initializes the SDK, wires middleware,
mounts routers, starts the server, and mounts the error handler last. Keep feature
code out of it. Conventional layout:

```
src/
  main.ts                  # composition root — no business logic
  routes/<resource>.ts     # one express.Router() per resource; thin HTTP handlers
  services/<resource>.ts   # business logic — no req/res, unit-testable
<% if (database === 'postgres') { %>  db/schema.ts             # Drizzle tables
  database.ts              # shared `db` instance
<% } else if (database === 'mongo') { %>  models/<resource>.ts     # Mongoose models
  database.ts              # connection helpers
<% } %>  events.ts                # domain event definitions
```

Rules of thumb:

- **Do not inline route handlers in `main.ts`.** Add an `express.Router()` per
  resource under `routes/` and mount it. `src/routes/example.ts` is the shipped
  starter (with a `example.spec.ts` supertest test) — copy its shape per resource
  and replace it as you build real features.
- **Keep handlers thin** — validate input, call a service, shape the response.
  Put non-trivial logic in `services/` so it can be tested without HTTP.
- **One responsibility per module** — routers do HTTP, services do logic, the db
  layer does persistence.
- **Pass runtime capabilities in; don't import them.** `logger` and `eventService`
  come from `initializeService()` in `main.ts`, so pass them into a router/service
  as arguments (e.g. `itemsRouter(eventService)`). Only module-level singletons —
  the `db` instance, Mongoose models — are imported directly.

## Adding routes

Factor each resource into its own router module and mount it in `main.ts`.

```typescript
// src/routes/items.ts
import { Router } from 'express';
import { authorize, createValidationHandler } from '@abgov/adsp-service-sdk';
import { z } from 'zod';
import * as items from '../services/items';
import { registry } from '../openapi';

const CreateItem = z.object({ name: z.string().trim().min(1) });
const BASE = '/<%= projectName %>/v1/items';

// Reuses CreateItem — the same schema passed to createValidationHandler below
// — so the served OpenAPI doc can't drift from what's actually validated. See
// "Why this matters for ADSP" below.
registry.registerPath({
  method: 'get',
  path: BASE,
  summary: 'List items.',
  security: [], // anonymous is allowed on the /v1 prefix — see the router below
  responses: { 200: { description: 'The items.', content: { 'application/json': { schema: z.array(CreateItem) } } } },
});
registry.registerPath({
  method: 'post',
  path: BASE,
  summary: 'Create an item.',
  request: { body: { content: { 'application/json': { schema: CreateItem } } } },
  responses: { 201: { description: 'The created item.', content: { 'application/json': { schema: CreateItem } } } },
});

export function itemsRouter(): Router {
  const router = Router();

  // Anonymous is allowed on the /v1 prefix, so req.user may be null here.
  router.get('/', async (_req, res, next) => {
    try {
      res.json(await items.list());
    } catch (err) {
      next(err);
    }
  });

  // Protected: require a role, validate the body, then delegate to the service.
  router.post(
    '/',
    authorize('item-writer'),
    createValidationHandler(CreateItem),
    async (req, res, next) => {
      try {
        // createValidationHandler validates req.body against the schema but
        // doesn't replace it with the parsed result — req.body is still the
        // raw body afterward. Invisible for a plain string, but any
        // transform (.trim() here) or coercion (z.coerce.date(), a default)
        // is silently lost if you read req.body directly instead of
        // re-parsing. Parse again, here, to get the actual parsed value.
        const { name } = CreateItem.parse(req.body);
        res.status(201).json(await items.create(name));
      } catch (err) {
        next(err);
      }
    }
  );

  return router;
}
```

Mount it in `main.ts` — after the passport/configuration middleware, before
`createErrorHandler`:

```typescript
import { itemsRouter } from './routes/items';
// ...
app.use('/<%= projectName %>/v1/items', itemsRouter());
```

`authorize(role)` → 403 if the user lacks the role; `createValidationHandler(schema)`
→ 400 on a bad body. Both forward errors to `next()`, which `createErrorHandler`
(mounted last in `main.ts`) turns into structured HTTP responses. Don't read
`req.body` directly in the handler and assert its type (`req.body as
z.infer<typeof Schema>`) — that's the raw, unparsed body, not the validated
one; parse it again with the same schema instead, as above.

### Why this matters for ADSP

`main.ts`'s root `/` handler returns a `docs` link pointing at `/swagger/docs/v1`, which serves an
OpenAPI document built at startup from every `registry.registerPath()` call across all mounted
routers (see `src/openapi.ts`). ADSP's directory service polls each registered service's root
endpoint and, when it finds that `docs` link, aggregates the spec into
`https://api.adsp.alberta.ca/{tenant}` — so every route added here (following the pattern above)
becomes part of the platform's aggregated API docs automatically, with no separate file to update.
This requires the service to already have a directory entry — a one-time setup step outside this
generator, done via the Tenant Management Webapp's directory admin UI.

## Recipe: add a resource end to end

Build a feature the conventional way — persistence, a service, a router, wiring,
and a test. (Replace the inline example routes in `main.ts` with resources built
like this.)

<% if (database === 'postgres') { %>1. **Table** — add it to `src/db/schema.ts`, then `nx db:generate <%= projectName %>` and `nx db:migrate <%= projectName %>` (see "Adding a table").
<% } else if (database === 'mongo') { %>1. **Model** — add a Mongoose model (see "Adding a Mongoose model").
<% } else { %>1. **Data source** — back the resource with your store (this service has no database configured).
<% } %>
2. **Service** — put the logic in `src/services/items.ts`, with no `req`/`res` so
   it is unit-testable in isolation:

```typescript
// src/services/items.ts
<% if (database === 'postgres') { %>import { db } from '../database';
import { items } from '../db/schema';

export const list = () => db.select().from(items);
export const create = async (name: string) => {
  const [row] = await db.insert(items).values({ name }).returning();
  return row;
};<% } else if (database === 'mongo') { %>import { ItemModel } from '../models/item';

export const list = () => ItemModel.find().lean();
export const create = (name: string) => ItemModel.create({ name });<% } else { %>const store: { id: number; name: string }[] = [];

export const list = async () => store;
export const create = async (name: string) => {
  const row = { id: store.length + 1, name };
  store.push(row);
  return row;
};<% } %>
```

3. **Event** (optional) — define it in `src/events.ts` and register it in
   `initializeService({ events })`. To emit it, have the router factory accept
   `eventService` — `itemsRouter(eventService)` — and call `eventService.send(...)`
   in the handler after a successful write (see "Domain events").

4. **Router** — create `src/routes/items.ts` as in "Adding routes" above: thin
   handlers that validate, call the service, and shape the response.

5. **Mount** — in `main.ts`: `app.use('/<%= projectName %>/v1/items', itemsRouter());`

6. **Test** — mount the router with supertest and mock the service (no DB, no
   server needed):

```typescript
import request from 'supertest';
import express from 'express';
import { itemsRouter } from './items';

jest.mock('../services/items', () => ({
  list: async () => [{ id: 1, name: 'first' }],
  create: jest.fn(),
}));

describe('items router', () => {
  it('GET / returns items', async () => {
    const app = express().use(express.json());
    app.use('/items', itemsRouter());
    const res = await request(app).get('/items');
    expect(res.status).toBe(200);
    expect(res.body).toEqual([{ id: 1, name: 'first' }]);
  });
});
```

## Adding an environment variable

1. Add the variable to `src/environment.ts` using `envalid`:
   ```typescript
   MY_SETTING: str({ default: 'default-value', docs: 'Purpose of this setting' }),
   ```
2. Reference it as `environment.MY_SETTING` in your handler
3. Set the value in `.env` for local development (`.env` is gitignored)
4. Add it as a ConfigMap entry or Secret ref in the OpenShift deployment manifest

Do not read from `process.env` directly — always go through `environment.ts`
so that missing required values fail loudly at startup.
<% if (database === 'postgres') { %>

## Local database

A local Postgres instance runs in a Podman container managed by `scripts/dev-db.sh`.

Start or resume it:

```bash
nx dev-db <%= projectName %>
```

`nx serve` depends on `dev-db` — the container starts automatically when you
run the application. Run `nx dev-db` explicitly only when you need the database
before `nx serve` (e.g. running migrations with `nx db:migrate <%= projectName %>`).

`DATABASE_URL` is written to `.env.local` on first run:

```
postgresql://<%= projectName %>:<%= projectName %>@localhost:5432/<%= projectName %>_dev
```

**If Postgres is not responding:**
1. Check that Podman is running: `podman ps`
2. Re-run: `nx dev-db <%= projectName %>`
3. macOS only — if `podman ps` errors: `podman machine start`

**macOS one-time prerequisite** (skip if already set up for another project):

```bash
podman machine init
podman machine start
```

## Adding a table

1. Edit `src/db/schema.ts` to add your table (Drizzle `pgTable`)
2. Run `nx db:generate <%= projectName %>` — writes a SQL migration to `drizzle/`
3. Run `nx db:migrate <%= projectName %>` — applies pending migrations to your dev DB
4. Import `{ db }` from `./database` and your table from `./db/schema` in a handler

```typescript
// Example route using Drizzle
import { db } from './database';
import { items } from './db/schema';

app.get('/<%= projectName %>/v1/items', async (_req, res) => {
  const rows = await db.select().from(items);
  res.json(rows);
});
```

## Drizzle targets

| Target | Command | Use |
|--------|---------|-----|
| `nx db:generate <%= projectName %>` | `drizzle-kit generate` | Generate a SQL migration from schema changes |
| `nx db:migrate <%= projectName %>` | `drizzle-kit migrate` | Apply pending migrations to the dev DB |
| `nx db:migrate:deploy <%= projectName %>` | `drizzle-kit migrate` | Apply pending migrations in a dev/CI shell |
| `nx db:studio <%= projectName %>` | `drizzle-kit studio` | Open Drizzle Studio to browse data |

`drizzle-kit` is a dev-only dependency. In the container, migrations are applied
by `migrate.js` (bundled from `src/migrate.ts`), which uses only `drizzle-orm` +
`pg` — no CLI and no native engine — so it runs under OpenShift's arbitrary UID.

## Database in OpenShift — migrations run automatically

Migrations are applied at deploy time by an **init container** named
`<%= projectName %>-migrate`, which runs `node migrate.js` (everything in
`drizzle/`) against the database **before the app container starts** — so a
deploy always brings the schema up to date, and the app never starts against a
stale DB. `migrate.js` is bundled from `src/migrate.ts` using only `drizzle-orm`
+ `pg` (no `drizzle-kit` CLI, no native engine), so it runs under OpenShift's
arbitrary UID; it logs `No migrations to apply.` and exits 0 when `drizzle/` is
empty. The `drizzle/` folder is shipped into the build output as a build asset.

**Sandbox** (`nx run <%= projectName %>:sandbox`) — nothing to set up. The
sandbox target provisions a shared `sandbox-postgres` Postgres instance (via the
CloudNativePG operator when available, plain Deployment otherwise), creates this
service's `<%= projectName %>_sandbox` database, and injects `DATABASE_URL`
(sourced from the `sandbox-postgres-app` secret — keys `username` and `password`)
into both the init container and the app. If a deploy fails on migrations, read
the init container's logs:

```bash
oc logs -n <namespace> <pod> -c <%= projectName %>-migrate
```

See `.openshift/<%= projectName %>/SANDBOX.md` for the full sandbox runbook.

**dev / test / prod** — the deployment reads `DATABASE_URL` from a Secret named
`<%= projectName %>-database`. Create it once per namespace before the first deploy:

```bash
oc create secret generic <%= projectName %>-database \
  --from-literal=DATABASE_URL=postgresql://user:password@host:5432/dbname \
  -n <namespace>
```
<% } else if (database === 'mongo') { %>

## Local database

A local MongoDB instance runs in a Podman container managed by `scripts/dev-db.sh`.

Start or resume it:

```bash
nx dev-db <%= projectName %>
```

`nx serve` depends on `dev-db` — the container starts automatically when you
run the application.

`MONGODB_URI` is written to `.env.local` on first run:

```
mongodb://localhost:27017/<%= projectName %>_dev
```

**If MongoDB is not responding:**
1. Check that Podman is running: `podman ps`
2. Re-run: `nx dev-db <%= projectName %>`
3. macOS only — if `podman ps` errors: `podman machine start`

**macOS one-time prerequisite** (skip if already set up for another project):

```bash
podman machine init
podman machine start
```

## Adding a Mongoose model

Create a model file under `src/models/`:

```typescript
// src/models/item.model.ts
import mongoose, { Schema, Document } from 'mongoose';

export interface IItem extends Document {
  name: string;
  createdAt: Date;
}

const ItemSchema = new Schema<IItem>({
  name: { type: String, required: true },
  createdAt: { type: Date, default: Date.now },
});

export const Item = mongoose.model<IItem>('Item', ItemSchema);
```

Then import and use in a route handler:

```typescript
import { Item } from './models/item.model';

app.get('/<%= projectName %>/v1/items', async (_req, res) => {
  const items = await Item.find();
  res.json(items);
});
```

## Database in OpenShift

**Sandbox** (`nx run <%= projectName %>:sandbox`) — nothing to set up. The
sandbox target provisions a shared `sandbox-mongodb` instance and injects
`MONGODB_URI` (sourced from the `sandbox-mongodb-creds` secret) into the app.
See `.openshift/<%= projectName %>/SANDBOX.md` for the full sandbox runbook.

**dev / test / prod** — the deployment reads `MONGODB_URI` from a Secret named
`<%= projectName %>-database`. Create it once per namespace before the first deploy:

```bash
oc create secret generic <%= projectName %>-database \
  --from-literal=MONGODB_URI=mongodb://user:password@host:27017/dbname \
  -n <namespace>
```
<% } %>

## Domain events

Domain events are defined in `src/events.ts` and registered at startup via
`initializeService({ events: [...] })` in `main.ts`.

To add a new event:

1. Add a new `const MY_EVENT_NAME` and `DomainEventDefinition` export to `src/events.ts`
2. Add the definition to the `events` array in `initializeService()`
3. Add a factory function that returns a typed `DomainEvent`
4. Call `eventService.send(myFactory(...))` at the appropriate point in your handler

## Testing

Tests live alongside source files (`*.spec.ts`) and run with Jest:

```bash
nx test <%= projectName %>          # run all tests
nx test <%= projectName %> --watch  # watch mode
```

Route handler test example:

```typescript
import request from 'supertest';
import express from 'express';

describe('GET /<%= projectName %>/v1/items', () => {
  it('returns 200', async () => {
    const app = express();
    app.get('/<%= projectName %>/v1/items', (_req, res) => res.json([]));
    const res = await request(app).get('/<%= projectName %>/v1/items');
    expect(res.status).toBe(200);
  });
});
```
<% if (!pairedProject) { %>

## Frontend proxy integration

This service has no paired frontend configured yet. All API routes live under
`/<%= projectName %>/v1/`. To wire up a Vue, React, or Angular frontend's
nginx proxy (production) and dev-server proxy (development) to this service,
run either:

```bash
# Re-run this generator against the existing frontend — preferred when the
# frontend already exists (both proxy files and the adsp:proxy-service: tag
# are updated automatically):
npx nx g @abgov/nx-adsp:express-service <%= projectName %> --pairedProject <frontend-name> --no-interactive

# Or run the frontend generator against its own existing project instead:
npx nx g @abgov/nx-adsp:vue-app <frontend-name> --pairedProject <%= projectName %> --no-interactive
# (substitute react-app or angular-app as appropriate)
```

Both paths produce the same result: the frontend routes `/api/` to
`/<%= projectName %>/` on port 3333 in dev and via nginx in production.
<% } %>

## OpenShift targets

```bash
nx run <%= projectName %>:sandbox           # build locally (podman) + push to GHCR + deploy
nx run <%= projectName %>:sandbox-teardown  # remove sandbox resources + delete the GHCR image
nx run <%= projectName %>:apply-envs        # apply manifests to all environments
nx run <%= projectName %>:teardown-dev      # remove from dev environment
```

## What NOT to change

- `initializeService(...)` config — `CLIENT_ID` and `CLIENT_SECRET` are read
  from environment; do not hardcode credentials
- The passport strategy setup — the tenant strategy handles JWT validation
- `configurationHandler` on the API path — provides configuration service
  integration; keep it applied before route handlers
- `createErrorHandler(logger)` — must remain the last `app.use()` call

## Sandbox deployment (local build)

**Deployment target: `sandbox`.** `nx run <%= projectName %>:sandbox` builds the image **locally with podman**, pushes it to GHCR, and deploys it to your namespace — no git push or CI wait. Run the generator once first to add the targets:

```bash
nx g @abgov/nx-oc:sandbox <%= projectName %> --sandboxProject <your-namespace>
```

That also writes **`.openshift/<%= projectName %>/SANDBOX.md`** — the full deploy runbook: prerequisites (`podman`, `oc` login, a `gh` account with **`write:packages`** as the *active* `gh` account), preflight failures and their fixes, `--skipBuild`/`--skipPush` to resume a partial deploy, a copy-paste manual-completion sequence, and troubleshooting (CPU quota, `CrashLoopBackOff`, registry auth, redirect URIs). Read it whenever a deploy misbehaves.
