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
80 changes: 80 additions & 0 deletions .changeset/api-metadata-type-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
"@objectstack/spec": minor
---

feat(spec): `api` is a declared metadata kind — `DEFAULT_METADATA_TYPE_REGISTRY` + `BUILTIN_METADATA_TYPE_SCHEMAS` (#5271, part of #5206)

`api` items were produced, indexed and executed while the spec declared the kind
nowhere. Artifact ingest maps `defineStack({ apis })` to `api` metadata
(`ARTIFACT_FIELD_TO_TYPE`), the endpoint matcher indexes them
(`buildEndpointIndex`), and #5040's executor serves them — but
`DEFAULT_METADATA_TYPE_REGISTRY` had no `{ type: 'api', … }` entry and
`BUILTIN_METADATA_TYPE_SCHEMAS` had no `api` binding. So
`getMetadataTypeSchema('api')` returned `undefined` and `saveMetaItem` took its
documented "unregistered type is stored without validation" branch:
`PUT /api/v1/meta/api/:name` accepted **any JSON** and answered 200. That is
`declared ≠ enforced` read backwards — enforced but **undeclared**.

Both halves are now declared, which is one fix with two faces:

- **A body is validated.** The existing 422 `invalid_metadata` path applies to
`api` like every other kind, with structured Zod issues naming the offending
key. An endpoint with no `target`, or no `type`, is refused instead of stored.
- **The type is describable.** `/meta/types` emits a real JSON Schema and a
create seed for `api`, so the metadata-admin engine renders a form rather than
a raw-JSON textarea, and the entry carries a real label, domain and file
patterns instead of the synthesised `label: 'api'`, `filePatterns: []`
placeholder a type with no registry row gets.

**The write door is unchanged.** `allowRuntimeCreate: true` records what the
runtime already did: with no static registry entry, both write gates
(`isRuntimeCreateAllowed`, `assertAllowed`) fall through to "runtime-creatable",
and both name `api` in that comment. `allowOrgOverride` stays `false`, also its
effective value today — an endpoint is the publishing package's outward URL
contract, and a per-org fork could move `path`, flip `authRequired` or drop
`rateLimit` on a URL third parties integrate against. Marking the type code-only
instead (`allowRuntimeCreate: false` + `allowOrgOverride: false`) was considered
and rejected: it would turn today's 200 into a 403 rather than validate it, and
#5086's refusal runs before persistence for drafts too, which would leave
#5206 step 2's `publishPackageDrafts` endpoint gate with no draft to gate.

**`ApiEndpointSchema` gains the ADR-0010 protection envelope, and stays open to
unknown keys.** Every registered kind must declare the envelope its loader
stamps (`_packageId` / `_provenance`), or it is dropped on every parse; that
spread is added. Closing the shape against unknown keys was attempted and
**measured to be unsafe**: the same schema parses stored rows as well as
authored declarations (`buildEndpointIndex`, `gateApiItemsForPublish`), and a
stored row carries the metadata layer's own bookkeeping (`packageId`, `state`),
so `strictObject` turned 10 tests in `packages/metadata` red — the load-time
backstop excluded endpoints and the publish gate reported a schema error in
place of its ADR-0121 D6 verdict. `api` therefore joins `view` on the #4001
campaign's `STILL_STRIP` list, with that measurement written into the list's own
note, and the real fix (separating the stored envelope from the body at the
metadata layer) is filed as #5309 rather than bought by teaching the authoring
vocabulary two storage keys.

**This is a shape check, not a second servability judge.** ADR-0121's rules —
the `apps/<namespace>` carve-out (D1/D2), anonymous-requires-an-armed-`rateLimit`
(D6), the supported target subset, mapping and policy — stay with
`validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure`, which run
at publish and again at load. A pin test asserts an anonymous unmetered endpoint
parses green here and is still refused by the gate, so the two never grow
competing opinions.

**Upgrade note (not purely additive).** A stored `api` row that does not satisfy
`ApiEndpointSchema` is refused with 422 on its **next write**; reads and the
existing load-time behaviour are unchanged (the matcher already excluded
unparseable rows loudly, #5189). Every `api` declaration reachable in this repo
— the two E8-migrated showcase endpoints and the two dogfood policy-fixture
endpoints — was parsed against `ApiEndpointSchema` before landing this: all four
clean. A live deployment's `sys_metadata` cannot be scanned from CI; an operator
holding hand-written `api` rows should run `GET /api/v1/meta/diagnostics?type=api`
(which now covers the type) before upgrading.

ADR-0088's admission test is satisfied on all three clauses: independent
lifecycle (the matcher indexes and invalidates one item at a time), declarative
governability (`allowRuntimeCreate` plus file patterns), and a real consumer
(#5040's executor, boot-proven by #5040 E8). This does not reverse the `router`
kind's retirement — `router`'s delivered forms are code contributions, whereas a
single `ApiEndpoint` is a declarative artifact, exactly the "third, real
delivered form" ADR-0088's own `router` row anticipated.
7 changes: 7 additions & 0 deletions content/docs/references/api/endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ const result = ApiEndpointSchema.parse(data);
| **authRequired** | `boolean` | ✅ | Require authentication |
| **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Rate limiting policy |
| **cacheTtl** | `number` | optional | Response cache TTL in seconds |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
| **_packageId** | `string` | optional | Owning package machine id. |
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -376,7 +376,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const result = MetadataBulkResultSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -202,6 +202,7 @@ const result = MetadataBulkResultSchema.parse(data);
* `datasource`
* `external_catalog`
* `translation`
* `api`
* `email_template`
* `doc`
* `book`
Expand All @@ -220,7 +221,7 @@ const result = MetadataBulkResultSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type identifier |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type identifier |
| **label** | `string` | ✅ | Display label for the metadata type |
| **description** | `string` | optional | Description of the metadata type |
| **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type |
Expand Down
21 changes: 15 additions & 6 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
'PERMANENT by design (ADR-0088): a runtime-created snapshot produced by Setup → Datasources → Sync (ADR-0062). A package shipping one would be stale on arrival; the showcase demos the federation flow that produces it.',
issue: ISSUE.noAuthoringSurface,
},
// [#5271] `api` graduated from STACK_COLLECTION_COVERAGE into the registry:
// it is now a real metadata kind (`DEFAULT_METADATA_TYPE_REGISTRY` +
// `BUILTIN_METADATA_TYPE_SCHEMAS`), so its coverage is owned here. The notes
// below moved verbatim from the old `STACK_COLLECTION_COVERAGE.apis` entry —
// the proof did not change, only which manifest is responsible for it.
api: {
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 retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.',
},
translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] },
email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] },
doc: {
Expand Down Expand Up @@ -179,12 +190,10 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
files: ['src/data/extensions/account.extension.ts'],
notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).',
},
apis: {
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.',
},
// `apis` is NOT listed here any more: as of #5271 it is a registry kind, so
// its coverage lives in `KIND_COVERAGE.api` above. Leaving a duplicate row in
// this manifest — whose contract is "stack collections that are NOT registry
// kinds" — would mean two places to update and one of them silently wrong.
connectors: {
status: 'demonstrated',
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,30 @@ describe('publishPackageDrafts — the ADR-0121 endpoint publish gate (#5206 ste
const { engine, rows } = makeStubEngine('showcase');
const protocol = new ObjectStackProtocolImplementation(engine);

// `api` has no entry in BUILTIN_METADATA_TYPE_SCHEMAS (that half is
// #5271, the spec lane), so the direct-write path stores arbitrary JSON
// verbatim. Parsing is the gate's precondition: an unparseable
// declaration cannot be judged and could never be served either.
await saveApiDraft(protocol, 'garbage', { name: 'garbage', totally: 'not an endpoint' });
// [#5271] This comment used to read "`api` has no entry in
// BUILTIN_METADATA_TYPE_SCHEMAS (that half is #5271, the spec lane), so
// the direct-write path stores arbitrary JSON verbatim" — and it minted
// the garbage draft through `saveMetaItem`. That half has now landed:
// `api` resolves `ApiEndpointSchema`, so this body is refused with a
// 422 at the EARLIEST door and the draft can no longer be created at
// all. That is the "一处修,两面得" outcome #5206 asked for, and it is
// asserted on the spec lane's side (packages/objectql
// /src/protocol-meta.test.ts, "refuses a spec-INVALID `api` item").
//
// The `ENDPOINT_SCHEMA` branch this case pins is therefore no longer
// reachable from the Studio write path — it is exactly what the module
// header calls it, a BACKSTOP, for a row that reached the store some
// other way: a direct `metadata.register()`, a migration, or a row
// written before #5271. Deleting the case would leave a live branch
// with no test; re-spelling the body would only re-test the 422. So the
// fixture PLANTS such a row instead of minting one — it saves a valid
// draft through the real write path (so every bookkeeping column is
// byte-for-byte what production writes) and then corrupts only the
// stored body, which is the one thing the earlier door cannot police.
await saveApiDraft(protocol, 'garbage', validEndpoint({ name: 'garbage' }));
const planted = Array.from(rows.values()).find((r) => r.name === 'garbage' && r.state === 'draft');
expect(planted, 'the valid draft must exist before it is corrupted').toBeDefined();
planted!.metadata = JSON.stringify({ name: 'garbage', totally: 'not an endpoint' });

const res = await protocol.publishPackageDrafts({ packageId: PKG });
expect(res).toMatchObject({ success: false, publishedCount: 0 });
Expand Down
Loading
Loading