fix(runtime): refuse a non-string descriptor filter instead of serving an empty automation palette (#7360) - #7499
Conversation
…g an empty automation palette (#7360) `GET /api/v1/automation/actions` (`?paradigm` / `?source` / `?category`) and `GET /api/v1/automation/connectors` (`?type`) compared a RAW query value against a string field. A repeated parameter arrives as an ARRAY from every query parser these routes run behind, and an array is never `===` any string and never a member of `paradigms[]` — so `?source=builtin&source=plugin`, a caller widening its filter or a UI serialising a multi-select the obvious way, answered 200 with ZERO descriptors. The designer palette reads that as "this deployment registers no actions", which is a different sentence from "no actions matched", and nothing in the response tells them apart. A structured `?category[$ne]=x` failed identically. This is #7300/#6928's family but not its mechanism: nothing is coerced and no value is invented — the filter is simply never satisfiable, and the emptiness is indistinguishable from a genuinely empty registry. So the fix is the shared `parseStringParam` the same file's runs branch already uses, not a new gate: a non-string is refused in the house shape, 400 `VALIDATION_FAILED` (ADR-0112) with a `details.fields[]` entry naming the parameter and carrying ADR-0114's `invalid_type`. Four call sites, one helper, no new vocabulary. Two judgement calls are recorded in the code: * The parse runs AHEAD of each route's service-capability probe. A malformed query is malformed whichever automation service a deployment mounts, and a 400 that appeared only where `getActionDescriptors` happens to be implemented would be a contract varying by deployment — leaving the caller unable to tell a rejected filter from an empty registry, which is the very confusion this card is about. A WELL-FORMED filter against an unimplemented method still answers the declared empty-but-valid registry. * Repeated parameters are REFUSED rather than read as an OR-filter. The card left that open as a capability question; measured, nothing needs it. The typed SDK builds both filter sets with `URLSearchParams.set` / a single `?type=` (packages/client/src/index.ts), so it cannot emit one. In objectui, the actual consumer, the designer palette fetches `/automation/actions` with NO query string and narrows by `paradigms.includes('flow')` client-side (packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts), and the connector pickers fetch `/automation/connectors` unfiltered (inspectors/FlowReferenceField.tsx). No caller anywhere sends a repeated descriptor filter. Accepting one would be inventing a wire capability nothing asks for, on the route where a wrong empty answer is hardest to notice; if a multi-select ever wants it, widening a refusal is a compatible change and refusing first is the safe floor. Nothing that worked before changes. Every STRING still filters exactly as today, including one naming no live paradigm/source/category/type — "no actions of that source" is a legitimate empty answer and stays one. Absent and empty spellings still mean "no filter"; the falsy gate these filters always had is preserved verbatim. The new pins live in `automation-descriptor-query-validation.test.ts` (37 cases) alongside the runs branch's own file, and assert the ENVELOPE on every refusal (code AND status), never a bare throw: unfixed, these inputs do not throw at all — they answer 200 with an empty list — so a throw-only assertion would pin the absence of a throw rather than the defect. Each new assertion was proven fail-able by mutation (mutate, see red, revert): paradigm/source/category/type each back to the raw value → 4/5/4/4 red in exactly their own block; the parse moved back inside the capability branch → the deployment-independence pin alone; the falsy gate replaced with `!== undefined` → both empty-spelling preservation rows; `parseStringParam` swapped for a closed-set `parseEnumParam` → the two "a string naming nothing live is still a 200 empty list" rows; the `?source=` filter inverted → its preservation rows; the parse hoisted above the #5519 anonymous baseline → the 401-before-parse ordering pin. `pnpm lint` and `tsc --noEmit` clean; `@objectstack/runtime` 1976/1976 and the client's descriptor tests 6/6 pass. Co-authored-by: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7360
What was broken
GET /api/v1/automation/actions(?paradigm/?source/?category) andGET /api/v1/automation/connectors(?type) compared a raw query value against a string field:A repeated parameter arrives as an array from every query parser these routes run behind, and an array is never
===any string and never a member ofparadigms[]. So?source=builtin&source=plugin— a caller widening its filter, or a UI serialising a multi-select the obvious way — answered 200 with zero descriptors. The designer palette reads that as "this deployment registers no actions", which is a different sentence from "no actions matched", and nothing in the response tells them apart. A structured?category[$ne]=xfailed identically.This is #7300/#6928's family but not its mechanism: nothing is coerced and no value is invented — the filter is simply never satisfiable, and the emptiness is indistinguishable from a genuinely empty registry.
The fix
All four filters now go through the shared
parseStringParamthat the same file's runs branch already uses (packages/runtime/src/query-param.ts), so a non-string is refused in the house shape:400VALIDATION_FAILED(ADR-0112) with adetails.fields[]entry naming the parameter and carrying ADR-0114'sinvalid_type. Four call sites, one helper, no new vocabulary. ⛔ The runs branch (#7359 / PR #7490) is untouched.Two judgement calls
1. The parse runs ahead of each route's service-capability probe. A malformed query is malformed whichever automation service a deployment mounts, and a 400 that appeared only where
getActionDescriptorshappens to be implemented would be a contract that varies by deployment — leaving the caller unable to tell a rejected filter from an empty registry, which is the very confusion this card is about. A well-formed filter against an unimplemented method still answers the declared empty-but-valid registry, unchanged.2. Repeated parameters are refused, not read as an OR-filter. The card left this open as a capability question. Measured, nothing needs it:
automation.listActions(packages/client/src/index.ts:3043)URLSearchParams.setper key — structurally cannot repeat a parameterautomation.listConnectors(:3058)?type=packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts:93)/automation/actionswith no query string at all; narrows byparadigms.includes('flow')client-side (:61)inspectors/FlowReferenceField.tsx:378,415)/automation/connectorsunfilteredplugin-rest-api.zod.ts)No caller anywhere sends a repeated descriptor filter — and the one place a multi-paradigm filter would be natural, the palette, does not use the server filter at all. Accepting one would be inventing a wire capability nothing asks for, on the route where a wrong empty answer is hardest to notice. Widening a refusal later is a compatible change; refusing first is the safe floor. No follow-up card filed, since the evidence points away from the need rather than merely being absent.
What does not change
Every string still filters exactly as today, including one that names no live paradigm, source, category or type — "no actions of that source" is a legitimate empty answer and stays one. Absent and empty spellings still mean "no filter": the falsy gate these filters always had is preserved verbatim. Only a non-string is refused.
Tests
packages/runtime/src/domains/automation-descriptor-query-validation.test.ts, 37 cases, alongside the runs branch's own file. Every refusal asserts the envelope (code and status), never a baretoThrow(): unfixed, these inputs do not throw at all — they answer 200 with an empty list — so a throw-only assertion would pin the absence of a throw rather than the defect.Each new assertion proven fail-able by mutation (mutate → red → revert):
paradigmback to the raw value?paradigm=refusals + the capped-message pinsourceback to raw?source=refusals, the?source=builtin&source=pluginpin, the deployment-independence pincategoryback to raw?category=refusals + "refuses before the registry is read"typeback to raw?type=refusals +/connectorsdeployment-independence/actionsdeployment-independence pin, alone!== undefinedparseStringParam→ closed-setparseEnumParamon?source?source=filter inverted (!==)Checks
pnpm lint— clean (repo-wide)tsc --noEmit(@objectstack/runtime) — clean@objectstack/runtime— 1976/1976 pass (122 files)@objectstack/clientdescriptor tests — 6/6 pass.changeset/automation-descriptor-query-refusal.md(@objectstack/runtimepatch). Nocontent/docs/releases/**edits.Generated by Claude Code