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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -256,8 +255,9 @@ export default defineStack({
// carve-out `/api/v1/apps/<manifest.namespace>/…`, 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
Expand Down Expand Up @@ -293,8 +293,6 @@ export default defineStack({
*/
export const onEnable = async (ctx: unknown): Promise<void> => {
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[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
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
];
181 changes: 181 additions & 0 deletions examples/app-showcase/src/automation/flows/recalc-estimate.flow.ts
Original file line number Diff line number Diff line change
@@ -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' },
],
});
2 changes: 1 addition & 1 deletion examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
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'] },
Expand Down
16 changes: 10 additions & 6 deletions examples/app-showcase/src/docs/showcase_tour_system.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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).
36 changes: 30 additions & 6 deletions examples/app-showcase/src/system/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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];
Loading
Loading