openvibers/openvibe.contracts Commits
v0.34.0: capability catalog gaps from roadmap §30.2, 15 capabilities checked against each service's routes and production. Active (public reads production serves to anyone today): live.channel.read (/api/streams/channel/:username…, /api/auth/user/:username), live.stream.read (/api/streams, /:id, /recent, slot resolve, /api/live-events), live.discovery.read (/api/home/*, recently-online, recent-vods, content feed and moments), community.space.read, community.thread.read (API, pages, RSS), community.pulse.read, search.query.run (the public index query, suggest and document read, served at search.openvibe.network; service tokens keep search.query.delegate). Planned, each naming its routes and the id that guards them today: live.owner.resolve (no public route; the internal live.lineage.resolve answers lineage.resolution@1), media.upload.create (v2 single and multipart upload, guarded by media.object.upload), media.derivative.create (v2 jobs: split, remux, thumbnail), media.derivative.read (no route lists derivatives yet), media.lifecycle.read (guarded by media.object.read), media.lifecycle.transition (restore, holds, tier moves), community.space.manage (only the members-only gate exists), community.vote.set (value 0 removes, so no vote.remove; covered today by community.post.create and community.comment.write). New publishing library manifest (openvibe-publishing v0.2.1, ADR-019) registering no publishing.* capability: a package has no grant boundary, and the product capabilities (wiki.*, blog.*, news.*) already cover those routes. test/run.js checks statuses, owners, public GETs for the active reads, the planned ones naming their current guard, and publishing having no capabilities
Changes
nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "community.pulse.read",++ "version": "1.0",++ "owner": "community",++ "status": "active",++ "visibility": "public",++ "description": "Read the network's public activity feed (Pulse): entity reference, title, URL and origin (user, ai or system), newest first with a cursor. Only public items are ever stored. Served to anyone (CORS allow-list for browsers). Live's /api/home/pulse is a different, Live-only feed.",++ "outputSchema": "common.entity-ref@1",++ "permissions": [++ "community:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "community-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/pulse",++ "GET /pulse"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "community.space.manage",++ "version": "1.0",++ "owner": "community",++ "status": "planned",++ "visibility": "internal",++ "description": "PLANNED: staff administration of spaces: create, rename, set visibility, and gate a space to one creator's VIP members. Only the members-only gate exists today (checked with community.comment.moderate plus a moderator); spaces are seeded by Community's database setup and nothing creates or edits them.",++ "permissions": [++ "community:moderate"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "community-moderate",++ "events": [],++ "implementedBy": [++ "PUT /api/v1/spaces/:space/members-only"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "community.space.read",++ "version": "1.0",++ "owner": "community",++ "status": "active",++ "visibility": "public",++ "description": "List the spaces the caller can open and read one space's metadata (roadmap §30.2 community.space.*). Anonymous callers see public spaces, signed-in people also members spaces, moderators also staff spaces; members-only (VIP) spaces are flagged. Served to anyone; Community checks space visibility itself.",++ "outputSchema": "common.entity-ref@1",++ "permissions": [++ "community:read"++ ],++ "resourceConstraints": [++ "space_member"++ ],++ "quotaClass": "community-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/spaces",++ "GET /api/v1/spaces/:space",++ "GET /s"++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "id": "community.thread.read",++ "version": "1.0",++ "owner": "community",++ "status": "active",++ "visibility": "public",++ "description": "Read a space's threads (hot, new or top, paged) and one thread with its posts, plus the RSS feeds. Community checks space visibility and the VIP gate (403 vip.members_only). Creating threads and replies stays community.post.create.",++ "outputSchema": "common.entity-ref@1",++ "permissions": [++ "community:read"++ ],++ "resourceConstraints": [++ "space_member"++ ],++ "quotaClass": "community-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/spaces/:space/threads",++ "GET /api/v1/spaces/:space/threads/:slug",++ "GET /s/:space",++ "GET /s/:space/t/:slug",++ "GET /s/feed.xml",++ "GET /s/:space/feed.xml"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "community.vote.set",++ "version": "1.0",++ "owner": "community",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED as its own grant: up-vote, down-vote or clear a vote (value 1, -1 or 0; 0 removes it, so there is no separate vote.remove) on a thread or a comment as the acting person (roadmap §30.2 community.vote.set|remove). Locked threads refuse; 60 votes a minute per person. The routes serve production today, guarded by community.post.create (threads) and community.comment.write (comments), which keep covering them.",++ "inputSchema": "common.entity-ref@1",++ "permissions": [++ "community:vote"++ ],++ "resourceConstraints": [++ "space_member"++ ],++ "quotaClass": "community-vote",++ "events": [],++ "implementedBy": [++ "POST /api/v1/spaces/:space/threads/:slug/votes",++ "POST /api/v1/comments/:commentId/votes",++ "POST /s/:space/t/:slug/vote"++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "live.channel.read",++ "version": "1.0",++ "owner": "live",++ "status": "active",++ "visibility": "public",++ "description": "Read a channel's public profile by username (the /@username page's data): metadata, owner display fields, follower count, live streams with playback endpoints, slots, and its public VODs and clips from Media. The owner and staff also see private listings; ingest keys are never returned. Anonymous callers are served (optionalAuth); no grant is checked at Live's boundary, and /api allows only OpenVibe origins for browsers. There is no public lookup by channel id yet (roadmap §30.2, sdk-live).",++ "permissions": [++ "live:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "live-read",++ "events": [],++ "implementedBy": [++ "GET /api/streams/channel/:username",++ "GET /api/streams/channel/:username/live",++ "GET /api/streams/channel/:username/popular",++ "GET /api/streams/channel/:username/clips-taken",++ "GET /api/streams/channel/:username/bio-en",++ "GET /api/auth/user/:username"++ ]++}nnew file mode 100644@@@ -0,0 +1,30 @@++{++ "id": "live.discovery.read",++ "version": "1.0",++ "owner": "live",++ "status": "active",++ "visibility": "public",++ "description": "Read Live's discovery surfaces (roadmap §30.2 live.discovery.*): the featured rotation, \"watch instead\" picks, the star of the day, the digest, home numbers, recently online streamers, recent VODs, and the content and AI-moments feeds. Public items only. There is no category directory and no channel search route yet; channel search belongs to OpenVibe.Search once Live publishes index documents.",++ "permissions": [++ "live:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "live-read",++ "events": [],++ "implementedBy": [++ "GET /api/home/featured",++ "GET /api/home/discover",++ "GET /api/home/star",++ "GET /api/home/digest",++ "GET /api/home/hero",++ "GET /api/home/stats-live",++ "GET /api/home/stats/series/:metric",++ "GET /api/home/pulse",++ "GET /api/streams/recently-online",++ "GET /api/streams/recent-vods",++ "GET /api/content/feed",++ "GET /api/content/moments"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "live.owner.resolve",++ "version": "1.0",++ "owner": "live",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED: the public form of the channel/owner resolver (roadmap §30.2, §15.10): which channel (id, slug, owner subject) a slug, slot, stream, or public VOD or clip belongs to, and how that was decided, answering unresolved rather than guessing; a display name alone resolves nothing. Unlike the internal live.lineage.resolve (GET|POST /internal/lineage/resolve, loopback, service token) it refuses legacy, Network and Media ids and treats private VODs and clips as not found. No public route exists yet; GET /api/streams/:id and GET /api/vods/:id/context answer part of it.",++ "inputSchema": "lineage.resolve-request@1",++ "outputSchema": "lineage.resolution@1",++ "permissions": [++ "live:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "live-read",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "live.stream.read",++ "version": "1.0",++ "owner": "live",++ "status": "active",++ "visibility": "public",++ "description": "Read broadcasts: who is live now with viewer counts, one stream with its endpoint, cameras and controls, recently ended streams with their public VOD, a slot's current session, and the went-live stream (/api/live-events, open CORS). Stream keys and the home ZIP are redacted. Anonymous callers are served; no grant is checked at Live's boundary (roadmap §30.2, sdk-live).",++ "permissions": [++ "live:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "live-read",++ "events": [],++ "implementedBy": [++ "GET /api/streams",++ "GET /api/streams/:id",++ "GET /api/streams/recent",++ "GET /api/streams/channel/:username/resolve/:ref",++ "GET /api/live-events"++ ]++}nnew file mode 100644@@@ -0,0 +1,30 @@++{++ "id": "media.derivative.create",++ "version": "1.0",++ "owner": "media",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED as its own grant: derive new objects from an existing one as idempotent jobs (roadmap §30.2 media.derivative.*): object.split, object.remux or thumbnail.regenerate, and approve a proposed job. Derived objects are private and linked derived_from; the source never changes. Waveforms, sprites and previews are not built yet (roadmap §28.3 m3). The routes serve production today but Media checks media.object.upload on the jobs API (and an app key on the v1 thumbnail route); a token holding only this id is refused until Media's guard accepts it.",++ "permissions": [++ "media:write"++ ],++ "resourceConstraints": [++ "namespace",++ "owner"++ ],++ "quotaClass": "media-job",++ "events": [++ "media.job.proposed",++ "media.job.queued",++ "media.job.started",++ "media.job.retrying",++ "media.job.succeeded",++ "media.job.failed",++ "media.job.cancelled"++ ],++ "implementedBy": [++ "POST /api/v2/:app/jobs",++ "POST /api/v2/:app/jobs/:jobId/approve",++ "POST /api/v1/:app/thumbnails/:kind/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "media.derivative.read",++ "version": "1.0",++ "owner": "media",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED: list an object's derivatives and variants with how they relate (derived_from, variant kind), and read public thumbnails and live frames. Nothing returns the relationship and variant tables yet; the public thumbnail and live-frame routes and the job list filtered by object are the nearest reads today.",++ "permissions": [++ "media:read"++ ],++ "resourceConstraints": [++ "namespace"++ ],++ "quotaClass": "media-read",++ "events": [],++ "implementedBy": [++ "GET /t/:id",++ "GET /live/:sel/frame.jpg",++ "GET /api/v2/:app/jobs?object_id="++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "media.lifecycle.read",++ "version": "1.0",++ "owner": "media",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED as its own grant: read an object's lifecycle status (uploading, ready, failed, archived, deleted), its retention holds, deletion time and storage locations (roadmap §30.2 media.lifecycle.read). The routes serve production today but Media checks media.object.read on them; a token holding only this id is refused until Media's guard accepts it.",++ "permissions": [++ "media:read"++ ],++ "resourceConstraints": [++ "namespace"++ ],++ "quotaClass": "media-read",++ "events": [],++ "implementedBy": [++ "GET /api/v2/:app/objects/:id",++ "GET /api/v2/:app/objects/:id/holds",++ "GET /api/v2/:app/objects?status=&include_deleted="++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "media.lifecycle.transition",++ "version": "1.0",++ "owner": "media",++ "status": "planned",++ "visibility": "first-party",++ "description": "PLANNED as its own grant: change an object's lifecycle (roadmap §30.2 media.lifecycle.transition): restore a soft-deleted object within retention, place or release retention holds (moderation, dmca, creator_pin, admin, evidence), and move VOD bytes between storage tiers. Soft delete itself is DELETE /api/v2/:app/objects/:id; no route sets archived. Today restore checks media.object.upload and holds and tier moves take an app key only.",++ "permissions": [++ "media:write"++ ],++ "resourceConstraints": [++ "namespace",++ "owner"++ ],++ "quotaClass": "media-write",++ "events": [],++ "implementedBy": [++ "POST /api/v2/:app/objects/:id/restore",++ "POST /api/v2/:app/objects/:id/holds",++ "DELETE /api/v2/:app/objects/:id/holds/:holdId",++ "POST /api/v1/:app/admin/storage/tiers/move",++ "POST /api/v1/:app/admin/storage/tiers/bulk-move"++ ]++}nnew file mode 100644@@@ -0,0 +1,30 @@++{++ "id": "media.upload.create",++ "version": "1.0",++ "owner": "media",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED as its own grant: upload an object through the v2 objects API (roadmap §30.2 media.upload.init|sign|complete|abort, one grant because the steps share chained upload tokens): start a single presigned PUT or a resumable multipart session, send the bytes with the session token, then complete it (hash, quota and content-type checks, then ready) or abort it. The routes serve production today but Media's tenantAuth checks media.object.upload on them; a token holding only this id is refused until Media's guard accepts it.",++ "permissions": [++ "media:write"++ ],++ "resourceConstraints": [++ "namespace",++ "owner"++ ],++ "quotaClass": "media-upload",++ "events": [++ "media.object.uploaded"++ ],++ "implementedBy": [++ "POST /api/v2/:app/objects",++ "POST /api/v2/:app/objects/:id/upload-url",++ "PUT /api/v2/:app/objects/:id/content",++ "POST /api/v2/:app/objects/:id/complete",++ "POST /api/v2/:app/objects/:id/multipart",++ "GET /api/v2/:app/objects/:id/multipart/:uploadId",++ "PUT /api/v2/:app/objects/:id/multipart/:uploadId/parts/:n",++ "POST /api/v2/:app/objects/:id/multipart/:uploadId/complete",++ "DELETE /api/v2/:app/objects/:id/multipart/:uploadId"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "search.query.run",++ "version": "1.0",++ "owner": "search",++ "status": "active",++ "visibility": "public",++ "description": "Query the network index as anyone (roadmap §30.2 search.query, search.suggest, search.facets.read): anonymous callers get public, published documents only, and a signed-in person's Network token adds the documents whose access list names them or their role. Covers title suggestions, facet counts (facets=, facet.<key>=) and reading one document; a document the caller may not see is the same 404 as a missing one. Served publicly at search.openvibe.network. A first-party service asking for a person uses search.query.delegate (today Search requires that id for any service token).",++ "permissions": [++ "search:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "search-query",++ "events": [],++ "implementedBy": [++ "GET /api/v1/search",++ "GET /api/v1/suggest",++ "GET /api/v1/documents/:owner/:type/:id"++ ]++}@@@ -19,7 +19,12 @@"community.post.create","community.comment.write","community.comment.moderate",-- "community.pulse.write"++ "community.pulse.write",++ "community.space.read",++ "community.space.manage",++ "community.thread.read",++ "community.vote.set",++ "community.pulse.read"],"eventsProduced": [],"eventsConsumed": [],@@@ -20,7 +20,11 @@"live.chat_mirror.write","live.follower.read","live.lineage.resolve",-- "live.tips_delivery.write"++ "live.tips_delivery.write",++ "live.channel.read",++ "live.stream.read",++ "live.discovery.read",++ "live.owner.resolve"],"eventsProduced": ["live.stream.started",@@@ -14,7 +14,12 @@"health": "/healthz","capabilities": ["media.object.upload",-- "media.object.read"++ "media.object.read",++ "media.upload.create",++ "media.derivative.create",++ "media.derivative.read",++ "media.lifecycle.read",++ "media.lifecycle.transition"],"eventsProduced": ["media.clip.failed",nnew file mode 100644@@@ -0,0 +1,13 @@++{++ "version": "0.2.1",++ "id": "publishing",++ "name": "OpenVibe.Publishing",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Publishing",++ "domains": [],++ "capabilities": [],++ "eventsProduced": [],++ "eventsConsumed": [],++ "namespacesOwned": [],++ "notes": "Shared publishing packages for the publication products (openvibe-publishing, ADR-019): revisions and diffs, scheduling, taxonomy, citations and the fact gate, media attachments, discussion references, the SEO indexability gate, feeds, redirects, authorship, index hooks (the *.index_document.* and publication events) and SSR helpers. A released library, not a runtime or an authority: tagged releases installed from https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/<tag> (v0.2.1 on 2026-09-23) and used by Wiki, Blog, News, Reviews, Deals, Coupons and Trade. Nothing to deploy or poll. The roadmap §30.2 Publishing row (publishing.revision.*, schedule.*, citation.*, seo.evaluate, feed.read, redirect.resolve) is deliberately not registered as capabilities: a package has no boundary where a grant could be checked, and every route that performs those actions belongs to a product that already names it (wiki.page.read|create, wiki.revision.propose|publish|revert, wiki.citation.attach, blog.post.*, blog.feed.read, news.story.read|revise|publish|retract, news.source.attach). The SDK's publishing subpath wraps those product capabilities."++}@@@ -15,7 +15,8 @@"ready": "/api/ready","capabilities": ["search.document.write",-- "search.query.delegate"++ "search.query.delegate",++ "search.query.run"],"eventsProduced": ["search.document.indexed",@@@ -352,6 +352,33 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscriptok(/Package signing/.test(adr) && /Review process/.test(adr) && /open decisions for the owner/.test(adr) && /Monetization only through Billing/.test(adr), 'ADR-013 names signing and review as open owner decisions and monetization through Billing');}++// ── Capability catalog gaps (v0.34.0, roadmap §30.2) ──────────────────────++// Public reads that production serves to anyone are active; an action whose routes exist but are++// guarded by an older id today is planned as its own grant, names those routes, and says which id++// guards them; the package-only Publishing registers no capabilities.++{++ const gaps = {++ 'live.channel.read': 'active', 'live.stream.read': 'active', 'live.discovery.read': 'active', 'live.owner.resolve': 'planned',++ 'media.upload.create': 'planned', 'media.derivative.create': 'planned', 'media.derivative.read': 'planned', 'media.lifecycle.read': 'planned', 'media.lifecycle.transition': 'planned',++ 'community.space.read': 'active', 'community.space.manage': 'planned', 'community.thread.read': 'active', 'community.vote.set': 'planned', 'community.pulse.read': 'active',++ 'search.query.run': 'active',++ };++ for (const [id, status] of Object.entries(gaps)) {++ const c = capabilities.get(id);++ ok(c && c.status === status && id.startsWith(`${c.owner}.`), `${id} is ${status} and owned by its prefix`);++ if (status === 'planned') ok(c.description.startsWith('PLANNED'), `${id}: a planned capability says so first`);++ if (status === 'active') ok(c.visibility === 'public' && c.implementedBy.every(r => r.startsWith('GET ')), `${id}: an active §30.2 read is a public GET`);++ }++ for (const id of ['media.upload.create', 'media.derivative.create']) ok(/media\.object\.upload/.test(capabilities.get(id).description) && capabilities.get(id).implementedBy.length > 0, `${id} names its routes and the id that guards them today`);++ ok(/media\.object\.read/.test(capabilities.get('media.lifecycle.read').description), 'media.lifecycle.read says media.object.read guards its routes today');++ ok(/community\.post\.create/.test(capabilities.get('community.vote.set').description) && /community\.comment\.write/.test(capabilities.get('community.vote.set').description), 'community.vote.set says which grants cover its routes today');++ ok(capabilities.get('live.owner.resolve').implementedBy.length === 0 && capabilities.get('live.owner.resolve').outputSchema === 'lineage.resolution@1', 'live.owner.resolve has no public route yet and answers lineage.resolution@1');++ ok(JSON.stringify(capabilities.get('search.query.run').implementedBy) === JSON.stringify(capabilities.get('search.query.delegate').implementedBy), 'search.query.run and search.query.delegate are the same routes, asked anonymously or for a person');++ const pub = services.get('publishing');++ ok(pub && pub.capabilities.length === 0 && pub.domains.length === 0 && !pub.health && /not registered as capabilities/.test(pub.notes), 'publishing is a library manifest with no capabilities (ADR-019)');++ ok(!capabilities.manifests.some(c => c.id.startsWith('publishing.')), 'no publishing.* capability: the product capabilities cover those routes');++}++// ── Retired services (v0.34.0) ───────────────────────────────────────────// A retired manifest offers nothing: no domain, capability, event or namespace. Realtime was closed// by ADR-005 (realtime runs inside OpenVibe.Events), so it is retired, not a placeholder.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "community.pulse.read",
+ "version": "1.0",
+ "owner": "community",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read the network's public activity feed (Pulse): entity reference, title, URL and origin (user, ai or system), newest first with a cursor. Only public items are ever stored. Served to anyone (CORS allow-list for browsers). Live's /api/home/pulse is a different, Live-only feed.",
+ "outputSchema": "common.entity-ref@1",
+ "permissions": [
+ "community:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "community-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/pulse",
+ "GET /pulse"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "community.space.manage",
+ "version": "1.0",
+ "owner": "community",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "PLANNED: staff administration of spaces: create, rename, set visibility, and gate a space to one creator's VIP members. Only the members-only gate exists today (checked with community.comment.moderate plus a moderator); spaces are seeded by Community's database setup and nothing creates or edits them.",
+ "permissions": [
+ "community:moderate"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "community-moderate",
+ "events": [],
+ "implementedBy": [
+ "PUT /api/v1/spaces/:space/members-only"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "community.space.read",
+ "version": "1.0",
+ "owner": "community",
+ "status": "active",
+ "visibility": "public",
+ "description": "List the spaces the caller can open and read one space's metadata (roadmap §30.2 community.space.*). Anonymous callers see public spaces, signed-in people also members spaces, moderators also staff spaces; members-only (VIP) spaces are flagged. Served to anyone; Community checks space visibility itself.",
+ "outputSchema": "common.entity-ref@1",
+ "permissions": [
+ "community:read"
+ ],
+ "resourceConstraints": [
+ "space_member"
+ ],
+ "quotaClass": "community-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/spaces",
+ "GET /api/v1/spaces/:space",
+ "GET /s"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "id": "community.thread.read",
+ "version": "1.0",
+ "owner": "community",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read a space's threads (hot, new or top, paged) and one thread with its posts, plus the RSS feeds. Community checks space visibility and the VIP gate (403 vip.members_only). Creating threads and replies stays community.post.create.",
+ "outputSchema": "common.entity-ref@1",
+ "permissions": [
+ "community:read"
+ ],
+ "resourceConstraints": [
+ "space_member"
+ ],
+ "quotaClass": "community-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/spaces/:space/threads",
+ "GET /api/v1/spaces/:space/threads/:slug",
+ "GET /s/:space",
+ "GET /s/:space/t/:slug",
+ "GET /s/feed.xml",
+ "GET /s/:space/feed.xml"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "community.vote.set",
+ "version": "1.0",
+ "owner": "community",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED as its own grant: up-vote, down-vote or clear a vote (value 1, -1 or 0; 0 removes it, so there is no separate vote.remove) on a thread or a comment as the acting person (roadmap §30.2 community.vote.set|remove). Locked threads refuse; 60 votes a minute per person. The routes serve production today, guarded by community.post.create (threads) and community.comment.write (comments), which keep covering them.",
+ "inputSchema": "common.entity-ref@1",
+ "permissions": [
+ "community:vote"
+ ],
+ "resourceConstraints": [
+ "space_member"
+ ],
+ "quotaClass": "community-vote",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/spaces/:space/threads/:slug/votes",
+ "POST /api/v1/comments/:commentId/votes",
+ "POST /s/:space/t/:slug/vote"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "live.channel.read",
+ "version": "1.0",
+ "owner": "live",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read a channel's public profile by username (the /@username page's data): metadata, owner display fields, follower count, live streams with playback endpoints, slots, and its public VODs and clips from Media. The owner and staff also see private listings; ingest keys are never returned. Anonymous callers are served (optionalAuth); no grant is checked at Live's boundary, and /api allows only OpenVibe origins for browsers. There is no public lookup by channel id yet (roadmap §30.2, sdk-live).",
+ "permissions": [
+ "live:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "live-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/streams/channel/:username",
+ "GET /api/streams/channel/:username/live",
+ "GET /api/streams/channel/:username/popular",
+ "GET /api/streams/channel/:username/clips-taken",
+ "GET /api/streams/channel/:username/bio-en",
+ "GET /api/auth/user/:username"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+{
+ "id": "live.discovery.read",
+ "version": "1.0",
+ "owner": "live",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read Live's discovery surfaces (roadmap §30.2 live.discovery.*): the featured rotation, \"watch instead\" picks, the star of the day, the digest, home numbers, recently online streamers, recent VODs, and the content and AI-moments feeds. Public items only. There is no category directory and no channel search route yet; channel search belongs to OpenVibe.Search once Live publishes index documents.",
+ "permissions": [
+ "live:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "live-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/home/featured",
+ "GET /api/home/discover",
+ "GET /api/home/star",
+ "GET /api/home/digest",
+ "GET /api/home/hero",
+ "GET /api/home/stats-live",
+ "GET /api/home/stats/series/:metric",
+ "GET /api/home/pulse",
+ "GET /api/streams/recently-online",
+ "GET /api/streams/recent-vods",
+ "GET /api/content/feed",
+ "GET /api/content/moments"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "live.owner.resolve",
+ "version": "1.0",
+ "owner": "live",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED: the public form of the channel/owner resolver (roadmap §30.2, §15.10): which channel (id, slug, owner subject) a slug, slot, stream, or public VOD or clip belongs to, and how that was decided, answering unresolved rather than guessing; a display name alone resolves nothing. Unlike the internal live.lineage.resolve (GET|POST /internal/lineage/resolve, loopback, service token) it refuses legacy, Network and Media ids and treats private VODs and clips as not found. No public route exists yet; GET /api/streams/:id and GET /api/vods/:id/context answer part of it.",
+ "inputSchema": "lineage.resolve-request@1",
+ "outputSchema": "lineage.resolution@1",
+ "permissions": [
+ "live:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "live-read",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "live.stream.read",
+ "version": "1.0",
+ "owner": "live",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read broadcasts: who is live now with viewer counts, one stream with its endpoint, cameras and controls, recently ended streams with their public VOD, a slot's current session, and the went-live stream (/api/live-events, open CORS). Stream keys and the home ZIP are redacted. Anonymous callers are served; no grant is checked at Live's boundary (roadmap §30.2, sdk-live).",
+ "permissions": [
+ "live:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "live-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/streams",
+ "GET /api/streams/:id",
+ "GET /api/streams/recent",
+ "GET /api/streams/channel/:username/resolve/:ref",
+ "GET /api/live-events"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+{
+ "id": "media.derivative.create",
+ "version": "1.0",
+ "owner": "media",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED as its own grant: derive new objects from an existing one as idempotent jobs (roadmap §30.2 media.derivative.*): object.split, object.remux or thumbnail.regenerate, and approve a proposed job. Derived objects are private and linked derived_from; the source never changes. Waveforms, sprites and previews are not built yet (roadmap §28.3 m3). The routes serve production today but Media checks media.object.upload on the jobs API (and an app key on the v1 thumbnail route); a token holding only this id is refused until Media's guard accepts it.",
+ "permissions": [
+ "media:write"
+ ],
+ "resourceConstraints": [
+ "namespace",
+ "owner"
+ ],
+ "quotaClass": "media-job",
+ "events": [
+ "media.job.proposed",
+ "media.job.queued",
+ "media.job.started",
+ "media.job.retrying",
+ "media.job.succeeded",
+ "media.job.failed",
+ "media.job.cancelled"
+ ],
+ "implementedBy": [
+ "POST /api/v2/:app/jobs",
+ "POST /api/v2/:app/jobs/:jobId/approve",
+ "POST /api/v1/:app/thumbnails/:kind/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "media.derivative.read",
+ "version": "1.0",
+ "owner": "media",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED: list an object's derivatives and variants with how they relate (derived_from, variant kind), and read public thumbnails and live frames. Nothing returns the relationship and variant tables yet; the public thumbnail and live-frame routes and the job list filtered by object are the nearest reads today.",
+ "permissions": [
+ "media:read"
+ ],
+ "resourceConstraints": [
+ "namespace"
+ ],
+ "quotaClass": "media-read",
+ "events": [],
+ "implementedBy": [
+ "GET /t/:id",
+ "GET /live/:sel/frame.jpg",
+ "GET /api/v2/:app/jobs?object_id="
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "media.lifecycle.read",
+ "version": "1.0",
+ "owner": "media",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED as its own grant: read an object's lifecycle status (uploading, ready, failed, archived, deleted), its retention holds, deletion time and storage locations (roadmap §30.2 media.lifecycle.read). The routes serve production today but Media checks media.object.read on them; a token holding only this id is refused until Media's guard accepts it.",
+ "permissions": [
+ "media:read"
+ ],
+ "resourceConstraints": [
+ "namespace"
+ ],
+ "quotaClass": "media-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v2/:app/objects/:id",
+ "GET /api/v2/:app/objects/:id/holds",
+ "GET /api/v2/:app/objects?status=&include_deleted="
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "media.lifecycle.transition",
+ "version": "1.0",
+ "owner": "media",
+ "status": "planned",
+ "visibility": "first-party",
+ "description": "PLANNED as its own grant: change an object's lifecycle (roadmap §30.2 media.lifecycle.transition): restore a soft-deleted object within retention, place or release retention holds (moderation, dmca, creator_pin, admin, evidence), and move VOD bytes between storage tiers. Soft delete itself is DELETE /api/v2/:app/objects/:id; no route sets archived. Today restore checks media.object.upload and holds and tier moves take an app key only.",
+ "permissions": [
+ "media:write"
+ ],
+ "resourceConstraints": [
+ "namespace",
+ "owner"
+ ],
+ "quotaClass": "media-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v2/:app/objects/:id/restore",
+ "POST /api/v2/:app/objects/:id/holds",
+ "DELETE /api/v2/:app/objects/:id/holds/:holdId",
+ "POST /api/v1/:app/admin/storage/tiers/move",
+ "POST /api/v1/:app/admin/storage/tiers/bulk-move"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+{
+ "id": "media.upload.create",
+ "version": "1.0",
+ "owner": "media",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED as its own grant: upload an object through the v2 objects API (roadmap §30.2 media.upload.init|sign|complete|abort, one grant because the steps share chained upload tokens): start a single presigned PUT or a resumable multipart session, send the bytes with the session token, then complete it (hash, quota and content-type checks, then ready) or abort it. The routes serve production today but Media's tenantAuth checks media.object.upload on them; a token holding only this id is refused until Media's guard accepts it.",
+ "permissions": [
+ "media:write"
+ ],
+ "resourceConstraints": [
+ "namespace",
+ "owner"
+ ],
+ "quotaClass": "media-upload",
+ "events": [
+ "media.object.uploaded"
+ ],
+ "implementedBy": [
+ "POST /api/v2/:app/objects",
+ "POST /api/v2/:app/objects/:id/upload-url",
+ "PUT /api/v2/:app/objects/:id/content",
+ "POST /api/v2/:app/objects/:id/complete",
+ "POST /api/v2/:app/objects/:id/multipart",
+ "GET /api/v2/:app/objects/:id/multipart/:uploadId",
+ "PUT /api/v2/:app/objects/:id/multipart/:uploadId/parts/:n",
+ "POST /api/v2/:app/objects/:id/multipart/:uploadId/complete",
+ "DELETE /api/v2/:app/objects/:id/multipart/:uploadId"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "search.query.run",
+ "version": "1.0",
+ "owner": "search",
+ "status": "active",
+ "visibility": "public",
+ "description": "Query the network index as anyone (roadmap §30.2 search.query, search.suggest, search.facets.read): anonymous callers get public, published documents only, and a signed-in person's Network token adds the documents whose access list names them or their role. Covers title suggestions, facet counts (facets=, facet.<key>=) and reading one document; a document the caller may not see is the same 404 as a missing one. Served publicly at search.openvibe.network. A first-party service asking for a person uses search.query.delegate (today Search requires that id for any service token).",
+ "permissions": [
+ "search:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "search-query",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/search",
+ "GET /api/v1/suggest",
+ "GET /api/v1/documents/:owner/:type/:id"
+ ]
+}
@@ -19,7 +19,12 @@
@@ -19,7 +19,12 @@
"community.post.create",
"community.post.create",
"community.comment.write",
"community.comment.write",
"community.comment.moderate",
"community.comment.moderate",
- "community.pulse.write"
+ "community.pulse.write",
+ "community.space.read",
+ "community.space.manage",
+ "community.thread.read",
+ "community.vote.set",
+ "community.pulse.read"
],
],
"eventsProduced": [],
"eventsProduced": [],
"eventsConsumed": [],
"eventsConsumed": [],
@@ -20,7 +20,11 @@
@@ -20,7 +20,11 @@
"live.chat_mirror.write",
"live.chat_mirror.write",
"live.follower.read",
"live.follower.read",
"live.lineage.resolve",
"live.lineage.resolve",
- "live.tips_delivery.write"
+ "live.tips_delivery.write",
+ "live.channel.read",
+ "live.stream.read",
+ "live.discovery.read",
+ "live.owner.resolve"
],
],
"eventsProduced": [
"eventsProduced": [
"live.stream.started",
"live.stream.started",
@@ -14,7 +14,12 @@
@@ -14,7 +14,12 @@
"health": "/healthz",
"health": "/healthz",
"capabilities": [
"capabilities": [
"media.object.upload",
"media.object.upload",
- "media.object.read"
+ "media.object.read",
+ "media.upload.create",
+ "media.derivative.create",
+ "media.derivative.read",
+ "media.lifecycle.read",
+ "media.lifecycle.transition"
],
],
"eventsProduced": [
"eventsProduced": [
"media.clip.failed",
"media.clip.failed",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+{
+ "version": "0.2.1",
+ "id": "publishing",
+ "name": "OpenVibe.Publishing",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Publishing",
+ "domains": [],
+ "capabilities": [],
+ "eventsProduced": [],
+ "eventsConsumed": [],
+ "namespacesOwned": [],
+ "notes": "Shared publishing packages for the publication products (openvibe-publishing, ADR-019): revisions and diffs, scheduling, taxonomy, citations and the fact gate, media attachments, discussion references, the SEO indexability gate, feeds, redirects, authorship, index hooks (the *.index_document.* and publication events) and SSR helpers. A released library, not a runtime or an authority: tagged releases installed from https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/<tag> (v0.2.1 on 2026-09-23) and used by Wiki, Blog, News, Reviews, Deals, Coupons and Trade. Nothing to deploy or poll. The roadmap §30.2 Publishing row (publishing.revision.*, schedule.*, citation.*, seo.evaluate, feed.read, redirect.resolve) is deliberately not registered as capabilities: a package has no boundary where a grant could be checked, and every route that performs those actions belongs to a product that already names it (wiki.page.read|create, wiki.revision.propose|publish|revert, wiki.citation.attach, blog.post.*, blog.feed.read, news.story.read|revise|publish|retract, news.source.attach). The SDK's publishing subpath wraps those product capabilities."
+}
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
"ready": "/api/ready",
"ready": "/api/ready",
"capabilities": [
"capabilities": [
"search.document.write",
"search.document.write",
- "search.query.delegate"
+ "search.query.delegate",
+ "search.query.run"
],
],
"eventsProduced": [
"eventsProduced": [
"search.document.indexed",
"search.document.indexed",
@@ -352,6 +352,33 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
@@ -352,6 +352,33 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(/Package signing/.test(adr) && /Review process/.test(adr) && /open decisions for the owner/.test(adr) && /Monetization only through Billing/.test(adr), 'ADR-013 names signing and review as open owner decisions and monetization through Billing');
ok(/Package signing/.test(adr) && /Review process/.test(adr) && /open decisions for the owner/.test(adr) && /Monetization only through Billing/.test(adr), 'ADR-013 names signing and review as open owner decisions and monetization through Billing');
}
}
+// ── Capability catalog gaps (v0.34.0, roadmap §30.2) ──────────────────────
+// Public reads that production serves to anyone are active; an action whose routes exist but are
+// guarded by an older id today is planned as its own grant, names those routes, and says which id
+// guards them; the package-only Publishing registers no capabilities.
+{
+ const gaps = {
+ 'live.channel.read': 'active', 'live.stream.read': 'active', 'live.discovery.read': 'active', 'live.owner.resolve': 'planned',
+ 'media.upload.create': 'planned', 'media.derivative.create': 'planned', 'media.derivative.read': 'planned', 'media.lifecycle.read': 'planned', 'media.lifecycle.transition': 'planned',
+ 'community.space.read': 'active', 'community.space.manage': 'planned', 'community.thread.read': 'active', 'community.vote.set': 'planned', 'community.pulse.read': 'active',
+ 'search.query.run': 'active',
+ };
+ for (const [id, status] of Object.entries(gaps)) {
+ const c = capabilities.get(id);
+ ok(c && c.status === status && id.startsWith(`${c.owner}.`), `${id} is ${status} and owned by its prefix`);
+ if (status === 'planned') ok(c.description.startsWith('PLANNED'), `${id}: a planned capability says so first`);
+ if (status === 'active') ok(c.visibility === 'public' && c.implementedBy.every(r => r.startsWith('GET ')), `${id}: an active §30.2 read is a public GET`);
+ }
+ for (const id of ['media.upload.create', 'media.derivative.create']) ok(/media\.object\.upload/.test(capabilities.get(id).description) && capabilities.get(id).implementedBy.length > 0, `${id} names its routes and the id that guards them today`);
+ ok(/media\.object\.read/.test(capabilities.get('media.lifecycle.read').description), 'media.lifecycle.read says media.object.read guards its routes today');
+ ok(/community\.post\.create/.test(capabilities.get('community.vote.set').description) && /community\.comment\.write/.test(capabilities.get('community.vote.set').description), 'community.vote.set says which grants cover its routes today');
+ ok(capabilities.get('live.owner.resolve').implementedBy.length === 0 && capabilities.get('live.owner.resolve').outputSchema === 'lineage.resolution@1', 'live.owner.resolve has no public route yet and answers lineage.resolution@1');
+ ok(JSON.stringify(capabilities.get('search.query.run').implementedBy) === JSON.stringify(capabilities.get('search.query.delegate').implementedBy), 'search.query.run and search.query.delegate are the same routes, asked anonymously or for a person');
+ const pub = services.get('publishing');
+ ok(pub && pub.capabilities.length === 0 && pub.domains.length === 0 && !pub.health && /not registered as capabilities/.test(pub.notes), 'publishing is a library manifest with no capabilities (ADR-019)');
+ ok(!capabilities.manifests.some(c => c.id.startsWith('publishing.')), 'no publishing.* capability: the product capabilities cover those routes');
+}
+
// ── Retired services (v0.34.0) ───────────────────────────────────────────
// ── Retired services (v0.34.0) ───────────────────────────────────────────
// A retired manifest offers nothing: no domain, capability, event or namespace. Realtime was closed
// A retired manifest offers nothing: no domain, capability, event or namespace. Realtime was closed
// by ADR-005 (realtime runs inside OpenVibe.Events), so it is retired, not a placeholder.
// by ADR-005 (realtime runs inside OpenVibe.Events), so it is retired, not a placeholder.