openvibers/openvibe.codes Commits

Project export and delete, over Network's /api/v1/projects

OP OpenVibers committed 23 Sept 2026 · 584012a905 · 8 files, +358 −7

- Export (/projects/:project/export, any member): one JSON document, format
  openvibe.codes.project-export v1. Network's project, members, apps with credential ids and
  hints, grants, quotas and, for admin+, the whole audit log (paged, marked incomplete past 50
  pages), read with the person's own token exactly as Network answers; plus Codes' releases with
  manifests and logs, trust tiers and playground runs. No secret; no-store; a Network failure
  fails the export instead of returning part of it.
- Delete (POST /projects/:project/delete, owner or staff, confirmed by typing the project name,
  CSRF-guarded): Network has no erasure endpoint, so it archives the project there first (every
  app and credential revoked) and changes nothing in Codes if Network refuses. Then Codes deletes
  draft releases, their manifests and playground runs, and revokes published and deprecated
  releases (codes.app.revoked), so people who installed them are told. Running it again is
  harmless.
- The project page offers both and says what Network keeps (the archived record and audit log).
- Tests: export contents per role, no secrets, non-member and anonymous, no partial export;
  delete refused for a developer, a wrong name, a forged form token and a failing Network with
  nothing changed; the owner's delete end to end; audit paging.

Changes

