From c5d23ae3d5ecbdb667a51c4261104c1c5d8a7e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:47:36 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(e2e,showcase):=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=99=A8=E6=94=B6=E5=AE=98=E9=AA=8C=E6=94=B6=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20showcase=20=E7=AB=AF=E7=82=B9=E5=9B=9E=E8=BF=81=20+?= =?UTF-8?q?=20=E7=9C=9F=E5=AE=9E=20boot=20e2e=20(#5040=20E8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4936 把 showcase 的两条 `apis:` 声明注释掉,理由是当时端点面全链路零执行:没有 挂载、没有匹配器、每个键(包括 `authRequired`)解析通过而不生效。E1–E7 建成执行器 并把整面硬拒收窄为逐端点 publish 门之后,那条理由不复存在。本单把两条声明按原意 恢复 —— 同名、同 target、同 `authRequired`、同 `cacheTtl` —— 只做 ADR-0121 D1 要求的一处修改:路径迁入本应用的命名空间保留区 `/api/v1/apps/showcase/…`。 - examples/app-showcase:恢复 TaskFeedEndpoint / InquiryPurgeEndpoint;coverage 的 `apis` 从 waived 翻回 demonstrated(理由改写为「由真实 boot 测量」而不是 「声明即证明」—— 这正是 #4936 抓到的那类假覆盖);gap-fill 的反向断言改回正向, 并新增命名空间与「零匿名面」两条静态钉子。 - packages/qa/dogfood:两个真实 boot e2e。showcase 一份走真实 artifact 摄入路径 (MetadataPlugin + local-file),证明匹配命中执行、find 的 data 与内建 /data 路由逐字节相同、匿名 401、cacheTtl 只随成功答案、挂载点下未声明路径与挂载点外 的裸 404 完全一致、/meta/api 与 /openapi.json 描述的正是挂载的东西;fixture 一份补上 ADR-0121 D6 的匿名分支 —— 省略 authRequired 拒绝匿名、显式 false 服务 匿名、已装配预算耗尽后 429 且 Retry-After 真的在线上。 RED-first:两份断言先于 showcase 回迁写就并运行,pre-restore 状态下 9 条失败(全部 是 #4936 记录的那个裸 404),回迁后 14 + 7 全绿。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- examples/app-showcase/objectstack.config.ts | 15 +- examples/app-showcase/src/coverage.ts | 8 +- .../app-showcase/src/system/apis/index.ts | 152 ++++---- examples/app-showcase/test/gap-fill.test.ts | 101 ++++-- packages/qa/dogfood/package.json | 1 + ...eclarative-endpoint-policy.dogfood.test.ts | 146 ++++++++ .../test/fixtures/endpoint-policy-fixture.ts | 91 +++++ ...case-declarative-endpoints.dogfood.test.ts | 325 ++++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 736 insertions(+), 106 deletions(-) create mode 100644 packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts create mode 100644 packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 90de4b659d..2392de916b 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -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//…`, 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 diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index c20f6b8bfd..41cffcb890 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -180,10 +180,10 @@ export const STACK_COLLECTION_COVERAGE: Record = { 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', diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 5306b0540b..c372360fde 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -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//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 `/apps//` + * — 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 `/apps//`, 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]; diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index edbdcf09d6..5736386bb2 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -63,42 +63,89 @@ describe('showcase gap fill — named import mapping (#2611)', () => { }); /** - * [#4936] Declarative api endpoints — the showcase declares NONE, on purpose. + * [#5040 E8 / #5112] Declarative api endpoints — declared again, and now LIVE. * - * This block used to assert the opposite: that `showcase_task_feed` and - * `showcase_inquiry_purge_api` were wired into the stack. They were — and that - * was the problem. The metadata loaded perfectly (`GET /api/v1/meta/api` - * returned both) while a real boot answered a bare 404 on each declared path, - * because no route was ever mounted and the dispatcher branch behind them - * called a `matchEndpoint` that no implementation provided. Every key was - * declared ≠ enforced, `authRequired: true` included. + * The history in three lines, because it is the reason these assertions look + * the way they do. Originally: "both endpoints are wired into the stack" — true, + * and exactly the problem, because the metadata loaded perfectly while a real + * boot answered a bare 404 on each declared path (#4936: nothing mounted them, + * and the dispatcher branch behind them called a `matchEndpoint` no + * implementation provided). Then, for one release: "the stack declares NONE", + * asserting an empty `apis:`, because a non-empty one was refused at publish. + * Now: declared again, with the executor behind them. * - * So the assertion is inverted rather than deleted. A test that merely stopped - * checking would let the endpoints drift back in silently; this one fails if - * they do — which matters because re-adding them is no longer just inert, it - * now breaks `objectstack validate` for the whole example. + * These are STATIC assertions about the declaration — the executable proof that + * the endpoints actually answer lives where it can only be answered, on a real + * boot: `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`. + * Keeping the two apart is deliberate: a static test that claims runtime + * behaviour is what #4936 caught, and no assertion in this file can tell you a + * route is mounted. */ -describe('[#4936] showcase declares no executable-less api endpoints', () => { - it('ships an EMPTY `apis:` — the shape publish/validate still accepts', () => { +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, - 'A non-empty `apis:` is rejected at publish/validate (#4936): the runtime has no ' + - 'executor for declarative endpoints, so declaring one would fail `objectstack ' + - 'validate` AND advertise a capability that does not exist (Prime Directive #10). ' + - 'The two definitions this example used to ship are preserved, commented, in ' + - 'src/system/apis/index.ts — restore them in the same PR that lands the executor (#5040).', - ).toEqual([]); + expect(apis.map((a) => a.name)).toEqual( + expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api']), + ); + }); + + it('declares every path inside this app’s ADR-0121 D1 namespace carve-out', () => { + // The one edit the restoration made to the pre-#4936 declarations, and the + // gate publish enforces: a path outside `/api/v1/apps//` parses + // fine and matches NOTHING, because the endpoint step only ever consults + // declarations under that mount. + const namespace = (stack as { manifest?: { namespace?: string } }).manifest?.namespace; + expect(namespace, '`apis:` requires an explicit manifest.namespace (ADR-0121 D2)').toBe('showcase'); + const apis = (stack as { apis?: Array<{ name: string; path: string }> }).apis ?? []; + for (const api of apis) { + expect(api.path, `endpoint '${api.name}' must live under this app's carve-out`).toMatch( + new RegExp(`^/api/v1/apps/${namespace}/.+`), + ); + } + }); + + it('opens no anonymous surface — every endpoint is session-gated', () => { + // `authRequired` DEFAULTS to true, so this passes for an omitted key too. + // The assertion is about `false`: it is the only thing that opens an + // anonymous execution entry point, and ADR-0121 D6 pairs it with a + // mandatory ARMED rate limit. Neither of these endpoints was ever + // anonymous; an example must not grow a public surface it never had. + const apis = (stack as { apis?: Array<{ name: string; authRequired?: boolean; rateLimit?: { enabled?: boolean } }> }).apis ?? []; + for (const api of apis) { + if (api.authRequired === false) { + expect( + api.rateLimit?.enabled, + `endpoint '${api.name}' is anonymous, so ADR-0121 D6 requires an ARMED rateLimit`, + ).toBe(true); + } + } + expect(apis.filter((a) => a.authRequired === false)).toEqual([]); + }); + + it('flow-typed endpoints target flows that actually exist (no 500 at dispatch)', () => { + const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; + const flowNames = ((stack as { flows?: Array<{ name: string }> }).flows ?? []).map((f) => f.name); + for (const api of apis.filter((a) => a.type === 'flow')) { + expect(flowNames, `api endpoint targets missing flow '${api.target}'`).toContain(api.target); + } + }); + + it('object_operation endpoints target objects that exist', () => { + const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; + const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name); + for (const api of apis.filter((a) => a.type === 'object_operation')) { + expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target); + } }); - it('still demonstrates HTTP endpoints the honest way — in code', () => { - // The replacement path the coverage waiver points at. If this file ever - // disappears the waiver is lying too, so pin it here rather than trusting - // the note alone. (vitest runs with cwd = the package root, as + 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 live HTTP proof", + 'the code-mounted endpoint is the showcase’s other live HTTP proof', ).toBe(true); }); }); diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 61507465b5..8cc14fe640 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -16,6 +16,7 @@ "@objectstack/example-crm": "workspace:*", "@objectstack/example-showcase": "workspace:*", "@objectstack/mcp": "workspace:*", + "@objectstack/metadata": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-audit": "workspace:*", diff --git a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts new file mode 100644 index 0000000000..c820efa959 --- /dev/null +++ b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The declarative-endpoint POLICY matrix, on a real boot (#5040 E8 / #5112). +// +// `showcase-declarative-endpoints.dogfood.test.ts` proves the restored showcase +// endpoints answer, and both of those are session-gated — which is the whole +// truth about the showcase and only half the truth about the policy chain. The +// other half is the anonymous arm of ADR-0121 D6, and it is the arm where a +// mistake is unrecoverable: `authRequired: false` opens a free, unauthenticated +// execution entry point into the data pipeline, and the rate limit is the only +// thing standing behind it. +// +// So this file drives a throwaway fixture app (`fixtures/endpoint-policy- +// fixture.ts`) whose entire purpose is to be probed: +// +// 1. an OMITTED `authRequired` really denies anonymous — the upgrade guide +// tells every maintainer that "an omission is safe", and this is what +// makes that sentence testable rather than reassuring; +// 2. an EXPLICIT `authRequired: false` really serves anonymous — otherwise +// D6's obligation would be protecting nothing; +// 3. the armed budget really meters, on the wire: the (N+1)-th anonymous +// request inside the window gets 429 **with a `Retry-After` header**. A +// 429 that loses that header has told the client nothing it can act on, +// and only a real transport can prove the header survives the response +// write — which is why this is not a unit test. +// +// Pre-condition worth stating: the fixture publishes at all. A stack declaring +// `authRequired: false` WITHOUT `rateLimit.enabled === true` is refused by the +// E7 publish gate, so booting this app is itself a positive assertion that the +// armed form is the accepted one. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { endpointPolicyFixtureStack } from './fixtures/endpoint-policy-fixture.js'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MetadataPlugin } from '@objectstack/metadata'; + +const PUBLIC_FEED = '/apps/e8policy/public-notes'; +const PRIVATE_FEED = '/apps/e8policy/private-notes'; + +let stack: VerifyStack; +let tempDir: string; +let adminToken: string; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'os-e8-policy-')); + const artifactPath = join(tempDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(endpointPolicyFixtureStack)); + + stack = await bootStack(endpointPolicyFixtureStack, { + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + adminToken = await stack.signIn(); +}, 120_000); + +afterAll(async () => { + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('[#5112] authRequired — the default is the protection', () => { + it('an OMITTED authRequired denies anonymous (the schema default is true)', async () => { + const res = await stack.api(PRIVATE_FEED, { method: 'GET' }); + expect( + res.status, + 'the upgrade guide tells maintainers an omitted `authRequired` is SAFE — this is that claim, measured', + ).toBe(401); + }); + + it('the same endpoint serves an authenticated caller', async () => { + const res = await stack.apiAs(adminToken, 'GET', PRIVATE_FEED); + expect(res.status, await res.clone().text()).toBe(200); + }); +}); + +describe('[#5112] ADR-0121 D6 — anonymous is served, and metered', () => { + it('an EXPLICIT authRequired:false serves an anonymous caller', async () => { + const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as { success?: boolean }; + expect(body.success).toBe(true); + }); + + it('carries the declared cacheTtl on the anonymous success', async () => { + const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); + expect(res.headers.get('cache-control')).toMatch(/max-age=15/); + }); + + it('answers 429 WITH Retry-After once the armed budget is exhausted', async () => { + // The declared budget is 2 requests / 60s. One was already spent by each of + // the two cases above, so the very next anonymous call is over budget — + // but do not rely on that arithmetic: drive until the limiter speaks, with + // a hard cap so a limiter that never fires fails loudly instead of hanging. + let limited: Response | undefined; + for (let i = 0; i < 10 && !limited; i++) { + const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); + if (res.status === 429) limited = res; + } + + expect( + limited, + 'an anonymous endpoint declaring `rateLimit: { enabled: true, maxRequests: 2 }` must actually ' + + 'refuse the over-budget request — an unmetered anonymous execution entry point is what ' + + 'ADR-0121 D6 exists to prevent', + ).toBeDefined(); + expect( + limited!.headers.get('retry-after'), + 'a 429 that loses its Retry-After has told the client nothing it can act on', + ).toBeTruthy(); + expect(Number(limited!.headers.get('retry-after'))).toBeGreaterThan(0); + + const body = (await limited!.json()) as { success?: boolean }; + expect(body.success).toBe(false); + }); + + it('never puts a cache directive on the 429', async () => { + let limited: Response | undefined; + for (let i = 0; i < 10 && !limited; i++) { + const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); + if (res.status === 429) limited = res; + } + expect(limited).toBeDefined(); + expect( + limited!.headers.get('cache-control') ?? '', + 'caching a throttle answer for 15s would make the throttle stick past its own window', + ).not.toMatch(/max-age=15/); + }); + + it('the metered budget is per-ENDPOINT, not global — the session-gated one still answers', async () => { + const res = await stack.apiAs(adminToken, 'GET', PRIVATE_FEED); + expect( + res.status, + 'exhausting one endpoint’s budget must not take down another endpoint of the same stack', + ).toBe(200); + }); +}); diff --git a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts new file mode 100644 index 0000000000..8e4b80fedc --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Declarative-endpoint POLICY fixture (#5040 E8 / #5112). +// +// Why a fixture and not the showcase: the two endpoints the showcase restores +// are the two it historically declared, and BOTH were session-gated. Growing +// an anonymous, rate-limited surface on a published example just to cover the +// policy matrix would be exactly the move Prime Directive #10 forbids in the +// other direction — demoing a shape the example never meant to have. So the +// anonymous half of ADR-0121 D6 gets its own throwaway app, whose entire +// reason to exist is to be probed. +// +// What it declares, and why each key is here: +// +// • `authRequired: false` — the ONLY thing that opens an anonymous execution +// entry point. Its schema default is `true`, so this is a deliberate, +// visible act; the upgrade guide tells maintainers to grep for exactly this +// literal before upgrading. +// • `rateLimit: { enabled: true, … }` — D6's paired obligation. `enabled` +// defaults to `false`, so a budget written without it meters NOTHING while +// satisfying the letter of "declares a rateLimit"; publish rejects the +// anonymous-without-armed-budget combination for that reason. The budget is +// deliberately tiny (2 requests per minute) so a test can exhaust it without +// sleeping. +// • `cacheTtl` on the GET — proves the success-only `Cache-Control` on a +// second, independent stack. +// +// The paths sit under `/api/v1/apps/e8policy/…` because ADR-0121 D1 confines a +// declared path to the stack's own namespace carve-out, and D2 requires that +// namespace to be declared EXPLICITLY (there is no derivation from +// `manifest.id`). A path outside the carve-out is refused at publish. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { ApiEndpoint } from '@objectstack/spec/api'; + +/** One object, so the `object_operation` endpoints have something real to read. */ +export const PolicyNote = ObjectSchema.create({ + name: 'e8policy_note', + // [ADR-0090 D1] grandfather stamp: the gate under test is the endpoint + // policy chain, not record sharing. + sharingModel: 'public_read_write', + label: 'Policy Note', + pluralLabel: 'Policy Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +/** Anonymous by declaration, metered by obligation (ADR-0121 D6). */ +export const AnonymousMeteredEndpoint: ApiEndpoint = { + name: 'e8policy_public_notes', + path: '/api/v1/apps/e8policy/public-notes', + method: 'GET', + summary: 'Public note feed', + description: 'Anonymous, rate-limited object_operation endpoint (ADR-0121 D6).', + type: 'object_operation', + target: 'e8policy_note', + objectParams: { object: 'e8policy_note', operation: 'find' }, + authRequired: false, + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 2 }, + cacheTtl: 15, +}; + +/** The control: same object, same operation, session-gated, unmetered. */ +export const SessionGatedEndpoint: ApiEndpoint = { + name: 'e8policy_private_notes', + path: '/api/v1/apps/e8policy/private-notes', + method: 'GET', + summary: 'Session-gated note feed', + description: 'The control for the anonymous endpoint — same target, default authRequired.', + type: 'object_operation', + target: 'e8policy_note', + objectParams: { object: 'e8policy_note', operation: 'find' }, + // `authRequired` deliberately OMITTED: the schema default is `true`, and the + // upgrade guide's central claim is that an omission is safe. This endpoint is + // what makes that claim testable rather than asserted. +}; + +export const endpointPolicyFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.endpoint_policy_fixture', + namespace: 'e8policy', + version: '0.0.0', + type: 'app', + name: 'Endpoint Policy Fixture', + description: 'Single-object app whose declared endpoints exercise the ADR-0121 D6 policy matrix.', + }, + objects: [PolicyNote], + apis: [AnonymousMeteredEndpoint, SessionGatedEndpoint], +}); diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts new file mode 100644 index 0000000000..92ee6e236b --- /dev/null +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ACCEPTANCE for the declarative `apis:` endpoint executor (#5040 E8, #5112). +// +// ## What this file is +// +// The whole #5040 program was built E1–E7 with every unit landing BEHIND a +// publish gate that made it structurally unreachable, so every test written +// along the way drove one link with the next one stubbed — a matcher against a +// fake store, a step against a fake matcher, an executor against a fake +// `callData`. That is the right way to build a chain nobody can reach yet, and +// it is exactly the shape #4936 caught being wrong: `handleApiEndpoint` was +// unit-tested by calling `dispatch()` directly, which is why nobody noticed +// that no route ever mounted the paths it claimed to serve. +// +// So this file stubs NOTHING. It boots the real showcase app, through the real +// artifact ingestion path (`MetadataPlugin` + a `local-file` artifact — the +// same path `objectstack dev` / `serve` take), over the real Hono transport, +// and asks the questions a caller asks: +// +// 1. does a declared endpoint answer at all, and with the SAME body the +// built-in route gives for the same operation (#5040 §4's red line); +// 2. does `authRequired` actually gate (401 for anonymous), rather than +// parsing green and gating nothing as it did before #4936; +// 3. does `cacheTtl` reach the wire as `Cache-Control`; +// 4. does an UNDECLARED path under the mount still answer the transport's +// own bare 404, byte for byte — the seam must cost non-endpoint traffic +// 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). +// +// ## RED-first +// +// Written and run BEFORE `examples/app-showcase` restored its `apis:` block. +// Against the pre-restore stack every endpoint case failed with the bare 404 +// that #4936 recorded — which is the point: the assertions describe behaviour +// that did not exist, and the showcase restoration is what makes them pass. +// +// ## The one thing deliberately NOT asserted +// +// `components.schemas` and `$ref` resolution in the OpenAPI document. That +// document ships six built-in dangling refs today (#5168) and this program did +// not introduce them; asserting on them here would make an unrelated pre- +// existing defect fail the executor's acceptance. Scope is the endpoint PATH +// entries, which are this program's output. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import showcaseStack from '@objectstack/example-showcase'; + +/** The ADR-0121 D1 mount, spelled here as a caller would type it. */ +const TASKS = '/apps/showcase/tasks'; +const PURGE = '/apps/showcase/inquiries/purge'; + +/** Two negative controls: undeclared UNDER the mount, and off the mount entirely. */ +const UNDECLARED_UNDER_MOUNT = '/apps/showcase/no-such-endpoint-e8'; +const OFF_MOUNT = '/no-such-route-e8'; + +/** + * The showcase's declarative connectors resolve their file-path specs and stdio + * commands relative to the APP ROOT, exactly as `os dev`/`serve` run them — so + * the boot chdirs there for its duration (vitest gives each test FILE its own + * process, so this is isolated). Same note as + * `showcase-declarative-mcp.dogfood.test.ts`, which boots the same way. + */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +let tempDir: string; +let prevCwd: string; +let stack: VerifyStack; +let adminToken: string; + +/** + * Boot the showcase the way a deployment boots it. + * + * `@objectstack/verify`'s lean harness composes no `MetadataPlugin` — its + * proofs are data/authorization ones that read metadata straight off the + * config. A declared endpoint cannot be reached without one: `matchEndpoint` + * is `IMetadataService`'s member, and `NodeMetadataManager` is its only + * implementation. So this file supplies it through the harness's `extraPlugins` + * seam, pointed at an artifact written from the showcase's own stack + * definition — the SAME `artifactSource: { mode: 'local-file' }` ingestion + * `createStandaloneStack` uses, which is what maps `apis:` → `api` metadata + * items (`ARTIFACT_FIELD_TO_TYPE` in `packages/metadata/src/plugin.ts`). + * + * Writing the artifact through `ObjectStackDefinitionSchema` — which the + * ingest performs on load — means this boot ALSO re-proves the E7 publish + * gates accept the restored declarations. A declaration that could not publish + * could not be ingested here either. + */ +beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + tempDir = mkdtempSync(join(tmpdir(), 'os-e8-endpoints-')); + const artifactPath = join(tempDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(showcaseStack)); + + stack = await bootStack(showcaseStack, { + // The `flow`-typed endpoint delegates to `IAutomationService.execute`; + // without the service the honest answer is a 501, not a flow run. + automation: true, + extraPlugins: [ + // The showcase declares `connectors:` bound to these providers; the + // automation service refuses to start without their factories (ADR-0097), + // exactly as `objectstack dev` would. + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + adminToken = await stack.signIn(); +}, 120_000); + +afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +// ============================================================================ +// 1. The declarations are ingested and visible on the metadata face +// ============================================================================ + +describe('[#5112] the showcase declares endpoints again, and the store holds them', () => { + it('GET /meta/api returns both restored declarations', async () => { + const res = await stack.apiAs(adminToken, 'GET', '/meta/api'); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as { data?: unknown; items?: unknown }; + const items = (Array.isArray((body as any).data) ? (body as any).data + : Array.isArray((body as any).items) ? (body as any).items + : Array.isArray((body as any).data?.items) ? (body as any).data.items + : []) as Array>; + const byName = new Map(items.map((i) => [String(i.name), i])); + + expect( + [...byName.keys()], + 'the two endpoints #4936 commented out are declared again', + ).toEqual(expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api'])); + + // ADR-0121 D1: the namespace carve-out is not decoration — it is the + // reason an app can never claim a built-in domain or a sibling's URL. + for (const name of ['showcase_task_feed', 'showcase_inquiry_purge_api']) { + expect(String(byName.get(name)?.path)).toMatch(/^\/api\/v1\/apps\/showcase\//); + } + }); +}); + +// ============================================================================ +// 2. object_operation — match, execute, and answer what /data answers +// ============================================================================ + +describe('[#5112] object_operation endpoint: same pipeline, same answer', () => { + it('answers 200 for an authenticated caller', async () => { + const res = await stack.apiAs(adminToken, 'GET', TASKS); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as { success?: boolean; data?: unknown }; + expect(body.success, 'the declared endpoint uses the platform success envelope').toBe(true); + expect(body.data, 'a find endpoint answers with data').toBeDefined(); + }); + + it('answers EXACTLY what the built-in /data route answers (#5040 §4)', async () => { + // The ruling this whole program is built on: a declared endpoint is a + // stable URL plus a policy layer over an EXISTING pipeline, never a second + // execution dialect. Two pipelines for one operation drift; this assertion + // is what makes the claim falsifiable. + // + // The comparison is payload-to-payload, and that asymmetry is real rather + // than a convenience: `packages/rest` — the sole owner of + // `/api/v1/data/:object` — serializes the pipeline's payload BARE, while + // the endpoint executor wraps the identical payload in the platform's + // `{ success, data, meta }` envelope (`successAnswer`, mirroring + // `HttpDispatcher.success`). Same records, same totals, same field values, + // one wrapper apart. Asserting `a.data === b` states exactly that, and + // fails the moment the two pipelines disagree about the data itself. + const viaEndpoint = await stack.apiAs(adminToken, 'GET', TASKS); + const viaBuiltin = await stack.apiAs(adminToken, 'GET', '/data/showcase_task'); + expect(viaBuiltin.status).toBe(200); + const a = (await viaEndpoint.json()) as { data?: unknown }; + const b = await viaBuiltin.json(); + expect(a.data).toEqual(b); + }); + + it('carries the declared cacheTtl as a Cache-Control header', async () => { + const res = await stack.apiAs(adminToken, 'GET', TASKS); + expect(res.headers.get('cache-control'), 'cacheTtl: 30 must reach the wire').toMatch(/max-age=30/); + }); + + it('DENIES an anonymous caller with 401 — authRequired finally gates', async () => { + // The security semantic #4936 found parsing green and enforcing nothing. + const res = await stack.api(TASKS, { method: 'GET' }); + expect(res.status, await res.clone().text()).toBe(401); + const body = (await res.json()) as { success?: boolean; error?: unknown }; + expect(body.success).toBe(false); + }); + + it('never lets an error answer carry a cache directive', async () => { + const res = await stack.api(TASKS, { method: 'GET' }); + expect(res.status).toBe(401); + expect( + res.headers.get('cache-control') ?? '', + 'telling a client to cache a 401 for 30s is worse than saying nothing', + ).not.toMatch(/max-age=30/); + }); +}); + +// ============================================================================ +// 3. flow — match, execute through the automation service +// ============================================================================ + +describe('[#5112] flow endpoint: invokes the real flow over HTTP', () => { + it('answers 200 for an authenticated caller and runs the janitor flow', async () => { + const res = await stack.apiAs(adminToken, 'POST', PURGE, {}); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as { success?: boolean; data?: unknown }; + expect(body.success).toBe(true); + expect(body.data, 'the flow run result is the endpoint answer').toBeDefined(); + }); + + it('DENIES an anonymous caller with 401', async () => { + const res = await stack.api(PURGE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + expect(res.status, await res.clone().text()).toBe(401); + }); + + it('does not answer a flow endpoint on the wrong method', async () => { + // GET on a POST-only declaration is a MISS, not a 405: nothing mounted a + // route for this path, so the transport's own unmatched answer stands. + const res = await stack.apiAs(adminToken, 'GET', PURGE); + expect(res.status).toBe(404); + }); +}); + +// ============================================================================ +// 4. Negative controls — the seam costs unmatched traffic nothing (#5090) +// ============================================================================ + +describe('[#5112] undeclared paths keep the transport’s own answer, byte for byte', () => { + async function bare(path: string) { + const res = await stack.apiAs(adminToken, 'GET', path); + return { status: res.status, text: await res.text() }; + } + + it('an undeclared path UNDER the endpoint mount is the same bare 404 as one off it', async () => { + const underMount = await bare(UNDECLARED_UNDER_MOUNT); + const offMount = await bare(OFF_MOUNT); + expect(underMount.status).toBe(404); + expect(offMount.status).toBe(404); + expect( + underMount.text, + 'the endpoint step writes NOTHING on a miss — a declared-endpoint mount must not ' + + 'turn a 404 into a different 404 for everybody else (#5090)', + ).toBe(offMount.text); + }); + + it('a declared path on the wrong METHOD is the same bare 404', async () => { + const res = await stack.apiAs(adminToken, 'POST', TASKS, {}); + const offMount = await bare(OFF_MOUNT); + expect(res.status).toBe(404); + expect(await res.text()).toBe(offMount.text); + }); +}); + +// ============================================================================ +// 5. The documentation face describes exactly what is mounted (ADR-0076 §4) +// ============================================================================ + +describe('[#5112] /openapi.json carries the declared endpoints (#5040 E6)', () => { + let paths: Record; + + beforeAll(async () => { + const res = await stack.apiAs(adminToken, 'GET', '/openapi.json'); + expect(res.status).toBe(200); + const doc = (await res.json()) as { paths?: Record }; + paths = doc.paths ?? {}; + }); + + it('documents the object_operation endpoint under its declared path', () => { + const entry = paths['/api/v1/apps/showcase/tasks']; + expect(entry, 'the declared path must appear verbatim in `paths`').toBeDefined(); + expect(entry.get.operationId).toBe('showcase_task_feed'); + expect(entry.get.summary).toBe('Task feed'); + // `authRequired: true` → the document's own security requirement, never an + // invented scheme name. + expect(entry.get.security, 'a session-gated endpoint must not read as public').toBeDefined(); + expect(entry.get.security).not.toEqual([]); + expect(entry.get.responses['200']).toBeDefined(); + expect(entry.get.responses['401']).toBeDefined(); + }); + + it('documents the flow endpoint under its declared path', () => { + const entry = paths['/api/v1/apps/showcase/inquiries/purge']; + expect(entry).toBeDefined(); + expect(entry.post.operationId).toBe('showcase_inquiry_purge_api'); + expect(entry.post.summary).toBe('Purge closed inquiries'); + expect(entry.post.requestBody, 'a flow endpoint reads the body as flow input').toBeDefined(); + expect(entry.post.security).not.toEqual([]); + }); + + it('does not document an endpoint the runtime would not serve', () => { + // The document is generated from the same stored items the matcher reads, + // so anything it names must be reachable. Prove the converse direction on + // the one path we know is undeclared. + expect(paths['/api/v1/apps/showcase/no-such-endpoint-e8']).toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523ea07444..ff44ec219c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1676,6 +1676,9 @@ importers: '@objectstack/mcp': specifier: workspace:* version: link:../../mcp + '@objectstack/metadata': + specifier: workspace:* + version: link:../../metadata '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From def584c1a024a708539d8541e780197ce7e11608 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:08:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(e2e):=20=E7=9B=B4=E5=86=99=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E6=8E=A2=E9=92=88=E6=8E=A5=E4=B8=8A=20E7b,fixture=20?= =?UTF-8?q?=E7=94=A8=20ApiEndpointInput=20=E8=A1=A8=E8=BE=BE=E3=80=8C?= =?UTF-8?q?=E7=9C=81=E7=95=A5=20authRequired=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E7b(#5189/#5203)在 main 上落地之后,「一次绕过 publish 的直写不会变成活路由」 从待办 TODO 变成可以真实断言的事:往 store 里直接写一条 `authRequired: false` 且没有已装配预算的 api 条目(D6 最危险的那个形状),两种调用者都必须拿到 404 —— 不是「因为匿名所以 401」,而是压根没有这条路由。 同时把 fixture 里那条「故意省略 authRequired」的声明改标 `ApiEndpointInput`: `ApiEndpoint` 是 schema 的输出类型,`.default(true)` 已被物化,该键在上面是必填, 所以用它做注解的 TS 作者根本写不出升级指南推荐的那个安全形状(另立 #5227 记录)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- ...showcase-declarative-endpoints-restored.md | 23 ++++++++++ ...eclarative-endpoint-policy.dogfood.test.ts | 46 +++++++++++++++++++ .../test/fixtures/endpoint-policy-fixture.ts | 10 +++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .changeset/showcase-declarative-endpoints-restored.md diff --git a/.changeset/showcase-declarative-endpoints-restored.md b/.changeset/showcase-declarative-endpoints-restored.md new file mode 100644 index 0000000000..c1dcf81d54 --- /dev/null +++ b/.changeset/showcase-declarative-endpoints-restored.md @@ -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` 真的在线上。 diff --git a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts index c820efa959..42e593deeb 100644 --- a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts +++ b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts @@ -144,3 +144,49 @@ describe('[#5112] ADR-0121 D6 — anonymous is served, and metered', () => { ).toBe(200); }); }); + +// ============================================================================ +// The second door — a direct metadata write is not a publish (#5189 / E7b) +// ============================================================================ + +describe('[#5112] an ungated direct write never becomes a live route (#5040 E7b)', () => { + it('excludes an api item stored WITHOUT passing the publish gates', async () => { + // E7 hung the five per-endpoint gates on `ObjectStackDefinitionSchema`, + // which covers every path that parses a STACK — and a stored `api` item + // need never have been part of one. #5189 closed that with a second door + // at load time, and D6 is the reason it had to exist: the runtime honours + // `authRequired: false` faithfully and builds no bucket for a budget whose + // `enabled` is not `true`, so a bypassed D6 mints an anonymous, ZERO-QUOTA + // execution entry point — the exact shape D6 forbids. + // + // So: write one straight into the store, bypassing publish entirely, in the + // shape that would be most dangerous if served, and assert the route stays + // dead. This is the one case in this file that cannot be expressed as a + // fixture declaration, because a fixture goes through publish by + // construction. + const metadata = await stack.kernel.getServiceAsync<{ + register(type: string, name: string, item: unknown): Promise; + }>('metadata'); + await metadata.register('api', 'e8policy_backdoor', { + name: 'e8policy_backdoor', + path: '/api/v1/apps/e8policy/backdoor', + method: 'GET', + type: 'object_operation', + target: 'e8policy_note', + objectParams: { object: 'e8policy_note', operation: 'find' }, + // The D6 violation, stated plainly: anonymous, and no armed budget. + authRequired: false, + }); + + const anon = await stack.api('/apps/e8policy/backdoor', { method: 'GET' }); + expect( + anon.status, + 'an anonymous, unmetered endpoint that never passed publish must answer 404, not data', + ).toBe(404); + + // And not merely "401 because anonymous" — it must be UNROUTED, identical + // to any other undeclared path, so no authenticated caller reaches it either. + const authed = await stack.apiAs(adminToken, 'GET', '/apps/e8policy/backdoor'); + expect(authed.status).toBe(404); + }); +}); diff --git a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts index 8e4b80fedc..5e95c9a650 100644 --- a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts @@ -32,7 +32,7 @@ import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; -import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpoint, ApiEndpointInput } from '@objectstack/spec/api'; /** One object, so the `object_operation` endpoints have something real to read. */ export const PolicyNote = ObjectSchema.create({ @@ -63,7 +63,7 @@ export const AnonymousMeteredEndpoint: ApiEndpoint = { }; /** The control: same object, same operation, session-gated, unmetered. */ -export const SessionGatedEndpoint: ApiEndpoint = { +export const SessionGatedEndpoint: ApiEndpointInput = { name: 'e8policy_private_notes', path: '/api/v1/apps/e8policy/private-notes', method: 'GET', @@ -75,6 +75,12 @@ export const SessionGatedEndpoint: ApiEndpoint = { // `authRequired` deliberately OMITTED: the schema default is `true`, and the // upgrade guide's central claim is that an omission is safe. This endpoint is // what makes that claim testable rather than asserted. + // + // Typed `ApiEndpointInput` rather than `ApiEndpoint` for exactly that reason: + // `ApiEndpoint` is the schema's OUTPUT type, where `.default(true)` has + // already been materialised and `authRequired` is therefore REQUIRED. A TS + // author who annotates `: ApiEndpoint` cannot express the omission at all — + // the safe-by-default shape is only writable through the input type. }; export const endpointPolicyFixtureStack = defineStack({