Skip to content

Commit 404db56

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23946-email-template-loader-boot-order
2 parents d879052 + be076da commit 404db56

7 files changed

Lines changed: 493 additions & 123 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/hono': minor
3+
---
4+
5+
fix(hono)!: `createHonoApp` reads a dispatched body by `@objectstack/plugin-hono-server`'s rule, so a JSON body sent as `text/plain`, or any other body that declares neither JSON nor a form, is refused `400 INVALID_REQUEST` when the dispatched domain reads it, instead of being parsed or dispatched as `{}`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the `createHonoApp` catch-all and its `/auth/*` fallback for bodies that declare neither JSON nor a form, which used to be parsed as JSON whatever they declared or dispatched as `{}`, and are now refused when a domain reads them. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The `${prefix}/*` catch-all and the `/auth/*` legacy fallback parsed every body as JSON whatever its `Content-Type`, and handed `dispatch()` `{}` for any body that did not parse. So the two Hono doors answered one request two ways: `@objectstack/plugin-hono-server` (`objectstack dev`, `os serve`) refused a valid query body sent as `text/plain`, and this door honoured it; a garbled body that did not declare JSON reached the data domain here as `{}`, the empty query, and read every row the caller could read.
14+
15+
What changes for an integrator, on a host that serves `createHonoApp`, for a `POST`, `PUT` or `PATCH` body read by a domain that reads the parsed body (the data, metadata and every other domain that takes `body`), and on the `/auth/*` legacy fallback for every method it hands a body (all but `GET` and `HEAD`):
16+
17+
- A JSON body sent as `text/plain`, or as any media type other than JSON or a form: FROM parsed and honoured, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json." } }`, the message naming the media type that was sent.
18+
- Any other body that declares neither JSON nor a form (`text/plain`, `application/octet-stream`, a `+json` suffix type such as `application/merge-patch+json`, or no `Content-Type` at all), when the request declares a body (`Content-Length` other than `0`, or `Transfer-Encoding`): FROM `{}`, TO the same refusal ("sent with no Content-Type" when there is none).
19+
- A `multipart/form-data` or `application/x-www-form-urlencoded` body: FROM `{}`, TO its parsed fields, as `plugin-hono-server` hands them (Hono's `parseBody`: the last value of a repeated name). A form that does not parse: TO the refusal "The request body could not be parsed as form data (it was sent as multipart/form-data). It was refused rather than read as an empty body."
20+
21+
The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. Each answer is the status and body `@objectstack/plugin-hono-server` gives the same request. This replaces the earlier `createHonoApp` entry's statement that a body that does not declare JSON is read as before.
22+
23+
**Remedy, in one line.** Send a JSON body with `Content-Type: application/json`, or send no body.
24+
25+
**Unchanged.**
26+
27+
- A body sent as `application/json` (matched case-insensitively) is parsed when well-formed, `{}` when blank or whitespace-only, and refused when it does not parse, as before.
28+
- A request that declares no body is dispatched as `{}`, whatever its media type.
29+
- The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, whatever the body declares: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form.
30+
- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before.

‎docs/adr/0018-unified-node-action-registry.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,15 @@ Managed credentials/secret vault, OAuth2 token refresh, multi-tenant connection
234234
- [x] `builtin/connector-nodes.ts`: `connector_action` executor + descriptor (`category: 'io'`, `source: 'builtin'`, `paradigms: ['flow','approval']`), wired into `installBuiltinNodes()`. The core plugin now seeds 11 baseline node types (was 10).
235235
- [x] First concrete plugin `@objectstack/connector-rest` (the reference connector) validating the registry — `request` action, static auth (`none`/`api-key`/`basic`/`bearer`), no OAuth2 refresh.
236236
- [x] Tests: baseline dispatch (fake connector) + REST plugin auth-header injection + end-to-end kernel boot (both plugins → `connector_action` flow → REST handler).
237+
238+
---
239+
240+
## Addendum (2026-10-16): the three retired callout names are reserved
241+
242+
> Status of this addendum: **ruled** (ruling `6182001882` on #23543, item 6). It narrows §2 ("Open the protocol's node type") by exactly three names and closes §4's alias window, which 11.0 ended without a refusal.
243+
244+
The node-type namespace stays open, with one closed exception: `http_request`, `http_call` and `webhook` — the three outbound callout names §4 unified into `http` — are **reserved retired names**. No flow may type a node with one, and no plugin may register an executor under one.
245+
246+
- **Authoring refuses them, naming `http`.** The flow parse refuses each as a node's `type` (top level and inside a region body), with the prescription to rename the node to `http`. The ADR-0087 D2 conversion `flow-node-http-callout-rename`, which rewrote them at load through 17, leaves the load path at 18; it still runs at every data-at-rest seam, so a stored flow written with an old name keeps loading as `http`.
247+
- **Registration refuses them too.** `AutomationEngine.registerNodeExecutor` throws for an executor or descriptor typed with a reserved name: no node could ever reach it, because an authored node is refused and a stored one is rewritten first. This retires the conversion's ADR-0078 conflict guard, which existed only for a third-party executor under one of these names.
248+
- **Not affected:** a wait node's `waitEventConfig.eventType: 'webhook'` is a different field and stays legal.

‎examples/app-showcase/src/ui/reports/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const HoursByStatusReport = defineReport({
2626
export const StatusPriorityMatrixReport = defineReport({
2727
name: 'showcase_status_priority_matrix',
2828
label: 'Status × Priority (Matrix)',
29-
description: 'Task counts cross-tabulated by status and priority.',
29+
description: 'Estimated hours cross-tabulated by status and priority.',
3030
type: 'matrix',
3131
drilldown: true,
3232
// ADR-0021 D2 — true pivot: `rows` down × `columns` across, measures in cells.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import * as datasets from '../src/ui/datasets/index.js';
6+
import { allReports } from '../src/ui/reports/index.js';
7+
8+
/**
9+
* A report's `description` is the subtitle the console renders under its
10+
* title; its cells are the measures `values` binds. The two must name the same
11+
* quantity. The Status × Priority matrix shipped "Task counts cross-tabulated
12+
* by status and priority." over `values: ['est_hours']` — a `sum` of
13+
* `estimate_hours` — so a reader trusting the subtitle took a cell reading
14+
* 60.0 for sixty tasks when it held one task's estimated hours (#23931).
15+
*
16+
* Graded against the bound DATASET's own declarations, never against a copy of
17+
* the subtitle text:
18+
*
19+
* - a bound measure is named by its declared `label` — the name the cell's
20+
* header shows — and a `count` measure may also be named in count words;
21+
* - the converse is the defect as shipped: count words in a subtitle whose
22+
* bound measures count nothing.
23+
*
24+
* Joined reports bind their measures per block and carry no top-level
25+
* `values`, so their subtitle names no single quantity; they are out of scope
26+
* by construction, not by a skip.
27+
*/
28+
29+
type Measure = { name: string; label?: unknown; aggregate?: string };
30+
31+
const MEASURES_BY_DATASET = new Map<string, readonly Measure[]>(
32+
Object.values(datasets).map((d) => [d.name, d.measures as readonly Measure[]]),
33+
);
34+
35+
/** The words a subtitle uses to say its cells are counts. */
36+
const COUNT_WORDS = /\b(counts?|number of)\b/i;
37+
38+
/** The plain-string copy this pin knows how to read; an inline locale map is reported, not guessed. */
39+
function plain(value: unknown, what: string): string {
40+
expect(typeof value, `${what} must be a plain string for this pin to read it`).toBe('string');
41+
return value as string;
42+
}
43+
44+
function boundMeasures(report: (typeof allReports)[number]): Measure[] {
45+
const measures = MEASURES_BY_DATASET.get(report.dataset ?? '');
46+
expect(measures, `${report.name}: dataset '${report.dataset}' is not a showcase dataset`).toBeDefined();
47+
return (report.values ?? []).map((name) => {
48+
const measure = measures!.find((m) => m.name === name);
49+
expect(measure, `${report.name}: measure '${name}' is not declared on '${report.dataset}'`).toBeDefined();
50+
return measure!;
51+
});
52+
}
53+
54+
const graded = allReports
55+
.filter((r) => r.description !== undefined && (r.values?.length ?? 0) > 0)
56+
.map((r) => [r.name, r] as const);
57+
58+
describe('showcase reports — the subtitle names what the cells hold', () => {
59+
it('grades the Status × Priority matrix, among the measure-bound reports', () => {
60+
expect(graded.map(([name]) => name)).toContain('showcase_status_priority_matrix');
61+
});
62+
63+
it.each(graded)('%s: the description names a bound measure', (name, report) => {
64+
const description = plain(report.description, `${name}.description`).toLowerCase();
65+
const named = boundMeasures(report).filter((m) => {
66+
const label = plain(m.label, `measure ${m.name}.label`).toLowerCase();
67+
return description.includes(label) || (m.aggregate === 'count' && COUNT_WORDS.test(description));
68+
});
69+
expect(
70+
named.map((m) => m.name),
71+
`${name}: "${report.description}" names none of its bound measures ${JSON.stringify(report.values)}`,
72+
).not.toEqual([]);
73+
});
74+
75+
it.each(graded)('%s: the description claims no count its cells do not hold', (name, report) => {
76+
const description = plain(report.description, `${name}.description`);
77+
const bindsACount = boundMeasures(report).some((m) => m.aggregate === 'count');
78+
const countWord = description.match(COUNT_WORDS)?.[0];
79+
expect(
80+
countWord === undefined || bindsACount,
81+
`${name}: "${description}" says "${countWord}", but ${JSON.stringify(report.values)} aggregate no count`,
82+
).toBe(true);
83+
});
84+
});

0 commit comments

Comments
 (0)