Repository navigation
fix(runtime)!: the publish-drafts door refuses a body that is not a JSON object 400 VALIDATION_ERROR (#28541) - #28593
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 28 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 4ed0b4d348b51cc19f60d829181a5d7a57af1b7a && git checkout 4ed0b4d348b51cc19f60d829181a5d7a57af1b7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cd065d3698db13969a28f46bf26f3e47e9ecc09c 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d && git checkout -B drift-repro cd065d3698db13969a28f46bf26f3e47e9ecc09c && git merge --no-ff 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d
node scripts/docs-audit/affected-docs.mjs --json cd065d3698db13969a28f46bf26f3e47e9ecc09c
|
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo Contract reviewServed-tier: Face: the narrowing face alone. ① Derived judgmentsAccept-set change at
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #28541
Clause-②: no (narrowing) — a present body that is not a JSON object (an array, a string, a number, a boolean), which
POST /packages/:id/publish-draftsanswered 200 with the default note, is refused 400VALIDATION_ERRORbefore anything is published. Nothing is widened.Metadata-protocol: none
Skills-docs: none — no page under
skills/**orcontent/docsdescribes what this door does with a body that is not an object; the generated reference already declares the body an object from the schema, which does not move.BREAKING accept-set narrowing at the publish-drafts door (
@objectstack/runtime).维护者速读
POST /packages/:id/publish-drafts)收到一个存在但不是 JSON 对象的请求体(数组、字符串、数字、布尔)时,直接 400VALIDATION_ERROR拒收,用的是规范PublishPackageDraftsRequestSchema自己的整体报错,什么都不发布。null仍按「没有请求体」照常 200;对象请求体的行为一字不变(字符串说明、非字符串message的拒收、旧 SDK 的actor、坏backfill的唯一拒收)。回滚即 revert。packages/runtime/src/domains/packages.ts(publish-drafts 分支里declaredMessage之前新加的整体校验);packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts(真实 SQLite 上的新用例);.changeset/28541-publish-drafts-non-object-body-refused.md。pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-publish-drafts-request-conformance.test.ts src/domains/packages-publish-drafts-change-note.integration.test.ts→ 15/15 通过。What changes
The door (
packages/runtime/src/domains/packages.ts, thepublish-draftsbranch). Right before #28527'smessageparse (after the 404 for an unknown package), a body that is present (!== undefined,!== null) and is not a plain object (typeof !== 'object'or an array) is parsed withPublishPackageDraftsRequestSchemaas a whole, and its issue is answered withdeps.error(text, 400): the envelope themessagerefusal and the install door's schema refusals already use (status 400 derivesVALIDATION_ERROR; no code is minted). The text names the path the install door's way,(body)for the root:Invalid publish-drafts body — (body): Invalid input: expected object, received array. Nothing was published: ….The whole-body parse runs only for a non-object body. An object body never reaches it, so
backfillkeeps its one refusal (the protocol's) and a non-stringmessagekeeps #28527's sentence.What stays exactly as it was, by measurement:
HonoHttpServer,getRawApp().fetch, a scratch probe not committed): no body with noContent-Type→req.body{}; no body asapplication/json→{}; an emptyapplication/jsonbody →{};null→null. An in-process call passesundefined. All four publish with the default note, and the existing pinan absent body reads as {}(bodiesundefined,null) still passes. The same probe shows[],"x"and42reach the handler as an array, a string and a number.{},{"message":"note"},{"actor":"x"}answer 200 as before; a badbackfillanswers the protocol's refusal withpublishPackageDraftscalled once, as before; a non-stringmessagekeeps fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527's refusal. All are existing cases in the conformance suite, green unchanged.actorhandling changed (runtime: the package doors record whateveractorthe request body names — publish, save, discard, revert, rollback, reassign and duplicate write a caller-chosen name into metadata history #28414).Pins.
packages-publish-drafts-request-conformance.test.ts: a new case over[],['message'],'x','',42,0,true,false. Each: the schema refuses it, the door answers 400VALIDATION_ERRORwith a headline startingInvalid publish-drafts body — (body):, and neitherpublishPackageDraftsnor the app re-save is called. The suite header gains the whole-body sentence.packages-publish-drafts-change-note.integration.test.ts(real door, real protocol, ObjectQL, on-disk SQLite):[],'x',42are each refused 400VALIDATION_ERROR; nopublishhistory row and no package commit exist afterwards; the same draft then publishes with an object body and its row carries the note.route-ledger.ts: the publish-drafts row note now says a present non-object body is refused and an absent one publishes with no note.Tests
packages.tschecked out fromorigin/mainwith the new tests in place, then restored fromHEAD): both new cases red,[]: expected 200 to be 400; 13 other cases green.node scripts/ablation-replace.mjs, anchorif (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) {→if (false && …) {): anchor 1 → 0, blob1f8629c8d362→4fe1e2242c0e; both new cases red, 13 controls green (string note, no body,actorstripped, undeclared members, non-stringmessagerefusal, badbackfillreal-protocol refusal,actoron the app re-save); restored blob equals HEAD,git diff HEADempty. The runtime tests import the door fromsrc/, so no build sits between the mutation and the run.pnpm --filter @objectstack/runtime typecheckexit 0 (withcheck:test-typecheckOK);pnpm --filter @objectstack/runtime test: Test Files 500 passed (500), Tests 8141 passed | 19 skipped (8160).Acceptance notes
trueandfalseare also present non-object bodies the schema refuses, so they are refused too and pinned.null. Kept as "no body", as the order says, although the schema itself refusesnull. The transport hands it on asnull, and the existing pin publishes it.PublishPackageDraftsRequestSchema's docblock says "an absent body reads as{}" and describes the two members; nothing in it becomes false, sopackages/specis not touched.packages.tsandroute-ledger.tsafter the branch point;origin/mainwas merged in (clean, no hunk shared) and every reading below is on the merged head.message, publishes with the default note" in the pending28452-publish-drafts-message-refusedchangeset by a superseding sentence; that file is not edited.Verification
All readings on head
5cb5e1b8(the merge oforigin/mainatc6583207; nothing was committed after the union ran).origin/mainmoved 7 commits since, none touching the five files of this diff.Builds and lint
pnpm build --concurrency=2(full workspace, under the verify lock) on the merged head:VERDICT command-exit 0, Tasks 117 successful, 117 total.eslint packages/runtime --no-inline-config(thepnpm lintcommand scoped to the touched package): exit 0.Derived gates: 63 run, 63 exit 0
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 commands (the same list before and after the merge); each ran once with stdin closed, exit code captured before any pipe.--ran:✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED. One first read was NOT MEASURED, not red:check:type-check-debtexit 3, its--re-measureclosure build queued behind this PR's own dogfood run on the verify lock (VERDICT queue-timeout (exit 99)); re-run under the lock afterwards: exit 0,126/129 workspace packages type-checked … 0 in the DEBT ledger.check-plugin-teardown-shape.mjs --self-testran aftergit fetch --depth=1 origin 621a4876….Hand gates:
check-platform-checklist.mjs --changed-from origin/mainexit 0;check-commit-card-trailers.mjs --range origin/main..HEADexit 0.Dogfood battery
Once, on the final head
5cb5e1b8, after the full build:bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/dogfood test"(no shard, no path).os-verify-lock: VERDICT command-exit 0 · held the lock 1408s (23m28s) · waited 0sTest Files 363 passed | 3 skipped (366)Tests 2932 passed | 28 skipped (2960)Generated by Claude Code