Repository navigation
fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors - #28607
Conversation
…ring at the datasource admin write doors POST /api/v1/datasources and PATCH /api/v1/datasources/:name projected the body onto the record's known keys, so a top-level password or connectionString vanished and the write answered success while os compile refuses the same datasource by name. Both doors now refuse either key before anything is written, with DatasourceSchema's own refusal for that key under the heading the record judgement uses. Only key names are judged, so the answer never echoes the credential. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5moUefysT3ba6SpdYY
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c67601d083dcc6d1a217446eaee2da25b2a50b4a && git checkout c67601d083dcc6d1a217446eaee2da25b2a50b4a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 0cc981aa238a292eff24079a3dfcb7a9fe55df1a && git checkout -B drift-repro ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 && git merge --no-ff 0cc981aa238a292eff24079a3dfcb7a9fe55df1a
node scripts/docs-audit/affected-docs.mjs --json ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8
|
|
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH Contract reviewServed-tier: Inputs read: card #28549 (body, triage 6554155790, claim 6554684433, dev report 6555151884), PR #28607 body, file list and net diff at the head above, the 45 check-runs on that head, and the cited code at ① Derived judgments
② Semver level
③ Boundary flags
Check-runs on ④ Design judgmentThe narrowing, judged against the published spec on Attempts to break it, each read in the diff and the cited code:
Findings:
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH 席位核对 · ACCEPT · PR #28607 · 2026-11-01T08:06ZHead-sha: 维护者速读(席位终稿)
核对六项
|
Requested by Leehom · project thread
Fixes #28549
Clause-②: no (narrowing) — a create or edit body that carries
passwordorconnectionStringat its top level, whichPOST /api/v1/datasourcesanswered 201 (andPATCH /api/v1/datasources/NAME200) with the key silently dropped, is refused400 DATASOURCE_ADMIN_ERRORbefore anything is written. Nothing is widened.Metadata-protocol: none
Skills-docs: none — no page under
skills/**orcontent/docsteaches the admin write body; the refusal text isDatasourceSchema's own, which already ships with the spec and does not move.BREAKING accept-set narrowing at the datasource admin write doors (
@objectstack/service-datasource).维护者速读
POST /api/v1/datasources)和编辑(PATCH /api/v1/datasources/NAME)现在会拒收写在请求体顶层的password或connectionString:400DATASOURCE_ADMIN_ERROR,点名这个键,不回显值,什么都不写入。拒收文字直接取自DatasourceSchema自己的报错,也就是os compile打印的那一段(password → 用秘密字段或external.credentialsRef绑定;connectionString → 放进config.url)。secret字段传凭据、编辑时保留已绑定的external.credentialsRef、config.password的原有拒收都不变。控制台表单只发label/driver/schemaMode/config/external/secret,不会触发。回滚即 revert。packages/services/service-datasource/src/datasource-admin-service.ts(新加的assertNoTopLevelCredential及其在 create / update 里的两处调用);packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts;.changeset/28549-datasource-top-level-credential.md。pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-top-level-credential.test.ts→ 10/10 通过。What changes
The doors (
packages/services/service-datasource/src/datasource-admin-service.ts). A new privateassertNoTopLevelCredential(name, body)runs at the start of both write doors, before anything is read from or written to the store:createDatasource: right after the name rule and the draftexternal.credentialsRefrefusal.updateDatasource: right after the 404 and the code-defined refusal, before the merge. It runs on every patch, alabel/active-only edit included: that exemption spares a row stored before a gate, while this judges a credential the author is writing now.It looks for the keys in
TOP_LEVEL_CREDENTIAL_KEYS(password,connectionString) on the body. When one is present it asksDatasourceSchemafor its refusal of those key names, withnullstanding in for every value, and keeps the rootunrecognized_keysmessage. It then throwsInvalid datasource 'NAME'.plus that message, the heading the existing record judgement (assertValidRecord) uses. The route turns the throw into400 DATASOURCE_ADMIN_ERROR, as it does for every refusal from this service.One spelling. The prescription is not copied anywhere. The message is the strict shape's own refusal from
packages/spec/src/data/datasource.zod.ts(thepasswordandconnectionStringguidance rows), the same textos compileprints. The pin asserts the door's message equalsInvalid datasource 'NAME'.plusDatasourceSchema's message derived inside the test, so a re-inlined copy that differs by a word goes red.No credential stored or echoed. Only key names reach the judgement; the values never do. The refusal is thrown before
writeSecret,putDatasourceRecordor any pool seam runs. The tests read the store, the secret store and the op log afterwards, and check that the answer holds neither the placeholder password nor the DSN host.Tests
New file
src/__tests__/datasource-admin-top-level-credential.test.ts. It drives the real routes overHonoHttpServerwith the realDatasourceAdminServiceon an in-memory record store and secret store, using theentitled-caller.fixturecaller. Every credential is an obvious placeholder (PLACEHOLDER_NOT_A_SECRET,db.example.invalid).error.codeDATASOURCE_ADMIN_ERROR+success: false, the exact message, the value absent, and nothing stored, minted or registered):password(the card's repro 1); the message also namesexternal.credentialsRef.connectionStringcarrying URL userinfo (repro 2).passwordbeside asecret.password, and with a top-levelconnectionString: the stored record is unchanged.label-only plus apassword.secret: 201, asys_secret:ref bound atexternal.credentialsRef, the placeholder held only in the secret store.external.credentialsRef: 200, ref kept.config.password: still refused with the driver contract'sInvalid configuration for driver 'postgres'.namingconfig.password, unchanged.Red first, green, ablation
Tests 7 failed | 3 passed (10). The four POST refusals wereexpected { status: 201, success: true, … } to deeply equal { status: 400, success: false, … }, and the three PATCH refusals answeredstatus: 200. The three controls were green.Tests 10 passed (10).node scripts/ablation-replace.mjs, anchorif (present.length === 0) return;→if (present.length >= 0) return;, which turns the check off at both call sites): anchor 1 → 0, replacement 0 → 1, blobfae370d6fc02→7e11b57f76be. Result: the 7 refusal cases red again (POST back to 201, PATCH back to 200) and the 3 controls green. Restore: blob equals HEAD (fae370d6fc02),git diff HEADempty. The test imports the service fromsrc/, so no build sits between the mutation and the run.pnpm --filter @objectstack/service-datasource test: Test Files 67 passed (67), Tests 1110 passed (1110).pnpm --filter @objectstack/service-datasource typecheckexit 0, andtsc --listFilesincludes the new test file.Acceptance notes
external.credentialsRef") is given forpassword.connectionStringgets "belongs insideconfig…config: { url: … }", and aconfig.urlwith userinfo is then refused by the driver contract, which points at the secret. The doors take each key's text verbatim from the schema, soconnectionStringanswers with theconfig.urlprescription, not thecredentialsRefone. That is "the same prescription the build door gives" for that key.toRecordand the update merge, which project onlyname,label,driver,schemaMode,config,external,pool,active): the other connection keys the schema has guidance for (url,host,port,database,user,username,filename),connector, the retired blocks (readReplicas,capabilities,retryPolicy,healthCheck, …), and any other unknown key. A top-levelurlcan carry userinfo exactly asconnectionStringdoes. Out of scope by the order ("a general unknown-key refusal for every top-level key is out of scope"). A closed body would also have to tolerate what a client round-trips fromGET /datasources/NAME(origin,hasSecret,redactedConfigKeys,definedIn).POST /api/v1/datasources/test,testConnection) projects through the sametoRecordand still ignores a top-levelpassword: the probe then fails to connect with the driver's own error, not a silent green. It is not a write door, so it is left as it was.external.credentialsRefstill answers 201" does not hold onmain: since the own-reference rule, create refuses a draft that namesexternal.credentialsRef. The controls are a create whose credential arrives assecretand is bound atexternal.credentialsRef(201), and an edit that keeps a boundexternal.credentialsRef(200).ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx,draftBody) sendslabel,driver,schemaMode,config,external,secretand never a top-level credential.packages/clientexposes only thedatasources/NAME/external/*family.Verification
All readings on head
0cc981aa(the only commit; nothing landed after the union ran).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 61 families, each run with its exit code captured before any pipe (stdin from/dev/null).--ranreconciliation:✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED. Two first reads were container prerequisites, re-read after meeting them:check-plugin-teardown-shape.mjs --self-testexit 3 →git fetch --depth=1 origin 621a4876…→ exit 0 (48 cases pass).check:dual-build-cjs-loadsexit 3 → fullpnpm build --concurrency=2(117/117 tasks) → exit 0.node scripts/check-commit-card-trailers.mjs --range origin/main..HEADexit 0;node scripts/check-platform-checklist.mjs --changed-from origin/mainexit 0 (no anchored file in the change set);node scripts/check-nul-bytes.mjsexit 0;pnpm exec eslint --no-inline-config packages/services/service-datasourceexit 0, no findings. The repo-widepnpm lintis CI's.!and the(narrowing)arm): after the@objectstack/dogfood^...build, under the lock,pnpm --filter @objectstack/dogfood test→ Test Files 364 passed | 3 skipped (367), Tests 2934 passed | 28 skipped (2962); no failure.test:repo, the lint farm, the type-check lanes) are read off this PR's check-runs by the seat.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5moUefysT3ba6SpdYY