Pulp Engine Document Rendering
Get started
Release v0.67.1

v0.67.1 — Phase C.0 Stage 2 review follow-up

Patch release. Closes four blocker/high/medium issues surfaced in the v0.67.0 review. No schema changes, no new migrations, no new features — v0.67.1 exists solely to make the v0.67.0 feature flag behave the way the v0.67.0 release notes said it behaved.

Why this patch exists

Stage 2 shipped v0.67.0 with a promise: MULTI_TENANT_ENABLED=false + restart is the one-step rollback. A post-release review demonstrated that promise was not upheld in four places:

  1. MULTI_TENANT_ENABLED was parsed via z.coerce.boolean(). In Zod, the string "false" coerces to true and "0" also coerces to true. Operators explicitly disabling the flag could accidentally enable multi-tenant mode.
  2. Tenant CRUD was not actually feature-gated. /admin/tenants/* was always registered, PostgresTenantStore was always constructed under Postgres mode, and API_KEY_SUPER_ADMIN was accepted with the flag off. A super-admin on single-tenant Postgres could still reach the tenant CRUD surface.
  3. Named-user tenantId attribution was not manageable through /admin/users. The admin user CRUD routes and UserRegistry.updateUser() never accepted or returned the field that v0.67.0 added to EditorUser.
  4. The promised startup validation was missing. v0.67.0 release notes claimed a “warn at boot” for API_KEYS_JSON unknown tenants and a “fail at startup” for bad OIDC_DEFAULT_TENANT, neither implemented.

All four are fixed in this patch with targeted regression tests.

What changed

1. Strict boolean parser for MULTI_TENANT_ENABLED

New envBool() helper at apps/api/src/config.ts — a z.preprocess wrapper that recognizes exactly these literals (case-insensitive):

InputResult
true, 1, yes, ontrue
false, 0, no, off, ""false
anything elsestartup rejection with a Zod validation error

Applied to MULTI_TENANT_ENABLED. Other boolean env vars are deliberately left on z.coerce.boolean() for this patch because they were not in scope for the review and changing them risks behavior drift — operators relying on the old coercion semantics for DOCS_ENABLED, METRICS_TOKEN_REQUIRED, etc. should continue to set them explicitly to true/false.

Tightening: MULTI_TENANT_ENABLED=garbage now fails at startup instead of silently being truthy. This is a strict improvement; no operator had a legitimate reason to rely on the old behavior.

2. Tenant CRUD feature-gating — four coordinated fixes

  • server.tstenantRoutes is only registered when MULTI_TENANT_ENABLED=true. Requests to /admin/tenants/* 404 otherwise, regardless of credential scope.
  • storage-factory.tstenantStore: null in the Postgres branch when the flag is off. Defense-in-depth: even if a future route wired past the server registration gate, the store itself is null.
  • auth.plugin.tsAPI_KEY_SUPER_ADMIN + MULTI_TENANT_ENABLED=false is now a startup error, not a silent warning. Super-admin is a multi-tenant primitive with no meaningful single-tenant behavior; accepting the credential silently was a latent trapdoor that would surprise operators the moment the flag was re-enabled.
  • auth.plugin.tsAPI_KEYS_JSON entries with non-'default' tenantId (including null super-admin markers) are similarly rejected at startup when the flag is off. Single-tenant deployments may still use API_KEYS_JSON, but every entry must be tenantId: 'default'.

3. /admin/users tenantId round-trip

  • users.routes.tsUserResponseSchema, CreateUserRequestSchema, and UpdateUserRequestSchema declare an optional tenantId field with slug-pattern validation (^[a-z0-9][a-z0-9-]{0,62}$).
  • user-registry.tsupdateUser() patch accepts tenantId?: string | null where null clears the attribution tag (the user falls back to 'default' at mint time); a slug replaces it. Slug validation mirrors the create-time rule.
  • addUser() already routed through validateUserFields() which validates tenantId in v0.67.0, so POST with tenantId already worked at the registry layer — this patch exposes it through the HTTP surface.

4. Startup validation

At the end of auth.plugin.ts’s credential-map build, under MULTI_TENANT_ENABLED=true:

API_KEYS_JSON unknown-tenant check (warn only). For every tenant-bound entry, the plugin calls app.tenantStatusCache.status(entry.tenantId). Missing tenants log a api_key_references_unknown_tenant warn-level structured event with the full tenantId list. The credential still 403s at request time via assertKnown in the auth hook — this log line just surfaces the mismatch early so operators see it in boot logs instead of debugging a surprising 403 later. Warn-don’t-throw is deliberate: an operator bootstrapping a fresh deployment creates tenants via POST /admin/tenants using the super-admin credential, and throwing at boot would make the chicken-and-egg (need super-admin creds loaded to create the first tenant) impossible to resolve.

OIDC_DEFAULT_TENANT check (throws). When OIDC_DISCOVERY_URL is configured in multi-tenant mode, the plugin calls status(config.OIDC_DEFAULT_TENANT) and throws at startup if the result is 'unknown' or 'archived'. Missing OIDC default tenant means every OIDC login would silently fail at mint time — a configuration error, not a runtime condition, so startup failure is the right signal. The error message points operators at the POST /admin/tenants + POST /admin/tenants/<id>/unarchive remediation path.

Regression tests

New file apps/api/src/tests/stage2-fixes.test.ts — 37 tests across four sections:

  • Boolean parser matrix (12 tests): unset → false, "false"/"0"/"no"/"off"/"" → false, "FALSE" case-insensitivity, "true"/"1"/"yes" → true under postgres mode, garbage strings → process.exit(1).
  • Tenant CRUD feature-gating (6 tests): API_KEY_SUPER_ADMIN + flag off → startup throw, same with explicit MULTI_TENANT_ENABLED=false, API_KEYS_JSON with non-default tenantId → throw, API_KEYS_JSON with null super-admin marker → throw, API_KEYS_JSON with only tenantId: 'default' → boots cleanly, GET /admin/tenants → 404 when flag off (via buildServer() + inject).
  • /admin/users tenantId round-trip (5 tests): POST accept + GET return, POST reject invalid slug, PUT patch tenantId, PUT null clear tenantId, PUT reject invalid slug.
  • TenantStatusCache primitive (14 tests): single-tenant short-circuit for status/assertKnown/assertActive, multi-tenant ternary (active/archived/unknown), assertKnown passes archived (stage 1 soft-archive rule) and fails unknown, assertActive passes active and rejects archived with TenantArchivedError and unknown with TenantUnknownError, bust() cache invalidation, ttlMs=0 strict mode.

All 37 pass locally on first run.

Residual coverage gap

The auth.plugin validation loop’s live Postgres integration path is not exercised in this patch’s unit tests — the unit harness cannot stand up a Postgres backend, so the tests pin the TenantStatusCache primitive (which is the only thing the validation depends on) rather than the end-to-end buildServer() → real Prisma lookup → assertion flow. The v0.67.0 integration matrix still covers that path; operators running the integration matrix against v0.67.1 will exercise the fixed code paths.

Migration notes

  • No schema changes, no new migrations, no API breaks. Upgrade: replace the image and restart.
  • Operators with API_KEY_SUPER_ADMIN set in single-tenant v0.67.0 deployments must either remove the env var or set MULTI_TENANT_ENABLED=true before upgrading to v0.67.1. The v0.67.1 server refuses to boot with the combination. This is the intentional tightening.
  • Operators with API_KEYS_JSON entries carrying non-default tenantId in single-tenant v0.67.0 deployments must either update those entries to tenantId: 'default' or set MULTI_TENANT_ENABLED=true. Same rationale.
  • Single-tenant deployments that had MULTI_TENANT_ENABLED=false set and had no super-admin credentials require no config changes.

Rollback

v0.67.1 → v0.67.0 rollback is safe and reverse-compatible. The code changes are additive: feature-gate tightening, schema field exposure, and new validation. No data is touched.

Files touched

Modified (10)

  • apps/api/src/config.tsenvBool() helper + MULTI_TENANT_ENABLED wired to it
  • apps/api/src/plugins/auth.plugin.ts — super-admin startup throw, API_KEYS_JSON startup throw, API_KEYS_JSON unknown-tenant warn, OIDC_DEFAULT_TENANT startup throw
  • apps/api/src/server.tstenantRoutes registration gated on the flag
  • apps/api/src/storage/storage-factory.tstenantStore: null when flag off
  • apps/api/src/routes/admin/users.routes.ts — schemas and body types carry tenantId
  • apps/api/src/lib/user-registry.tsupdateUser() patch accepts tenantId?: string | null
  • CHANGELOG.md[0.67.1] section + link reference
  • Version lockstep: package.json, apps/api/package.json, apps/editor/package.json, apps/preview/package.json, packages/sdk-typescript/package.json, packages/template-model/package.json, packages/sdk-python/pyproject.toml, packages/sdk-dotnet/src/PulpEngine.Sdk/PulpEngine.Sdk.csproj, packages/sdk-go/version.go

Added (2)

  • apps/api/src/__tests__/stage2-fixes.test.ts — 37 regression tests
  • docs/release-v0.67.1.md — this file

Not in v0.67.1

Explicitly out of scope: anything beyond the four review findings. No C.0b work, no new features, no SDK regeneration, no unrelated test additions. This is a tight patch release.