Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/orpc-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@codelitdev/platform": minor
"@codelitdev/platform-conformance": minor
"@codelitdev/platform-cli": minor
---

REST contracts move from ts-rest to oRPC, and every product commits its OpenAPI document (ADR 0017).

- `@codelitdev/platform/rest` (new): `platformErrors`, `encodePlatformErrorBody` and `platformErrorResponseBodySchema` keep oRPC error bodies and the generated OpenAPI in the platform `{ code, message, details? }` shape. Also adds `listOperationIds`, `normalizeOpenApiDocument`, and `writeOpenApiDocument`/`runOpenApiScript` for a product's `generate:openapi --check` script. It has no runtime dependency on oRPC.
- `@codelitdev/platform-conformance`: **breaking.** `openapiDocument()` must also return `committedDocumentCurrent`. The `openapi` suite fails when the committed `openapi.json` does not match the contract.
- `@codelitdev/platform-cli`: the template defines its contract with oRPC on Express, commits `apps/api/openapi.json`, and adds `generate:openapi`/`check:openapi` (`check:openapi` is in `check:drift` and CI). The preset pins `@orpc/*` 1.16.1 instead of `@ts-rest/*`. The `@anatine/zod-openapi` patch and the `ts-deepmerge` override are removed. `doctor` now checks the lowest version when several manifests declare the same package, so a vulnerable pin can no longer be hidden by directory read order.

Existing products: follow ADR 0017. Add `generate:openapi` to your release version step so each release commits the document hosted docs serve.
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"!**/migrations",
"!**/node_modules",
"!**/*.generated.ts",
"!**/apps/api/openapi.json",
"!**/*.tgz",
"!packages/design-system/public/r"
]
Expand Down
97 changes: 63 additions & 34 deletions bun.lock

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Package manager and runtime: Bun
CodeLit Platform is the reusable application kernel and bootstrap system for CodeLit SaaS products. It lets products share a proven implementation of:

- TypeScript monorepo and build conventions.
- Express and ts-rest APIs with generated OpenAPI.
- Express APIs defined by an oRPC contract, with a generated and committed OpenAPI document (ADR 0017).
- Better Auth and @codelitdev/oauth-server-kit.
- Tenant selection, membership, API-key, and authorization conventions.
- REST and MCP parity over common application services.
Expand Down Expand Up @@ -74,7 +74,7 @@ Auth, MCP, observability, and billing improvements arrive as package releases. M

REST routes and MCP tools call the same framework-independent application service:

web/API client -> ts-rest adapter --+
web/API client -> oRPC handler -----+
+-> application service -> authorization
MCP client -> MCP adapter -----+ -> repositories
-> audit/outbox
Expand Down Expand Up @@ -367,7 +367,7 @@ The extraction must not copy product event names, context allowlists, environmen
The preset ships inside @codelitdev/platform-cli as `src/preset.json` (ADR 0006). It is a tested compatibility bill of materials pinning:

- Exact compatible releases of the independently versioned Platform workspace packages and selected external peers such as dodopayments.
- Express, ts-rest, Zod, Better Auth, Drizzle, MCP SDK, Pino, Bun test, and PGlite.
- Express, oRPC, Zod, Better Auth, Drizzle, MCP SDK, Pino, Bun test, and PGlite.
- Supported Bun, TypeScript, Next.js, and React lines.

Products depend on individual packages; CI verifies that resolved versions match one preset. The preset has no runtime code.
Expand Down Expand Up @@ -473,9 +473,9 @@ A managed file may be replaced only when its current hash matches the hash recor

The completed reference product is the executable specification:

