openvibers/openvibe.network Commits

Add Link Send relay and web sender (#686)

OP OpenVibers committed 5 days ago · fe0824597d · 13 files, +466 −5

* Add same-owner Link Send relay and page

* Verify Link Send retries before completion

* Check machine key Locker fingerprints

* Update Network checks for Link Send

* Link Send: the web sender has its route, the inbox lists no ciphertext, nginx fits a file

- POST /api/v1/me/nodes/send (what the openvibe.link page calls) had no route: the
  person's router was mounted under /send-keys. It is its own router now, signed-in
  only, and the test drives both paths.
- The machine inbox is polled every few seconds while Link's window is open; it now
  lists id, sender, size and times only, and the ciphertext comes once from accept.
  It no longer deletes the whole table's expired rows on every poll (the 15-minute
  sweep does that).
- nginx: the Link Send API gets its own location on openvibe.link with a 40 MB body
  limit (the page location is GET/HEAD again), and openvibe.network's
  /api/v1/node/self/send gets the same limit; both were 1 MB and 5 MB.

Changes

ddiff --git a/deploy/nginx/openvibe.network.conf b/deploy/nginx/openvibe.network.conf
iindex eb3da140..f92e71bc 100644
---- a/deploy/nginx/openvibe.network.conf
++++ b/deploy/nginx/openvibe.network.conf
@@@ -142,6 +142,20 @@ server {
# Cache-Control: five minutes (+60s stale) by default, a year for a ?v= content-hash URL.
}
++ # ── Link Send from a paired machine: one encrypted file per POST (up to 20 MB before base64) ──
++ location = /api/v1/node/self/send {
++ limit_req zone=openvibenetwork_api burst=120 nodelay;
++ client_max_body_size 40m;
++ proxy_request_buffering off;
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
# ── API (moderate rate limit) ───────────────────────────
location /api/ {
# 10 a second per address, with a burst of 120: one account page asks ~20 things at once, every open OpenVibe tab's
ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex ef175ea3..ff8cfc7e 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -188,7 +188,21 @@ server {
location ~* ^/(metrics|internal)(/|$) { return 404; }
# Link's page, its installer redirect, its machines and pairing signposts, its Markdown twin and the agent files.
-- location ~ ^/(|install|machines|pair|index\.md|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {
++ location ~ ^/api/v1/me/nodes/(send-keys|send)$ {
++ # Link Send from the browser: the key list (GET) and one encrypted file (POST, up to 20 MB before base64).
++ limit_except GET HEAD POST { deny all; }
++ client_max_body_size 40m;
++ proxy_request_buffering off;
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ add_header Cache-Control "no-store" always;
++ }
++ location ~ ^/(|install|machines|pair|index\.md|send\.js|locker\.mjs|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
ddiff --git a/migrations/0053_link_send_relay.sql b/migrations/0053_link_send_relay.sql
nnew file mode 100644
iindex 00000000..3bfaf8a4
---- /dev/null
++++ b/migrations/0053_link_send_relay.sql
@@@ -0,0 +1,22 @@
++-- phase: expand
++-- Link Send v1 relays only Locker ciphertext between a person's own paired machines.
++CREATE TABLE IF NOT EXISTS link_send_items (
++ id text PRIMARY KEY,
++ owner_subject text NOT NULL REFERENCES users(subject_id) ON DELETE CASCADE,
++ sender_node text REFERENCES platform_node_principals(id) ON DELETE CASCADE,
++ target_node text NOT NULL REFERENCES platform_node_principals(id) ON DELETE CASCADE,
++ envelope text NOT NULL,
++ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'accepted', 'declined')),
++ created_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz NOT NULL
++);
++CREATE INDEX IF NOT EXISTS link_send_target_pending ON link_send_items (target_node, created_at)
++ WHERE state = 'pending';
++CREATE INDEX IF NOT EXISTS link_send_expiry ON link_send_items (expires_at);
++
++CREATE TABLE IF NOT EXISTS link_send_keys (
++ node_id text PRIMARY KEY REFERENCES platform_node_principals(id) ON DELETE CASCADE,
++ public_key jsonb NOT NULL,
++ key_thumbprint text NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/index.js b/server/index.js
iindex b9afe816..8265c630 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -164,7 +164,11 @@ let eventsConsumer = null;
app.use('/internal/events', (req, res, next) => (eventsConsumer ? eventsConsumer.router(req, res, next) : res.status(503).json({ error: 'starting' })));
// Export parts (ADR-033) carry up to 20 MB and are parsed by their own route.
const jsonBody = express.json({ limit: '1mb' });
--app.use((req, res, next) => (req.method === 'POST' && /^\/internal\/account-exports\/[^/]+\/parts$/.test(req.path) ? next() : jsonBody(req, res, next)));
++const linkSendJson = express.json({ limit: '40mb' });
++app.use((req, res, next) => {
++ if (req.method === 'POST' && /^\/api\/v1\/(?:node\/self\/send|me\/nodes\/send)$/.test(req.path)) return linkSendJson(req, res, next);
++ return req.method === 'POST' && /^\/internal\/account-exports\/[^/]+\/parts$/.test(req.path) ? next() : jsonBody(req, res, next);
++});
app.use(express.urlencoded({ extended: true }));
// ── CORS ─────────────────────────────────────────────────────
@@@ -631,6 +635,12 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));
// The machine itself (slice N4c): its own capabilities and credential, resolved from its node token alone.
app.use('/api/v1/node/self', require('./identity/principals').guard('network.node.self.manage'), require('./registry/node-principals').selfRouter());
++ const sendRelay = require('./node/send-relay').router({
++ nodeGuard: require('./identity/principals').guard('network.node.self.manage'), requireAuth, db,
++ });
++ app.use('/api/v1/node/self/send', rateLimit({ windowMs: 60_000, max: 120 }), sendRelay.self);
++ app.use('/api/v1/me/nodes/send-keys', rateLimit({ windowMs: 60_000, max: 60 }), sendRelay.keys);
++ app.use('/api/v1/me/nodes/send', rateLimit({ windowMs: 60_000, max: 30 }), sendRelay.web);
}
// The machines hub (plan L34, step M2): a person's machines on openvibe.network/machines. Bot stays the registry
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
ddiff --git a/server/node/page.js b/server/node/page.js
iindex 769c72eb..29da4c21 100644
---- a/server/node/page.js
++++ b/server/node/page.js
@@@ -198,6 +198,16 @@ ${installBlock(release, userAgent)}
<p class="np-cta" style="margin-top:12px">${kit.button({ href: 'https://openvibe.my/machines', label: 'Open your machines', kind: 'primary' })} ${kit.button({ href: '/pair', label: 'Pair a machine', kind: 'quiet' })}</p>
</section>
++<section class="np-sec" id="send" aria-labelledby="h-send"><h2 id="h-send">Send to my machine</h2>
++<p class="np-fine">Choose one of your paired machines and send a file or a short text note. Link encrypts it for that machine before it leaves this page; the relay only holds the encrypted envelope for up to 24 hours.</p>
++<form class="np-send" data-link-send>
++<label>Send to <select name="machine" required><option value="">Loading your machines…</option></select></label>
++<label>File <input name="file" type="file"></label>
++<label>Or a text note <textarea name="text" rows="3" maxlength="10000" placeholder="Write a short note"></textarea></label>
++<button type="submit">Encrypt and send</button><p role="status" aria-live="polite" data-send-status></p>
++</form>
++</section>
++
${releaseBlock(release)}
<section class="np-sec" aria-labelledby="h-never"><h2 id="h-never">What it never does</h2>
@@@ -219,6 +229,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></script>
++<script type="module" src="/send.js"></script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
</body>
</html>`;
@@@ -232,6 +243,11 @@ const CSS = `
.np-lede code,.np code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
.np-cta{display:flex;flex-wrap:wrap;gap:10px;margin:0}
.np-sec{margin-top:44px}
++.np-send{display:grid;gap:12px;max-width:560px;padding:18px;border:1px solid var(--border,#1f2d47);border-radius:16px;background:var(--bg-card,#0f1729)}
++.np-send label{display:grid;gap:6px;font-size:.92rem;font-weight:650}
++.np-send input,.np-send select,.np-send textarea{width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border,#1f2d47);border-radius:9px;background:var(--bg-primary,#080d18);color:var(--text-primary,#eef2ff);font:inherit}
++.np-send button{justify-self:start;padding:10px 14px;border:0;border-radius:9px;background:var(--accent,#3b82f6);color:white;font:inherit;font-weight:700;cursor:pointer}
++.np-send [role=status]{margin:0;min-height:1.3em;color:var(--text-secondary,#96a7c2)}
.np-sec h2{margin:0 0 14px;font-size:1.3rem;letter-spacing:-.01em}
.np-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
.np-does{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);display:flex;flex-direction:column;gap:6px}
ddiff --git a/server/node/routes.js b/server/node/routes.js
iindex 3cd3e6a2..8e1c5c54 100644
---- a/server/node/routes.js
++++ b/server/node/routes.js
@@@ -10,6 +10,8 @@
// The route is mounted before the agent layer, so Link's page is not answered by Network's home renderer.
const { markdownTwin, linkHeaderTags } = require('openvibe-shared/agent-layer');
++const fs = require('node:fs');
++const pathLib = require('node:path');
const page = require('./page');
const release = require('./release');
@@@ -108,6 +110,20 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
return res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(xml);
}
++ if (path === '/locker.mjs' && (req.method === 'GET' || req.method === 'HEAD')) {
++ let source;
++ try { source = fs.readFileSync(pathResolveLocker(), 'utf8'); }
++ catch { return res.status(503).type('text/plain').send('Locker is not available right now.'); }
++ res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400',
++ 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff' });
++ return res.end(req.method === 'HEAD' ? undefined : source);
++ }
++ if (path === '/send.js' && (req.method === 'GET' || req.method === 'HEAD')) {
++ const source = fs.readFileSync(pathLib.join(__dirname, 'send.js'), 'utf8');
++ res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=600', 'X-Content-Type-Options': 'nosniff' });
++ return res.end(req.method === 'HEAD' ? undefined : source);
++ }
++
if (path !== '/' && path !== '/index.md' && path !== '/index.html') return next();
const isTwin = path === '/index.md' || url.searchParams.get('format') === 'markdown'
@@@ -131,4 +147,8 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
return router;
}
++function pathResolveLocker() {
++ return pathLib.join(pathLib.dirname(require.resolve('openvibe-sdk')), 'browser', 'openvibe-sdk.mjs');
++}
++
module.exports = { createNodeRoutes, llmsTxt, hostOf, HOST, WWW_HOST, LEGACY_HOST, SITE };
ddiff --git a/server/node/send-relay.js b/server/node/send-relay.js
nnew file mode 100644
iindex 00000000..eea38364
---- /dev/null
++++ b/server/node/send-relay.js
@@@ -0,0 +1,183 @@
++'use strict';
++
++// Link Send's relay stores only Locker envelopes. A sender and recipient are resolved from node tokens, and both
++// principals must still belong to the same user. Nothing in this module opens or inspects an envelope.
++const crypto = require('crypto');
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++
++const MAX_ENVELOPE = 32 * 1024 * 1024;
++const MAX_OWNER_ITEMS = 100;
++const MAX_OWNER_BYTES = 128 * 1024 * 1024;
++const TTL_MS = 24 * 60 * 60 * 1000;
++const id = () => `snd_${crypto.randomBytes(16).toString('hex')}`;
++const bad = (status, code, message) => Object.assign(new Error(message), { status, code });
++
++async function principal(db, req) {
++ const sub = String(req.principal?.sub || '');
++ if (!sub.startsWith('node:')) throw bad(403, 'send.machine_required', 'only a paired machine can use this route');
++ const row = await db.prepare("SELECT id, owner_subject, owner_kind, status FROM platform_node_principals WHERE id = ?").get(sub.slice(5));
++ if (!row || row.status !== 'active' || row.owner_kind !== 'user' || !row.owner_subject) {
++ throw bad(401, 'send.machine_unavailable', 'this machine is no longer paired to an account');
++ }
++ return row;
++}
++
++function router({ nodeGuard, requireAuth, db }) {
++ const self = express.Router();
++ self.use(http.middleware(), nodeGuard);
++ self.put('/key', express.json({ limit: '4kb' }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const machine = await principal(db, req);
++ const key = req.body?.public_key;
++ if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string' || Object.keys(key).some((k) => !['kty', 'crv', 'x', 'y', 'ext', 'key_ops'].includes(k))) {
++ throw bad(400, 'send.bad_key', 'Link needs a public P-256 receive key');
++ }
++ const jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
++ let thumbprint;
++ try {
++ crypto.createPublicKey({ key: jwk, format: 'jwk' });
++ thumbprint = crypto.createHash('sha256').update(JSON.stringify({ crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y })).digest('base64url');
++ } catch { throw bad(400, 'send.bad_key', 'Link needs a valid public P-256 receive key'); }
++ await db.prepare(`INSERT INTO link_send_keys (node_id, public_key, key_thumbprint, updated_at) VALUES (?, ?::jsonb, ?, now())
++ ON CONFLICT (node_id) DO UPDATE SET public_key = EXCLUDED.public_key, key_thumbprint = EXCLUDED.key_thumbprint, updated_at = now()`)
++ .run(machine.id, JSON.stringify(jwk), thumbprint);
++ res.set('Cache-Control', 'no-store').json({ node_id: machine.id, key_thumbprint: thumbprint });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.get('/inbox', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const target = await principal(db, req);
++ // The list is polled every few seconds while Link's window is open: names and sizes only. The ciphertext
++ // is fetched once, by accept, when the person chooses to receive it.
++ const rows = await db.prepare(`SELECT i.id, i.sender_node, s.name AS sender_name, octet_length(i.envelope) AS bytes,
++ i.created_at, i.expires_at
++ FROM link_send_items i LEFT JOIN platform_node_principals s ON s.id = i.sender_node
++ WHERE i.target_node = ? AND i.state = 'pending' AND i.expires_at > now() ORDER BY i.created_at`).all(target.id);
++ res.set('Cache-Control', 'no-store').json({ items: rows.map((r) => ({
++ id: r.id, sender_node: r.sender_node || null,
++ sender_name: r.sender_node ? (r.sender_name || 'Link machine') : 'openvibe.link',
++ bytes: Number(r.bytes) || 0, created_at: r.created_at, expires_at: r.expires_at,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.get('/targets', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const sender = await principal(db, req);
++ const rows = await db.prepare(`SELECT n.id, n.name, n.last_seen_at, k.public_key
++ FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active' AND n.id <> ?
++ ORDER BY n.created_at DESC, n.id`).all(sender.owner_subject, sender.id);
++ res.set('Cache-Control', 'no-store').json({ machines: rows.map((r) => ({
++ id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
++ public_key: r.public_key,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const sender = await principal(db, req);
++ const targetId = String(req.body?.target_node || '');
++ const envelope = req.body?.envelope;
++ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
++ throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
++ }
++ const target = await db.prepare("SELECT id FROM platform_node_principals WHERE id = ? AND status = 'active' AND owner_kind = 'user' AND owner_subject = ?").get(targetId, sender.owner_subject);
++ if (!target || target.id === sender.id) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
++ await assertQuota(db, sender.owner_subject, Buffer.byteLength(envelope));
++ const itemId = id();
++ const expires = new Date(Date.now() + TTL_MS).toISOString();
++ await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
++ .run(itemId, sender.owner_subject, sender.id, target.id, envelope, expires);
++ res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.post('/:id/:decision', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const target = await principal(db, req);
++ const decision = req.params.decision;
++ if (!['accept', 'decline', 'complete'].includes(decision)) throw bad(400, 'send.bad_decision', 'choose accept, decline or complete');
++ const row = await db.prepare("SELECT id, envelope FROM link_send_items WHERE id = ? AND target_node = ? AND state = 'pending' AND expires_at > now()")
++ .get(req.params.id, target.id);
++ if (!row) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
++ if (decision === 'decline' || decision === 'complete') {
++ await db.prepare("DELETE FROM link_send_items WHERE id = ? AND target_node = ?").run(row.id, target.id);
++ return res.status(204).end();
++ }
++ res.set('Cache-Control', 'no-store').json({ id: row.id, envelope: row.envelope });
++ } catch (e) { return sendError(res, e); }
++ });
++
++ const keys = express.Router();
++ keys.use(http.middleware(), requireAuth);
++ keys.get('/', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const me = await ensureUserSubject(db, req.user);
++ const rows = await db.prepare(`SELECT n.id, n.name, n.status, n.last_seen_at, k.public_key
++ FROM platform_node_principals n LEFT JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
++ ORDER BY n.created_at DESC, n.id`).all(me);
++ res.set('Cache-Control', 'private, no-store').json({ machines: rows.map((r) => ({
++ id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
++ public_key: r.public_key || null,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ // The browser sender on openvibe.link: POST /api/v1/me/nodes/send, as the signed-in person.
++ const web = express.Router();
++ web.use(http.middleware(), requireAuth);
++ web.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const owner = await ensureUserSubject(db, req.user);
++ const targetId = String(req.body?.target_node || '');
++ const envelope = req.body?.envelope;
++ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
++ throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
++ }
++ const target = await db.prepare(`SELECT n.id FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.id = ? AND n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'`).get(targetId, owner);
++ if (!target) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
++ await assertQuota(db, owner, Buffer.byteLength(envelope));
++ const itemId = id(), expires = new Date(Date.now() + TTL_MS).toISOString();
++ await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, NULL, ?, ?, ?)")
++ .run(itemId, owner, target.id, envelope, expires);
++ res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
++ } catch (e) { return sendError(res, e); }
++ });
++ const cleanup = setInterval(() => {
++ if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
++ }, 15 * 60_000);
++ cleanup.unref?.();
++ if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
++ return { self, keys, web };
++}
++
++async function assertQuota(db, owner, adding) {
++ const quota = await db.prepare("SELECT COUNT(*) AS items, COALESCE(SUM(octet_length(envelope)), 0) AS bytes FROM link_send_items WHERE owner_subject = ? AND expires_at > now()").get(owner);
++ if (Number(quota.items) >= MAX_OWNER_ITEMS || Number(quota.bytes) + adding > MAX_OWNER_BYTES) {
++ throw bad(429, 'send.queue_full', 'your transfer queue is full; accept or decline a waiting item first');
++ }
++}
++
++async function cleanupExpired(db) {
++ await db.prepare("DELETE FROM link_send_items WHERE expires_at <= now()").run();
++}
++
++function sendError(res, e) {
++ const status = Number(e.status) || 500;
++ const code = e.code || 'send.failed';
++ const message = status < 500 ? e.message : 'Link could not complete that transfer';
++ return http.sendProblem(res, status, code, { detail: message });
++}
++
++module.exports = { router, principal, MAX_ENVELOPE, MAX_OWNER_ITEMS, MAX_OWNER_BYTES, TTL_MS, assertQuota, cleanupExpired };
ddiff --git a/server/node/send.js b/server/node/send.js
nnew file mode 100644
iindex 00000000..b4039e17
---- /dev/null
++++ b/server/node/send.js
@@@ -0,0 +1,65 @@
++import { locker } from '/locker.mjs';
++
++const form = document.querySelector('[data-link-send]');
++if (form) {
++ const select = form.elements.machine;
++ const status = form.querySelector('[data-send-status]');
++ const say = (text) => { status.textContent = text; };
++ const machines = async () => {
++ const r = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
++ if (r.status === 401 || r.status === 403) throw new Error('Sign in on openvibe.link to choose one of your machines.');
++ if (!r.ok) throw new Error('Your machines could not be loaded.');
++ const body = await r.json();
++ select.replaceChildren(new Option('Choose a machine', ''));
++ for (const m of body.machines || []) {
++ if (!m.public_key) continue;
++ select.add(new Option(`${m.name}${m.online ? '' : ' (offline)'}`, m.id));
++ }
++ if (select.options.length === 1) say('Pair a machine and open Link once to turn on receiving.');
++ };
++ const base64 = (bytes) => {
++ let out = '';
++ for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
++ return btoa(out);
++ };
++ async function packageContent(file, text) {
++ let name, mime, data;
++ if (file) { name = file.name; mime = file.type || 'application/octet-stream'; data = new Uint8Array(await file.arrayBuffer()); }
++ else { name = 'note.txt'; mime = 'text/plain;charset=utf-8'; data = new TextEncoder().encode(text); }
++ const meta = new TextEncoder().encode(JSON.stringify({ name, mime }));
++ const out = new Uint8Array(4 + meta.length + data.length);
++ new DataView(out.buffer).setUint32(0, meta.length);
++ out.set(meta, 4); out.set(data, 4 + meta.length);
++ return out;
++ }
++ machines().catch((e) => { select.replaceChildren(new Option('Sign in to load machines', '')); say(e.message); });
++ form.addEventListener('submit', async (event) => {
++ event.preventDefault();
++ const machine = (form.elements.machine.value);
++ const file = form.elements.file.files[0];
++ const text = form.elements.text.value;
++ if (!machine) return say('Choose one of your machines.');
++ if (!file && !text.trim()) return say('Choose a file or write a note.');
++ if (file && file.size > 20 * 1024 * 1024) return say('Choose a file smaller than 20 MB for now.');
++ try {
++ say('Encrypting in this browser…');
++ const listed = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
++ if (!listed.ok) throw new Error('Sign in on openvibe.link, then try again.');
++ const target = (await listed.json()).machines.find((m) => m.id === machine && m.public_key);
++ if (!target) throw new Error('That machine has no receive key yet. Open Link on it and try again.');
++ const payload = await packageContent(file, text);
++ const sealed = await locker.seal(payload, { lock: 'people', keys: [{ subject: { type: 'node', id: target.id }, publicKey: target.public_key }] });
++ say('Sending encrypted data…');
++ const sent = await fetch('/api/v1/me/nodes/send', {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ target_node: target.id, envelope: base64(sealed.bytes) }),
++ });
++ if (!sent.ok) {
++ const detail = await sent.json().catch(() => ({}));
++ throw new Error(detail.detail || 'Link could not send this item.');
++ }
++ say('Sent. The receiving machine will ask before saving it.');
++ form.elements.file.value = ''; form.elements.text.value = '';
++ } catch (e) { say(e.message || 'Link could not send this item.'); }
++ });
++}
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex a13fd191..7c2ace52 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex bf2bdf5c..0e5dd7e3 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -61,13 +61,17 @@ const SHAPED = {
// The vhost must send /machines and /pair to the app (which signposts them), not the catch-all to Network.
const vhost = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'parked-domains.conf'), 'utf8');
-- assert.ok(/location ~ \^\/\(\|install\|machines\|pair\|/.test(vhost), 'the vhost proxies /machines and /pair to the Link app');
++ assert.ok(/location ~ \^\/\(\|install\|machines\|pair\|index\\\.md\|send\\\.js\|locker\\\.mjs\|/.test(vhost), 'the vhost proxies Link Send assets to Network');
++ const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));
++ assert.ok(sendApi.length < vhost.length && /client_max_body_size 40m;/.test(sendApi.slice(0, sendApi.indexOf('\n }'))), 'the Link Send API has its own location with a body limit that fits a file');
// …and /auth/* to Link's own sign-in (Network#664), or openvibe.link never signs anyone in (11-08 audit).
const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));
assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');
// ── The page: OpenVibe Link, and every fact the Node page had ──
const html = page.render({ release: first, releaseId: 'rel_test' });
++ assert.ok(html.includes('id="send"') && html.includes('Send to my machine'), 'the Link page has the send form');
++ assert.ok(html.includes('src="/send.js"'), 'the page loads its Locker sender');
const oldCopy = page.render({ release: { ...first, stale: true, ageMs: 3 * release.DAY_MS }, releaseId: 'rel_test' });
assert.ok(oldCopy.includes('from 3 days ago'), 'a stale copy older than a day says how old it is');
const youngCopy = page.render({ release: { ...first, stale: true, ageMs: release.TTL_MS }, releaseId: 'rel_test' });
@@@ -181,6 +185,12 @@ const SHAPED = {
assert.ok(link.body.includes('<h1>Your machines, connected</h1>'), 'and Link\u2019s h1');
assert.ok(!link.body.includes('network home'), 'and not Network\u2019s home');
assert.ok(String(link.headers.link || '').includes('text/markdown'), 'the page offers its Markdown twin');
++ const sendScript = await get('/send.js', 'openvibe.link');
++ assert.strictEqual(sendScript.status, 200, 'Link Send script is served on the Link host');
++ assert.ok(sendScript.body.includes('locker.seal'), 'the page seals content before upload');
++ const locker = await get('/locker.mjs', 'openvibe.link');
++ assert.strictEqual(locker.status, 200, 'the SDK browser bundle is served for Link');
++ assert.strictEqual(locker.headers['access-control-allow-origin'], '*', 'the public SDK bundle can be imported by Link\'s loopback window');
const twin = await get('/index.md', 'openvibe.link');
assert.strictEqual(twin.status, 200);
ddiff --git a/test/link-send-relay.test.js b/test/link-send-relay.test.js
nnew file mode 100644
iindex 00000000..77d0c8a4
---- /dev/null
++++ b/test/link-send-relay.test.js
@@@ -0,0 +1,106 @@
++'use strict';
++// Link Send relays opaque Locker envelopes only between an owner's own machines.
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const { router } = require('../server/node/send-relay');
++const locker = require('openvibe-sdk/locker');
++
++(async () => {
++ const db = getDb();
++ const alice = `usr_${ids.ulid()}`, bob = `usr_${ids.ulid()}`;
++ await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id) VALUES
++ (9311, 'sendalice', 'Alice', 'x', ?), (9312, 'sendbob', 'Bob', 'x', ?)`).run(alice, bob);
++ const node = async (principal, owner, n) => db.prepare(`INSERT INTO platform_node_principals
++ (id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
++ VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`).run(principal, `send-${n}`, owner, `${n}`.padStart(64, '0'), owner);
++ const [from, target, stranger] = [`nod_${ids.ulid()}`, `nod_${ids.ulid()}`, `nod_${ids.ulid()}`];
++ await node(from, alice, 1); await node(target, alice, 2); await node(stranger, bob, 3);
++ const tokens = new Map([['from', from], ['target', target], ['stranger', stranger]]);
++ const send = router({ nodeGuard: (req, res, next) => {
++ const principal = tokens.get(req.headers.authorization);
++ if (!principal) return res.status(401).end();
++ req.principal = { sub: `node:${principal}` }; next();
++ }, requireAuth: (req, res, next) => {
++ if (req.headers.authorization !== 'alice-session') return res.status(401).end();
++ req.user = { id: 9311, subject_id: alice }; next();
++ } });
++ const app = express(); app.use(express.json({ limit: '40mb' })); Object.assign(app.locals, { db });
++ app.use('/send', send.self);
++ app.use('/me/send-keys', send.keys);
++ app.use('/me/send', send.web);
++ const server = http.createServer(app);
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}/send`;
++ const call = (url, token, method = 'GET', body) => fetch(base + url, {
++ method, headers: { authorization: token, ...(body ? { 'content-type': 'application/json' } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ });
++ try {
++ const recipient = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
++ const publicKey = recipient.publicKey.export({ format: 'jwk' });
++ let res = await call('/key', 'target', 'PUT', { public_key: publicKey });
++ assert.equal(res.status, 200, 'the target machine publishes its own receive key');
++ assert.equal((await res.json()).key_thumbprint, await locker.jwkThumbprint(publicKey), 'Network registers the SDK Locker thumbprint');
++ res = await call('/targets', 'from');
++ assert.equal(res.status, 200);
++ assert.deepEqual((await res.json()).machines[0].public_key, { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y });
++ const subject = { type: 'node', id: target };
++ const sealed = await locker.seal(new TextEncoder().encode('Locker round trip'), {
++ lock: 'people', keys: [{ subject, publicKey }],
++ });
++ const envelope = Buffer.from(sealed.bytes).toString('base64');
++ res = await call('/', 'from', 'POST', { target_node: stranger, envelope });
++ assert.equal(res.status, 404, 'a sender cannot address another owner\'s machine');
++ res = await call('/', 'from', 'POST', { target_node: from, envelope });
++ assert.equal(res.status, 404, 'a machine cannot send to itself');
++ res = await call('/', 'from', 'POST', { target_node: target, envelope });
++ assert.equal(res.status, 201);
++ const created = await res.json();
++ res = await call('/inbox', 'target');
++ assert.equal(res.status, 200);
++ const inbox = await res.json();
++ assert.equal(inbox.items.length, 1);
++ assert.equal(inbox.items[0].envelope, undefined, 'the polled list carries no ciphertext');
++ assert.equal(inbox.items[0].bytes, Buffer.byteLength(envelope));
++ assert.equal(inbox.items[0].sender_node, from);
++ res = await call(`/${created.id}/accept`, 'stranger', 'POST');
++ assert.equal(res.status, 404, 'another machine cannot accept the recipient\'s item');
++ res = await call(`/${created.id}/accept`, 'target', 'POST');
++ assert.equal(res.status, 200);
++ const delivered = await res.json();
++ assert.equal(delivered.envelope, envelope);
++ const opened = await locker.open(Buffer.from(delivered.envelope, 'base64'), {
++ privateKey: recipient.privateKey.export({ format: 'jwk' }), subject,
++ });
++ assert.equal(new TextDecoder().decode(opened), 'Locker round trip', 'only the target private key opens the relay payload');
++ assert.equal((await (await call('/inbox', 'target')).json()).items.length, 1, 'accept keeps the ciphertext until the receiver finishes opening it');
++ res = await call(`/${created.id}/accept`, 'target', 'POST');
++ assert.equal(res.status, 200, 'a receiver can retry an interrupted download before completion');
++ assert.equal((await res.json()).envelope, envelope);
++ res = await call(`/${created.id}/complete`, 'target', 'POST');
++ assert.equal(res.status, 204);
++ assert.equal((await (await call('/inbox', 'target')).json()).items.length, 0);
++
++ // The browser sender: the signed-in person's own keyed machines only, at the paths the page calls.
++ const web = (path, method = 'GET', body, token = 'alice-session') => fetch(base.replace(/\/send$/, '') + path, {
++ method, headers: { authorization: token, ...(body ? { 'content-type': 'application/json' } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ });
++ res = await web('/me/send-keys');
++ assert.equal(res.status, 200);
++ assert.ok((await res.json()).machines.some((m) => m.id === target && m.public_key), 'the keyed machine is listed');
++ res = await web('/me/send', 'POST', { target_node: target, envelope }, 'nobody');
++ assert.equal(res.status, 401, 'the web sender needs a signed-in person');
++ res = await web('/me/send', 'POST', { target_node: stranger, envelope });
++ assert.equal(res.status, 404, 'the web sender cannot address another owner\'s machine');
++ res = await web('/me/send', 'POST', { target_node: target, envelope });
++ assert.equal(res.status, 201, 'the page\'s POST /api/v1/me/nodes/send has a route');
++ const fromWeb = (await (await call('/inbox', 'target')).json()).items;
++ assert.equal(fromWeb.length, 1);
++ assert.equal(fromWeb[0].sender_name, 'openvibe.link');
++ } finally { await new Promise((ok) => server.close(ok)); }
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex bbcd4784..0fdef86a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -131,6 +131,7 @@ const REVIEWED = {
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
++ 'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex a33e9238..09393cc6 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');