Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/28541-publish-drafts-non-object-body-refused.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the door now enforces the object body that PublishPackageDraftsRequestSchema already declares. Nothing was stored for the refused bodies (they carry no note or backfill), so there is nothing for a migration to rewrite. -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1953,6 +1953,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
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`' },
Expand Down
Loading