openvibers/openvibe.community Commits

Community: resource index for OpenVibe.Services (a person's pastes and spaces) (#59)

OP OpenVibers committed 10 Oct 2026 · 9d531c710a · 9 files, +237 −8

* openvibe-contracts v0.134.0 (was v0.129.0)

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

* Community: resource index for OpenVibe.Services (a person's pastes and spaces)

Plan T13: a person's pastes and spaces in OpenVibe.Services' merged index ("Your resources"), beside their robots, rooms, streams, watches and Actor tasks. This PR carries the openvibe-contracts v0.134.0 pin bump (#58, folded in); 0.134.0 declares `community.resource.read` active.

- **`GET /api/v1/resources`** (`server/registry/resource-index.js`): `common.resource-list-result@1`.
  - Two kinds:
    - `community.paste`: id the slug, named by its title, state its visibility, owner its owner. Deleted pastes are left out; a paste no person owns (an AI paste from a stream) has no owner.
    - `community.space`: id the slug, named by its name, state its visibility, owner its creator.
  - Pastes come first, then spaces, each by slug, on an opaque `[kind, slug]` cursor.
  - `owner` (a `usr_`/`agt_` id) keeps that subject's own across both kinds; `kind` narrows; `project` is always an empty page; a malformed filter is 400 `resources.bad_query`.
  - Summaries only: no paste content leaves the index.
  - Person-owned, so no OVRN: `GET /api/v1/resources/:ovrn` is 404.
- **Access:** only a service token holding `community.resource.read` (401 without a token, 403 for another service or a person's token).
- **nginx:** `location ~* ^/api/v1/resources(/|$) { return 404; }` beside `/metrics` and `/internal`, so Services reads it on loopback only.
- README (endpoint and capability rows) and STATUS.

Network grants the `services` principal next (Network#136 folded there), and Services reads the index from the manifest's `internalOrigin` on its pin bump.

Test `test/resource-index.test.js`:
- the summaries validate and exactly match the rows, in order;
- the deleted paste is absent and the paste content never appears;
- the agent and ownerless owners;
- `owner`/`kind`/`project` filters;
- a limit=1 walk visiting each row once across both kinds;
- six malformed filters refused;
- token rules and the 404 by name.

`nginx-internal-paths.test.js` asserts the new block and its order. `ov test`: green.

Changes

ddiff --git a/README.md b/README.md
iindex d1d2b06..d64e386 100644
---- a/README.md
++++ b/README.md
@@@ -152,6 +152,7 @@ API and machine endpoints:
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
++| `GET /api/v1/resources` | OpenVibe.Services' index of a person's pastes (`community.paste`) and the spaces they created (`community.space`): `common.resource-list-result@1`, filters `kind`, `owner`, `project` (always empty), `cursor`, `limit`. A service token with `community.resource.read` only; loopback (nginx answers 404 publicly). Summaries only, never paste content; deleted pastes left out; no OVRN (`/:ovrn` is 404) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /release.json`, `POST /release-metrics` | What this server runs (ADR-016 release manifest, `metrics_url: /release-metrics`); open tabs' update reports (release-watch's same-origin beacon, no auth) |
@@@ -575,6 +576,7 @@ same shape as its `manifests/capabilities`):
| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
++| `community.resource.read` | active in contracts (0.134.0) | OpenVibe.Services' resource index: a person's pastes and spaces in "Your resources" |
This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all go
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
@@@ -824,7 +826,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
--- openvibe-contracts: v0.129.0
++- openvibe-contracts: v0.134.0
- openvibe-sdk: v0.42.0
- openvibe-shared: v3.0.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex bf39efc..41c3adc 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"domain": "openvibe.community",
-- "contracts": "openvibe-contracts v0.129.0",
++ "contracts": "openvibe-contracts v0.134.0",
"sdk": "openvibe-sdk v0.42.0",
"shared": "openvibe-shared v3.0.1",
"capabilities": [
@@@ -33,6 +33,7 @@
"typed comment threads embedded by other products",
"forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)",
"per-space moderators (migration 0005)",
++ "resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)",
"members-only spaces and threads gated by OpenVibe.VIP",
"a space's chat room on OpenVibe.Chat",
"Pulse: the network's public activity from Events",
ddiff --git a/deploy/nginx/openvibe.community.conf b/deploy/nginx/openvibe.community.conf
iindex fbd20bb..aaa547c 100644
---- a/deploy/nginx/openvibe.community.conf
++++ b/deploy/nginx/openvibe.community.conf
@@@ -130,6 +130,8 @@ server {
# Regex locations match in order, so these sit before the static-asset regex. ──
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
++ # The resource index (community.resource.read) is OpenVibe.Services' on loopback, never public.
++ location ~* ^/api/v1/resources(/|$) { return 404; }
# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
ddiff --git a/package-lock.json b/package-lock.json
iindex c538d87..109fdee 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.134.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0"
@@@ -917,9 +917,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.129.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
-- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",
++ "version": "0.134.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.134.0",
++ "integrity": "sha512-sWjPIaJtv/V1evsLbNcXecsiHVHd7w+A7/VD7o41j4Veotd+zfQRnNHFHS2gFlOVGR3WBycEzOZzo7+r1jk3sg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex eab66da..f58b27c 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.134.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex 47c23bb..b5609df 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -239,6 +239,9 @@ async function createApp(opts = {}) {
app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
++ // OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
++ // nginx answers 404 for the path on the public vhost).
++ app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..013a9d9
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * Community's authority index for OpenVibe.Services (ADR-048, plan T13; capability community.resource.read). A
++ * person's pastes and the spaces they created, so "Your resources" on openvibe.services shows them beside their
++ * robots, rooms, streams, watches and Actor tasks. Both kinds are person-owned: no project, no OVRN.
++ *
++ * GET /api/v1/resources?kind=&owner=&project=&cursor=&limit= common.resource-list-result@1
++ * community.paste id the slug, named by its title, state its visibility, owner its owner (deleted ones left out)
++ * community.space id the slug, named by its name, state its visibility, owner the person who created it
++ * Pastes first, then spaces, each by slug; the cursor is opaque ([kind, slug]). owner (a usr_/agt_ id) keeps
++ * what that subject owns; project always answers an empty page (nothing here belongs to a project).
++ * GET /api/v1/resources/:ovrn 404 for every name (nothing here has one)
++ *
++ * First-party only: a service token holding community.resource.read. nginx answers 404 for the path on the public
++ * vhost; Services reads it on loopback.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { hasCap } = require('../identity/viewer');
++
++const SERVICE = 'community';
++const KINDS = ['community.paste', 'community.space'];
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** Community stores UTC as 'YYYY-MM-DD HH:MM:SS'; a summary carries ISO 8601. */
++function iso(text) {
++ if (!text) return null;
++ const d = new Date(String(text).includes('T') ? String(text) : `${String(text).replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++function ownerOf(subject) {
++ const s = String(subject || '');
++ if (!OWNER_SUBJECT_RE.test(s)) return null;
++ return { type: s.startsWith('agt_') ? 'agent' : 'user', id: s };
++}
++
++/** One row → common.resource-summary@1. Nothing else of a paste (its content, its views) leaves this index. */
++function summaryOf(kind, row) {
++ const out = {
++ id: row.slug, kind, service: SERVICE,
++ name: kind === 'community.paste' ? row.title : row.name,
++ state: row.visibility,
++ created_at: iso(row.created_at),
++ };
++ const updated = iso(row.updated_at);
++ if (updated) out.updated_at = updated;
++ const owner = ownerOf(row.owner);
++ if (owner) out.owner = owner;
++ return out;
++}
++
++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
++function decodeCursor(raw) {
++ let v;
++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(v) && v.length === 2 && KINDS.includes(v[0]) && typeof v[1] === 'string' && v[1] !== '' ? { kind: v[0], id: v[1] } : null;
++}
++
++function filtersOf(query) {
++ const project = query.project === undefined || query.project === '' ? null : query.project;
++ if (project !== null && (typeof project !== 'string' || !PROJECT_ID_RE.test(project))) return { error: 'project must be a prj_ id' };
++ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;
++ if (owner !== null && (typeof owner !== 'string' || !OWNER_SUBJECT_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (typeof query.limit === 'string' && query.limit !== '') {
++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (typeof query.cursor === 'string' && query.cursor !== '') {
++ cursor = decodeCursor(query.cursor);
++ if (!cursor) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, owner, kind, limit, cursor };
++}
++
++const QUERIES = {
++ 'community.paste': (owner) => `SELECT slug, title, visibility, owner_subject AS owner, created_at, updated_at FROM pastes
++ WHERE deleted_at IS NULL AND slug > ?${owner ? ' AND owner_subject = ?' : ''} ORDER BY slug LIMIT ?`,
++ 'community.space': (owner) => `SELECT slug, name, visibility, created_by AS owner, created_at, NULL AS updated_at FROM spaces
++ WHERE slug > ?${owner ? ' AND created_by = ?' : ''} ORDER BY slug LIMIT ?`,
++};
++
++/** One page across both kinds (pastes, then spaces), limit + 1 rows to know whether there is a next page. */
++async function page(db, f) {
++ const kinds = f.kind ? KINDS.filter((k) => k === f.kind) : KINDS;
++ const out = [];
++ for (const kind of kinds) {
++ if (f.cursor && KINDS.indexOf(kind) < KINDS.indexOf(f.cursor.kind)) continue;
++ const need = f.limit + 1 - out.length;
++ if (need <= 0) break;
++ const from = f.cursor && f.cursor.kind === kind ? f.cursor.id : '';
++ const rows = await db.prepare(QUERIES[kind](f.owner)).all(from, ...(f.owner ? [f.owner] : []), need);
++ for (const row of rows) out.push(summaryOf(kind, row));
++ }
++ return { resources: out.slice(0, f.limit), next_cursor: out.length > f.limit ? encodeCursor(out[f.limit - 1]) : null };
++}
++
++function router({ db, viewers }) {
++ const r = express.Router();
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const handle = (fn) => async (req, res, next) => { try { await fn(req, res); } catch (err) { next(err); } };
++
++ r.use(viewers.middleware());
++ r.use((req, res, next) => {
++ const v = req.viewer || { kind: 'anonymous' };
++ if (v.kind === 'service' && hasCap(v, 'community.resource.read')) return next();
++ if (v.kind === 'anonymous') return problem(req, res, 401, 'token.missing', 'call with a service token holding community.resource.read');
++ return problem(req, res, 403, 'capability.denied', 'community.resource.read is first-party and requires a service token that holds it');
++ });
++
++ r.get('/', handle(async (req, res) => {
++ const f = filtersOf(req.query);
++ if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
++ res.set('Cache-Control', 'private, max-age=60');
++ if (f.project) return res.json({ resources: [], next_cursor: null });
++ return res.json(await page(db, f));
++ }));
++ r.get('/:ovrn', (req, res) => problem(req, res, 404, 'resources.unknown_resource', `no resource named ${String(req.params.ovrn).slice(0, 200)}`));
++ return r;
++}
++
++module.exports = { router, summaryOf, filtersOf, encodeCursor, decodeCursor, iso, SERVICE, KINDS, DEFAULT_LIMIT, MAX_LIMIT };
ddiff --git a/test/nginx-internal-paths.test.js b/test/nginx-internal-paths.test.js
iindex c1941d8..962797d 100644
---- a/test/nginx-internal-paths.test.js
++++ b/test/nginx-internal-paths.test.js
@@@ -15,4 +15,6 @@ assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, '/in
const catchAll = conf.lastIndexOf('location / {');
assert.ok(conf.indexOf('location ~* ^/metrics(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the metrics block precedes the static-asset regex');
assert.ok(conf.indexOf('location ~* ^/internal(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the internal block precedes the static-asset regex');
--console.log('nginx internal paths: /metrics and /internal/ are blocked in the vhost');
++assert.match(conf, /location ~\* \^\/api\/v1\/resources\(\/\|\$\) \{ return 404; \}/, 'the resource index (OpenVibe.Services, loopback) is never public');
++assert.ok(conf.indexOf('location ~* ^/api/v1/resources(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the resources block precedes the static-asset regex');
++console.log('nginx internal paths: /metrics, /internal/ and /api/v1/resources are blocked in the vhost');
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..28852ae
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * Community's resource index for OpenVibe.Services (server/registry/resource-index.js, community.resource.read): a
++ * person's pastes and the spaces they created as common.resource-summary@1, pastes then spaces by slug on an opaque
++ * cursor; owner keeps one subject's own, project is always empty, a deleted paste never appears, nothing but the
++ * summary leaves (no content); only a service token holding the capability reads it, and no name resolves by OVRN.
++ */
++const assert = require('assert');
++const { validate, ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const forumStore = require('../server/forum/store');
++
++(async () => {
++ const t = await boot();
++ const db = t.app.locals.db || t.db;
++ const alice = ids.newId('user');
++ const bob = ids.newId('user');
++ const agent = ids.newId('agent');
++ const paste = async (slug, owner, extra = {}) => db.prepare(`INSERT INTO pastes (slug, owner_subject, title, content, visibility, deleted_at, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, owner, extra.title || `Paste ${slug}`, 'SECRET-CONTENT-never-in-the-index', extra.visibility || 'public',
++ extra.deleted ? '2026-10-10 12:00:00' : null, '2026-10-01 10:00:00', '2026-10-02 11:30:00');
++ await paste('ri-a1', alice, { title: 'Alice one' });
++ await paste('ri-a2', alice, { visibility: 'private' });
++ await paste('ri-b1', bob);
++ await paste('ri-ai', null, { title: 'From a stream' });
++ await paste('ri-gone', alice, { deleted: true });
++ await paste('ri-ag', agent);
++ await forumStore.createSpace(db, { slug: 'ri-space-alice', name: 'Alice\'s space', created_by: alice, visibility: 'members' });
++
++ const reader = t.network.signService({ sub: 'svc:services', cap: ['community.resource.read'] });
++ const list = (q = '', token = reader) => t.get(`/api/v1/resources${q}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
++ const ours = (rows) => rows.filter((r) => r.id.startsWith('ri-'));
++
++ await check('pastes then spaces, as resource summaries that validate, with nothing but the summary', async () => {
++ const r = await list('?limit=1000');
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ assert.ok(validate('common.resource-list-result@1', body).valid, JSON.stringify(validate('common.resource-list-result@1', body).errors));
++ const mine = ours(body.resources);
++ assert.deepStrictEqual(mine.map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-ag', 'ri-ai', 'ri-b1', 'ri-space-alice'], 'by kind, then slug; the deleted paste is left out');
++ const a1 = mine[0];
++ assert.deepStrictEqual(a1, { id: 'ri-a1', kind: 'community.paste', service: 'community', name: 'Alice one', state: 'public', created_at: '2026-10-01T10:00:00.000Z', updated_at: '2026-10-02T11:30:00.000Z', owner: { type: 'user', id: alice } });
++ assert.strictEqual(mine.find((x) => x.id === 'ri-a2').state, 'private');
++ assert.deepStrictEqual(mine.find((x) => x.id === 'ri-ag').owner, { type: 'agent', id: agent });
++ assert.strictEqual('owner' in mine.find((x) => x.id === 'ri-ai'), false, 'a paste no person owns has no owner');
++ const sp = mine.find((x) => x.id === 'ri-space-alice');
++ assert.deepStrictEqual([sp.kind, sp.name, sp.state, sp.owner.id], ['community.space', 'Alice\'s space', 'members', alice]);
++ assert.ok(!r.text.includes('SECRET-CONTENT'), 'no paste content leaves the index');
++ assert.ok(body.resources.every((x) => !('ovrn' in x) && !('project_id' in x)), 'person-owned: no project, no OVRN');
++ });
++
++ await check('owner keeps one subject\'s own, across both kinds; kind and project narrow it', async () => {
++ assert.deepStrictEqual(ours((await list(`?owner=${alice}&limit=1000`)).json().resources).map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-space-alice']);
++ assert.deepStrictEqual(ours((await list(`?owner=${bob}`)).json().resources).map((x) => x.id), ['ri-b1']);
++ assert.deepStrictEqual(ours((await list(`?owner=${alice}&kind=community.space`)).json().resources).map((x) => x.id), ['ri-space-alice']);
++ assert.deepStrictEqual((await list('?kind=bot.robot')).json().resources, [], 'a kind this index does not hold: nothing');
++ assert.deepStrictEqual((await list(`?project=prj_${'0'.repeat(26)}`)).json(), { resources: [], next_cursor: null }, 'nothing here belongs to a project');
++ });
++
++ await check('the cursor walks every row exactly once, across the kinds', async () => {
++ const seen = [];
++ let cursor = null;
++ for (let i = 0; i < 50; i++) {
++ const body = (await list(`?owner=${alice}&limit=1${cursor ? `&cursor=${cursor}` : ''}`)).json();
++ seen.push(...body.resources.map((x) => x.id));
++ if (!body.next_cursor) break;
++ cursor = body.next_cursor;
++ }
++ assert.deepStrictEqual(seen, ['ri-a1', 'ri-a2', 'ri-space-alice']);
++ });
++
++ await check('a malformed filter is 400 resources.bad_query, never ignored', async () => {
++ for (const q of ['?owner=svc:live', '?owner=usr_short', '?project=prj_x', '?limit=0', '?limit=1001', '?cursor=not-ours']) {
++ const r = await list(q);
++ assert.deepStrictEqual([r.status, r.json().code], [400, 'resources.bad_query'], q);
++ }
++ });
++
++ await check('only a service token holding community.resource.read; no OVRN resolves', async () => {
++ assert.strictEqual((await list('', null)).status, 401);
++ const other = t.network.signService({ sub: 'svc:live', cap: ['community.paste.create'] });
++ assert.strictEqual((await list('', other)).status, 403);
++ const user = t.network.sign({ id: 7, subject_id: alice, username: 'alice', role: 'user' });
++ assert.strictEqual((await list('', user)).status, 403, 'a person\'s token is not a service token');
++ const r = await list(`/ovrn:community:prj_${'0'.repeat(26)}:paste/abc_${'0'.repeat(26)}`);
++ assert.strictEqual(r.status, 404);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });