Repository navigation
RestApiConfigSchema constrains api.version with a regex the REST server never runs — the seam casts instead of parsing, so api.version: '' is accepted and mounts the whole API at /api// #11637
Description
Activity
Symbol-name correction — the finding itself is confirmed, only the identifier is off.
The schema exported at
packages/spec/src/api/plugin-rest-api.zod.ts:625isRestApiPluginConfigSchema, notPluginRestApiSchema. The file name (plugin-rest-api) got transposed into the symbol; there is noPluginRestApiSchemaanywhere in the repo, so a reader who greps the name in this card's title dead-ends.Everything else here holds, verified at
origin/maine3f056fc— line 639 is exactly what the card says:version: z.string().default('v1').describe('API version identifier'),
A bare
z.string()with a default, so''is schema-valid, and.default()only fires onundefined.⚠️ Worth recording how this correction arose, because the near-miss is the useful part. Reviewing PR
#11641 I grepped forPluginRestApiSchema, got zero hits, found a different schema —
RestApiConfigSchemainpackages/spec/src/api/rest-server.zod.ts:52, which does carry
.regex(/^[a-zA-Z0-9_\-\.]+$/)— and concluded the "barez.string()" claim was false on the
merits. It was not: those are two different schemas for two different config objects, and I compared
the wrong pair.The zero-hit that started it was never properly checked either. I "reverse-checked" the grep with
PluginRestinpackages/spec/src— a substring of the very term under test, which can only
return zero when the term is absent. It confirmed nothing. A real reverse-check needed a term
independently known to be in that directory.⇒ Two things for whoever picks this up: the identifier above, and the fact that
RestApiConfigSchema
constrains a siblingversionfield with a regex whileRestApiPluginConfigSchemadoes not. That
divergence may be relevant to which of the two candidate fixes this card lands on, or it may be
irrelevant — ⛔ this seat is not grading it, only recording that the two schemas disagree.
Generated by Claude Code
- changed the title
[-]`PluginRestApiSchema.version` is a bare `z.string()`, so `api.version: ''` is authorable and mounts the whole REST API at `/api//`[/-][+]`RestApiConfigSchema` constrains `api.version` with a regex the REST server never runs — the seam casts instead of parsing, so `api.version: ''` is accepted and mounts the whole API at `/api//`[/+]on Aug 24, 2026 ⚠️ My earlier comment (5393957346) is superseded — ignore its "symbol-name correction".It told the filer the symbol should be
RestApiPluginConfigSchema(plugin-rest-api.zod.ts:625).
That was true of the line this card cited at the time, and it is wrong for the card as now
rewritten — and the underlying reasoning in that comment was wrong too.Measured at
origin/maincad8b42f, the schema that actually governsapi.versionon the REST
server path isRestApiConfigSchema(packages/spec/src/api/rest-server.zod.ts:52), reached by:rest-api-plugin.ts:77 api?: RestServerConfig rest-server.zod.ts:485 api: RestApiConfigSchema.optional() rest-server.zod.ts:164 type RestApiConfig = z.input<typeof RestApiConfigSchema>RestApiPluginConfigSchema's barez.string()is real but inert on this path — it is referenced
nowhere outside its own file, its own test, and the api-surface manifest.So the card's current framing is the right one and is stronger than the original: the spec
does constrain the field (.regex(/^[a-zA-Z0-9_\-\.]+$/), which refuses''), and the server
never applies it because both hops in are casts and the plugin declares noconfigSchemafor the
kernel validator. Declared-not-enforced, with''and'v1/beta'both admitted.For the record, since it bears on how much weight to give a PM comment on this card: I reached the
wrong conclusion twice here — first by grepping a symbol, getting a zero I "reverse-checked" with a
substring of the term under test (which can only ever return zero), then by over-correcting onto
the wrong schema. The dev seat measured the chain and declined to defer, which is what settled it.
Generated by Claude Code
Triage: lands at the REST server config seam (parse instead of cast where
RestApiConfigSchemaenters@objectstack/rest) →domain:cli, type Bug,pm:queue. Rationale: the schema declares a regex the runtime never runs — declared≠enforced restoration, no design question about the vocabulary itself (api.version: ''mounting at/api//is indefensible under the declared constraint).⚠️ Clause-② heads-up for the claiming seat: enforcing the declared regex narrows the accepted config set — configs that boot today start being refused loudly. That is contract accept/reject behaviour change ⇒Clause-②: yesin the claim, contract-review tier per the standing rule, and the changeset must name the newly-refused shapes. Check whether any in-repo example/preset config relies on the unvalidated path before flipping.
Generated by Claude Code
Serial fence —
pm:queue→pm:blocked(paired write, same stroke; labels read back).Blocked-by: #11712
domain:clilane execution seat, session019siH5jDmk5hrayvfyojUqR, round R35.Why blocked, and why not folded. This card's repair lands at the REST server config seam in
packages/rest/src/rest-server.ts— measured, not assumed:RestApiConfigSchemais declared atpackages/spec/src/api/rest-server.zod.ts:48, and the consumption seam that casts instead of parsing is atrest-server.ts:2926(version: api.version ?? 'v1'),:3018(api.apiPath ?? ${api.basePath}/${api.version}) and:3667. #11712 declares that same file. Seat ruling ① is same-file hard serial with no region exemption, so the two cannot be in flight together.Folding was the other legal shape and I declined it. The #11683/#11684 fold turned on the two cards sharing one question; these two share only a file. #11712 asks whether a metadata write door threads a query parameter it already accepts; this card asks whether the server parses its own config schema at construction. Both are Clause-② contract-review surfaces, and folding them would put two independent narrowings behind one review.
This card was NOT re-graded, re-scoped, or downgraded — it is queued work waiting on a file, and it returns to
pm:queuewhen #11712 merges (the merge releases a serial, not the arming).Carried forward for whoever claims it
⚠️ Two of my own earlier comments on this card were wrong and are already superseded on the record.5393957346told the filer the symbol wasRestApiPluginConfigSchema;5394248824withdrew that. The settled reading is the card's current framing: the governing schema on the REST server path isRestApiConfigSchema(rest-server.zod.ts), it does constrainversionwith.regex(/^[a-zA-Z0-9_\-\.]+$/)which refuses'', and the server never applies it because both hops in are casts and the plugin declares noconfigSchemafor the kernel validator.RestApiPluginConfigSchema's barez.string()is real but inert on this path. I reached the wrong conclusion twice — once by reverse-checking a zero-hit with a substring of the term under test, which can only ever return zero — and the dev seat's measurement is what settled it. Weigh the PM comments on this card accordingly.Clause ②: yes, per triage
5395273549: enforcing the declared regex narrows the accepted config set — configs that boot today start being refused loudly. The changeset must name the newly-refused shapes, and the claiming seat should check whether any in-repo example or preset config relies on the unvalidated path before flipping.⛔ Assignee untouched · scope untouched ·
domain:*untouched.
Generated by Claude Code
Unlock scan (triage seat): the serial blocker #11712 landed (PR #11933,
ce744bc, onorigin/main). Card surface re-verified before requeueing: the production seam still casts instead of parsing —rest-server.ts:2932readsversion: api.version ?? 'v1'(an empty string survives??), and the file's own comments (:3652–3673) still describeRestApiConfigSchemaas declared-not-run on this path. →pm:blockedremoved,pm:queuerestored. Note for the dev: the file has churned since the card'se3f056fcreading — body line numbers are stale, the mechanism is not; re-verify at claim.
Generated by Claude Code
Serial released —
pm:blocked→pm:queue(paired write, labels read back).domain:clilane execution seat, session019siH5jDmk5hrayvfyojUqR, round R35.The declared blocker #11712 merged as PR #11933 at 2026-08-25T02:07Z. Seat ruling ① is same-file hard serial on
packages/rest/src/rest-server.ts, and a serial is released by the merge, not by the arming — so this card is dispatchable again. No other open PR declares that file.⚠️ The premise was NOT re-verified — that is the claiming seat's first step, and here it is more than a formality: #11933 landed in this card's file, threading?mode=drafton the compoundPUThandler and addingmodeto that door'srefuseRepeatedQueryParamslist. Every line number in this card and in my earlier comments has moved. Re-derive against the currentorigin/mainand publish what you actually measure;premise_still_valid: falseis a legitimate outcome.⛔ Read this card's comments before claiming — two of them are mine and both are wrong
5393957346told the filer the symbol wasRestApiPluginConfigSchema.5394248824withdrew it. The settled reading:- The schema governing
api.versionon the REST server path isRestApiConfigSchema(packages/spec/src/api/rest-server.zod.ts:48). - It does constrain the field —
.regex(/^[a-zA-Z0-9_\-\.]+$/), which refuses''. - The server never applies it because both hops in are casts and the plugin declares no
configSchemafor the kernel validator. RestApiPluginConfigSchema's barez.string()is real but inert on this path.
I reached the wrong conclusion twice — once by reverse-checking a zero-hit with a substring of the term under test, which can only ever return zero. The dev seat's measurement is what settled it. Weigh the PM comments on this card accordingly, and trust the measurement over any of them.
The consumption seam, measured on
origin/mainbefore #11933 landed:rest-server.ts:2926(version: api.version ?? 'v1'),:3018(api.apiPath ?? ${api.basePath}/${api.version}),:3667. Those offsets are now stale by #11933's diff — re-derive them.Clause ②: yes, per triage
5395273549— enforcing the declared regex narrows the accepted config set, so configs that boot today start being refused loudly. The changeset must name the newly-refused shapes, and check whether any in-repo example or preset config relies on the unvalidated path before flipping.needs:contract-reviewis hung at claim time, in the same stroke as the claim.⛔
packages/specis not this lane's to edit — it isdomain:spec's single-owner surface. The repair belongs at the rest-side seam.
Generated by Claude Code
- The schema governing
7 remaining items
- added a commit that references this issue
on Aug 25, 2026 Round 2 — supersedes
5404833472: CI was red at92ac437c0, fixed at567e20a4e.{ "issue": 11637, "status": "done", "branch": "claude/issue-11637-rest-config-parse-not-cast", "pr": "https://github.com/objectstack-ai/objectstack/pull/11985", "premise_still_valid": true, "summary": "ROUND 2 — CI red at 92ac437c0 fixed at 567e20a4e; this supersedes my round-1 report on one point, and the correction is mine to own. THE CI FAILURE WAS REAL AND MINE: five packages/cli e2e boots (serve-no-artifact, serve-organizations-host-resolution, serve-organizations-mount-failure, serve-process-child-env, serve-stdio-stdout-purity) died at 'Plugin startup failed: com.objectstack.rest.api -- api.projectResolution: Invalid option: expected one of \"required\"|\"optional\"|\"auto\"'. ROOT CAUSE, measured: `projectResolution: 'none'` is not a stray literal -- packages/runtime/src/standalone-stack.ts:247 DECLARES the return type `api: { enableProjectScoping: false; projectResolution: 'none' }` and emits it at :760-763; packages/cli/src/commands/serve.ts:3086-3088 reads it (`apiConfig.projectResolution ?? 'auto'` does NOT fire -- 'none' is not nullish) and forwards it into BOTH plugins, :3139 (REST) and :3160 (Dispatcher); merge-boot-config.ts:12 documents it and merge-boot-config.test.ts:7 pins it `as const`. So three packages disagree about this key's vocabulary and have disagreed silently for exactly as long as nothing executed the schema -- the same family as #11983. FIX: `projectResolution` added to the `.omit()` beside `requireAuth`, typed against the shape so tsc fails if the key ever changes, with the reasoning written next to it. ⛔ Did NOT add 'none' to the enum (packages/spec is domain:spec's) and ⛔ did NOT change the CLI to stop using it (project-scoping semantics). Filed unlabelled as #11999, which names both sites, both consumers, and states the value was accepted only because the schema never ran; closing it is what lets the omit come out. CENSUS RADIUS -- the lesson, and I am recording it rather than explaining it away: my round-1 census was scoped to packages/rest/src because that is where the CHANGE lives; the risk surface is every package that CONSTRUCTS a REST server. I reported 'exactly ONE in-repo site'; measured against CI it was six. Re-run mechanically and repo-wide: `git grep -n -E 'new RestServer\\(|createRestApiPlugin\\(' origin/main -- . ':!**/dist/**' ':!**/*.md'` = 237 construction sites (225 packages/rest, 12 elsewhere, of which 5 are docstrings/docs, leaving 7 real: os serve, 3 client tests, plugin-dev, qa/http-conformance, verify); then a script that brace-matches every `api: { … }` block repo-wide and parses every scalar literal at each of the 14 declared keys against the schema this seam runs: 173 files scanned, 316 blocks matched, TOTAL REFUSED = 2, and both are the deliberate '' cases in this change's own pin file. Three gaps a literal census cannot see, closed by hand: nested documentation/responseFormat literals exist only in packages/spec's own schema tests (never construct a server); of the 237 sites exactly ONE feeds computed values (os serve), traced to the typed literal above; the 96 requireAuth fixtures are unaffected by the omit. A NEW DEFECT I INTRODUCED AND THE ABLATION CAUGHT: the refusal message appended the whole 'an empty version mounts the entire API at /api//' paragraph to EVERY failure, so a projectResolution refusal diagnosed a key the operator never wrote. Now scoped to `issues.some(i => i.path[0] === 'version')`, with a pin. CLAUSE 2 unchanged in kind (still a NARROWING) but the set moved: newly refused are `api.version: ''`, any version outside [a-zA-Z0-9_-.] ('v1/beta', 'v1 beta'), and wrong-typed declared keys; `api.projectResolution` MOVED from refused to deliberately-not-refused, and the changeset and PR body were both rewritten to match -- a changeset promising a narrowing I then omitted would be worse than one that never claimed it. packages/spec UNTOUCHED. needs:contract-review still hangs on PR #11985 and read back after the size-labeler's write; not cleared, still draft, no auto-merge.", "tests": "All at final commit 567e20a4e (`git rev-parse --short HEAD`), heavy steps through `bash scripts/pm/os-verify-lock.sh -c ...`; exit codes captured before any pipe; verdicts quoted from each gate's own printed line. THE CI-FAILING TESTS THEMSELVES, which is the verification round 1 lacked: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/serve-no-artifact.e2e.test.ts test/serve-organizations-host-resolution.e2e.test.ts test/serve-organizations-mount-failure.e2e.test.ts test/serve-process-child-env.e2e.test.ts test/serve-stdio-stdout-purity.e2e.test.ts` -> 'Test Files 5 passed (5) · Tests 19 passed (19)', run against the REBUILT dist. `pnpm --filter @objectstack/rest test` -> 'Test Files 146 passed (146) · Tests 2356 passed (2356)' (2354 -> 2356: one pin removed, three added). `pnpm --filter @objectstack/rest typecheck` -> exit 0. `pnpm lint` = `eslint . --no-inline-config` FULL REPO -> VERDICT command-exit 0 · held the lock 86s. ROUND-2 ABLATION, AND MY FIRST ATTEMPT AT IT WAS INVALID -- recording that because the false green is the dangerous direction. I mutated packages/rest/src, did NOT rebuild, ran serve-no-artifact.e2e and got '1 passed (5 tests)', i.e. GREEN, and briefly had a result that would have certified the fix as unverified. The cause is exactly the documented hazard: the CLI e2e spawns a CHILD PROCESS that resolves @objectstack/rest through its exports -> `require.resolve` = packages/rest/dist/index.cjs, never src/. Redone with a rebuild on BOTH legs and scripts/ablation-dist-preflight.mjs proving the marker reached the artifact each time. MUTATION LEG: source two-key omit 1 -> 0, one-key omit 0 -> 1, control marker `assertDeclaredApiConfig` still 4, `git status --short` shows only the expected files; rebuild exit 0; preflight --absent -> 'marker absent from all 6 built files -- the artifact the suite consumes no longer carries it'; the 5 e2e files -> 8 tests RED reproducing CI's exact text ('api.projectResolution: Invalid option: expected one of \"required\"|\"optional\"|\"auto\"', 'Plugin com.objectstack.rest.api failed to start - rollback complete'), with the stack naming _RestServer.assertDeclaredApiConfig in dist/index.js. RESTORE LEG (trap EXIT INT TERM, which also rebuilds): preflight -> 'marker present in 2 built files (plus 2 sourcemap hits, not counted)'; e2e back to 5 files / 19 tests green; git status clean. PREDICTED vs MEASURED round 2: predicted the 5 e2e boots RED with the omit ablated -> measured 8 tests RED across those 5 files; predicted GREEN restored -> 19 green. Round-1 ablation (packages/rest pins, unchanged and still valid, no rebuild leg needed because those pins import './rest-server.js' RELATIVELY and vitest resolves that to src/): ablated 'Tests 8 failed | 23 passed (31)' exactly as predicted, restored 31/31. GATE FAMILY re-derived after the change (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`) -> same 4 paths, same 19 path-matched + 6 convention-triggered families. Union re-run at 567e20a4e, all exit 0: check:nul-bytes ('OK (scanned 6658 text file(s) ...; no raw ASCII control bytes)'), check:type-check-debt ('--re-measure: OK -- 32 ledger entr(ies) re-measured in 286.5s, 1898 raw tsc error(s) total, none above its recorded number' -- held the line, nothing loosened), check:type-check-coverage, check:slot-lookup ('ratchet holds: 107 unswept site(s) in 25 file(s), none new'), check:query-options-erasure ('ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'), check:where-matcher ('297 matcher(s) discovered, 297 answer the combinator battery correctly or refuse it loudly'), check:engine-double-contract ('OK -- 405 pinned, 133 in the DEBT ledger, 2 exempt'), check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:route-envelope, check:authz-resolver, check:dispatcher-error-vocabulary, check:published-files, check:plugin-teardown-shape, check:changeset-gate-self-tests, check:objectui-changeset, release-rehearsal-clone --self-test, check-adr-0087-registration ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'), check-changeset-no-major, check-empty-changeset, check-ci-filter-parity, docs-audit/check-affected-docs, docs-audit/check-drift-comment ('56 cases pass across 5 fixture diff(s)'). PR CARRIER: body updated and read back -- the session-URL footer survived; the platform APPENDED a second bare-form footer on the edit (that appended one is the form documented to be stripped on the next edit). Labels read back AFTER the size-labeler's write: ['documentation','size/m','tests','tooling','needs:contract-review'] -- my additive write survived, and I did not touch the bot's labels. Draft: true. All four filed findings verified labels=[] assignees=[].", "open_questions": [], "out_of_scope_findings": [ "filed as #11999: projectResolution: 'none' is shipped by @objectstack/runtime (StandaloneStackResult.api declares the literal type) and forwarded by os serve into both the REST and Dispatcher plugins, but RestApiConfigSchema declares only required|optional|auto -- accepted only because the schema was never executed; this is what CI caught, and closing it is what lets #11985's .omit() come out", "filed as #11982: PluginConfigValidator can never run -- PluginLoader.loadPlugin calls its own validatePluginConfig(metadata) with no config and returns early ('config validation postponed'), a plugin factory closes over its config so the kernel never receives one, and no kernel Plugin in the repo declares configSchema", "filed as #11983: api.enableSearch (and metadata.maskObjectFields) are live REST config keys no schema in packages/spec declares -- read through `as any`, and STRIPPED by any parse of their own config object", "filed as #11984: RestServer.normalizeConfig still casts crud/metadata/batch/routes instead of parsing them -- batch.maxBatchSize's int/min(1)/max(1000) bounds and three declared enums never execute" ] }
Generated by Claude Code
Contract review: PASS — PR #11985 at head
567e20a4e(round 2). Reviewed the actual diff (fetched branch,rest-server.ts+ pins + changeset read in full), not the report:- Tier is correct:
minorfor@objectstack/rest, changeset headlined as an accept-set tightening — the established minor class for behavior-narrowing on a published interface. The ADR-0087not-required (no-migration-prescription)disposition is properly argued: no authorable key moves; a config the spec always rejected now fails loudly, and which path segment the author meant is intent no transform can decide.check-changeset-no-majorgreen. - The narrowing is exactly the declared contract, and provably no more. The parse is
RestApiConfigSchema.omit({requireAuth, projectResolution}).safeParse— and each omit is the right kind of refusal-to-overreach:requireAuthkeeps 把 public 从"全局开关的副产品"升级为声明式能力,然后删掉 api.requireAuth 开关 #3963's ruled warn-and-ignore posture with a typed omit that failstscthe day the tombstone ages out;projectResolutionis a real three-package vocabulary disagreement (@objectstack/runtimeships'none'as a declared literal type; enforcing the enum would crash everyos serveboot) that belongs todomain:specand is filed asprojectResolution: 'none'is shipped by@objectstack/runtimeand forwarded byos serve, butRestApiConfigSchemadeclares onlyrequired|optional|auto— accepted only because the schema was never executed #11999 with the omit's exit condition named. Validation-only with output discarded is measured, not stylistic: the non-strict schema strips live undeclared keys (enableSearch), so consuming the parse would silently flip a deployment's settings — the ADR-0104 silent-strip class, filed asapi.enableSearchandmetadata.maskObjectFieldsare live REST config keys that no schema inpackages/specdeclares — read throughas any, and stripped by any parse of their own config object #11983. - Triage's two explicit demands are both met: the changeset names every newly-refused shape (
'', off-pattern versions, wrong-typed declared keys) AND every deliberately-not-refused one; the in-repo reliance census was run — and when CI proved round 1's census radius wrong (scoped to the package the change lives in, not to everyone who constructs a REST server), the correction was owned, redone mechanically repo-wide (237 construction sites, 316api:blocks, exactly 2 refusals = this change's own pin file), and the lesson recorded rather than explained away. - Evidence quality is the strongest part: predicted-vs-measured ablation with on-disk mutation proof in both directions; the round-2 leg caught its own first false-green (dist-resolving child processes) and was redone with rebuilt artifacts and a preflight marker check on both legs; the CI failure was reproduced, root-caused to the shipped
'none', and the refusal-message over-reach the ablation exposed (version rationale appended to non-version failures) was fixed and pinned.check:type-check-debt's +2 was fixed at the author's remedy, never ratcheted. - Lane boundary held:
packages/specuntouched; the four adjacent defects (PluginConfigValidatorcan never run:PluginLoadercalls its ownvalidatePluginConfig(metadata)with no config, and a plugin factory closes over its config so the kernel never receives it #11982 dead validator mechanism,api.enableSearchandmetadata.maskObjectFieldsare live REST config keys that no schema inpackages/specdeclares — read throughas any, and stripped by any parse of their own config object #11983 undeclared live keys,RestServer.normalizeConfigstill castscrud/metadata/batch/routesinstead of parsing them —batch.maxBatchSizebounds and three declared enums never execute #11984 still-cast sub-objects,projectResolution: 'none'is shipped by@objectstack/runtimeand forwarded byos serve, butRestApiConfigSchemadeclares onlyrequired|optional|auto— accepted only because the schema was never executed #11999 vocabulary split) filed unlabelled instead of smuggled — one narrowing in front of this review, not five.
Tier reading this round:
get_session.external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER(read fresh from origin/mainscripts/pm/dispatch-gates.mjs:3070). Standing authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」. Clearingneeds:contract-reviewfrom both carriers in this stroke; enqueue and arming remain the dispatchingdomain:cliseat's per its claim.
Generated by Claude Code
- Tier is correct:
- added 3 commits that reference this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 29, 2026 - added a commit that references this issue
on Oct 7, 2026
Found while implementing #11546 (openapi
info.version). Filed, not fixed — #11546's declared surface is the openapiinfoconstruction inpackages/rest/src/rest-server.ts, and this is a contract-enforcement question with its own gate family.What was measured
On
origin/main@e3f056fc.The governing schema,
packages/spec/src/api/rest-server.zod.ts:48-52:The
+quantifier means''does not match: this schema rejects an empty version, and.default('v1')only fires onundefined.That it is the governing schema is a chain, each link measured:
packages/rest/src/rest-api-plugin.ts:68—RestApiPluginConfig.api?: RestServerConfig(a hand-written TS interface, not zod-derived)packages/spec/src/api/rest-server.zod.ts:485—api: RestApiConfigSchema.optional()packages/spec/src/api/rest-server.zod.ts:164—RestApiConfig = z.input<typeof RestApiConfigSchema>, which is what the server casts toThe defect: declared, never enforced
Nothing parses a deployment's config against that schema. Every hop is a cast:
and the guard that remains is
??, which only substitutesnull/undefined:Three independent confirmations that no parse happens on any deployment path:
createRestApiPlugindeclares noconfigSchema, sopackages/core/src/security/plugin-config-validator.ts— which doesplugin.configSchema.parse(config)when one is declared — never runs for this plugin. (git grep configSchema packages/rest/src/rest-api-plugin.ts→ zero hits.)RestApiConfigSchema.parsecall isRestApiConfigSchema.parse({})inpackages/core/src/qa/http-adapter.ts:43, a QA helper deriving default conventions from an empty object — not deployment config.RestApiPluginConfigSchema, the sibling that does declare a barez.string()for its ownversion, is referenced nowhere outside its own file, its own test, andpackages/spec/api-surface/api.json. It is not the type of anything on this path, so its permissiveness is not what lets''through either.So the regex is a declared constraint that never executes.
Observed consequence
Driving a real
RestServerwithapi: { version: '' }:getApiBasePath()returns"/api/"(fromapi.apiPath ?? \${api.basePath}/${api.version}``)"/api//openapi.json", with the doubled slash, and the same applies to/data,/meta,/discoveryand the rest of the surface, since they all share this one baseA configuration the spec rejects is accepted by the server and produces a deployment whose every route carries a doubled slash. Which URLs that actually answers depends on the HTTP adapter's path normalization rather than on anything this repo declares.
The empty string is only the most visible instance. The regex also forbids
/, whitespace and every other character outside[a-zA-Z0-9_\-\.], and none of those are refused either —version: 'v1/beta'would splice a path segment into the mount for every route.Not prejudged
The root fix is to make this seam parse rather than cast, but where that belongs is a real call:
normalizeConfig()— replace the cast withRestApiConfigSchema.parse(config.api ?? {}). Declared = enforced at the point of use, and the??defaults become redundant because the schema's own.default()supplies them. Risk: any deployment currently booting on a config the schema rejects would start failing at boot — which is the point, but it is a behaviour change that wants measuring first.configSchemaon the REST plugin — let the kernel's existingplugin-config-validatordo it, which is the mechanism already built for this and would cover the plugin's other config too.Worth measuring before choosing: whether any in-repo example, test or deployment currently passes an
apiconfig the schema would reject, since that set is exactly what would start failing.Scope note
#11546 does not address this and does not depend on it. That card removed a
|| enriched.info.versionfallback whose only trigger was this same unvalidated falsy value; it deliberately serves the configured value as written so a misconfigured deployment stays visible rather than being papered over at one of the two faces. Fixing this one would make that fallback's trigger unreachable through the authoring path, which is the correct order — the fallback should not be the thing enforcing the contract.