openvibers/openvibe.network Commits
Link Send: resumable relay, delivery state, and same-network handover (#779)
Changes
@@@ -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;nnew file mode 100644@@@ -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()++);@@@ -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.@@@ -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)}@@@ -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_atFROM 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.lanFROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.idWHERE 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.lanFROM platform_node_principals n LEFT JOIN link_send_keys k ON k.node_id = n.idWHERE 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.idWHERE 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 };@@@ -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);}@@@ -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.@@@ -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');@@@ -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; });@@@ -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'],@@@ -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.
@@ -190,9 +190,10 @@ server {
@@ -190,9 +190,10 @@ server {
location ~* ^/(metrics|internal)(/|$) { return 404; }
location ~* ^/(metrics|internal)(/|$) { return 404; }
# Link's page, its installer and add-computer redirects, machines and pairing signposts, Markdown twin and agent files.
# 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;
client_max_body_size 40m;
proxy_request_buffering off;
proxy_request_buffering off;
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:4000;
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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()
+);
@@ -644,9 +644,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -644,9 +644,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
const sendRelay = require('./link/send-relay').router({
const sendRelay = require('./link/send-relay').router({
nodeGuard: require('./identity/principals').guard('network.link.self.manage'), requireAuth, db,
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-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
// 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.
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
@@ -25,7 +25,7 @@ const DOES = [
@@ -25,7 +25,7 @@ const DOES = [
const NEVER = [
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.',
'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.',
'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.',
'It keeps a local kill switch: one command stops everything it drives, and it stays stopped.',
];
];
@@ -211,8 +211,13 @@ ${installBlock(release, userAgent)}
@@ -211,8 +211,13 @@ ${installBlock(release, userAgent)}
<label>Send to <select name="machine" required><option value="">Loading your machines…</option></select></label>
<label>Send to <select name="machine" required><option value="">Loading your machines…</option></select></label>
<label>File <input name="file" type="file"></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>
<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>
</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>
</section>
${releaseBlock(release)}
${releaseBlock(release)}
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
// Link Send's relay stores only Locker envelopes. A sender and recipient are resolved from node tokens, and both
// 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.
// 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 crypto = require('crypto');
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
@@ -10,8 +15,16 @@ const { ensureUserSubject } = require('../identity/subjects');
@@ -10,8 +15,16 @@ const { ensureUserSubject } = require('../identity/subjects');
const MAX_ENVELOPE = 32 * 1024 * 1024;
const MAX_ENVELOPE = 32 * 1024 * 1024;
const MAX_OWNER_ITEMS = 100;
const MAX_OWNER_ITEMS = 100;
const MAX_OWNER_BYTES = 128 * 1024 * 1024;
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 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 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 });
const bad = (status, code, message) => Object.assign(new Error(message), { status, code });
async function principal(db, req) {
async function principal(db, req) {
@@ -24,6 +37,190 @@ async function principal(db, req) {
@@ -24,6 +37,190 @@ async function principal(db, req) {
return row;
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 }) {
function router({ nodeGuard, requireAuth, db }) {
const self = express.Router();
const self = express.Router();
self.use(http.middleware(), nodeGuard);
self.use(http.middleware(), nodeGuard);
@@ -36,15 +233,17 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -36,15 +233,17 @@ function router({ nodeGuard, requireAuth, db }) {
throw bad(400, 'send.bad_key', 'Link needs a public P-256 receive key');
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 jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
+ const lan = lanList(req.body?.lan);
let thumbprint;
let thumbprint;
try {
try {
crypto.createPublicKey({ key: jwk, format: 'jwk' });
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');
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'); }
} 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); }
} catch (e) { return sendError(res, e); }
});
});
self.get('/inbox', async (req, res) => {
self.get('/inbox', async (req, res) => {
@@ -56,7 +255,7 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -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,
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
i.created_at, i.expires_at
FROM link_send_items i LEFT JOIN platform_node_principals s ON s.id = i.sender_node
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) => ({
res.set('Cache-Control', 'no-store').json({ items: rows.map((r) => ({
id: r.id, sender_node: r.sender_node || null,
id: r.id, sender_node: r.sender_node || null,
sender_name: r.sender_node ? (r.sender_name || 'Link machine') : 'openvibe.link',
sender_name: r.sender_node ? (r.sender_name || 'Link machine') : 'openvibe.link',
@@ -68,26 +267,47 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -68,26 +267,47 @@ function router({ nodeGuard, requireAuth, db }) {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const sender = await principal(db, req);
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
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 <> ?
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);
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) => ({
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,
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); }
} 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) => {
self.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const sender = await principal(db, req);
const sender = await principal(db, req);
const targetId = String(req.body?.target_node || '');
const targetId = String(req.body?.target_node || '');
const envelope = req.body?.envelope;
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);
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');
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));
await assertQuota(db, sender.owner_subject, Buffer.byteLength(envelope));
@@ -98,20 +318,38 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -98,20 +318,38 @@ function router({ nodeGuard, requireAuth, db }) {
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
} catch (e) { return sendError(res, e); }
} catch (e) { return sendError(res, e); }
});
});
+ self.use('/upload', uploadRoutes(selfActor));
self.post('/:id/:decision', async (req, res) => {
self.post('/:id/:decision', async (req, res) => {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const target = await principal(db, req);
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;
const decision = req.params.decision;
if (!['accept', 'decline', 'complete'].includes(decision)) throw bad(400, 'send.bad_decision', 'choose accept, decline or complete');
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();
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); }
} catch (e) { return sendError(res, e); }
});
});
@@ -121,29 +359,39 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -121,29 +359,39 @@ function router({ nodeGuard, requireAuth, db }) {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const me = await ensureUserSubject(db, req.user);
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
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'
WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
ORDER BY n.created_at DESC, n.id`).all(me);
ORDER BY n.created_at DESC, n.id`).all(me);
res.set('Cache-Control', 'private, no-store').json({ machines: rows.map((r) => ({
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,
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); }
} catch (e) { return sendError(res, e); }
});
});
// The browser sender on openvibe.link: POST /api/v1/me/nodes/send, as the signed-in person.
// The browser sender on openvibe.link: POST /api/v1/me/nodes/send, as the signed-in person.
const web = express.Router();
const web = express.Router();
web.use(http.middleware(), requireAuth);
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) => {
web.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const owner = await ensureUserSubject(db, req.user);
const owner = await ensureUserSubject(db, req.user);
const targetId = String(req.body?.target_node || '');
const targetId = String(req.body?.target_node || '');
const envelope = req.body?.envelope;
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
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);
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');
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 }) {
@@ -154,6 +402,7 @@ function router({ nodeGuard, requireAuth, db }) {
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
} catch (e) { return sendError(res, e); }
} catch (e) { return sendError(res, e); }
});
});
+ web.use('/upload', uploadRoutes(webActor));
const cleanup = setInterval(() => {
const cleanup = setInterval(() => {
if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
}, 15 * 60_000);
}, 15 * 60_000);
@@ -163,14 +412,16 @@ function router({ nodeGuard, requireAuth, db }) {
@@ -163,14 +412,16 @@ function router({ nodeGuard, requireAuth, db }) {
}
}
async function assertQuota(db, owner, adding) {
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) {
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');
throw bad(429, 'send.queue_full', 'your transfer queue is full; accept or decline a waiting item first');
}
}
}
}
async function cleanupExpired(db) {
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) {
function sendError(res, e) {
@@ -180,4 +431,4 @@ function sendError(res, e) {
@@ -180,4 +431,4 @@ function sendError(res, e) {
return http.sendProblem(res, status, code, { detail: message });
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 };
@@ -1,17 +1,32 @@
@@ -1,17 +1,32 @@
import { locker } from '/locker.mjs';
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]');
const form = document.querySelector('[data-link-send]');
if (form) {
if (form) {
const select = form.elements.machine;
const select = form.elements.machine;
const status = form.querySelector('[data-send-status]');
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 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
// 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).
// 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);
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' });
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.');
if (!r.ok) throw new Error('Your machines could not be loaded.');
const body = await r.json();
const body = await r.json();
select.replaceChildren(new Option('Choose a machine', ''));
select.replaceChildren(new Option('Choose a machine', ''));
@@ -20,50 +35,165 @@ if (form) {
@@ -20,50 +35,165 @@ if (form) {
select.add(new Option(`${m.name}${m.online ? '' : ' (offline)'}`, m.id));
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.');
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) {
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 meta = new TextEncoder().encode(JSON.stringify({ name, mime }));
const out = new Uint8Array(4 + meta.length + data.length);
const out = new Uint8Array(4 + meta.length + data.length);
new DataView(out.buffer).setUint32(0, meta.length);
new DataView(out.buffer).setUint32(0, meta.length);
out.set(meta, 4); out.set(data, 4 + meta.length);
out.set(meta, 4); out.set(data, 4 + meta.length);
return out;
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 (!machine) return say('Choose one of your machines.');
if (!file && !text.trim()) return say('Choose a file or write a note.');
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.');
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 {
try {
say('Encrypting in this browser…');
say('Encrypting in this browser…');
const listed = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
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.');
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 payload = await packageContent(file, text);
const sealed = await locker.seal(payload, { lock: 'people', keys: [{ subject: { type: 'link', id: target.id }, publicKey: target.public_key }] });
const sealed = await locker.seal(payload, { lock: 'people', keys: [{ subject: { type: 'link', id: target.id }, publicKey: target.public_key }] });
say('Sending encrypted data…');
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.');
say('Sent. The receiving machine will ask before saving it.');
form.elements.file.value = ''; form.elements.text.value = '';
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);
}
}
@@ -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'], ['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.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.
@@ -66,7 +66,7 @@ const SHAPED = {
@@ -66,7 +66,7 @@ const SHAPED = {
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\|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(/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');
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');
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
// …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.
// 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 = {
@@ -81,6 +81,7 @@ const SHAPED = {
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('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('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' });
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' });
@@ -197,6 +198,8 @@ const SHAPED = {
@@ -197,6 +198,8 @@ const SHAPED = {
const sendScript = await get('/send.js', 'openvibe.link');
const sendScript = await get('/send.js', 'openvibe.link');
assert.strictEqual(sendScript.status, 200, 'Link Send script is served on the Link host');
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('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).
// 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/'))) {
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');
const script = await get(src, 'openvibe.link');
@@ -42,12 +42,23 @@ const locker = require('openvibe-sdk/locker');
@@ -42,12 +42,23 @@ const locker = require('openvibe-sdk/locker');
try {
try {
const recipient = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const recipient = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = recipient.publicKey.export({ format: 'jwk' });
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(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');
res = await call('/targets', 'from');
assert.equal(res.status, 200);
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 subject = { type: 'link', id: target };
const sealed = await locker.seal(new TextEncoder().encode('Locker round trip'), {
const sealed = await locker.seal(new TextEncoder().encode('Locker round trip'), {
lock: 'people', keys: [{ subject, publicKey }],
lock: 'people', keys: [{ subject, publicKey }],
@@ -85,6 +96,51 @@ const locker = require('openvibe-sdk/locker');
@@ -85,6 +96,51 @@ const locker = require('openvibe-sdk/locker');
assert.equal(res.status, 204);
assert.equal(res.status, 204);
assert.equal((await (await call('/inbox', 'target')).json()).items.length, 0);
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.
// 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, {
const web = (path, method = 'GET', body, token = 'alice-session') => fetch(base.replace(/\/send$/, '') + path, {
method, headers: { authorization: token, ...(body ? { 'content-type': 'application/json' } : {}) },
method, headers: { authorization: token, ...(body ? { 'content-type': 'application/json' } : {}) },
@@ -99,8 +155,35 @@ const locker = require('openvibe-sdk/locker');
@@ -99,8 +155,35 @@ const locker = require('openvibe-sdk/locker');
assert.equal(res.status, 404, 'the web sender cannot address another owner\'s machine');
assert.equal(res.status, 404, 'the web sender cannot address another owner\'s machine');
res = await web('/me/send', 'POST', { target_node: target, envelope });
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');
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;
const fromWeb = (await (await call('/inbox', 'target')).json()).items;
assert.equal(fromWeb.length, 1);
assert.equal(fromWeb.length, 1);
assert.equal(fromWeb[0].sender_name, 'openvibe.link');
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)); }
} finally { await new Promise((ok) => server.close(ok)); }
})().catch((e) => { console.error(e); process.exitCode = 1; });
})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -135,7 +135,7 @@ const REVIEWED = {
@@ -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/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/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/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/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'],
'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'],
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,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'], ['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');
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
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.