openvibers/openvibe.services Commits

Services: your resources (a person's own, in no project) over the owner filter (#34)

OP OpenVibers committed 10 Oct 2026 · 9d4da2645c · 7 files, +105 −28

* openvibe-sdk v0.41.0 (was v0.38.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.41.0 release.

* Services: your resources (a person's own, in no project) over the owner filter

Plan T13: one place for every resource, including a person's own. Their robots, chat rooms, streams, watches and Actor tasks belong to no project, so neither the API (project-scoped for people) nor a project page shows them. This PR carries the openvibe-sdk v0.41.0 pin bump from #33.

- **`/projects/mine`, "Your resources":** the merged index for the signed-in person.
  - **How it asks:** every authority is asked with `?owner=<their subject>` (SDK 0.41 passes it; the authorities are adding the filter: Watch, Actor and Bot are live, the rest are in review).
  - **Local filter:** `server/resources.js` keeps the merged page to rows whose `owner` is that subject and which belong to no project. An authority that has not added the filter yet never shows another person's row; it only costs a longer walk. A project's rows stay behind project membership.
  - **Subject:** only the session's own subject is asked for; a query parameter cannot name someone else.
  - **Same as the project page:** filters, paging, partial notices and the escaped table, without the owner column.
- **Shared view:** the project Resources page and this one render through one `resourcesPage()`. The projects list links to "Your resources", and the project page's footer points there.
- README updated.

Test: `test/project-resources.test.js` adds a fake in-process authority that ignores `?owner=` and answers everyone's rows, including one of the person's inside a project. It checks:
- the index asks it with `owner` and keeps only the person's unprojected row;
- the page shows that row alone, with no owner column;
- a forged `?owner=` changes nothing;
- each person sees their own;
- signed out is 401;
- the projects page links it.

`ov test` and the security suites are green.

Changes

ddiff --git a/README.md b/README.md
iindex 3aaf9db..ee29fe2 100644
---- a/README.md
++++ b/README.md
@@@ -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:4930
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")
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 559f5fd..7970b34 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex fd7b122..5c14851 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 1b74ad4..330083c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 6b69f17..cb2b6d2 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -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')}` : ''}`,
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((p
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) => {
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 ──
ddiff --git a/server/resources.js b/server/resources.js
iindex 5343129..a3f9e79 100644
---- a/server/resources.js
++++ b/server/resources.js
@@@ -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');
ddiff --git a/test/project-resources.test.js b/test/project-resources.test.js
iindex d6b59ed..4283db6 100644
---- a/test/project-resources.test.js
++++ b/test/project-resources.test.js
@@@ -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);
})();