openvibers/openvibe.contracts Commits

Add node principals, node capabilities and per-kind resource offers (#6)

OP OpenVibers committed 2 Oct 2026 · 29a8e3269c · 33 files, +1272 −24

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Contracts/pull/6

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ef2beb3a..1c41c336 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -16,6 +16,31 @@ listed by `manifests/services/billing.json`. `POST /api/v1/usage` takes one `pla
`principal` and the `reading` itself; fixtures `recorded` (valid) and `reading-without-idempotency-key` (invalid).
Network grants for the usage producers follow this release. Additive.
++**Node principals, node capabilities and per-kind resource offers** (plan T1 lane A, the contracts brief of
++OpenVibe.Network `docs/t2-cells-and-node-principal.md` §9.1):
++- `lib/ids.js`: `PREFIX.node = 'nod'`; `principalSub({ type: 'node', id })` gives `node:nod_<ULID>`. A node is a
++ principal only: `node` is not a `SUBJECT_TYPES` entry nor an `identity.subject-ref@1` type.
++- `identity.service-token-claims`: `sub` also accepts `node:nod_<ULID>` and `actor_type` gains `node`, so
++ `verifyServiceToken` accepts a node token and still rejects a malformed node id. Fixtures `node-token` (valid) and
++ `node-bad-id` (invalid).
++- New capabilities `network.node.manage` (a service mints pairing codes and reads or revokes the node principals it
++ paired; `nodes:pair`, `nodes:read`, `nodes:revoke`), `network.node.self.manage` (a paired machine presents its own
++ capabilities and rotates its own credential; `nodes:self`) and `network.resource.report` (a source reports its
++ complete set of `platform.resource-offer@1`; `resources:write`), all active, internal and listed by
++ `manifests/services/network.json`. Their bodies are Network-local, so they name no schemas and are recorded in
++ `compatibility/capability-schema-gaps.json`. `network.node.report` is also implemented by
++ `POST /internal/registry/instances/report`.
++- `platform.resource-offer`: `kind` gains `storage`, `delivery`, `runtime`, `agent` and `harness`; the new
++ optional `detail` is required for those kinds and validated by the kind's own `platform.<kind>-offer@1`, and is
++ forbidden for `node` and `provider`. Fixtures `storage`, `delivery`, `runtime`, `agent`, `harness` (valid, beside
++ `owned-node` and `provider`), `storage-without-detail` and `node-with-detail` (invalid).
++- Optional fields: `platform.service-instance` `route_weight` (integer 0-1000); `platform.node-capabilities`
++ `capabilities` (resource-offer capability form), `agent_version` (at most 40 characters) and `updated_at`
++ (date-time); `network.node` `cell` (`^[a-z]{2,8}-[0-9]{1,3}$`). Fixtures `route-weight`, `agent-report` and
++ `with-cell` (valid), `route-weight-over-1000`, `bad-capability` and `bad-cell` (invalid).
++
++Additive.
++
## 0.84.0 — 2026-10-02
Add nine public `platform.*@1` schemas: usage and telemetry samples; node capabilities and
ddiff --git a/compatibility/capability-schema-gaps.json b/compatibility/capability-schema-gaps.json
iindex f2dd5b1b..c8f19d7d 100644
---- a/compatibility/capability-schema-gaps.json
++++ b/compatibility/capability-schema-gaps.json
@@@ -1,4 +1,26 @@
{
"_about": "Active capabilities still without an input or output schema (roadmap WS-C task 4). test/capability-schemas.test.js fails on any active capability missing a schema that is not listed here, and on a listed one that has since gained it (remove it from this list). The list only shrinks: fill a capability's schemas, then delete its row. A request with no body names common.no-body@1; a response of bytes names common.binary@1.",
-- "gaps": []
++ "gaps": [
++ {
++ "id": "network.node.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.node.self.manage",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ },
++ {
++ "id": "network.resource.report",
++ "missing": [
++ "inputSchema",
++ "outputSchema"
++ ]
++ }
++ ]
}
ddiff --git a/contracts/identity/service-token-claims.v1.json b/contracts/identity/service-token-claims.v1.json
iindex edf08998..ac560cea 100644
---- a/contracts/identity/service-token-claims.v1.json
++++ b/contracts/identity/service-token-claims.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"title": "ServiceTokenClaims",
-- "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
++ "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service, app, mod or node principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"required": [
"iss",
@@@ -23,13 +23,14 @@
},
"sub": {
"type": "string",
-- "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"
++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|node:nod_[0-9A-HJKMNP-TV-Z]{26})$"
},
"actor_type": {
"enum": [
"service",
"app",
-- "mod"
++ "mod",
++ "node"
]
},
"aud": {
ddiff --git a/contracts/network/node.v1.json b/contracts/network/node.v1.json
iindex 2d44e652..784ac0e3 100644
---- a/contracts/network/node.v1.json
++++ b/contracts/network/node.v1.json
@@@ -36,6 +36,7 @@
"checked_at": { "type": "string", "format": "date-time" }
}
},
-- "updated_at": { "type": "string", "format": "date-time" }
++ "updated_at": { "type": "string", "format": "date-time" },
++ "cell": { "type": "string", "pattern": "^[a-z]{2,8}-[0-9]{1,3}$", "description": "Cell the node belongs to, e.g. wnam-1." }
}
}
ddiff --git a/contracts/platform/node-capabilities.v1.json b/contracts/platform/node-capabilities.v1.json
iindex 8da04750..783da4da 100644
---- a/contracts/platform/node-capabilities.v1.json
++++ b/contracts/platform/node-capabilities.v1.json
@@@ -136,6 +136,23 @@
"required": [
"per_hour_usd"
]
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]*:[a-z0-9.-]+$"
++ },
++ "description": "What the machine offers, in the platform.resource-offer@1 capability form (e.g. node:http, worker:ffmpeg)."
++ },
++ "agent_version": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "Version of the node agent that reported."
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
}
},
"examples": [
ddiff --git a/contracts/platform/resource-offer.v1.json b/contracts/platform/resource-offer.v1.json
iindex 65d13788..5623a7bf 100644
---- a/contracts/platform/resource-offer.v1.json
++++ b/contracts/platform/resource-offer.v1.json
@@@ -23,7 +23,12 @@
"type": "string",
"enum": [
"node",
-- "provider"
++ "provider",
++ "storage",
++ "delivery",
++ "runtime",
++ "agent",
++ "harness"
]
},
"node_id": {
@@@ -255,6 +260,144 @@
"updated_at": {
"type": "string",
"format": "date-time"
++ },
++ "detail": {
++ "type": "object",
++ "description": "The kind's own offer: platform.storage-offer@1, platform.delivery-offer@1, platform.runtime-offer@1, platform.agent-offer@1 or platform.harness-offer@1 for those kinds; absent for kind node and provider."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "storage"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "storage-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "delivery"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "delivery-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "runtime"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "runtime-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "agent"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "agent-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "harness"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "harness-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "enum": [
++ "node",
++ "provider"
++ ]
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "not": {
++ "required": [
++ "detail"
++ ]
++ }
++ }
}
-- }
++ ]
}
ddiff --git a/contracts/platform/service-instance.v1.json b/contracts/platform/service-instance.v1.json
iindex ec618da1..690695d7 100644
---- a/contracts/platform/service-instance.v1.json
++++ b/contracts/platform/service-instance.v1.json
@@@ -57,6 +57,12 @@
"started_at": {
"type": "string",
"format": "date-time"
++ },
++ "route_weight": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 1000,
++ "description": "Share of this service's traffic the router sends to this instance, relative to its siblings; absent means equal weight."
}
},
"examples": [
ddiff --git a/fixtures/identity.service-token-claims/invalid/node-bad-id.json b/fixtures/identity.service-token-claims/invalid/node-bad-id.json
nnew file mode 100644
iindex 00000000..d2d27825
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/node-bad-id.json
@@@ -0,0 +1,14 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "node:nod_42",
++ "actor_type": "node",
++ "aud": [
++ "openvibe.bot"
++ ],
++ "cap": [
++ "network.node.self.manage"
++ ],
++ "iat": 1790000000,
++ "exp": 1790000300,
++ "jti": "tok_node0001"
++}
ddiff --git a/fixtures/identity.service-token-claims/valid/node-token.json b/fixtures/identity.service-token-claims/valid/node-token.json
nnew file mode 100644
iindex 00000000..95c82bd7
---- /dev/null
++++ b/fixtures/identity.service-token-claims/valid/node-token.json
@@@ -0,0 +1,14 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "node:nod_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "actor_type": "node",
++ "aud": [
++ "openvibe.bot"
++ ],
++ "cap": [
++ "network.node.self.manage"
++ ],
++ "iat": 1790000000,
++ "exp": 1790000300,
++ "jti": "tok_node0001"
++}
ddiff --git a/fixtures/network.node/invalid/bad-cell.json b/fixtures/network.node/invalid/bad-cell.json
nnew file mode 100644
iindex 00000000..3e23149f
---- /dev/null
++++ b/fixtures/network.node/invalid/bad-cell.json
@@@ -0,0 +1,25 @@
++{
++ "id": "oregon-1",
++ "name": "Oregon 1",
++ "roles": [
++ "web",
++ "app",
++ "data",
++ "ingest"
++ ],
++ "location": {
++ "region": "us-west",
++ "country": "US",
++ "city": "Hillsboro",
++ "lat": 45.52,
++ "lon": -122.99
++ },
++ "provider": "ovh",
++ "beacon": "https://openvibe.network/api/v1/nodes/oregon-1/beacon",
++ "health": {
++ "status": "up",
++ "checked_at": "2026-09-28T12:00:00Z"
++ },
++ "updated_at": "2026-09-28T12:00:00Z",
++ "cell": "WNAM_1"
++}
ddiff --git a/fixtures/network.node/valid/with-cell.json b/fixtures/network.node/valid/with-cell.json
nnew file mode 100644
iindex 00000000..348a4a55
---- /dev/null
++++ b/fixtures/network.node/valid/with-cell.json
@@@ -0,0 +1,25 @@
++{
++ "id": "oregon-1",
++ "name": "Oregon 1",
++ "roles": [
++ "web",
++ "app",
++ "data",
++ "ingest"
++ ],
++ "location": {
++ "region": "us-west",
++ "country": "US",
++ "city": "Hillsboro",
++ "lat": 45.52,
++ "lon": -122.99
++ },
++ "provider": "ovh",
++ "beacon": "https://openvibe.network/api/v1/nodes/oregon-1/beacon",
++ "health": {
++ "status": "up",
++ "checked_at": "2026-09-28T12:00:00Z"
++ },
++ "updated_at": "2026-09-28T12:00:00Z",
++ "cell": "wnam-1"
++}
ddiff --git a/fixtures/platform.node-capabilities/invalid/bad-capability.json b/fixtures/platform.node-capabilities/invalid/bad-capability.json
nnew file mode 100644
iindex 00000000..bc9963b9
---- /dev/null
++++ b/fixtures/platform.node-capabilities/invalid/bad-capability.json
@@@ -0,0 +1,37 @@
++{
++ "node_id": "node-1",
++ "cpu": {
++ "cores": 8,
++ "threads": 16
++ },
++ "arch": "x86_64",
++ "memory_mb": 16384,
++ "gpu": {
++ "model": "L4",
++ "count": 1,
++ "vram_mb": 24576
++ },
++ "storage": {
++ "capacity_gb": 500,
++ "available_gb": 300,
++ "kind": "ssd"
++ },
++ "network": {
++ "ingress_mbps": 1000,
++ "egress_mbps": 1000
++ },
++ "regions": [
++ "us-west"
++ ],
++ "tags": [
++ "gpu",
++ "edge"
++ ],
++ "costs": {
++ "per_hour_usd": 0.2,
++ "egress_per_gb_usd": 0.01
++ },
++ "capabilities": [
++ "ffmpeg"
++ ]
++}
ddiff --git a/fixtures/platform.node-capabilities/valid/agent-report.json b/fixtures/platform.node-capabilities/valid/agent-report.json
nnew file mode 100644
iindex 00000000..78aeef60
---- /dev/null
++++ b/fixtures/platform.node-capabilities/valid/agent-report.json
@@@ -0,0 +1,40 @@
++{
++ "node_id": "node-1",
++ "cpu": {
++ "cores": 8,
++ "threads": 16
++ },
++ "arch": "x86_64",
++ "memory_mb": 16384,
++ "gpu": {
++ "model": "L4",
++ "count": 1,
++ "vram_mb": 24576
++ },
++ "storage": {
++ "capacity_gb": 500,
++ "available_gb": 300,
++ "kind": "ssd"
++ },
++ "network": {
++ "ingress_mbps": 1000,
++ "egress_mbps": 1000
++ },
++ "regions": [
++ "us-west"
++ ],
++ "tags": [
++ "gpu",
++ "edge"
++ ],
++ "costs": {
++ "per_hour_usd": 0.2,
++ "egress_per_gb_usd": 0.01
++ },
++ "capabilities": [
++ "node:http",
++ "worker:ffmpeg"
++ ],
++ "agent_version": "0.9.0",
++ "updated_at": "2026-10-02T00:00:00Z"
++}
ddiff --git a/fixtures/platform.resource-offer/invalid/node-with-detail.json b/fixtures/platform.resource-offer/invalid/node-with-detail.json
nnew file mode 100644
iindex 00000000..41c7d659
---- /dev/null
++++ b/fixtures/platform.resource-offer/invalid/node-with-detail.json
@@@ -0,0 +1,66 @@
++{
++ "offer_id": "off_oregon_1",
++ "kind": "node",
++ "node_id": "ovh-oregon-1",
++ "provider": "ovh",
++ "region": "us-west",
++ "cell": "wnam-1",
++ "trust": "first-party",
++ "capabilities": [
++ "node:http",
++ "worker:browser",
++ "worker:ffmpeg",
++ "events:durable"
++ ],
++ "capacity": {
++ "cpu": {
++ "utilization": 0.31,
++ "available_cores": 5.5
++ },
++ "memory": {
++ "available_mb": 18300
++ },
++ "workers": {
++ "browser": 6,
++ "ffmpeg": 3
++ },
++ "queues": {
++ "media-jobs": {
++ "depth": 2,
++ "oldest_ms": 40
++ }
++ }
++ },
++ "latency_ms": {
++ "publish_p95": 3.1
++ },
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "prepaid",
++ "marginal_usd_per_unit": 0,
++ "unit": "job"
++ },
++ "updated_at": "2026-09-28T23:00:00Z",
++ "detail": {
++ "id": "storage-1",
++ "class": "warm",
++ "capacity_gb": 1000,
++ "price_per_gb_month_usd": 0.02,
++ "price_per_operation_usd": 1e-05,
++ "region": "us-west",
++ "node": "node-1",
++ "durability": {
++ "replicas": 3,
++ "target_nines": 9
++ },
++ "lifecycle_rules": [
++ {
++ "after_days": 90,
++ "action": "transition",
++ "target_class": "cold"
++ }
++ ]
++ }
++}
ddiff --git a/fixtures/platform.resource-offer/invalid/storage-without-detail.json b/fixtures/platform.resource-offer/invalid/storage-without-detail.json
nnew file mode 100644
iindex 00000000..56fdaa23
---- /dev/null
++++ b/fixtures/platform.resource-offer/invalid/storage-without-detail.json
@@@ -0,0 +1,17 @@
++{
++ "offer_id": "off_storage_1",
++ "kind": "storage",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "object:r2"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z"
++}
ddiff --git a/fixtures/platform.resource-offer/valid/agent.json b/fixtures/platform.resource-offer/valid/agent.json
nnew file mode 100644
iindex 00000000..33854420
---- /dev/null
++++ b/fixtures/platform.resource-offer/valid/agent.json
@@@ -0,0 +1,34 @@
++{
++ "offer_id": "off_agent_1",
++ "kind": "agent",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "agent:model-1"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z",
++ "detail": {
++ "id": "agent-1",
++ "harness": "harness-1",
++ "provider": "example-ai",
++ "model": "model-1",
++ "context_limits": {
++ "input_tokens": 128000,
++ "output_tokens": 8192
++ },
++ "concurrency": 4,
++ "price_per_1k_tokens": {
++ "fresh_usd": 0.003,
++ "cached_usd": 0.001,
++ "output_usd": 0.01
++ },
++ "availability_windows": []
++ }
++}
ddiff --git a/fixtures/platform.resource-offer/valid/delivery.json b/fixtures/platform.resource-offer/valid/delivery.json
nnew file mode 100644
iindex 00000000..7182c29e
---- /dev/null
++++ b/fixtures/platform.resource-offer/valid/delivery.json
@@@ -0,0 +1,36 @@
++{
++ "offer_id": "off_delivery_1",
++ "kind": "delivery",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "delivery:hls"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z",
++ "detail": {
++ "id": "delivery-1",
++ "transports": [
++ "http",
++ "hls"
++ ],
++ "regions": [
++ "us-west"
++ ],
++ "edge": true,
++ "node": "node-1",
++ "price_per_gb_usd": 0.02,
++ "price_per_request_usd": 1e-05,
++ "cache_rules": {
++ "enabled": true,
++ "ttl_seconds": 300,
++ "max_object_mb": 100
++ }
++ }
++}
ddiff --git a/fixtures/platform.resource-offer/valid/harness.json b/fixtures/platform.resource-offer/valid/harness.json
nnew file mode 100644
iindex 00000000..3bbe5ee0
---- /dev/null
++++ b/fixtures/platform.resource-offer/valid/harness.json
@@@ -0,0 +1,41 @@
++{
++ "offer_id": "off_harness_1",
++ "kind": "harness",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "harness:cli"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z",
++ "detail": {
++ "id": "harness-1",
++ "name": "Example Agent",
++ "provider": "example-ai",
++ "capabilities": {
++ "host_access": true,
++ "mcp": true,
++ "long_autonomy": true,
++ "resume": true
++ },
++ "address": {
++ "kind": "cli",
++ "target": "example-agent"
++ },
++ "price": {
++ "amount_usd": 0.1,
++ "unit": "hour"
++ },
++ "limits": {
++ "max_duration_seconds": 7200,
++ "max_concurrent_runs": 4,
++ "max_context_tokens": 128000
++ }
++ }
++}
ddiff --git a/fixtures/platform.resource-offer/valid/runtime.json b/fixtures/platform.resource-offer/valid/runtime.json
nnew file mode 100644
iindex 00000000..1386e3b8
---- /dev/null
++++ b/fixtures/platform.resource-offer/valid/runtime.json
@@@ -0,0 +1,36 @@
++{
++ "offer_id": "off_runtime_1",
++ "kind": "runtime",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "runtime:container"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z",
++ "detail": {
++ "id": "runtime-1",
++ "kind": "container",
++ "region": "us-west",
++ "node": "node-1",
++ "limits": {
++ "cpu_cores": 2,
++ "memory_mb": 4096,
++ "duration_seconds": 3600
++ },
++ "price": {
++ "amount_usd": 0.05,
++ "unit": "hour"
++ },
++ "availability": "available",
++ "constraints": [
++ "first-party"
++ ]
++ }
++}
ddiff --git a/fixtures/platform.resource-offer/valid/storage.json b/fixtures/platform.resource-offer/valid/storage.json
nnew file mode 100644
iindex 00000000..fbe9b4c1
---- /dev/null
++++ b/fixtures/platform.resource-offer/valid/storage.json
@@@ -0,0 +1,37 @@
++{
++ "offer_id": "off_storage_1",
++ "kind": "storage",
++ "provider": "example",
++ "region": "us-west",
++ "trust": "first-party",
++ "capabilities": [
++ "object:r2"
++ ],
++ "health": {
++ "status": "up"
++ },
++ "pricing": {
++ "model": "per-operation"
++ },
++ "updated_at": "2026-10-02T00:00:00Z",
++ "detail": {
++ "id": "storage-1",
++ "class": "warm",
++ "capacity_gb": 1000,
++ "price_per_gb_month_usd": 0.02,
++ "price_per_operation_usd": 1e-05,
++ "region": "us-west",
++ "node": "node-1",
++ "durability": {
++ "replicas": 3,
++ "target_nines": 9
++ },
++ "lifecycle_rules": [
++ {
++ "after_days": 90,
++ "action": "transition",
++ "target_class": "cold"
++ }
++ ]
++ }
++}
ddiff --git a/fixtures/platform.service-instance/invalid/route-weight-over-1000.json b/fixtures/platform.service-instance/invalid/route-weight-over-1000.json
nnew file mode 100644
iindex 00000000..9a749e73
---- /dev/null
++++ b/fixtures/platform.service-instance/invalid/route-weight-over-1000.json
@@@ -0,0 +1,14 @@
++{
++ "id": "inst-1",
++ "service": "media",
++ "version": "1.4.0",
++ "cell": "cell-west",
++ "node": "node-1",
++ "region": "us-west",
++ "endpoints": [
++ "https://media.internal.example"
++ ],
++ "state": "ready",
++ "started_at": "2026-09-30T10:00:00Z",
++ "route_weight": 1001
++}
ddiff --git a/fixtures/platform.service-instance/valid/route-weight.json b/fixtures/platform.service-instance/valid/route-weight.json
nnew file mode 100644
iindex 00000000..39ec7f66
---- /dev/null
++++ b/fixtures/platform.service-instance/valid/route-weight.json
@@@ -0,0 +1,14 @@
++{
++ "id": "inst-1",
++ "service": "media",
++ "version": "1.4.0",
++ "cell": "cell-west",
++ "node": "node-1",
++ "region": "us-west",
++ "endpoints": [
++ "https://media.internal.example"
++ ],
++ "state": "ready",
++ "started_at": "2026-09-30T10:00:00Z",
++ "route_weight": 250
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 4bc3ce56..dd61391a 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -187,7 +187,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"title": "ServiceTokenClaims",
-- "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
++ "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service, app, mod or node principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"required": [
"iss",
@@@ -208,13 +208,14 @@
},
"sub": {
"type": "string",
-- "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"
++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|node:nod_[0-9A-HJKMNP-TV-Z]{26})$"
},
"actor_type": {
"enum": [
"service",
"app",
-- "mod"
++ "mod",
++ "node"
]
},
"aud": {
@@@ -57486,6 +57487,11 @@
"updated_at": {
"type": "string",
"format": "date-time"
++ },
++ "cell": {
++ "type": "string",
++ "pattern": "^[a-z]{2,8}-[0-9]{1,3}$",
++ "description": "Cell the node belongs to, e.g. wnam-1."
}
}
}
@@@ -57749,7 +57755,12 @@
"type": "string",
"enum": [
"node",
-- "provider"
++ "provider",
++ "storage",
++ "delivery",
++ "runtime",
++ "agent",
++ "harness"
]
},
"node_id": {
@@@ -57981,8 +57992,146 @@
"updated_at": {
"type": "string",
"format": "date-time"
++ },
++ "detail": {
++ "type": "object",
++ "description": "The kind's own offer: platform.storage-offer@1, platform.delivery-offer@1, platform.runtime-offer@1, platform.agent-offer@1 or platform.harness-offer@1 for those kinds; absent for kind node and provider."
}
-- }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "storage"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "storage-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "delivery"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "delivery-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "runtime"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "runtime-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "agent"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "agent-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "harness"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "detail"
++ ],
++ "properties": {
++ "detail": {
++ "$ref": "harness-offer.v1.json"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "kind": {
++ "enum": [
++ "node",
++ "provider"
++ ]
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "not": {
++ "required": [
++ "detail"
++ ]
++ }
++ }
++ }
++ ]
}
},
"platform.workload-requirements": {
@@@ -59144,6 +59293,23 @@
"required": [
"per_hour_usd"
]
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]*:[a-z0-9.-]+$"
++ },
++ "description": "What the machine offers, in the platform.resource-offer@1 capability form (e.g. node:http, worker:ffmpeg)."
++ },
++ "agent_version": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "Version of the node agent that reported."
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
}
},
"examples": [
@@@ -59247,6 +59413,12 @@
"started_at": {
"type": "string",
"format": "date-time"
++ },
++ "route_weight": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 1000,
++ "description": "Share of this service's traffic the router sends to this instance, relative to its siblings; absent means equal weight."
}
},
"examples": [
ddiff --git a/generated/openapi/index.json b/generated/openapi/index.json
iindex bca11053..d51dba8e 100644
---- a/generated/openapi/index.json
++++ b/generated/openapi/index.json
@@@ -96,8 +96,8 @@
"service": "network",
"name": "OpenVibe.Network",
"origin": "https://openvibe.network",
-- "operations": 29,
-- "capabilities": 22
++ "operations": 36,
++ "capabilities": 25
},
{
"service": "news",
ddiff --git a/generated/openapi/network.json b/generated/openapi/network.json
iindex 308a9fbc..fc428643 100644
---- a/generated/openapi/network.json
++++ b/generated/openapi/network.json
@@@ -136,6 +136,84 @@
}
}
},
++ "/api/v1/node/self/capabilities": {
++ "put": {
++ "operationId": "put_api_v1_node_self_capabilities",
++ "summary": "A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
++ "description": "**network.node.self.manage** (internal): A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.self.manage"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.self.manage"
++ ]
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/node/self/credential": {
++ "post": {
++ "operationId": "post_api_v1_node_self_credential",
++ "summary": "A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
++ "description": "**network.node.self.manage** (internal): A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.self.manage"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.self.manage"
++ ]
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"/api/v1/staff/moderators": {
"get": {
"operationId": "get_api_v1_staff_moderators",
@@@ -1517,6 +1595,143 @@
}
}
},
++ "/internal/node-pairings": {
++ "post": {
++ "operationId": "post_internal_node_pairings",
++ "summary": "A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired.",
++ "description": "**network.node.manage** (internal): A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired. Never grantable to apps.",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.manage"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.manage"
++ ]
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/internal/node-principals/{id}": {
++ "get": {
++ "operationId": "get_internal_node_principals_id",
++ "summary": "A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired.",
++ "description": "**network.node.manage** (internal): A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired. Never grantable to apps.",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.manage"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.manage"
++ ]
++ }
++ ],
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/internal/node-principals/{id}/revoke": {
++ "post": {
++ "operationId": "post_internal_node_principals_id_revoke",
++ "summary": "A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired.",
++ "description": "**network.node.manage** (internal): A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired. Never grantable to apps.",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.manage"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.manage"
++ ]
++ }
++ ],
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"/internal/nodes/report": {
"post": {
"operationId": "post_internal_nodes_report",
@@@ -1685,6 +1900,101 @@
}
}
},
++ "/internal/registry/instances/report": {
++ "post": {
++ "operationId": "post_internal_registry_instances_report",
++ "summary": "Report the machines of the platform (roles, region-level location, beacon, health) to the node registry, which the geo API and every location-aware product read (ADR-034 section 12).",
++ "description": "**network.node.report** (internal): Report the machines of the platform (roles, region-level location, beacon, health) to the node registry, which the geo API and every location-aware product read (ADR-034 section 12).",
++ "tags": [
++ "network.node"
++ ],
++ "x-openvibe-capabilities": [
++ "network.node.report"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.node.report"
++ ]
++ }
++ ],
++ "requestBody": {
++ "required": false,
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/network.node-report-request.v1"
++ }
++ }
++ }
++ },
++ "responses": {
++ "2XX": {
++ "description": "Success",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/network.node-list-result.v1"
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/internal/resources/report": {
++ "post": {
++ "operationId": "post_internal_resources_report",
++ "summary": "Report the complete set of a source's resource offers (platform.resource-offer@1) to the registry; offers absent from a later report of the same source are marked down.",
++ "description": "**network.resource.report** (internal): Report the complete set of a source's resource offers (platform.resource-offer@1) to the registry; offers absent from a later report of the same source are marked down.",
++ "tags": [
++ "network.resource"
++ ],
++ "x-openvibe-capabilities": [
++ "network.resource.report"
++ ],
++ "x-openvibe-visibility": [
++ "internal"
++ ],
++ "x-openvibe-quota-class": "admin",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.resource.report"
++ ]
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success"
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"/internal/url-registry/resolved": {
"get": {
"operationId": "get_internal_url_registry_resolved",
@@@ -3182,6 +3492,11 @@
"updated_at": {
"type": "string",
"format": "date-time"
++ },
++ "cell": {
++ "type": "string",
++ "pattern": "^[a-z]{2,8}-[0-9]{1,3}$",
++ "description": "Cell the node belongs to, e.g. wnam-1."
}
}
},
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex c80af1ad..2d34608e 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -53,7 +53,7 @@ export interface LegacyIdentityMapEntry {
/** [email protected] (owner: network) */
/**
-- * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.
++ * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service, app, mod or node principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.
*/
export type ServiceTokenClaims = {
[k: string]: unknown | undefined;
@@@ -63,7 +63,7 @@ export type ServiceTokenClaims = {
*/
iss: string;
sub: string;
-- actor_type: "service" | "app" | "mod";
++ actor_type: "service" | "app" | "mod" | "node";
/**
* @minItems 1
*/
@@@ -50616,6 +50616,10 @@ export interface NetworkNode {
checked_at: string;
};
updated_at: string;
++ /**
++ * Cell the node belongs to, e.g. wnam-1.
++ */
++ cell?: string;
}
/** [email protected] (owner: network) */
@@@ -50712,9 +50716,11 @@ export interface NetworkCoinsReadResult {
/**
* What one node or external provider can run right now (roadmap WS-Z9, decision 43): capabilities, multidimensional capacity, measured latencies, health and pricing. First-party only: it carries capacity the public node registry (network.node@1) deliberately leaves out.
*/
--export interface ResourceOffer {
++export type ResourceOffer = {
++ [k: string]: unknown | undefined;
++} & {
offer_id: string;
-- kind: "node" | "provider";
++ kind: "node" | "provider" | "storage" | "delivery" | "runtime" | "agent" | "harness";
/**
* network.node@1 id, for kind node
*/
@@@ -50798,7 +50804,11 @@ export interface ResourceOffer {
rate_card?: string;
};
updated_at: string;
--}
++ /**
++ * The kind's own offer: platform.storage-offer@1, platform.delivery-offer@1, platform.runtime-offer@1, platform.agent-offer@1 or platform.harness-offer@1 for those kinds; absent for kind node and provider.
++ */
++ detail?: {};
++};
/** [email protected] (owner: network) */
/**
@@@ -51195,6 +51205,15 @@ export interface NodeCapabilities {
*/
egress_per_gb_usd?: number;
};
++ /**
++ * What the machine offers, in the platform.resource-offer@1 capability form (e.g. node:http, worker:ffmpeg).
++ */
++ capabilities?: string[];
++ /**
++ * Version of the node agent that reported.
++ */
++ agent_version?: string;
++ updated_at?: string;
}
/** [email protected] (owner: network) */
@@@ -51214,6 +51233,10 @@ export interface ServiceInstance {
endpoints: [string, ...string[]];
state: "starting" | "ready" | "degraded" | "draining" | "stopped";
started_at: string;
++ /**
++ * Share of this service's traffic the router sends to this instance, relative to its siblings; absent means equal weight.
++ */
++ route_weight?: number;
}
/** [email protected] (owner: network) */
ddiff --git a/lib/ids.js b/lib/ids.js
iindex af0e0d3b..42016d7d 100644
---- a/lib/ids.js
++++ b/lib/ids.js
@@@ -6,7 +6,7 @@
const crypto = require('crypto');
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
--const PREFIX = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', agent: 'agt', event: 'evt', media: 'med', project: 'prj', confirmation: 'cnf' };
++const PREFIX = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', agent: 'agt', node: 'nod', event: 'evt', media: 'med', project: 'prj', confirmation: 'cnf' };
const SLUG_TYPES = new Set(['service', 'system']);
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const SLUG_RE = /^[a-z][a-z0-9-]{1,39}$/;
@@@ -50,11 +50,11 @@ function formatSubject(ref) {
return `${ref.type}:${ref.id}`;
}
--/** Service-token `sub` claim: svc:<slug> | app:app_<ULID> | mod:mod_<ULID> | agent:agt_<ULID>. */
++/** Service-token `sub` claim: svc:<slug> | app:app_<ULID> | mod:mod_<ULID> | agent:agt_<ULID> | node:nod_<ULID>. */
function principalSub(ref) {
if (ref.type === 'service' && SLUG_RE.test(ref.id)) return `svc:${ref.id}`;
-- if ((ref.type === 'app' || ref.type === 'mod' || ref.type === 'agent') && isSubjectId(ref.type, ref.id)) return `${ref.type}:${ref.id}`;
-- throw new TypeError('principals are service, app, mod or agent subjects');
++ if ((ref.type === 'app' || ref.type === 'mod' || ref.type === 'agent' || ref.type === 'node') && isSubjectId(ref.type, ref.id)) return `${ref.type}:${ref.id}`;
++ throw new TypeError('principals are service, app, mod, agent or node subjects');
}
/** Transitional Media reference for today's rows (Wave 4 introduces med_ ids). */
ddiff --git a/manifests/capabilities/network.node.manage.json b/manifests/capabilities/network.node.manage.json
nnew file mode 100644
iindex 00000000..197dfc76
---- /dev/null
++++ b/manifests/capabilities/network.node.manage.json
@@@ -0,0 +1,23 @@
++{
++ "id": "network.node.manage",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired. Never grantable to apps.",
++ "permissions": [
++ "nodes:pair",
++ "nodes:read",
++ "nodes:revoke"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "admin",
++ "events": [],
++ "implementedBy": [
++ "POST /internal/node-pairings",
++ "GET /internal/node-principals/:id",
++ "POST /internal/node-principals/:id/revoke"
++ ]
++}
ddiff --git a/manifests/capabilities/network.node.report.json b/manifests/capabilities/network.node.report.json
iindex 8dab7308..e9b9c53e 100644
---- a/manifests/capabilities/network.node.report.json
++++ b/manifests/capabilities/network.node.report.json
@@@ -14,7 +14,8 @@
"quotaClass": "admin",
"events": [],
"implementedBy": [
-- "POST /internal/nodes/report"
++ "POST /internal/nodes/report",
++ "POST /internal/registry/instances/report"
],
"inputSchema": "network.node-report-request@1",
"outputSchema": "network.node-list-result@1"
ddiff --git a/manifests/capabilities/network.node.self.manage.json b/manifests/capabilities/network.node.self.manage.json
nnew file mode 100644
iindex 00000000..1ab348cb
---- /dev/null
++++ b/manifests/capabilities/network.node.self.manage.json
@@@ -0,0 +1,20 @@
++{
++ "id": "network.node.self.manage",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
++ "permissions": [
++ "nodes:self"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "admin",
++ "events": [],
++ "implementedBy": [
++ "PUT /api/v1/node/self/capabilities",
++ "POST /api/v1/node/self/credential"
++ ]
++}
ddiff --git a/manifests/capabilities/network.resource.report.json b/manifests/capabilities/network.resource.report.json
nnew file mode 100644
iindex 00000000..1a253047
---- /dev/null
++++ b/manifests/capabilities/network.resource.report.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.resource.report",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Report the complete set of a source's resource offers (platform.resource-offer@1) to the registry; offers absent from a later report of the same source are marked down.",
++ "permissions": [
++ "resources:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "admin",
++ "events": [],
++ "implementedBy": [
++ "POST /internal/resources/report"
++ ]
++}
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 28d0bae1..6d78aa12 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -48,12 +48,15 @@
"network.integration.github.read",
"network.modules.read",
"network.modules.write",
++ "network.node.manage",
"network.node.report",
++ "network.node.self.manage",
"network.notifications.push",
"network.operator.alert",
"network.project.manage",
"network.project.read",
"network.registry.read",
++ "network.resource.report",
"network.staff.read",
"network.status.incident"
],
ddiff --git a/test/run.js b/test/run.js
iindex a955bd8d..e48ed57e 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -500,6 +500,10 @@ for (const kind of ['user', 'guest', 'app', 'mod']) {
ok(ids.newId('user', 1000) < ids.newId('user', 2000), 'ulids sort by time');
ok(ids.parseSubject('user:42') === null && ids.parseSubject('user') === null && ids.parseSubject('svc:live') === null, 'malformed subjects are rejected');
ok(ids.principalSub({ type: 'service', id: 'live' }) === 'svc:live', 'service principal sub');
++const nodeId = ids.newId('node');
++ok(nodeId.startsWith('nod_') && ids.principalSub({ type: 'node', id: nodeId }) === `node:${nodeId}`, 'node principal sub is node:nod_<ULID>');
++assert.throws(() => ids.principalSub({ type: 'node', id: 'nod_42' }), /principals/);
++ok(!ids.SUBJECT_TYPES.includes('node') && ids.parseSubject(`node:${nodeId}`) === null, 'a node is a principal, never a subject');
assert.throws(() => ids.principalSub({ type: 'user', id: ids.newId('user') }), /principals/);
ok(contracts.validate('media.media-ref', { media_id: ids.legacyMediaId('live', 'vod', 42) }).valid, 'legacyMediaId is a valid MediaRef');
ok(contracts.validate('events.event-envelope', { event_id: ids.newId('event'), event_type: 'network.user.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'user', id: ids.newId('user') }, payload: {} }).valid, 'newId(event) builds a valid envelope');
@@@ -563,6 +567,9 @@ ok(V(sandboxTok, { acceptSandbox: true }).ok, 'sandbox app token accepted when t
ok(V(serviceAuth.signServiceToken({ ...appClaims, env: 'production' }, kp.privateKey)).ok, 'production app token accepted');
ok(V(serviceAuth.signServiceToken((({ project_id, ...rest }) => rest)(appClaims), kp.privateKey)).code === 'token.invalid_claims', 'an app token needs project_id');
ok(contracts.ids.newId('project').startsWith('prj_'), 'project ids use the prj prefix');
++const nodeClaims = { ...claims, sub: 'node:nod_01J0000000000000000000000Z', actor_type: 'node' };
++ok(V(serviceAuth.signServiceToken(nodeClaims, kp.privateKey)).ok, 'node token accepted');
++ok(V(serviceAuth.signServiceToken({ ...nodeClaims, sub: 'node:nod_42' }, kp.privateKey)).code === 'token.invalid_claims', 'a malformed node id is rejected');
const run = (guard, headers) => new Promise((resolve) => {
const req = { headers, body: {} };