Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/external-pin-floors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codelitdev/platform-cli": patch
---

`doctor` now treats external pins as caret floors, so products can take later patch and minor releases (including security fixes) without failing conformance. The `@modelcontextprotocol/sdk` floor rises to 1.32.1.
92 changes: 46 additions & 46 deletions bun.lock

Large diffs are not rendered by default.

23 changes: 17 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ Products depend on individual packages; CI verifies that resolved versions match
}
```

`recommended` is the exact version emitted into a new product. `supported` is the range covered by conformance for an existing adopter. `minimumSecure` is monotonic within a supported major line and makes `doctor` and conformance fail when the resolved version is older. The preset validates the product manifest and lockfile; it does not mutate dependencies. Dependabot performs upgrades through reviewable product pull requests.
`recommended` is the exact version emitted into a new product. `supported` is the range covered by conformance for an existing adopter. `minimumSecure` is monotonic within a supported major line and makes `doctor` and conformance fail when the resolved version is older. An `external` pin is the exact version `create` emits and, for `doctor`, a caret floor: later releases on the same major line pass, older ones fail (ADR 0017). The preset validates the product manifest and lockfile; it does not mutate dependencies. Dependabot performs upgrades through reviewable product pull requests.

### 6.8 @codelitdev/platform-conformance

Expand Down Expand Up @@ -474,7 +474,7 @@ 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/web: Next.js admin shell, BFF cookies, sign-in, tenant switcher, and one resource screen.
- 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.
- 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.
Expand Down Expand Up @@ -507,7 +507,7 @@ Admin auth follows FrontLit:

- Better Auth persists accounts and sessions in product PostgreSQL.
- oauth-server-kit provides OAuth provider behavior.
- Browser auth uses a same-origin BFF and HttpOnly Secure cookies.
- Browser auth uses hosted sign-in on the API host and an HttpOnly Secure session cookie shared with the web host across the product's subdomains (section 8.5).
- API and MCP accept configured OAuth tokens and tenant-scoped API keys.
- Product code checks membership on every request.
- Sensitive actions use recent auth and a single-use action token or equivalent grant.
Expand All @@ -522,14 +522,14 @@ Tenant memberships, invitations, API keys, audit events, and the selected-tenant

The reference API accepts one credential mechanism per request:

1. A Better Auth session cookie forwarded by the same-origin BFF.
1. A Better Auth session cookie, sent by the browser or forwarded by the web app's server code.
2. An OAuth access token in `Authorization: Bearer`.
3. A tenant-bound API key in `X-API-Key`.
4. An explicitly constructed internal `system` context that cannot be supplied over HTTP or MCP.

More than one supplied mechanism returns `credential_ambiguous`. An invalid explicit credential returns `unauthenticated`; the server never falls back to another credential or anonymous access. MCP accepts OAuth bearer or API key, not browser sessions.

The reference product uses `X-Tenant-ID` as an untrusted selector for tenant-scoped API and OAuth-authenticated MCP operations. The BFF may derive it from its route or selected-tenant preference. The resolver validates the public ID, loads current membership, and derives permissions on every request. Account-scoped endpoints reject a tenant selector unless their contract explicitly supports it; tenant-scoped endpoints return `tenant_required` when it is absent. An API key ignores any alternate selector and always resolves to its persisted tenant.
The reference product uses `X-Tenant-ID` as an untrusted selector for tenant-scoped API and OAuth-authenticated MCP operations. The web app may derive it from its route or selected-tenant preference. The resolver validates the public ID, loads current membership, and derives permissions on every request. Account-scoped endpoints reject a tenant selector unless their contract explicitly supports it; tenant-scoped endpoints return `tenant_required` when it is absent. An API key ignores any alternate selector and always resolves to its persisted tenant.

### 8.3 API keys

Expand Down Expand Up @@ -557,6 +557,17 @@ IDs and permissions remain product-owned. Separate identity realms remain separa

For billing actions, the Platform/application adapter converts that proof into @codelitdev/billing's consume-once BillingActionGrant bound to actor, action, target, issue time, and expiry. The billing engine still checks the persisted payer/customer/subscription association; an authorized tenant owner cannot use a fresh grant to impersonate another payer. Webhook and scheduled reconciliation use separate trusted system entry points.

### 8.5 Browser sessions and hosted sign-in

Each product serves its API and its admin web app from sibling subdomains of one product domain, for example `api.sendlit.app` and `app.sendlit.app`. SendLit, FrontLit, and MediaLit share this shape, and the reference product follows it:

- **Hosted sign-in on the API host.** The API mounts the hosted pages from `@codelitdev/oauth-server-kit` (`createOAuthPagesRouter`). The web app's `/login` sends the browser to `<api>/login?redirect=<web URL>`. After sign-in the hosted page redirects back only to origins in `allowedRedirectOrigins`, which is the product's configured web origin.
- **Session cookie shared across subdomains.** Better Auth sets a product-specific `advanced.cookiePrefix` (`sendlit`, `frontlit`, `medialit`) and `crossSubDomainCookies` with `domain` taken from `AUTH_COOKIE_DOMAIN`, the shared parent domain. The cookie is HttpOnly, Secure, and SameSite=Lax. Without a cookie domain the cookie stays host-only on the API host and the web app never sees the session, so every deployment with separate API and web hostnames sets it. Local development leaves it unset because `localhost` cookies are not port-scoped.
Comment on lines +564 to +565

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align the browser-auth description with the shipped flow

Readers using the reference product or template would be directed to a flow the apps do not implement: apps/web/app/login/page.tsx renders an OTP form that calls /api/auth/* through the same-origin BFF, rather than redirecting to API-host /login, and referenceAuthOptions does not set the advanced cookie settings described here. This section therefore misstates the shipped auth behavior and may lead adopters to configure a cross-subdomain session flow the template does not provide; align the docs with the code or add the corresponding product-auth configuration.

AGENTS.md reference: AGENTS.md:L4-L4

Useful? React with 👍 / 👎.

- **The web app validates; it never creates sessions.** Server code and middleware forward the incoming `Cookie` header to the API's `/api/auth/get-session` and trust only that response. The presence of a cookie is not a session. Same-origin route handlers in the web app may proxy browser calls to the API and forward the cookie, but they do not issue or rewrite session cookies.
- **Redirects use configured origins.** The web app builds absolute URLs from configuration (`WEB_CLIENT` or `WEB_ORIGIN`), never from the request URL. Behind the proxy the container binds `0.0.0.0`, so the request origin is wrong.
- **Cookie names stay aligned.** Code that parses cookie names, such as web middleware or session-freshness checks, uses the same prefix as Better Auth. The hosted pages' `legacyHostOnlySessionCookieNames` lists any older host-only cookies to clear when a cookie domain is first enabled.
- **Signing keys depend on the secret.** Better Auth's `jwt()` plugin encrypts its private keys with `BETTER_AUTH_SECRET` and signs a token inside `/get-session`. Changing the secret after the first start leaves stored keys undecryptable and `/get-session` fails, which looks like a sign-in loop. Clear the `jwks` table if the secret has to change.

## 9. API and MCP composition

Standard server order:
Expand Down Expand Up @@ -689,7 +700,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, same-origin BFF sessions, tenant selection, memberships, invitations, API keys, audit events, and the credential-precedence rules in section 8.
- 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.
- 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
33 changes: 33 additions & 0 deletions docs/decisions/0017-external-pins-are-caret-floors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ADR 0017: External preset pins are caret floors

Status: accepted; amends ADR 0002 and ADR 0006
Date: 2026-11-09

## Context

`doctor` required each external dependency in the preset (`next`, the MCP
SDK, and others) to resolve to exactly the pinned version. When Next.js
16.3.6 picked up critical RCE advisories fixed in 16.4.1, product
dependency-audit pull requests that took the fix failed the platform
conformance check (issue #14). A product could stay conformant or take the
security fix, not both, until the platform shipped a new CLI release.

## Decision

- An `external` pin is still the exact version `create` writes into a new
product and the version the template declares (`sync-preset.mjs` is
unchanged).
- `doctor` treats it as a caret floor: a resolved version passes if it is
at or above the pin on the same major line (same minor while the major
is 0). Older versions and the next breaking line still fail as
`external_mismatch`.
- When an advisory affects a pinned external, raise the template's pin to
the patched version so `doctor` rejects the vulnerable release.

## Consequences

- Products take patch and minor security fixes through Dependabot without
waiting for a platform release.
- Conformance no longer proves a product runs the exact external versions
the template was tested with. Minor releases of mature dependencies are
trusted to be compatible; a major upgrade still requires a preset change.
2 changes: 1 addition & 1 deletion examples/reference-product/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"dependencies": {
"@codelitdev/design-system": "workspace:*",
"@codelitdev/observability": "workspace:*",
"next": "^16.3.4",
"next": "^16.4.1",
"posthog-js": "^1.427.2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-cli/src/preset.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"pino": "9.6.0",
"@types/bun": "1.4.4",
"@electric-sql/pglite": "0.5.8",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/sdk": "1.32.1",
"dodopayments": "2.48.0",
"next": "16.4.3",
"react": "19.2.8",
Expand Down
10 changes: 9 additions & 1 deletion packages/platform-cli/src/preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe("platform preset", () => {
expect(ok).toEqual([]);
});

it("checks exact external compatibility pins", () => {
it("treats external pins as caret floors", () => {
const manifest = loadPresetManifest();
expect(
validateResolvedVersions(
Expand All @@ -37,5 +37,13 @@ describe("platform preset", () => {
expect(
validateResolvedVersions({ typescript: manifest.external.typescript }, manifest),
).toEqual([]);
const pinned = { ...manifest, external: { next: "16.4.1", pino: "0.5.2" } };
const reasons = (resolved: Record<string, string>) =>
validateResolvedVersions(resolved, pinned).map((issue) => issue.packageName);
expect(reasons({ next: "16.4.1", pino: "0.5.2" })).toEqual([]);
expect(reasons({ next: "16.4.5", pino: "0.5.9" })).toEqual([]);
expect(reasons({ next: "16.5.0" })).toEqual([]);
expect(reasons({ next: "16.3.6", pino: "0.5.1" })).toEqual(["next", "pino"]);
expect(reasons({ next: "17.0.0", pino: "0.6.0" })).toEqual(["next", "pino"]);
});
});
22 changes: 19 additions & 3 deletions packages/platform-cli/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ export type PackagePin = {

/**
* Compatibility rules for products: recommended, supported, and minimum-secure
* versions of each @codelitdev package, plus exact pins for the external
* packages the template uses. `scripts/sync-preset.mjs` writes `recommended`
* versions of each @codelitdev package, plus pins for the external packages
* the template uses. An external pin is the exact version `create` writes and
* the floor `doctor` accepts: any later release on the same caret line passes
* (ADR 0017). `scripts/sync-preset.mjs` writes `recommended`
* and `external`; `supported` and `minimumSecure` are hand-maintained.
*/
export type PresetManifest = {
Expand Down Expand Up @@ -83,6 +85,20 @@ function satisfies(version: string, range: string): boolean {
});
}

/**
* `^pin`: at or above the pin without crossing the next breaking line
* (the major, or the minor while the major is 0). Lets products take patch
* and minor security fixes without a preset release.
*/
function satisfiesCaret(version: string, pin: string): boolean {
const v = parseVersion(version);
const p = parseVersion(pin);
if (!v || !p) return cmp(version, pin) === 0;
if (v.major !== p.major) return false;
if (p.major === 0 && v.minor !== p.minor) return false;
return cmp(version, pin) >= 0;
}

export type PresetIssue = {
packageName: string;
reason:
Expand All @@ -101,7 +117,7 @@ export function validateResolvedVersions(
for (const [name, version] of Object.entries(resolved)) {
const pin = manifest.packages[name];
if (!pin && name in manifest.external) {
if (cmp(version, manifest.external[name]!) !== 0) {
if (!satisfiesCaret(version, manifest.external[name]!)) {
issues.push({
packageName: name,
reason: "external_mismatch",
Expand Down
Loading