openvibers/openvibe.services Commits
Services: your resources (a person's own, in no project) over the owner filter (#34)
Changes
@@@ -31,7 +31,7 @@ A place where a developer outside the network goes from an OpenVibe account to a| Surface | Path | Notes ||---|---|---|--| Portal | `/projects`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Services' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |++| Portal | `/projects`, `/projects/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Services' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. || OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. || Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` and v2, `signDeliveryHeaders`) for any event type the contracts catalog lists. || Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |@@@ -224,7 +224,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4930npx openvibe-contracts-check --service services --src server```--Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); the resources page (any member, a stranger refused before any authority is asked, nothing of another project, malformed filters refused, unreadable authorities named, names escaped, a cursor walk that never repeats or skips a resource); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `services.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released services manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).++Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); the resources pages (your resources: only the signed-in person's own rows in no project, even from an authority that ignores `?owner=`, never another subject named in a query; a project's: any member, a stranger refused before any authority is asked, nothing of another project, malformed filters refused, unreadable authorities named, names escaped, a cursor walk that never repeats or skips a resource); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `services.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released services manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).## Acceptance (must be true before "done")@@@ -252,6 +252,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.131.0--- openvibe-sdk: v0.38.0++- openvibe-sdk: v0.41.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -8,11 +8,11 @@"port": 4930,"domain": "openvibe.services","contracts": "openvibe-contracts v0.131.0",-- "sdk": "openvibe-sdk v0.38.0",++ "sdk": "openvibe-sdk v0.41.0","shared": "openvibe-shared v3.0.0","packages": ["openvibe-contracts v0.131.0",-- "openvibe-sdk v0.38.0",++ "openvibe-sdk v0.41.0","openvibe-shared v3.0.0"],"capabilities": [@@@ -17,7 +17,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"},@@@ -906,9 +906,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.38.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",-- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",++ "version": "0.41.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0",++ "integrity": "sha512-joQfMWDTIt79qtTlijXoSMSqf8ZoYe8yv3T5Pjj+JJJBbUgA37BX+TOOHs8KX7znAmJZbPqtoYpQuWGjkp0iUQ==","license": "MIT","engines": {"node": ">=20"@@@ -28,7 +28,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"},@@@ -123,6 +123,7 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((phtml`<a href="/projects/${p.id}">${p.name}</a><br><code class="small">${p.id}</code>${p.archived_at ? html` ${badge('archived', 'bad')}` : ''}`,p.role || (req.viewer.staff ? 'staff' : '—'), (p.environments || []).join(', '), p.counts ? p.counts.apps : '—', time(p.created_at),]), { empty: 'You are not a member of any project yet.' })}++<p><a href="/projects/mine">Your resources</a> <span class="muted small">what you own outside any project: robots, chat rooms, streams, watches, Actor tasks</span></p><h2>Create a project</h2><form method="post" action="/projects" class="inline-form">${csrfField(csrf(req))}<label>Name <input name="name" required maxlength="80"></label><button type="submit">Create</button></form>@@@ -143,6 +144,19 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((preturn got.data;}++ // Yours: what the signed-in person owns outside any project. Only their own subject is ever asked for.++ r.get('/mine', async (req, res) => {++ await resourcesPage(req, res, {++ scope: { owner: req.viewer.subject }, base: '/projects/mine',++ title: 'Your resources', crumbs: [{ label: 'Projects', href: '/projects' }, { label: 'Your resources' }],++ heading: html`<h1>Your resources</h1>`,++ intro: 'What you own yourself, outside any project: your robots, chat rooms, streams, watches, Actor tasks and the rest. Each OpenVibe service answers for its own, read just now and merged here; the service that holds each one owns it.',++ empty: 'Nothing of yours outside a project yet. A robot you add on OpenVibe.Bot, a room you open on OpenVibe.Chat, a stream on OpenRestream or a watch on OpenVibe.Watch appears here.',++ footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>.`,++ ownerColumn: false,++ });++ });++r.get('/:project', idParams, async (req, res) => {const project = await loadProject(req, res);if (!project) return;@@@ -338,20 +352,20 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$});});-- // ── Resources (ADR-048, plan T13): the merged index, as the API serves it to a member ──-- // Network decides membership (loadProject) before any authority is asked; the index then reads every-- // authority for this project only and keeps the page to it (server/resources.js).-- r.get('/:project/resources', idParams, async (req, res) => {-- const project = await loadProject(req, res);-- if (!project) return;-- const base = `/projects/${project.id}/resources`;++ // ── Resources (ADR-048, plan T13): the merged index, as the API serves it ──++ /**++ * One page of the merged index for one scope: a project (any member; Network decided membership before this), or++ * the signed-in person's own resources (`owner`: the ones in no project, each authority filtering by its own owner++ * column, server/resources.js keeping the page to them).++ */++ async function resourcesPage(req, res, { scope, base, title, crumbs, heading, intro, empty, footer, ownerColumn }) {const services = ctx.index.adapters.map((a) => a.authority.id).sort();const q = { service: req.query.service, kind: req.query.kind, cursor: req.query.cursor };let filter = { service: null, kind: null, cursor: null };let result = null, refused = null;try {filter = listQuery(q, config);-- result = await ctx.index.list({ ...filter, project: project.id, limit: RESOURCES_PER_PAGE });++ result = await ctx.index.list({ ...filter, ...scope, limit: RESOURCES_PER_PAGE });} catch (err) {if (!(err instanceof ServiceError) || err.status !== 400) throw err;refused = err;@@@ -371,26 +385,42 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$const rows = result ? result.resources.map((x) => [html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,html`<a href="${link({ kind: x.kind, service: null })}"><code>${x.kind}</code></a>`,-- stateBadge(x.state), ownerCell(x.owner), time(x.created_at), time(x.updated_at),++ stateBadge(x.state),++ ...(ownerColumn ? [ownerCell(x.owner)] : []),++ time(x.created_at), time(x.updated_at),]) : [];const partial = result ? result.partial : [];const filtered = Boolean(filter.service || filter.kind);page(req, res, {-- title: `Resources · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Resources' }],-- body: html`<h1>Resources <small>${project.name}</small></h1>--<p class="muted">Each OpenVibe service answers for its own resources in this project, read just now and merged here. Services shows them; the service that holds each one owns it.</p>++ title, crumbs,++ body: html`${heading}++<p class="muted">${intro}</p><form method="get" action="${base}" class="inline-form"><label>Service <select name="service"><option value="">every service</option>${services.map((s) => html`<option value="${s}"${s === filter.service ? raw(' selected') : ''}>${s}</option>`)}</select></label><label>Kind <input name="kind" value="${filter.kind || ''}" placeholder="media.object" maxlength="72" spellcheck="false"></label><button type="submit">Show</button>${filtered ? html` <a href="${base}">Clear</a>` : ''}</form>${refused ? notice(html`That filter was refused: ${refused.detail || refused.message} (<code>${refused.code}</code>).`, 'bad') : ''}${partial.length ? notice(html`Not on this page: ${partial.map((p, i) => html`${i ? ', ' : ''}<strong>${p.service}</strong> (<code>${p.code}</code>)`)} could not be read just now. Everything else is here; reload to try ${partial.length === 1 ? 'it' : 'them'} again.`, 'warn') : ''}--${result ? table(['Resource', 'Kind', 'State', 'Owner', 'Created', 'Updated'], rows, {-- empty: filtered ? 'No resources match this filter.' : 'No resources in this project yet. Releases, Media objects, event subscriptions and the other things apps in this project create appear here.',++${result ? table(['Resource', 'Kind', 'State', ...(ownerColumn ? ['Owner'] : []), 'Created', 'Updated'], rows, {++ empty: filtered ? 'No resources match this filter.' : empty,}) : ''}${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? html`<a href="${link({})}">First page</a>` : ''}${filter.cursor && result.next_cursor ? ' · ' : ''}${result.next_cursor ? html`<a href="${link({ cursor: result.next_cursor })}">Next page</a>` : ''}</p>` : ''}--<p class="muted small">Things a person owns outside any project (robots, chat rooms, streams) are on those products' own pages. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>.</p>`,++<p class="muted small">${footer}</p>`,}, refused ? 400 : 200);++ }++++ r.get('/:project/resources', idParams, async (req, res) => {++ const project = await loadProject(req, res);++ if (!project) return;++ await resourcesPage(req, res, {++ scope: { project: project.id }, base: `/projects/${project.id}/resources`,++ title: `Resources · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Resources' }],++ heading: html`<h1>Resources <small>${project.name}</small></h1>`,++ intro: 'Each OpenVibe service answers for its own resources in this project, read just now and merged here. Services shows them; the service that holds each one owns it.',++ empty: 'No resources in this project yet. Releases, Media objects, event subscriptions and the other things apps in this project create appear here.',++ footer: html`What you own outside any project (robots, chat rooms, streams) is on <a href="/projects/mine">your resources</a>. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>.`,++ ownerColumn: true,++ });});// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──@@@ -12,7 +12,7 @@* - the authority set (one adapter each, its own client-credentials token; server/authorities/),* - a deterministic total order over the merged resources (service, ovrn, id) and an opaque keyset* cursor into it, so a page walk never repeats or skips a resource,-- * - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a++ * - a local filter for `project` / `kind` / `service` / `owner` on top of the authorities' own answers: a* scoped read (a person's, above all) must never show a row outside its scope even if an authority* ignores a query parameter,* - and the partial answer. common.resource-list-result@1's `partial` array (contracts 1.1.0) names@@@ -85,17 +85,21 @@ function createResourceIndexService({ config, authorities, tokens, self = null,* authorities whose rows this page could not include (their cursor walk failed, timed out or was* refused), `code` being the problem code each one answered, in the caller's terms.*/-- async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {++ async function list({ project = null, kind = null, service = null, owner = null, cursor = null, limit = config.index.defaultLimit } = {}) {const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;if (service && !set.length) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);if (!set.length) return { resources: [], next_cursor: null, partial: [] };-- const { resources, stale } = await indexFor(set).list({ project, kind, limit: config.index.pageSize });++ const { resources, stale } = await indexFor(set).list({ project, kind, owner, limit: config.index.pageSize });let merged = resources;// The authority filters on its own; this keeps a scoped read scoped even if one does not// (project_id is optional on common.resource-summary@1, so a row without one fails a project filter).if (service) merged = merged.filter((r) => r.service === service);if (kind) merged = merged.filter((r) => r.kind === kind);if (project) merged = merged.filter((r) => r.project_id === project);++ // A person's own (owner): each authority filters on its owner column (openvibe-sdk 0.41 sends ?owner=); this++ // keeps the page to them even if one does not yet, and to what is in no project (a project's resources are++ // read through the project, where membership decides).++ if (owner) merged = merged.filter((r) => r.owner && r.owner.id === owner && !r.project_id);merged = merged.slice().sort((a, b) => compare(sortKeyOf(a), sortKeyOf(b)));const after = cursor ? decodeCursor(cursor) : null;if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');@@@ -141,6 +141,49 @@ const { boot, check, done } = require('./helpers/boot');assert.deepStrictEqual(new Set(seen), want);});++ await check('your resources: the person\'s own, in no project, asked of every authority by owner and kept to them', async () => {++ const { createResourceIndexService } = require('../server/resources');++ const bob = t.network.addUser('bob');++ const at = '2026-10-01T00:00:00.000Z';++ const rob = (id, owner, extra = {}) => ({ id, kind: 'bot.robot', service: 'bot', name: `Robot ${id.slice(-2)}`, state: 'ready', created_at: at, ...(owner ? { owner: { type: 'user', id: owner } } : {}), ...extra });++ const R1 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN01', R2 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN02', R3 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN03', R4 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN04';++ const seen = [];++ // An authority that has not added the owner filter: it answers everyone's, including a projected row of the owner.++ const careless = {++ authority: Object.freeze({ id: 'bot', name: 'OpenVibe.Bot', internalOrigin: 'http://bot.fake', publicOrigin: 'https://openvibe.bot', audience: 'openvibe.bot', capability: 'bot.resource.read', status: 'in-process' }),++ origin: 'http://bot.fake',++ async fetchAs(url) {++ seen.push(new URL(url));++ const body = { resources: [rob(R1, owner.subject), rob(R2, bob.subject), rob(R3, null), rob(R4, owner.subject, { project_id: projectId })], next_cursor: null };++ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });++ },++ get: async () => ({ status: 404, body: {} }),++ };++ const index = createResourceIndexService({ config: t.ctx.config, authorities: { list: () => [] }, tokens: null, self: careless, log: { warn() {} } });++ const direct = await index.list({ owner: owner.subject });++ assert.deepStrictEqual(direct.resources.map((x) => x.id), [R1], 'only the owner\'s, and only outside a project');++ assert.strictEqual(seen[0].searchParams.get('owner'), owner.subject, 'owner is asked of the authority');++++ const kept = t.ctx.index;++ t.ctx.index = index;++ try {++ const r = await t.get('/projects/mine', { as: owner });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.text, /<h1>Your resources<\/h1>/);++ assert.ok(r.text.includes(R1) && !r.text.includes(R2) && !r.text.includes(R3) && !r.text.includes(R4), 'the person\'s own row only');++ assert.ok(!r.text.includes('<th scope="col">Owner</th>'), 'no owner column: all of it is theirs');++ assert.strictEqual(seen.at(-1).searchParams.get('owner'), owner.subject, 'the signed-in person\'s subject, never a query value');++ const forged = await t.get(`/projects/mine?owner=${bob.subject}`, { as: owner });++ assert.ok(!forged.text.includes(R2), 'a query cannot name someone else');++ assert.strictEqual(seen.at(-1).searchParams.get('owner'), owner.subject);++ const b = await t.get('/projects/mine', { as: bob });++ assert.ok(b.text.includes(R2) && !b.text.includes(R1), 'each person sees their own');++ assert.strictEqual((await t.get('/projects/mine')).status, 401, 'signed out: sign in');++ const list = await t.get('/projects', { as: owner });++ assert.ok(list.text.includes('<a href="/projects/mine">Your resources</a>'), 'the projects page links it');++ } finally { t.ctx.index = kept; }++ });++t.ctx.index.list = realList;await done(t);})();
@@ -31,7 +31,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -31,7 +31,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
| Surface | Path | Notes |
| Surface | Path | Notes |
|---|---|---|
|---|---|---|
-| Portal | `/projects`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Services' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |
+| Portal | `/projects`, `/projects/mine`, `/projects/:project`, `/projects/:project/resources`, `/projects/:project/apps/:app` | Create projects; add members; **your resources** (`/projects/mine`: what the signed-in person owns outside any project, such as robots, chat rooms, streams, watches and Actor tasks. Every authority is asked with `?owner=<their subject>` (openvibe-sdk 0.41), and the merged page is kept to their own rows in no project, so an authority without the filter yet never shows another person's. Only the session's own subject is ever asked for); **resources** (every member and staff: `/projects/:project/resources?service=&kind=`, the merged index for that one project, server-rendered: name and OVRN, kind, state, owner, created and updated, 50 a page on the index's own cursor; Network confirms membership before any authority is asked, and an authority that could not be read is named with the problem it gave); create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Services' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
| Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` and v2, `signDeliveryHeaders`) for any event type the contracts catalog lists. |
| Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` and v2, `signDeliveryHeaders`) for any event type the contracts catalog lists. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
@@ -224,7 +224,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4930
@@ -224,7 +224,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4930
npx openvibe-contracts-check --service services --src server
npx openvibe-contracts-check --service services --src server
```
```
-Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); the resources page (any member, a stranger refused before any authority is asked, nothing of another project, malformed filters refused, unreadable authorities named, names escaped, a cursor walk that never repeats or skips a resource); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `services.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released services manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
+Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); the resources pages (your resources: only the signed-in person's own rows in no project, even from an authority that ignores `?owner=`, never another subject named in a query; a project's: any member, a stranger refused before any authority is asked, nothing of another project, malformed filters refused, unreadable authorities named, names escaped, a cursor walk that never repeats or skips a resource); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `services.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released services manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
## Acceptance (must be true before "done")
## Acceptance (must be true before "done")
@@ -252,6 +252,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -252,6 +252,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.131.0
- openvibe-contracts: v0.131.0
-- openvibe-sdk: v0.38.0
+- openvibe-sdk: v0.41.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
"port": 4930,
"port": 4930,
"domain": "openvibe.services",
"domain": "openvibe.services",
"contracts": "openvibe-contracts v0.131.0",
"contracts": "openvibe-contracts v0.131.0",
- "sdk": "openvibe-sdk v0.38.0",
+ "sdk": "openvibe-sdk v0.41.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"packages": [
"packages": [
"openvibe-contracts v0.131.0",
"openvibe-contracts v0.131.0",
- "openvibe-sdk v0.38.0",
+ "openvibe-sdk v0.41.0",
"openvibe-shared v3.0.0"
"openvibe-shared v3.0.0"
],
],
"capabilities": [
"capabilities": [
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -906,9 +906,9 @@
@@ -906,9 +906,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.38.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",
+ "version": "0.41.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0",
+ "integrity": "sha512-joQfMWDTIt79qtTlijXoSMSqf8ZoYe8yv3T5Pjj+JJJBbUgA37BX+TOOHs8KX7znAmJZbPqtoYpQuWGjkp0iUQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.41.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -123,6 +123,7 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
@@ -123,6 +123,7 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
html`<a href="/projects/${p.id}">${p.name}</a><br><code class="small">${p.id}</code>${p.archived_at ? html` ${badge('archived', 'bad')}` : ''}`,
html`<a href="/projects/${p.id}">${p.name}</a><br><code class="small">${p.id}</code>${p.archived_at ? html` ${badge('archived', 'bad')}` : ''}`,
p.role || (req.viewer.staff ? 'staff' : '—'), (p.environments || []).join(', '), p.counts ? p.counts.apps : '—', time(p.created_at),
p.role || (req.viewer.staff ? 'staff' : '—'), (p.environments || []).join(', '), p.counts ? p.counts.apps : '—', time(p.created_at),
]), { empty: 'You are not a member of any project yet.' })}
]), { empty: 'You are not a member of any project yet.' })}
+<p><a href="/projects/mine">Your resources</a> <span class="muted small">what you own outside any project: robots, chat rooms, streams, watches, Actor tasks</span></p>
<h2>Create a project</h2>
<h2>Create a project</h2>
<form method="post" action="/projects" class="inline-form">${csrfField(csrf(req))}
<form method="post" action="/projects" class="inline-form">${csrfField(csrf(req))}
<label>Name <input name="name" required maxlength="80"></label><button type="submit">Create</button></form>
<label>Name <input name="name" required maxlength="80"></label><button type="submit">Create</button></form>
@@ -143,6 +144,19 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
@@ -143,6 +144,19 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
return got.data;
return got.data;
}
}
+ // Yours: what the signed-in person owns outside any project. Only their own subject is ever asked for.
+ r.get('/mine', async (req, res) => {
+ await resourcesPage(req, res, {
+ scope: { owner: req.viewer.subject }, base: '/projects/mine',
+ title: 'Your resources', crumbs: [{ label: 'Projects', href: '/projects' }, { label: 'Your resources' }],
+ heading: html`<h1>Your resources</h1>`,
+ intro: 'What you own yourself, outside any project: your robots, chat rooms, streams, watches, Actor tasks and the rest. Each OpenVibe service answers for its own, read just now and merged here; the service that holds each one owns it.',
+ empty: 'Nothing of yours outside a project yet. A robot you add on OpenVibe.Bot, a room you open on OpenVibe.Chat, a stream on OpenRestream or a watch on OpenVibe.Watch appears here.',
+ footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>.`,
+ ownerColumn: false,
+ });
+ });
+
r.get('/:project', idParams, async (req, res) => {
r.get('/:project', idParams, async (req, res) => {
const project = await loadProject(req, res);
const project = await loadProject(req, res);
if (!project) return;
if (!project) return;
@@ -338,20 +352,20 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
@@ -338,20 +352,20 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
});
});
});
});
- // ── Resources (ADR-048, plan T13): the merged index, as the API serves it to a member ──
- // Network decides membership (loadProject) before any authority is asked; the index then reads every
- // authority for this project only and keeps the page to it (server/resources.js).
- r.get('/:project/resources', idParams, async (req, res) => {
- const project = await loadProject(req, res);
- if (!project) return;
- const base = `/projects/${project.id}/resources`;
+ // ── Resources (ADR-048, plan T13): the merged index, as the API serves it ──
+ /**
+ * One page of the merged index for one scope: a project (any member; Network decided membership before this), or
+ * the signed-in person's own resources (`owner`: the ones in no project, each authority filtering by its own owner
+ * column, server/resources.js keeping the page to them).
+ */
+ async function resourcesPage(req, res, { scope, base, title, crumbs, heading, intro, empty, footer, ownerColumn }) {
const services = ctx.index.adapters.map((a) => a.authority.id).sort();
const services = ctx.index.adapters.map((a) => a.authority.id).sort();
const q = { service: req.query.service, kind: req.query.kind, cursor: req.query.cursor };
const q = { service: req.query.service, kind: req.query.kind, cursor: req.query.cursor };
let filter = { service: null, kind: null, cursor: null };
let filter = { service: null, kind: null, cursor: null };
let result = null, refused = null;
let result = null, refused = null;
try {
try {
filter = listQuery(q, config);
filter = listQuery(q, config);
- result = await ctx.index.list({ ...filter, project: project.id, limit: RESOURCES_PER_PAGE });
+ result = await ctx.index.list({ ...filter, ...scope, limit: RESOURCES_PER_PAGE });
} catch (err) {
} catch (err) {
if (!(err instanceof ServiceError) || err.status !== 400) throw err;
if (!(err instanceof ServiceError) || err.status !== 400) throw err;
refused = err;
refused = err;
@@ -371,26 +385,42 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
@@ -371,26 +385,42 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
const rows = result ? result.resources.map((x) => [
const rows = result ? result.resources.map((x) => [
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
html`<a href="${link({ kind: x.kind, service: null })}"><code>${x.kind}</code></a>`,
html`<a href="${link({ kind: x.kind, service: null })}"><code>${x.kind}</code></a>`,
- stateBadge(x.state), ownerCell(x.owner), time(x.created_at), time(x.updated_at),
+ stateBadge(x.state),
+ ...(ownerColumn ? [ownerCell(x.owner)] : []),
+ time(x.created_at), time(x.updated_at),
]) : [];
]) : [];
const partial = result ? result.partial : [];
const partial = result ? result.partial : [];
const filtered = Boolean(filter.service || filter.kind);
const filtered = Boolean(filter.service || filter.kind);
page(req, res, {
page(req, res, {
- title: `Resources · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Resources' }],
- body: html`<h1>Resources <small>${project.name}</small></h1>
-<p class="muted">Each OpenVibe service answers for its own resources in this project, read just now and merged here. Services shows them; the service that holds each one owns it.</p>
+ title, crumbs,
+ body: html`${heading}
+<p class="muted">${intro}</p>
<form method="get" action="${base}" class="inline-form">
<form method="get" action="${base}" class="inline-form">
<label>Service <select name="service"><option value="">every service</option>${services.map((s) => html`<option value="${s}"${s === filter.service ? raw(' selected') : ''}>${s}</option>`)}</select></label>
<label>Service <select name="service"><option value="">every service</option>${services.map((s) => html`<option value="${s}"${s === filter.service ? raw(' selected') : ''}>${s}</option>`)}</select></label>
<label>Kind <input name="kind" value="${filter.kind || ''}" placeholder="media.object" maxlength="72" spellcheck="false"></label>
<label>Kind <input name="kind" value="${filter.kind || ''}" placeholder="media.object" maxlength="72" spellcheck="false"></label>
<button type="submit">Show</button>${filtered ? html` <a href="${base}">Clear</a>` : ''}</form>
<button type="submit">Show</button>${filtered ? html` <a href="${base}">Clear</a>` : ''}</form>
${refused ? notice(html`That filter was refused: ${refused.detail || refused.message} (<code>${refused.code}</code>).`, 'bad') : ''}
${refused ? notice(html`That filter was refused: ${refused.detail || refused.message} (<code>${refused.code}</code>).`, 'bad') : ''}
${partial.length ? notice(html`Not on this page: ${partial.map((p, i) => html`${i ? ', ' : ''}<strong>${p.service}</strong> (<code>${p.code}</code>)`)} could not be read just now. Everything else is here; reload to try ${partial.length === 1 ? 'it' : 'them'} again.`, 'warn') : ''}
${partial.length ? notice(html`Not on this page: ${partial.map((p, i) => html`${i ? ', ' : ''}<strong>${p.service}</strong> (<code>${p.code}</code>)`)} could not be read just now. Everything else is here; reload to try ${partial.length === 1 ? 'it' : 'them'} again.`, 'warn') : ''}
-${result ? table(['Resource', 'Kind', 'State', 'Owner', 'Created', 'Updated'], rows, {
- empty: filtered ? 'No resources match this filter.' : 'No resources in this project yet. Releases, Media objects, event subscriptions and the other things apps in this project create appear here.',
+${result ? table(['Resource', 'Kind', 'State', ...(ownerColumn ? ['Owner'] : []), 'Created', 'Updated'], rows, {
+ empty: filtered ? 'No resources match this filter.' : empty,
}) : ''}
}) : ''}
${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? html`<a href="${link({})}">First page</a>` : ''}${filter.cursor && result.next_cursor ? ' · ' : ''}${result.next_cursor ? html`<a href="${link({ cursor: result.next_cursor })}">Next page</a>` : ''}</p>` : ''}
${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? html`<a href="${link({})}">First page</a>` : ''}${filter.cursor && result.next_cursor ? ' · ' : ''}${result.next_cursor ? html`<a href="${link({ cursor: result.next_cursor })}">Next page</a>` : ''}</p>` : ''}
-<p class="muted small">Things a person owns outside any project (robots, chat rooms, streams) are on those products' own pages. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>.</p>`,
+<p class="muted small">${footer}</p>`,
}, refused ? 400 : 200);
}, refused ? 400 : 200);
+ }
+
+ r.get('/:project/resources', idParams, async (req, res) => {
+ const project = await loadProject(req, res);
+ if (!project) return;
+ await resourcesPage(req, res, {
+ scope: { project: project.id }, base: `/projects/${project.id}/resources`,
+ title: `Resources · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Resources' }],
+ heading: html`<h1>Resources <small>${project.name}</small></h1>`,
+ intro: 'Each OpenVibe service answers for its own resources in this project, read just now and merged here. Services shows them; the service that holds each one owns it.',
+ empty: 'No resources in this project yet. Releases, Media objects, event subscriptions and the other things apps in this project create appear here.',
+ footer: html`What you own outside any project (robots, chat rooms, streams) is on <a href="/projects/mine">your resources</a>. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>.`,
+ ownerColumn: true,
+ });
});
});
// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* - the authority set (one adapter each, its own client-credentials token; server/authorities/),
* - the authority set (one adapter each, its own client-credentials token; server/authorities/),
* - a deterministic total order over the merged resources (service, ovrn, id) and an opaque keyset
* - a deterministic total order over the merged resources (service, ovrn, id) and an opaque keyset
* cursor into it, so a page walk never repeats or skips a resource,
* cursor into it, so a page walk never repeats or skips a resource,
- * - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a
+ * - a local filter for `project` / `kind` / `service` / `owner` on top of the authorities' own answers: a
* scoped read (a person's, above all) must never show a row outside its scope even if an authority
* scoped read (a person's, above all) must never show a row outside its scope even if an authority
* ignores a query parameter,
* ignores a query parameter,
* - and the partial answer. common.resource-list-result@1's `partial` array (contracts 1.1.0) names
* - and the partial answer. common.resource-list-result@1's `partial` array (contracts 1.1.0) names
@@ -85,17 +85,21 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
@@ -85,17 +85,21 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
* authorities whose rows this page could not include (their cursor walk failed, timed out or was
* authorities whose rows this page could not include (their cursor walk failed, timed out or was
* refused), `code` being the problem code each one answered, in the caller's terms.
* refused), `code` being the problem code each one answered, in the caller's terms.
*/
*/
- async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {
+ async function list({ project = null, kind = null, service = null, owner = null, cursor = null, limit = config.index.defaultLimit } = {}) {
const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;
const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;
if (service && !set.length) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
if (service && !set.length) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
if (!set.length) return { resources: [], next_cursor: null, partial: [] };
if (!set.length) return { resources: [], next_cursor: null, partial: [] };
- const { resources, stale } = await indexFor(set).list({ project, kind, limit: config.index.pageSize });
+ const { resources, stale } = await indexFor(set).list({ project, kind, owner, limit: config.index.pageSize });
let merged = resources;
let merged = resources;
// The authority filters on its own; this keeps a scoped read scoped even if one does not
// The authority filters on its own; this keeps a scoped read scoped even if one does not
// (project_id is optional on common.resource-summary@1, so a row without one fails a project filter).
// (project_id is optional on common.resource-summary@1, so a row without one fails a project filter).
if (service) merged = merged.filter((r) => r.service === service);
if (service) merged = merged.filter((r) => r.service === service);
if (kind) merged = merged.filter((r) => r.kind === kind);
if (kind) merged = merged.filter((r) => r.kind === kind);
if (project) merged = merged.filter((r) => r.project_id === project);
if (project) merged = merged.filter((r) => r.project_id === project);
+ // A person's own (owner): each authority filters on its owner column (openvibe-sdk 0.41 sends ?owner=); this
+ // keeps the page to them even if one does not yet, and to what is in no project (a project's resources are
+ // read through the project, where membership decides).
+ if (owner) merged = merged.filter((r) => r.owner && r.owner.id === owner && !r.project_id);
merged = merged.slice().sort((a, b) => compare(sortKeyOf(a), sortKeyOf(b)));
merged = merged.slice().sort((a, b) => compare(sortKeyOf(a), sortKeyOf(b)));
const after = cursor ? decodeCursor(cursor) : null;
const after = cursor ? decodeCursor(cursor) : null;
if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
@@ -141,6 +141,49 @@ const { boot, check, done } = require('./helpers/boot');
@@ -141,6 +141,49 @@ const { boot, check, done } = require('./helpers/boot');
assert.deepStrictEqual(new Set(seen), want);
assert.deepStrictEqual(new Set(seen), want);
});
});
+ await check('your resources: the person\'s own, in no project, asked of every authority by owner and kept to them', async () => {
+ const { createResourceIndexService } = require('../server/resources');
+ const bob = t.network.addUser('bob');
+ const at = '2026-10-01T00:00:00.000Z';
+ const rob = (id, owner, extra = {}) => ({ id, kind: 'bot.robot', service: 'bot', name: `Robot ${id.slice(-2)}`, state: 'ready', created_at: at, ...(owner ? { owner: { type: 'user', id: owner } } : {}), ...extra });
+ const R1 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN01', R2 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN02', R3 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN03', R4 = 'rob_01JAB2C3D4E5F6G7H8J9K0MN04';
+ const seen = [];
+ // An authority that has not added the owner filter: it answers everyone's, including a projected row of the owner.
+ const careless = {
+ authority: Object.freeze({ id: 'bot', name: 'OpenVibe.Bot', internalOrigin: 'http://bot.fake', publicOrigin: 'https://openvibe.bot', audience: 'openvibe.bot', capability: 'bot.resource.read', status: 'in-process' }),
+ origin: 'http://bot.fake',
+ async fetchAs(url) {
+ seen.push(new URL(url));
+ const body = { resources: [rob(R1, owner.subject), rob(R2, bob.subject), rob(R3, null), rob(R4, owner.subject, { project_id: projectId })], next_cursor: null };
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+ },
+ get: async () => ({ status: 404, body: {} }),
+ };
+ const index = createResourceIndexService({ config: t.ctx.config, authorities: { list: () => [] }, tokens: null, self: careless, log: { warn() {} } });
+ const direct = await index.list({ owner: owner.subject });
+ assert.deepStrictEqual(direct.resources.map((x) => x.id), [R1], 'only the owner\'s, and only outside a project');
+ assert.strictEqual(seen[0].searchParams.get('owner'), owner.subject, 'owner is asked of the authority');
+
+ const kept = t.ctx.index;
+ t.ctx.index = index;
+ try {
+ const r = await t.get('/projects/mine', { as: owner });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.text, /<h1>Your resources<\/h1>/);
+ assert.ok(r.text.includes(R1) && !r.text.includes(R2) && !r.text.includes(R3) && !r.text.includes(R4), 'the person\'s own row only');
+ assert.ok(!r.text.includes('<th scope="col">Owner</th>'), 'no owner column: all of it is theirs');
+ assert.strictEqual(seen.at(-1).searchParams.get('owner'), owner.subject, 'the signed-in person\'s subject, never a query value');
+ const forged = await t.get(`/projects/mine?owner=${bob.subject}`, { as: owner });
+ assert.ok(!forged.text.includes(R2), 'a query cannot name someone else');
+ assert.strictEqual(seen.at(-1).searchParams.get('owner'), owner.subject);
+ const b = await t.get('/projects/mine', { as: bob });
+ assert.ok(b.text.includes(R2) && !b.text.includes(R1), 'each person sees their own');
+ assert.strictEqual((await t.get('/projects/mine')).status, 401, 'signed out: sign in');
+ const list = await t.get('/projects', { as: owner });
+ assert.ok(list.text.includes('<a href="/projects/mine">Your resources</a>'), 'the projects page links it');
+ } finally { t.ctx.index = kept; }
+ });
+
t.ctx.index.list = realList;
t.ctx.index.list = realList;
await done(t);
await done(t);
})();
})();