Uh oh!
There was an error while loading. Please reload this page.
feat(spec): refuse bound external.credentialsRef with a username-less composed mongo config at publish (#9147) - #9312
Conversation
… composed mongo config at publish (#9147) The composed-branch twin of #9041, widening the same datasource-level refinement by one condition. With no `config.url` the factory composes the URI and the bound secret's only route into it is the userinfo written beside a username (`const auth = user ? … : ''`); `buildMongoAuth` returns early on `!url`, so a falsy `username` leaves the secret with nowhere to go and the datasource connects anonymously with the operator told nothing. Its own message, not #9041's: here `config.username` is the live field, so "add the username to the URL's userinfo" would name a fix this branch cannot take. Fences: mongodb arm only (legacy 'mongo' alias-resolved), `username` undefined or '' (the falsy set the composer actually tests), non-string left to the config gate, empty-string ref not a binding, postgres not widened to (#8873). Also corrects an over-refusal in the landed #9041 arm: `url: ''` composes at connect, so it is judged by the composed arm rather than refused as a user-less URL. ADR-0087 semantic entry + registry regen.
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 113 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
os-project-manager
commented
Aug 17, 2026
CI triage: |
| where | what | result |
|---|---|---|
this branch @ 2444d022b | full metadata-fs package, 3 runs | 51 tests passed, 3/3 |
| this branch | the failing file alone, 6 runs | 6/6 passed |
clean origin/main @ e4e5c6e3c | the failing file alone, 6 runs | 6/6 passed |
clean origin/main | same file, 8 concurrent runs | 8/8 passed |
So it is not deterministic on main either — the load-dependent, all-or-nothing suppression #7282 characterised, whose 20s deadline that card had already measured as spent. Recurrence filed as #9339 (unassigned). ⛔ Not fixed here: it is an unrelated defect in another package, and the refusal was not weakened to make a test pass.
Corrected consumer sweep. The original sweep grepped credentialsRef, which finds bindings but not fixtures that merely author a datasource the refinement now judges. Re-derived from the actual consumers of DatasourceSchema / defineDatasource (grep -rln across packages/apps/examples), then every one of them run:
| package | result |
|---|---|
@objectstack/spec | 408 files / 10885 tests passed |
@objectstack/objectql | 213 files / 3765 tests passed |
@objectstack/runtime | 165 files / 2464 tests passed |
@objectstack/metadata-protocol (registers the datasource type) | 117 files / 1617 tests passed |
@objectstack/service-datasource | 21 files / 480 tests passed |
@objectstack/downstream-contract | 2 files / 20 tests passed |
~18,400 tests, all green. examples/app-crm and examples/app-showcase author datasources too; both are covered by the Dogfood Regression Gate shards, green on this run. Every mongo datasource artefact in the tree (grep -rln "driver: *['\"]?mongo") lives in packages/spec or packages/services/service-datasource — both in the table above — so no fixture outside the two edited packages feeds the narrowed rule.
No code change was warranted, so the branch is unchanged at 2444d022b. Failed jobs re-queued on the same run.
Generated by Claude Code
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9147
The composed-branch twin of #9041:
external.credentialsRefbound while a mongoconfigauthors nourland names nousernameis the same silent no-op, and is now refused at the same publish door.Where this actually landed
The card and the dispatch both name
packages/services/service-datasource. The refinement to extend is not there — PR #9146 landed #9041's recognizer inpackages/spec/src/data/datasource.zod.ts(DatasourceSchema'ssuperRefine), pinned inpackages/spec/src/data/driver/driver-credential-refusal.test.ts.service-datasourceholds the connect path the refusal is measured against. This PR extends the landed refinement in place — no second recognizer.The defect, re-measured on
origin/main@b0fa4fc1aParsed green, connected anonymously, bound secret never used, operator told nothing.
Mechanism, read off
default-datasource-driver-factory.tsand pinned as a runtime test in this PR:url,buildMongoUrlcomposes the URI, and the bound secret's only route into it is the userinfo written beside a username —const auth = user ? ... : ''. A falsyusernamecloses that route;buildMongoAuthopens withif (!url) return undefined, because the composed branch injects through the URI it builds rather than beside it.So the secret has nowhere to go, and nothing can be fabricated to rescue it — a MongoDB handshake cannot authenticate from a password alone, the same measured asymmetry that made #9041's refusal the right answer rather than an unconditional injection.
Its own message, not #9041's
The remedy differs, and a refusal naming a remedy that does not apply is worse than no refusal — the failure mode this module's own history section documents (the pre-#4410
belongsInConfigline). On the URL branchconfig.urlsupersedes the discreteusername, so the only fix is the URL's userinfo. Hereurlis absent andconfig.usernameis the live field. The new message names both valid fixes and prescribes neither: addusernametoconfig, or remove theexternal.credentialsRefbinding. It is pathed atconfig.username, and a test asserts #9041's URL remedy text is not copied in.The fence — both directions pinned
Refused (new): mongodb arm only,
urlabsent,usernameabsent,credentialsRefbound. Legacydriver: 'mongo'is judged identically viaresolveDriverId.Every near-miss is pinned as still accepted, because the near-misses are what prove the fence:
urlpresent naming a user (condition 1 absent)urlpresent naming no userusernamepresent (condition 2 absent)credentialsRefusernameTwo judgment calls, named rather than buried
1. "names no username" is the falsy set (
undefinedor''), not key-absence.username: ''composes the identical userinfo-free URI and drops the identical secret — measured, and now pinned inbound-secret-dsn-branches.test.ts. It refuses nothing that works today, so it does not widen past the measured no-op; excluding it would leave this refusal prescribingconfig.usernamewhile the platform still accepted the one spelling ofconfig.usernamethat keeps the binding silent. Note the deliberate asymmetry with #9041's present-but-empty userinfo carve-out: thereMongoClientitself throws (URI contained empty userinfo section) so the shape is already loud, while here nothing throws. Each fence follows the measurement on its own branch. This is the one place the implementation reads the dispatch's three-condition fence as "names no username" rather than "key absent" — reversible in one token if the PM disagrees.2. An empty
config.urlis the composed branch — an over-refusal in the landed #9041 arm, corrected here.buildMongoUrlopensif (explicit) return explicit;, sourl: ''falls through and composes from the discrete fields, where a live discreteusernamemakes the bound secret work. #9041's arm judged it as a URL "naming no user" and refused it — i.e. rejected at publish a datasource that connects authenticated at runtime, and a shape the Setup form can plausibly submit from an untouched empty "Connection URI" input (measured: refused onorigin/main, accepted here). Both arms now split on the factory's own branch test, so each judges exactly the branch that will run. Same refinement, same defect class, same gate families, evidence pinned bybuildMongoUrlitself — corrected in place rather than filed, and named here per the bounded in-place rule.Verification
Union run at
2444d022b— the final commit, tree clean.pnpm --filter @objectstack/spec test— 408 files / 10885 tests passedpnpm --filter @objectstack/service-datasource test— 21 files / 480 tests passedpnpm --filter @objectstack/spec typecheck— OK (test layer at its frozen debt, no drift)pnpm --filter @objectstack/service-datasource typecheck— OKorigin/main, restored): 7 failures, exactly the predicted set — the 4 refusal-direction pins, the 2url: ''routing pins, and the mongoconfig.options.auth.passwordis a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040 composition pin. The near-miss acceptance pins correctly stay green in both states; they pin the fence, not the fix.credentialsRefacrosspackages/apps/examples/content): no mongo fixture anywhere bindscredentialsRef; every other binder is sqlite/postgres. No doc example carries the refused shape.Gates, derived with
node scripts/pm/dispatch-gates.mjsagainst the actual changed paths (merge-base diff) and all run green:check:migration-registry,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:doc-formula-expressions,check:empty-state,check:liveness,check:strictness-ledger,check:variant-docs,check:merge-driver,check:objectui-changeset,check:spec-parsed-alias,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-cross-package-test-inputs,check-dev-prereqs,check-empty-changeset,check-affected-docs,check-nul-bytes; convention-triggered:check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure(33 ledger entries, none above its recorded number — run against the built workspace closure).ADR-0087 semantic entry registered under protocol major 18 and the registry regenerated with
gen:migration-registry; changeset added (@objectstack/specminor, the launch-window convention #9041 used).Generated by Claude Code