openvibers/openvibe.network Commits

Link Send: resumable relay, delivery state, and same-network handover (#779)

OP OpenVibers committed 3 days ago · 4df36bd172 · 11 files, +579 −69

* Link Send: resumable relay, delivery state, and same-network handover

* Link Send: a lost finish answer reads the relay before sending again

* Link Send: check the browser sender's resumable upload path

* Review the Link Send page's new same-origin calls in the SSRF inventory

Changes

ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex e8b28289..75bd72a3 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -190,9 +190,10 @@ server {
location ~* ^/(metrics|internal)(/|$) { return 404; }
# Link's page, its installer and add-computer redirects, machines and pairing signposts, Markdown twin and agent files.
-- 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; }
++ location ~ ^/api/v1/me/nodes/(send-keys|send)(/.*)?$ {
++ # Link Send from the browser: the key list (GET), the outbox (GET), one encrypted file (POST, up to 20 MB
++ # before base64) and the resumable parts of it (PUT).
++ limit_except GET HEAD POST PUT { deny all; }
client_max_body_size 40m;
proxy_request_buffering off;
proxy_pass http://127.0.0.1:4000;
ddiff --git a/migrations/0068_link_send_resume.sql b/migrations/0068_link_send_resume.sql
nnew file mode 100644
iindex 00000000..9308b0ad
---- /dev/null
++++ b/migrations/0068_link_send_resume.sql
@@@ -0,0 +1,37 @@
++-- phase: expand
++-- Link Send v2: a transfer can be uploaded in parts and resumed after a dropped connection, and the sender can see
++-- whether the receiving machine accepted, declined or saved it. The relay still stores only Locker ciphertext.
++ALTER TABLE link_send_items ADD COLUMN IF NOT EXISTS accepted_at timestamptz;
++ALTER TABLE link_send_items ADD COLUMN IF NOT EXISTS decided_at timestamptz;
++ALTER TABLE link_send_items ADD COLUMN IF NOT EXISTS completed_at timestamptz;
++
++CREATE TABLE IF NOT EXISTS link_send_uploads (
++ 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,
++ bytes bigint NOT NULL CHECK (bytes > 0),
++ received bigint NOT NULL DEFAULT 0 CHECK (received >= 0),
++ item_id text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz NOT NULL
++);
++CREATE INDEX IF NOT EXISTS link_send_uploads_owner ON link_send_uploads (owner_subject, expires_at);
++
++CREATE TABLE IF NOT EXISTS link_send_chunks (
++ upload_id text NOT NULL REFERENCES link_send_uploads(id) ON DELETE CASCADE,
++ seq integer NOT NULL,
++ body text NOT NULL,
++ bytes integer NOT NULL,
++ PRIMARY KEY (upload_id, seq)
++);
++
++-- Direct delivery on the same LAN: each machine advertises its own endpoint, and the owner's machines share a secret
++-- that authenticates a sender without a round trip through OpenVibe. The relay never needs to be in the path.
++ALTER TABLE link_send_keys ADD COLUMN IF NOT EXISTS lan jsonb NOT NULL DEFAULT '[]'::jsonb;
++
++CREATE TABLE IF NOT EXISTS link_send_owner_secrets (
++ owner_subject text PRIMARY KEY REFERENCES users(subject_id) ON DELETE CASCADE,
++ secret text NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/index.js b/server/index.js
iindex dcfb6195..8581bc64 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -644,9 +644,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
const sendRelay = require('./link/send-relay').router({
nodeGuard: require('./identity/principals').guard('network.link.self.manage'), requireAuth, db,
});
-- app.use('/api/v1/link/self/send', rateLimit({ windowMs: 60_000, max: 120 }), sendRelay.self);
++ app.use('/api/v1/link/self/send', rateLimit({ windowMs: 60_000, max: 300 }), 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);
++ app.use('/api/v1/me/nodes/send', rateLimit({ windowMs: 60_000, max: 300 }), sendRelay.web);
}
// The machines hub (plan L34, step M2): a person's machines on openvibe.link/machines. Bot stays the registry
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
ddiff --git a/server/link/page.js b/server/link/page.js
iindex 5560c1ae..689eea01 100644
---- a/server/link/page.js
++++ b/server/link/page.js
@@@ -25,7 +25,7 @@ const DOES = [
const NEVER = [
'It cannot reach your computer without your pairing: nobody drives it, reads it or runs a command on it until you approved the machine.',
'Nothing runs that you did not turn on: no task, no camera, no driver and no upload starts on its own.',
-- 'It opens no port: it connects out, so it works behind any home router.',
++ 'It opens no port to the internet: it connects out, so it works behind any home router. On your own network it takes a transfer from your other machines only after they prove they are yours.',
'It keeps a local kill switch: one command stops everything it drives, and it stays stopped.',
];
@@@ -211,8 +211,13 @@ ${installBlock(release, userAgent)}
<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>
++<button type="submit">Encrypt and send</button>
++<progress max="100" value="0" data-send-progress hidden></progress>
++<p role="status" aria-live="polite" data-send-status></p>
</form>
++<h3>Sent from this page</h3>
++<p class="np-fine">Only what this page sent: whether the machine accepted it, declined it, or saved it. Nothing here can be read by OpenVibe.</p>
++<ul data-send-delivery aria-live="polite"></ul>
</section>
${releaseBlock(release)}
ddiff --git a/server/link/send-relay.js b/server/link/send-relay.js
iindex 8457bea6..cb1ac843 100644
---- a/server/link/send-relay.js
++++ b/server/link/send-relay.js
@@@ -2,6 +2,11 @@
// 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.
++//
++// A large envelope can be uploaded in parts: start an upload, PUT each part in order, then finish. A dropped
++// connection resumes from the last part the relay acknowledged (`GET /upload/:id`), and finishing twice returns the
++// same item rather than making a second one. The sender can read its own outbox to see whether the receiving machine
++// accepted, declined or saved the item.
const crypto = require('crypto');
const express = require('express');
const { http } = require('openvibe-contracts');
@@@ -10,8 +15,16 @@ const { ensureUserSubject } = require('../identity/subjects');
const MAX_ENVELOPE = 32 * 1024 * 1024;
const MAX_OWNER_ITEMS = 100;
const MAX_OWNER_BYTES = 128 * 1024 * 1024;
++const MAX_CHUNK = 2 * 1024 * 1024;
++const MAX_LAN = 4;
const TTL_MS = 24 * 60 * 60 * 1000;
++const UPLOAD_TTL_MS = 60 * 60 * 1000;
++const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
++const MACHINE = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ITEM = /^snd_[a-f0-9]{32}$/;
++const UPLOAD = /^upl_[a-f0-9]{32}$/;
const id = () => `snd_${crypto.randomBytes(16).toString('hex')}`;
++const uploadId = () => `upl_${crypto.randomBytes(16).toString('hex')}`;
const bad = (status, code, message) => Object.assign(new Error(message), { status, code });
async function principal(db, req) {
@@@ -24,6 +37,190 @@ async function principal(db, req) {
return row;
}
++/** Who is acting: a paired machine (sender_node set) or the signed-in person on openvibe.link (sender_node null). */
++async function selfActor(db, req) {
++ const machine = await principal(db, req);
++ return { owner: machine.owner_subject, sender: machine.id };
++}
++
++async function webActor(db, req) {
++ if (!req.user) throw bad(401, 'send.sign_in', 'sign in on openvibe.link to send to a machine');
++ const owner = await ensureUserSubject(db, req.user);
++ return { owner, sender: null };
++}
++
++/** One of the actor's own active machines, never itself. */
++async function targetFor(db, owner, targetId, sender) {
++ if (!MACHINE.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ const target = await db.prepare(`SELECT id, name FROM platform_node_principals
++ WHERE id = ? AND status = 'active' AND owner_kind = 'user' AND owner_subject = ?`).get(targetId, owner);
++ if (!target || target.id === sender) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
++ return target;
++}
++
++function lanList(value) {
++ if (value === undefined) return [];
++ if (!Array.isArray(value) || value.length > MAX_LAN) throw bad(400, 'send.bad_lan', 'Link sent too many local addresses');
++ return value.map((raw) => {
++ let url;
++ try { url = new URL(String(raw)); } catch { throw bad(400, 'send.bad_lan', 'Link sent an address that is not a URL'); }
++ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password) {
++ throw bad(400, 'send.bad_lan', 'Link sent an address that is not a local HTTP endpoint');
++ }
++ if (url.pathname !== '/' && url.pathname !== '') throw bad(400, 'send.bad_lan', 'Link sent an address with a path');
++ if (String(raw).length > 200) throw bad(400, 'send.bad_lan', 'Link sent an address that is too long');
++ return `${url.protocol}//${url.host}`;
++ });
++}
++
++function lanOf(value) {
++ if (Array.isArray(value)) return value;
++ if (typeof value === 'string') { try { return JSON.parse(value); } catch { return []; } }
++ return [];
++}
++
++function validEnvelope(envelope) {
++ return typeof envelope === 'string' && envelope.length > 0
++ && Buffer.byteLength(envelope) <= MAX_ENVELOPE && BASE64.test(envelope);
++}
++
++function uploadState(row, seq) {
++ return {
++ id: row.id, bytes: Number(row.bytes), received: Number(row.received), seq,
++ item_id: row.item_id || null, expires_at: row.expires_at,
++ };
++}
++
++/**
++ * The resumable upload routes. `actor` says whether the caller is a machine or the signed-in person; the same
++ * routes then live under /api/v1/link/self/send/upload and /api/v1/me/nodes/send/upload.
++ */
++function uploadRoutes(actor) {
++ const r = express.Router();
++
++ r.post('/', express.json({ limit: '4kb' }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const { owner, sender } = await actor(db, req);
++ const target = await targetFor(db, owner, String(req.body?.target_node || ''), sender);
++ const bytes = Number(req.body?.bytes);
++ if (!Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_ENVELOPE) {
++ throw bad(400, 'send.bad_size', 'the encrypted item is missing or too large');
++ }
++ 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 state <> 'declined' AND completed_at IS NULL AND expires_at > now()`).get(owner);
++ if (Number(quota.items) >= MAX_OWNER_ITEMS || Number(quota.bytes) + bytes > MAX_OWNER_BYTES) {
++ throw bad(429, 'send.queue_full', 'your transfer queue is full; accept or decline a waiting item first');
++ }
++ const row = { id: uploadId(), owner, sender, target: target.id, bytes };
++ await db.prepare(`INSERT INTO link_send_uploads (id, owner_subject, sender_node, target_node, bytes, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?)`).run(row.id, owner, sender, target.id, bytes, new Date(Date.now() + UPLOAD_TTL_MS).toISOString());
++ res.set('Cache-Control', 'no-store').status(201).json({ id: row.id, bytes, received: 0, seq: 0, target_node: target.id });
++ } catch (e) { return sendError(res, e); }
++ });
++
++ r.get('/:id', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const { owner, sender } = await actor(db, req);
++ if (!UPLOAD.test(req.params.id)) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ const row = await db.prepare(`SELECT id, bytes, received, item_id, expires_at FROM link_send_uploads
++ WHERE id = ? AND owner_subject = ? AND sender_node IS NOT DISTINCT FROM ? AND expires_at > now()`)
++ .get(req.params.id, owner, sender);
++ if (!row) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ const count = await db.prepare('SELECT COUNT(*) AS n FROM link_send_chunks WHERE upload_id = ?').get(row.id);
++ res.set('Cache-Control', 'no-store').json(uploadState(row, Number(count.n) || 0));
++ } catch (e) { return sendError(res, e); }
++ });
++
++ r.put('/:id/:seq', express.json({ limit: '4mb' }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const { owner, sender } = await actor(db, req);
++ if (!UPLOAD.test(req.params.id)) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ const seq = Number(req.params.seq);
++ const chunk = req.body?.chunk;
++ if (!Number.isSafeInteger(seq) || seq < 0 || seq > 10000) throw bad(400, 'send.bad_part', 'that part is not one this transfer can take');
++ if (typeof chunk !== 'string' || !chunk || Buffer.byteLength(chunk) > MAX_CHUNK || !BASE64.test(chunk)) {
++ throw bad(400, 'send.bad_part', 'that part of the encrypted item is missing or too large');
++ }
++ const state = await db.tx(async (t) => {
++ const row = await t.prepare(`SELECT id, bytes, received, item_id FROM link_send_uploads
++ WHERE id = ? AND owner_subject = ? AND sender_node IS NOT DISTINCT FROM ? AND expires_at > now() FOR UPDATE`)
++ .get(req.params.id, owner, sender);
++ if (!row) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ if (row.item_id) throw bad(409, 'send.already_finished', 'that item was already sent');
++ const count = Number((await t.prepare('SELECT COUNT(*) AS n FROM link_send_chunks WHERE upload_id = ?').get(row.id)).n) || 0;
++ if (seq < count) {
++ const old = await t.prepare('SELECT body FROM link_send_chunks WHERE upload_id = ? AND seq = ?').get(row.id, seq);
++ if (old && old.body === chunk) return { id: row.id, bytes: Number(row.bytes), received: Number(row.received), seq: count, item_id: null, expires_at: null };
++ throw bad(409, 'send.part_conflict', 'that part was already received with different bytes');
++ }
++ if (seq !== count) throw bad(409, 'send.part_missing', `the relay is waiting for part ${count}`);
++ const next = Number(row.received) + Buffer.byteLength(chunk);
++ if (next > Number(row.bytes)) throw bad(400, 'send.too_much', 'the parts add up to more than the encrypted item');
++ await t.prepare('INSERT INTO link_send_chunks (upload_id, seq, body, bytes) VALUES (?, ?, ?, ?)')
++ .run(row.id, seq, chunk, Buffer.byteLength(chunk));
++ await t.prepare('UPDATE link_send_uploads SET received = ? WHERE id = ?').run(next, row.id);
++ return { id: row.id, bytes: Number(row.bytes), received: next, seq: count + 1, item_id: null, expires_at: null };
++ });
++ res.set('Cache-Control', 'no-store').json({ id: state.id, bytes: state.bytes, received: state.received, seq: state.seq });
++ } catch (e) { return sendError(res, e); }
++ });
++
++ r.post('/:id/finish', express.json({ limit: '4kb' }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const { owner, sender } = await actor(db, req);
++ if (!UPLOAD.test(req.params.id)) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ const done = await db.tx(async (t) => {
++ const row = await t.prepare(`SELECT id, bytes, received, item_id, target_node FROM link_send_uploads
++ WHERE id = ? AND owner_subject = ? AND sender_node IS NOT DISTINCT FROM ? AND expires_at > now() FOR UPDATE`)
++ .get(req.params.id, owner, sender);
++ if (!row) throw bad(404, 'send.upload_unavailable', 'that transfer is no longer available');
++ if (row.item_id) {
++ const item = await t.prepare('SELECT expires_at FROM link_send_items WHERE id = ?').get(row.item_id);
++ return { id: row.item_id, expires_at: item?.expires_at || null };
++ }
++ const parts = await t.prepare('SELECT seq, body, bytes FROM link_send_chunks WHERE upload_id = ? ORDER BY seq').all(row.id);
++ const received = parts.reduce((n, p) => n + Number(p.bytes), 0);
++ if (!parts.length || received !== Number(row.bytes) || received !== Number(row.received)) {
++ throw bad(409, 'send.upload_incomplete', 'the encrypted item did not arrive in full');
++ }
++ if (parts.some((p, i) => Number(p.seq) !== i)) throw bad(409, 'send.upload_incomplete', 'the encrypted item did not arrive in order');
++ const envelope = parts.map((p) => p.body).join('');
++ await assertQuota(t, owner, Buffer.byteLength(envelope));
++ const item = id(), expires = new Date(Date.now() + TTL_MS).toISOString();
++ await t.prepare(`INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?)`).run(item, owner, sender, row.target_node, envelope, expires);
++ await t.prepare('UPDATE link_send_uploads SET item_id = ? WHERE id = ?').run(item, row.id);
++ return { id: item, expires_at: expires };
++ });
++ res.set('Cache-Control', 'no-store').status(201).json(done);
++ } catch (e) { return sendError(res, e); }
++ });
++
++ return r;
++}
++
++/** What the sender's own list calls each row, in plain words. */
++function deliveryState(row) {
++ if (row.completed_at) return 'saved';
++ if (row.state === 'declined') return 'declined';
++ if (row.accepted_at) return 'accepted';
++ if (row.expires_at && Date.parse(row.expires_at) <= Date.now()) return 'expired';
++ return 'pending';
++}
++
++function outboxRow(row) {
++ return {
++ id: row.id, target_node: row.target_node, target_name: row.target_name || 'Link machine',
++ bytes: Number(row.bytes) || 0, state: deliveryState(row),
++ created_at: row.created_at, accepted_at: row.accepted_at || null,
++ decided_at: row.decided_at || null, completed_at: row.completed_at || null, expires_at: row.expires_at,
++ };
++}
++
function router({ nodeGuard, requireAuth, db }) {
const self = express.Router();
self.use(http.middleware(), nodeGuard);
@@@ -36,15 +233,17 @@ function router({ nodeGuard, requireAuth, db }) {
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 };
++ const lan = lanList(req.body?.lan);
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 });
++ await db.prepare(`INSERT INTO link_send_keys (node_id, public_key, key_thumbprint, lan, updated_at) VALUES (?, ?::jsonb, ?, ?::jsonb, now())
++ ON CONFLICT (node_id) DO UPDATE SET public_key = EXCLUDED.public_key, key_thumbprint = EXCLUDED.key_thumbprint,
++ lan = EXCLUDED.lan, updated_at = now()`)
++ .run(machine.id, JSON.stringify(jwk), thumbprint, JSON.stringify(lan));
++ res.set('Cache-Control', 'no-store').json({ node_id: machine.id, key_thumbprint: thumbprint, lan });
} catch (e) { return sendError(res, e); }
});
self.get('/inbox', async (req, res) => {
@@@ -56,7 +255,7 @@ function router({ nodeGuard, requireAuth, db }) {
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);
++ WHERE i.target_node = ? AND i.state = 'pending' AND i.completed_at IS NULL 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',
@@@ -68,26 +267,47 @@ function router({ nodeGuard, requireAuth, db }) {
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
++ const rows = await db.prepare(`SELECT n.id, n.name, n.last_seen_at, k.public_key, k.lan
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,
++ public_key: r.public_key, lan: lanOf(r.lan),
})) });
} catch (e) { return sendError(res, e); }
});
++ self.get('/outbox', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const sender = await principal(db, req);
++ const rows = await db.prepare(`SELECT i.id, i.target_node, n.name AS target_name, octet_length(i.envelope) AS bytes,
++ i.state, i.created_at, i.accepted_at, i.decided_at, i.completed_at, i.expires_at
++ FROM link_send_items i LEFT JOIN platform_node_principals n ON n.id = i.target_node
++ WHERE i.owner_subject = ? AND i.sender_node = ? AND i.expires_at > now()
++ ORDER BY i.created_at DESC LIMIT 50`).all(sender.owner_subject, sender.id);
++ res.set('Cache-Control', 'no-store').json({ items: rows.map(outboxRow) });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.get('/lan-key', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const machine = await principal(db, req);
++ const created = crypto.randomBytes(32).toString('base64url');
++ await db.prepare(`INSERT INTO link_send_owner_secrets (owner_subject, secret) VALUES (?, ?)
++ ON CONFLICT (owner_subject) DO NOTHING`).run(machine.owner_subject, created);
++ const row = await db.prepare('SELECT secret FROM link_send_owner_secrets WHERE owner_subject = ?').get(machine.owner_subject);
++ res.set('Cache-Control', 'no-store').json({ key: row.secret });
++ } 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 (!/^lnk_[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');
-- }
++ if (!MACHINE.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (!validEnvelope(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));
@@@ -98,20 +318,38 @@ function router({ nodeGuard, requireAuth, db }) {
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
} catch (e) { return sendError(res, e); }
});
++ self.use('/upload', uploadRoutes(selfActor));
self.post('/:id/:decision', async (req, res) => {
try {
const db = req.app.locals.db;
const target = await principal(db, req);
++ if (!ITEM.test(req.params.id)) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
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);
++ if (decision === 'accept') {
++ // Accept keeps the ciphertext until the receiver says it finished opening it, so an interrupted
++ // download can be retried. A second accept is therefore allowed.
++ const row = await db.prepare(`SELECT id, envelope FROM link_send_items
++ WHERE id = ? AND target_node = ? AND state = 'pending' AND completed_at IS NULL AND expires_at > now()`)
++ .get(req.params.id, target.id);
++ if (!row) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
++ await db.prepare('UPDATE link_send_items SET accepted_at = COALESCE(accepted_at, now()) WHERE id = ? AND target_node = ?')
++ .run(row.id, target.id);
++ return res.set('Cache-Control', 'no-store').json({ id: row.id, envelope: row.envelope });
++ }
++ if (decision === 'decline') {
++ const done = await db.prepare(`UPDATE link_send_items SET state = 'declined', decided_at = now()
++ WHERE id = ? AND target_node = ? AND state = 'pending'`).run(req.params.id, target.id);
++ if (!done.changes) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
return res.status(204).end();
}
-- res.set('Cache-Control', 'no-store').json({ id: row.id, envelope: row.envelope });
++ const done = await db.prepare(`UPDATE link_send_items SET completed_at = now(), decided_at = now()
++ WHERE id = ? AND target_node = ? AND accepted_at IS NOT NULL AND completed_at IS NULL`).run(req.params.id, target.id);
++ if (!done.changes) {
++ const finished = await db.prepare('SELECT completed_at FROM link_send_items WHERE id = ? AND target_node = ?').get(req.params.id, target.id);
++ if (!finished?.completed_at) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
++ }
++ return res.status(204).end();
} catch (e) { return sendError(res, e); }
});
@@@ -121,29 +359,39 @@ function router({ nodeGuard, requireAuth, db }) {
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
++ const rows = await db.prepare(`SELECT n.id, n.name, n.status, n.last_seen_at, k.public_key, k.lan
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,
++ public_key: r.public_key || null, lan: lanOf(r.lan),
})) });
} 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.get('/', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const owner = await ensureUserSubject(db, req.user);
++ const rows = await db.prepare(`SELECT i.id, i.target_node, n.name AS target_name, octet_length(i.envelope) AS bytes,
++ i.state, i.created_at, i.accepted_at, i.decided_at, i.completed_at, i.expires_at
++ FROM link_send_items i LEFT JOIN platform_node_principals n ON n.id = i.target_node
++ WHERE i.owner_subject = ? AND i.sender_node IS NULL AND i.expires_at > now()
++ ORDER BY i.created_at DESC LIMIT 50`).all(owner);
++ res.set('Cache-Control', 'private, no-store').json({ items: rows.map(outboxRow) });
++ } catch (e) { return sendError(res, e); }
++ });
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 (!/^lnk_[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');
-- }
++ if (!MACHINE.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (!validEnvelope(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');
@@@ -154,6 +402,7 @@ function router({ nodeGuard, requireAuth, db }) {
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
} catch (e) { return sendError(res, e); }
});
++ web.use('/upload', uploadRoutes(webActor));
const cleanup = setInterval(() => {
if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
}, 15 * 60_000);
@@@ -163,14 +412,16 @@ function router({ nodeGuard, requireAuth, db }) {
}
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);
++ 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 state <> 'declined' AND completed_at IS NULL 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();
++ await db.prepare('DELETE FROM link_send_uploads WHERE expires_at <= now()').run();
++ await db.prepare('DELETE FROM link_send_items WHERE expires_at <= now()').run();
}
function sendError(res, e) {
@@@ -180,4 +431,4 @@ function sendError(res, e) {
return http.sendProblem(res, status, code, { detail: message });
}
--module.exports = { router, principal, MAX_ENVELOPE, MAX_OWNER_ITEMS, MAX_OWNER_BYTES, TTL_MS, assertQuota, cleanupExpired };
++module.exports = { router, principal, MAX_ENVELOPE, MAX_CHUNK, MAX_OWNER_ITEMS, MAX_OWNER_BYTES, TTL_MS, UPLOAD_TTL_MS, assertQuota, cleanupExpired, deliveryState };
ddiff --git a/server/link/send.js b/server/link/send.js
iindex 59c2a4df..ac9ead13 100644
---- a/server/link/send.js
++++ b/server/link/send.js
@@@ -1,17 +1,32 @@
import { locker } from '/locker.mjs';
++// Link Send from the browser: the page seals the item for one of the person's own machines with Locker, then uploads
++// the ciphertext in parts so a dropped connection resumes instead of starting again. The relay never sees a key. The
++// list below the form reads the page's own outbox, so the person can see whether the machine accepted or saved it.
++const CHUNK = 1024 * 1024;
++
const form = document.querySelector('[data-link-send]');
if (form) {
const select = form.elements.machine;
const status = form.querySelector('[data-send-status]');
++ const progress = form.querySelector('[data-send-progress]');
++ const delivery = document.querySelector('[data-send-delivery]');
const say = (text) => { status.textContent = text; };
-- const machines = async () => {
++ const signedOut = 'Sign in on openvibe.link to choose one of your machines.';
++
++ function bytesToBase64(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 machines() {
// Signed out, there is nothing to list: ask the session first instead of letting the keys read fail (a 401 in
// every signed-out visitor's console, 11-09 sweep).
const me = await fetch('/auth/me', { credentials: 'same-origin', cache: 'no-store' }).then((x) => (x.ok ? x.json() : null)).catch(() => null);
-- if (!me || !me.user) throw new Error('Sign in on openvibe.link to choose one of your machines.');
++ if (!me || !me.user) throw new Error(signedOut);
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.status === 401 || r.status === 403) throw new Error(signedOut);
if (!r.ok) throw new Error('Your machines could not be loaded.');
const body = await r.json();
select.replaceChildren(new Option('Choose a machine', ''));
@@@ -20,50 +35,165 @@ if (form) {
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 name = file ? file.name : 'note.txt';
++ const mime = file ? (file.type || 'application/octet-stream') : 'text/plain;charset=utf-8';
++ const data = file ? new Uint8Array(await file.arrayBuffer()) : 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;
++
++ async function jsonOrProblem(response, missing) {
++ if (response.ok) return response.status === 204 ? null : response.json().catch(() => ({}));
++ const body = await response.json().catch(() => ({}));
++ const error = new Error(body.detail || missing || 'Link could not send this item.');
++ error.status = response.status;
++ throw error;
++ }
++
++ async function startUpload(targetId, bytes) {
++ const r = await fetch('/api/v1/me/nodes/send/upload', {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ target_node: targetId, bytes }),
++ });
++ return jsonOrProblem(r, 'Your machine could not start receiving this item.');
++ }
++
++ async function uploadStatus(id) {
++ const r = await fetch(`/api/v1/me/nodes/send/upload/${encodeURIComponent(id)}`, { credentials: 'same-origin', cache: 'no-store' });
++ return jsonOrProblem(r, 'This transfer is no longer waiting.');
++ }
++
++ async function putPart(id, seq, chunk) {
++ const r = await fetch(`/api/v1/me/nodes/send/upload/${encodeURIComponent(id)}/${seq}`, {
++ method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ chunk }),
++ });
++ return jsonOrProblem(r, 'A part of the encrypted item was refused.');
++ }
++
++ async function finishUpload(id) {
++ const r = await fetch(`/api/v1/me/nodes/send/upload/${encodeURIComponent(id)}/finish`, {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}',
++ });
++ return jsonOrProblem(r, 'Your machine could not confirm the item.');
++ }
++
++ function drawProgress(sent, total) {
++ if (!progress) return;
++ progress.hidden = false;
++ progress.max = 100;
++ progress.value = total ? Math.min(100, Math.round((sent / total) * 100)) : 0;
++ }
++
++ async function sendEncrypted(targetId, envelope) {
++ const bytes = envelope.length;
++ let upload = await startUpload(targetId, bytes);
++ let seq = Number(upload.seq) || 0;
++ let attempts = 0;
++ while (seq * CHUNK < envelope.length) {
++ const chunk = envelope.slice(seq * CHUNK, (seq + 1) * CHUNK);
++ try {
++ const answer = await putPart(upload.id, seq, chunk);
++ seq = Number(answer.seq) || seq + 1;
++ attempts = 0;
++ drawProgress(Math.min(bytes, seq * CHUNK), bytes);
++ } catch (error) {
++ // A dropped connection is not a lost transfer: ask the relay which part it has and continue there.
++ attempts += 1;
++ if (attempts > 5) throw new Error('The connection kept dropping. Your machine did not receive this item; try again.');
++ say('The connection dropped. Continuing where it stopped…');
++ try {
++ const answer = await uploadStatus(upload.id);
++ seq = Number(answer.seq) || seq;
++ } catch { seq = Math.max(0, seq - 1); }
++ }
++ }
++ let done = null;
++ try { done = await finishUpload(upload.id); } catch {
++ // The relay may have made the item and lost the answer. Its status says whether it did.
++ try { const answer = await uploadStatus(upload.id); if (answer.item_id) done = { id: answer.item_id }; } catch { /* retry below */ }
++ }
++ for (let i = 0; i < 3 && !done?.id; i++) done = await finishUpload(upload.id).catch(() => null);
++ if (!done?.id) throw new Error('Your machine could not confirm the item. The relay kept the encrypted parts for an hour.');
++ drawProgress(bytes, bytes);
++ return done;
++ }
++
++ async function sendPayload(file, text) {
++ const machine = select.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.');
++ const button = form.querySelector('button[type="submit"]');
++ if (button) button.disabled = true;
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);
++ const target = (await jsonOrProblem(listed, signedOut)).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: 'link', 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.');
-- }
++ drawProgress(0, 1);
++ await sendEncrypted(target.id, bytesToBase64(sealed.bytes));
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.'); }
++ if (progress) { progress.value = 100; setTimeout(() => { progress.hidden = true; }, 2500); }
++ await loadDelivery();
++ } catch (e) {
++ say(e.message || 'Link could not send this item.');
++ if (progress) progress.hidden = true;
++ } finally {
++ if (button) button.disabled = false;
++ }
++ }
++
++ function deliveryLine(item) {
++ const name = item.target_name || 'your machine';
++ const size = Number(item.bytes) || 0;
++ const words = {
++ pending: `Waiting for ${name} to accept`,
++ accepted: `${name} accepted it; it is saving it now`,
++ saved: `Saved on ${name}`,
++ declined: `${name} declined it`,
++ expired: `Link removed it after 24 hours; ${name} did not accept it`,
++ };
++ return `${words[item.state] || words.pending} · ${size < 1048576 ? Math.max(1, Math.round(size / 1024)) + ' KB' : (size / 1048576).toFixed(1) + ' MB'}`;
++ }
++
++ async function loadDelivery() {
++ if (!delivery) return;
++ try {
++ const r = await fetch('/api/v1/me/nodes/send', { credentials: 'same-origin', cache: 'no-store' });
++ if (r.status === 401 || r.status === 403) return;
++ if (!r.ok) return;
++ const body = await r.json();
++ const items = Array.isArray(body.items) ? body.items : [];
++ delivery.replaceChildren(...items.slice(0, 10).map((item) => {
++ const li = document.createElement('li');
++ const when = item.created_at ? new Date(item.created_at).toLocaleString() : '';
++ li.textContent = `${deliveryLine(item)}${when ? ' · ' + when : ''}`;
++ return li;
++ }));
++ if (!items.length) {
++ const li = document.createElement('li');
++ li.textContent = 'Nothing sent from this page yet.';
++ delivery.replaceChildren(li);
++ }
++ } catch { /* the list is informational; the send itself reported its own failure */ }
++ }
++
++ machines().catch((e) => { select.replaceChildren(new Option('Sign in to load machines', '')); say(e.message); });
++ form.addEventListener('submit', (event) => {
++ event.preventDefault();
++ sendPayload(form.elements.file.files[0], form.elements.text.value);
});
++ loadDelivery();
++ setInterval(loadDelivery, 5000);
}
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex b2ce5ff6..6889b0e0 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', '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'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex 740e8d9c..9fc12493 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -66,7 +66,7 @@ const SHAPED = {
const vhost = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'parked-domains.conf'), 'utf8');
assert.ok(/location ~ \^\/\(\|install\|add-computer\(\/complete\)\?\|machines(?:\(\/\.\*\)\?)?\|pair\|index\\\.md\|send\\\.js\|egress-settings\\\.js\|locker\\\.mjs\|/.test(vhost), 'the vhost passes the add flow, page scripts, machine hub and its old sub-paths, and Locker bundle to Network');
assert.ok(vhost.includes('add-computer(/complete)?'), 'the add-computer return flow reaches Link before the openvibe.network catch-all');
-- const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));
++ 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');
// …the machine hub's own API (the machines list, the owner's controls, the live screen, pools), which the page
// asks for on this host: without it those calls left for openvibe.network's catch-all and the hub showed nothing.
@@@ -81,6 +81,7 @@ const SHAPED = {
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');
++ assert.ok(html.includes('data-send-progress') && html.includes('data-send-delivery'), 'the page shows upload progress and delivery');
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' });
@@@ -197,6 +198,8 @@ const SHAPED = {
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');
++ assert.ok(sendScript.body.includes('/api/v1/me/nodes/send/upload'), 'the page uploads in resumable parts');
++ assert.ok(sendScript.body.includes("fetch('/api/v1/me/nodes/send'"), 'the page reads delivery from its outbox');
// Every script the page names is served on the Link host itself (egress-settings.js 301'd to Network and 404'd).
for (const src of [...html.matchAll(/<script[^>]+src="(\/[^"/][^"]*)"/g)].map((m) => m[1]).filter((s) => !s.startsWith('/shared/'))) {
const script = await get(src, 'openvibe.link');
ddiff --git a/test/link-send-relay.test.js b/test/link-send-relay.test.js
iindex 535de713..1a8e8712 100644
---- a/test/link-send-relay.test.js
++++ b/test/link-send-relay.test.js
@@@ -42,12 +42,23 @@ const locker = require('openvibe-sdk/locker');
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 });
++ let res = await call('/key', 'target', 'PUT', { public_key: publicKey, lan: ['http://192.168.1.20:4123'] });
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');
++ const keyBody = await res.json();
++ assert.equal(keyBody.key_thumbprint, await locker.jwkThumbprint(publicKey), 'Network registers the SDK Locker thumbprint');
++ assert.deepEqual(keyBody.lan, ['http://192.168.1.20:4123'], 'Link publishes its own local endpoint for a same-network transfer');
++ res = await call('/lan-key', 'from');
++ assert.equal(res.status, 200);
++ const ownerLanKey = (await res.json()).key;
++ res = await call('/lan-key', 'target');
++ assert.equal((await res.json()).key, ownerLanKey, 'the owner\'s own machines share the LAN sender secret');
++ res = await call('/lan-key', 'stranger');
++ assert.notEqual((await res.json()).key, ownerLanKey, 'another owner has a different LAN sender secret');
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 listedTarget = (await res.json()).machines[0];
++ assert.deepEqual(listedTarget.public_key, { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y });
++ assert.deepEqual(listedTarget.lan, ['http://192.168.1.20:4123'], 'the target\'s local endpoint is offered to its owner\'s other machine');
const subject = { type: 'link', id: target };
const sealed = await locker.seal(new TextEncoder().encode('Locker round trip'), {
lock: 'people', keys: [{ subject, publicKey }],
@@@ -85,6 +96,51 @@ const locker = require('openvibe-sdk/locker');
assert.equal(res.status, 204);
assert.equal((await (await call('/inbox', 'target')).json()).items.length, 0);
++ // A transfer uploaded in parts resumes after a dropped connection: the relay says which part it has, the
++ // sender continues there, and finishing produces exactly one item.
++ const half = Math.floor(envelope.length / 3);
++ res = await call('/upload', 'stranger', 'POST', { target_node: target, bytes: Buffer.byteLength(envelope) });
++ assert.equal(res.status, 404, 'another owner cannot start an upload to this machine');
++ res = await call('/upload', 'from', 'POST', { target_node: target, bytes: Buffer.byteLength(envelope) });
++ assert.equal(res.status, 201, 'a machine starts a resumable upload');
++ const upload = await res.json();
++ assert.equal(upload.seq, 0);
++ res = await call(`/upload/${upload.id}/0`, 'from', 'PUT', { chunk: envelope.slice(0, half) });
++ assert.equal(res.status, 200);
++ assert.equal((await res.json()).seq, 1);
++ res = await call(`/upload/${upload.id}/0`, 'from', 'PUT', { chunk: envelope.slice(0, half) });
++ assert.equal(res.status, 200, 'a repeated part is acknowledged, not stored twice');
++ // The connection drops here. The sender asks the relay, not its own memory, where to continue.
++ res = await call(`/upload/${upload.id}`, 'from');
++ assert.equal(res.status, 200);
++ const resumed = await res.json();
++ assert.equal(resumed.seq, 1, 'the relay reports the part it already holds');
++ assert.ok(resumed.received > 0);
++ res = await call(`/upload/${upload.id}/1`, 'from', 'PUT', { chunk: envelope.slice(half) });
++ assert.equal(res.status, 200);
++ res = await call(`/upload/${upload.id}/finish`, 'from', 'POST', {});
++ assert.equal(res.status, 201, 'a resumed upload finishes');
++ const resumedItem = await res.json();
++ res = await call(`/upload/${upload.id}/finish`, 'from', 'POST', {});
++ assert.equal(res.status, 201, 'finishing again returns the same item, not a second one');
++ assert.equal((await res.json()).id, resumedItem.id);
++ assert.equal((await (await call(`/upload/${upload.id}`, 'from')).json()).item_id, resumedItem.id);
++ let mine = (await (await call(`/${resumedItem.id}/accept`, 'target', 'POST')).json()).envelope;
++ assert.equal(mine, envelope, 'the parts add up to the exact ciphertext');
++ assert.equal((await call(`/${resumedItem.id}/complete`, 'target', 'POST')).status, 204);
++ res = await call('/outbox', 'from');
++ assert.equal(res.status, 200);
++ const sentByMachine = (await res.json()).items.find((i) => i.id === resumedItem.id);
++ assert.equal(sentByMachine.state, 'saved', 'the sender sees that the receiving machine saved the item');
++ assert.ok(sentByMachine.target_name, 'the outbox names the receiving machine');
++ res = await call('/upload', 'from', 'POST', { target_node: target, bytes: 4 });
++ assert.equal(res.status, 201);
++ const refused = await res.json();
++ res = await call(`/upload/${refused.id}/0`, 'from', 'PUT', { chunk: 'AAAA' });
++ assert.equal(res.status, 200);
++ res = await call(`/upload/${refused.id}/0`, 'from', 'PUT', { chunk: 'BBBB' });
++ assert.equal(res.status, 409, 'a part cannot be replaced with different bytes');
++
// 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' } : {}) },
@@@ -99,8 +155,35 @@ const locker = require('openvibe-sdk/locker');
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 webItem = await res.json();
const fromWeb = (await (await call('/inbox', 'target')).json()).items;
assert.equal(fromWeb.length, 1);
assert.equal(fromWeb[0].sender_name, 'openvibe.link');
++ // The page's delivery list is its own outbox: waiting, then accepted, then saved.
++ res = await web('/me/send');
++ assert.equal(res.status, 200);
++ let pageItem = (await res.json()).items.find((i) => i.id === webItem.id);
++ assert.equal(pageItem.state, 'pending', 'the page sees the item waiting');
++ assert.ok(pageItem.target_name, 'the page names the receiving machine');
++ assert.equal((await call(`/${webItem.id}/accept`, 'target', 'POST')).status, 200);
++ res = await web('/me/send');
++ pageItem = (await res.json()).items.find((i) => i.id === webItem.id);
++ assert.equal(pageItem.state, 'accepted', 'the page sees the machine accepted it');
++ assert.equal((await call(`/${webItem.id}/complete`, 'target', 'POST')).status, 204);
++ res = await web('/me/send');
++ pageItem = (await res.json()).items.find((i) => i.id === webItem.id);
++ assert.equal(pageItem.state, 'saved', 'the page sees it was saved');
++ // The page's own resumable upload: the signed-in person's session, no node token.
++ res = await web('/me/send/upload', 'POST', { target_node: target, bytes: Buffer.byteLength(envelope) });
++ assert.equal(res.status, 201, 'the page can start a resumable upload');
++ const pageUpload = await res.json();
++ res = await web(`/me/send/upload/${pageUpload.id}/0`, 'PUT', { chunk: envelope });
++ assert.equal(res.status, 200);
++ res = await web(`/me/send/upload/${pageUpload.id}/finish`, 'POST', {});
++ assert.equal(res.status, 201, 'the page can finish a resumable upload');
++ res = await call('/inbox', 'target');
++ const pageInbox = (await res.json()).items;
++ assert.equal(pageInbox.length, 1, 'the page\'s upload reached the machine');
++ await call(`/${pageInbox[0].id}/complete`, 'target', 'POST');
} finally { await new Promise((ok) => server.close(ok)); }
})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f0364ffb..7e292fcc 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -135,7 +135,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/link/send.js fetch': [4, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys and transfers); page input supplies file bytes, never a URL or destination host'],
++ 'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); 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': [7, '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, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 91f054a5..d6a54328 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', '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'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.