ddiff --git a/README.md b/README.md
iindex 1fc793c..cf0f726 100644
---- a/README.md
++++ b/README.md
@@@ -30,7 +30,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; audit (admin+). Network's errors are shown with its status, code, detail and request id. |
++| 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; 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 Codes' releases with manifests and logs, trust tiers and playground runs; never a secret) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Codes 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`; v1 `X-OpenVibe-Signature: sha256=<HMAC>` is checked and shown but never decides. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDelivery`/`verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed with v1 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. |
@@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
```
--Tests: 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, v1 and v2 (tampering, prefixes, lengths, the ±300 s window both ways, timestamp header mismatch, v1 never deciding, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
++Tests: 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, v1 and v2 (tampering, prefixes, lengths, the ±300 s window both ways, timestamp header mismatch, v1 never deciding, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; 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; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
## Acceptance (must be true before "done")
ddiff --git a/STATUS.json b/STATUS.json
iindex 71427f8..55f00cd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -16,6 +16,7 @@
"works": [
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
"Projects, members, apps, credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas and audit over Network's /api/v1/projects API with the person's own token; Network's errors shown as Network answered them",
++ "Project export (one JSON document: Network's project, members, apps, credential ids and hints, grants, quotas, audit for admin+, plus Codes' releases, manifests, trust and playground runs) and delete (owner: archive in Network first, then drafts and playground runs deleted, public releases revoked). Network has no project erasure endpoint, only archive",
"Client secrets shown once, in the create/rotate response only, with no-store; never stored or logged (tested)",
"Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
"Docs generated at boot from the pinned openvibe-contracts and openvibe-sdk: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",
@@@ -30,7 +31,7 @@
"notYet": [
"The playgrounds have never been used on production (0 playground runs); the one production end-to-end run used curl, and it is not a committed, repeatable check",
"No usage, trace or error dashboards: no service reports per-project usage yet; quotas show recorded limits only",
-- "Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates; project data export and delete",
++ "Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates",
"Consent screen for third-party production apps (a Network gap)",
"No JSON-LD or llms.txt on the public pages",
"Governance material (contribution guide, code of conduct, contributor ladder, moderation policy) is not written"
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex ed16e54..d6676fc 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -74,7 +74,7 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
: call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
quotas: (t, p) => call(t, 'GET', `${P(p)}/quotas`),
-- audit: (t, p, { before } = {}) => call(t, 'GET', `${P(p)}/audit${before ? `?before=${enc(before)}` : ''}`),
++ audit: (t, p, { before, limit } = {}) => call(t, 'GET', `${P(p)}/audit${before || limit ? `?${new URLSearchParams({ ...(before ? { before: String(before) } : {}), ...(limit ? { limit: String(limit) } : {}) })}` : ''}`),
};
// Registry: public, cached for registryTtlMs so a page view never waits on Network twice.
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
iindex 20eff0e..f5c579f 100644
---- a/server/domain/playground.js
++++ b/server/domain/playground.js
@@@ -77,6 +77,8 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
}
const runsFor = (appId, limit = 20) => db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);
++ const runsForProject = (projectId) => db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));
++ const deleteRunsForProject = (projectId) => db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId)).changes;
const recentCount = (actor) => db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString()).n;
/** Steps 1–2 only: may this app run this playground at all? */
@@@ -210,7 +212,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
}
}
-- return { run, precheck, runsFor, grantAdvice, KINDS, projectKey, appSource };
++ return { run, precheck, runsFor, runsForProject, deleteRunsForProject, grantAdvice, KINDS, projectKey, appSource };
}
module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource };
ddiff --git a/server/domain/project-export.js b/server/domain/project-export.js
nnew file mode 100644
iindex 0000000..3ac7912
---- /dev/null
++++ b/server/domain/project-export.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/**
++ * A project's metadata as one JSON document, for the developer to keep or move elsewhere.
++ *
++ * Network owns the project, members, apps, credentials, grants, quotas and audit (ADR-014), so
++ * those parts are read from its /api/v1/projects API with the person's own token, exactly as it
++ * answers: Codes adds nothing and copies nothing into its own database. Codes adds only what is
++ * Codes': releases (with manifests and logs), trust tiers and playground run logs.
++ *
++ * There is no secret in it: Network lists credentials by id and last four characters only, and no
++ * Codes table holds a secret (test/secrets.test.js). The audit log needs the admin role in Network;
++ * for anyone else it is left out and the document says so. Any Network failure fails the whole
++ * export: a partial export presented as complete would be wrong.
++ */
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++const FORMAT = 'openvibe.codes.project-export';
++const FORMAT_VERSION = 1;
++const AUDIT_PAGE = 200;
++const AUDIT_MAX_PAGES = 50;
++
++async function buildExport({ network, token, project, includeAudit, releases, playground, trust, meta }) {
++ const P = network.projects;
++ const [members, apps, quotas] = await Promise.all([P.members(token, project.id), P.apps(token, project.id), P.quotas(token, project.id)]);
++ const appList = await Promise.all((apps.apps || []).map(async (a) => {
++ const [credentials, grants] = await Promise.all([P.credentials(token, project.id, a.id), P.grants(token, project.id, a.id)]);
++ return { ...a, credentials: credentials.credentials || [], grants: grants.grants || [] };
++ }));
++
++ let audit = null;
++ if (includeAudit) {
++ audit = { entries: [], complete: true };
++ let before;
++ for (let page = 0; ; page++) {
++ if (page === AUDIT_MAX_PAGES) { audit.complete = false; break; }
++ const got = await P.audit(token, project.id, { before, limit: AUDIT_PAGE });
++ audit.entries.push(...(got.entries || []));
++ if (!got.next_before) break;
++ before = got.next_before;
++ }
++ }
++
++ const appIds = appList.map((a) => a.id);
++ return {
++ format: FORMAT,
++ format_version: FORMAT_VERSION,
++ exported_at: meta.now,
++ exported_by: meta.subject,
++ sources: {
++ network: { url: meta.networkUrl, api: '/api/v1/projects', owns: ['project', 'members', 'apps', 'credentials', 'grants', 'quotas', 'audit'] },
++ codes: { url: meta.baseUrl, contracts: contractsVersion, owns: ['releases', 'trust', 'playground_runs'] },
++ },
++ project,
++ members: members.members || [],
++ apps: appList,
++ quotas: quotas.quotas || [],
++ audit: audit || { entries: null, note: 'the audit log needs the admin role in this project' },
++ codes: {
++ releases: releases.listForProject(project.id),
++ trust: appIds.map((id) => ({ app_id: id, ...trust.get(id), history: trust.history(id) })),
++ playground_runs: playground.runsForProject(project.id),
++ },
++ };
++}
++
++module.exports = { buildExport, FORMAT, FORMAT_VERSION };
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
iindex 47fbc13..dbb2382 100644
---- a/server/domain/releases.js
++++ b/server/domain/releases.js
@@@ -178,7 +178,37 @@ function createReleases({ store, outbox, trust }) {
return { release: get(r.id), event_id: env ? env.event_id : null };
}
-- return { get, manifestOf, listForApp, recentPublic, log, createDraft, publish, deprecate, revoke, manageRole };
++ /** Every release of a project (drafts included), each with its manifest and log: for the project export. */
++ const listForProject = (projectId) => db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId))
++ .map((r) => ({ ...view(r), manifest: manifestOf(r.manifest_id), log: log(r.id) }));
++
++ /**
++ * Codes' part of deleting a project (Network archives the project itself). Drafts were never
++ * public, so they and their manifests are deleted (the append-only log records it). Published
++ * and deprecated releases are revoked, not erased: people who installed them need the
++ * revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.
++ * The caller has checked that the actor owns the project (or is staff).
++ */
++ function retireProject({ actor, projectId, reason = 'project deleted by its owner' }) {
++ const rows = db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
++ const out = { revoked: [], deletedDrafts: [] };
++ for (const r of rows.filter((x) => x.status !== 'draft')) {
++ revoke({ actor, releaseId: r.id, app: null, reason });
++ out.revoked.push(r.id);
++ }
++ db.transaction(() => {
++ for (const r of rows.filter((x) => x.status === 'draft')) {
++ db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);
++ const shared = db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id).n;
++ if (!shared) db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);
++ writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });
++ out.deletedDrafts.push(r.id);
++ }
++ })();
++ return out;
++ }
++
++ return { get, manifestOf, listForApp, listForProject, recentPublic, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };
}
module.exports = { createReleases, ReleaseError, manageRole, RANK };
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 96ddb18..9c2433d 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -24,6 +24,7 @@ const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const manifestsLib = require('../domain/manifests');
const { RANK, ReleaseError } = require('../domain/releases');
const { validationResult } = require('./tools');
++const { buildExport } = require('../domain/project-export');
const { statusBadge } = require('./pages');
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -172,8 +173,21 @@ ${!archived && assignable.length ? html`<h3>Add a member</h3>
<form method="post" action="/projects/${project.id}/members" class="inline-form">${csrfField(c)}
<label>Username <input name="username" required maxlength="64"></label>
<label>Role <select name="role">${assignable.map((x) => html`<option>${x}</option>`)}</select></label><button type="submit">Add</button></form>` : ''}
++
++<h2>Export</h2>
++<p>Download this project's metadata as JSON: the project, members, apps with their credentials (ids and last four characters, never a secret) and grants, quotas${atLeast(role, 'admin') || req.viewer.staff ? ', the audit log' : ''} as OpenVibe.Network holds them, and Codes' releases, manifests, trust tiers and playground runs.${atLeast(role, 'admin') || req.viewer.staff ? '' : ' The audit log needs the admin role and is left out.'}</p>
++<p><a class="button" href="/projects/${project.id}/export" download>Download export (JSON)</a></p>
${role === 'owner' && !archived ? html`<h2>Archive</h2><form method="post" action="/projects/${project.id}/archive" class="stack">${csrfField(c)}
--<label><input type="checkbox" name="confirm" value="1" required> Archiving cannot be undone and revokes every app in the project</label><button type="submit" class="danger">Archive project</button></form>` : ''}`,
++<label><input type="checkbox" name="confirm" value="1" required> Archiving cannot be undone and revokes every app in the project</label><button type="submit" class="danger">Archive project</button></form>` : ''}
++${role === 'owner' || req.viewer.staff ? html`<h2>Delete</h2>
++<p>Deleting archives the project in OpenVibe.Network (every app and credential is revoked; this cannot be undone) and removes Codes' data for it:</p>
++<ul><li>draft releases and their manifests are deleted;</li>
++<li>published and deprecated releases are <strong>revoked</strong>, not erased, so people who installed them are told (the <a href="/policy/compatibility">compatibility policy</a>);</li>
++<li>playground run logs are deleted.</li></ul>
++<p class="muted small">OpenVibe.Network keeps the archived project record and its append-only audit log; it offers archiving, not erasure. Download the export first if you want a copy.</p>
++<form method="post" action="/projects/${project.id}/delete" class="stack">${csrfField(c)}
++<label>Type the project name, <strong>${project.name}</strong>, to confirm <input name="confirm_name" required autocomplete="off" spellcheck="false"></label>
++<button type="submit" class="danger">Delete project</button></form>` : ''}`,
});
});
@@@ -215,6 +229,36 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
after(req, res, got, back(req), 'Project archived');
});
++ r.get('/:project/export', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const got = await net(req, res, (t) => buildExport({
++ network, token: t, project, includeAudit: atLeast(project.role, 'admin') || req.viewer.staff, releases, playground, trust,
++ meta: { now: new Date().toISOString(), subject: req.viewer.subject, networkUrl: config.networkUrl, baseUrl: config.baseUrl },
++ }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Export', back: back(req) });
++ res.set('Cache-Control', 'no-store');
++ res.set('Content-Disposition', `attachment; filename="openvibe-project-${project.id}.json"`);
++ res.type('application/json').send(`${JSON.stringify(got.data, null, 2)}\n`);
++ });
++
++ r.post('/:project/delete', idParams, form, guard, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ if (project.role !== 'owner' && !req.viewer.staff) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner can delete it' }, { back: back(req) });
++ if (String(req.body.confirm_name || '').trim() !== project.name) return problemPage(req, res, { status: 422, code: 'codes.confirm', detail: 'type the project name exactly to confirm' }, { back: back(req) });
++ // Network first: if it refuses, nothing in Codes changes.
++ if (!project.archived_at) {
++ const got = await net(req, res, (t) => network.projects.archive(t, project.id));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Delete project', back: back(req) });
++ }
++ const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.role, staff: req.viewer.staff, traceparent: req.ov.traceparent };
++ const retired = releases.retireProject({ actor, projectId: project.id });
++ const runs = playground.deleteRunsForProject(project.id);
++ log.info(`[Codes] project ${project.id} deleted by ${actor.label}: archived in Network, ${retired.revoked.length} releases revoked, ${retired.deletedDrafts.length} drafts and ${runs} playground runs deleted`);
++ res.redirect(303, `${back(req)}?done=${encodeURIComponent(`Deleted: ${retired.revoked.length} revoked, ${retired.deletedDrafts.length} drafts and ${runs} runs removed`)}`);
++ });
++
r.get('/:project/audit', idParams, async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
ddiff --git a/test/project-export-delete.test.js b/test/project-export-delete.test.js
nnew file mode 100644
iindex 0000000..90af88d
---- /dev/null
++++ b/test/project-export-delete.test.js
@@@ -0,0 +1,207 @@
++'use strict';
++/**
++ * Project export and delete.
++ * - export: one JSON document, read from Network's /api/v1/projects with the person's token
++ * (project, members, apps with credentials and grants, quotas, audit for admin+) plus Codes'
++ * releases, trust and playground runs; never a secret; any member may export; a Network
++ * failure fails the export instead of returning a partial one
++ * - delete: owner (or staff) only, confirmed by the project name, CSRF-guarded; archives in
++ * Network first and changes nothing in Codes when Network refuses; then deletes drafts (and
++ * their manifests) and playground runs, and revokes public releases with codes.app.revoked
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const { buildExport, FORMAT } = require('../server/domain/project-export');
++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 dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const projectId = await t.project(owner, 'Exportable');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'dev', role: 'developer' } });
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vee', role: 'viewer' } });
++ const app = await t.app(owner, projectId, { name: 'Exporter' });
++ assert.ok(app.secret, 'a confidential app gets a secret');
++ await t.grant(owner, projectId, app.id, 'media.object.upload');
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Exporter' }), version });
++ const draft = async (version) => {
++ const r = await t.get(`/projects/${projectId}/apps/${app.id}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ return r.headers.get('location').split('/').pop();
++ };
++ const published = await draft('1.0.0');
++ assert.strictEqual((await t.get(`/releases/${published}/publish`, { as: owner, form: {} })).status, 303);
++ const drafted = await draft('1.1.0');
++ const draftManifest = db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted).manifest_id;
++ db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)
++ VALUES ('run_01JTEST00000000000000000000', ?, ?, ?, ?, 'media', 'media.object.upload', 'client_secret', 'ok', 'done', 'stored 5 bytes')`)
++ .run(new Date().toISOString(), `user:${owner.subject}`, projectId, app.id);
++ const count = (sql, ...a) => db.prepare(sql).get(...a).n;
++ const exportPath = `/projects/${projectId}/export`;
++
++ await check('the owner exports everything Network and Codes hold for the project, as a JSON download', async () => {
++ const r = await t.get(exportPath, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.headers.get('content-type'), /^application\/json/);
++ assert.strictEqual(r.headers.get('content-disposition'), `attachment; filename="openvibe-project-${projectId}.json"`);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const x = r.json();
++ assert.strictEqual(x.format, FORMAT);
++ assert.strictEqual(x.format_version, 1);
++ assert.strictEqual(x.exported_by, owner.subject);
++ assert.strictEqual(x.project.id, projectId);
++ assert.deepStrictEqual(x.members.map((m) => m.username).sort(), ['dev', 'ona', 'vee']);
++ assert.strictEqual(x.apps.length, 1);
++ assert.strictEqual(x.apps[0].id, app.id);
++ assert.strictEqual(x.apps[0].credentials.length, 1);
++ assert.ok(x.apps[0].credentials[0].hint, 'credentials by id and hint');
++ assert.deepStrictEqual(x.apps[0].grants.map((g) => g.capability), ['media.object.upload']);
++ assert.ok(Array.isArray(x.quotas));
++ assert.ok(Array.isArray(x.audit.entries) && x.audit.entries.length >= 1, 'the owner gets the audit log');
++ assert.strictEqual(x.audit.complete, true);
++ assert.deepStrictEqual(x.codes.releases.map((rel) => [rel.id, rel.status]), [[published, 'published'], [drafted, 'draft']]);
++ assert.strictEqual(x.codes.releases[0].manifest.body.version, '1.0.0');
++ assert.deepStrictEqual(x.codes.releases[0].log.map((l) => l.action), ['created', 'published']);
++ assert.strictEqual(x.codes.trust[0].app_id, app.id);
++ assert.strictEqual(x.codes.trust[0].tier, 'unreviewed');
++ assert.strictEqual(x.codes.playground_runs.length, 1);
++ assert.strictEqual(x.sources.network.api, '/api/v1/projects');
++ });
++
++ await check('the export never contains a secret or a token', async () => {
++ const r = await t.get(exportPath, { as: owner });
++ assert.ok(!r.text.includes(app.secret), 'the client secret');
++ assert.ok(!/ovsec_/.test(r.text));
++ assert.ok(!r.text.includes(t.network.userToken(owner).split('.')[2]));
++ });
++
++ await check('a viewer may export; the audit log (admin+ in Network) is left out and the document says so', async () => {
++ const r = await t.get(exportPath, { as: viewer });
++ assert.strictEqual(r.status, 200);
++ const x = r.json();
++ assert.strictEqual(x.audit.entries, null);
++ assert.match(x.audit.note, /admin role/);
++ assert.strictEqual(x.apps.length, 1);
++ const pageText = (await t.get(`/projects/${projectId}`, { as: viewer })).text;
++ assert.match(pageText, /Download export \(JSON\)/);
++ assert.ok(!pageText.includes(`action="/projects/${projectId}/delete"`), 'no delete form for a viewer');
++ });
++
++ await check('a non-member gets Network\'s answer, not a document; anonymous must sign in', async () => {
++ const r = await t.get(exportPath, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ assert.ok(!r.headers.get('content-disposition'));
++ assert.strictEqual((await t.get(exportPath)).status, 401);
++ });
++
++ await check('when Network fails part-way the export fails; it is never partial', async () => {
++ const orig = t.ctx.network.projects.quotas;
++ t.ctx.network.projects.quotas = async () => { throw Object.assign(new Error('x'), { status: 503 }); };
++ try {
++ const r = await t.get(exportPath, { as: owner });
++ assert.notStrictEqual(r.status, 200);
++ assert.ok(!r.headers.get('content-disposition'));
++ } finally { t.ctx.network.projects.quotas = orig; }
++ });
++
++ const unchanged = () => {
++ assert.strictEqual(t.network.state.projects.get(projectId).archived_at, null, 'not archived in Network');
++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);
++ assert.strictEqual(count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);
++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);
++ };
++ const del = `/projects/${projectId}/delete`;
++
++ await check('delete: a developer is refused and nothing changes', async () => {
++ const r = await t.get(del, { as: dev, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /only the project owner/);
++ unchanged();
++ });
++
++ await check('delete: the wrong name, or no form token, changes nothing and calls no Network write', async () => {
++ const before = t.network.requests.filter((q) => q.method === 'POST').length;
++ const wrong = await t.get(del, { as: owner, form: { confirm_name: 'exportable' } });
++ assert.strictEqual(wrong.status, 422);
++ assert.match(wrong.text, /type the project name exactly/);
++ const noCsrf = await t.get(del, { as: owner, form: { confirm_name: 'Exportable', csrf: 'forged' } });
++ assert.strictEqual(noCsrf.status, 403);
++ assert.strictEqual(t.network.requests.filter((q) => q.method === 'POST').length, before);
++ unchanged();
++ });
++
++ await check('delete: when Network refuses to archive, Codes keeps everything', async () => {
++ const orig = t.ctx.network.projects.archive;
++ t.ctx.network.projects.archive = async () => { throw Object.assign(new Error('x'), { status: 503 }); };
++ try {
++ const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.notStrictEqual(r.status, 303);
++ } finally { t.ctx.network.projects.archive = orig; }
++ unchanged();
++ });
++
++ await check('delete by the owner: archived in Network, drafts and runs removed, public releases revoked with an event', async () => {
++ const page = await t.get(`/projects/${projectId}`, { as: owner });
++ assert.match(page.text, /Type the project name/);
++ const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.match(decodeURIComponent(r.headers.get('location')), /Deleted: 1 revoked, 1 drafts and 1 runs removed/);
++ const p = t.network.state.projects.get(projectId);
++ assert.ok(p.archived_at, 'archived in Network');
++ assert.ok(t.network.state.apps.get(app.id).revoked_at, 'Network revoked the app');
++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');
++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');
++ assert.strictEqual(db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published).status, 'revoked');
++ assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);
++ const ev = db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope));
++ const revoked = ev.filter((e) => e.event_type === 'codes.app.revoked');
++ assert.strictEqual(revoked.length, 1);
++ assert.strictEqual(revoked[0].payload.release_id, published);
++ assert.deepStrictEqual(db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');
++ // The public release page now says revoked.
++ assert.match((await t.get(`/releases/${published}`)).text, /revoked/i);
++ });
++
++ await check('after delete: the export still works (archived) and deleting again is harmless', async () => {
++ const x = (await t.get(exportPath, { as: owner })).json();
++ assert.ok(x.project.archived_at);
++ assert.deepStrictEqual(x.codes.releases.map((rel) => [rel.id, rel.status]), [[published, 'revoked']]);
++ assert.deepStrictEqual(x.codes.playground_runs, []);
++ const again = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(again.status, 303);
++ assert.match(decodeURIComponent(again.headers.get('location')), /Deleted: 0 revoked, 0 drafts and 0 runs removed/);
++ });
++
++ await check('buildExport pages through Network\'s audit log and marks a very long one incomplete', async () => {
++ const fake = (pages) => {
++ let calls = 0;
++ const P = {
++ members: async () => ({ members: [] }), apps: async () => ({ apps: [] }), quotas: async () => ({ quotas: [] }),
++ audit: async (_t, _p, { before, limit }) => {
++ calls++;
++ assert.strictEqual(limit, 200);
++ const n = before ? Number(before) : pages;
++ return { entries: [{ id: n }], next_before: n > 1 ? n - 1 : null };
++ },
++ };
++ return { network: { projects: P }, calls: () => calls };
++ };
++ const deps = { releases: { listForProject: () => [] }, playground: { runsForProject: () => [] }, trust: { get: () => ({}), history: () => [] }, meta: {} };
++ const three = fake(3);
++ const a = await buildExport({ ...deps, network: three.network, token: 't', project: { id: 'prj_x' }, includeAudit: true });
++ assert.deepStrictEqual(a.audit.entries.map((e) => e.id), [3, 2, 1]);
++ assert.strictEqual(a.audit.complete, true);
++ const many = fake(1000);
++ const b = await buildExport({ ...deps, network: many.network, token: 't', project: { id: 'prj_x' }, includeAudit: true });
++ assert.strictEqual(many.calls(), 50);
++ assert.strictEqual(b.audit.complete, false);
++ });
++
++ await t.close();
++ done();
++})();