openvibers/openvibe.services Commits
Services: a project's Resources page over the merged index (#32)
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/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: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); 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")@@@ -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);nnew file mode 100644@@@ -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('<img src=x onerror=alert(1)>'), '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 <service>\.<type> \(<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(/&/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);++})();
@@ -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/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. |
| 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); 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")
## Acceptance (must be true before "done")
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* The signed-in portal: projects, members, apps, credentials, grants, quotas, usage and audit — all over
* 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
* 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:
* 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:
* Honesty rules:
* - a Network failure is shown as Network answered it (HTTP status, problem code, detail, request
* - a Network failure is shown as Network answered it (HTTP status, problem code, detail, request
@@ -16,6 +17,8 @@
@@ -16,6 +17,8 @@
* shown with its lag; Services computes nothing of its own
* 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
* - the scope editor offers only capabilities apps can be granted (public, or partner when in the
* allowance) and refuses to forward anything else
* 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 express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -31,6 +34,8 @@ const { buildExport } = require('../domain/project-export');
@@ -31,6 +34,8 @@ const { buildExport } = require('../domain/project-export');
const { ArchiveError } = require('../domain/project-archive');
const { ArchiveError } = require('../domain/project-archive');
const { statusBadge } = require('./pages');
const { statusBadge } = require('./pages');
const { usageBody, usageQuery } = require('../render/usage');
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 PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[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}$/;
@@ -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 USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
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];
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
@@ -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>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>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>
<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>
<h2>Apps</h2>
${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
${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=$
@@ -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 ──
// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
r.get('/:project/usage', idParams, async (req, res) => {
r.get('/:project/usage', idParams, async (req, res) => {
const project = await loadProject(req, res);
const project = await loadProject(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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('<img src=x onerror=alert(1)>'), '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 <service>\.<type> \(<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(/&/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);
+})();