openvibers/openvibe-git Commits
Link relay: clone to my computer, and a published machine (#18)
Changes
@@@ -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.0nnew file mode 100644@@@ -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);@@@ -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;@@@ -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 }));nnew file mode 100644@@@ -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 };@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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>nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -73,6 +73,24 @@ A service never holds a person's password.
@@ -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.
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 -->
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-contracts: v0.384.0
- openvibe-sdk: v0.133.0
- openvibe-sdk: v0.133.0
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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);
@@ -45,6 +45,30 @@
@@ -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) => {
document.addEventListener('click', (event) => {
const toggle = event.target.closest('[data-git-diff-toggle]');
const toggle = event.target.closest('[data-git-diff-toggle]');
if (!toggle) return;
if (!toggle) return;
@@ -35,6 +35,8 @@ const { createPageRoutes } = require('./http/pages');
@@ -35,6 +35,8 @@ const { createPageRoutes } = require('./http/pages');
const { createReviewRoutes } = require('./http/review');
const { createReviewRoutes } = require('./http/review');
const { createReviews } = require('./reviews');
const { createReviews } = require('./reviews');
const { createReviewServices } = require('./review-services');
const { createReviewServices } = require('./review-services');
+const { createLinkRelay } = require('./link-relay');
+const { createLinkRoutes } = require('./http/link');
const { setRelease } = require('./render/layout');
const { setRelease } = require('./render/layout');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -59,6 +61,7 @@ function createApp(opts = {}) {
@@ -59,6 +61,7 @@ function createApp(opts = {}) {
secureCookies: config.cookies.secure, fetch: fetchImpl, log,
secureCookies: config.cookies.secure, fetch: fetchImpl, log,
});
});
const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, smartHttp, sso, 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));
ctx.reviews = createReviews(ctx, opts.reviewServices || createReviewServices(config, fetchImpl));
@@ -154,6 +157,7 @@ function createApp(opts = {}) {
@@ -154,6 +157,7 @@ function createApp(opts = {}) {
windowMs: 60_000, limit: config.apiRateLimitPerMinute, standardHeaders: true, legacyHeaders: false,
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 }),
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', createReviewRoutes(ctx, true));
app.use('/api/v1', createApi(ctx));
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 }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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 };
@@ -21,13 +21,14 @@ const express = require('express');
@@ -21,13 +21,14 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views');
const views = require('../views');
+const machineViews = require('../views/machine');
const { viewerOf } = require('./identity');
const { viewerOf } = require('./identity');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const { renderMarkdown } = require('../render/markdown');
const { renderMarkdown } = require('../render/markdown');
const { validRef, validPath } = require('../git/refs');
const { validRef, validPath } = require('../git/refs');
function createPageRoutes(ctx) {
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();
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '256kb' }));
r.use(express.urlencoded({ extended: false, limit: '256kb' }));
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
@@ -151,6 +152,90 @@ function createPageRoutes(ctx) {
@@ -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.` });
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 ────────────────────────────────────────
// ── A repository ────────────────────────────────────────
r.get('/:owner/:name', h(async (req, res) => {
r.get('/:owner/:name', h(async (req, res) => {
const row = await rowFor(req, res);
const row = await rowFor(req, res);
@@ -162,6 +247,18 @@ function createPageRoutes(ctx) {
@@ -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));
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) => {
r.get('/:owner/:name/tree', h(async (req, res) => {
const row = await rowFor(req, res);
const row = await rowFor(req, res);
if (!row) return undefined;
if (!row) return undefined;
new file mode 100644
new file mode 100644
@@ -0,0 +1,289 @@
@@ -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 };
@@ -34,6 +34,7 @@ function home({ viewer, stats, trending = [], activity = [], official = [], base
@@ -34,6 +34,7 @@ function home({ viewer, stats, trending = [], activity = [], official = [], base
<div class="git-actions">
<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--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>
<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>
</div>
<div class="git-hero-motif" role="img" aria-label="A repository, its branch, and a clone to a computer">
<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 }) {
@@ -152,11 +153,11 @@ function repoHead({ row, viewer, starred, watching, forkOf }) {
</div>
</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 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>
</div>
- ${getIt(row)}
+ ${getIt(row, signedIn)}
</header>`;
</header>`;
}
}
-function getIt(row) {
+function getIt(row, signedIn) {
const clone = `${row.owner_username}/${row.name}.git`;
const clone = `${row.owner_username}/${row.name}.git`;
const full = `${row.owner_username}/${row.name}`;
const full = `${row.owner_username}/${row.name}`;
return `<details class="git-get" id="get">
return `<details class="git-get" id="get">
@@ -171,8 +172,10 @@ function getIt(row) {
@@ -171,8 +172,10 @@ function getIt(row) {
</div>
</div>
<div class="git-get-choice">
<div class="git-get-choice">
<b>Clone to my computer</b>
<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>
<div class="git-get-choice">
<div class="git-get-choice">
<b>Open in Codes</b>
<b>Open in Codes</b>
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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); });