openvibers/openvibe.network Commits
Add Link Send relay and web sender (#686)
Changes
@@@ -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@@@ -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;nnew file mode 100644@@@ -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()++);@@@ -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.@@@ -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}@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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.'); }++ });++}@@@ -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.@@@ -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);nnew file mode 100644@@@ -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; });@@@ -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'],@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -142,6 +142,20 @@ server {
@@ -142,6 +142,20 @@ server {
# Cache-Control: five minutes (+60s stale) by default, a year for a ?v= content-hash URL.
# 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) ───────────────────────────
# ── API (moderate rate limit) ───────────────────────────
location /api/ {
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
# 10 a second per address, with a burst of 120: one account page asks ~20 things at once, every open OpenVibe tab's
@@ -188,7 +188,21 @@ server {
@@ -188,7 +188,21 @@ server {
location ~* ^/(metrics|internal)(/|$) { return 404; }
location ~* ^/(metrics|internal)(/|$) { return 404; }
# Link's page, its installer redirect, its machines and pairing signposts, its Markdown twin and the agent files.
# 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; }
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_http_version 1.1;
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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()
+);
@@ -164,7 +164,11 @@ let eventsConsumer = null;
@@ -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' })));
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.
// Export parts (ADR-033) carry up to 20 MB and are parsed by their own route.
const jsonBody = express.json({ limit: '1mb' });
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 }));
app.use(express.urlencoded({ extended: true }));
// ── CORS ─────────────────────────────────────────────────────
// ── CORS ─────────────────────────────────────────────────────
@@ -631,6 +635,12 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -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));
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.
// 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());
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
// 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.
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
@@ -198,6 +198,16 @@ ${installBlock(release, userAgent)}
@@ -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>
<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>
+<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)}
${releaseBlock(release)}
<section class="np-sec" aria-labelledby="h-never"><h2 id="h-never">What it never does</h2>
<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'
@@ -219,6 +229,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
${require('../boost').SCRIPT}
${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></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>
<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>
</body>
</html>`;
</html>`;
@@ -232,6 +243,11 @@ const CSS = `
@@ -232,6 +243,11 @@ const CSS = `
.np-lede code,.np code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
.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-cta{display:flex;flex-wrap:wrap;gap:10px;margin:0}
.np-sec{margin-top:44px}
.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-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-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}
.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}
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
// The route is mounted before the agent layer, so Link's page is not answered by Network's home renderer.
// 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 { markdownTwin, linkHeaderTags } = require('openvibe-shared/agent-layer');
+const fs = require('node:fs');
+const pathLib = require('node:path');
const page = require('./page');
const page = require('./page');
const release = require('./release');
const release = require('./release');
@@ -108,6 +110,20 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
@@ -108,6 +110,20 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
return res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(xml);
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();
if (path !== '/' && path !== '/index.md' && path !== '/index.html') return next();
const isTwin = path === '/index.md' || url.searchParams.get('format') === 'markdown'
const isTwin = path === '/index.md' || url.searchParams.get('format') === 'markdown'
@@ -131,4 +147,8 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
@@ -131,4 +147,8 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
return router;
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 };
module.exports = { createNodeRoutes, llmsTxt, hostOf, HOST, WWW_HOST, LEGACY_HOST, SITE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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.'); }
+ });
+}
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -61,13 +61,17 @@ const SHAPED = {
@@ -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.
// 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');
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).
// …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;'));
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');
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 ──
// ── The page: OpenVibe Link, and every fact the Node page had ──
const html = page.render({ release: first, releaseId: 'rel_test' });
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' });
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');
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' });
const youngCopy = page.render({ release: { ...first, stale: true, ageMs: release.TTL_MS }, releaseId: 'rel_test' });
@@ -181,6 +185,12 @@ const SHAPED = {
@@ -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('<h1>Your machines, connected</h1>'), 'and Link\u2019s h1');
assert.ok(!link.body.includes('network home'), 'and not Network\u2019s home');
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');
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');
const twin = await get('/index.md', 'openvibe.link');
assert.strictEqual(twin.status, 200);
assert.strictEqual(twin.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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; });
@@ -131,6 +131,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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'],
'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'],
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');