- apps/api: Express, ts-rest, OpenAPI, Better Auth, Drizzle/PostgreSQL, REST, MCP, Pino, optional PostHog, and worker entry point.
- apps/api: Express, oRPC, OpenAPI, Better Auth, Drizzle/PostgreSQL, REST, MCP, Pino, optional PostHog, and worker entry point.
- apps/web: Next.js admin shell, hosted sign-in redirect, session validation against the API, tenant switcher, and one resource screen.
- packages/api-contract: shared Zod and ts-rest contract.
- packages/api-contract: shared Zod schemas and the oRPC contract.
- Tenant, membership, invitation, API-key, audit-event, and example-resource models.
- Official @codelitdev/billing example with billing.config.ts, committed billing.generated.ts, application-owned Drizzle migration, createBilling composition, fake provider, action-grant adapter, audit/product-effect hooks, and worker maintenance entry point.
- PGlite integration tests and Docker Compose for PostgreSQL.
Expand Down Expand Up @@ -583,7 +583,7 @@ Standard server order:
3. Security headers, body limits, and rate limiting.
4. Authentication.
5. Tenant resolution and membership.
6. ts-rest adapter or MCP transport.
6. oRPC handler or MCP transport.
7. Product service and transaction.
8. Sanitized error capture.
9. Final error response.
Expand Down Expand Up @@ -707,7 +707,7 @@ Exit: repository history disposition is recorded, blocking ADRs are accepted, an
- Implement @codelitdev/platform's context, error, lifecycle, audit, ID, and safe transport-mapping contracts.
- Create `examples/reference-product/apps/api`, `apps/web`, and `packages/api-contract` without creating the distributable template yet.
- Implement Better Auth schema generation, OAuth composition, cross-subdomain session cookies, tenant selection, memberships, invitations, API keys, audit events, and the credential-precedence rules in section 8.
- Add one tenant-owned example resource through ts-rest/OpenAPI and a Pino-stdout-only baseline.
- Add one tenant-owned example resource through the oRPC contract and OpenAPI and a Pino-stdout-only baseline.
- Integrate @codelitdev/billing through packed public exports, a committed generated schema, an application-owned migration, fake provider, action grants, audit/product-effect hooks, and bounded maintenance invocation.
- Add PGlite tests, Docker Compose PostgreSQL, migration drift checks, and public-API reports.

Expand Down Expand Up @@ -779,7 +779,7 @@ Required tests:
- Ambiguous/invalid credential rejection with no fallback, and proof that an API key cannot select another tenant.
- API-key one-time display, digest verification, expiry, rotation overlap, immediate revocation, permission subsets, and log redaction.
- Invitation expiry, resend invalidation, email mismatch, consume-once concurrency, membership idempotency, auditing, and last-owner protection.
- ts-rest runtime/OpenAPI consistency.
- oRPC runtime/OpenAPI consistency, and a committed `openapi.json` that matches the contract.
- MCP session, auth, cancellation, and parity.
- PostHog absent/configured/failing, idempotent initialization, and timeout-bounded flush/shutdown.
- Exception redaction, product property allowlists, bounded dedupe state, per-source rate caps, and manual-versus-SDK double-capture prevention.
Expand Down Expand Up @@ -812,7 +812,7 @@ Security requirements:
- Every credential kind passes tenant-isolation conformance.
- Better Auth and billing schema generation are deterministic and application migrations remain application-owned.
- Credential precedence, API-key lifecycle, invitations, tenant selection, and last-owner invariants pass security conformance.
- OpenAPI comes from the runtime ts-rest contract.
- OpenAPI comes from the oRPC contract and is committed as `apps/api/openapi.json`; CI and conformance fail when it is stale.
- Optional PostHog is redacted, bounded, and failure-isolated.
- The reference app consumes the preset-pinned official billing package using public exports, a checked-in generated schema, and application-owned migration.
- Billing action grants, audit/product-effect hooks, fake-provider Cloud/OSS behavior, and maintenance invocation pass composition conformance.
Expand Down
89 changes: 89 additions & 0 deletions docs/decisions/0017-orpc-contract-and-committed-openapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR 0017: oRPC contracts and a committed OpenAPI document

Status: accepted
Date: 2026-11-13

## Context

The template, the reference product, SendLit, FrontLit, and CourseLit define
their REST APIs with ts-rest and generate OpenAPI with `@ts-rest/open-api`.
ts-rest has had no release since `3.53.0-rc.1` (June 2025). Its OpenAPI
generator depends on `@anatine/zod-openapi`, which needed a local patch, and
it supports Zod 3 only. MediaLit uses Express with Joi and swagger-autogen, so
its document comes from hand-written comments and does not match the code.

A survey of the four products (2026-11-13) found:

