openvibers/openvibe-git Commits

Link relay: clone to my computer, and a published machine (#18)

OP OpenVibers committed 3 days ago · b078df971e · 10 files, +871 −5

Changes

ddiff --git a/README.md b/README.md
iindex 3de9a52..56935ec 100644
---- a/README.md
++++ b/README.md
@@@ -73,6 +73,24 @@ A service never holds a person's password.
G2 adds changes and review, compare, issues, blame and code search. See [the API and integration notes](docs/g2-review.md) for routes, capability grants, the AI-choices dependency, and recovery details.
++## Link: clone here, and publish your computer
++
++G3 adds Link's outbound relay (contracts `git.clone-request@1` and `git.machine@1`):
++
++- **Clone to my computer** on a repository page makes a `clr_…` request for two minutes and shows
++ `openvibe-link clone '<link>'` (and an `openvibe-link://clone?url=…` deep link). Link reads the request over
++ HTTPS with the one-time token, asks the person there, clones into a folder inside the root they chose, and posts
++ approved / progress / done / refused / failed back; the page's progress line polls the same row. The clone URL's
++ read token reaches only the Link, is kept in this process, and is revoked when the request ends.
++- **Your computers** (`/machines`) makes an `mch_…` link for `openvibe-link git connect '<link>'`. Link publishes
++ the repositories under its root, read-only: the page lists them with the branch, head and date, and browses tree,
++ file and log through the poll the machine opened. A machine that has not checked in for thirty seconds is
++ **offline since …**; there is no write path.
++
++The routes are `POST /api/v1/clone-requests`, `GET /api/v1/clone-requests/:id`, `POST|GET /api/v1/machines`, and the
++Link's own `/api/v1/link/clone-requests/:id` and `/api/v1/link/machines/:id/{hello,watch,results}`. Tests:
++`test/link-relay.test.js`.
++
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-sdk: v0.133.0
ddiff --git a/migrations/0005_link_relay.sql b/migrations/0005_link_relay.sql
nnew file mode 100644
iindex 0000000..7d4af19
---- /dev/null
++++ b/migrations/0005_link_relay.sql
@@@ -0,0 +1,36 @@
++-- phase: expand
++-- G3: Link's outbound relay. A "clone to my computer" request lives two minutes and carries only the state the page
++-- shows; the clone URL's read token is kept in the process, never here. A published machine lists the repositories
++-- under the root the person chose in Link, read-only; its token is stored only as a SHA-256 hash. Nothing here is a
++-- write path to the machine.
++
++CREATE TABLE git_clone_requests (
++ id text PRIMARY KEY, -- clr_<ULID>
++ subject text NOT NULL, -- usr_…: who may read the page's copy
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ repo_full text NOT NULL, -- owner/name at the time of the request
++ state text NOT NULL DEFAULT 'pending'
++ CHECK (state IN ('pending', 'approved', 'cloning', 'done', 'refused', 'failed', 'expired')),
++ token_hash text NOT NULL, -- hex SHA-256 of the one-time Link token
++ progress jsonb, -- {percent, line} while cloning
++ path text, -- where Link put it, on done
++ reason text, -- why it was refused or failed
++ created_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX git_clone_requests_subject ON git_clone_requests (subject, created_at DESC);
++
++CREATE TABLE git_machines (
++ id text PRIMARY KEY, -- mch_<ULID>
++ subject text NOT NULL, -- the person who connected it
++ owner_username text NOT NULL, -- the namespace on the page
++ name text NOT NULL,
++ os text,
++ token_hash text NOT NULL UNIQUE,
++ repos jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{name, branch, head, head_subject, updated_at}]
++ created_at timestamptz NOT NULL DEFAULT now(),
++ last_seen_at timestamptz,
++ offline_since timestamptz
++);
++CREATE INDEX git_machines_subject ON git_machines (subject, created_at DESC);
ddiff --git a/public/js/git.js b/public/js/git.js
iindex 174b56b..d25d913 100644
---- a/public/js/git.js
++++ b/public/js/git.js
@@@ -45,6 +45,30 @@
}
});
++ // A clone request's progress line: the page polls the row, Link posts each step. The page works without this
++ // file: the request is still made (the form is a plain POST) and the terminal command is on the page.
++ const line = document.querySelector('[data-clone-request]');
++ if (line) {
++ const id = line.getAttribute('data-clone-request');
++ const words = { pending: 'Waiting for your answer in Link…', approved: 'Approved; Link is starting git…', cloning: 'Cloning…', done: 'Cloned.', refused: 'Refused in Link.', failed: 'The clone failed.', expired: 'The request expired after two minutes.' };
++ const tick = async () => {
++ try {
++ const res = await fetch(`/api/v1/clone-requests/${encodeURIComponent(id)}`, { credentials: 'same-origin' });
++ if (!res.ok) return;
++ const data = await res.json();
++ const req = data.request || {};
++ let text = words[req.state] || req.state || '';
++ if (req.progress && (req.state === 'cloning' || req.state === 'done')) text = `${req.progress.percent}% · ${req.progress.line}`;
++ if (req.state === 'done' && req.path) text = `Cloned into ${req.path}`;
++ if (req.reason) text = `${text} ${req.reason}`;
++ line.textContent = text;
++ if (['done', 'refused', 'failed', 'expired'].includes(req.state)) return;
++ } catch { /* the next tick tries again */ }
++ setTimeout(tick, 1500);
++ };
++ setTimeout(tick, 600);
++ }
++
document.addEventListener('click', (event) => {
const toggle = event.target.closest('[data-git-diff-toggle]');
if (!toggle) return;
ddiff --git a/server/app.js b/server/app.js
iindex 77a1631..185cb7c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -35,6 +35,8 @@ const { createPageRoutes } = require('./http/pages');
const { createReviewRoutes } = require('./http/review');
const { createReviews } = require('./reviews');
const { createReviewServices } = require('./review-services');
++const { createLinkRelay } = require('./link-relay');
++const { createLinkRoutes } = require('./http/link');
const { setRelease } = require('./render/layout');
const VERSION = require('../package.json').version;
@@@ -59,6 +61,7 @@ function createApp(opts = {}) {
secureCookies: config.cookies.secure, fetch: fetchImpl, log,
});
const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, smartHttp, sso, log };
++ ctx.relay = createLinkRelay(ctx);
ctx.reviews = createReviews(ctx, opts.reviewServices || createReviewServices(config, fetchImpl));
@@@ -154,6 +157,7 @@ function createApp(opts = {}) {
windowMs: 60_000, limit: config.apiRateLimitPerMinute, standardHeaders: true, legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
++ app.use('/api/v1', createLinkRoutes(ctx));
app.use('/api/v1', createReviewRoutes(ctx, true));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/http/link.js b/server/http/link.js
nnew file mode 100644
iindex 0000000..20df61f
---- /dev/null
++++ b/server/http/link.js
@@@ -0,0 +1,111 @@
++'use strict';
++
++/**
++ * Link's relay over HTTPS (Git plan G3). The Link only ever connects out; these routes are the two ends:
++ *
++ * page (signed in) /api/v1/clone-requests, /api/v1/machines
++ * Link (one-time token) /api/v1/link/clone-requests/:id, /api/v1/link/machines/:id/{hello,watch,results}
++ *
++ * A page's request never carries the clone URL or the token: the page polls its own row, the Link gets the question.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { viewerOf } = require('./identity');
++
++function createLinkRoutes(ctx) {
++ const { config, repos, relay } = ctx;
++ const r = express.Router();
++ const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const fail = (req, res, err) => problem(req, res, err.status || 500, err.code || 'request.failed', err.status ? err.message : undefined);
++ const viewer = (req) => viewerOf(req);
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).host === new URL(config.baseUrl).host; } catch { return false; }
++ };
++ const requirePerson = (req, res) => {
++ const me = viewer(req);
++ if (!me) { problem(req, res, 401, 'auth.required', 'Sign in first.'); return null; }
++ if (!sameOrigin(req)) { problem(req, res, 403, 'origin.forbidden', 'Make this request on git.openvibe.codes itself.'); return null; }
++ return me;
++ };
++ const linkToken = (req) => {
++ const auth = String(req.get('authorization') || '');
++ if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
++ return String(req.query.token || '');
++ };
++
++ // ── Clone to my computer ────────────────────────────────
++ r.post('/clone-requests', h(async (req, res) => {
++ const me = requirePerson(req, res);
++ if (!me) return undefined;
++ const [owner, name] = String(req.body && req.body.repo || '').toLowerCase().split('/');
++ if (!owner || !name) return problem(req, res, 400, 'repo.invalid', 'A repository is owner/name.');
++ const row = await repos.getRow(owner, name);
++ if (!row || !repos.canView(row, me)) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
++ try {
++ const out = await relay.createClone({ viewer: me, row });
++ return res.status(201).json({ request: out.request, link: { url: out.link, command: `openvibe-link clone '${out.link}'` } });
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.get('/clone-requests/:id', h(async (req, res) => {
++ const me = viewer(req);
++ if (!me) return problem(req, res, 401, 'auth.required', 'Sign in first.');
++ const request = await relay.getForPage({ id: String(req.params.id), subject: me.subject });
++ if (!request) return problem(req, res, 404, 'clone.not_found', 'No such clone request.');
++ return res.set('Cache-Control', 'no-store').json({ request });
++ }));
++
++ // The Link's end of the same request.
++ r.get('/link/clone-requests/:id', h(async (req, res) => {
++ const doc = await relay.linkGet({ id: String(req.params.id), token: linkToken(req) });
++ if (!doc) return problem(req, res, 404, 'clone.not_found', 'No such clone request, or the link expired.');
++ return res.set('Cache-Control', 'no-store').json(doc);
++ }));
++
++ r.post('/link/clone-requests/:id', h(async (req, res) => {
++ const doc = await relay.linkUpdate({ id: String(req.params.id), token: linkToken(req), body: req.body || {} });
++ if (!doc) return problem(req, res, 404, 'clone.not_found', 'No such clone request, or the link expired.');
++ return res.json({ request: doc });
++ }));
++
++ // ── A published machine, read-only ──────────────────────
++ r.post('/machines', h(async (req, res) => {
++ const me = requirePerson(req, res);
++ if (!me) return undefined;
++ try {
++ const out = await relay.createMachine({ viewer: me, name: req.body && req.body.name });
++ return res.status(201).json({ machine: out.machine, link: { url: out.link, command: `openvibe-link git connect '${out.link}'` } });
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.get('/machines', h(async (req, res) => {
++ const me = viewer(req);
++ if (!me) return problem(req, res, 401, 'auth.required', 'Sign in first.');
++ return res.set('Cache-Control', 'no-store').json({ machines: await relay.listMachines({ subject: me.subject }) });
++ }));
++
++ r.post('/link/machines/:id/hello', h(async (req, res) => {
++ const out = await relay.machineHello({ id: String(req.params.id), token: linkToken(req), body: req.body || {} });
++ if (!out) return problem(req, res, 404, 'machine.not_found', 'No such machine, or the link expired.');
++ return res.json(out);
++ }));
++
++ r.get('/link/machines/:id/watch', h(async (req, res) => {
++ const out = await relay.machineWatch({ id: String(req.params.id), token: linkToken(req) });
++ if (!out) return problem(req, res, 404, 'machine.not_found', 'No such machine, or the link expired.');
++ return res.set('Cache-Control', 'no-store').json(out);
++ }));
++
++ r.post('/link/machines/:id/results', h(async (req, res) => {
++ const out = await relay.machineResult({ id: String(req.params.id), token: linkToken(req), body: req.body || {} });
++ if (!out) return problem(req, res, 404, 'machine.not_found', 'No such machine, or the link expired.');
++ return res.json(out);
++ }));
++
++ return r;
++}
++
++module.exports = { createLinkRoutes };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex dbdd9cc..1d6b085 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -21,13 +21,14 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const { send } = require('../render/layout');
const views = require('../views');
++const machineViews = require('../views/machine');
const { viewerOf } = require('./identity');
const { esc } = require('../render/html');
const { renderMarkdown } = require('../render/markdown');
const { validRef, validPath } = require('../git/refs');
function createPageRoutes(ctx) {
-- const { config, repos, git, github, mirrors, insights, store } = ctx;
++ const { config, repos, git, github, mirrors, insights, store, relay } = ctx;
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '256kb' }));
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
@@@ -151,6 +152,90 @@ function createPageRoutes(ctx) {
return page(req, res, views.profilePage({ profile }), { title: `@${profile.username}`, description: `@${profile.username}'s public repositories on OpenVibe Git.` });
}));
++ // ── Your computers, published through Link (G3) ──────────
++ const machineFor = async (req, res) => {
++ const me = viewer(req);
++ if (!me) { res.redirect(303, loginTo('/machines')); return null; }
++ const machine = await relay.getMachine({ id: String(req.params.machine || ''), subject: me.subject });
++ if (!machine) { page(req, res, views.notFound(), { status: 404, title: 'Not found' }); return null; }
++ return machine;
++ };
++ const repoAt = (machine, name) => (machine.repos || []).find((r) => r.name === String(name || ''));
++ const machineResult = (req, res, machine, repo, out, render) => {
++ if (out.state === 'offline') return page(req, res, machineViews.machineRepoPage({ machine: out.machine || machine, repo, ref: '', offline: true }), { status: 200, title: `${repo.name} · ${machine.name}` });
++ if (out.state === 'missing') return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ if (out.state === 'timeout') return page(req, res, machineViews.machineRepoPage({ machine: out.machine || machine, repo, ref: '', error: 'The computer did not answer in time. It is online; try again.' }), { status: 504, title: `${repo.name} · ${machine.name}` });
++ return page(req, res, render(out.result), { title: `${repo.name} · ${machine.name}`, index: false });
++ };
++
++ r.get('/machines', h(async (req, res) => {
++ const me = viewer(req);
++ if (!me) return res.redirect(303, loginTo('/machines'));
++ return page(req, res, machineViews.machinesPage({ viewer: me, machines: await relay.listMachines({ subject: me.subject }) }), { title: 'Your computers', index: false });
++ }));
++
++ r.post('/machines', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ try {
++ const out = await relay.createMachine({ viewer: viewer(req), name: req.body.name });
++ return page(req, res, machineViews.machineConnectPage({ machine: out.machine, link: out.link }), { title: `Connect ${out.machine.name}`, index: false });
++ } catch (err) {
++ return page(req, res, machineViews.machinesPage({ viewer: viewer(req), machines: await relay.listMachines({ subject: viewer(req).subject }), error: err.message }), { status: 422, title: 'Your computers', index: false });
++ }
++ }));
++
++ r.get('/machines/:machine', h(async (req, res) => {
++ const machine = await machineFor(req, res);
++ if (!machine) return undefined;
++ return page(req, res, machineViews.machinePage({ machine, repos: machine.repos || [] }), { title: machine.name, index: false });
++ }));
++
++ r.get('/machines/:machine/:repo/tree', h(async (req, res) => {
++ const machine = await machineFor(req, res);
++ if (!machine) return undefined;
++ const repo = repoAt(machine, req.params.repo);
++ if (!repo) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const ref = String(req.query.ref || repo.branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt)) return page(req, res, views.message({ title: 'Not a ref or path', text: 'That ref or path is not something this server reads.', link: { href: `/machines/${machine.id}/${repo.name}`, label: 'Back to the repository' } }), { status: 422 });
++ const out = await relay.browse({ machineId: machine.id, kind: 'tree', repo: repo.name, ref, path: pathAt });
++ return machineResult(req, res, machine, repo, out, (result) => machineViews.machineRepoPage({ machine, repo, ref, path: pathAt, entries: result.entries || [] }));
++ }));
++
++ r.get('/machines/:machine/:repo/blob', h(async (req, res) => {
++ const machine = await machineFor(req, res);
++ if (!machine) return undefined;
++ const repo = repoAt(machine, req.params.repo);
++ if (!repo) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const ref = String(req.query.ref || repo.branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt) || !pathAt) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const out = await relay.browse({ machineId: machine.id, kind: 'file', repo: repo.name, ref, path: pathAt });
++ return machineResult(req, res, machine, repo, out, (result) => machineViews.machineFilePage({ machine, repo, ref, path: pathAt, blob: result }));
++ }));
++
++ r.get('/machines/:machine/:repo/commits', h(async (req, res) => {
++ const machine = await machineFor(req, res);
++ if (!machine) return undefined;
++ const repo = repoAt(machine, req.params.repo);
++ if (!repo) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const ref = String(req.query.ref || repo.branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt)) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const out = await relay.browse({ machineId: machine.id, kind: 'log', repo: repo.name, ref, path: pathAt });
++ return machineResult(req, res, machine, repo, out, (result) => machineViews.machineCommitsPage({ machine, repo, ref, path: pathAt, commits: result.commits || [] }));
++ }));
++
++ r.get('/machines/:machine/:repo', h(async (req, res) => {
++ const machine = await machineFor(req, res);
++ if (!machine) return undefined;
++ const repo = repoAt(machine, req.params.repo);
++ if (!repo) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const ref = repo.branch || 'main';
++ const out = await relay.browse({ machineId: machine.id, kind: 'tree', repo: repo.name, ref, path: '' });
++ return machineResult(req, res, machine, repo, out, (result) => machineViews.machineRepoPage({ machine, repo, ref, path: '', entries: result.entries || [] }));
++ }));
++
// ── A repository ────────────────────────────────────────
r.get('/:owner/:name', h(async (req, res) => {
const row = await rowFor(req, res);
@@@ -162,6 +247,18 @@ function createPageRoutes(ctx) {
return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, ...context }), meta(req, row));
}));
++ r.post('/:owner/:name/clone', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ try {
++ const out = await relay.createClone({ viewer: viewer(req), row });
++ return page(req, res, machineViews.clonePage({ row, request: out.request, link: out.link }), { title: `Clone ${row.owner_username}/${row.name} to your computer`, index: false });
++ } catch (err) {
++ return page(req, res, views.message({ title: 'The clone request was not made', text: err.message, link: { href: `/${row.owner_username}/${row.name}`, label: 'Back to the repository' } }), { status: err.status || 422 });
++ }
++ }));
++
r.get('/:owner/:name/tree', h(async (req, res) => {
const row = await rowFor(req, res);
if (!row) return undefined;
ddiff --git a/server/link-relay.js b/server/link-relay.js
nnew file mode 100644
iindex 0000000..00129e1
---- /dev/null
++++ b/server/link-relay.js
@@@ -0,0 +1,289 @@
++'use strict';
++
++/**
++ * Link's outbound relay (Git plan G3). Link connects to Git; Git never connects to Link. Two kinds of link:
++ *
++ * ├ clone: the repository page mints a clr_… request and a one-time token, and the person runs
++ * │ `openvibe-link clone <link>`. Link reads the request, asks there, clones with the person's own git
++ * │ and posts approved / cloning / progress / done / refused / failed back. The page polls the same row.
++ * └ machine: the person's Git page mints an mch_… id and a token; `openvibe-link git connect <link>` publishes the
++ * repositories under the root the person chose in Link, read-only. Git queues a tree, file or log question
++ * and answers it on the watch the machine opened.
++ *
++ * The Link token is 32 random bytes, stored only as its SHA-256 hash; the clone URL's read token is kept in this
++ * process only, never in the database and never in a page. A clone request lives two minutes.
++ */
++const crypto = require('node:crypto');
++const { hashToken, mintToken } = require('./auth/git-auth');
++
++const CLONE_TTL_MS = 2 * 60_000;
++const OFFLINE_AFTER_MS = 30_000;
++const WATCH_MS = 25_000;
++const BROWSE_MS = 8_000;
++const TERMINAL = new Set(['done', 'refused', 'failed', 'expired']);
++
++const newOpaque = () => crypto.randomBytes(32).toString('base64url');
++const newID = (prefix) => `${prefix}_${crypto.randomBytes(12).toString('hex').toUpperCase().slice(0, 26)}`;
++
++function createLinkRelay(ctx) {
++ const { store, repos, config, log = console } = ctx;
++ const now = store.now;
++ // Secrets stay in memory for the request's short life: the clone URL's read token and the Link token's raw value
++ // is never stored. A restart loses pending requests, which is right: Link is polling them.
++ const cloneURLs = new Map(); // clr_id -> { url, token_id }
++ const queue = new Map(); // mch_id -> [{ id, kind, repo, ref, path }]
++ const wake = new Map(); // mch_id -> Set<resolve>
++ const inflight = new Map(); // request id -> { resolve, timer, machineId }
++
++ const progressOf = (row) => {
++ if (!row.progress) return null;
++ if (typeof row.progress === 'string') { try { return JSON.parse(row.progress); } catch { return null; } }
++ return row.progress;
++ };
++ const cloneDoc = (row) => ({
++ id: row.id,
++ repo: row.repo_full,
++ state: row.state,
++ created_at: row.created_at,
++ expires_at: row.expires_at,
++ ...(progressOf(row) ? { progress: progressOf(row) } : {}),
++ ...(row.path ? { path: row.path } : {}),
++ ...(row.reason ? { reason: row.reason } : {}),
++ });
++
++ const isExpired = (row) => Date.parse(row.expires_at) <= now();
++
++ /** End a request past its two minutes: the row says expired and the clone's read token is revoked. */
++ async function expire(row) {
++ await store.execute("UPDATE git_clone_requests SET state = 'expired', updated_at = now() WHERE id = ? AND state IN ('pending', 'approved')", [row.id]);
++ const secret = cloneURLs.get(row.id);
++ cloneURLs.delete(row.id);
++ if (secret) await store.execute('UPDATE git_tokens SET revoked_at = now() WHERE id = ? AND revoked_at IS NULL', [secret.token_id]).catch(() => {});
++ row.state = 'expired';
++ return row;
++ }
++
++ async function loadForLink(id, token) {
++ const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
++ if (!row) return null;
++ if (!safeEqual(hashToken(token), row.token_hash)) return null;
++ if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
++ return row;
++ }
++
++ async function createClone({ viewer, row }) {
++ if (!viewer || !viewer.subject) {
++ throw Object.assign(new Error('Sign in to clone to your computer.'), { status: 401, code: 'auth.required' });
++ }
++ if (!repos.canView(row, viewer)) {
++ throw Object.assign(new Error('No such repository.'), { status: 404, code: 'repo.not_found' });
++ }
++ const id = store.newId('clr');
++ const token = newOpaque();
++ const expires = new Date(now() + CLONE_TTL_MS).toISOString();
++ // A short read token is minted for this one clone; it names the person and is revoked when the request ends.
++ const minted = await mintToken(store, { subject: viewer.subject, scopes: ['git.repo.read'], hours: 1, label: `clone ${row.owner_username}/${row.name}` });
++ const host = config.baseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
++ const url = `${config.baseUrl.replace(/\/+$/, '')}/${row.owner_username}/${row.name}.git`;
++ const cloneURL = `https://x-access-token:${minted.token}@${host}/${row.owner_username}/${row.name}.git`;
++ await store.execute(
++ `INSERT INTO git_clone_requests (id, subject, repo_id, repo_full, state, token_hash, created_at, expires_at, updated_at)
++ VALUES (?, ?, ?, ?, 'pending', ?, now(), ?, now())`,
++ [id, viewer.subject, row.id, `${row.owner_username}/${row.name}`, hashToken(token), expires],
++ );
++ cloneURLs.set(id, { url: cloneURL, token_id: minted.id });
++ const link = `${config.baseUrl.replace(/\/+$/, '')}/api/v1/link/clone-requests/${id}?token=${encodeURIComponent(token)}`;
++ return { request: cloneDoc({ id, repo_full: `${row.owner_username}/${row.name}`, state: 'pending', created_at: new Date(now()).toISOString(), expires_at: expires, progress: null, path: null, reason: null }), link };
++ }
++
++ async function getForPage({ id, subject }) {
++ const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
++ if (!row || row.subject !== subject) return null;
++ if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
++ return cloneDoc(row);
++ }
++
++ async function linkGet({ id, token }) {
++ const row = await loadForLink(id, token);
++ if (!row) return null;
++ const doc = cloneDoc(row);
++ if (row.state === 'pending' || row.state === 'approved') {
++ const secret = cloneURLs.get(id);
++ if (secret) doc.clone_url = secret.url;
++ }
++ return doc;
++ }
++
++ async function linkUpdate({ id, token, body }) {
++ const row = await loadForLink(id, token);
++ if (!row) return null;
++ if (TERMINAL.has(row.state)) return cloneDoc(row);
++ if (row.state === 'pending' && isExpired(row)) return cloneDoc({ ...row, state: 'expired' });
++ const state = String(body.state || '');
++ const allowed = (row.state === 'pending' && (state === 'approved' || state === 'refused'))
++ || ((row.state === 'approved' || row.state === 'cloning') && (state === 'cloning' || state === 'done' || state === 'failed' || state === 'refused'));
++ if (!allowed) return cloneDoc(row);
++ const progress = state === 'cloning' && body.progress && Number.isFinite(Number(body.progress.percent))
++ ? { percent: Math.max(0, Math.min(100, Math.round(Number(body.progress.percent)))), line: String(body.progress.line || '').slice(0, 200) }
++ : null;
++ const path = state === 'done' ? String(body.path || '').slice(0, 4096) : null;
++ const reason = (state === 'refused' || state === 'failed') ? String(body.reason || '').slice(0, 300) : null;
++ await store.execute(
++ `UPDATE git_clone_requests SET state = ?, progress = COALESCE(?, progress), path = COALESCE(?, path), reason = COALESCE(?, reason), updated_at = now() WHERE id = ?`,
++ [state, progress ? JSON.stringify(progress) : null, path, reason, id],
++ );
++ if (TERMINAL.has(state)) {
++ const secret = cloneURLs.get(id);
++ cloneURLs.delete(id);
++ if (secret) await store.execute('UPDATE git_tokens SET revoked_at = now() WHERE id = ? AND revoked_at IS NULL', [secret.token_id]).catch(() => {});
++ }
++ return cloneDoc({ ...row, state, progress: progress || row.progress, path: path || row.path, reason: reason || row.reason });
++ }
++
++ // ── A published machine ─────────────────────────────────
++ const machineDoc = (row) => {
++ const online = row.last_seen_at && now() - Date.parse(row.last_seen_at) < OFFLINE_AFTER_MS;
++ const repos = Array.isArray(row.repos) ? row.repos : JSON.parse(row.repos || '[]');
++ return {
++ id: row.id, name: row.name, os: row.os || undefined,
++ online: Boolean(online),
++ last_seen_at: row.last_seen_at || row.created_at,
++ offline_since: online ? null : (row.last_seen_at || row.created_at),
++ repos,
++ };
++ };
++
++ async function createMachine({ viewer, name }) {
++ if (!viewer || !viewer.subject) {
++ throw Object.assign(new Error('Sign in to connect a computer.'), { status: 401, code: 'auth.required' });
++ }
++ const id = store.newId('mch');
++ const token = newOpaque();
++ await store.execute(
++ `INSERT INTO git_machines (id, subject, owner_username, name, token_hash, repos, created_at) VALUES (?, ?, ?, ?, ?, '[]'::jsonb, now())`,
++ [id, viewer.subject, viewer.username || 'you', String(name || 'this computer').slice(0, 80), hashToken(token)],
++ );
++ const link = `${config.baseUrl.replace(/\/+$/, '')}/api/v1/link/machines/${id}?token=${encodeURIComponent(token)}`;
++ return { machine: { id, name: String(name || 'this computer').slice(0, 80), online: false, last_seen_at: null, offline_since: null, repos: [] }, link };
++ }
++
++ async function machineFor(id, token) {
++ const row = await store.maybe('SELECT * FROM git_machines WHERE id = ?', [id]);
++ if (!row || !safeEqual(hashToken(token), row.token_hash)) return null;
++ return row;
++ }
++
++ async function machineHello({ id, token, body }) {
++ const row = await machineFor(id, token);
++ if (!row) return null;
++ const wasOffline = !row.last_seen_at || now() - Date.parse(row.last_seen_at) >= OFFLINE_AFTER_MS;
++ const name = String(body.name || row.name || 'this computer').slice(0, 80);
++ const os = String(body.os || '').slice(0, 40) || null;
++ const list = Array.isArray(body.repos) ? body.repos.slice(0, 500).filter(validMachineRepo) : [];
++ await store.execute(
++ `UPDATE git_machines SET name = ?, os = ?, repos = ?, last_seen_at = now(), offline_since = CASE WHEN ? THEN COALESCE(last_seen_at, created_at) ELSE NULL END WHERE id = ?`,
++ [name, os, JSON.stringify(list), wasOffline, id],
++ );
++ return { ok: true };
++ }
++
++ async function touchMachine(id) {
++ await store.execute('UPDATE git_machines SET last_seen_at = now(), offline_since = NULL WHERE id = ?', [id]).catch(() => {});
++ }
++
++ async function machineWatch({ id, token }) {
++ const row = await machineFor(id, token);
++ if (!row) return null;
++ await touchMachine(id);
++ const q = queue.get(id);
++ if (q && q.length) return { online: true, request: q.shift() };
++ return await new Promise((resolve) => {
++ const set = wake.get(id) || new Set();
++ wake.set(id, set);
++ let done = false;
++ const finish = (value) => {
++ if (done) return;
++ done = true;
++ clearTimeout(timer);
++ set.delete(finish);
++ resolve(value);
++ };
++ const timer = setTimeout(() => finish({ online: true }), WATCH_MS);
++ set.add(finish);
++ });
++ }
++
++ async function machineResult({ id, token, body }) {
++ if (!(await machineFor(id, token))) return null;
++ await touchMachine(id);
++ const pending = inflight.get(String(body.request_id || ''));
++ if (!pending || pending.machineId !== id) return { ok: true };
++ clearTimeout(pending.timer);
++ inflight.delete(String(body.request_id));
++ pending.resolve({ ok: Boolean(body.ok), result: body.result, error: body.error });
++ return { ok: true };
++ }
++
++ function notify(machineId, request) {
++ const set = wake.get(machineId);
++ if (!set || !set.size) return;
++ const q = queue.get(machineId) || [];
++ queue.set(machineId, q.filter((r) => r.id !== request.id));
++ for (const finish of [...set]) finish({ online: true, request });
++ }
++
++ async function browse({ machineId, kind, repo, ref, path }) {
++ const row = await store.maybe('SELECT * FROM git_machines WHERE id = ?', [machineId]);
++ if (!row) return { state: 'missing' };
++ const online = row.last_seen_at && now() - Date.parse(row.last_seen_at) < OFFLINE_AFTER_MS;
++ if (!online) return { state: 'offline', offline_since: row.last_seen_at || row.created_at, machine: machineDoc(row) };
++ const list = Array.isArray(row.repos) ? row.repos : JSON.parse(row.repos || '[]');
++ if (!list.some((r) => r.name === repo)) return { state: 'missing' };
++ const request = { id: newID('req'), kind, repo, ref: ref || '', path: path || '' };
++ const q = queue.get(machineId) || [];
++ q.push(request);
++ queue.set(machineId, q);
++ notify(machineId, request);
++ return await new Promise((resolve) => {
++ const timer = setTimeout(() => {
++ inflight.delete(request.id);
++ const left = (queue.get(machineId) || []).filter((r) => r.id !== request.id);
++ queue.set(machineId, left);
++ resolve({ state: 'timeout', machine: machineDoc(row) });
++ }, BROWSE_MS);
++ inflight.set(request.id, { machineId, timer, resolve: (value) => resolve({ state: 'ok', ...value, machine: machineDoc(row) }) });
++ });
++ }
++
++ async function listMachines({ subject }) {
++ const rows = await store.many('SELECT * FROM git_machines WHERE subject = ? ORDER BY created_at DESC', [subject]);
++ return rows.map(machineDoc);
++ }
++
++ async function getMachine({ id, subject }) {
++ const row = await store.maybe('SELECT * FROM git_machines WHERE id = ? AND subject = ?', [id, subject]);
++ return row ? machineDoc(row) : null;
++ }
++
++ return {
++ createClone, getForPage, linkGet, linkUpdate,
++ createMachine, machineHello, machineWatch, machineResult, listMachines, getMachine, browse,
++ // For tests.
++ CLONE_TTL_MS, OFFLINE_AFTER_MS,
++ };
++}
++
++function safeEqual(a, b) {
++ const x = Buffer.from(String(a));
++ const y = Buffer.from(String(b));
++ return x.length === y.length && crypto.timingSafeEqual(x, y);
++}
++
++function validMachineRepo(r) {
++ if (!r || typeof r !== 'object') return false;
++ if (!/^[a-z0-9][a-z0-9._-]{0,99}$/.test(String(r.name || ''))) return false;
++ if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(String(r.head || ''))) return false;
++ return true;
++}
++
++module.exports = { createLinkRelay, CLONE_TTL_MS, OFFLINE_AFTER_MS };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex e4af9cd..ceb0c02 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -34,6 +34,7 @@ function home({ viewer, stats, trending = [], activity = [], official = [], base
<div class="git-actions">
<a class="ovk-btn ovk-btn--primary" href="${signedIn ? '/new' : '/auth/login?next=%2Fnew'}">${icon('plus')} New repository</a>
<a class="ovk-btn ovk-btn--ghost" href="/explore">${icon('search')} Explore</a>
++ ${signedIn ? `<a class="ovk-btn ovk-btn--ghost" href="/machines">${icon('computer')} Your computers</a>` : ''}
</div>
</div>
<div class="git-hero-motif" role="img" aria-label="A repository, its branch, and a clone to a computer">
@@@ -152,11 +153,11 @@ function repoHead({ row, viewer, starred, watching, forkOf }) {
</div>
<div class="git-repo-actions">${starAction}${watchAction}${signedIn ? `<form method="post" action="${base}/fork" class="git-inline"><button class="ovk-btn ovk-btn--ghost" type="submit">${icon('forward')} Fork <b>${row.forks}</b></button></form>` : `<a class="ovk-btn ovk-btn--ghost" href="/auth/login?next=${encodeURIComponent(base)}">${icon('forward')} Fork <b>${row.forks}</b></a>`}</div>
</div>
-- ${getIt(row)}
++ ${getIt(row, signedIn)}
</header>`;
}
--function getIt(row) {
++function getIt(row, signedIn) {
const clone = `${row.owner_username}/${row.name}.git`;
const full = `${row.owner_username}/${row.name}`;
return `<details class="git-get" id="get">
@@@ -171,8 +172,10 @@ function getIt(row) {
</div>
<div class="git-get-choice">
<b>Clone to my computer</b>
-- <p>OpenVibe Link opens on your computer and asks you to approve the clone. The folder is yours; pushes go to this repository.</p>
-- <a class="ovk-btn" href="openvibe-link://clone?repo=${encodeURIComponent(full)}">${icon('computer')} Open in Link</a>
++ <p>OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.</p>
++ ${signedIn
++ ? `<form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to my computer</button></form>`
++ : `<a class="ovk-btn" href="/auth/login?next=${encodeURIComponent('/' + full)}">${icon('computer')} Clone to my computer</a>`}
</div>
<div class="git-get-choice">
<b>Open in Codes</b>
ddiff --git a/server/views/machine.js b/server/views/machine.js
nnew file mode 100644
iindex 0000000..b099466
---- /dev/null
++++ b/server/views/machine.js
@@@ -0,0 +1,143 @@
++'use strict';
++
++/**
++ * The G3 pages: a clone request's progress, and a person's computers published through Link. Plain kit markup and
++ * no new styles; the design pass owns the look.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc, bytes, ago, initials } = require('../render/html');
++
++const icon = (name) => kit.icon(name);
++const qs = (o) => {
++ const p = new URLSearchParams();
++ for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v);
++ const s = p.toString();
++ return s ? `?${s}` : '';
++};
++
++function clonePage({ row, request, link }) {
++ const full = `${row.owner_username}/${row.name}`;
++ return `<div class="git-list-page git-clone">
++<header class="git-page-head"><p class="git-crumb"><a href="/${esc(full)}">${esc(full)}</a></p><h1>Clone to my computer</h1>
++<p>OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.</p></header>
++<div class="git-get-choice">
++ <p class="git-clone-line" data-clone-request="${esc(request.id)}" role="status" aria-live="polite">Waiting for your answer in Link…</p>
++ <p><a class="ovk-btn ovk-btn--primary" href="openvibe-link://clone?url=${encodeURIComponent(link)}">${icon('computer')} Open Link</a></p>
++ <p class="muted">No Link window? Run this in a terminal:</p>
++ <pre class="git-cmd">openvibe-link clone '${esc(link)}'</pre>
++ <p class="muted"><a href="/${esc(full)}">Back to ${esc(full)}</a></p>
++</div>
++</div>`;
++}
++
++function machinesPage({ viewer, machines = [], error = null }) {
++ const name = viewer && viewer.username ? viewer.username : 'you';
++ const rows = machines.map((m) => `<li class="git-machine">
++ <a href="/machines/${esc(m.id)}"><span class="git-entry-icon">${icon('computer')}</span><b>${esc(m.name)}</b></a>
++ <span class="git-chip">${m.online ? 'online' : `offline since ${esc(ago(m.offline_since || m.last_seen_at))}`}</span>
++ <small>${m.online ? `${m.repos.length} ${m.repos.length === 1 ? 'repository' : 'repositories'}` : 'Link is not checking in'}</small>
++</li>`).join('');
++ return `<div class="git-list-page">
++<header class="git-page-head"><h1>Your computers</h1><p>Repositories on your own computers, published through Link. Read-only: Git can look, never write. Browsing works while the computer is online.</p></header>
++${error ? `<p class="git-error" role="alert">${esc(error)}</p>` : ''}
++${machines.length ? `<ul class="git-refs git-machines">${rows}</ul>` : '<p class="git-empty">No computer is connected yet.</p>'}
++<details class="git-fold"><summary class="ovk-btn ovk-btn--primary">${icon('plus')} Connect a computer</summary>
++<form method="post" action="/machines" class="git-form">
++ <label>Name <input name="name" maxlength="80" placeholder="my laptop"></label>
++ <p class="muted">Link shows you the command; it publishes the repositories under the root you chose there.</p>
++ <button class="ovk-btn ovk-btn--primary" type="submit">Make a Link command</button>
++</form></details>
++<p class="muted">Signed in as @${esc(name)}.</p>
++</div>`;
++}
++
++function machineConnectPage({ machine, link }) {
++ return `<div class="git-list-page">
++<header class="git-page-head"><p class="git-crumb"><a href="/machines">Your computers</a></p><h1>${esc(machine.name)}</h1>
++<p>Run this on the computer. Link publishes the repositories under the root you chose in Link, read-only, and keeps checking in until you stop it.</p></header>
++<pre class="git-cmd">openvibe-link git connect '${esc(link)}'</pre>
++<p><a class="ovk-btn ovk-btn--primary" href="openvibe-link://git?url=${encodeURIComponent(link)}">${icon('computer')} Open Link</a></p>
++<p class="muted">This link is the computer's own; keep it private. It appears in <a href="/machines">Your computers</a> when Link connects.</p>
++</div>`;
++}
++
++function machinePage({ machine, repos = [] }) {
++ const rows = repos.map((r) => `<li class="git-machine">
++ <a href="/machines/${esc(machine.id)}/${esc(r.name)}"><span class="git-entry-icon">${icon('code')}</span><b>${esc(r.name)}</b></a>
++ <span class="git-ref-chip">${esc(r.branch)}</span>
++ <small>${esc(r.head_subject || '')} · ${esc(ago(r.updated_at))}</small>
++</li>`).join('');
++ return `<div class="git-list-page">
++<header class="git-page-head"><p class="git-crumb"><a href="/machines">Your computers</a></p><h1>${esc(machine.name)}</h1>
++<p>${machine.online ? 'Online now, read-only through Link.' : `Offline since ${esc(ago(machine.offline_since || machine.last_seen_at))}. Start Link to browse.`}</p></header>
++${repos.length ? `<ul class="git-refs git-machines">${rows}</ul>` : '<p class="git-empty">No repositories under the root yet.</p>'}
++</div>`;
++}
++
++function machineRepoPage({ machine, repo, ref, path = '', entries = [], offline = null, error = null }) {
++ const base = `/machines/${machine.id}/${repo.name}`;
++ if (offline) return machineOffline({ machine, repo });
++ if (error) return machineError({ machine, repo, error });
++ const crumbs = [{ name: repo.name, path: '' }, ...String(path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
++ const rows = entries.map((e) => {
++ const href = e.type === 'tree' ? `${base}/tree${qs({ ref, path: e.path })}` : `${base}/blob${qs({ ref, path: e.path })}`;
++ return `<tr><td class="git-entry"><a href="${esc(href)}"><span class="git-entry-icon">${icon(e.type === 'tree' ? 'folder' : 'file')}</span>${esc(e.name)}</a></td><td class="git-entry-size">${e.size == null ? '' : esc(bytes(e.size))}</td></tr>`;
++ }).join('');
++ return `<div class="git-file">
++<header class="git-page-head"><p class="git-crumb"><a href="/machines/${esc(machine.id)}">${esc(machine.name)}</a> ${icon('chevron')} <a href="${esc(base)}">${esc(repo.name)}</a></p><h1>${esc(repo.name)}</h1>
++<p class="git-file-meta"><span class="git-chip">${esc(ref)}</span> · read-only through Link</p>
++<div class="git-actions"><a class="ovk-btn ovk-btn--ghost" href="${base}/commits${qs({ ref })}">${icon('clock')} History</a><a class="ovk-btn ovk-btn--ghost" href="/machines/${esc(machine.id)}">${icon('back')} This computer</a></div></header>
++<nav class="git-path" aria-label="Path">${crumbs.map((c, i) => `${i ? icon('chevron') : ''}<a href="${base}/tree${qs({ ref, path: c.path })}">${esc(c.name)}</a>`).join('')}</nav>
++<div class="table-wrap"><table class="git-tree"><caption class="sr-only">Files in ${esc(repo.name)}</caption><tbody>
++${path && crumbs.length > 1 ? `<tr><td class="git-entry"><a href="${base}/tree${qs({ ref, path: crumbs[crumbs.length - 2].path })}">${icon('back')} ..</a></td><td></td></tr>` : ''}
++${rows || '<tr><td class="git-empty">This folder is empty (or the machine has not answered yet).</td><td></td></tr>'}
++</tbody></table></div>
++</div>`;
++}
++
++function machineFilePage({ machine, repo, ref, path, blob, offline = null, error = null }) {
++ if (offline) return machineOffline({ machine, repo });
++ if (error) return machineError({ machine, repo, error });
++ const base = `/machines/${machine.id}/${repo.name}`;
++ let body;
++ if (blob.binary) body = `<p class="git-empty">A binary file (${esc(bytes(blob.size))}).</p>`;
++ else if (blob.truncated) body = `<p class="git-notice">This file is ${esc(bytes(blob.size))}; the page shows the start of it.</p><pre class="git-cmd">${esc((blob.text || '').slice(0, 200000))}</pre>`;
++ else body = `<div class="git-code"><table class="git-code-table"><tbody>${String(blob.text || '').split('\n').map((line, i) => `<tr id="L${i + 1}"><td class="git-ln"><a href="#L${i + 1}" aria-label="Line ${i + 1}">${i + 1}</a></td><td class="git-code-line">${esc(line) || ' '}</td></tr>`).join('')}</tbody></table></div>`;
++ return `<div class="git-file">
++<header class="git-page-head"><p class="git-crumb"><a href="${esc(base)}">${esc(repo.name)}</a> ${icon('chevron')} <a href="${base}/tree${qs({ ref, path: path.split('/').slice(0, -1).join('/') })}">${esc(path.split('/').slice(0, -1).join('/') || repo.name)}</a></p><h1>${esc(path.split('/').pop())}</h1>
++<p class="git-file-meta"><span class="git-chip">${esc(ref)}</span> ${esc(bytes(blob.size))} · read-only through Link</p>
++<div class="git-actions"><a class="ovk-btn ovk-btn--ghost" href="${base}/commits${qs({ ref, path })}">${icon('clock')} History</a></div></header>
++${body}
++</div>`;
++}
++
++function machineCommitsPage({ machine, repo, ref, path = '', commits = [], offline = null, error = null }) {
++ if (offline) return machineOffline({ machine, repo });
++ if (error) return machineError({ machine, repo, error });
++ const base = `/machines/${machine.id}/${repo.name}`;
++ return `<div class="git-commits">
++<header class="git-page-head"><p class="git-crumb"><a href="${esc(base)}">${esc(repo.name)}</a></p><h1>Commits</h1><p class="git-file-meta"><span class="git-chip">${esc(ref)}</span>${path ? ` · ${esc(path)}` : ''} · read-only through Link</p></header>
++${commits.length ? `<ul class="git-commit-list">${commits.map((c) => `<li><a href="${base}/commits${qs({ ref })}">
++<span class="git-avatar git-avatar--commit">${icon('commit')}</span>
++<span class="git-commit-main"><b>${esc(c.subject || '(no message)')}</b><small>${esc(c.author)} · ${esc(ago(c.date))}</small></span>
++<span class="git-commit-sha"><code>${esc(c.short || c.sha.slice(0, 7))}</code></span></a></li>`).join('')}</ul>` : '<p class="git-empty">No commits here yet.</p>'}
++</div>`;
++}
++
++function machineOffline({ machine, repo }) {
++ return `<div class="git-list-page">
++<header class="git-page-head"><p class="git-crumb"><a href="/machines/${esc(machine.id)}">${esc(machine.name)}</a></p><h1>${esc(repo.name)}</h1>
++<p>This computer is offline since ${esc(ago(machine.offline_since || machine.last_seen_at))}. OpenVibe Git browses it through Link, so it comes back when Link checks in again.</p></header>
++<p><a class="ovk-btn" href="/machines/${esc(machine.id)}">${icon('back')} This computer</a></p>
++</div>`;
++}
++
++function machineError({ machine, repo, error }) {
++ return `<div class="git-list-page">
++<header class="git-page-head"><p class="git-crumb"><a href="/machines/${esc(machine.id)}">${esc(machine.name)}</a></p><h1>${esc(repo.name)}</h1>
++<p class="git-error" role="alert">${esc(error)}</p></header>
++<p><a class="ovk-btn" href="/machines/${esc(machine.id)}">${icon('back')} This computer</a></p>
++</div>`;
++}
++
++module.exports = { clonePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };
ddiff --git a/test/link-relay.test.js b/test/link-relay.test.js
nnew file mode 100644
iindex 0000000..9e6d04d
---- /dev/null
++++ b/test/link-relay.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ let nowMs = Date.now();
++ const t = await boot({ now: () => nowMs });
++ try {
++ const sam = t.asUser('sam');
++ const demo = await t.ctx.repos.create({ actor: sam, name: 'demo', description: 'A demo.', starter: 'empty', visibility: 'public' });
++ const other = t.asUser('other');
++ const secret = await t.ctx.repos.create({ actor: other, name: 'secret', description: 'Private.', starter: 'empty', visibility: 'private' });
++
++ // ── Clone to my computer ────────────────────────────
++ const made = await t.get('/api/v1/clone-requests', { as: sam, json: { repo: 'sam/demo' } });
++ assert.equal(made.status, 201);
++ const request = made.json().request;
++ const link = made.json().link;
++ assert.match(request.id, /^clr_[0-9A-Z]{26}$/);
++ assert.equal(request.state, 'pending');
++ assert.ok(Date.parse(request.expires_at) - Date.parse(request.created_at) === 120000);
++ assert.match(link.command, /openvibe-link clone '/);
++
++ // Signed out: refused.
++ const anon = await t.get('/api/v1/clone-requests', { json: { repo: 'sam/demo' } });
++ assert.equal(anon.status, 401);
++ // A private repository somebody else owns: 404, its existence is not shown.
++ const denied = await t.get('/api/v1/clone-requests', { as: sam, json: { repo: 'other/secret' } });
++ assert.equal(denied.status, 404);
++
++ // The page's copy has no clone URL; the Link's has one.
++ const pageCopy = await t.get(`/api/v1/clone-requests/${request.id}`, { as: sam });
++ assert.equal(pageCopy.status, 200);
++ assert.equal(pageCopy.json().request.clone_url, undefined);
++ const token = new URL(link.url).searchParams.get('token');
++ const linkURL = link.url;
++ const linkCopy = await t.get(linkURL.replace(t.base, ''));
++ assert.equal(linkCopy.status, 200);
++ assert.match(linkCopy.json().clone_url, /^https:\/\/x-access-token:git_/);
++
++ // Link answers, reports progress and finishes; the page sees each one.
++ const approved = await fetch(linkURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'approved' }) });
++ assert.equal(approved.status, 200);
++ const progress = await fetch(linkURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'cloning', progress: { percent: 42, line: 'Receiving objects: 42%' } }) });
++ assert.equal(progress.status, 200);
++ const done = await fetch(linkURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'done', path: '/home/sam/OpenVibe/demo' }) });
++ assert.equal(done.status, 200);
++ const final = await t.get(`/api/v1/clone-requests/${request.id}`, { as: sam });
++ assert.equal(final.json().request.state, 'done');
++ assert.equal(final.json().request.path, '/home/sam/OpenVibe/demo');
++ const after = await fetch(linkURL);
++ assert.equal(after.status, 200);
++ const afterBody = await after.json();
++ assert.equal(afterBody.clone_url, undefined, 'the read token is not given back after the clone');
++ assert.equal(afterBody.state, 'done');
++
++ // A token that is not the request's is refused without saying which part was wrong.
++ const wrong = await fetch(linkURL.replace(token, 'x'.repeat(43)));
++ assert.equal(wrong.status, 404);
++
++ // ── Two minutes, then the request is over ───────────
++ const made2 = await t.get('/api/v1/clone-requests', { as: sam, json: { repo: 'sam/demo' } });
++ const link2 = made2.json().link;
++ nowMs += 3 * 60_000;
++ const expired = await t.get(`/api/v1/clone-requests/${made2.json().request.id}`, { as: sam });
++ assert.equal(expired.json().request.state, 'expired');
++ const expiredLink = await fetch(link2.url);
++ const expiredBody = await expiredLink.json();
++ assert.equal(expiredBody.state, 'expired');
++ assert.equal(expiredBody.clone_url, undefined);
++
++ // ── A published machine, read-only ──────────────────
++ nowMs = Date.now();
++ const madeMachine = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-laptop' } });
++ assert.equal(madeMachine.status, 201);
++ const machine = madeMachine.json().machine;
++ const machineURL = madeMachine.json().link.url;
++ const machineToken = new URL(machineURL).searchParams.get('token');
++ const helloURL = new URL(machineURL); helloURL.pathname += '/hello';
++ const hello = await fetch(helloURL, {
++ method: 'POST', headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ name: 'sam-laptop', os: 'linux', repos: [{ name: 'demo', branch: 'main', head: 'a'.repeat(40), head_subject: 'First', updated_at: new Date(nowMs).toISOString() }] }),
++ });
++ assert.equal(hello.status, 200);
++ const listed = await t.get('/api/v1/machines', { as: sam });
++ const mine = listed.json().machines.find((m) => m.id === machine.id);
++ assert.equal(mine.online, true);
++ assert.equal(mine.repos[0].name, 'demo');
++
++ // Git asks over the watch the machine opened; the machine answers; the page gets the tree.
++ const pending = t.ctx.relay.browse({ machineId: machine.id, kind: 'tree', repo: 'demo', ref: 'main', path: '' });
++ const watch = await t.ctx.relay.machineWatch({ id: machine.id, token: machineToken });
++ assert.equal(watch.request.kind, 'tree');
++ await t.ctx.relay.machineResult({
++ id: machine.id, token: machineToken,
++ body: { request_id: watch.request.id, ok: true, result: { ref: 'main', path: '', entries: [{ name: 'README.md', path: 'README.md', type: 'blob', size: 6 }] } },
++ });
++ const answer = await pending;
++ assert.equal(answer.state, 'ok');
++ assert.equal(answer.result.entries[0].name, 'README.md');
++
++ // The page browses it while the machine answers on the poll.
++ const page = t.get(`/machines/${machine.id}/demo`, { as: sam });
++ const answered = (async () => {
++ await new Promise((r) => setTimeout(r, 50));
++ const w = await t.ctx.relay.machineWatch({ id: machine.id, token: machineToken });
++ await t.ctx.relay.machineResult({
++ id: machine.id, token: machineToken,
++ body: { request_id: w.request.id, ok: true, result: { ref: 'main', path: '', entries: [{ name: 'src', path: 'src', type: 'tree' }] } },
++ });
++ })();
++ const rendered = await page;
++ await answered;
++ assert.equal(rendered.status, 200);
++ assert.match(rendered.text, /read-only through Link/);
++ assert.match(rendered.text, /src/);
++
++ // Offline: the page says since when, and never tries to write.
++ nowMs += 60_000;
++ const offline = await t.get(`/machines/${machine.id}/demo`, { as: sam });
++ assert.equal(offline.status, 200);
++ assert.match(offline.text, /offline since/);
++ const offList = await t.get('/api/v1/machines', { as: sam });
++ assert.equal(offList.json().machines.find((m) => m.id === machine.id).online, false);
++
++ // Somebody else's machine is not listed and not browsable.
++ const otherView = await t.get('/api/v1/machines', { as: other });
++ assert.equal(otherView.json().machines.some((m) => m.id === machine.id), false);
++ const notMine = await t.get(`/machines/${machine.id}/demo`, { as: other });
++ assert.equal(notMine.status, 404);
++
++ // The page's "Clone to my computer" button is a plain POST and lands on the progress line.
++ const pageForm = await t.get('/sam/demo', { as: sam });
++ assert.match(pageForm.text, /Clone to my computer/);
++ assert.match(pageForm.text, /action="\/sam\/demo\/clone"/);
++
++ process.stdout.write('link-relay: ok\n');
++ } finally {
++ await t.close();
++ }
++})().catch((err) => { process.stderr.write(`${err && err.stack || err}\n`); process.exit(1); });