openvibers/openvibe-git Commits
Clone to one of my computers from the repository page (#20)
Changes
@@@ -82,14 +82,22 @@ G3 adds Link's outbound relay (contracts `git.clone-request@1` and `git.machine@HTTPS with the one-time token, asks the person there, clones into a folder inside the root they chose, and postsapproved / progress / done / refused / failed back; the page's progress line polls the same row. The clone URL'sread token reaches only the Link, is kept in this process, and is revoked when the request ends.++- **Clone to one of my computers:** the Get it panel lists the person's own Link computers, one plain button each.++ The button makes a `clr_…` request addressed to that computer and sends it down the watch the computer already++ holds (`kind: clone`, the repository, the https clone URL with a short read token, who asked, and the end of the two++ minutes). The computer answers `waiting` (it is asking its owner), `approved`, `cloning` with git's percentage,++ then `done` with the folder, or `refused` / `failed`; the panel's progress line follows the same row. Nothing is++ written until the person answers there, and only the computer the request went to can move it. With no computer++ online the panel says so and keeps the terminal-command link above.- **Your computers** (`/machines`) makes an `mch_…` link for `openvibe-link git connect '<link>'`. Link publishesthe 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.++ **offline since …**; the only write it ever does is a clone the person approved there.--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`.++The routes are `POST /api/v1/clone-requests`, `GET /api/v1/clone-requests/:id`,++`POST /api/v1/repos/:owner/:name/clone-to-machine` and `GET …/clone-to-machine/:id`, `POST|GET /api/v1/machines`, and++the Link's own `/api/v1/link/clone-requests/:id`, `/api/v1/link/machines/:id/{hello,watch,results}` and++`POST /api/v1/link/machines/:id/clone-requests/:clr`. Tests: `test/link-relay.test.js`, `test/clone-to-machine.test.js`.<!-- versions:start -->- openvibe-contracts: v0.384.0nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- G3, step 3: a "clone to my computer" request can be addressed to one of the person's own Link computers, so the++-- page knows where the request went and the computer's own approval is the only way it starts. Two steps come++-- before the clone: queued (on its way over the machine's watch) and waiting (the computer has it and is asking).++++ALTER TABLE git_clone_requests ADD COLUMN machine_id text;++ALTER TABLE git_clone_requests DROP CONSTRAINT IF EXISTS git_clone_requests_state_check;++ALTER TABLE git_clone_requests ADD CONSTRAINT git_clone_requests_state_check++ CHECK (state IN ('queued', 'waiting', 'pending', 'approved', 'cloning', 'done', 'refused', 'failed', 'expired'));++CREATE INDEX git_clone_requests_machine ON git_clone_requests (machine_id) WHERE machine_id IS NOT NULL;@@@ -50,10 +50,11 @@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 endpoint = line.getAttribute('data-clone-endpoint') || `/api/v1/clone-requests/${encodeURIComponent(id)}`;++ const words = { queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…', 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' });++ const res = await fetch(endpoint, { credentials: 'same-origin' });if (!res.ok) return;const data = await res.json();const req = data.request || {};@@@ -58,6 +58,37 @@ function createLinkRoutes(ctx) {return res.set('Cache-Control', 'no-store').json({ request });}));++ // ── Clone to one of my computers ────────────────────────++ // The request rides the watch that computer already holds; only the computer it was addressed to can report on it.++ r.post('/repos/:owner/:name/clone-to-machine', h(async (req, res) => {++ const me = requirePerson(req, res);++ if (!me) return undefined;++ const row = await repos.getRow(String(req.params.owner || '').toLowerCase(), String(req.params.name || '').toLowerCase());++ if (!row || !repos.canView(row, me)) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');++ try {++ const request = await relay.createCloneToMachine({ viewer: me, row, machineId: req.body && req.body.machine });++ return res.set('Cache-Control', 'no-store').status(201).json({ request });++ } catch (err) { return fail(req, res, err); }++ }));++++ r.get('/repos/:owner/:name/clone-to-machine/: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 });++ const repo = `${String(req.params.owner || '').toLowerCase()}/${String(req.params.name || '').toLowerCase()}`;++ if (!request || request.repo !== repo) return problem(req, res, 404, 'clone.not_found', 'No such clone request.');++ return res.set('Cache-Control', 'no-store').json({ request });++ }));++++ // The computer's report on the clone it was asked to run, over its own one-time token.++ r.post('/link/machines/:id/clone-requests/:cloneId', h(async (req, res) => {++ const request = await relay.machineCloneUpdate({++ machineId: String(req.params.id), token: linkToken(req), id: String(req.params.cloneId), body: req.body || {},++ });++ if (!request) return problem(req, res, 404, 'clone.not_found', 'No such clone request for this computer.');++ 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) });@@@ -65,14 +65,15 @@ function createPageRoutes(ctx) {async function repoContext(req, row) {const me = viewer(req);-- const [starred, watching, parent] = await Promise.all([++ const [starred, watching, parent, machines] = await Promise.all([repos.isStarred(row, me && me.subject),repos.isWatching(row, me && me.subject),row.fork_of ? repos.byId(row.fork_of) : Promise.resolve(null),++ me && me.subject ? relay.listMachines({ subject: me.subject }) : Promise.resolve(null),]);const start = { starred, watching, forkOf: parent ? `${parent.owner_username}/${parent.name}` : null, isOwner: repos.isOwner(row, me) };const card = row.github_full_name ? await github.card(row.github_full_name, { branch: row.default_branch }) : null;-- return { ...start, card, languages: row.languages };++ return { ...start, card, languages: row.languages, machines };}async function readReadme(row, ref) {@@@ -244,7 +245,10 @@ function createPageRoutes(ctx) {const ref = row.default_branch || 'main';const [tree, context] = await Promise.all([git.readTree(row.owner_username, row.name, ref, '').catch(() => null), repoContext(req, row)]);const readme = tree ? await readReadme(row, ref) : null;-- return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, ...context }), meta(req, row));++ const cloneRequest = req.query.clone && signedIn(req)++ ? await relay.getForPage({ id: String(req.query.clone), subject: viewer(req).subject })++ : null;++ return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, cloneRequest, ...context }), meta(req, row));}));r.post('/:owner/:name/clone', h(async (req, res) => {@@@ -259,6 +263,29 @@ function createPageRoutes(ctx) {}}));++ // Clone to one of the person's own Link computers, from a plain button in the Get it panel. The request rides the++ // watch that computer already holds; this goes back to the repository with its progress line open.++ r.post('/:owner/:name/clone-to-machine', h(async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await rowFor(req, res);++ if (!row) return undefined;++ try {++ const request = await relay.createCloneToMachine({ viewer: viewer(req), row, machineId: req.body.machine });++ return res.redirect(303, `/${row.owner_username}/${row.name}?clone=${encodeURIComponent(request.id)}#get`);++ } 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/clone-to-machine/:id', h(async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, loginTo(req.originalUrl));++ const row = await rowFor(req, res);++ if (!row) return undefined;++ const request = await relay.getForPage({ id: String(req.params.id), subject: viewer(req).subject });++ if (!request || request.repo !== `${row.owner_username}/${row.name}`) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });++ return page(req, res, machineViews.cloneToMachinePage({ row, request }), { title: `Clone ${row.owner_username}/${row.name} to your computer`, index: false });++ }));++r.get('/:owner/:name/tree', h(async (req, res) => {const row = await rowFor(req, res);if (!row) return undefined;@@@ -21,6 +21,9 @@ const OFFLINE_AFTER_MS = 30_000;const WATCH_MS = 25_000;const BROWSE_MS = 8_000;const TERMINAL = new Set(['done', 'refused', 'failed', 'expired']);++// Before the clone runs: queued (on its way to one of the person's own computers) and waiting (that computer has++// it and is asking them). Both end after the same two minutes; a clone already running is left to finish.++const UNSTARTED = new Set(['queued', 'waiting', 'pending', 'approved']);const newOpaque = () => crypto.randomBytes(32).toString('base64url');const newID = (prefix) => `${prefix}_${crypto.randomBytes(12).toString('hex').toUpperCase().slice(0, 26)}`;@@@ -49,13 +52,14 @@ function createLinkRelay(ctx) {...(progressOf(row) ? { progress: progressOf(row) } : {}),...(row.path ? { path: row.path } : {}),...(row.reason ? { reason: row.reason } : {}),++ ...(row.machine_id ? { machine_id: row.machine_id } : {}),});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]);++ await store.execute("UPDATE git_clone_requests SET state = 'expired', updated_at = now() WHERE id = ? AND state IN ('queued', 'waiting', '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(() => {});@@@ -67,7 +71,7 @@ function createLinkRelay(ctx) {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);++ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);return row;}@@@ -96,10 +100,97 @@ function createLinkRelay(ctx) {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 };}++ /**++ * Clone to one of the person's own Link computers. The request rides the watch that computer already holds (the++ * same poll the /machines pages browse through), so the person needs no terminal command; the computer asks its++ * owner, and until they answer nothing is written there. The clone URL's short read token travels only to that++ * machine: never to the page, never into the database.++ */++ async function createCloneToMachine({ viewer, row, machineId }) {++ 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 machineRow = await store.maybe('SELECT * FROM git_machines WHERE id = ? AND subject = ?', [String(machineId || ''), viewer.subject]);++ if (!machineRow) {++ throw Object.assign(new Error('No such computer of yours.'), { status: 404, code: 'machine.not_found' });++ }++ if (!machineDoc(machineRow).online) {++ throw Object.assign(new Error(`${machineRow.name} is not online: start Link there, then ask again.`), { status: 409, code: 'machine.offline' });++ }++ const id = store.newId('clr');++ const token = newOpaque();++ const expires = new Date(now() + CLONE_TTL_MS).toISOString();++ 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 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, machine_id, created_at, expires_at, updated_at)++ VALUES (?, ?, ?, ?, 'queued', ?, ?, now(), ?, now())`,++ [id, viewer.subject, row.id, `${row.owner_username}/${row.name}`, hashToken(token), machineRow.id, expires],++ );++ cloneURLs.set(id, { url: cloneURL, token_id: minted.id });++ const request = {++ id: newID('req'), kind: 'clone', clone_id: id, repo: `${row.owner_username}/${row.name}`,++ url: cloneURL, requested_by: viewer.username || viewer.subject, expires_at: expires,++ };++ const q = queue.get(machineRow.id) || [];++ q.push(request);++ queue.set(machineRow.id, q);++ notify(machineRow.id, request);++ return cloneDoc({++ id, repo_full: `${row.owner_username}/${row.name}`, state: 'queued', machine_id: machineRow.id,++ created_at: new Date(now()).toISOString(), expires_at: expires, progress: null, path: null, reason: null,++ });++ }++++ /**++ * The machine's own report on a clone it was asked to run, over its one-time token: waiting (it is asking its++ * owner), approved, cloning with git's percentage, done with the folder, refused or failed. Only the machine the++ * request was addressed to can move it, and only in order.++ */++ async function machineCloneUpdate({ machineId, token, id, body }) {++ const machineRow = await machineFor(machineId, token);++ if (!machineRow) return null;++ await touchMachine(machineId);++ const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);++ if (!row || row.machine_id !== machineId) return null;++ if (TERMINAL.has(row.state)) return cloneDoc(row);++ if (UNSTARTED.has(row.state) && isExpired(row)) {++ await expire(row);++ return cloneDoc({ ...row, state: 'expired' });++ }++ const state = String(body.state || '');++ const from = {++ queued: ['waiting', 'approved', 'refused', 'failed'],++ waiting: ['approved', 'cloning', 'refused', 'failed'],++ approved: ['cloning', 'done', 'failed', 'refused'],++ cloning: ['cloning', 'done', 'failed', 'refused'],++ }[row.state] || [];++ if (!from.includes(state)) return cloneDoc(row);++ const progress = (state === 'cloning' || state === 'done') && 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 });++ }++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);++ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);return cloneDoc(row);}@@@ -107,7 +198,7 @@ function createLinkRelay(ctx) {const row = await loadForLink(id, token);if (!row) return null;const doc = cloneDoc(row);-- if (row.state === 'pending' || row.state === 'approved') {++ if (UNSTARTED.has(row.state)) {const secret = cloneURLs.get(id);if (secret) doc.clone_url = secret.url;}@@@ -196,7 +287,16 @@ function createLinkRelay(ctx) {if (!row) return null;await touchMachine(id);const q = queue.get(id);-- if (q && q.length) return { online: true, request: q.shift() };++ // A clone request that waited out its two minutes is never handed over: it is over, and the page says so.++ while (q && q.length) {++ const next = q.shift();++ if (next.kind === 'clone') {++ const clone = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [next.clone_id]);++ if (!clone || TERMINAL.has(clone.state)) continue;++ if (isExpired(clone)) { await expire(clone); continue; }++ }++ return { online: true, request: next };++ }return await new Promise((resolve) => {const set = wake.get(id) || new Set();wake.set(id, set);@@@ -266,7 +366,7 @@ function createLinkRelay(ctx) {}return {-- createClone, getForPage, linkGet, linkUpdate,++ createClone, getForPage, linkGet, linkUpdate, createCloneToMachine, machineCloneUpdate,createMachine, machineHello, machineWatch, machineResult, listMachines, getMachine, browse,// For tests.CLONE_TTL_MS, OFFLINE_AFTER_MS,@@@ -134,7 +134,7 @@ function repoTabs(row, active, ref) {return `<nav class="git-tabs" aria-label="Repository">${tabs.map(([key, label, href]) => `<a class="git-tab${active === key ? ' is-on' : ''}" href="${esc(href)}${key === 'commits' && ref ? qs({ ref }) : ''}"${active === key ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('')}</nav>`;}--function repoHead({ row, viewer, starred, watching, forkOf }) {++function repoHead({ row, viewer, starred, watching, forkOf, machines = null, cloneRequest = null }) {const signedIn = viewer && viewer.kind === 'user';const base = `/${row.owner_username}/${row.name}`;const starAction = signedIn@@@ -153,14 +153,46 @@ 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, signedIn)}++ ${getIt(row, signedIn, machines, cloneRequest)}</header>`;}--function getIt(row, signedIn) {++/** One plain button per online Link computer; the offline ones say since when, and nothing is sent to them. */++function machineChoices(full, machines) {++ const online = machines.filter((m) => m.online);++ const offline = machines.filter((m) => !m.online);++ if (!online.length) {++ return `<p>No Link computer is online right now. Start Link on your computer, then ask again.</p>++ <form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--quiet" type="submit">${icon('computer')} Get a terminal command instead</button></form>++ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}`;++ }++ return `${online.map((m) => `<form method="post" action="/${esc(full)}/clone-to-machine" class="git-inline">++ <input type="hidden" name="machine" value="${esc(m.id)}">++ <button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to ${esc(m.name)}</button>++ </form>`).join('')}++ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}++ <p class="muted"><a href="/machines">Your computers</a></p>`;++}++++/** The plain words of a clone request's state; the progress line (git.js) uses the same ones. */++function cloneWords(request) {++ const words = {++ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',++ 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.',++ };++ let text = words[request.state] || request.state || '';++ if (request.progress && (request.state === 'cloning' || request.state === 'done')) text = `${request.progress.percent}% · ${request.progress.line}`;++ if (request.state === 'done' && request.path) text = `Cloned into ${request.path}`;++ if (request.reason) text = `${text} ${request.reason}`;++ return text;++}++++function getIt(row, signedIn, machines = null, cloneRequest = null) {const clone = `${row.owner_username}/${row.name}.git`;const full = `${row.owner_username}/${row.name}`;-- return `<details class="git-get" id="get">++ return `<details class="git-get" id="get"${cloneRequest ? ' open' : ''}><summary class="ovk-btn ovk-btn--primary">${icon('download')} Get it <span class="git-get-caret">${icon('down')}</span></summary><div class="git-sheet"><h2>Get this repository</h2>@@@ -173,10 +205,13 @@ function getIt(row, signedIn) {<div class="git-get-choice"><b>Clone to my computer</b><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>`}++ ${!signedIn++ ? `<a class="ovk-btn" href="/auth/login?next=${encodeURIComponent('/' + full)}">${icon('computer')} Clone to my computer</a>`++ : machines === null++ ? `<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>`++ : machineChoices(full, machines)}</div>++ ${cloneRequest ? `<p class="git-clone-line" data-clone-request="${esc(cloneRequest.id)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(cloneRequest.id)}" role="status" aria-live="polite">${esc(cloneWords(cloneRequest))}</p>` : ''}<div class="git-get-choice"><b>Open in Codes</b><p>A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.</p>@@@ -226,11 +261,11 @@ ${repoHead({ row, viewer, starred, watching, forkOf })}</div></div>`;}--function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false }) {++function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false, machines = null, cloneRequest = null }) {const base = `/${row.owner_username}/${row.name}`;const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];return `<div class="git-repo">--${repoHead({ row, viewer, starred, watching, forkOf })}++${repoHead({ row, viewer, starred, watching, forkOf, machines, cloneRequest })}<div class="git-repo-grid"><div class="git-repo-main">${repoTabs(row, 'code')}@@@ -30,6 +30,30 @@ function clonePage({ row, request, link }) {</div>`;}++/** The clone's own page: the same plain progress line the Get it panel shows, for a link opened on its own. */++function cloneToMachinePage({ row, request }) {++ 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>Your computer asks you in Link before it writes anything. The folder is inside the root you chose there, 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)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(request.id)}" role="status" aria-live="polite">${esc(words(request))}</p>++ <p class="muted"><a href="/${esc(full)}">Back to ${esc(full)}</a></p>++</div>++</div>`;++}++++function words(request) {++ const text = {++ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',++ 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.',++ }[request.state] || request.state || '';++ if (request.state === 'done' && request.path) return `Cloned into ${request.path}`;++ return request.reason ? `${text} ${request.reason}` : text;++}++function machinesPage({ viewer, machines = [], error = null }) {const name = viewer && viewer.username ? viewer.username : 'you';const rows = machines.map((m) => `<li class="git-machine">@@@ -140,4 +164,4 @@ function machineError({ machine, repo, error }) {</div>`;}--module.exports = { clonePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };++module.exports = { clonePage, cloneToMachinePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };nnew file mode 100644@@@ -0,0 +1,144 @@++'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 other = t.asUser('other');++ const demo = await t.ctx.repos.create({ actor: sam, name: 'demo', description: 'A demo.', starter: 'empty', visibility: 'public' });++ await t.ctx.repos.create({ actor: other, name: 'secret', description: 'Private.', starter: 'empty', visibility: 'private' });++++ // Two of sam's computers, and one of other's.++ const madeMine = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-laptop' } });++ const mine = madeMine.json().machine;++ const mineURL = madeMine.json().link.url;++ const mineToken = new URL(mineURL).searchParams.get('token');++ let helloURL = new URL(mineURL); helloURL.pathname += '/hello';++ await fetch(helloURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'sam-laptop', os: 'linux', repos: [] }) });++++ const madeSecond = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-tower' } });++ const second = madeSecond.json().machine;++ const secondToken = new URL(madeSecond.json().link.url).searchParams.get('token');++ const madeTheirs = await t.get('/api/v1/machines', { as: other, json: { name: 'other-laptop' } });++ const theirs = madeTheirs.json().machine;++ // A computer that never checks in stays offline in the panel's list.++ await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-desk' } });++++ // ── The request and its refusals ────────────────────++ const anon = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { json: { machine: mine.id } });++ assert.equal(anon.status, 401, 'signed out');++ const privateOne = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: sam, json: { machine: mine.id } });++ assert.equal(privateOne.status, 404, 'a repository sam may not read is 404, never 403');++ const notMyMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: theirs.id } });++ assert.equal(notMyMachine.status, 404, "another person's computer cannot be targeted");++ const noMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: 'mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA' } });++ assert.equal(noMachine.status, 404, 'an id that is nobody\'s is 404');++ const theirRepo = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: other, json: { machine: mine.id } });++ assert.equal(theirRepo.status, 404, 'other cannot address sam\'s machine even for their own repository');++++ // An offline computer: 409, nothing queued.++ const offlineMachine = madeSecond.json().machine;++ const offline = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: offlineMachine.id } });++ assert.equal(offline.status, 409);++ assert.equal(offline.json().code, 'machine.offline');++++ // ── Sent to the computer over its own watch ─────────++ const made = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });++ assert.equal(made.status, 201);++ const request = made.json().request;++ assert.match(request.id, /^clr_[0-9A-Z]{26}$/);++ assert.equal(request.state, 'queued');++ assert.equal(request.machine_id, mine.id);++ assert.equal(request.repo, 'sam/demo');++ assert.equal(request.clone_url, undefined, 'the page never sees a clone URL');++++ const watchURL = new URL(mineURL); watchURL.pathname += '/watch';++ const watch = await fetch(watchURL);++ assert.equal(watch.status, 200);++ const item = (await watch.json()).request;++ assert.equal(item.kind, 'clone');++ assert.equal(item.clone_id, request.id);++ assert.equal(item.repo, 'sam/demo');++ assert.match(item.url, /^https:\/\/x-access-token:git_/);++ assert.equal(item.requested_by, 'sam');++++ // The page sees each step the computer reports; only that computer can report it.++ const progressURL = new URL(mineURL); progressURL.pathname += `/clone-requests/${request.id}`;++ const pageState = async (as) => (await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as })).json().request;++ assert.equal((await pageState(sam)).state, 'queued');++ const elsewhere = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as: other });++ assert.equal(elsewhere.status, 404, "another person cannot read sam's request");++ const wrongPath = await t.get(`/api/v1/repos/sam/other-name/clone-to-machine/${request.id}`, { as: sam });++ assert.equal(wrongPath.status, 404);++++ const report = (state, extra = {}) => fetch(progressURL, {++ method: 'POST', headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ state, ...extra }),++ });++ const bareURL = new URL(progressURL); bareURL.searchParams.delete('token');++ assert.equal((await fetch(bareURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'no token: 404');++ const secondURL = new URL(madeSecond.json().link.url); secondURL.pathname += `/clone-requests/${request.id}`;++ assert.equal((await fetch(secondURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'another of sam\'s computers has a valid token but is not the one the request went to');++ assert.equal((await pageState(sam)).state, 'queued');++ const theirsURL = new URL(madeTheirs.json().link.url); theirsURL.pathname += `/clone-requests/${request.id}`;++ assert.equal((await fetch(theirsURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, "another person's computer cannot move it");++++ assert.equal((await report('waiting')).status, 200);++ assert.equal((await pageState(sam)).state, 'waiting');++ assert.equal((await report('approved')).status, 200);++ assert.equal((await pageState(sam)).state, 'approved');++ assert.equal((await report('cloning', { progress: { percent: 42, line: 'Receiving objects: 42%' } })).status, 200);++ const cloning = await pageState(sam);++ assert.equal(cloning.state, 'cloning');++ assert.equal(cloning.progress.percent, 42);++ assert.equal((await report('done', { path: '/home/sam/OpenVibe/demo', progress: { percent: 100, line: 'Cloned into demo' } })).status, 200);++ const done = await pageState(sam);++ assert.equal(done.state, 'done');++ assert.equal(done.path, '/home/sam/OpenVibe/demo');++ assert.equal((await report('failed', { reason: 'late' })).status, 200, 'a terminal request is not moved again');++ assert.equal((await pageState(sam)).state, 'done');++++ // ── The panel ───────────────────────────────────────++ const repoPage = await t.get('/sam/demo', { as: sam });++ assert.equal(repoPage.status, 200);++ assert.match(repoPage.text, /Clone to sam-laptop/);++ assert.match(repoPage.text, /action="\/sam\/demo\/clone-to-machine"/);++ assert.match(repoPage.text, /sam-desk is offline since/, 'an offline computer is named, not offered');++ const noMachines = await t.get('/sam/demo', { as: t.asUser('nobody') });++ assert.match(noMachines.text, /No Link computer is online right now/, 'with none online the panel says so…');++ assert.match(noMachines.text, /action="\/sam\/demo\/clone"/, '…and keeps the existing link');++ const withLine = await t.get(`/sam/demo?clone=${request.id}`, { as: sam });++ assert.match(withLine.text, /data-clone-endpoint="\/api\/v1\/repos\/sam\/demo\/clone-to-machine\//);++ assert.match(withLine.text, /Cloned into \/home\/sam\/OpenVibe\/demo/);++++ // The plain button goes back to the repository with its progress line.++ const formPost = await t.get('/sam/demo/clone-to-machine', { as: sam, form: { machine: mine.id } });++ assert.equal(formPost.status, 303);++ assert.match(formPost.headers.get('location'), /^\/sam\/demo\?clone=clr_[0-9A-Z]{26}#get$/);++ const progressPage = await t.get(formPost.headers.get('location').split('#')[0], { as: sam });++ assert.equal(progressPage.status, 200);++ assert.match(progressPage.text, /data-clone-request=/);++++ // ── Two minutes, then it is over ────────────────────++ const late = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });++ const lateRequest = late.json().request;++ nowMs += 3 * 60_000;++ const expiredPage = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${lateRequest.id}`, { as: sam });++ assert.equal(expiredPage.json().request.state, 'expired');++ const lateURL = new URL(mineURL); lateURL.pathname += `/clone-requests/${lateRequest.id}`;++ const lateReport = await fetch(lateURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) });++ assert.equal(lateReport.status, 200);++ assert.equal((await lateReport.json()).request.state, 'expired', 'an expired request is refused');++ // …and the computer is not online any more after a gap that long.++ const stale = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });++ assert.equal(stale.status, 409);++ assert.equal(stale.json().code, 'machine.offline');++++ process.stdout.write('clone-to-machine: ok\n');++ } finally {++ await t.close();++ }++})().catch((err) => { process.stderr.write(`${err && err.stack || err}\n`); process.exit(1); });
@@ -82,14 +82,22 @@ G3 adds Link's outbound relay (contracts `git.clone-request@1` and `git.machine@
@@ -82,14 +82,22 @@ G3 adds Link's outbound relay (contracts `git.clone-request@1` and `git.machine@
HTTPS with the one-time token, asks the person there, clones into a folder inside the root they chose, and posts
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
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.
read token reaches only the Link, is kept in this process, and is revoked when the request ends.
+- **Clone to one of my computers:** the Get it panel lists the person's own Link computers, one plain button each.
+ The button makes a `clr_…` request addressed to that computer and sends it down the watch the computer already
+ holds (`kind: clone`, the repository, the https clone URL with a short read token, who asked, and the end of the two
+ minutes). The computer answers `waiting` (it is asking its owner), `approved`, `cloning` with git's percentage,
+ then `done` with the folder, or `refused` / `failed`; the panel's progress line follows the same row. Nothing is
+ written until the person answers there, and only the computer the request went to can move it. With no computer
+ online the panel says so and keeps the terminal-command link above.
- **Your computers** (`/machines`) makes an `mch_…` link for `openvibe-link git connect '<link>'`. Link publishes
- **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,
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
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.
+ **offline since …**; the only write it ever does is a clone the person approved there.
-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`.
+The routes are `POST /api/v1/clone-requests`, `GET /api/v1/clone-requests/:id`,
+`POST /api/v1/repos/:owner/:name/clone-to-machine` and `GET …/clone-to-machine/:id`, `POST|GET /api/v1/machines`, and
+the Link's own `/api/v1/link/clone-requests/:id`, `/api/v1/link/machines/:id/{hello,watch,results}` and
+`POST /api/v1/link/machines/:id/clone-requests/:clr`. Tests: `test/link-relay.test.js`, `test/clone-to-machine.test.js`.
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.384.0
- openvibe-contracts: v0.384.0
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- G3, step 3: a "clone to my computer" request can be addressed to one of the person's own Link computers, so the
+-- page knows where the request went and the computer's own approval is the only way it starts. Two steps come
+-- before the clone: queued (on its way over the machine's watch) and waiting (the computer has it and is asking).
+
+ALTER TABLE git_clone_requests ADD COLUMN machine_id text;
+ALTER TABLE git_clone_requests DROP CONSTRAINT IF EXISTS git_clone_requests_state_check;
+ALTER TABLE git_clone_requests ADD CONSTRAINT git_clone_requests_state_check
+ CHECK (state IN ('queued', 'waiting', 'pending', 'approved', 'cloning', 'done', 'refused', 'failed', 'expired'));
+CREATE INDEX git_clone_requests_machine ON git_clone_requests (machine_id) WHERE machine_id IS NOT NULL;
@@ -50,10 +50,11 @@
@@ -50,10 +50,11 @@
const line = document.querySelector('[data-clone-request]');
const line = document.querySelector('[data-clone-request]');
if (line) {
if (line) {
const id = line.getAttribute('data-clone-request');
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 endpoint = line.getAttribute('data-clone-endpoint') || `/api/v1/clone-requests/${encodeURIComponent(id)}`;
+ const words = { queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…', 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 () => {
const tick = async () => {
try {
try {
- const res = await fetch(`/api/v1/clone-requests/${encodeURIComponent(id)}`, { credentials: 'same-origin' });
+ const res = await fetch(endpoint, { credentials: 'same-origin' });
if (!res.ok) return;
if (!res.ok) return;
const data = await res.json();
const data = await res.json();
const req = data.request || {};
const req = data.request || {};
@@ -58,6 +58,37 @@ function createLinkRoutes(ctx) {
@@ -58,6 +58,37 @@ function createLinkRoutes(ctx) {
return res.set('Cache-Control', 'no-store').json({ request });
return res.set('Cache-Control', 'no-store').json({ request });
}));
}));
+ // ── Clone to one of my computers ────────────────────────
+ // The request rides the watch that computer already holds; only the computer it was addressed to can report on it.
+ r.post('/repos/:owner/:name/clone-to-machine', h(async (req, res) => {
+ const me = requirePerson(req, res);
+ if (!me) return undefined;
+ const row = await repos.getRow(String(req.params.owner || '').toLowerCase(), String(req.params.name || '').toLowerCase());
+ if (!row || !repos.canView(row, me)) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
+ try {
+ const request = await relay.createCloneToMachine({ viewer: me, row, machineId: req.body && req.body.machine });
+ return res.set('Cache-Control', 'no-store').status(201).json({ request });
+ } catch (err) { return fail(req, res, err); }
+ }));
+
+ r.get('/repos/:owner/:name/clone-to-machine/: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 });
+ const repo = `${String(req.params.owner || '').toLowerCase()}/${String(req.params.name || '').toLowerCase()}`;
+ if (!request || request.repo !== repo) return problem(req, res, 404, 'clone.not_found', 'No such clone request.');
+ return res.set('Cache-Control', 'no-store').json({ request });
+ }));
+
+ // The computer's report on the clone it was asked to run, over its own one-time token.
+ r.post('/link/machines/:id/clone-requests/:cloneId', h(async (req, res) => {
+ const request = await relay.machineCloneUpdate({
+ machineId: String(req.params.id), token: linkToken(req), id: String(req.params.cloneId), body: req.body || {},
+ });
+ if (!request) return problem(req, res, 404, 'clone.not_found', 'No such clone request for this computer.');
+ return res.set('Cache-Control', 'no-store').json({ request });
+ }));
+
// The Link's end of the same request.
// The Link's end of the same request.
r.get('/link/clone-requests/:id', h(async (req, res) => {
r.get('/link/clone-requests/:id', h(async (req, res) => {
const doc = await relay.linkGet({ id: String(req.params.id), token: linkToken(req) });
const doc = await relay.linkGet({ id: String(req.params.id), token: linkToken(req) });
@@ -65,14 +65,15 @@ function createPageRoutes(ctx) {
@@ -65,14 +65,15 @@ function createPageRoutes(ctx) {
async function repoContext(req, row) {
async function repoContext(req, row) {
const me = viewer(req);
const me = viewer(req);
- const [starred, watching, parent] = await Promise.all([
+ const [starred, watching, parent, machines] = await Promise.all([
repos.isStarred(row, me && me.subject),
repos.isStarred(row, me && me.subject),
repos.isWatching(row, me && me.subject),
repos.isWatching(row, me && me.subject),
row.fork_of ? repos.byId(row.fork_of) : Promise.resolve(null),
row.fork_of ? repos.byId(row.fork_of) : Promise.resolve(null),
+ me && me.subject ? relay.listMachines({ subject: me.subject }) : Promise.resolve(null),
]);
]);
const start = { starred, watching, forkOf: parent ? `${parent.owner_username}/${parent.name}` : null, isOwner: repos.isOwner(row, me) };
const start = { starred, watching, forkOf: parent ? `${parent.owner_username}/${parent.name}` : null, isOwner: repos.isOwner(row, me) };
const card = row.github_full_name ? await github.card(row.github_full_name, { branch: row.default_branch }) : null;
const card = row.github_full_name ? await github.card(row.github_full_name, { branch: row.default_branch }) : null;
- return { ...start, card, languages: row.languages };
+ return { ...start, card, languages: row.languages, machines };
}
}
async function readReadme(row, ref) {
async function readReadme(row, ref) {
@@ -244,7 +245,10 @@ function createPageRoutes(ctx) {
@@ -244,7 +245,10 @@ function createPageRoutes(ctx) {
const ref = row.default_branch || 'main';
const ref = row.default_branch || 'main';
const [tree, context] = await Promise.all([git.readTree(row.owner_username, row.name, ref, '').catch(() => null), repoContext(req, row)]);
const [tree, context] = await Promise.all([git.readTree(row.owner_username, row.name, ref, '').catch(() => null), repoContext(req, row)]);
const readme = tree ? await readReadme(row, ref) : null;
const readme = tree ? await readReadme(row, ref) : null;
- return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, ...context }), meta(req, row));
+ const cloneRequest = req.query.clone && signedIn(req)
+ ? await relay.getForPage({ id: String(req.query.clone), subject: viewer(req).subject })
+ : null;
+ return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, cloneRequest, ...context }), meta(req, row));
}));
}));
r.post('/:owner/:name/clone', h(async (req, res) => {
r.post('/:owner/:name/clone', h(async (req, res) => {
@@ -259,6 +263,29 @@ function createPageRoutes(ctx) {
@@ -259,6 +263,29 @@ function createPageRoutes(ctx) {
}
}
}));
}));
+ // Clone to one of the person's own Link computers, from a plain button in the Get it panel. The request rides the
+ // watch that computer already holds; this goes back to the repository with its progress line open.
+ r.post('/:owner/:name/clone-to-machine', h(async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await rowFor(req, res);
+ if (!row) return undefined;
+ try {
+ const request = await relay.createCloneToMachine({ viewer: viewer(req), row, machineId: req.body.machine });
+ return res.redirect(303, `/${row.owner_username}/${row.name}?clone=${encodeURIComponent(request.id)}#get`);
+ } 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/clone-to-machine/:id', h(async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, loginTo(req.originalUrl));
+ const row = await rowFor(req, res);
+ if (!row) return undefined;
+ const request = await relay.getForPage({ id: String(req.params.id), subject: viewer(req).subject });
+ if (!request || request.repo !== `${row.owner_username}/${row.name}`) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
+ return page(req, res, machineViews.cloneToMachinePage({ row, request }), { title: `Clone ${row.owner_username}/${row.name} to your computer`, index: false });
+ }));
+
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;
@@ -21,6 +21,9 @@ const OFFLINE_AFTER_MS = 30_000;
@@ -21,6 +21,9 @@ const OFFLINE_AFTER_MS = 30_000;
const WATCH_MS = 25_000;
const WATCH_MS = 25_000;
const BROWSE_MS = 8_000;
const BROWSE_MS = 8_000;
const TERMINAL = new Set(['done', 'refused', 'failed', 'expired']);
const TERMINAL = new Set(['done', 'refused', 'failed', 'expired']);
+// Before the clone runs: queued (on its way to one of the person's own computers) and waiting (that computer has
+// it and is asking them). Both end after the same two minutes; a clone already running is left to finish.
+const UNSTARTED = new Set(['queued', 'waiting', 'pending', 'approved']);
const newOpaque = () => crypto.randomBytes(32).toString('base64url');
const newOpaque = () => crypto.randomBytes(32).toString('base64url');
const newID = (prefix) => `${prefix}_${crypto.randomBytes(12).toString('hex').toUpperCase().slice(0, 26)}`;
const newID = (prefix) => `${prefix}_${crypto.randomBytes(12).toString('hex').toUpperCase().slice(0, 26)}`;
@@ -49,13 +52,14 @@ function createLinkRelay(ctx) {
@@ -49,13 +52,14 @@ function createLinkRelay(ctx) {
...(progressOf(row) ? { progress: progressOf(row) } : {}),
...(progressOf(row) ? { progress: progressOf(row) } : {}),
...(row.path ? { path: row.path } : {}),
...(row.path ? { path: row.path } : {}),
...(row.reason ? { reason: row.reason } : {}),
...(row.reason ? { reason: row.reason } : {}),
+ ...(row.machine_id ? { machine_id: row.machine_id } : {}),
});
});
const isExpired = (row) => Date.parse(row.expires_at) <= now();
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. */
/** End a request past its two minutes: the row says expired and the clone's read token is revoked. */
async function expire(row) {
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]);
+ await store.execute("UPDATE git_clone_requests SET state = 'expired', updated_at = now() WHERE id = ? AND state IN ('queued', 'waiting', 'pending', 'approved')", [row.id]);
const secret = cloneURLs.get(row.id);
const secret = cloneURLs.get(row.id);
cloneURLs.delete(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(() => {});
if (secret) await store.execute('UPDATE git_tokens SET revoked_at = now() WHERE id = ? AND revoked_at IS NULL', [secret.token_id]).catch(() => {});
@@ -67,7 +71,7 @@ function createLinkRelay(ctx) {
@@ -67,7 +71,7 @@ function createLinkRelay(ctx) {
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
if (!row) return null;
if (!row) return null;
if (!safeEqual(hashToken(token), row.token_hash)) return null;
if (!safeEqual(hashToken(token), row.token_hash)) return null;
- if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
+ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);
return row;
return row;
}
}
@@ -96,10 +100,97 @@ function createLinkRelay(ctx) {
@@ -96,10 +100,97 @@ function createLinkRelay(ctx) {
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 };
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 };
}
}
+ /**
+ * Clone to one of the person's own Link computers. The request rides the watch that computer already holds (the
+ * same poll the /machines pages browse through), so the person needs no terminal command; the computer asks its
+ * owner, and until they answer nothing is written there. The clone URL's short read token travels only to that
+ * machine: never to the page, never into the database.
+ */
+ async function createCloneToMachine({ viewer, row, machineId }) {
+ 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 machineRow = await store.maybe('SELECT * FROM git_machines WHERE id = ? AND subject = ?', [String(machineId || ''), viewer.subject]);
+ if (!machineRow) {
+ throw Object.assign(new Error('No such computer of yours.'), { status: 404, code: 'machine.not_found' });
+ }
+ if (!machineDoc(machineRow).online) {
+ throw Object.assign(new Error(`${machineRow.name} is not online: start Link there, then ask again.`), { status: 409, code: 'machine.offline' });
+ }
+ const id = store.newId('clr');
+ const token = newOpaque();
+ const expires = new Date(now() + CLONE_TTL_MS).toISOString();
+ 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 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, machine_id, created_at, expires_at, updated_at)
+ VALUES (?, ?, ?, ?, 'queued', ?, ?, now(), ?, now())`,
+ [id, viewer.subject, row.id, `${row.owner_username}/${row.name}`, hashToken(token), machineRow.id, expires],
+ );
+ cloneURLs.set(id, { url: cloneURL, token_id: minted.id });
+ const request = {
+ id: newID('req'), kind: 'clone', clone_id: id, repo: `${row.owner_username}/${row.name}`,
+ url: cloneURL, requested_by: viewer.username || viewer.subject, expires_at: expires,
+ };
+ const q = queue.get(machineRow.id) || [];
+ q.push(request);
+ queue.set(machineRow.id, q);
+ notify(machineRow.id, request);
+ return cloneDoc({
+ id, repo_full: `${row.owner_username}/${row.name}`, state: 'queued', machine_id: machineRow.id,
+ created_at: new Date(now()).toISOString(), expires_at: expires, progress: null, path: null, reason: null,
+ });
+ }
+
+ /**
+ * The machine's own report on a clone it was asked to run, over its one-time token: waiting (it is asking its
+ * owner), approved, cloning with git's percentage, done with the folder, refused or failed. Only the machine the
+ * request was addressed to can move it, and only in order.
+ */
+ async function machineCloneUpdate({ machineId, token, id, body }) {
+ const machineRow = await machineFor(machineId, token);
+ if (!machineRow) return null;
+ await touchMachine(machineId);
+ const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
+ if (!row || row.machine_id !== machineId) return null;
+ if (TERMINAL.has(row.state)) return cloneDoc(row);
+ if (UNSTARTED.has(row.state) && isExpired(row)) {
+ await expire(row);
+ return cloneDoc({ ...row, state: 'expired' });
+ }
+ const state = String(body.state || '');
+ const from = {
+ queued: ['waiting', 'approved', 'refused', 'failed'],
+ waiting: ['approved', 'cloning', 'refused', 'failed'],
+ approved: ['cloning', 'done', 'failed', 'refused'],
+ cloning: ['cloning', 'done', 'failed', 'refused'],
+ }[row.state] || [];
+ if (!from.includes(state)) return cloneDoc(row);
+ const progress = (state === 'cloning' || state === 'done') && 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 });
+ }
+
async function getForPage({ id, subject }) {
async function getForPage({ id, subject }) {
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
const row = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [id]);
if (!row || row.subject !== subject) return null;
if (!row || row.subject !== subject) return null;
- if ((row.state === 'pending' || row.state === 'approved') && isExpired(row)) await expire(row);
+ if (UNSTARTED.has(row.state) && isExpired(row)) await expire(row);
return cloneDoc(row);
return cloneDoc(row);
}
}
@@ -107,7 +198,7 @@ function createLinkRelay(ctx) {
@@ -107,7 +198,7 @@ function createLinkRelay(ctx) {
const row = await loadForLink(id, token);
const row = await loadForLink(id, token);
if (!row) return null;
if (!row) return null;
const doc = cloneDoc(row);
const doc = cloneDoc(row);
- if (row.state === 'pending' || row.state === 'approved') {
+ if (UNSTARTED.has(row.state)) {
const secret = cloneURLs.get(id);
const secret = cloneURLs.get(id);
if (secret) doc.clone_url = secret.url;
if (secret) doc.clone_url = secret.url;
}
}
@@ -196,7 +287,16 @@ function createLinkRelay(ctx) {
@@ -196,7 +287,16 @@ function createLinkRelay(ctx) {
if (!row) return null;
if (!row) return null;
await touchMachine(id);
await touchMachine(id);
const q = queue.get(id);
const q = queue.get(id);
- if (q && q.length) return { online: true, request: q.shift() };
+ // A clone request that waited out its two minutes is never handed over: it is over, and the page says so.
+ while (q && q.length) {
+ const next = q.shift();
+ if (next.kind === 'clone') {
+ const clone = await store.maybe('SELECT * FROM git_clone_requests WHERE id = ?', [next.clone_id]);
+ if (!clone || TERMINAL.has(clone.state)) continue;
+ if (isExpired(clone)) { await expire(clone); continue; }
+ }
+ return { online: true, request: next };
+ }
return await new Promise((resolve) => {
return await new Promise((resolve) => {
const set = wake.get(id) || new Set();
const set = wake.get(id) || new Set();
wake.set(id, set);
wake.set(id, set);
@@ -266,7 +366,7 @@ function createLinkRelay(ctx) {
@@ -266,7 +366,7 @@ function createLinkRelay(ctx) {
}
}
return {
return {
- createClone, getForPage, linkGet, linkUpdate,
+ createClone, getForPage, linkGet, linkUpdate, createCloneToMachine, machineCloneUpdate,
createMachine, machineHello, machineWatch, machineResult, listMachines, getMachine, browse,
createMachine, machineHello, machineWatch, machineResult, listMachines, getMachine, browse,
// For tests.
// For tests.
CLONE_TTL_MS, OFFLINE_AFTER_MS,
CLONE_TTL_MS, OFFLINE_AFTER_MS,
@@ -134,7 +134,7 @@ function repoTabs(row, active, ref) {
@@ -134,7 +134,7 @@ function repoTabs(row, active, ref) {
return `<nav class="git-tabs" aria-label="Repository">${tabs.map(([key, label, href]) => `<a class="git-tab${active === key ? ' is-on' : ''}" href="${esc(href)}${key === 'commits' && ref ? qs({ ref }) : ''}"${active === key ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('')}</nav>`;
return `<nav class="git-tabs" aria-label="Repository">${tabs.map(([key, label, href]) => `<a class="git-tab${active === key ? ' is-on' : ''}" href="${esc(href)}${key === 'commits' && ref ? qs({ ref }) : ''}"${active === key ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('')}</nav>`;
}
}
-function repoHead({ row, viewer, starred, watching, forkOf }) {
+function repoHead({ row, viewer, starred, watching, forkOf, machines = null, cloneRequest = null }) {
const signedIn = viewer && viewer.kind === 'user';
const signedIn = viewer && viewer.kind === 'user';
const base = `/${row.owner_username}/${row.name}`;
const base = `/${row.owner_username}/${row.name}`;
const starAction = signedIn
const starAction = signedIn
@@ -153,14 +153,46 @@ function repoHead({ row, viewer, starred, watching, forkOf }) {
@@ -153,14 +153,46 @@ 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, signedIn)}
+ ${getIt(row, signedIn, machines, cloneRequest)}
</header>`;
</header>`;
}
}
-function getIt(row, signedIn) {
+/** One plain button per online Link computer; the offline ones say since when, and nothing is sent to them. */
+function machineChoices(full, machines) {
+ const online = machines.filter((m) => m.online);
+ const offline = machines.filter((m) => !m.online);
+ if (!online.length) {
+ return `<p>No Link computer is online right now. Start Link on your computer, then ask again.</p>
+ <form method="post" action="/${esc(full)}/clone" class="git-inline"><button class="ovk-btn ovk-btn--quiet" type="submit">${icon('computer')} Get a terminal command instead</button></form>
+ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}`;
+ }
+ return `${online.map((m) => `<form method="post" action="/${esc(full)}/clone-to-machine" class="git-inline">
+ <input type="hidden" name="machine" value="${esc(m.id)}">
+ <button class="ovk-btn ovk-btn--primary" type="submit">${icon('computer')} Clone to ${esc(m.name)}</button>
+ </form>`).join('')}
+ ${offline.length ? `<p class="muted">${offline.map((m) => `${esc(m.name)} is offline since ${esc(ago(m.offline_since || m.last_seen_at))}`).join(' · ')}</p>` : ''}
+ <p class="muted"><a href="/machines">Your computers</a></p>`;
+}
+
+/** The plain words of a clone request's state; the progress line (git.js) uses the same ones. */
+function cloneWords(request) {
+ const words = {
+ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',
+ 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.',
+ };
+ let text = words[request.state] || request.state || '';
+ if (request.progress && (request.state === 'cloning' || request.state === 'done')) text = `${request.progress.percent}% · ${request.progress.line}`;
+ if (request.state === 'done' && request.path) text = `Cloned into ${request.path}`;
+ if (request.reason) text = `${text} ${request.reason}`;
+ return text;
+}
+
+function getIt(row, signedIn, machines = null, cloneRequest = null) {
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"${cloneRequest ? ' open' : ''}>
<summary class="ovk-btn ovk-btn--primary">${icon('download')} Get it <span class="git-get-caret">${icon('down')}</span></summary>
<summary class="ovk-btn ovk-btn--primary">${icon('download')} Get it <span class="git-get-caret">${icon('down')}</span></summary>
<div class="git-sheet">
<div class="git-sheet">
<h2>Get this repository</h2>
<h2>Get this repository</h2>
@@ -173,10 +205,13 @@ function getIt(row, signedIn) {
@@ -173,10 +205,13 @@ function getIt(row, signedIn) {
<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 asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.</p>
<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>`}
+ ${!signedIn
+ ? `<a class="ovk-btn" href="/auth/login?next=${encodeURIComponent('/' + full)}">${icon('computer')} Clone to my computer</a>`
+ : machines === null
+ ? `<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>`
+ : machineChoices(full, machines)}
</div>
</div>
+ ${cloneRequest ? `<p class="git-clone-line" data-clone-request="${esc(cloneRequest.id)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(cloneRequest.id)}" role="status" aria-live="polite">${esc(cloneWords(cloneRequest))}</p>` : ''}
<div class="git-get-choice">
<div class="git-get-choice">
<b>Open in Codes</b>
<b>Open in Codes</b>
<p>A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.</p>
<p>A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.</p>
@@ -226,11 +261,11 @@ ${repoHead({ row, viewer, starred, watching, forkOf })}
@@ -226,11 +261,11 @@ ${repoHead({ row, viewer, starred, watching, forkOf })}
</div></div>`;
</div></div>`;
}
}
-function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false }) {
+function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false, machines = null, cloneRequest = null }) {
const base = `/${row.owner_username}/${row.name}`;
const base = `/${row.owner_username}/${row.name}`;
const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
return `<div class="git-repo">
return `<div class="git-repo">
-${repoHead({ row, viewer, starred, watching, forkOf })}
+${repoHead({ row, viewer, starred, watching, forkOf, machines, cloneRequest })}
<div class="git-repo-grid">
<div class="git-repo-grid">
<div class="git-repo-main">
<div class="git-repo-main">
${repoTabs(row, 'code')}
${repoTabs(row, 'code')}
@@ -30,6 +30,30 @@ function clonePage({ row, request, link }) {
@@ -30,6 +30,30 @@ function clonePage({ row, request, link }) {
</div>`;
</div>`;
}
}
+/** The clone's own page: the same plain progress line the Get it panel shows, for a link opened on its own. */
+function cloneToMachinePage({ row, request }) {
+ 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>Your computer asks you in Link before it writes anything. The folder is inside the root you chose there, 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)}" data-clone-endpoint="/api/v1/repos/${esc(full)}/clone-to-machine/${esc(request.id)}" role="status" aria-live="polite">${esc(words(request))}</p>
+ <p class="muted"><a href="/${esc(full)}">Back to ${esc(full)}</a></p>
+</div>
+</div>`;
+}
+
+function words(request) {
+ const text = {
+ queued: 'Sent to your computer…', waiting: 'Waiting for your answer in Link…',
+ 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.',
+ }[request.state] || request.state || '';
+ if (request.state === 'done' && request.path) return `Cloned into ${request.path}`;
+ return request.reason ? `${text} ${request.reason}` : text;
+}
+
function machinesPage({ viewer, machines = [], error = null }) {
function machinesPage({ viewer, machines = [], error = null }) {
const name = viewer && viewer.username ? viewer.username : 'you';
const name = viewer && viewer.username ? viewer.username : 'you';
const rows = machines.map((m) => `<li class="git-machine">
const rows = machines.map((m) => `<li class="git-machine">
@@ -140,4 +164,4 @@ function machineError({ machine, repo, error }) {
@@ -140,4 +164,4 @@ function machineError({ machine, repo, error }) {
</div>`;
</div>`;
}
}
-module.exports = { clonePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };
+module.exports = { clonePage, cloneToMachinePage, machinesPage, machineConnectPage, machinePage, machineRepoPage, machineFilePage, machineCommitsPage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'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 other = t.asUser('other');
+ const demo = await t.ctx.repos.create({ actor: sam, name: 'demo', description: 'A demo.', starter: 'empty', visibility: 'public' });
+ await t.ctx.repos.create({ actor: other, name: 'secret', description: 'Private.', starter: 'empty', visibility: 'private' });
+
+ // Two of sam's computers, and one of other's.
+ const madeMine = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-laptop' } });
+ const mine = madeMine.json().machine;
+ const mineURL = madeMine.json().link.url;
+ const mineToken = new URL(mineURL).searchParams.get('token');
+ let helloURL = new URL(mineURL); helloURL.pathname += '/hello';
+ await fetch(helloURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'sam-laptop', os: 'linux', repos: [] }) });
+
+ const madeSecond = await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-tower' } });
+ const second = madeSecond.json().machine;
+ const secondToken = new URL(madeSecond.json().link.url).searchParams.get('token');
+ const madeTheirs = await t.get('/api/v1/machines', { as: other, json: { name: 'other-laptop' } });
+ const theirs = madeTheirs.json().machine;
+ // A computer that never checks in stays offline in the panel's list.
+ await t.get('/api/v1/machines', { as: sam, json: { name: 'sam-desk' } });
+
+ // ── The request and its refusals ────────────────────
+ const anon = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { json: { machine: mine.id } });
+ assert.equal(anon.status, 401, 'signed out');
+ const privateOne = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: sam, json: { machine: mine.id } });
+ assert.equal(privateOne.status, 404, 'a repository sam may not read is 404, never 403');
+ const notMyMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: theirs.id } });
+ assert.equal(notMyMachine.status, 404, "another person's computer cannot be targeted");
+ const noMachine = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: 'mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA' } });
+ assert.equal(noMachine.status, 404, 'an id that is nobody\'s is 404');
+ const theirRepo = await t.get('/api/v1/repos/other/secret/clone-to-machine', { as: other, json: { machine: mine.id } });
+ assert.equal(theirRepo.status, 404, 'other cannot address sam\'s machine even for their own repository');
+
+ // An offline computer: 409, nothing queued.
+ const offlineMachine = madeSecond.json().machine;
+ const offline = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: offlineMachine.id } });
+ assert.equal(offline.status, 409);
+ assert.equal(offline.json().code, 'machine.offline');
+
+ // ── Sent to the computer over its own watch ─────────
+ const made = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
+ assert.equal(made.status, 201);
+ const request = made.json().request;
+ assert.match(request.id, /^clr_[0-9A-Z]{26}$/);
+ assert.equal(request.state, 'queued');
+ assert.equal(request.machine_id, mine.id);
+ assert.equal(request.repo, 'sam/demo');
+ assert.equal(request.clone_url, undefined, 'the page never sees a clone URL');
+
+ const watchURL = new URL(mineURL); watchURL.pathname += '/watch';
+ const watch = await fetch(watchURL);
+ assert.equal(watch.status, 200);
+ const item = (await watch.json()).request;
+ assert.equal(item.kind, 'clone');
+ assert.equal(item.clone_id, request.id);
+ assert.equal(item.repo, 'sam/demo');
+ assert.match(item.url, /^https:\/\/x-access-token:git_/);
+ assert.equal(item.requested_by, 'sam');
+
+ // The page sees each step the computer reports; only that computer can report it.
+ const progressURL = new URL(mineURL); progressURL.pathname += `/clone-requests/${request.id}`;
+ const pageState = async (as) => (await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as })).json().request;
+ assert.equal((await pageState(sam)).state, 'queued');
+ const elsewhere = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${request.id}`, { as: other });
+ assert.equal(elsewhere.status, 404, "another person cannot read sam's request");
+ const wrongPath = await t.get(`/api/v1/repos/sam/other-name/clone-to-machine/${request.id}`, { as: sam });
+ assert.equal(wrongPath.status, 404);
+
+ const report = (state, extra = {}) => fetch(progressURL, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ state, ...extra }),
+ });
+ const bareURL = new URL(progressURL); bareURL.searchParams.delete('token');
+ assert.equal((await fetch(bareURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'no token: 404');
+ const secondURL = new URL(madeSecond.json().link.url); secondURL.pathname += `/clone-requests/${request.id}`;
+ assert.equal((await fetch(secondURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, 'another of sam\'s computers has a valid token but is not the one the request went to');
+ assert.equal((await pageState(sam)).state, 'queued');
+ const theirsURL = new URL(madeTheirs.json().link.url); theirsURL.pathname += `/clone-requests/${request.id}`;
+ assert.equal((await fetch(theirsURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) })).status, 404, "another person's computer cannot move it");
+
+ assert.equal((await report('waiting')).status, 200);
+ assert.equal((await pageState(sam)).state, 'waiting');
+ assert.equal((await report('approved')).status, 200);
+ assert.equal((await pageState(sam)).state, 'approved');
+ assert.equal((await report('cloning', { progress: { percent: 42, line: 'Receiving objects: 42%' } })).status, 200);
+ const cloning = await pageState(sam);
+ assert.equal(cloning.state, 'cloning');
+ assert.equal(cloning.progress.percent, 42);
+ assert.equal((await report('done', { path: '/home/sam/OpenVibe/demo', progress: { percent: 100, line: 'Cloned into demo' } })).status, 200);
+ const done = await pageState(sam);
+ assert.equal(done.state, 'done');
+ assert.equal(done.path, '/home/sam/OpenVibe/demo');
+ assert.equal((await report('failed', { reason: 'late' })).status, 200, 'a terminal request is not moved again');
+ assert.equal((await pageState(sam)).state, 'done');
+
+ // ── The panel ───────────────────────────────────────
+ const repoPage = await t.get('/sam/demo', { as: sam });
+ assert.equal(repoPage.status, 200);
+ assert.match(repoPage.text, /Clone to sam-laptop/);
+ assert.match(repoPage.text, /action="\/sam\/demo\/clone-to-machine"/);
+ assert.match(repoPage.text, /sam-desk is offline since/, 'an offline computer is named, not offered');
+ const noMachines = await t.get('/sam/demo', { as: t.asUser('nobody') });
+ assert.match(noMachines.text, /No Link computer is online right now/, 'with none online the panel says so…');
+ assert.match(noMachines.text, /action="\/sam\/demo\/clone"/, '…and keeps the existing link');
+ const withLine = await t.get(`/sam/demo?clone=${request.id}`, { as: sam });
+ assert.match(withLine.text, /data-clone-endpoint="\/api\/v1\/repos\/sam\/demo\/clone-to-machine\//);
+ assert.match(withLine.text, /Cloned into \/home\/sam\/OpenVibe\/demo/);
+
+ // The plain button goes back to the repository with its progress line.
+ const formPost = await t.get('/sam/demo/clone-to-machine', { as: sam, form: { machine: mine.id } });
+ assert.equal(formPost.status, 303);
+ assert.match(formPost.headers.get('location'), /^\/sam\/demo\?clone=clr_[0-9A-Z]{26}#get$/);
+ const progressPage = await t.get(formPost.headers.get('location').split('#')[0], { as: sam });
+ assert.equal(progressPage.status, 200);
+ assert.match(progressPage.text, /data-clone-request=/);
+
+ // ── Two minutes, then it is over ────────────────────
+ const late = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
+ const lateRequest = late.json().request;
+ nowMs += 3 * 60_000;
+ const expiredPage = await t.get(`/api/v1/repos/sam/demo/clone-to-machine/${lateRequest.id}`, { as: sam });
+ assert.equal(expiredPage.json().request.state, 'expired');
+ const lateURL = new URL(mineURL); lateURL.pathname += `/clone-requests/${lateRequest.id}`;
+ const lateReport = await fetch(lateURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'waiting' }) });
+ assert.equal(lateReport.status, 200);
+ assert.equal((await lateReport.json()).request.state, 'expired', 'an expired request is refused');
+ // …and the computer is not online any more after a gap that long.
+ const stale = await t.get('/api/v1/repos/sam/demo/clone-to-machine', { as: sam, json: { machine: mine.id } });
+ assert.equal(stale.status, 409);
+ assert.equal(stale.json().code, 'machine.offline');
+
+ process.stdout.write('clone-to-machine: ok\n');
+ } finally {
+ await t.close();
+ }
+})().catch((err) => { process.stderr.write(`${err && err.stack || err}\n`); process.exit(1); });