- Every product runs Express 4. The Platform packages also assume Express:
`oauth-server-kit` exports Express routers, and `observability` lists Express
as a peer. Products also depend on Express for raw-body webhooks, rate
limiters, SSE, WebSockets, tus, multipart uploads, and about 50 test files
that build an Express app.
- SendLit and FrontLit use the contract for typed clients. SendLit's public
SDK is built on ts-rest types, and both web apps call `initClient`.
- Contract keys are used as operation ids by authorization policies and MCP
parity in SendLit, FrontLit, and CourseLit.
- Hosted FrontLit serves each product's committed `openapi.json` at
`<product-website>/openapi.json`. Only SendLit and CourseLit commit the
file, and neither regenerates it as part of a release.

## Decision

- **Contracts are oRPC contracts** (`@orpc/contract`) built with Zod. They
stay in the product's `packages/api-contract`. Routes keep their keys as
operation ids, and nested routers use dot paths (`settings.esps.list`).
- **Express stays.** oRPC's `OpenAPIHandler` from `@orpc/openapi/node` runs as
Express middleware. Requests it does not match fall through to the rest of
the app, so webhooks, Better Auth, the kit routers, and uploads are
unchanged. We rejected Hono and Fastify: replacing Express would ripple
through the Platform kits and every product without improving the API
documentation.
- **Errors keep the platform shape.** `@codelitdev/platform/rest` adds:
- `platformErrors(...codes)`, which declares platform error codes on a route.
- `encodePlatformErrorBody`, which is the handler's
`customErrorResponseBodyEncoder`.
- `platformErrorResponseBodySchema`, which is the generator's
`customErrorResponseBodySchema`.

The runtime body and the documented body are therefore both
`{ code, message, details? }`. A route can declare a code that is not a
platform code, with its own `data` schema (for example readiness on 503).
Its body is then that data unchanged. This is how products with their own
error shapes migrate. Input validation failures and thrown exceptions
become platform bodies, and internal messages are never exposed.
- **The OpenAPI document is generated from the contract alone.** Generation
uses `OpenAPIGenerator` with `@orpc/zod`'s Zod 3 converter. It does not
start the server or need a database. The output is OpenAPI 3.1.
- **Every product commits `apps/api/openapi.json`:**
- `generate:openapi` writes the file. `info.version` is the API package
version, and the server URL is the production API URL.
- `check:openapi`, which is `generate:openapi --check`, fails CI when the
file is stale. It is part of `check:drift`.
- The release version step runs `generate:openapi`, so the release commit
carries the document for that release. This repository does it in
`version-packages`. Products add it to their own version step.
- `platform-conformance` fails a product that declares `openapi` when its
committed document does not match the contract
(`committedDocumentCurrent`).
- **Versions:** oRPC is pinned to exact versions in the preset (`1.16.1`), and
Zod stays on 3.25. oRPC accepts Zod 4 through `@orpc/zod/zod4`, so moving
to Zod 4 is a separate decision.

## Consequences

- ts-rest, `@anatine/zod-openapi`, its patch, and the `ts-deepmerge` override
are gone from this repository and the template.
- Paths use `{param}` instead of `:param`. Code that reads contract paths
directly must change, for example CourseLit's policy resolver and its
generated MCP tools.
- oRPC's typed client throws on error responses. ts-rest returned a
`{ status, body }` union instead. SendLit's and FrontLit's client wrappers
and SendLit's SDK change shape when they migrate.
- Each committed `openapi.json` changes once in a large diff: from OpenAPI 3.0
to 3.1, a different schema converter, and fuller error responses. Docs
renderers must accept 3.1.
- oRPC 2.0 is in beta. A future major upgrade is a contract-builder migration
across products, so we pin exact versions and upgrade deliberately.
- Product migration order: CourseLit (no typed client), MediaLit (adopt a
contract for its public routes and replace swagger-autogen), FrontLit, and
then SendLit (its public SDK is affected).
5 changes: 5 additions & 0 deletions examples/reference-product/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ not enable it for a production deployment.
Open `http://127.0.0.1:4000/docs` for the interactive Swagger UI, or fetch the
generated OpenAPI document from `http://127.0.0.1:4000/openapi.json`.

The REST API is an oRPC contract in `packages/api-contract`. The committed
`apps/api/openapi.json` is generated from it with
`bun run --filter @reference-product/api generate:openapi`. CI fails when the
file is stale, and the release version step regenerates it (ADR 0017).

In a second terminal, start the Next.js web app:

```bash
Expand Down
Loading
Loading