openvibers/openvibe.codes Commits

Project export, the full archive (roadmap WS-N task 9): a project's owner or an admin downloads one zip (POST /projects/:project/export/archive) with manifest.json (counts, limits reached, every file's size and SHA-256, what is not included), README.txt, project.json (the metadata document with the audit log: the configuration), modules/<release>.json (each release's app or mod manifest as validated), and per environment media/<env>/namespaces.json, media/<env>/objects.jsonl (every object, deleted ones too, with its public URL or a 1-hour signed one; objects are listed, not copied) and events/<env>.jsonl (the project's retained app.<project_key>.* events, paged). Media and Events are read only with Network's read-only export tokens (one per service and environment, asked again before they expire, never stored or logged; Network checks the role and audits each). A failing service fails the whole export and the page names the service, part and environment; a limit (CODES_EXPORT_MAX_OBJECTS 5000, CODES_EXPORT_MAX_EVENTS 10000 per environment) makes the part and the archive say complete: false and where they stopped. /docs/export describes the format. test/project-archive.test.js

OP OpenVibers committed 26 Sept 2026 · 0ef363fec6 · 14 files, +960 −18


Changes

ddiff --git a/.env.example b/.env.example
iindex d644693..3bffe74 100644
---- a/.env.example
++++ b/.env.example
@@@ -31,6 +31,15 @@ CODES_STAFF_SUBJECTS=
# CODES_PLAYGROUND_MAX_UPLOAD_BYTES=1048576
# CODES_PLAYGROUND_RUNS_PER_HOUR=60
++# The full project archive (owner/admin, /projects/:project/export/archive) reads Media and Events
++# server-side with Network's read-only export tokens (defaults: OV_MEDIA_INTERNAL_URL /
++# OV_EVENTS_INTERNAL_URL, else loopback). Limits per environment; signed URL lifetime (60-3600 s).
++# CODES_EXPORT_MEDIA_URL=http://127.0.0.1:4100
++# CODES_EXPORT_EVENTS_URL=http://127.0.0.1:4300
++# CODES_EXPORT_MAX_OBJECTS=5000
++# CODES_EXPORT_MAX_EVENTS=10000
++# CODES_EXPORT_URL_TTL_S=3600
++
# Registry answers are cached this long (ms).
# CODES_REGISTRY_TTL_MS=30000
ddiff --git a/README.md b/README.md
iindex 5b9a956..f340ad1 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+); **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. |
++| 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); 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.codes/docs/export)) 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. |
@@@ -45,8 +45,8 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
## Depends on
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
--- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`).
--- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace.
++- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
++- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
- **openvibe-contracts** v0.33.0, **openvibe-sdk v0.5.0**, **openvibe-shared v1.5.1** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
ddiff --git a/STATUS.json b/STATUS.json
iindex f4f3acb..6fc2d1a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -17,6 +17,7 @@
"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",
++ "Full project archive for the owner and admins (roadmap WS-N task 9): one zip with the metadata document (audit included), the release manifests (the project's modules), Media objects (listed with public or 1-hour signed download URLs) and namespaces, and the app events Events still keeps, for production and sandbox; read with Network's read-only export tokens (POST /api/v1/projects/:project/export-tokens, 5 minutes, audited). All or nothing when a service fails; at most CODES_EXPORT_MAX_OBJECTS / CODES_EXPORT_MAX_EVENTS per environment, stated in manifest.json when reached",
"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",
ddiff --git a/server/app.js b/server/app.js
iindex e7445c7..a18d847 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -30,6 +30,7 @@ const { generate } = require('./docs/generate');
const { createTrust } = require('./domain/trust');
const { createReleases } = require('./domain/releases');
const { createPlayground } = require('./domain/playground');
++const { createArchiver } = require('./domain/project-archive');
const { createDocsRoutes } = require('./http/docs');
const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
@@@ -57,8 +58,9 @@ function createApp(opts = {}) {
const trust = createTrust({ store, outbox });
const releases = createReleases({ store, outbox, trust });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
++ const archiver = createArchiver({ config, fetchImpl, log });
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, log };
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex d6676fc..4a166b7 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -7,6 +7,7 @@
* Network owns projects, members, apps, credentials, grants and quotas (ADR-014); Codes
* renders what it answers and forwards what the person asks. It never stores any of it.
* registry /api/v1/registry/* and /.well-known/openvibe (public, cached briefly).
++ * POST /:project/export-tokens mints the project export's read-only tokens (never kept).
* appToken /oauth/token client_credentials for an app — only in playgrounds, with the secret the
* developer typed for that one request (never stored, never logged, never echoed).
*
@@@ -75,6 +76,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
: 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, limit } = {}) => call(t, 'GET', `${P(p)}/audit${before || limit ? `?${new URLSearchParams({ ...(before ? { before: String(before) } : {}), ...(limit ? { limit: String(limit) } : {}) })}` : ''}`),
++ // A 5-minute read-only token for the project export (owner/admin; Network checks and audits).
++ exportToken: (t, p, body) => call(t, 'POST', `${P(p)}/export-tokens`, body),
};
// Registry: public, cached for registryTtlMs so a page view never waits on Network twice.
ddiff --git a/server/config.js b/server/config.js
iindex 3c26cf9..db46f8c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -67,6 +67,18 @@ function load(env = process.env) {
runsPerHour: int(env.CODES_PLAYGROUND_RUNS_PER_HOUR, 60),
},
++ // The full project archive (server/domain/project-archive.js): Media and Events are read
++ // server-side (loopback by default, as for /docs/limits) with Network's read-only export
++ // tokens. The limits bound one archive: objects and events per environment, and how long
++ // Media's signed download URLs in it stay valid (Media allows at most an hour).
++ export: {
++ mediaUrl: trim(env.CODES_EXPORT_MEDIA_URL || env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
++ eventsUrl: trim(env.CODES_EXPORT_EVENTS_URL || env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300'),
++ maxObjects: Math.max(1, int(env.CODES_EXPORT_MAX_OBJECTS, 5000)),
++ maxEvents: Math.max(1, int(env.CODES_EXPORT_MAX_EVENTS, 10000)),
++ urlTtlS: Math.min(3600, Math.max(60, int(env.CODES_EXPORT_URL_TTL_S, 3600))),
++ },
++
// Registry answers are cached this long (Network's own health poll runs every 60 s).
registryTtlMs: int(env.CODES_REGISTRY_TTL_MS, 30 * 1000),
ddiff --git a/server/domain/project-archive.js b/server/domain/project-archive.js
nnew file mode 100644
iindex 0000000..8ebae5d
---- /dev/null
++++ b/server/domain/project-archive.js
@@@ -0,0 +1,289 @@
++'use strict';
++
++/**
++ * The full project archive (roadmap WS-N task 9): one zip with everything the project holds on the
++ * platform, for its owner or an admin. Built when asked, sent once, never stored.
++ *
++ * manifest.json what each part holds: counts, limits reached, file sizes and hashes
++ * README.txt the same in words, and how to fetch the objects before the URLs expire
++ * project.json the metadata document (project-export.js): Network's project, members,
++ * apps, credentials by id and hint, grants, quotas and audit; Codes'
++ * releases, trust tiers and playground runs. This is the configuration.
++ * modules/<release_id>.json each release's manifest (codes.app-manifest@1 or mods.mod-manifest@1)
++ * exactly as validated: the project's app and mod modules
++ * media/<env>/namespaces.json Media's namespaces for the project: policy, quotas, usage
++ * media/<env>/objects.jsonl every object Media holds for the project (soft-deleted ones too),
++ * with `download`: its public URL, or a signed URL valid urlTtlS
++ * events/<env>.jsonl the project's app events (app.<project_key>.*) Events still keeps,
++ * one { seq, event } per line
++ *
++ * Objects are listed with download URLs rather than copied into the zip: a project may hold a GiB
++ * or more, and Codes stays out of the byte path. Media signs private and sandbox URLs for at most
++ * an hour; public production objects keep their permanent URL.
++ *
++ * Authorization: for each audience (openvibe.media, openvibe.events) and environment, Network mints
++ * a 5-minute read-only export token for the signed-in person (`mintToken`, with their own Network
++ * token; Network allows only the owner and admins). A long export asks again a minute before one
++ * expires. No app secret is involved, and no token is written to the archive or the logs.
++ *
++ * Failure: a service that fails (an error, a refusal, no answer) fails the whole export with an
++ * ArchiveError naming the service, the part and the environment; a partial archive is never sent as
++ * if it were complete. A size limit is not a failure: the part says `complete: false`, how many it
++ * holds and where it stopped, and manifest.json's top-level `complete` is false.
++ */
++const crypto = require('crypto');
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { zip } = require('./zip');
++
++const FORMAT = 'openvibe.codes.project-archive';
++const FORMAT_VERSION = 1;
++const ENVS = ['production', 'sandbox'];
++const OBJECT_PAGE = 200; // Media's largest list page
++const EVENT_PAGE = 1000; // Events' largest pull page
++const MAX_EVENT_PAGES = 10000; // a guard; each pull scans up to 5,000 rows of the project's topic
++const REMINT_BEFORE_MS = 60 * 1000;
++const DOWNLOAD_CONCURRENCY = 8;
++const SERVICES = { media: 'OpenVibe.Media', events: 'OpenVibe.Events' };
++
++const NOT_INCLUDED = Object.freeze([
++ { what: 'Tools job results', why: 'OpenVibe.Tools keeps them in its own Media tenant (tools.app.<project_id>), readable only by Tools; fetch each result from the Tools job API with the app\'s own token.' },
++ { what: 'Events webhook subscriptions', why: 'they belong to each app (listing them needs events.app.subscribe) and carry signing secrets; list them with the app\'s own token.' },
++ { what: 'Network user modules', why: 'they are per person, not per project; each member exports their own from OpenVibe.Network (GET /api/modules).' },
++ { what: 'Client secrets and tokens', why: 'never exported: Network lists credentials by id and last four characters only.' },
++]);
++
++/** A service that failed during the export. `problem` is what the portal shows. */
++class ArchiveError extends Error {
++ constructor({ service, part, env, status, code, detail, requestId }) {
++ const name = SERVICES[service] || service;
++ const answered = status ? `answered ${status}${code ? ` (${code})` : ''}` : `did not answer${code ? ` (${code})` : ''}`;
++ super(`${name} ${answered} for ${part} (${env})`);
++ this.problem = {
++ status: 502, code: 'codes.export_failed', requestId: requestId || null,
++ detail: `${name} ${answered} while exporting ${part} (${env})${detail ? `: ${detail}` : ''}. Nothing was exported: an archive is never sent with a part missing.`,
++ };
++ }
++}
++
++const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
++const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
++const jsonl = (rows) => rows.map((r) => `${JSON.stringify(r)}\n`).join('');
++const json = (v) => `${JSON.stringify(v, null, 2)}\n`;
++
++/** Run `fn` over `items`, `n` at a time; the first failure rejects (the rest are not started). */
++async function pool(items, n, fn) {
++ let next = 0;
++ let failed = null;
++ const worker = async () => {
++ while (!failed && next < items.length) {
++ const item = items[next++];
++ try { await fn(item); } catch (err) { failed = failed || err; }
++ }
++ };
++ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
++ if (failed) throw failed;
++}
++
++function createArchiver({ config, fetchImpl, log = console }) {
++ const opts = config.export;
++ const client = createClient({
++ network: config.networkInternalUrl, autoDiscover: false, retries: 1, timeoutMs: 15000, deadlineMs: 30000,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ onWarning: (m) => log.warn('[Codes] sdk:', m),
++ });
++
++ /** A token source for one audience and environment: minted on first use and again before it expires. */
++ function tokenSource(mintToken, audience, env) {
++ let current = null;
++ return async () => {
++ if (!current || Date.parse(current.expires_at) - Date.now() < REMINT_BEFORE_MS) current = await mintToken(audience, env);
++ return current.access_token;
++ };
++ }
++
++ /**
++ * GET one JSON document from Media or Events; any failure there becomes an ArchiveError. The
++ * token comes first and outside: Network refusing it is Network's answer, not the service's.
++ */
++ async function get(service, part, env, token, path, query) {
++ const baseUrl = service === 'media' ? opts.mediaUrl : opts.eventsUrl;
++ const bearer = await token();
++ try {
++ return (await client.json({ baseUrl, path, query, token: bearer })) || {};
++ } catch (err) {
++ if (err instanceof ArchiveError || !isOpenVibeError(err)) throw err;
++ const unreachable = !err.status || String(err.code || '').startsWith('sdk.');
++ throw new ArchiveError({
++ service, part, env, status: unreachable ? null : err.status, code: err.code,
++ detail: unreachable ? '' : String(err.detail || err.title || '').slice(0, 300), requestId: err.requestId,
++ });
++ }
++ }
++
++ async function mediaPart(project, env, token) {
++ const base = `/api/v2/${encodeURIComponent(project.id)}`;
++ const ns = await get('media', 'namespaces', env, token, `${base}/namespaces`);
++ const objects = [];
++ let cursor = null;
++ for (;;) {
++ const page = await get('media', 'objects', env, token, `${base}/objects`, { limit: OBJECT_PAGE, include_deleted: 1, ...(cursor ? { cursor } : {}) });
++ objects.push(...(page.objects || []));
++ cursor = page.next_cursor || null;
++ if (!cursor || objects.length >= opts.maxObjects) break;
++ }
++ const complete = !cursor && objects.length <= opts.maxObjects;
++ objects.length = Math.min(objects.length, opts.maxObjects);
++
++ const counts = { public: 0, signed: 0, none: 0 };
++ const toSign = [];
++ for (const o of objects) {
++ if (o.lifecycle_status === 'ready' && o.public_url) { o.download = { url: o.public_url, expires_at: null, public: true }; counts.public++; }
++ else if (o.lifecycle_status === 'ready') toSign.push(o);
++ else { o.download = null; counts.none++; }
++ }
++ await pool(toSign, DOWNLOAD_CONCURRENCY, async (o) => {
++ const d = await get('media', 'download URLs', env, token, `${base}/objects/${encodeURIComponent(o.id)}/download`, { format: 'json', ttl: opts.urlTtlS });
++ o.download = { url: d.url, expires_at: d.expires_at || null, public: Boolean(d.public) };
++ if (d.public) counts.public++; else counts.signed++;
++ });
++ const expiries = objects.map((o) => o.download && o.download.expires_at).filter(Boolean).sort();
++ return {
++ namespaces: ns.namespaces || [],
++ objects,
++ summary: {
++ count: objects.length, complete, limit: opts.maxObjects,
++ ...(complete ? {} : { next_cursor: objects.length ? objects[objects.length - 1].id : null, note: `stopped at ${opts.maxObjects} objects (CODES_EXPORT_MAX_OBJECTS); list the rest from Media with ?cursor=next_cursor` }),
++ bytes: objects.reduce((n, o) => n + (Number(o.size_bytes) || 0), 0),
++ downloads: counts, urls_expire_at: expiries[0] || null,
++ },
++ };
++ }
++
++ async function eventsPart(project, env, token) {
++ const topic = `app.${projectKey(project.id)}.*`;
++ const events = [];
++ let after = 0;
++ let end = null;
++ let gap = null;
++ let pages = 0;
++ let stopped = false;
++ for (;;) {
++ const page = await get('events', 'events', env, token, '/api/v1/events', { topic, after_seq: after, limit: EVENT_PAGE });
++ if (end === null) end = Number(page.latest_seq) || 0;
++ if (page.gap && !gap) gap = page.gap;
++ events.push(...(page.events || []));
++ const next = Number(page.next_after_seq);
++ if (events.length > opts.maxEvents) { stopped = true; break; }
++ if (!(next > after) || next >= end) break;
++ if (++pages >= MAX_EVENT_PAGES) { stopped = true; break; }
++ after = next;
++ }
++ events.length = Math.min(events.length, opts.maxEvents);
++ return {
++ events,
++ summary: {
++ count: events.length, complete: !stopped, limit: opts.maxEvents, topic,
++ first_seq: events.length ? events[0].seq : null, last_seq: events.length ? events[events.length - 1].seq : null,
++ ...(stopped ? { next_after_seq: events.length ? events[events.length - 1].seq : after, note: `stopped at ${opts.maxEvents} events (CODES_EXPORT_MAX_EVENTS); pull the rest from Events with after_seq=next_after_seq` } : {}),
++ retention: `Events keeps app events for a limited time (production 30 days, sandbox 7 by default); older ones are gone${gap ? ` (the oldest kept is seq ${gap.to_seq + 1})` : ''}.`,
++ },
++ };
++ }
++
++ /**
++ * project: Network's project view; metadata: the project.json document (buildExport);
++ * mintToken(audience, env) → { access_token, expires_at } (throws Network's problem as is).
++ * Returns { buffer, filename, summary }.
++ */
++ async function build({ project, metadata, mintToken, meta }) {
++ const media = {};
++ const events = {};
++ for (const env of ENVS) {
++ media[env] = await mediaPart(project, env, tokenSource(mintToken, 'openvibe.media', env));
++ events[env] = await eventsPart(project, env, tokenSource(mintToken, 'openvibe.events', env));
++ }
++
++ const releases = (metadata.codes && metadata.codes.releases) || [];
++ const modules = releases.filter((r) => r.manifest && r.manifest.body).map((r) => ({
++ path: `modules/${r.id}.json`, body: r.manifest.body,
++ entry: { path: `modules/${r.id}.json`, release_id: r.id, app_id: r.app_id, kind: r.kind, name: r.name, version: r.version, status: r.status, environment: r.environment, schema: r.kind === 'mod' ? 'mods.mod-manifest@1' : 'codes.app-manifest@1' },
++ }));
++
++ const files = [
++ { name: 'project.json', data: json(metadata) },
++ ...modules.map((m) => ({ name: m.path, data: json(m.body) })),
++ ];
++ for (const env of ENVS) {
++ files.push({ name: `media/${env}/namespaces.json`, data: json({ project_id: project.id, env, namespaces: media[env].namespaces }) });
++ files.push({ name: `media/${env}/objects.jsonl`, data: jsonl(media[env].objects) });
++ files.push({ name: `events/${env}.jsonl`, data: jsonl(events[env].events) });
++ }
++ for (const f of files) f.data = Buffer.from(f.data, 'utf8');
++
++ const complete = ENVS.every((env) => media[env].summary.complete && events[env].summary.complete) && !(metadata.audit && metadata.audit.complete === false);
++ const manifest = {
++ format: FORMAT, format_version: FORMAT_VERSION,
++ exported_at: meta.now, exported_by: meta.subject,
++ project: { id: project.id, name: project.name, archived_at: project.archived_at || null },
++ complete,
++ parts: {
++ metadata: { path: 'project.json', format: metadata.format, format_version: metadata.format_version, holds: 'project, members, apps, credentials (id and last four only), grants, quotas, audit; releases, trust tiers, playground runs' },
++ modules: { path: 'modules/', count: modules.length, files: modules.map((m) => m.entry) },
++ media: Object.fromEntries(ENVS.map((env) => [env, {
++ namespaces: { path: `media/${env}/namespaces.json`, count: media[env].namespaces.length },
++ objects: { path: `media/${env}/objects.jsonl`, ...media[env].summary },
++ }])),
++ events: Object.fromEntries(ENVS.map((env) => [env, { path: `events/${env}.jsonl`, ...events[env].summary }])),
++ },
++ not_included: NOT_INCLUDED,
++ files: files.map((f) => ({ path: f.name, bytes: f.data.length, sha256: sha256(f.data) })),
++ };
++ const entries = [{ name: 'manifest.json', data: json(manifest) }, { name: 'README.txt', data: readme(manifest) }, ...files];
++ const buffer = zip(entries, { date: new Date(meta.now) });
++ return {
++ buffer,
++ filename: `openvibe-project-${project.id}-${meta.now.slice(0, 10)}.zip`,
++ summary: {
++ complete,
++ objects: ENVS.reduce((n, env) => n + media[env].objects.length, 0),
++ events: ENVS.reduce((n, env) => n + events[env].events.length, 0),
++ modules: modules.length, bytes: buffer.length,
++ },
++ };
++ }
++
++ return { build };
++}
++
++function readme(m) {
++ const env = (e) => {
++ const o = m.parts.media[e].objects;
++ const ev = m.parts.events[e];
++ return ` ${e}:\n`
++ + ` media/${e}/objects.jsonl ${o.count} objects${o.complete ? '' : ` (NOT COMPLETE: ${o.note})`}; ${o.downloads.public} public, ${o.downloads.signed} signed, ${o.downloads.none} without bytes to fetch (not ready or deleted)\n`
++ + ` media/${e}/namespaces.json ${m.parts.media[e].namespaces.count} namespaces (policy, quotas, usage)\n`
++ + ` events/${e}.jsonl ${ev.count} events${ev.complete ? '' : ` (NOT COMPLETE: ${ev.note})`}\n`;
++ };
++ const expiry = ['production', 'sandbox'].map((e) => m.parts.media[e].objects.urls_expire_at).filter(Boolean).sort()[0];
++ return `OpenVibe project archive: ${m.project.name} (${m.project.id})
++Exported ${m.exported_at} by ${m.exported_by}. Format ${m.format} v${m.format_version}.
++${m.complete ? 'Complete: every part below holds everything the services returned.' : 'NOT COMPLETE: a size limit was reached; manifest.json says which part and where it stopped.'}
++
++project.json the project as OpenVibe.Network holds it (members, apps, credentials by id and
++ last four characters, grants, quotas, audit) and OpenVibe.Codes' releases, trust
++ tiers and playground runs. This is the project's configuration.
++modules/ ${m.parts.modules.count} release manifest(s), exactly as validated (app and mod modules).
++${env('production')}${env('sandbox')}
++Objects are listed, not copied: each line of objects.jsonl has "download": a public URL, or a
++signed URL that ${expiry ? `expires at ${expiry}` : 'expires within the hour'}. Fetch them before then, for example:
++
++ node -e "const fs=require('fs'),p=require('path');(async()=>{for(const l of fs.readFileSync(process.argv[1],'utf8').split('\\n').filter(Boolean)){const o=JSON.parse(l);if(!o.download)continue;const r=await fetch(o.download.url);if(!r.ok){console.error(o.id,r.status);continue;}fs.mkdirSync('objects',{recursive:true});fs.writeFileSync(p.join('objects',o.id),Buffer.from(await r.arrayBuffer()));}})()" media/production/objects.jsonl
++
++Download the archive again for fresh URLs. Not included:
++${m.not_included.map((x) => ` - ${x.what}: ${x.why}`).join('\n')}
++
++manifest.json lists every file with its size and SHA-256.
++`;
++}
++
++module.exports = { createArchiver, ArchiveError, FORMAT, FORMAT_VERSION, NOT_INCLUDED, projectKey };
ddiff --git a/server/domain/zip.js b/server/domain/zip.js
nnew file mode 100644
iindex 0000000..96cd8c8
---- /dev/null
++++ b/server/domain/zip.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * A minimal zip writer (PKZIP 2.0: deflate, UTF-8 names, no zip64) for the project archive. Every
++ * archive Codes builds is bounded well below zip's 4 GiB and 65,535-entry limits; past them it
++ * throws rather than write a file other tools would misread.
++ */
++const zlib = require('zlib');
++
++const MAX_BYTES = 0xffffffff;
++const MAX_ENTRIES = 0xffff;
++
++let table = null;
++function crc32(buf) {
++ if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;
++ if (!table) {
++ table = new Uint32Array(256);
++ for (let n = 0; n < 256; n++) {
++ let c = n;
++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
++ table[n] = c >>> 0;
++ }
++ }
++ let c = 0xffffffff;
++ for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
++ return (c ^ 0xffffffff) >>> 0;
++}
++
++/** MS-DOS time and date (UTC; zip has no time zone). */
++function dosTime(d) {
++ return {
++ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2),
++ date: ((Math.max(1980, d.getUTCFullYear()) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),
++ };
++}
++
++/**
++ * entries: [{ name, data: Buffer | string }] in the order they are written. Returns one Buffer.
++ * Small or incompressible entries are stored; the rest are deflated.
++ */
++function zip(entries, { date = new Date() } = {}) {
++ if (entries.length > MAX_ENTRIES) throw new Error(`zip: ${entries.length} entries is past the zip limit`);
++ const { time, date: day } = dosTime(date);
++ const locals = [];
++ const central = [];
++ let offset = 0;
++ for (const e of entries) {
++ const name = Buffer.from(String(e.name), 'utf8');
++ const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');
++ const deflated = data.length > 64 ? zlib.deflateRawSync(data, { level: 6 }) : null;
++ const method = deflated && deflated.length < data.length ? 8 : 0;
++ const body = method === 8 ? deflated : data;
++ const crc = crc32(data);
++ if (data.length > MAX_BYTES || offset + 30 + name.length + body.length > MAX_BYTES) throw new Error('zip: past the 4 GiB limit');
++ const head = Buffer.alloc(30);
++ head.writeUInt32LE(0x04034b50, 0);
++ head.writeUInt16LE(20, 4);
++ head.writeUInt16LE(0x0800, 6); // names are UTF-8
++ head.writeUInt16LE(method, 8);
++ head.writeUInt16LE(time, 10);
++ head.writeUInt16LE(day, 12);
++ head.writeUInt32LE(crc, 14);
++ head.writeUInt32LE(body.length, 18);
++ head.writeUInt32LE(data.length, 22);
++ head.writeUInt16LE(name.length, 26);
++ head.writeUInt16LE(0, 28);
++ locals.push(head, name, body);
++ const dir = Buffer.alloc(46);
++ dir.writeUInt32LE(0x02014b50, 0);
++ dir.writeUInt16LE(20, 4);
++ dir.writeUInt16LE(20, 6);
++ dir.writeUInt16LE(0x0800, 8);
++ dir.writeUInt16LE(method, 10);
++ dir.writeUInt16LE(time, 12);
++ dir.writeUInt16LE(day, 14);
++ dir.writeUInt32LE(crc, 16);
++ dir.writeUInt32LE(body.length, 20);
++ dir.writeUInt32LE(data.length, 24);
++ dir.writeUInt16LE(name.length, 28);
++ dir.writeUInt32LE(offset, 42);
++ central.push(dir, name);
++ offset += head.length + name.length + body.length;
++ }
++ const dirBytes = central.reduce((n, b) => n + b.length, 0);
++ if (offset + dirBytes + 22 > MAX_BYTES) throw new Error('zip: past the 4 GiB limit');
++ const end = Buffer.alloc(22);
++ end.writeUInt32LE(0x06054b50, 0);
++ end.writeUInt16LE(entries.length, 8);
++ end.writeUInt16LE(entries.length, 10);
++ end.writeUInt32LE(dirBytes, 12);
++ end.writeUInt32LE(offset, 16);
++ return Buffer.concat([...locals, ...central, end]);
++}
++
++module.exports = { zip, crc32 };
ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 569b567..c61231b 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -16,6 +16,7 @@
* /docs/services the Network's registry with health as reported (never invented)
* /docs/billing the billing policy as OpenVibe.Billing's /policy.json states it (never restated)
* /docs/limits limits and tiers: each service's /limits.json as it answers (never restated)
++ * /docs/export the project export: the metadata JSON and the full archive's layout
* /docs/sdk[/:module] SDK reference from its .d.ts files
* /docs/adr/:id ADRs as published in openvibe-contracts
*/
@@@ -56,6 +57,7 @@ function createDocsRoutes(ctx) {
<li><a href="/docs/tools"><strong>Tools API</strong></a><span>every OpenVibe tool you can call from code, from the live registry</span></li>
<li><a href="/docs/billing"><strong>Billing policy</strong></a><span>prices, the creator split, fees, holds and cashouts, live from OpenVibe.Billing</span></li>
<li><a href="/docs/limits"><strong>Limits and tiers</strong></a><span>what a project may do in sandbox and production, live from the services that enforce it</span></li>
++<li><a href="/docs/export"><strong>Project export</strong></a><span>take a project with you: its configuration, modules, objects and events as one download</span></li>
<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
</ul>
@@@ -478,6 +480,39 @@ ${table(['Owner', 'Capabilities (quota class)'], owners.map((o) => [code(o), htm
});
});
++ // ── Project export (WS-N task 9; server/domain/project-archive.js) ──
++ r.get('/export', (req, res) => {
++ const x = config.export;
++ page(req, res, {
++ title: 'Project export',
++ description: 'What an OpenVibe project export holds: the metadata JSON and the full archive (configuration, modules, objects, events).',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Project export' }],
++ body: html`<h1>Project export</h1>
++<p>A project's data is yours to take elsewhere. On the project's page in the portal there are two downloads. Neither contains a secret: credentials appear by id and last four characters only, and no token is ever written into either.</p>
++<h2>Metadata (JSON)</h2>
++<p>Any member can download it. It is one JSON document (<code>openvibe.codes.project-export</code> v1) with what OpenVibe.Network holds for the project (the project, members, apps, credentials, grants and quotas, plus the audit log for admins) and what Codes holds (releases with their manifests and logs, trust tiers, playground runs). If Network fails part-way, you get an error, never half a document.</p>
++<h2>Full archive (zip)</h2>
++<p>The owner and admins can download it. Codes asks OpenVibe.Network for a read-only <em>export token</em> for each service and environment. Network checks your role, records the request in the project's audit log, and the token lasts five minutes. Codes then reads the project's data with it. Codes keeps nothing of the archive.</p>
++${table(['File', 'What it holds'], [
++ [code('manifest.json'), 'format (openvibe.codes.project-archive v1), counts per part, whether each part is complete and where it stopped, every file with its size and SHA-256, and what is not included'],
++ [code('README.txt'), 'the same in words, with a one-line script that fetches the objects'],
++ [code('project.json'), 'the metadata document above, with the audit log. This is the project\'s configuration: environments, allowance, apps and redirect URIs, grants, quotas'],
++ [code('modules/<release_id>.json'), 'each release\'s manifest (codes.app-manifest@1 or mods.mod-manifest@1) exactly as validated'],
++ [code('media/<env>/namespaces.json'), 'OpenVibe.Media\'s namespaces for the project: policy, quotas, usage'],
++ [code('media/<env>/objects.jsonl'), html`every object, soft-deleted ones included, one per line with Media's metadata and <code>download</code>: a public URL, or a signed one valid for ${Math.round(x.urlTtlS / 60)} minutes`],
++ [code('events/<env>.jsonl'), html`the project's app events (<code>app.&lt;project_key&gt;.*</code>) that OpenVibe.Events still keeps, one <code>{ seq, event }</code> per line`],
++ ])}
++<p><code>&lt;env&gt;</code> is <code>production</code> and <code>sandbox</code>, both always present (empty when the project has nothing there).</p>
++<ul>
++<li><strong>Objects are listed, not copied.</strong> A project can hold gigabytes; the zip holds each object's URL, and the bytes come straight from Media. Fetch them before the signed URLs expire, or download the archive again for fresh ones.</li>
++<li><strong>Limits.</strong> At most ${x.maxObjects} objects and ${x.maxEvents} events per environment. A part that reaches its limit says <code>complete: false</code> and the cursor to continue from, and so does the archive as a whole.</li>
++<li><strong>All or nothing.</strong> If Network, Media or Events fails or refuses, the page names the service, part and environment, and nothing is downloaded.</li>
++<li><strong>Retention.</strong> Events keeps app events for a limited time (see <a href="/docs/limits#events">Limits</a>); older ones are gone before any export.</li>
++<li><strong>Not included:</strong> Tools job results (readable only by Tools), Events webhook subscriptions (each app's own, with signing secrets), and Network user modules (per person, not per project: each member exports their own from Network).</li>
++</ul>`,
++ });
++ });
++
// ── SDK ─────────────────────────────────────────────────
r.get('/sdk', (req, res) => {
page(req, res, {
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 9c2433d..9da7411 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -25,6 +25,7 @@ const manifestsLib = require('../domain/manifests');
const { RANK, ReleaseError } = require('../domain/releases');
const { validationResult } = require('./tools');
const { buildExport } = require('../domain/project-export');
++const { ArchiveError } = require('../domain/project-archive');
const { statusBadge } = require('./pages');
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -37,7 +38,7 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
-- const { config, docs, network, sso, releases, trust, playground, log } = ctx;
++ const { config, docs, network, sso, releases, trust, playground, archiver, log } = ctx;
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '96kb' });
@@@ -177,6 +178,9 @@ ${!archived && assignable.length ? html`<h3>Add a member</h3>
<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>
++${atLeast(role, 'admin') ? html`<h3>Full archive</h3>
++<p>Everything the project holds, as one zip: the metadata above with the audit log, its release manifests (its app and mod modules), its Media objects and namespaces, and the app events OpenVibe.Events still keeps, in both environments. Objects are listed with their download URLs (signed ones stay valid for ${Math.round(config.export.urlTtlS / 60)} minutes), not copied into the zip. At most ${config.export.maxObjects} objects and ${config.export.maxEvents} events per environment: the archive says when a part stops short, and if a service fails nothing is downloaded. <a href="/docs/export">What is in it</a>.</p>
++<form method="post" action="/projects/${project.id}/export/archive" class="stack">${csrfField(c)}<button type="submit">Download full archive (zip)</button></form>` : html`<p class="muted small">The full archive (objects, events and modules too) is for the project's owner and admins.</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>` : ''}
${role === 'owner' || req.viewer.staff ? html`<h2>Delete</h2>
@@@ -242,6 +246,43 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
res.type('application/json').send(`${JSON.stringify(got.data, null, 2)}\n`);
});
++ // The full archive (owner/admin; Network checks the role again when it mints the export tokens).
++ // One at a time per project: it reads every object and event the project holds.
++ const exporting = new Set();
++ r.post('/:project/export/archive', idParams, form, guard, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ if (!atLeast(project.role, 'admin')) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner or an admin can download the full archive' }, { title: 'Export', back: back(req) });
++ if (exporting.has(project.id)) return problemPage(req, res, { status: 429, code: 'codes.export_running', detail: 'an archive of this project is being built; try again when it is done' }, { title: 'Export', back: back(req) });
++ exporting.add(project.id);
++ try {
++ const meta = { now: new Date().toISOString(), subject: req.viewer.subject, networkUrl: config.networkUrl, baseUrl: config.baseUrl };
++ const metadata = await net(req, res, (t) => buildExport({ network, token: t, project, includeAudit: true, releases, playground, trust, meta }));
++ if (!metadata.ok) return problemPage(req, res, metadata.problem, { title: 'Export', back: back(req) });
++ const mintToken = async (audience, env) => {
++ const got = await net(req, res, (t) => network.projects.exportToken(t, project.id, { audience, env }));
++ if (!got.ok) throw Object.assign(new Error('export token refused'), { networkProblem: got.problem });
++ return got.data;
++ };
++ const out = await archiver.build({ project, metadata: metadata.data, mintToken, meta });
++ log.info(`[Codes] project ${project.id} archive exported by user:${req.viewer.subject}: ${out.summary.objects} objects, ${out.summary.events} events, ${out.summary.modules} modules, ${out.summary.bytes} bytes${out.summary.complete ? '' : ' (a limit was reached)'}`);
++ res.set('Cache-Control', 'no-store');
++ res.set('Content-Disposition', `attachment; filename="${out.filename}"`);
++ res.type('application/zip').send(out.buffer);
++ } catch (err) {
++ if (err.networkProblem) return problemPage(req, res, err.networkProblem, { title: 'Export', back: back(req) });
++ if (!(err instanceof ArchiveError)) throw err;
++ log.warn(`[Codes] project ${project.id} archive failed: ${err.message}`);
++ page(req, res, {
++ title: 'Export failed',
++ body: html`<h1>Export failed</h1>${problemBox(err.problem, { title: 'The archive could not be built' })}
++<p>Nothing was downloaded. Try again in a moment; the metadata export (JSON) does not depend on these services.</p><p><a href="${back(req)}">Back</a></p>`,
++ }, err.problem.status);
++ } finally {
++ exporting.delete(project.id);
++ }
++ });
++
r.post('/:project/delete', idParams, form, guard, async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 589c306..1a14f5c 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -35,6 +35,7 @@ async function boot(opts = {}) {
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
CODES_FORM_SECRET: 'test-form-secret',
CODES_PLAYGROUND_EVENTS_URL: events.url, CODES_PLAYGROUND_MEDIA_URL: media.url,
++ CODES_EXPORT_EVENTS_URL: events.url, CODES_EXPORT_MEDIA_URL: media.url,
CODES_REGISTRY_TTL_MS: '0',
...(opts.relay ? { EVENTS_URL: events.url, EVENTS_RELAY_INTERVAL_MS: '50' } : {}),
...(opts.env || {}),
@@@ -74,8 +75,9 @@ async function boot(opts = {}) {
body = fd;
}
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
-- const text = await res.text();
-- return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
++ const buf = Buffer.from(await res.arrayBuffer());
++ const text = buf.toString('utf8');
++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
}
/** Every value in every table of Codes' database, as one string. */
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 131dcc6..b503fac 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -9,8 +9,15 @@
* with the same shapes, roles and problem codes as OpenVibe.Network's developer API
* (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
* connections-equivalent (503) and drop() closes the socket.
-- * Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability
-- * Media POST /api/v1/:app/files: verifies the token, capability and namespace
++ * POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
++ * sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
++ * Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability;
++ * GET /api/v1/events (pull, events.app.read): the token's project and env only, paged
++ * like Events (next_after_seq, latest_seq); addAppEvent() seeds it
++ * Media POST /api/v1/:app/files: verifies the token, capability and namespace;
++ * GET /api/v2/:project/(objects|objects/:id/download|namespaces) for app-shaped tokens
++ * of that project, the token's env picking the tenant; addObject() seeds it
++ * Both fail(pathPattern, status) answers matching requests with a problem (failure tests)
* Every request is recorded in `.requests` so tests can assert what was (not) called.
*/
const http = require('http');
@@@ -53,7 +60,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const st = {
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), audit: [], codes: new Map(), refresh: new Map(),
-- catalog: null, down: false, requests: [], tokenRequests: [],
++ catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
};
let issuer = null;
let nextUserId = 1;
@@@ -208,6 +215,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }
}
}
++ if (rest[0] === 'export-tokens' && rest.length === 1 && m === 'POST') {
++ if (!role || RANK[role] < RANK.admin) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const cap = { 'openvibe.media': ['media.object.list', 'media.object.read'], 'openvibe.events': ['events.app.read'] }[body.audience];
++ if (!cap) return problem(json, 422, 'export.invalid_audience', 'audience is one of openvibe.media, openvibe.events');
++ if (!['sandbox', 'production'].includes(body.env)) return problem(json, 422, 'export.invalid_env', 'env is one of sandbox, production');
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `app:app_${p.id.replace(/^prj_/, '')}`, actor_type: 'app', aud: [body.audience], cap, ns: [p.id, `app.${p.id}.*`],
++ project_id: p.id, env: body.env, on_behalf_of: u.subject, purpose: 'export', iat: now, exp: now + st.exportTtl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ };
++ const token = serviceAuth.signServiceToken(claims, privatePem);
++ st.exportTokens.push({ project_id: p.id, audience: body.audience, env: body.env, subject: u.subject, jti: claims.jti, token });
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'project.export_token_issued', target: claims.sub, detail: { audience: body.audience, env: body.env, cap, jti: claims.jti } });
++ return json(201, { access_token: token, token_type: 'Bearer', expires_in: st.exportTtl, expires_at: new Date(claims.exp * 1000).toISOString(), scope: cap.join(' '), audience: body.audience, env: body.env, purpose: 'export', subject: claims.sub, project_id: p.id, jti: claims.jti });
++ }
return problem(json, 404, 'route.not_found', 'no such route');
}
const res204 = (json) => json(204, {});
@@@ -296,15 +318,48 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
};
}
--/** Events: POST /api/v1/events with an app or service token carrying events.event.publish. */
++/** Problem answers and injected failures shared by the Events and Media stand-ins. */
++function failures() {
++ const list = [];
++ return {
++ fail(pattern, status = 503, code = 'service.unavailable') { list.push({ re: pattern instanceof RegExp ? pattern : new RegExp(pattern), status, code }); },
++ clear() { list.length = 0; },
++ match: (url) => list.find((f) => f.re.test(url)) || null,
++ };
++}
++const problemJson = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail }, { 'Content-Type': 'application/problem+json' });
++
++/**
++ * Events: POST /api/v1/events with an app or service token carrying events.event.publish; GET
++ * /api/v1/events pulls an app token's own project and environment (events.app.read, topic
++ * app.<project_key>.* only), with Events' paging: next_after_seq moves to latest_seq at the end.
++ */
async function startEvents(network) {
const published = [];
const requests = [];
++ const stored = [];
++ const f = failures();
let seq = 0;
++ const verify = (req) => serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
const srv = await listen((req, raw, json) => {
-- requests.push({ method: req.method, path: req.url });
++ const url = new URL(req.url, 'http://x');
++ requests.push({ method: req.method, path: req.url, auth: String(req.headers.authorization || '') });
++ const failing = f.match(req.url);
++ if (failing) return problemJson(json, failing.status, failing.code, 'injected failure');
++ if (url.pathname === '/api/v1/events' && req.method === 'GET') {
++ const v = verify(req);
++ if (!v.ok) return problemJson(json, 401, v.code, v.reason);
++ if (!String(v.claims.sub).startsWith('app:')) return problemJson(json, 403, 'capability.denied', 'service reads are not modelled here');
++ if (!v.claims.cap.includes('events.app.read')) return problemJson(json, 403, 'capability.denied', 'events.app.read not granted');
++ const key = `p${v.claims.project_id.replace(/^prj_/, '').toLowerCase()}`;
++ if (url.searchParams.get('topic') !== `app.${key}.*`) return problemJson(json, 403, 'events.topic_not_allowed', `app.* patterns must name your project: app.${key}.*`);
++ const after = Number(url.searchParams.get('after_seq') || 0);
++ const limit = Math.min(1000, Number(url.searchParams.get('limit') || 100));
++ const rows = stored.filter((r) => r.seq > after && r.project_id === v.claims.project_id && r.env === v.claims.env).slice(0, limit);
++ return json(200, { events: rows.map((r) => ({ seq: r.seq, event: r.event })), next_after_seq: rows.length === limit ? rows[rows.length - 1].seq : seq, latest_seq: seq });
++ }
if (req.url === '/api/v1/events' && req.method === 'POST') {
-- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
++ const v = verify(req);
if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });
const isApp = String(v.claims.sub).startsWith('app:');
const needed = isApp ? 'events.app.publish' : 'events.event.publish';
@@@ -325,18 +380,72 @@ async function startEvents(network) {
}
return json(404, { error: 'not found' });
});
-- return { url: srv.url, published, requests, close: srv.close };
++ /** An app event of `projectId` in `env`, as an app of that project would have published it. */
++ function addAppEvent(projectId, env, name = 'order.created', payload = {}) {
++ const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
++ const event = { event_id: ids.newId('event'), event_type: `app.${key}.${name}`, version: 1, source: `app-${ids.ulid().toLowerCase()}`, actor: { type: 'app', id: ids.newId('app') }, timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'thing', id: String(seq + 1) }, payload };
++ stored.push({ seq: ++seq, project_id: projectId, env, event });
++ return stored[stored.length - 1];
++ }
++ return { url: srv.url, published, requests, stored, addAppEvent, fail: f.fail, clear: f.clear, close: srv.close };
}
--/** Media: POST /api/v1/:app/files with a token carrying media.object.upload for that namespace. */
++/**
++ * Media: POST /api/v1/:app/files with a token carrying media.object.upload for that namespace; and
++ * for a developer project's app-shaped tokens, GET /api/v2/:project/objects (list verb: .list or
++ * .read), /objects/:id/download?format=json (media.object.read) and /namespaces, the token's env
++ * picking the tenant. Sandbox objects are never public; private ones get signed URLs (ttl ≤ 1 h).
++ */
async function startMedia(network) {
const uploads = [];
const requests = [];
++ const objects = [];
++ const f = failures();
++ const verify = (req) => serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media', acceptSandbox: true });
++ let base = null;
++ function v2(req, url, json) {
++ const m = url.pathname.match(/^\/api\/v2\/(prj_[0-9A-HJKMNP-TV-Z]{26})\/(namespaces|objects)(?:\/(med_[0-9A-HJKMNP-TV-Z]{26})\/download)?$/);
++ if (!m || req.method !== 'GET') return json(404, { error: 'not found' });
++ const [, project, what, id] = m;
++ const v = verify(req);
++ if (!v.ok) return json(401, { error: 'Authentication required' });
++ if (v.claims.actor_type !== 'app') return problemJson(json, 403, 'capability.denied', 'app tokens only here');
++ if (v.claims.project_id !== project) return problemJson(json, 403, 'capability.namespace_denied', `this app token belongs to ${v.claims.project_id}, not ${project}`);
++ const verbOk = id ? v.claims.cap.includes('media.object.read') : v.claims.cap.some((c) => c === 'media.object.list' || c === 'media.object.read');
++ if (!verbOk) return problemJson(json, 403, 'capability.denied', `${id ? 'media.object.read' : 'media.object.list'} not granted`);
++ const env = v.claims.env === 'sandbox' ? 'sandbox' : 'production';
++ const mine = objects.filter((o) => o._project === project && o._env === env);
++ const view = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !k.startsWith('_')));
++ const root = env === 'sandbox' ? `app.${project}.sandbox` : `app.${project}`;
++ if (what === 'namespaces') {
++ return json(200, { namespaces: [{ namespace: root, owner: `project:${project}`, parent: null, policy: {}, quota: { bytes: env === 'sandbox' ? 100 * 1024 * 1024 : 1024 * 1024 * 1024, objects: null }, usage: { bytes: mine.reduce((n, o) => n + o.size_bytes, 0), objects: mine.length } }] });
++ }
++ if (!id) {
++ const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 50), 1), 200);
++ const cursor = url.searchParams.get('cursor');
++ const all = ['1', 'true'].includes(url.searchParams.get('include_deleted') || '');
++ const rows = mine.filter((o) => (all || o.lifecycle_status !== 'deleted') && (!cursor || o.id < cursor)).sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit + 1);
++ const page = rows.slice(0, limit);
++ return json(200, { objects: page.map(view), next_cursor: rows.length > limit ? page[page.length - 1].id : null, limit });
++ }
++ const o = mine.find((x) => x.id === id);
++ if (!o) return problemJson(json, 404, 'media.object.not_found', 'No such object in this namespace');
++ if (o.lifecycle_status === 'deleted') return problemJson(json, 410, 'media.object.deleted', 'Object was deleted');
++ if (o.lifecycle_status !== 'ready') return problemJson(json, 409, 'media.object.not_ready', `Object is ${o.lifecycle_status}`);
++ if (o.public_url) return json(200, { url: o.public_url, expires_at: null, public: true });
++ const ttl = Math.min(3600, Math.max(30, Number(url.searchParams.get('ttl')) || 300));
++ const exp = Math.floor(Date.now() / 1000) + ttl;
++ return json(200, { url: `${base}/o/${o.id}?exp=${exp}&sig=${crypto.createHash('sha256').update(`${o.id}.${exp}`).digest('hex').slice(0, 32)}`, expires_at: new Date(exp * 1000).toISOString(), public: false });
++ }
const srv = await listen((req, raw, json) => new Promise((resolve) => {
-- requests.push({ method: req.method, path: req.url });
++ requests.push({ method: req.method, path: req.url, auth: String(req.headers.authorization || '') });
++ const failing = f.match(req.url);
++ if (failing) { problemJson(json, failing.status, failing.code, 'injected failure'); return resolve(); }
++ const url = new URL(req.url, 'http://x');
++ if (url.pathname.startsWith('/api/v2/')) { v2(req, url, json); return resolve(); }
const m = req.url.match(/^\/api\/v1\/([^/]+)\/files$/);
if (!m || req.method !== 'POST') { json(404, { error: 'not found' }); return resolve(); }
-- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media', acceptSandbox: true });
++ const v = verify(req);
if (!v.ok) { json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason }); return resolve(); }
const c = capabilities.check(v.claims, 'media.object.upload', { namespace: decodeURIComponent(m[1]) });
if (!c.allowed) { json(403, { type: 'x', title: 'Forbidden', status: 403, code: c.code, detail: c.reason }); return resolve(); }
@@@ -351,7 +460,23 @@ async function startMedia(network) {
});
bb.end(raw);
}));
-- return { url: srv.url, uploads, requests, close: srv.close };
++ base = srv.url;
++ /** An object in the project's `env` tenant, shaped as Media's object view (locations left out). */
++ function addObject(projectId, env, { visibility = 'private', status = 'ready', size = 5, metadata = {} } = {}) {
++ const id = `med_${ids.ulid()}`;
++ const tenant = env === 'sandbox' ? `${projectId}-sandbox` : projectId;
++ const o = {
++ id, media_ref: { media_id: id }, legacy_ref: null, app_id: tenant, namespace: env === 'sandbox' ? `app.${projectId}.sandbox` : `app.${projectId}`, kind: 'file',
++ owner: { subject: null, app: null, user_id: null }, visibility, lifecycle_status: status, mime_type: 'text/plain', size_bytes: size,
++ content_hash: crypto.createHash('sha256').update(id).digest('hex'), metadata, held: false, readiness: { metadata: true, bytes_verified: status === 'ready', playable: false },
++ public_url: visibility !== 'private' && status === 'ready' && env === 'production' ? `${srv.url}/o/${id}` : null,
++ created_at: new Date().toISOString(), updated_at: new Date().toISOString(), deleted_at: status === 'deleted' ? new Date().toISOString() : null,
++ ...(env === 'sandbox' ? { sandbox: true } : {}), _project: projectId, _env: env,
++ };
++ objects.push(o);
++ return o;
++ }
++ return { url: srv.url, uploads, requests, objects, addObject, fail: f.fail, clear: f.clear, close: srv.close };
}
module.exports = { startNetwork, startEvents, startMedia, signJwt };
ddiff --git a/test/helpers/unzip.js b/test/helpers/unzip.js
nnew file mode 100644
iindex 0000000..57a1e2d
---- /dev/null
++++ b/test/helpers/unzip.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Reads a zip the way a general-purpose tool does: from the end-of-central-directory record, through
++ * the central directory, to each local header; inflates and checks every entry's CRC-32 and sizes.
++ * Returns a Map of name → Buffer, in central-directory order. Throws on anything malformed.
++ */
++const zlib = require('zlib');
++const { crc32 } = require('../../server/domain/zip');
++
++function unzip(buf) {
++ let eocd = -1;
++ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
++ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
++ }
++ if (eocd < 0) throw new Error('unzip: no end-of-central-directory record');
++ const count = buf.readUInt16LE(eocd + 10);
++ const dirSize = buf.readUInt32LE(eocd + 12);
++ let p = buf.readUInt32LE(eocd + 16);
++ if (p + dirSize !== eocd) throw new Error('unzip: central directory does not end at the end record');
++ const out = new Map();
++ for (let n = 0; n < count; n++) {
++ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`unzip: bad central header ${n}`);
++ const flags = buf.readUInt16LE(p + 8);
++ const method = buf.readUInt16LE(p + 10);
++ const crc = buf.readUInt32LE(p + 16);
++ const csize = buf.readUInt32LE(p + 20);
++ const usize = buf.readUInt32LE(p + 24);
++ const nameLen = buf.readUInt16LE(p + 28);
++ const extraLen = buf.readUInt16LE(p + 30);
++ const commentLen = buf.readUInt16LE(p + 32);
++ const local = buf.readUInt32LE(p + 42);
++ const name = buf.slice(p + 46, p + 46 + nameLen).toString(flags & 0x0800 ? 'utf8' : 'latin1');
++ p += 46 + nameLen + extraLen + commentLen;
++ if (buf.readUInt32LE(local) !== 0x04034b50) throw new Error(`unzip: bad local header for ${name}`);
++ if (buf.slice(local + 30, local + 30 + buf.readUInt16LE(local + 26)).toString('utf8') !== name) throw new Error(`unzip: local name differs for ${name}`);
++ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
++ const raw = buf.slice(start, start + csize);
++ const data = method === 8 ? zlib.inflateRawSync(raw) : method === 0 ? raw : null;
++ if (!data) throw new Error(`unzip: method ${method} for ${name}`);
++ if (data.length !== usize) throw new Error(`unzip: size of ${name}`);
++ if (crc32(data) !== crc) throw new Error(`unzip: CRC of ${name}`);
++ if (out.has(name)) throw new Error(`unzip: ${name} twice`);
++ out.set(name, data);
++ }
++ return out;
++}
++
++module.exports = { unzip };
ddiff --git a/test/project-archive.test.js b/test/project-archive.test.js
nnew file mode 100644
iindex 0000000..1c12371
---- /dev/null
++++ b/test/project-archive.test.js
@@@ -0,0 +1,280 @@
++'use strict';
++/**
++ * The full project archive (roadmap WS-N task 9; server/domain/project-archive.js):
++ * - owner and admins only (developers, viewers, non-members, forged forms get nothing, and no
++ * export token is minted for them); Network mints one read-only export token per service and
++ * environment with the person's own token, and Media and Events are read only with those
++ * - one zip: manifest.json, README.txt, project.json (the metadata document with the audit log),
++ * modules/<release>.json, media/<env>/(namespaces.json|objects.jsonl), events/<env>.jsonl, for
++ * both environments; every object (deleted and uploading ones too) with a public or signed URL
++ * or none; every retained app event of the project, paged; nothing of another project
++ * - never a secret or a token in the archive, Codes' database or its logs
++ * - a failing service fails the whole export (never a partial zip); a limit makes the part and
++ * the archive say complete: false and where they stopped
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const manifests = require('../server/domain/manifests');
++const { FORMAT } = require('../server/domain/project-archive');
++const { unzip } = require('./helpers/unzip');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('ona');
++ const admin = t.network.addUser('ada');
++ const dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const staff = t.network.addUser('staffer', { role: 'admin' });
++ const projectId = await t.project(owner, 'Archived things');
++ t.network.setEnvironmentPolicy(projectId, 'sandbox+production');
++ for (const [u, role] of [['ada', 'admin'], ['dev', 'developer'], ['vee', 'viewer']]) {
++ assert.strictEqual((await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: u, role } })).status, 303);
++ }
++ const app = await t.app(owner, projectId, { name: 'Shop' });
++ assert.ok(app.secret);
++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Shop' }), version: '1.2.3' });
++ let r = await t.get(`/projects/${projectId}/apps/${app.id}/releases`, { as: owner, form: { kind: 'app', manifest, intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const releaseId = r.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${releaseId}/publish`, { as: owner, form: {} })).status, 303);
++
++ // What the services hold: this project's objects and events in both environments, and another project's.
++ const otherProject = await t.project(stranger, 'Not mine');
++ const O = {
++ pub: [0, 1, 2].map(() => t.media.addObject(projectId, 'production', { visibility: 'public' })),
++ priv: [0, 1].map(() => t.media.addObject(projectId, 'production', { visibility: 'private', size: 2048 })),
++ uploading: t.media.addObject(projectId, 'production', { status: 'uploading' }),
++ deleted: t.media.addObject(projectId, 'production', { status: 'deleted' }),
++ sandbox: [0, 1, 2].map(() => t.media.addObject(projectId, 'sandbox', { visibility: 'public' })),
++ other: t.media.addObject(otherProject, 'production', { visibility: 'public' }),
++ };
++ const E = { production: [], sandbox: [], other: [] };
++ for (let i = 0; i < 1203; i++) {
++ E.production.push(t.events.addAppEvent(projectId, 'production', 'order.created', { n: i }));
++ if (i % 400 === 0) E.sandbox.push(t.events.addAppEvent(projectId, 'sandbox', 'order.tested', { n: i }));
++ if (i % 300 === 0) E.other.push(t.events.addAppEvent(otherProject, 'production', 'order.created', { n: i }));
++ }
++
++ const archivePath = `/projects/${projectId}/export/archive`;
++ const exportAs = (who, form = {}) => t.get(archivePath, { as: who, form });
++ const minted = () => t.network.state.exportTokens;
++ const lines = (buf) => buf.toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
++ let files = null;
++
++ await check('the owner downloads one zip with every part, for both environments', async () => {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.strictEqual(r.headers.get('content-type'), 'application/zip');
++ assert.match(r.headers.get('content-disposition'), new RegExp(`^attachment; filename="openvibe-project-${projectId}-\\d{4}-\\d{2}-\\d{2}\\.zip"$`));
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ files = unzip(r.buffer);
++ assert.deepStrictEqual([...files.keys()], [
++ 'manifest.json', 'README.txt', 'project.json', `modules/${releaseId}.json`,
++ 'media/production/namespaces.json', 'media/production/objects.jsonl', 'events/production.jsonl',
++ 'media/sandbox/namespaces.json', 'media/sandbox/objects.jsonl', 'events/sandbox.jsonl',
++ ]);
++ const m = JSON.parse(files.get('manifest.json'));
++ assert.strictEqual(m.format, FORMAT);
++ assert.strictEqual(m.format_version, 1);
++ assert.strictEqual(m.exported_by, owner.subject);
++ assert.deepStrictEqual(m.project, { id: projectId, name: 'Archived things', archived_at: null });
++ assert.strictEqual(m.complete, true);
++ assert.deepStrictEqual([m.parts.media.production.objects.count, m.parts.media.sandbox.objects.count, m.parts.events.production.count, m.parts.events.sandbox.count], [7, 3, 1203, E.sandbox.length]);
++ assert.deepStrictEqual(m.parts.media.production.objects.downloads, { public: 3, signed: 2, none: 2 });
++ assert.deepStrictEqual(m.parts.media.sandbox.objects.downloads, { public: 0, signed: 3, none: 0 });
++ assert.deepStrictEqual(m.parts.modules.files.map((x) => [x.release_id, x.kind, x.version, x.status, x.schema]), [[releaseId, 'app', '1.2.3', 'published', 'codes.app-manifest@1']]);
++ assert.ok(m.not_included.some((x) => /Tools job results/.test(x.what)) && m.not_included.some((x) => /user modules/.test(x.what)));
++ for (const f of m.files) {
++ assert.strictEqual(files.get(f.path).length, f.bytes, f.path);
++ assert.strictEqual(crypto.createHash('sha256').update(files.get(f.path)).digest('hex'), f.sha256, f.path);
++ }
++ assert.match(files.get('README.txt').toString(), /Complete: every part/);
++ });
++
++ await check('project.json is the metadata document with the audit log; modules are the manifests as validated', async () => {
++ const x = JSON.parse(files.get('project.json'));
++ assert.strictEqual(x.format, 'openvibe.codes.project-export');
++ assert.strictEqual(x.project.id, projectId);
++ assert.deepStrictEqual(x.members.map((mm) => mm.username).sort(), ['ada', 'dev', 'ona', 'vee']);
++ assert.ok(Array.isArray(x.audit.entries) && x.audit.entries.some((e) => e.action === 'app.created'), 'the audit log (admin+)');
++ assert.deepStrictEqual(x.codes.releases.map((rel) => rel.id), [releaseId]);
++ assert.deepStrictEqual(JSON.parse(files.get(`modules/${releaseId}.json`)), JSON.parse(manifest));
++ });
++
++ await check('objects: all of this project\'s, per environment, each with a public URL, a signed one or none', async () => {
++ const prod = lines(files.get('media/production/objects.jsonl'));
++ const sbx = lines(files.get('media/sandbox/objects.jsonl'));
++ const ids = (list) => list.map((o) => o.id).sort();
++ assert.deepStrictEqual(ids(prod), [...O.pub, ...O.priv, O.uploading, O.deleted].map((o) => o.id).sort(), 'deleted and uploading objects are listed too');
++ assert.deepStrictEqual(ids(sbx), O.sandbox.map((o) => o.id).sort());
++ assert.ok(![...prod, ...sbx].some((o) => o.id === O.other.id), 'nothing of another project');
++ const byId = new Map(prod.map((o) => [o.id, o]));
++ for (const o of O.pub) assert.deepStrictEqual(byId.get(o.id).download, { url: o.public_url, expires_at: null, public: true });
++ for (const o of [...O.priv.map((x) => byId.get(x.id)), ...sbx]) {
++ assert.strictEqual(o.download.public, false);
++ assert.match(o.download.url, new RegExp(`/o/${o.id}\\?exp=\\d+&sig=`));
++ const left = Date.parse(o.download.expires_at) - Date.now();
++ assert.ok(left > 55 * 60 * 1000 && left <= 3600 * 1000, 'signed for an hour');
++ }
++ assert.strictEqual(byId.get(O.uploading.id).download, null);
++ assert.strictEqual(byId.get(O.deleted.id).download, null);
++ assert.strictEqual(byId.get(O.priv[0].id).size_bytes, 2048, 'Media\'s metadata as it answered');
++ const ns = JSON.parse(files.get('media/sandbox/namespaces.json'));
++ assert.deepStrictEqual(ns.namespaces.map((n) => n.namespace), [`app.${projectId}.sandbox`]);
++ });
++
++ await check('events: every retained app event of the project, per environment, paged in order', async () => {
++ const prod = lines(files.get('events/production.jsonl'));
++ const sbx = lines(files.get('events/sandbox.jsonl'));
++ assert.deepStrictEqual(prod.map((e) => e.seq), E.production.map((e) => e.seq), 'all 1,203, across two pages');
++ assert.deepStrictEqual(sbx.map((e) => e.event.event_id), E.sandbox.map((e) => e.event.event_id));
++ assert.deepStrictEqual(prod[5].event, E.production[5].event, 'the envelope as Events keeps it');
++ const pulls = t.events.requests.filter((q) => q.method === 'GET' && q.path.startsWith('/api/v1/events'));
++ assert.ok(pulls.length >= 3, 'production took more than one page');
++ });
++
++ await check('Network minted the tokens for the owner, one per service and environment; Media and Events saw only those', async () => {
++ const mine = minted().filter((x) => x.subject === owner.subject);
++ assert.deepStrictEqual(mine.map((x) => `${x.audience} ${x.env}`).sort(), ['openvibe.events production', 'openvibe.events sandbox', 'openvibe.media production', 'openvibe.media sandbox']);
++ const tokens = new Set(minted().map((x) => `Bearer ${x.token}`));
++ const reads = [...t.media.requests, ...t.events.requests].filter((q) => q.method === 'GET');
++ assert.ok(reads.length > 10);
++ assert.ok(reads.every((q) => tokens.has(q.auth)), 'every read carried an export token, never the person\'s own');
++ const mintCalls = t.network.requests.filter((q) => q.path.endsWith('/export-tokens'));
++ assert.strictEqual(mintCalls.length, 4);
++ const claimsOf = (h) => JSON.parse(Buffer.from(String(h).split('.')[1], 'base64url').toString('utf8'));
++ assert.ok(mintCalls.every((q) => claimsOf(q.headers.authorization).sub === owner.id && !claimsOf(q.headers.authorization).actor_type), 'asked with the person\'s own Network token');
++ });
++
++ await check('no secret and no token in the archive, in Codes\' database or in its logs', async () => {
++ const everything = [...files.values()].map((b) => b.toString('utf8')).join('\n');
++ assert.ok(!everything.includes(app.secret), 'the client secret');
++ assert.ok(!/ovsec_/.test(everything));
++ assert.ok(!everything.includes(t.network.userToken(owner).split('.')[2]), 'the person\'s token');
++ for (const x of minted()) {
++ const sig = x.token.split('.')[2];
++ assert.ok(!everything.includes(sig), 'an export token in the archive');
++ assert.ok(!t.dbDump().includes(sig), 'an export token in Codes\' database');
++ assert.ok(!t.logs().includes(sig), 'an export token in the logs');
++ }
++ assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(everything), 'no JWT at all');
++ assert.match(t.logs(), new RegExp(`project ${projectId} archive exported by user:${owner.subject}: 10 objects, ${1203 + E.sandbox.length} events, 1 modules`));
++ });
++
++ await check('an admin may download it; the project page offers it to owner and admins only', async () => {
++ r = await exportAs(admin);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(minted().some((x) => x.subject === admin.subject));
++ assert.match((await t.get(`/projects/${projectId}`, { as: admin })).text, /Download full archive \(zip\)/);
++ const devPage = (await t.get(`/projects/${projectId}`, { as: dev })).text;
++ assert.ok(!devPage.includes(`action="${archivePath}"`));
++ assert.match(devPage, /for the project's owner and admins/);
++ assert.match(devPage, /Download export \(JSON\)/, 'the metadata export stays for every member');
++ });
++
++ await check('developers, viewers, staff outside the project, strangers, forged forms and anonymous visitors get nothing, and nothing is minted', async () => {
++ const before = minted().length;
++ const readsBefore = t.media.requests.length + t.events.requests.length;
++ for (const who of [dev, viewer, staff]) {
++ r = await exportAs(who);
++ assert.strictEqual(r.status, 403, who.username);
++ assert.ok(!r.headers.get('content-disposition'));
++ }
++ r = await exportAs(stranger);
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ r = await exportAs(owner, { csrf: 'forged' });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual((await t.get(archivePath, { method: 'POST', form: {} })).status, 401);
++ assert.strictEqual((await t.get(archivePath, { as: owner })).status, 404, 'no GET: it is a form');
++ assert.strictEqual(minted().length, before);
++ assert.strictEqual(t.media.requests.length + t.events.requests.length, readsBefore);
++ });
++
++ const failsWhole = async (label, arrange, pattern) => {
++ arrange();
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 502, `${label}: ${r.status} ${r.text.slice(0, 200)}`);
++ assert.ok(!r.headers.get('content-disposition'), `${label}: no download`);
++ assert.match(r.headers.get('content-type'), /text\/html/);
++ assert.match(r.text, pattern, label);
++ assert.match(r.text, /Nothing was exported/);
++ } finally { t.media.clear(); t.events.clear(); }
++ };
++
++ await check('a failing service fails the whole export: nothing is downloaded, the page names the service, part and environment', async () => {
++ await failsWhole('Media listing', () => t.media.fail(/\/objects\?/, 503), /OpenVibe\.Media answered 503[^]*objects \(production\)/);
++ await failsWhole('one signed URL', () => t.media.fail(new RegExp(`/objects/${O.sandbox[1].id}/download`), 500, 'media.internal'), /OpenVibe\.Media answered 500 \(media\.internal\)[^]*download URLs \(sandbox\)/);
++ await failsWhole('Events refusing', () => t.events.fail(/\/api\/v1\/events\?/, 403, 'capability.denied'), /OpenVibe\.Events answered 403 \(capability\.denied\)[^]*events \(production\)/);
++ await failsWhole('Events unreachable', () => { t.ctx.config.export.eventsUrl = 'http://127.0.0.1:9'; }, /OpenVibe\.Events did not answer[^]*events \(production\)/);
++ t.ctx.config.export.eventsUrl = t.events.url;
++ });
++
++ await check('when Network refuses the export token, its answer is shown and no service is called', async () => {
++ const orig = t.ctx.network.projects.exportToken;
++ t.ctx.network.projects.exportToken = async () => { throw Object.assign(new (require('openvibe-sdk/core').OpenVibeError)({ status: 403, code: 'project.forbidden', message: 'requires admin role' })); };
++ const readsBefore = t.media.requests.length;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 403, r.text.slice(0, 300));
++ assert.match(r.text, /project\.forbidden/);
++ assert.ok(!r.headers.get('content-disposition'));
++ assert.strictEqual(t.media.requests.length, readsBefore);
++ } finally { t.ctx.network.projects.exportToken = orig; }
++ });
++
++ await check('limits: a part that reaches one says complete: false and where it stopped; so does the archive', async () => {
++ const x = t.ctx.config.export;
++ const saved = { ...x };
++ x.maxObjects = 4;
++ x.maxEvents = 1000;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const z = unzip(r.buffer);
++ const m = JSON.parse(z.get('manifest.json'));
++ assert.strictEqual(m.complete, false);
++ const o = m.parts.media.production.objects;
++ assert.deepStrictEqual([o.count, o.complete, o.limit], [4, false, 4]);
++ const listed = lines(z.get('media/production/objects.jsonl'));
++ assert.strictEqual(listed.length, 4);
++ assert.strictEqual(o.next_cursor, listed[3].id, 'continue from the last one listed');
++ assert.strictEqual(m.parts.media.sandbox.objects.complete, true, 'the sandbox has 3: under the limit');
++ const ev = m.parts.events.production;
++ assert.deepStrictEqual([ev.count, ev.complete, ev.next_after_seq], [1000, false, E.production[999].seq]);
++ assert.strictEqual(lines(z.get('events/production.jsonl')).length, 1000);
++ assert.strictEqual(m.parts.events.sandbox.complete, true);
++ assert.match(z.get('README.txt').toString(), /NOT COMPLETE/);
++ } finally { Object.assign(x, saved); }
++ });
++
++ await check('a long export asks Network again before a token expires', async () => {
++ t.network.state.exportTtl = 30; // under the one-minute margin: every read gets a fresh token
++ const before = minted().length;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(minted().length - before > 10, `re-minted (${minted().length - before})`);
++ } finally { t.network.state.exportTtl = 300; }
++ });
++
++ await check('an archived project can still be exported by its owner', async () => {
++ assert.strictEqual((await t.get(`/projects/${projectId}/archive`, { as: owner, form: { confirm: '1' } })).status, 303);
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(JSON.parse(unzip(r.buffer).get('manifest.json')).project.archived_at);
++ });
++
++ await check('the docs describe the archive', async () => {
++ r = await t.get('/docs/export');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['manifest.json', 'objects.jsonl', 'events/&lt;env&gt;.jsonl', 'modules/&lt;release_id&gt;.json', 'All or nothing', 'Not included']) assert.ok(r.text.includes(s), s);
++ assert.match((await t.get('/docs')).text, /href="\/docs\/export"/);
++ });
++
++ await t.close();
++ done();
++})();