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
23 changes: 23 additions & 0 deletions .changeset/showcase-declarative-endpoints-restored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/example-showcase": patch
---

test(e2e,showcase): showcase 的 `apis:` 回迁,并由真实 boot 探针证明它真的在服务(#5040 E8)

#4936 把 showcase 的两条声明式端点注释掉,不是因为它们写错了,而是因为当时整条端点链零执行:没有任何路由为声明的 `path` 挂载,没有匹配器,每一个键 —— 包括 `authRequired` —— 解析通过而不生效。那时候留着它们就是在演示一个运行时不兑现的能力(Prime Directive #10)。

#5040 的 E1–E7 把执行器建起来、把整面硬拒收窄成逐端点 publish 门之后,那条理由不复存在。本单按**原意**恢复这两条 —— 同名、同 target、同 `authRequired`、同 `cacheTtl` —— 只做 ADR-0121 D1 要求的一处修改:路径迁进本应用的命名空间保留区。

```
- path: '/api/v1/showcase/tasks'
+ path: '/api/v1/apps/showcase/tasks'

- path: '/api/v1/showcase/inquiries/purge'
+ path: '/api/v1/apps/showcase/inquiries/purge'
```

这处修改不是装饰:`manifest.namespace: 'showcase'` 从此是发布的前置条件(声明了 `apis:` 却没有显式 namespace 会被 publish 拒绝),而 `apps/{namespace}/` 这一段让路由归属变成结构性的 —— 没有任何内建域住在 `apps/` 下,两个包也不可能因为 namespace 不同而撞车。

**匿名面没有增加**:两条历史声明本来就都是 `authRequired: true`,回迁后仍然是。一个例子不该长出它从来没有过的公开面。

coverage 清单里 `apis` 从 `waived` 翻回 `demonstrated`,理由重写为「由真实 boot 测量」而不是「声明即证明」—— 后者正是 #4936 抓到的那类假覆盖。支撑它的是两份新的真实 boot e2e:showcase 那份走真实 artifact 摄入路径,证明匹配命中执行(find 的 data 与内建 `/data` 路由逐字节相同)、匿名 401、`cacheTtl` 只随成功答案上线、挂载点下未声明路径与挂载点外的裸 404 完全一致、`/meta/api` 与 `/openapi.json` 描述的正是挂载的东西;fixture 那份补上 ADR-0121 D6 的匿名分支 —— 省略 `authRequired` 拒绝匿名、显式 `false` 服务匿名、已装配预算耗尽后 429 且 `Retry-After` 真的在线上。
15 changes: 8 additions & 7 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,14 @@ export default defineStack({
},
jobs: allJobs,
emailTemplates: allEmails,
// Declarative REST endpoints — SUSPENDED and therefore EMPTY (#4936). The
// two definitions this used to carry are preserved, commented, in
// src/system/apis/index.ts with the full rationale: the surface parsed
// perfectly and executed nothing, so a non-empty `apis:` is now rejected at
// publish/validate. The code-mounted recalc endpoint
// (src/system/server/recalc-endpoint.ts) is the working path meanwhile; the
// executor that makes this key usable again is tracked by #5040.
// Declarative REST endpoints (object_operation + flow) — LIVE again since
// #5040 landed the executor and E7 narrowed #4936's blanket refusal to
// per-endpoint publish gates. Both paths sit under this app's ADR-0121 D1
// 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.
apis: allApis,
// Declarative `connectors:` — both kinds (ADR-0097): provider-bound
// INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via
Expand Down
8 changes: 4 additions & 4 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,10 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).',
},
apis: {
status: 'waived',
reason:
'NOT demonstrable: the runtime has no executor for declarative `apis:`. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" until #4936 measured it on a real boot — the two declared endpoints returned a bare 404 (no route was ever mounted for them) while a control request on the same cookie and prefix returned 200, and the dispatcher branch named here called a `matchEndpoint` that no implementation in the repo provided. That made this the exact false-coverage claim Prime Directive #10 forbids, on a surface whose keys include `authRequired`. Per the 2026-08-04 verdict the vocabulary is kept but a non-empty `apis:` is now rejected at publish/validate, so the showcase declares none; src/system/apis/index.ts keeps both definitions commented, ready to restore. HTTP endpoints are still demonstrated the honest way, in code, by src/system/server/recalc-endpoint.ts (the router kind stays waived: code-only). Flip this back to `demonstrated` in the same PR that lands the executor.',
issue: 'https://github.com/objectstack-ai/objectstack/issues/5040',
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 `cacheTtl: 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 waived: code-only. src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.',
},
connectors: {
status: 'demonstrated',
Expand Down
152 changes: 84 additions & 68 deletions examples/app-showcase/src/system/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,98 @@
import type { ApiEndpoint } from '@objectstack/spec/api';

/**
* Declarative API endpoints (`apis:`) — SUSPENDED in v17 (#4936).
* Declarative API endpoints (`apis:`) — RESTORED and LIVE in v17 (#5040 E8).
*
* ## Why this file is empty
* ## What these are
*
* The showcase used to declare the two endpoints preserved below, and the
* metadata side worked perfectly: `defineStack({ apis })` loaded them, and
* `GET /api/v1/meta/api` returned both with every key intact. The EXECUTION
* side was zero-hit. On a real boot (showcase, 47 plugins) the declared paths
* answered a bare `404 {"error":"Not found"}` — not even the dispatcher's
* semantic 404, because no route was ever mounted for them and the request
* died at Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint`
* branch called a `matchEndpoint` method that no implementation in the repo
* ever provided, so it could not have executed anything even if reached.
* The metadata-authored counterpart of the code-mounted endpoint in
* `src/system/server/recalc-endpoint.ts`. 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
* `POST /automation/<name>/trigger` uses. A declared endpoint is a stable URL
* plus a policy layer over an existing pipeline — never a second execution
* dialect (#5040 §4).
*
* Every key was therefore declared ≠ enforced — `authRequired: true` included,
* which parsed green while gating nothing at all. Per the maintainer verdict
* (2026-08-04, #4936) a non-empty `apis:` is now REJECTED at publish/validate
* with a prescription, so these definitions are commented out rather than
* shipped: an example must never demo a capability the runtime does not
* deliver (Prime Directive #10).
* ## Why they were commented out, and what changed
*
* ## What replaces it today
* #4936 measured this surface on a real boot and found it zero-execution end
* to end: no route was mounted for a declared `path`, the request died at
* Hono's `notFound`, and the dispatcher branch behind it called a
* `matchEndpoint` no implementation in the repo ever provided. Every key was
* declared ≠ enforced — `authRequired: true` included, a security semantic
* that parsed green and gated nothing — so a non-empty `apis:` was refused
* outright and these two definitions were preserved here, commented.
*
* A code-mounted endpoint — see `src/system/server/recalc-endpoint.ts`. That
* is the honest path until the executor lands.
* #5040's E-series built the executor (mount seam, matcher, policy keys,
* target delegation, mapping keys, OpenAPI enrichment) and E7 narrowed the
* blanket refusal to per-endpoint publish gates. So the premise of the comment
* is gone, and these come back **unchanged in intent** — same names, same
* targets, same `authRequired`, same `cacheTtl` — with the ONE edit ADR-0121
* D1 requires: the paths move under this app's namespace carve-out.
*
* ## Restoring these
* ## The namespace carve-out (ADR-0121 D1/D2)
*
* The `ApiEndpoint` vocabulary is deliberately KEPT: the verdict rejected
* retiring it, because endpoint shapes are an industry-stable form that would
* only be re-introduced identically later. When the executor ships (#5040 —
* mounting + endpoint matching + per-key wiring), the rejection is replaced by
* real execution and these two come back. They are kept verbatim for that.
* A declared path must be `<runtime prefix>/apps/<manifest.namespace>/<subpath>`
* — here `/api/v1/apps/showcase/…`, from the explicit `manifest.namespace:
* 'showcase'` in `objectstack.config.ts`. That segment is what makes route
* ownership structural rather than a list somebody maintains: no built-in
* domain lives under `apps/`, and two packages can never collide because their
* namespaces differ. Only the subpath is ours to name; publish rejects
* anything outside the carve-out, and a stack that declares `apis:` without an
* explicit `manifest.namespace` is rejected too (there is deliberately no
* derivation from `manifest.id` — an outward URL contract must not move
* because a package id was rewritten).
*
* ⚠️ ONE edit is required when restoring them, and it is not cosmetic:
* ADR-0121 D1 (accepted 2026-08-04, after these were commented out) namespaces
* endpoint paths as `<runtime-prefix>/apps/<namespace>/<subpath>`, so that an
* app can only claim its own namespace and can never collide with a built-in
* domain or another installed package. The `path` values below predate that
* rule and would be rejected under it. Per that ADR they return as
* `/api/v1/apps/showcase/tasks` and `/api/v1/apps/showcase/inquiries/purge`
* (its §D1 names them explicitly, restored by #5040 E8).
* ## `authRequired` is deliberate here, not incidental
*
* Both endpoints are 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
* endpoints was ever anonymous, and restoring them did not make one anonymous:
* an example must not demo an anonymous surface it never had.
*
* Proven end to end, on a real boot, by
* `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`.
*/

// /** Read-only data projection: GET a filtered task list through a stable URL. */
// export const TaskFeedEndpoint: ApiEndpoint = {
// name: 'showcase_task_feed',
// path: '/api/v1/showcase/tasks',
// method: 'GET',
// summary: 'Task feed',
// description: 'Returns tasks via a declarative object_operation endpoint — no handler code.',
// type: 'object_operation',
// target: 'showcase_task',
// objectParams: {
// object: 'showcase_task',
// operation: 'find',
// },
// authRequired: true,
// cacheTtl: 30,
// };
//
// /** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */
// export const InquiryPurgeEndpoint: ApiEndpoint = {
// name: 'showcase_inquiry_purge_api',
// path: '/api/v1/showcase/inquiries/purge',
// method: 'POST',
// summary: 'Purge closed inquiries',
// description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.',
// type: 'flow',
// target: 'showcase_inquiry_purge',
// authRequired: true,
// };
/** Read-only data projection: GET a filtered task list through a stable URL. */
export const TaskFeedEndpoint: ApiEndpoint = {
name: 'showcase_task_feed',
// ADR-0121 D1: was `/api/v1/showcase/tasks` before #4936; the namespace
// segment is the only change this restoration makes to the declaration.
path: '/api/v1/apps/showcase/tasks',
method: 'GET',
summary: 'Task feed',
description: 'Returns tasks via a declarative object_operation endpoint — no handler code.',
type: 'object_operation',
target: 'showcase_task',
objectParams: {
object: 'showcase_task',
operation: 'find',
},
authRequired: true,
// Seconds. Emitted as `Cache-Control: public, max-age=30` on a SUCCESSFUL
// answer only — never on a 401/429/5xx, because telling a client to reuse a
// failure for half a minute is worse than saying nothing. GET-only by rule:
// publish rejects `cacheTtl` on any other method rather than parsing it and
// ignoring it.
cacheTtl: 30,
};

/**
* Empty on purpose (#4936). An empty array and an absent key are both still
* accepted — only a NON-EMPTY `apis:` is rejected — so the stack keeps
* exercising the accepted shape rather than dropping the wiring entirely.
*/
export const allApis: ApiEndpoint[] = [];
/** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */
export const InquiryPurgeEndpoint: ApiEndpoint = {
name: 'showcase_inquiry_purge_api',
path: '/api/v1/apps/showcase/inquiries/purge',
method: 'POST',
summary: 'Purge closed inquiries',
description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.',
type: 'flow',
target: 'showcase_inquiry_purge',
authRequired: true,
};

export const allApis: ApiEndpoint[] = [TaskFeedEndpoint, InquiryPurgeEndpoint];
Loading
Loading