openvibers/openvibe.services Commits

Services: a project's Resources page over the merged index (#32)

OP OpenVibers committed 10 Oct 2026 · 78e39c5ea7 · 3 files, +219 −4

The console had projects, apps, members, usage and audit, but no page showing the merged resource index, which is the core of plan T13: one place to see every resource. This adds `/projects/:project/resources`.

- Server-rendered and works without JavaScript. Columns: name and OVRN, kind (links to filter by that kind), state badge, owner ("you" for your own), created, updated. 50 rows a page, using the index's own cursor.
- Who sees it: any member and staff, the same rule the API uses. Network confirms membership (`loadProject`) before any authority is asked. A stranger gets Network's 404 and no authority is asked.
- Filters by service and kind with a GET form. Malformed values are refused with the API's own `listQuery`, never ignored: a 400 with the problem code shown. An unknown service and a forged cursor are refused the same way.
- An authority that could not be read is named on the page with the problem code it gave. The rest of the page is still served.
- The project page links to it for every member.
- The README console row and the tests section are updated.

Test: `test/project-resources.test.js` covers who can see the page, isolation from other projects, the filters, the partial-answer notice, escaping of authority-supplied names, and a two-page cursor walk with no repeats or skips.

Changes

ddiff --git a/README.md b/README.md
iindex 0ce9069..3aaf9db 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/apps/:app` | Create projects; add members; 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/: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. |
| 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); 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 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).
## Acceptance (must be true before "done")
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 1b5ba2f..6b69f17 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -4,7 +4,8 @@
* The signed-in portal: projects, members, apps, credentials, grants, quotas, usage and audit — all over
* OpenVibe.Network's /api/v1/projects API with the person's own access token (ADR-014: Network owns
* every one of these; Services stores none of them). Plus the Services-owned parts that hang off an app:
-- * releases (manifest editor, publish/deprecate/revoke) and playgrounds.
++ * releases (manifest editor, publish/deprecate/revoke) and playgrounds. And the project's resources: the
++ * merged index (ADR-048) as GET /api/v1/resources serves it to a member, one table over every authority.
*
* Honesty rules:
* - a Network failure is shown as Network answered it (HTTP status, problem code, detail, request
@@@ -16,6 +17,8 @@
* shown with its lag; Services computes nothing of its own
* - the scope editor offers only capabilities apps can be granted (public, or partner when in the
* allowance) and refuses to forward anything else
++ * - resources are what each authority answered just now; an authority that could not be read is named
++ * on the page with the problem it gave, never left out silently
*/
const express = require('express');
const { asyncRouter } = require('./router');
@@@ -31,6 +34,8 @@ const { buildExport } = require('../domain/project-export');
const { ArchiveError } = require('../domain/project-archive');
const { statusBadge } = require('./pages');
const { usageBody, usageQuery } = require('../render/usage');
++const { listQuery } = require('../api/v1');
++const { ServiceError } = require('../util');
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -38,6 +43,15 @@ const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
const USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
++const RESOURCES_PER_PAGE = 50;
++
++/** A resource's state as a badge. States are each service's own words; the common ones get a colour. */
++const STATE_KIND = {
++ ready: 'ok', active: 'ok', published: 'ok', live: 'ok', enabled: 'ok', online: 'ok', open: 'ok',
++ draft: 'warn', queued: 'warn', pending: 'warn', paused: 'warn', processing: 'warn', deprecated: 'warn', offline: 'warn',
++ failed: 'bad', revoked: 'bad', deleted: 'bad', disabled: 'bad', archived: 'bad', suspended: 'bad',
++};
++const stateBadge = (s) => badge(s, STATE_KIND[s] || '');
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
@@@ -150,7 +164,7 @@ ${archived ? notice('This project is archived: every app in it is revoked.', 'ba
<dt>Environments</dt><dd>${envs.join(', ')}${project.environment_policy === 'sandbox' ? html` <span class="muted small">(production apps need staff to enable production for this project)</span>` : ''}</dd>
<dt>Allowance</dt><dd>${(project.allowance || []).length ? html`<ul class="plain">${project.allowance.map((id) => html`<li><a href="/docs/capabilities/${id}"><code>${id}</code></a></li>`)}</ul>` : html`<span class="muted">empty: staff decide which capabilities this project's apps may hold</span>`}</dd>
<dt>Created</dt><dd>${time(project.created_at)}</dd></dl>
--${atLeast(role, 'admin') || req.viewer.staff ? html`<p><a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a></p>` : ''}
++<p><a href="/projects/${project.id}/resources">Resources</a>${atLeast(role, 'admin') || req.viewer.staff ? html` · <a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a>` : ''}</p>
<h2>Apps</h2>
${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
@@@ -324,6 +338,61 @@ ${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`;
++ 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 });
++ } catch (err) {
++ if (!(err instanceof ServiceError) || err.status !== 400) throw err;
++ refused = err;
++ }
++ const link = (o) => {
++ const p = new URLSearchParams();
++ for (const [k, v] of Object.entries({ service: filter.service, kind: filter.kind, ...o })) if (v) p.set(k, v);
++ const s = p.toString();
++ return s ? `${base}?${s}` : base;
++ };
++ const me = req.viewer.subject;
++ const ownerCell = (o) => {
++ if (!o) return html`<span class="muted">—</span>`;
++ if (o.type === 'user' && o.id === me) return 'you';
++ return html`<code class="small">${o.id}</code>`;
++ };
++ 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),
++ ]) : [];
++ 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>
++<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 && (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>`,
++ }, refused ? 400 : 200);
++ });
++
// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
r.get('/:project/usage', idParams, async (req, res) => {
const project = await loadProject(req, res);
ddiff --git a/test/project-resources.test.js b/test/project-resources.test.js
nnew file mode 100644
iindex 0000000..d6b59ed
---- /dev/null
++++ b/test/project-resources.test.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * A project's Resources page (ADR-048, plan T13): /projects/:project/resources renders the merged resource index
++ * for that project, server-side and complete without JavaScript. Network decides who may see it (any member, or
++ * staff) before any authority is asked; rows of another project never appear; the service and kind filters are a
++ * GET form whose malformed values are refused, not ignored; an authority that could not be read is named with the
++ * problem it gave; pages are walked with the index's own cursor; an authority's names are escaped.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const db = t.ctx.store.db;
++ const owner = t.network.addUser('ona');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const projectId = await t.project(owner, 'Indexed');
++ const otherId = await t.project(stranger, 'Elsewhere');
++ assert.strictEqual((await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vee', role: 'viewer' } })).status, 303);
++
++ // Services' own resources (read in process by the index): releases and manifests in both projects.
++ const at = '2026-10-01T00:00:00Z';
++ const insertManifest = db.prepare('INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
++ const insertRelease = db.prepare("INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at) VALUES (?, ?, ?, 'sandbox', ?, ?, ?, ?, ?, ?, '{}', '', ?, ?)");
++ const add = async (project, name, status, by, n = 1) => {
++ const app = ids.newId('app'); const subject = ids.newId('app');
++ const made = [];
++ for (let i = 0; i < n; i++) {
++ const mfs = ids.newId('manifest'); const rel = ids.newId('release');
++ const version = `1.0.${i}`;
++ await insertManifest.run(mfs, 'app', app, project, subject, version, JSON.stringify({ id: subject, name, version }), '0.107.0', by, at);
++ await insertRelease.run(rel, app, project, 'app', subject, name, version, mfs, status, by, at);
++ made.push({ mfs, rel });
++ }
++ return made;
++ };
++ const [mine] = await add(projectId, 'Notes <img src=x onerror=alert(1)>', 'published', `user:${owner.subject}`);
++ const [theirs] = await add(otherId, 'Secret app', 'draft', `user:${stranger.subject}`);
++ const page = `/projects/${projectId}/resources`;
++
++ // Count what the index is asked, and let a check set the partial answer. (The test registry's own authorities
++ // are not running, so the real answer already names them.)
++ const realList = t.ctx.index.list;
++ const asked = [];
++ let partialAs = null;
++ t.ctx.index.list = async (q) => { asked.push(q); const out = await realList(q); return partialAs ? { ...out, partial: partialAs } : out; };
++
++ await check('the project page links its resources for every member', async () => {
++ for (const who of [owner, viewer]) {
++ const r = await t.get(`/projects/${projectId}`, { as: who });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`<a href="/projects/${projectId}/resources">Resources</a>`), 'the Resources link');
++ }
++ });
++
++ await check('the owner sees this project\'s resources, server-rendered, with their OVRNs, states and owner', async () => {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.text, /<h1>Resources <small>Indexed<\/small><\/h1>/);
++ assert.ok(r.text.includes(`<code class="small">ovrn:services:${projectId}:release/${mine.rel}</code>`), 'the release by its OVRN');
++ assert.ok(r.text.includes(`<code class="small">ovrn:services:${projectId}:manifest/${mine.mfs}</code>`), 'the manifest by its OVRN');
++ assert.match(r.text, /<span class="badge ok">published<\/span>/);
++ assert.match(r.text, /<td>you<\/td>/);
++ assert.ok(r.text.includes(`<a href="${page}?kind=services.release"><code>services.release</code></a>`), 'a kind narrows the list');
++ assert.ok(!r.text.includes(theirs.rel) && !r.text.includes(theirs.mfs) && !r.text.includes('Secret app'), 'nothing of another project');
++ assert.ok(!r.text.includes('<img src=x'), 'an authority\'s name is escaped');
++ assert.ok(r.text.includes('&lt;img src=x onerror=alert(1)&gt;'), 'and shown as text');
++ assert.match(r.text, new RegExp(`<form method="get" action="${page}"`));
++ assert.ok(!/src="\/js\//.test(r.text), 'no Services script on the page');
++ assert.deepStrictEqual(asked.at(-1), { service: null, kind: null, cursor: null, project: projectId, limit: 50 });
++ });
++
++ await check('a viewer member sees them too; a stranger gets Network\'s answer and no authority is asked', async () => {
++ const v = await t.get(page, { as: viewer });
++ assert.strictEqual(v.status, 200);
++ assert.ok(v.text.includes(mine.rel));
++ assert.ok(!/<td>you<\/td>/.test(v.text), 'the owner column names the owner, not the viewer');
++ const before = asked.length;
++ const s = await t.get(page, { as: stranger });
++ assert.strictEqual(s.status, 404, s.text.slice(0, 200));
++ assert.ok(!s.text.includes(mine.rel));
++ assert.strictEqual(asked.length, before, 'the index is not asked for a non-member');
++ const anon = await t.get(page);
++ assert.strictEqual(anon.status, 401);
++ });
++
++ await check('the filters reach the index as offered; a malformed or unknown one is refused, not ignored', async () => {
++ let r = await t.get(`${page}?service=services&kind=services.manifest`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<option value="services" selected>/);
++ assert.ok(r.text.includes(mine.mfs) && !r.text.includes(`release/${mine.rel}`), 'only manifests');
++ assert.deepStrictEqual(asked.at(-1), { service: 'services', kind: 'services.manifest', cursor: null, project: projectId, limit: 50 });
++ assert.ok(r.text.includes(`<a href="${page}">Clear</a>`));
++ r = await t.get(`${page}?kind=services.nothing`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /No resources match this filter\./);
++ r = await t.get(`${page}?kind=${encodeURIComponent('<b>')}`, { as: owner });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.text, /That filter was refused: kind must be &lt;service&gt;\.&lt;type&gt; \(<code>resources\.bad_query<\/code>\)/);
++ r = await t.get(`${page}?service=nowhere`, { as: owner });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.text, /<code>resources\.unknown_service<\/code>/);
++ r = await t.get(`${page}?cursor=forged`, { as: owner });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.text, /cursor must be one this index issued/);
++ });
++
++ await check('an authority that could not be read is named with its problem; the rest is served', async () => {
++ partialAs = [{ service: 'media', code: 'resources.authority_timeout' }, { service: 'chat', code: 'resources.authority_unavailable' }];
++ try {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Not on this page: <strong>media</strong> (<code>resources.authority_timeout</code>), <strong>chat</strong> (<code>resources.authority_unavailable</code>) could not be read just now.'), r.text.match(/Not on this page.{0,400}/s)[0]);
++ assert.ok(r.text.includes(mine.rel), 'the rows that could be read are still there');
++ partialAs = [];
++ const whole = await t.get(page, { as: owner });
++ assert.ok(!whole.text.includes('Not on this page'), 'a complete page says nothing of the kind');
++ } finally { partialAs = null; }
++ });
++
++ await check('pages are walked with the index\'s cursor, never repeating or skipping a resource', async () => {
++ const many = await add(projectId, 'Bulk', 'draft', `app:${ids.newId('app')}`, 30);
++ const want = new Set([mine.rel, mine.mfs, ...many.flatMap((x) => [x.rel, x.mfs])]);
++ const seen = [];
++ let url = page;
++ let pages = 0;
++ while (url) {
++ const r = await t.get(url, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ pages++;
++ for (const m of r.text.matchAll(/:(?:release|manifest)\/((?:rel|mfs)_[0-9A-Z]{26})<\/code>/g)) seen.push(m[1]);
++ if (pages > 1) assert.ok(r.text.includes(`<a href="${page}">First page</a>`), 'a later page links back to the first');
++ const next = r.text.match(/<a href="([^"]+)">Next page<\/a>/);
++ url = next ? next[1].replace(/&amp;/g, '&') : null;
++ assert.ok(pages < 5, 'the walk ends');
++ }
++ assert.strictEqual(pages, 2, '62 resources at 50 a page');
++ assert.strictEqual(seen.length, want.size, 'each resource once');
++ assert.deepStrictEqual(new Set(seen), want);
++ });
++
++ t.ctx.index.list = realList;
++ await done(t);
++})();