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:
MULTI_TENANT_ENABLEDwas parsed viaz.coerce.boolean(). In Zod, the string"false"coerces totrueand"0"also coerces totrue. Operators explicitly disabling the flag could accidentally enable multi-tenant mode.- Tenant CRUD was not actually feature-gated.
/admin/tenants/*was always registered,PostgresTenantStorewas always constructed under Postgres mode, andAPI_KEY_SUPER_ADMINwas accepted with the flag off. A super-admin on single-tenant Postgres could still reach the tenant CRUD surface. - Named-user
tenantIdattribution was not manageable through/admin/users. The admin user CRUD routes andUserRegistry.updateUser()never accepted or returned the field that v0.67.0 added toEditorUser. - The promised startup validation was missing. v0.67.0 release notes claimed a “warn at boot” for
API_KEYS_JSONunknown tenants and a “fail at startup” for badOIDC_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):
| Input | Result |
|---|---|
true, 1, yes, on | true |
false, 0, no, off, "" | false |
| anything else | startup 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.ts —
tenantRoutesis only registered whenMULTI_TENANT_ENABLED=true. Requests to/admin/tenants/*404 otherwise, regardless of credential scope. - storage-factory.ts —
tenantStore: nullin 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.ts —
API_KEY_SUPER_ADMIN+MULTI_TENANT_ENABLED=falseis 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.ts —
API_KEYS_JSONentries with non-'default'tenantId(includingnullsuper-admin markers) are similarly rejected at startup when the flag is off. Single-tenant deployments may still useAPI_KEYS_JSON, but every entry must betenantId: 'default'.
3. /admin/users tenantId round-trip
- users.routes.ts —
UserResponseSchema,CreateUserRequestSchema, andUpdateUserRequestSchemadeclare an optionaltenantIdfield with slug-pattern validation (^[a-z0-9][a-z0-9-]{0,62}$). - user-registry.ts —
updateUser()patch acceptstenantId?: string | nullwherenullclears 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 throughvalidateUserFields()which validates tenantId in v0.67.0, so POST withtenantIdalready 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 explicitMULTI_TENANT_ENABLED=false,API_KEYS_JSONwith non-default tenantId → throw,API_KEYS_JSONwithnullsuper-admin marker → throw,API_KEYS_JSONwith onlytenantId: 'default'→ boots cleanly,GET /admin/tenants→ 404 when flag off (viabuildServer()+inject). /admin/userstenantId round-trip (5 tests): POST accept + GET return, POST reject invalid slug, PUT patch tenantId, PUT null clear tenantId, PUT reject invalid slug.TenantStatusCacheprimitive (14 tests): single-tenant short-circuit forstatus/assertKnown/assertActive, multi-tenant ternary (active/archived/unknown),assertKnownpasses archived (stage 1 soft-archive rule) and fails unknown,assertActivepasses active and rejects archived withTenantArchivedErrorand unknown withTenantUnknownError,bust()cache invalidation,ttlMs=0strict 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_ADMINset in single-tenant v0.67.0 deployments must either remove the env var or setMULTI_TENANT_ENABLED=truebefore 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_JSONentries carrying non-defaulttenantIdin single-tenant v0.67.0 deployments must either update those entries totenantId: 'default'or setMULTI_TENANT_ENABLED=true. Same rationale. - Single-tenant deployments that had
MULTI_TENANT_ENABLED=falseset 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.ts—envBool()helper +MULTI_TENANT_ENABLEDwired to itapps/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 throwapps/api/src/server.ts—tenantRoutesregistration gated on the flagapps/api/src/storage/storage-factory.ts—tenantStore: nullwhen flag offapps/api/src/routes/admin/users.routes.ts— schemas and body types carry tenantIdapps/api/src/lib/user-registry.ts—updateUser()patch acceptstenantId?: string | nullCHANGELOG.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 testsdocs/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.