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
64 changes: 64 additions & 0 deletions .changeset/capability-metadata-kind-registry-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": patch
---

fix(spec,metadata-protocol): `capability` 补齐三处注册 —— 授权面不再接受任意 JSON (#5961)

`capability` 是「enforced but undeclared」——#5271 给 `api` 关掉的那个
`declared ≠ enforced` 的镜像。平台早就把它当成一个 metadata kind 在用:
`PLURAL_TO_SINGULAR` 从 #5870 起就有 `capabilities` → `capability`,
`AppPlugin` 用这个名字注册 stack 声明的 capability,
`bootstrapDeclaredCapabilities` 再读回来 seed `sys_capability`。但三处注册表
里都没有它:`MetadataTypeSchema`(kind 枚举)、`BUILTIN_METADATA_TYPE_SCHEMAS`
(schema 解析)、`DEFAULT_METADATA_TYPE_REGISTRY`(谁可以写、怎么加载)。

后果有两条,第二条才是这个 issue 属于授权缺陷而非整洁度问题的原因:

- `getMetadataTypeSchema('capability')` 返回 `undefined`,于是 `saveMetaItem`
走了它自己文档化的「未注册类型 → 不校验直接存」分支,
`PUT /api/v1/meta/capability/:name` 接受**任意 JSON** 落进 `sys_metadata`。
capability 是靠**名字字符串**被解析的——授予侧 `systemPermissions`、
要求侧 `requiredPermissions` 都是——所以一行任意 JSON 直接落在活的授权命名
空间里。
- `isRuntimeCreateAllowed` 镜像 `getMetaTypes()` 的合成规则:没有静态注册表条目
的类型被当作可运行时创建。所以缺的那一行不只是「没关上门」,它**把门打开了**。
`/meta/types` 同步发布了这个虚构:`allowRuntimeCreate: true` + 无 schema,
metadata-admin 引擎据此渲染成一个 raw-JSON 文本框。

### 改了什么

- **`BUILTIN_METADATA_TYPE_SCHEMAS['capability'] = CapabilityDeclarationSchema`**。
既有的 422 `invalid_metadata` 路径就此覆盖 `capability`,`/meta/types` 发出真
JSON Schema。
- **`DEFAULT_METADATA_TYPE_REGISTRY` 新增 `capability` 条目,
`allowRuntimeCreate: false` + `allowOrgOverride: false`**。ADR-0066 D1:包
DEFINE capability,权限集 GRANT,资源 REQUIRE。管理员在运行时凭空造一个
capability 在这个三分里没有位置——代码里不会有任何地方 require 那个名字,这行
只是授权命名空间里一个无人引用的授予目标。这一对标志就是 #5086 的 CODE-ONLY
声明,`saveMetaItem` 在**任何** kernel 上都以 403 `not_creatable` 拒绝,并从条
目自己的 `filePatterns[0]` 读回「该去哪儿声明」。`supportsOverlay: false`——
capability 只是名字/标签/scope,没有 merge 语义,而允许租户 overlay 一个包发布
的声明等于允许把 `scope` 从 `org` 抬成 `platform`。`loadOrder: 12` 早于
`permission`/`position`(15),使权限集的 `systemPermissions` 解析时 capability
已经存在。
- **`MetadataTypeSchema` 枚举补 `'capability'`**。
- **`CapabilityDeclarationSchema` 声明 ADR-0010 保护信封并收紧为 `.strict()`**。
信封是必须的:loader 对每个已注册类型都调 `applyProtection`,不声明就会 422 掉
loader 自己的输出(#4001 在 `permission`/`position` 上补过同一个洞)。收紧则与
`api` 不同——`ApiEndpointSchema` 同时是**存储行**的解析器,所以它留在
`STILL_STRIP`;而没有任何地方拿这个 schema 重新解析 `sys_capability` 行
(`bootstrapDeclaredCapabilities` 通过 `capabilityRowFields` 按名读字段),
所以收紧零成本,买到的是一个授权面本就该有的 declared = enforced 姿态。
改用 `strictObject` 书写,已知键从 shape 派生,不新增手抄键表。

**包声明通道完全没动。** `AppPlugin` 通过 `registerInMemory` 注册 stack 的
`capabilities[]`,文件系统 loader 按 `filePatterns` glob——两条都不经过
`saveMetaItem`,所以 `bootstrapDeclaredCapabilities` 依旧照常 seed。
`OS_METADATA_WRITABLE=capability` 仍是 ADR-0005 那唯一一道运维逃生门,而在它后面
写入现在由 `CapabilityDeclarationSchema` 判定(422),不再原样落盘。

⛔ `role` / `profile` / `policy` **不搭车**:它们没有 `PLURAL_TO_SINGULAR` 映射、
没有声明 schema、没有读回接缝,是另一个问题,另开单。这条以断言形式钉在
`capability-metadata-kind.test.ts` 里,因为「capability 有了条目,邻居也该有」
正是下一个显而易见却错误的改动。
5 changes: 3 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,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' \| 'api' \| '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' \| 'capability' \| '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 @@ -361,7 +361,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' \| … +11 more>` | ✅ | Metadata type |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | Metadata type |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down Expand Up @@ -391,6 +391,7 @@ Metadata query with filtering, sorting, and pagination
* `book`
* `permission`
* `position`
* `capability`
* `agent`
* `tool`
* `skill`
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,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' \| 'api' \| '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' \| 'capability' \| '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 @@ -184,6 +184,7 @@ const result = MetadataBulkResultSchema.parse(data);
* `book`
* `permission`
* `position`
* `capability`
* `agent`
* `tool`
* `skill`
Expand All @@ -197,7 +198,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' \| … +11 more>` | ✅ | Metadata type identifier |
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | 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 Expand Up @@ -235,6 +236,7 @@ const result = MetadataBulkResultSchema.parse(data);
* `book`
* `permission`
* `position`
* `capability`
* `agent`
* `tool`
* `skill`
Expand Down
7 changes: 7 additions & 0 deletions content/docs/references/security/misc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ const result = CapabilityDeclarationSchema.parse(data);
| **description** | `string` | optional | What holding this capability permits |
| **scope** | `Enum<'platform' \| 'org'>` | ✅ | platform = a platform-wide power; org = scoped to an organization |
| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped) |
| **_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
10 changes: 10 additions & 0 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
files: ['src/security/positions.ts'],
notes: 'Flat positions only (no hierarchy — ADR-0090 D3); everyone/guest are built-in anchors, never declared.',
},
// [#5961] `capability` graduated into the registry (schema + entry, code-only:
// allowRuntimeCreate/allowOrgOverride both false — ADR-0066 D1). The showcase
// has declared capabilities since ADR-0066 landed; the registry entry only
// made the kind's coverage ownable here.
capability: {
status: 'demonstrated',
files: ['src/security/capabilities.ts'],
notes:
'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), seeded into sys_capability at publish. Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.',
},

// ── ai ──
agent: {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/test/metadata-type-schema-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ const GATED_AT: Readonly<Record<string, string>> = {
book: 'books',
permission: 'permissions',
position: 'positions',
// [#5961] `capability` joined the registry as a CLOSED shape, so it goes
// straight into the gated set rather than into NOT_YET_CLOSED below: the
// stack authors it at `capabilities:` as a flat array of the registry's own
// shape (`stack.zod.ts`: `z.array(CapabilityDeclarationSchema)`), and that
// shape is `.strict()`, so both gates reject an undeclared key identically.
capability: 'capabilities',
agent: 'agents',
tool: 'tools',
skill: 'skills',
Expand Down
Loading
Loading