From 1d32594190e99bf2f6eae95599e7f0cda082873e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:24:49 +0000 Subject: [PATCH 1/4] fix(showcase): re-land the recalc as a declared flow endpoint that writes as its caller The two `api` actions (showcase_recalc_estimate, showcase_recalc_selection) posted to a code-mounted http.server route that wrote through ctx.ql with no execution context, so the security layer refused every write as principal-less and both answered 500. Under ruling C-prime the recalc is now metadata: a `type: 'flow'` endpoint at /api/v1/apps/showcase/recalc (authRequired) targets showcase_recalc_estimates, which turns either request body into a list of ids and maps the per-task flow showcase_recalc_task_estimate over it (get_record, an assignment computing the window's inclusive days x 8 in CEL, update_record), both runAs 'user'. The code-mounted route, its kernel:ready mount and its unit test are deleted, and every showcase reader of the route is re-pointed. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- examples/app-showcase/objectstack.config.ts | 8 +- .../src/automation/flows/index.ts | 5 + .../automation/flows/recalc-estimate.flow.ts | 181 ++++++++++++++++++ examples/app-showcase/src/coverage.ts | 2 +- .../src/docs/showcase_tour_system.md | 16 +- .../app-showcase/src/system/apis/index.ts | 36 +++- .../src/system/server/recalc-endpoint.ts | 115 ----------- examples/app-showcase/src/ui/actions/index.ts | 20 +- .../app-showcase/src/ui/views/task.view.ts | 8 +- examples/app-showcase/test/gap-fill.test.ts | 44 +++-- .../app-showcase/test/recalc-endpoint.test.ts | 76 -------- 11 files changed, 278 insertions(+), 233 deletions(-) create mode 100644 examples/app-showcase/src/automation/flows/recalc-estimate.flow.ts delete mode 100644 examples/app-showcase/src/system/server/recalc-endpoint.ts delete mode 100644 examples/app-showcase/test/recalc-endpoint.test.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index d728363357a..ba59a489e75 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -17,7 +17,6 @@ import * as objects from './src/data/objects/index.js'; import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-external.datasource.js'; import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; -import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; @@ -256,8 +255,9 @@ export default defineStack({ // carve-out `/api/v1/apps//…`, which is why // `manifest.namespace: 'showcase'` above is load-bearing rather than // decorative: publish rejects an `apis:` block without it. See - // src/system/apis/ for the declarations and the `authRequired` reasoning; - // src/system/server/recalc-endpoint.ts remains the code-mounted counterpart. + // src/system/apis/ for the declarations and the `authRequired` reasoning. + // They are the app's whole HTTP surface: the recalc behind the two `api` + // actions is one of them, a flow that writes as its caller. apis: allApis, // Declarative `connectors:` — both kinds (ADR-0097): provider-bound // INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via @@ -293,8 +293,6 @@ export default defineStack({ */ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); - // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. - registerRecalcEndpoint(ctx as Parameters[0]); // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: // assign the dev admin the approver positions and launch the signoff flows so // real pending requests land in the inbox (cannot be a seed — see diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index e2bc16dc156..4641712a684 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -3,6 +3,7 @@ import { defineFlow } from '@objectstack/spec'; import { DynamicApprovalFlow } from './dynamic-approval.flow'; import { ApproverBindingsFlow } from './approver-bindings.flow'; +import { RecalcEstimatesFlow, RecalcTaskEstimateFlow } from './recalc-estimate.flow'; /** * Task Completed → Notify — an autolaunched, record-triggered flow that fires @@ -2012,4 +2013,8 @@ export const allFlows = [ // designer's Value control has a specimen for each. Draft on purpose — see // the module docstring. ApproverBindingsFlow, + // The recalc behind the two `api` actions, served by the declared + // `showcase_recalc_estimates_api` endpoint (src/system/apis/). + RecalcEstimatesFlow, + RecalcTaskEstimateFlow, ]; diff --git a/examples/app-showcase/src/automation/flows/recalc-estimate.flow.ts b/examples/app-showcase/src/automation/flows/recalc-estimate.flow.ts new file mode 100644 index 00000000000..aa606566b4b --- /dev/null +++ b/examples/app-showcase/src/automation/flows/recalc-estimate.flow.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineFlow } from '@objectstack/spec'; + +/** + * The estimate recalculation behind the two `api` actions + * (`showcase_recalc_estimate`, `showcase_recalc_selection`) — declared, with + * no handler code anywhere. + * + * The actions POST to `RecalcEstimatesEndpoint` (`src/system/apis/index.ts`), + * a `type: 'flow'` endpoint, so the request runs through the platform's own + * door: the caller's session is resolved, `authRequired` turns an anonymous + * caller away with `401` before anything runs, and the flows below run AS THE + * CALLER (`runAs: 'user'`). Every read and write they make is judged by the + * security layer as that user's own edit — a caller without edit rights on a + * task is refused there, and nothing is written. + * + * ⛔ Not `runAs: 'system'`. An elevated recalc would write a row its caller + * may not edit: the endpoint would become a way around the caller's own + * permissions, which is the opposite of what an example should teach. + * + * Two flows, because the selection is a list and the recalculation is per row: + * + * - {@link RecalcTaskEstimateFlow} recalculates ONE task: read its schedule + * window, compute the estimate in an `assignment`, write it with + * `update_record`. + * - {@link RecalcEstimatesFlow} is the endpoint's target. It turns either + * request body into a list of ids and runs the per-task flow once per id + * through a `map` node — the single-record body is a list of one. + */ + +/** + * The window's days × 8h, in CEL: inclusive whole days from `start_date` to + * `end_date`, eight hours each. A task with no window — either date unset, or + * an end before its start — gets one day (8h). + * + * `has()` comes first because CEL refuses to read a key the record does not + * carry; `daysBetween` takes the two dates as stored (`YYYY-MM-DD`) and counts + * whole days between them, so the `+ 1` is what makes the window inclusive. + */ +const WINDOW_ESTIMATE = + 'has(task.start_date) && has(task.end_date) && task.start_date != null && task.end_date != null' + + ' && daysBetween(task.start_date, task.end_date) >= 0' + + ' ? (daysBetween(task.start_date, task.end_date) + 1) * 8' + + ' : 8'; + +/** Recalculate one task's `estimate_hours` from its own schedule window. */ +export const RecalcTaskEstimateFlow = defineFlow({ + name: 'showcase_recalc_task_estimate', + label: 'Recalculate One Task Estimate', + description: 'Recomputes one task’s estimate_hours from its schedule window (inclusive days × 8h) and writes it, as the caller.', + // Started only by `RecalcEstimatesFlow`'s `map` node, never as an entry. + type: 'autolaunched', + // The caller's own identity, stated rather than left to the default: this is + // the line that makes the write the caller's edit. + runAs: 'user', + variables: [ + { name: 'recordId', type: 'text', isInput: true }, + { name: 'estimate_hours', type: 'number', isOutput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'load_task', + type: 'get_record', + label: 'Read the schedule window', + // The window is read from the stored row, never from the request: the + // console sends only the id, and a body could carry any dates at all. + config: { + objectName: 'showcase_task', + filter: { id: { dialect: 'cel', source: 'recordId' } }, + fields: ['start_date', 'end_date'], + outputVariable: 'task', + }, + }, + { + id: 'compute_estimate', + type: 'assignment', + label: 'Days in the window × 8h', + config: { + assignments: { + estimate_hours: { dialect: 'cel', source: WINDOW_ESTIMATE }, + }, + }, + }, + { + id: 'write_estimate', + type: 'update_record', + label: 'Write the estimate', + config: { + objectName: 'showcase_task', + filter: { id: { dialect: 'cel', source: 'recordId' } }, + fields: { estimate_hours: { dialect: 'cel', source: 'estimate_hours' } }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'load_task' }, + { id: 'e2', source: 'load_task', target: 'compute_estimate' }, + { id: 'e3', source: 'compute_estimate', target: 'write_estimate' }, + { id: 'e4', source: 'write_estimate', target: 'end' }, + ], +}); + +/** + * The endpoint's target: recalculate the tasks a request names. + * + * The two actions send the two bodies the bulk-dispatch contract defines + * (`execution` on the action, `bulkActionDefs` on the view): + * + * - per record — `{ recordId }`, one request per task; + * - aggregate — `{ _selectedIds: [...] }`, ONE request for the whole + * selection. + * + * Both become one list, and the `map` node runs {@link RecalcTaskEstimateFlow} + * for each id, in order. The `map` stops at the first task it cannot + * recalculate and the whole request is refused, so a caller is never told the + * selection was recalculated when part of it was not — the aggregate contract's + * "a handler that cannot cover the whole selection must reject" (`bulkActionDefs` + * in `@objectstack/spec`'s list view). The run is not a transaction: tasks + * before the refused one keep their new estimate. The contract's remedy is to + * run the action again, and that is safe here because recalculating a task + * twice writes the same value. + * + * A body naming no task at all is refused too: the list is then `[null]`, and + * the per-task read refuses a filter whose id evaluates to nothing. + * + * `type: 'screen'` because it IS an entry — a signed-in caller starts it on + * demand through the endpoint (the `showcase_inquiry_purge` precedent). It has + * no screen, so it runs straight through. + */ +export const RecalcEstimatesFlow = defineFlow({ + name: 'showcase_recalc_estimates', + label: 'Recalculate Estimates', + description: 'Recalculates the estimate of every task a request names — one id, or a whole selection — by running the per-task recalculation for each, as the caller.', + type: 'screen', + runAs: 'user', + variables: [ + // Each body carries exactly one of the two, so both default to `null`: an + // unbound name cannot even be compared in CEL. + { name: 'recordId', type: 'text', isInput: true, defaultValue: null }, + { name: '_selectedIds', type: 'list', isInput: true, defaultValue: null }, + // Each task's recalculated estimate, in request order. + { name: 'estimates', type: 'list', isOutput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'collect_ids', + type: 'assignment', + label: 'The tasks this request names', + config: { + assignments: { + task_ids: { + dialect: 'cel', + source: '_selectedIds != null && size(_selectedIds) > 0 ? _selectedIds : [recordId]', + }, + }, + }, + }, + { + id: 'recalc_each', + type: 'map', + label: 'Recalculate each task', + config: { + collection: { dialect: 'cel', source: 'task_ids' }, + flowName: 'showcase_recalc_task_estimate', + iteratorVariable: 'task_id', + input: { recordId: { dialect: 'cel', source: 'task_id' } }, + outputVariable: 'estimates', + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'collect_ids' }, + { id: 'e2', source: 'collect_ids', target: 'recalc_each' }, + { id: 'e3', source: 'recalc_each', target: 'end' }, + ], +}); diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 3800fd7665e..c92c839296a 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -130,7 +130,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/apis/index.ts'], notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', + 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). The app mounts no route handler of its own: the recalc behind the two `api` actions is a declared flow endpoint too (`showcase_recalc_estimates_api`), and the same file pins that its flow writes as the caller — an admin's recalc updates the estimate, a caller without edit rights is refused by the security layer with nothing written, for one task and for a selection alike.', }, translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] }, email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] }, diff --git a/examples/app-showcase/src/docs/showcase_tour_system.md b/examples/app-showcase/src/docs/showcase_tour_system.md index aa212e1e146..237d13be1fb 100644 --- a/examples/app-showcase/src/docs/showcase_tour_system.md +++ b/examples/app-showcase/src/docs/showcase_tour_system.md @@ -1,6 +1,6 @@ --- title: "Tour · System" -description: Guided tour of the system domain — datasources & federation, i18n, email templates, docs-as-metadata, and the custom endpoint. +description: Guided tour of the system domain — datasources & federation, i18n, email templates, docs-as-metadata, and the declared HTTP endpoints. --- # Guided tour — System @@ -33,12 +33,16 @@ reading, exposed publicly through the library portal. The [documentation guide](./showcase_docs_guide.md) states the authoring rules this very page obeys. -## Code-only surfaces +## HTTP endpoints -`src/system/server/recalc-endpoint.ts` mounts a custom REST endpoint -imperatively — the code-level counterpart of the `router` / `function` / -`service` kinds, which have no declarative authoring surface today (waived -in the coverage manifest with the tracking issue). +`src/system/apis/` declares every endpoint this app serves, under its own +`/api/v1/apps/showcase/` namespace. None has handler code: each one names +a pipeline the platform already runs, a data operation or a flow. The +**Recalculate Estimate** and **Recalculate Selection** buttons on a task +call `POST /api/v1/apps/showcase/recalc`, whose flow recomputes the +estimate from the task's schedule window and writes it as the person who +clicked. Someone who may not edit the task is refused, and nothing is +written. Continue with the [Security tour](./showcase_tour_security.md), or go back to the [overview](./showcase_index.md). diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 4f0847c083c..7442aafaccc 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -7,8 +7,8 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; * * ## What these are * - * The metadata-authored counterpart of the code-mounted endpoint in - * `src/system/server/recalc-endpoint.ts`. The runtime matches a declared + * Every HTTP endpoint this app serves, declared as metadata: an app does not + * mount raw route handlers of its own. The runtime matches a declared * `path` + `method` against the stored `api` items and executes the target * with NO handler code: `object_operation` delegates to the same `callData` * the built-in `/data` route uses, `flow` to the same automation pipeline @@ -48,14 +48,17 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; * * ## `authRequired` is deliberate here, not incidental * - * Both endpoints are session-gated. `authRequired` DEFAULTS to `true`, so the + * Every endpoint here is session-gated. `authRequired` DEFAULTS to `true`, so the * explicit `true` below is documentation rather than protection — but it is * the key whose historical value matters most: an explicit `false` is the only * thing that opens an anonymous execution entry point, and ADR-0121 D6 then * requires it to carry an ARMED `rateLimit` (`enabled: true`; the key defaults - * to `false`, so a budget written without it meters nothing). Neither of these + * to `false`, so a budget written without it meters nothing). None of these * endpoints was ever anonymous, and restoring them did not make one anonymous: - * an example must not demo an anonymous surface it never had. + * an example must not demo an anonymous surface it never had. The recalc + * endpoint below WRITES, so for it the gate is also the first of two: an + * anonymous caller is turned away with `401` before its flow runs, and a + * signed-in one is then judged row by row as themselves. * * Proven end to end, on a real boot, by * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`. @@ -114,4 +117,25 @@ export const InquiryPurgeEndpoint: ApiEndpoint = { authRequired: true, }; -export const allApis: ApiEndpoint[] = [TaskFeedEndpoint, InquiryPurgeEndpoint]; +/** + * Flow-typed endpoint behind the two `api` actions — `showcase_recalc_estimate` + * (one task) and `showcase_recalc_selection` (a whole selection in one call). + * + * Its target runs `runAs: 'user'`, so the recalculation writes as the caller: + * the security layer judges each task's write as that user's own edit, and a + * caller without edit rights is refused there with nothing written. The flow + * reads both request bodies the actions send, `{ recordId }` and + * `{ _selectedIds }` (`src/automation/flows/recalc-estimate.flow.ts`). + */ +export const RecalcEstimatesEndpoint: ApiEndpoint = { + name: 'showcase_recalc_estimates_api', + path: '/api/v1/apps/showcase/recalc', + method: 'POST', + summary: 'Recalculate task estimates', + description: 'Recomputes estimate_hours from each named task’s schedule window (inclusive days × 8h), as the caller. Body: { recordId } for one task, or { _selectedIds } for a selection.', + type: 'flow', + target: 'showcase_recalc_estimates', + authRequired: true, +}; + +export const allApis: ApiEndpoint[] = [TaskFeedEndpoint, InquiryPurgeEndpoint, RecalcEstimatesEndpoint]; diff --git a/examples/app-showcase/src/system/server/recalc-endpoint.ts b/examples/app-showcase/src/system/server/recalc-endpoint.ts deleted file mode 100644 index 23ea0d20ee5..00000000000 --- a/examples/app-showcase/src/system/server/recalc-endpoint.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Custom REST endpoint backing the `showcase_recalc_estimate` **api** action. - * - * The showcase exercises every `ActionType`; `type: 'api'` means "POST to a - * custom endpoint". That endpoint has to exist, or the button 404s on click — - * a soft failure the build can't catch (an `api` action only needs a target - * *string*; the URL's reachability isn't statically verifiable). So we mount a - * real route here. - * - * Wiring: there is no declarative endpoint surface in a bundle, so we register - * imperatively against the `http.server` service. We do it on `kernel:ready` - * (the service is reliably available then, and Hono's route matcher is only - * frozen later on `kernel:listening`, so the route lands in time). - * - * Contract: the objectui ActionRunner POSTs the record as the JSON body for a - * string-target api action, so the body carries `id` + the record fields. - * We recompute `estimate_hours` from the schedule window (working at 8h/day) - * and persist it; `refreshAfter: true` on the action repaints the new value. - * - * Aggregate branch (objectui#3139): an `execution: 'aggregate'` bulk dispatch - * (see `showcase_recalc_selection` + task.view's `bulk_actions`) POSTs ONE - * request whose body carries `_selectedIds: string[]` instead of a single id. - * The handler recomputes every id in that one call and reports the count — - * all-or-nothing, per the aggregate contract: a failure rejects the whole - * request rather than reporting partial success. - */ - -interface RecalcHostContext { - ql: { update: (object: string, data: Record, options?: unknown) => Promise }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void; error?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; - getService?: (name: string) => Promise; -} - -/** Inclusive day-count between two dates × 8h; falls back to one day. */ -function estimateFromWindow(start: unknown, end: unknown): number { - const s = typeof start === 'string' || start instanceof Date ? new Date(start as string) : undefined; - const e = typeof end === 'string' || end instanceof Date ? new Date(end as string) : undefined; - if (s && e && !Number.isNaN(s.getTime()) && !Number.isNaN(e.getTime()) && e >= s) { - const days = Math.round((e.getTime() - s.getTime()) / 86_400_000) + 1; - return Math.max(1, days) * 8; - } - return 8; -} - -export function registerRecalcEndpoint(ctx: RecalcHostContext): void { - const mount = async (): Promise => { - let server: { post?: (path: string, handler: (req: unknown, res: unknown) => unknown) => void } | undefined; - try { - server = await ctx.getService?.('http.server'); - } catch { - server = undefined; - } - if (!server || typeof server.post !== 'function') { - ctx.logger?.warn?.('[showcase] http.server unavailable — POST /api/v1/showcase/recalc not mounted'); - return; - } - - server.post('/api/v1/showcase/recalc', async (req: unknown, res: unknown) => { - const r = res as { - status: (code: number) => void; - json: (body: unknown) => void; - }; - try { - const body = ((req as { body?: Record })?.body) ?? {}; - // Aggregate bulk dispatch (objectui#3139): one request, every selected - // id in `_selectedIds`. The single-record shape has the record fields - // in the body; here only ids arrive, so recompute from a flat 8h - // baseline per task — the point of the specimen is the ONE-call shape, - // not the estimation model. - const selectedIds = Array.isArray(body._selectedIds) - ? (body._selectedIds as unknown[]).map(String).filter(Boolean) - : undefined; - if (selectedIds) { - if (selectedIds.length === 0) { - r.status(400); - r.json({ success: false, error: '_selectedIds is empty' }); - return; - } - for (const sid of selectedIds) { - await ctx.ql.update( - 'showcase_task', - { id: sid, estimate_hours: 8 }, - { where: { id: sid } }, - ); - } - r.json({ success: true, data: { recalculated: selectedIds.length } }); - return; - } - const id = (body.id ?? body.recordId) as string | undefined; - if (!id) { - r.status(400); - r.json({ success: false, error: 'recordId required' }); - return; - } - const estimate_hours = estimateFromWindow(body.start_date, body.end_date); - await ctx.ql.update('showcase_task', { id, estimate_hours }, { where: { id } }); - r.json({ success: true, data: { id, estimate_hours } }); - } catch (err) { - r.status(500); - r.json({ success: false, error: err instanceof Error ? err.message : String(err) }); - } - }); - - ctx.logger?.info?.('[showcase] mounted POST /api/v1/showcase/recalc'); - }; - - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:ready', mount); - } else { - void mount(); - } -} diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 64db7bac4fb..1d5e454a082 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -164,16 +164,20 @@ export const QuickViewAction = defineAction({ refreshAfter: false, }); -/** api — call a custom endpoint. */ +/** + * api — call an endpoint. The target is this app's declared + * `showcase_recalc_estimates_api` (src/system/apis/), a `type: 'flow'` endpoint + * whose flow writes as the caller — no handler code. + */ export const RecalcEstimateAction = defineAction({ name: 'showcase_recalc_estimate', label: 'Recalculate Estimate', icon: 'calculator', objectName: task, type: 'api', - target: '/api/v1/showcase/recalc', - // #17319 — one POST per record; the endpoint's per-record branch reads the - // single id. Its aggregate twin below is a SEPARATE action against the same + target: '/api/v1/apps/showcase/recalc', + // #17319 — one POST per record; the endpoint's flow reads the single id as a + // list of one. Its aggregate twin below is a SEPARATE action against the same // endpoint, which is what the platform used to require: one body, one // contract, and until this key no way to say which. execution: 'perRecord', @@ -195,8 +199,8 @@ export const RecalcEstimateAction = defineAction({ * value (see `task.view.ts` → `bulk_actions`); a bare-string wiring of it is * now refused at authoring time instead of quietly recalculating one row. The * renderer then dispatches it ONCE for the whole selection, with every - * selected id in `params._selectedIds` — the recalc endpoint's batch branch - * recomputes all of them in that single call (the "one zip for N devices" + * selected id in `params._selectedIds` — the recalc endpoint's flow runs the + * per-task recalculation for every one of them in that single call (the "one zip for N devices" * shape, minus the zip). Contrast with RecalcEstimateAction above: same * endpoint, one POST per record. * @@ -214,7 +218,7 @@ export const RecalcEstimateAction = defineAction({ * So `record_more` here is an explicit SINGLE-RECORD placement, not a way of * dodging an everywhere-default: it puts the action in the record overflow * menu, where the dispatch carries exactly one id and the recalc endpoint's - * per-record branch handles it (via `recordIdParam`). Placement being + * flow reads it as a list of one (via `recordIdParam`). Placement being * declared rather than inherited is also what keeps the action off the LIST * TOOLBAR — a toolbar dispatch would carry neither a selection (no * `_selectedIds`) nor a record id, and the endpoint would reject it — but @@ -226,7 +230,7 @@ export const RecalcSelectionAction = defineAction({ icon: 'calculator', objectName: task, type: 'api', - target: '/api/v1/showcase/recalc', + target: '/api/v1/apps/showcase/recalc', // #17319 — ONE dispatch for the whole selection, every id in // `params._selectedIds`. The view's `bulkActionDefs` entry still performs // the dispatch; this declares what the body was written to receive, so a diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 76ad776952a..e46a8d5362a 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -155,15 +155,15 @@ export const TaskViews = defineView({ // Per-record (default) — each selected record is one dispatch: // `showcase_mark_done` — type `script`; its sandboxed body flips // `done`/`progress` per record via the platform action route. - // `showcase_recalc_estimate` — type `api`; POSTs the showcase's own - // `/api/v1/showcase/recalc`, with `recordIdParam: 'recordId'` (already - // declared for the row surface) carrying each record's id. + // `showcase_recalc_estimate` — type `api`; POSTs the showcase's declared + // `/api/v1/apps/showcase/recalc` endpoint, with `recordIdParam: 'recordId'` + // (already declared for the row surface) carrying each record's id. // // Aggregate (objectui#3139) — the whole selection in ONE dispatch: // `showcase_recalc_selection` — the `bulkActionDefs` entry below opts in // with `execution: 'aggregate'`, so the renderer POSTs the SAME recalc // endpoint once, carrying every selected id in `params._selectedIds` - // (the endpoint's batch branch). This is the "one zip for N devices" + // (its flow runs the per-task recalculation for each). This is the "one zip for N devices" // dispatch shape; results are all-or-nothing, no per-row retry. // // None declares `list_toolbar`: a bulk action is not a toolbar action, diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index df92d34c49a..f2200033228 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -1,7 +1,5 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { existsSync } from 'node:fs'; - import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { CubeSchema } from '@objectstack/spec/data'; @@ -146,7 +144,7 @@ describe('[#5112] showcase declares its api endpoints again (#5040 E8)', () => { it('is wired into the stack definition', () => { const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? []; expect(apis.map((a) => a.name)).toEqual( - expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api']), + expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api', 'showcase_recalc_estimates_api']), ); }); @@ -202,15 +200,37 @@ describe('[#5112] showcase declares its api endpoints again (#5040 E8)', () => { } }); - it('still demonstrates HTTP endpoints in code as well', () => { - // The code-mounted counterpart the coverage note points at. If this file - // ever disappears the note is lying too, so pin it here rather than - // trusting the prose alone. (vitest runs with cwd = the package root, as - // test/coverage.test.ts also relies on.) - expect( - existsSync(`${process.cwd()}/src/system/server/recalc-endpoint.ts`), - 'the code-mounted endpoint is the showcase’s other live HTTP proof', - ).toBe(true); + it('the two recalc `api` actions post to the declared recalc endpoint, whose flows run as the caller', () => { + // The app mounts no route handler of its own, so an `api` action's target + // is reachable only if this stack DECLARES it — an undeclared path is a + // bare 404 on click, which no build step can see (the target is just a + // string). The coverage note says the recalc is declared and writes as its + // caller; this keeps both halves of that sentence true statically. Whether + // the write is really judged as the caller is a runtime fact, pinned on a + // real boot in showcase-declarative-endpoints.dogfood.test.ts. + type Endpoint = { name: string; path: string; method: string; type: string; target?: string }; + const apis = (stack as { apis?: Endpoint[] }).apis ?? []; + const actions = (stack as { actions?: Array<{ name: string; type?: string; target?: unknown }> }).actions ?? []; + const flows = (stack as { flows?: Array<{ name: string; runAs?: string; nodes?: Array<{ type: string; config?: { flowName?: string } }> }> }).flows ?? []; + + const recalc = actions.filter((a) => a.name === 'showcase_recalc_estimate' || a.name === 'showcase_recalc_selection'); + expect(recalc.map((a) => a.name).sort()).toEqual(['showcase_recalc_estimate', 'showcase_recalc_selection']); + for (const action of recalc) { + expect(action.type).toBe('api'); + const endpoint = apis.find((e) => e.path === action.target && e.method === 'POST'); + expect(endpoint, `action '${action.name}' posts to '${String(action.target)}', which this stack does not declare`) + .toMatchObject({ name: 'showcase_recalc_estimates_api', type: 'flow', target: 'showcase_recalc_estimates' }); + } + + // ⛔ Not a system context: the entry flow and the per-task flow its `map` + // runs both declare the caller's identity. `system` would let anyone who + // can reach the endpoint write rows they may not edit. + const entry = flows.find((f) => f.name === 'showcase_recalc_estimates'); + const perTask = entry?.nodes?.find((n) => n.type === 'map')?.config?.flowName; + expect(perTask).toBe('showcase_recalc_task_estimate'); + for (const name of ['showcase_recalc_estimates', perTask]) { + expect(flows.find((f) => f.name === name)?.runAs, `flow '${String(name)}' must run as the caller`).toBe('user'); + } }); }); diff --git a/examples/app-showcase/test/recalc-endpoint.test.ts b/examples/app-showcase/test/recalc-endpoint.test.ts deleted file mode 100644 index 38b9a0294dd..00000000000 --- a/examples/app-showcase/test/recalc-endpoint.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { registerRecalcEndpoint } from '../src/system/server/recalc-endpoint.js'; - -/** - * Unit coverage for the custom REST endpoint behind the `showcase_recalc_estimate` - * **api** action (#2169 sibling: an api action whose endpoint was never mounted - * 404s on click). Drives the registration + handler with fakes — no live server. - */ -function harness() { - let kernelReady: (() => Promise | void) | undefined; - let routeHandler: ((req: unknown, res: unknown) => unknown) | undefined; - const updates: Array<{ object: string; data: Record }> = []; - const ctx = { - ql: { - update: async (object: string, data: Record) => { - updates.push({ object, data }); - return { id: data.id }; - }, - }, - logger: { info() {}, warn() {}, error() {} }, - hook: (event: string, handler: () => Promise | void) => { - if (event === 'kernel:ready') kernelReady = handler; - }, - getService: async (name: string) => - name === 'http.server' - ? { post: (_p: string, h: (req: unknown, res: unknown) => unknown) => { routeHandler = h; } } - : undefined, - }; - registerRecalcEndpoint(ctx as never); - return { - boot: async () => { await kernelReady?.(); }, - call: async (body: unknown) => { - let status = 200; - let json: unknown; - await routeHandler?.({ body }, { status: (c: number) => { status = c; }, json: (b: unknown) => { json = b; } }); - return { status, json }; - }, - updates, - hasRoute: () => routeHandler !== undefined, - }; -} - -describe('showcase recalc endpoint', () => { - it('registers the route on kernel:ready', async () => { - const h = harness(); - expect(h.hasRoute()).toBe(false); - await h.boot(); - expect(h.hasRoute()).toBe(true); - }); - - it('recomputes estimate from the schedule window (days × 8h) and persists it', async () => { - const h = harness(); - await h.boot(); - const res = await h.call({ id: 't1', start_date: '2026-06-16', end_date: '2026-07-02' }); - expect(res.status).toBe(200); - expect(res.json).toEqual({ success: true, data: { id: 't1', estimate_hours: 136 } }); - expect(h.updates).toEqual([{ object: 'showcase_task', data: { id: 't1', estimate_hours: 136 } }]); - }); - - it('falls back to 8h when the window is missing', async () => { - const h = harness(); - await h.boot(); - const res = await h.call({ id: 't2' }); - expect(res.json).toEqual({ success: true, data: { id: 't2', estimate_hours: 8 } }); - }); - - it('rejects a request without a record id', async () => { - const h = harness(); - await h.boot(); - const res = await h.call({}); - expect(res.status).toBe(400); - expect(h.updates).toHaveLength(0); - }); -}); From 5e09eefa6376184afd36222c0f13ebec70c4f63c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:42:07 +0000 Subject: [PATCH 2/4] test(dogfood): pin the showcase recalc endpoint writing as its caller An admin's recalc answers 200 and writes the window's days x 8; a member without edit rights on the row is refused with nothing written; the aggregate _selectedIds body does both the same way, each task from its own window; an anonymous caller is turned away with 401; a body naming no task is refused. Also drops an apostrophe that broke the coverage note's string literal. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- examples/app-showcase/src/coverage.ts | 2 +- ...case-declarative-endpoints.dogfood.test.ts | 140 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index c92c839296a..20a17c34111 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -130,7 +130,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/apis/index.ts'], notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). The app mounts no route handler of its own: the recalc behind the two `api` actions is a declared flow endpoint too (`showcase_recalc_estimates_api`), and the same file pins that its flow writes as the caller — an admin's recalc updates the estimate, a caller without edit rights is refused by the security layer with nothing written, for one task and for a selection alike.', + 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). The app mounts no route handler of its own: the recalc behind the two `api` actions is a declared flow endpoint too (`showcase_recalc_estimates_api`), and the same file pins that its flow writes as the caller — a recalc by an admin updates the estimate, a caller without edit rights is refused by the security layer with nothing written, for one task and for a selection alike.', }, translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] }, email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] }, diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index dba3df7e16f..25a27263b2a 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -63,6 +63,7 @@ import { buildShapedArtifact, writeBuildShapedArtifact } from './build-shaped-ar /** The ADR-0121 D1 mount, spelled here as a caller would type it. */ const TASKS = '/apps/showcase/tasks'; const PURGE = '/apps/showcase/inquiries/purge'; +const RECALC = '/apps/showcase/recalc'; /** Two negative controls: undeclared UNDER the mount, and off the mount entirely. */ const UNDECLARED_UNDER_MOUNT = '/apps/showcase/no-such-endpoint-e8'; @@ -354,6 +355,145 @@ describe('[#5112] flow endpoint: invokes the real flow over HTTP', () => { }); }); +// ============================================================================ +// 3b. The recalc endpoint: a flow that writes AS ITS CALLER (#23128) +// ============================================================================ + +/** + * `POST /apps/showcase/recalc` backs the two `api` actions. It used to be a + * code-mounted route that wrote with no execution context, so the security + * layer refused every write as principal-less and the actions answered 500. + * It is now a declared `type: 'flow'` endpoint whose flows run + * `runAs: 'user'`, and these cases are the triage pins: + * + * - an admin's recalc answers 200 and updates the estimate; + * - a caller without edit rights on the row is refused by the security + * layer, and nothing is written — not a 500; + * - the aggregate body (`_selectedIds`) does both the same way. + * + * The refused caller is a signed-up member: it holds only the showcase's + * `everyone` baseline, which grants `showcase_task` read and create but not + * edit. Each refusal is asserted beside the admin's success on the SAME row + * with the SAME body, so the caller is the only thing that differs. + * + * Fixtures are written and read straight through the engine as the system + * principal: the subject is the endpoint, so neither the setup nor the + * read-back goes through it. + */ +describe('[#23128] the recalc flow endpoint writes as its caller', () => { + const SYS = { isSystem: true } as const; + /** Not a value the recalc can produce (it answers 8 × days, at least 8). */ + const SENTINEL = 1; + /** Three windows with three different answers: 17 days, 5 days, and none. */ + const WINDOWS = [ + { start_date: '2026-06-16', end_date: '2026-07-02', hours: 136 }, + { start_date: '2026-03-02', end_date: '2026-03-06', hours: 40 }, + { start_date: '2026-05-01', end_date: null, hours: 8 }, + ] as const; + + let ql: any; + let memberToken: string; + let ids: string[]; + + const estimateOf = async (id: string): Promise => + (await ql.findOne('showcase_task', { where: { id }, fields: ['estimate_hours'], context: SYS }))?.estimate_hours; + + /** Give the rows their windows back and a sentinel estimate, so a write is visible. */ + const resetFixtures = async (): Promise => { + for (const [i, w] of WINDOWS.entries()) { + await ql.update( + 'showcase_task', + { id: ids[i], start_date: w.start_date, end_date: w.end_date, estimate_hours: SENTINEL }, + { where: { id: ids[i] }, context: SYS }, + ); + } + }; + + /** The refusal every refused case answers, read off the envelope's own fields. */ + const expectRefused = async (res: Response): Promise => { + const text = await res.clone().text(); + expect(res.status, text).toBe(400); + const body = (await res.json()) as { success?: boolean; error?: { code?: string } }; + expect(body.success).toBe(false); + expect(body.error?.code, text).toBe('FLOW_FAILED'); + }; + + beforeAll(async () => { + ql = await stack.kernel.getServiceAsync('objectql'); + const rows = (await ql.find('showcase_task', { fields: ['id'], limit: WINDOWS.length, context: SYS })) as Array<{ id: string }>; + ids = rows.map((r) => String(r.id)); + expect(ids, 'the showcase seed carries at least three tasks').toHaveLength(WINDOWS.length); + memberToken = await stack.signUp('recalc-member-23128@example.com', 'Recalc!Member23128', 'Recalc Member'); + }, 60_000); + + it('precondition: the member can READ the row and the data door refuses its edit', async () => { + // What makes the member the right refused caller: the refusal below must be + // about the WRITE, so the member reads the row fine — and the platform's own + // data door refuses the very edit the recalc would make. + await resetFixtures(); + const read = await stack.apiAs(memberToken, 'GET', `/data/showcase_task/${ids[0]}`); + expect(read.status, await read.clone().text()).toBe(200); + const edit = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ids[0]}`, { estimate_hours: 99 }); + expect(edit.status, await edit.clone().text()).toBe(403); + expect(await estimateOf(ids[0])).toBe(SENTINEL); + }); + + it('an admin recalc of ONE task answers 200 and writes the window’s days × 8', async () => { + await resetFixtures(); + const res = await stack.apiAs(adminToken, 'POST', RECALC, { recordId: ids[0] }); + expect(res.status, await res.clone().text()).toBe(200); + expect(((await res.json()) as { success?: boolean }).success).toBe(true); + // 2026-06-16 … 2026-07-02 is 17 days, inclusive. + expect(await estimateOf(ids[0])).toBe(WINDOWS[0].hours); + // The other rows were not named, so they were not touched. + expect(await estimateOf(ids[1])).toBe(SENTINEL); + }); + + it('a member without edit rights is REFUSED for one task, and nothing is written', async () => { + await resetFixtures(); + const res = await stack.apiAs(memberToken, 'POST', RECALC, { recordId: ids[0] }); + await expectRefused(res); + expect(await estimateOf(ids[0]), 'a refused recalc writes nothing').toBe(SENTINEL); + }); + + it('an admin recalc of a SELECTION answers 200 and writes each task from its own window', async () => { + await resetFixtures(); + const res = await stack.apiAs(adminToken, 'POST', RECALC, { _selectedIds: ids }); + expect(res.status, await res.clone().text()).toBe(200); + expect(((await res.json()) as { success?: boolean }).success).toBe(true); + // Three different answers prove the per-task flow ran once per id, each on + // its own row — the window-less task gets one day. + for (const [i, w] of WINDOWS.entries()) { + expect(await estimateOf(ids[i]), `task ${i}`).toBe(w.hours); + } + }); + + it('a member without edit rights is REFUSED for a selection, and no task is written', async () => { + await resetFixtures(); + const res = await stack.apiAs(memberToken, 'POST', RECALC, { _selectedIds: ids }); + await expectRefused(res); + for (const [i] of WINDOWS.entries()) { + expect(await estimateOf(ids[i]), `task ${i}`).toBe(SENTINEL); + } + }); + + it('an anonymous caller is turned away with 401 before the flow runs', async () => { + await resetFixtures(); + const res = await stack.api(RECALC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recordId: ids[0] }), + }); + expect(res.status, await res.clone().text()).toBe(401); + expect(await estimateOf(ids[0])).toBe(SENTINEL); + }); + + it('a body that names no task is refused rather than answered as success', async () => { + const res = await stack.apiAs(adminToken, 'POST', RECALC, {}); + await expectRefused(res); + }); +}); + // ============================================================================ // 4. Negative controls — the seam costs unmatched traffic nothing (#5090) // ============================================================================ From 10a10ebfa9f6c97fce58f6052af7674ce4c0de2f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:00:58 +0000 Subject: [PATCH 3/4] test(dogfood): hold the recalc refusal to the security layer's own words A flow endpoint answers a failed run with the shared flow-dispatch table's 400 FLOW_FAILED, so the code alone does not say why. The refused cases now also require the run's error to carry the refusal the data door answers the same member for the same edit under 403 PERMISSION_DENIED, compared with that live answer rather than a copied sentence. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- ...case-declarative-endpoints.dogfood.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index 25a27263b2a..8edafe47930 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -28,7 +28,9 @@ // nothing (#5090); // 5. do the two machine-readable faces — `GET /meta/api` and // `GET /openapi.json` — describe exactly what is mounted (ADR-0076 §4: -// a machine-readable surface must not lie). +// a machine-readable surface must not lie); +// 6. does a flow endpoint that WRITES do so as its caller — the recalc +// behind the showcase's two `api` actions (section 3b). // // ## RED-first // @@ -394,6 +396,8 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { let ql: any; let memberToken: string; let ids: string[]; + /** The data door's own answer to the member editing the row: the security layer's refusal, as it speaks. */ + let dataDoorEdit: { status: number; body: { code?: string; error?: string } }; const estimateOf = async (id: string): Promise => (await ql.findOne('showcase_task', { where: { id }, fields: ['estimate_hours'], context: SYS }))?.estimate_hours; @@ -409,13 +413,30 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { } }; - /** The refusal every refused case answers, read off the envelope's own fields. */ - const expectRefused = async (res: Response): Promise => { + /** + * The refusal every refused case answers. A flow endpoint answers a run that + * failed with the shared flow-dispatch table's `400 FLOW_FAILED` (the same + * answer as `POST /automation/:name/trigger`), so the code says "the run was + * refused", not why. + */ + const expectRefused = async (res: Response): Promise<{ message?: string }> => { const text = await res.clone().text(); expect(res.status, text).toBe(400); - const body = (await res.json()) as { success?: boolean; error?: { code?: string } }; + const body = (await res.json()) as { success?: boolean; error?: { code?: string; message?: string } }; expect(body.success).toBe(false); expect(body.error?.code, text).toBe('FLOW_FAILED'); + return body.error ?? {}; + }; + + /** + * …and the why: the run's error carries the security layer's own refusal of + * the edit, the words the data door answers the same member with under + * `403 PERMISSION_DENIED`. Compared with that live answer rather than a + * copied sentence, so this holds the source of the refusal, not its wording. + */ + const expectSecurityRefusal = (error: { message?: string }): void => { + expect(dataDoorEdit.body.error, 'the data door states its refusal').toBeTruthy(); + expect(error.message, 'the flow was refused by the security layer, not by anything else').toContain(dataDoorEdit.body.error); }; beforeAll(async () => { @@ -424,6 +445,9 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { ids = rows.map((r) => String(r.id)); expect(ids, 'the showcase seed carries at least three tasks').toHaveLength(WINDOWS.length); memberToken = await stack.signUp('recalc-member-23128@example.com', 'Recalc!Member23128', 'Recalc Member'); + await resetFixtures(); + const edit = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ids[0]}`, { estimate_hours: 99 }); + dataDoorEdit = { status: edit.status, body: (await edit.json()) as { code?: string; error?: string } }; }, 60_000); it('precondition: the member can READ the row and the data door refuses its edit', async () => { @@ -433,8 +457,8 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { await resetFixtures(); const read = await stack.apiAs(memberToken, 'GET', `/data/showcase_task/${ids[0]}`); expect(read.status, await read.clone().text()).toBe(200); - const edit = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ids[0]}`, { estimate_hours: 99 }); - expect(edit.status, await edit.clone().text()).toBe(403); + expect(dataDoorEdit.status, JSON.stringify(dataDoorEdit.body)).toBe(403); + expect(dataDoorEdit.body.code).toBe('PERMISSION_DENIED'); expect(await estimateOf(ids[0])).toBe(SENTINEL); }); @@ -452,7 +476,7 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { it('a member without edit rights is REFUSED for one task, and nothing is written', async () => { await resetFixtures(); const res = await stack.apiAs(memberToken, 'POST', RECALC, { recordId: ids[0] }); - await expectRefused(res); + expectSecurityRefusal(await expectRefused(res)); expect(await estimateOf(ids[0]), 'a refused recalc writes nothing').toBe(SENTINEL); }); @@ -471,7 +495,7 @@ describe('[#23128] the recalc flow endpoint writes as its caller', () => { it('a member without edit rights is REFUSED for a selection, and no task is written', async () => { await resetFixtures(); const res = await stack.apiAs(memberToken, 'POST', RECALC, { _selectedIds: ids }); - await expectRefused(res); + expectSecurityRefusal(await expectRefused(res)); for (const [i] of WINDOWS.entries()) { expect(await estimateOf(ids[i]), `task ${i}`).toBe(SENTINEL); } From 2ce73b323428f80185ff5c61a55f1d27bd29dc57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:17:59 +0000 Subject: [PATCH 4/4] style(showcase): re-wrap the selection action's comment Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- examples/app-showcase/src/ui/actions/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 1d5e454a082..3097b102c39 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -200,9 +200,9 @@ export const RecalcEstimateAction = defineAction({ * now refused at authoring time instead of quietly recalculating one row. The * renderer then dispatches it ONCE for the whole selection, with every * selected id in `params._selectedIds` — the recalc endpoint's flow runs the - * per-task recalculation for every one of them in that single call (the "one zip for N devices" - * shape, minus the zip). Contrast with RecalcEstimateAction above: same - * endpoint, one POST per record. + * per-task recalculation for every one of them in that single call (the + * "one zip for N devices" shape, minus the zip). Contrast with + * RecalcEstimateAction above: same endpoint, one POST per record. * * `locations` is a SEPARATE declaration from that selection-bar entry, which * the view owns. It is not redundant, and it is not an opt-out of anything: