openvibers/openvibe.media Commits

Service-principal tokens on the tenant API; token-only 'community' tenant; paste export bundle for Community's importer

OP OpenVibers committed 22 Sept 2026 · 2db74cf199 · 8 files, +285 −2

Roadmap Wave 1 (ADR-003) and Wave 5 (Community becomes the paste authority).
- tenantAuth({ capability }): a Network service token (RS256, aud openvibe.media) is accepted only on
  routes that name the capability they perform, and only for the :app namespaces it was granted.
  POST /api/v1/:app/files takes media.object.upload. Every other route refuses service tokens
- 'community' tenant created at boot with no API key (tokens only) and a 10 GB quota, for the
  screenshot bytes Community uploads
- scripts/export-pastes.js: read-only, deterministic bundle of an app's pastes, comments and likes
  with sha256 and counts, screenshot URLs made absolute, no IP addresses, file mode 0600,
  --since-id for the incremental run after cutover
- CI also runs the contract check

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex d640bde..04e7152 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -15,3 +15,5 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm test
++ - name: contract check (openvibe-contracts)
++ run: npx openvibe-contracts-check --service media --src server
ddiff --git a/package-lock.json b/package-lock.json
iindex 4260f1a..21651db 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,6 +16,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared",
"sharp": "^0.33.4"
}
@@@ -866,6 +867,39 @@
"node": ">= 0.6"
}
},
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@@ -1377,6 +1411,28 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@@ -1598,6 +1654,12 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -1891,6 +1953,22 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-contracts": {
++ "version": "0.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
++ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"resolved": "vendor/openvibe-shared",
"link": true
@@@ -2042,6 +2120,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
ddiff --git a/package.json b/package.json
iindex 1fe9734..b644449 100644
---- a/package.json
++++ b/package.json
@@@ -11,6 +11,7 @@
},
"license": "MIT",
"dependencies": {
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
ddiff --git a/scripts/export-pastes.js b/scripts/export-pastes.js
nnew file mode 100755
iindex 0000000..9bae05c
---- /dev/null
++++ b/scripts/export-pastes.js
@@@ -0,0 +1,61 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Export an app's pastes, comments and likes as a canonical bundle for OpenVibe.Community's importer
++ * (roadmap Wave 5: Community becomes the paste authority; Media keeps only the screenshot bytes).
++ *
++ * node scripts/export-pastes.js --app live --out /tmp/pastes.json [--since-id N] [--db ./data/media.db]
++ *
++ * Read-only (opens the database readonly). Deterministic for the same data: rows ordered by id, keys
++ * in a fixed order, sha256 over { pastes, comments, likes }. IP addresses are never exported.
++ * --since-id exports pastes with id > N plus every comment/like on any paste (they are small and the
++ * importer upserts them), so an incremental run after cutover catches late writes.
++ */
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const Database = require('better-sqlite3');
++
++const args = process.argv.slice(2);
++const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
++const app = arg('app', 'live');
++const out = arg('out');
++const sinceId = Number(arg('since-id', 0)) || 0;
++const dbPath = path.resolve(arg('db', process.env.DB_PATH || './data/media.db'));
++const publicBase = String(process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, '');
++if (!out) { console.error('usage: export-pastes.js --app <id> --out <file> [--since-id N] [--db path]'); process.exit(2); }
++
++const db = new Database(dbPath, { readonly: true, fileMustExist: true });
++const json = (s) => { if (s == null || s === '') return null; try { return JSON.parse(s); } catch { return null; } };
++const tags = (s) => { const v = json(s); return Array.isArray(v) ? v : (typeof s === 'string' && s ? s.split(',').map(x => x.trim()).filter(Boolean) : null); };
++
++const pastes = db.prepare(`SELECT * FROM pastes WHERE app_id = ? AND id > ? ORDER BY id`).all(app, sinceId).map(p => ({
++ id: p.id, slug: p.slug, user_id: p.user_id, type: p.type, title: p.title, content: p.content, language: p.language,
++ visibility: p.visibility, stream_id: p.stream_id,
++ screenshot_url: p.screenshot_path ? `${publicBase}/p/${p.slug}/screenshot` : null,
++ metadata: json(p.metadata), burn_after_read: p.burn_after_read, forked_from: p.forked_from, pinned: p.pinned,
++ views: p.views, unique_views: p.unique_views || 0, copies: p.copies, likes: p.likes, is_nsfw: p.is_nsfw,
++ ai_summary: p.ai_summary, ai_tags: tags(p.ai_tags), ai_analyzed_at: p.ai_analyzed_at,
++ created_at: p.created_at, updated_at: p.updated_at,
++}));
++const comments = db.prepare(`SELECT c.* FROM paste_comments c JOIN pastes p ON p.id = c.paste_id WHERE p.app_id = ? ORDER BY c.id`).all(app).map(c => ({
++ id: c.id, paste_id: c.paste_id, user_id: c.user_id, parent_id: c.parent_id, anon_name: c.anon_name,
++ message: c.message, is_deleted: c.is_deleted, created_at: c.created_at, updated_at: c.updated_at,
++}));
++const likes = db.prepare(`SELECT l.* FROM paste_likes l JOIN pastes p ON p.id = l.paste_id WHERE p.app_id = ? ORDER BY l.paste_id, l.user_id`).all(app).map(l => ({
++ paste_id: l.paste_id, user_id: l.user_id, created_at: l.created_at,
++}));
++db.close();
++
++const body = { pastes, comments, likes };
++const bundle = {
++ format: 'openvibe.media.pastes-export', version: 1, app,
++ generated_at: new Date().toISOString(), since_id: sinceId,
++ max_id: pastes.length ? pastes[pastes.length - 1].id : sinceId,
++ sha256: crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'),
++ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },
++ ...body,
++};
++fs.writeFileSync(out, JSON.stringify(bundle));
++fs.chmodSync(out, 0o600); // paste content can be private/unlisted
++console.log(JSON.stringify({ out, app, since_id: sinceId, max_id: bundle.max_id, counts: bundle.counts, sha256: bundle.sha256 }));
ddiff --git a/server/auth.js b/server/auth.js
iindex 9e4d7ef..f1ff0ac 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -16,6 +16,7 @@
'use strict';
const crypto = require('crypto');
++const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const db = require('./db/database');
const config = require('./config');
@@@ -92,6 +93,27 @@ function verifyUserJwt(token) {
}
}
++/** A Network-issued service token for this service (sub svc:/app:/mod:), or null. */
++function verifyServiceToken(token) {
++ if (!_networkPublicKeyPem) return null;
++ const r = serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media' });
++ return r.ok ? r.claims : null;
++}
++
++function problem(res, status, code, detail) {
++ return http.sendProblem(res, status, code, { detail });
++}
++
++/**
++ * Tenants that exist only to be reached with service tokens (no API key is ever issued):
++ * OpenVibe.Community stores screenshot bytes here under its own namespace.
++ */
++function ensureTokenOnlyApps() {
++ for (const [appId, name] of [['community', 'OpenVibe.Community']]) {
++ if (!db.getApp(appId)) db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)", [appId, name, 10 * 1024 ** 3]);
++ }
++}
++
// ── App key check (constant-time) ────────────────────────────
function checkAppKey(app, presentedKey) {
@@@ -138,7 +160,7 @@ function actingUserId(req) {
* @param {boolean} [opts.allowUser=false] also accept a Network user JWT
* (browser endpoints); Origin, when present, must be allow-listed.
*/
--function tenantAuth({ allowUser = false } = {}) {
++function tenantAuth({ allowUser = false, capability = null } = {}) {
return (req, res, next) => {
const appId = String(req.params.app || '').trim();
const app = appId ? db.getApp(appId) : null;
@@@ -166,6 +188,21 @@ function tenantAuth({ allowUser = false } = {}) {
return res.status(403).json({ error: 'API key not valid for this app' });
}
++ // 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on
++ // routes that name the capability they perform, and only for the :app namespaces the token was
++ // granted. It carries the app's authority for that one action — no acting user.
++ if (token && token.split('.').length === 3) {
++ const svc = verifyServiceToken(token);
++ if (svc) {
++ if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
++ const c = capabilities.check(svc, capability, { namespace: appId });
++ if (!c.allowed) return problem(res, 403, c.code, c.reason);
++ req.authType = 'app';
++ req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
++ return next();
++ }
++ }
++
// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's
// users is calling. It names the account by the Network's id; every user_id
// stored here is in the app's own space. They are different numbers over the
@@@ -280,6 +317,9 @@ function seedApps() {
module.exports = {
tenantAuth,
++ ensureTokenOnlyApps,
++ verifyServiceToken,
++ _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
tenantCors,
optionalIdentity,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
ddiff --git a/server/files/routes.js b/server/files/routes.js
iindex 3466af4..0836dea 100644
---- a/server/files/routes.js
++++ b/server/files/routes.js
@@@ -72,7 +72,7 @@ function sha256File(filePath) {
}
// ── Upload ───────────────────────────────────────────────────
--router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {
++router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload' }), upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
ddiff --git a/server/index.js b/server/index.js
iindex 2306a72..0190cc2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -22,6 +22,7 @@ for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, co
db.getDb(); // init schema (WAL)
auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
++auth.ensureTokenOnlyApps(); // tenants reached only with Network service tokens (community)
auth.startJwksRefresh();
const app = express();
ddiff --git a/test/service-token.test.js b/test/service-token.test.js
nnew file mode 100644
iindex 0000000..a1b682b
---- /dev/null
++++ b/test/service-token.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++// Service-principal tokens on Media's tenant API (roadmap Wave 1/5): accepted only on routes that name
++// their capability and only for granted :app namespaces; the token-only 'community' tenant; and the
++// paste export bundle Community imports.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const { execFileSync } = require('child_process');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-svc-'));
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.FILES_PATH = path.join(tmp, 'files');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++
++const db = require('../server/db/database');
++const auth = require('../server/auth');
++const { serviceAuth } = require('openvibe-contracts');
++const express = require('express');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++auth._setNetworkPublicKeyForTests(keys.publicKey);
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++auth.ensureTokenOnlyApps();
++auth.ensureTokenOnlyApps(); // idempotent
++const community = db.getApp('community');
++assert.ok(community && community.api_key_hash === '' && community.quota_bytes > 0, 'token-only community tenant with a quota');
++
++const now = Math.floor(Date.now() / 1000);
++const tok = (over = {}) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_test12345', ...over }, keys.privateKey);
++
++const app = express();
++app.use('/api/v1/:app/files', require('../server/files/routes'));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const upload = (appId, bearer) => {
++ const fd = new FormData();
++ fd.append('file', new Blob([Buffer.from('\x89PNG\r\n\x1a\nfake')], { type: 'image/png' }), 'shot.png');
++ return fetch(`${base}/api/v1/${appId}/files`, { method: 'POST', headers: bearer ? { authorization: `Bearer ${bearer}` } : {}, body: fd })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++ };
++
++ let r = await upload('community', tok());
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ assert.ok(r.body.key && r.body.url, 'upload answers key + url');
++ r = await upload('live', tok());
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.namespace_denied', 'a community token cannot write the live namespace');
++ r = await upload('community', tok({ cap: ['media.object.read'] }));
++ assert.strictEqual(r.body.code, 'capability.denied');
++ r = await upload('community', tok({ aud: ['openvibe.network'] }));
++ assert.strictEqual(r.status, 401, 'wrong audience is not a service credential here');
++ r = await upload('community', serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_forged123' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey));
++ assert.strictEqual(r.status, 401, 'forged token refused');
++ r = await upload('community', '');
++ assert.strictEqual(r.status, 401);
++ r = await upload('community', 'live-key');
++ assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");
++ const list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });
++ assert.strictEqual(list.status, 403, 'routes that name no capability refuse service tokens');
++ server.close();
++
++ // ── Paste export bundle ──
++ const d = db.getDb();
++ d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata, ip_address, ai_tags) VALUES (1, 'live', 'a-a-1', 5, 'paste', 'A', 'x', 'public', NULL, '{\"k\":1}', '10.0.0.1', 'one,two'), (2, 'live', 'b-b-2', NULL, 'screenshot', 'B', '', 'unlisted', '/data/s.png', NULL, '10.0.0.2', NULL), (3, 'tools', 'c-c-3', 1, 'paste', 'C', 'y', 'public', NULL, NULL, NULL, NULL)").run();
++ d.prepare("INSERT INTO paste_comments (id, paste_id, user_id, anon_name, message, ip_address) VALUES (1, 1, NULL, 'anon', 'hi', '10.0.0.3')").run();
++ d.prepare('INSERT INTO paste_likes (paste_id, user_id) VALUES (1, 7)').run();
++ const outFile = path.join(tmp, 'bundle.json');
++ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--db', process.env.DB_PATH], { env: { ...process.env } });
++ const b = JSON.parse(fs.readFileSync(outFile, 'utf8'));
++ assert.strictEqual(b.format, 'openvibe.media.pastes-export');
++ assert.deepStrictEqual(b.counts, { pastes: 2, comments: 1, likes: 1 }, 'only the requested app');
++ assert.strictEqual(b.sha256, crypto.createHash('sha256').update(JSON.stringify({ pastes: b.pastes, comments: b.comments, likes: b.likes })).digest('hex'));
++ assert.ok(!JSON.stringify(b).includes('10.0.0.'), 'no IP addresses leave Media');
++ assert.strictEqual(b.pastes[1].screenshot_url, 'https://media.test/p/b-b-2/screenshot');
++ assert.deepStrictEqual(b.pastes[0].metadata, { k: 1 });
++ assert.deepStrictEqual(b.pastes[0].ai_tags, ['one', 'two']);
++ assert.strictEqual((fs.statSync(outFile).mode & 0o777), 0o600, 'bundle is private');
++ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--since-id', '1', '--db', process.env.DB_PATH]);
++ const inc = JSON.parse(fs.readFileSync(outFile, 'utf8'));
++ assert.deepStrictEqual(inc.pastes.map((p) => p.id), [2], 'incremental export');
++ assert.strictEqual(inc.counts.comments, 1, 'comments always included');
++
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('service tokens + paste export: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });