From 79482f14272112ee1fe7f48822dcd65f31d366b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:08:19 +0000 Subject: [PATCH 1/3] fix(runtime): refuse a publish-drafts body that is not a JSON object Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg --- packages/runtime/src/domains/packages.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index b04cdc456a2..1f8629c8d36 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1932,6 +1932,32 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // `opts.actor`) still publishes. Placed beside that backfill // judgement, after the 404, so both body-member refusals sit at // one point of the door's order. + // + // [#28541] A body that is present and is not a JSON object (an + // array, a string, a number, a boolean) has no member to read, + // so it used to publish with the default note. It is refused + // first, with the declaration's own whole-body issue and the + // same status as the note refusal. "No body" stays a publish + // with no note: the transport hands an absent or empty body as + // `{}`, and an in-process caller or a JSON `null` as + // `undefined` / `null`, and none of those is refused here. + if (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) { + const declaredBody = PublishPackageDraftsRequestSchema.safeParse(body); + if (!declaredBody.success) { + const located = declaredBody.error.issues + .map((issue) => `${issue.path.map(String).join('.') || '(body)'}: ${issue.message}`) + .join('; '); + return { + handled: true, + response: deps.error( + `Invalid publish-drafts body — ${located}. Nothing was published: the body is a JSON ` + + 'object, { "message"?: string, "backfill"?: [...] }; send one, or send no body to ' + + 'publish with the sentence naming the package as the note.', + 400, + ), + }; + } + } const declaredMessage = PublishPackageDraftsRequestSchema.shape.message.safeParse(body?.message); if (!declaredMessage.success) { const located = declaredMessage.error.issues From 48b4a0412471275e9fe966431ada2a47c0eec39a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:12:59 +0000 Subject: [PATCH 2/3] test(runtime): pin the publish-drafts refusal of a non-object body Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg --- ...ish-drafts-change-note.integration.test.ts | 19 +++++++++++++++ ...publish-drafts-request-conformance.test.ts | 24 +++++++++++++++++++ packages/runtime/src/route-ledger.ts | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts b/packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts index ee34564c8fa..caeaf293dea 100644 --- a/packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts +++ b/packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts @@ -180,6 +180,25 @@ describe('publish-drafts records the change note and the session user (ADR-0146 expect(published.map((r) => [r.name, r.change_note])).toEqual([['notes_open', 'Open notes list']]); }); + it('a body that is not a JSON object is refused 400 VALIDATION_ERROR: nothing is published and no history row is written', async () => { + // [#28541] It used to publish every draft with the default note. + const env = await boot(); + await draftViews(env.protocol, ['notes_open']); + + for (const body of [[], 'x', 42]) { + const result = await env.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, caller('u_alice')); + const response: any = result.response; + expect(response?.status, JSON.stringify(body)).toBe(400); + expect(response?.body?.error?.code, JSON.stringify(body)).toBe('VALIDATION_ERROR'); + } + const history = (await env.engine.find('sys_metadata_history', { where: {} })) as any[]; + expect(history.filter((r) => r.operation_type === 'publish')).toEqual([]); + expect(await env.engine.find('sys_metadata_commit', { where: { package_id: PKG } })).toEqual([]); + // The draft is still pending, and an object body publishes it. + const { history: published } = await publish(env, { message: 'Open notes list' }); + expect(published.map((r) => [r.name, r.change_note])).toEqual([['notes_open', 'Open notes list']]); + }); + it('a caller with no user id records no actor, never the body’s', async () => { const env = await boot(); await draftViews(env.protocol, ['notes_open']); diff --git a/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts b/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts index e71f5e0111a..805e6600409 100644 --- a/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts +++ b/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts @@ -24,6 +24,11 @@ * authenticated caller. That is why the schema strips unknown keys instead * of refusing them: the SDK's deprecated `opts.actor` keeps working. * + * The body as a whole: one that is present and is not a JSON object (an + * array, a string, a number, a boolean) is refused `400 VALIDATION_ERROR` with + * the schema's own whole-body issue before anything is published; an absent + * body (`undefined`, `null`) is a publish with no note. + * * The protocol is a double for the forwarding cases (what it receives is the * assertion) and the real `ObjectStackProtocolImplementation` for the * refusal cases, whose shape judgement runs before any server state is read. @@ -163,6 +168,25 @@ describe('publish-drafts request body conforms to PublishPackageDraftsRequestSch } }); + it('a body that is not a JSON object is refused 400 VALIDATION_ERROR with the schema\'s own refusal, before anything is published', async () => { + // [#28541] An array, a string, a number or a boolean has no member to + // read, so it used to publish with the default note. The schema refuses + // it as a whole, and so does the door. An absent body is pinned above. + for (const body of [[], ['message'], 'x', '', 42, 0, true, false]) { + const schemaVerdict = PublishPackageDraftsRequestSchema.safeParse(body); + expect(schemaVerdict.success, JSON.stringify(body)).toBe(false); + const publishPackageDrafts = vi.fn(); + const door = makeDoor({ publishPackageDrafts }); + const result = await door.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, PKG_ADMIN()); + const response = result.response as any; + expect(response.status, JSON.stringify(body)).toBe(400); + expect(response.body?.error?.code, JSON.stringify(body)).toBe('VALIDATION_ERROR'); + expect(response.body?.error?.message, JSON.stringify(body)).toMatch(/^Invalid publish-drafts body — \(body\): /); + expect(publishPackageDrafts, JSON.stringify(body)).not.toHaveBeenCalled(); + expect(door.saveMetaItem, JSON.stringify(body)).not.toHaveBeenCalled(); + } + }); + it('the ADR-0045 app re-save after the publish records the caller too: a body `actor` reaches no write', async () => { // [#28414] The visibility flip that re-saves each unpublished app // afterwards used to pass a body `actor` to `saveMetaItem`, the one diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index c27d7905861..f21d40f4867 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -523,7 +523,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts', requestSchema: 'PublishPackageDraftsRequestSchema', responseSchema: 'PublishPackageDraftsResponseSchema', - note: 'dispatch() answers through the { success, data } envelope, so the named schema is the `data` — the protocol result AFTER the door\'s own mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). Fillable because packages-publish-drafts-response-conformance.test.ts drives THIS handler and parses the payload it answers; the producer half is pinned in objectql\'s publish-package-drafts-response-conformance.test.ts. `probes` is deliberately opaque in the declaration, upgraded to a modeled schema only when a consumer needs a field of it. `requestSchema` is the body the door reads (`message`, parsed with the schema\'s own member so a non-string note is refused 400 VALIDATION_ERROR before anything is published; `backfill`, judged by the protocol; a body `actor` is ignored, so the schema strips unknown keys rather than refusing them), filled because packages-publish-drafts-request-conformance.test.ts drives THIS handler and pins what each body member does' }, + note: 'dispatch() answers through the { success, data } envelope, so the named schema is the `data` — the protocol result AFTER the door\'s own mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). Fillable because packages-publish-drafts-response-conformance.test.ts drives THIS handler and parses the payload it answers; the producer half is pinned in objectql\'s publish-package-drafts-response-conformance.test.ts. `probes` is deliberately opaque in the declaration, upgraded to a modeled schema only when a consumer needs a field of it. `requestSchema` is the body the door reads (a present body that is not a JSON object is refused 400 VALIDATION_ERROR with the schema\'s own whole-body issue, an absent one publishes with no note; `message`, parsed with the schema\'s own member so a non-string note is refused 400 VALIDATION_ERROR before anything is published; `backfill`, judged by the protocol; a body `actor` is ignored, so the schema strips unknown keys rather than refusing them), filled because packages-publish-drafts-request-conformance.test.ts drives THIS handler and pins what each body member does' }, { route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.discardDrafts', responseSchema: 'DiscardPackageDraftsResponseSchema', note: 'Enveloped — the named schema is the `data`: `discardPackageDrafts`\'s declared return, transcribed describe-only. Conformance: spec `api/package-lifecycle.test.ts`' }, From 5639924c3ad71a1c5d2dbaafce5888ccbf80da4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:14:25 +0000 Subject: [PATCH 3/3] chore: changeset for the publish-drafts non-object body refusal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m1btDPnuG9fbECLtg --- ...1-publish-drafts-non-object-body-refused.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/28541-publish-drafts-non-object-body-refused.md diff --git a/.changeset/28541-publish-drafts-non-object-body-refused.md b/.changeset/28541-publish-drafts-non-object-body-refused.md new file mode 100644 index 00000000000..5d528317627 --- /dev/null +++ b/.changeset/28541-publish-drafts-non-object-body-refused.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: `POST /packages/:id/publish-drafts` refuses a body that is not a JSON object (an array, a string, a number, a boolean) with `400 VALIDATION_ERROR`, instead of publishing with the default note (#28541) + +Clause-②: no (narrowing) — a present body that is not a JSON object, which the door answered `200` with the default note, is refused `400 VALIDATION_ERROR` before anything is published. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the publish-drafts door (`@objectstack/runtime`). + +A body with no members to read used to publish every pending draft with the sentence naming the package as the note, and the caller never learnt its body was ignored. The door now refuses it with `PublishPackageDraftsRequestSchema`'s own whole-body issue (`(body): Invalid input: expected object, received array`), in the same envelope as the non-string `message` refusal: nothing is published and no history row is written. + +Unchanged: no body (an absent or empty body, or JSON `null`) still publishes with the default note; an object body answers exactly as before, so a string note publishes with it, a non-string `message` keeps its refusal, unknown keys such as the SDK's deprecated `actor` are still ignored, and a malformed `backfill` is still refused once, by the protocol, with the same answer. + +This narrows the sentence "no body, or no `message`, publishes with the default note" in the pending `28452-publish-drafts-message-refused` changeset: a body that is present but not an object is refused from this release.