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..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). 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 — 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/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..3097b102c39 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,10 +199,10 @@ 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" - * shape, minus the zip). Contrast with RecalcEstimateAction above: same - * endpoint, one POST per record. + * 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. * * `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: @@ -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); - }); -}); 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..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 // @@ -63,6 +65,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 +357,167 @@ 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[]; + /** 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; + + /** 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. 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; 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 () => { + 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'); + 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 () => { + // 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); + expect(dataDoorEdit.status, JSON.stringify(dataDoorEdit.body)).toBe(403); + expect(dataDoorEdit.body.code).toBe('PERMISSION_DENIED'); + 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] }); + expectSecurityRefusal(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 }); + expectSecurityRefusal(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) // ============================================================================