device-center 0.2.0-beta.5 → 0.2.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ import { defaultLinkFile, readLink } from './cloudLink.mjs';
2
+ import { machinePost } from './remote.mjs';
3
+
4
+ export function cliError(code, message, statusCode = 400) {
5
+ return Object.assign(new Error(message), { cliCode: code, statusCode });
6
+ }
7
+ export function localClient({ port = 5174, fetchImpl = fetch } = {}) {
8
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw cliError('invalid_arguments', '端口须为 1024–65535。');
9
+ const origin = `http://127.0.0.1:${port}`;
10
+ async function request(path, input) {
11
+ let response;
12
+ try { response = await fetchImpl(origin + path, { method: input === undefined ? 'GET' : 'POST',
13
+ headers: { origin, ...(input === undefined ? {} : { 'content-type': 'application/json' }) },
14
+ ...(input === undefined ? {} : { body: JSON.stringify(input) }), redirect: 'error', signal: AbortSignal.timeout(30_000) }); }
15
+ catch { throw cliError('local_driver_unavailable', '本机 Driver 未响应。先检查 device-center service status 或启动 device-center start。', 503); }
16
+ if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw cliError('invalid_driver_response', '本机端口未返回 Device Center JSON。', 502);
17
+ const chunks = []; let size = 0;
18
+ try { for await (const chunk of response.body) { size += chunk.length; if (size > 2_097_152) throw cliError('response_too_large', '本机响应过大。', 502); chunks.push(chunk); } }
19
+ catch (error) { if (error.cliCode) throw error; throw cliError('local_driver_unavailable', '本机响应中断。', 503); }
20
+ let data;
21
+ try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw cliError('invalid_driver_response', '本机响应格式无效。', 502); }
22
+ if (!response.ok) throw cliError(/^[a-z_]+$/.test(data?.error ?? '') ? data.error : 'local_request_failed', '本机请求失败。', response.status);
23
+ return data;
24
+ }
25
+ let ready = false;
26
+ async function local() {
27
+ if (ready) return;
28
+ const health = await request('/api/health');
29
+ if (health?.ok !== true || health.mode !== 'local') throw cliError('local_driver_required', '这个端口不是本机 Driver。', 409);
30
+ ready = true;
31
+ }
32
+ const get = path => async () => { await local(); return request(path); };
33
+ const post = path => async input => { await local(); return request(path, input); };
34
+ return { origin, health: get('/api/health'), overview: get('/api/overview'), link: get('/api/driver/link'),
35
+ setup: get('/api/connector/setup'), connector: get('/api/connector/status'),
36
+ candidates: get('/api/ssh/initialization'), collect: post('/api/devices/collect'), profile: post('/api/devices/profile'),
37
+ importSsh: post('/api/ssh/initialize'), checkSsh: post('/api/devices/ssh/check'), removeSsh: post('/api/devices/ssh/remove'),
38
+ async mcp(method, params) { await local();
39
+ // MCP has its own Accept contract. No arbitrary URL or JSON-RPC method is exposed.
40
+ if (!['tools/list', 'tools/call'].includes(method)) throw cliError('invalid_arguments', '不支持的 MCP 方法。');
41
+ let response;
42
+ try { response = await fetchImpl(origin + '/mcp', { method: 'POST', headers: { origin, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
43
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), redirect: 'error', signal: AbortSignal.timeout(30_000) }); }
44
+ catch { throw cliError('mcp_unavailable', 'MCP 未响应。', 503); }
45
+ if (!response.ok) throw cliError('mcp_unavailable', 'MCP 未响应。', response.status);
46
+ if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw cliError('invalid_driver_response', 'MCP 响应格式无效。', 502);
47
+ const chunks = []; let size = 0;
48
+ try { for await (const chunk of response.body) { size += chunk.length; if (size > 2_097_152) throw cliError('response_too_large', 'MCP 响应过大。', 502); chunks.push(chunk); } }
49
+ catch (error) { if (error.cliCode) throw error; throw cliError('mcp_unavailable', 'MCP 响应中断。', 503); }
50
+ let value;
51
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw cliError('invalid_driver_response', 'MCP 响应格式无效。', 502); }
52
+ if (value.error || value.result?.isError) throw cliError('mcp_tool_failed', '工具未完成;检查参数、本机信任与目标日志授权。', 409);
53
+ return value.result;
54
+ },
55
+ };
56
+ }
57
+
58
+ export function cloudClient({ file = defaultLinkFile(), fetchImpl, read = readLink } = {}) {
59
+ const state = read(file);
60
+ if (!state?.driverId || state.disabled) throw cliError('device_binding_required', '先运行 connect 绑定这台管理电脑,再 auth login 单独授权 CLI。', 403);
61
+ const call = async (action, input = {}) => {
62
+ try { return await machinePost(state, `cli/${action}`, input, fetchImpl); }
63
+ catch (error) {
64
+ if (Number.isInteger(error.statusCode)) throw error;
65
+ throw cliError('cloud_driver_unavailable', '已绑定的控制面未响应有效数据;检查网络和控制面版本。', 503);
66
+ }
67
+ };
68
+ return { origin: state.origin, fingerprint: state.fingerprint, call,
69
+ async devices() {
70
+ const devices = []; let offset = 0, total;
71
+ for (let page = 0; page < 26; page++) {
72
+ const result = await call('list', { offset, limit: 10 });
73
+ if (!Array.isArray(result.devices) || result.devices.length > 10 || !Number.isInteger(result.total) || result.total > 256 ||
74
+ (result.nextOffset !== null && result.nextOffset !== offset + 10)) throw cliError('invalid_cloud_response', '云端分页响应无效。', 502);
75
+ total ??= result.total;
76
+ if (total !== result.total) throw cliError('inventory_changed', '设备清单在分页期间变化,请重新 list。', 409);
77
+ devices.push(...result.devices);
78
+ if (result.nextOffset === null) {
79
+ if (devices.length !== total || new Set(devices.map(d => d.id)).size !== total) throw cliError('inventory_changed', '设备清单在分页期间变化,请重新 list。', 409);
80
+ return { devices, total };
81
+ }
82
+ offset = result.nextOffset;
83
+ }
84
+ throw cliError('invalid_cloud_response', '云端分页超出上限。', 502);
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,119 @@
1
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
+ import { fail, object } from './driverProtocol.mjs';
3
+
4
+ export const CLI_SCOPES = Object.freeze(['devices:read', 'devices:profile', 'devices:collect']);
5
+ const hash = value => createHash('sha256').update(value).digest('hex');
6
+ const AGE = 300_000;
7
+ // A bound telemetry identity is not an account management credential. A human owner
8
+ // grants this separate, short-lived capability; every call still proves the device key.
9
+ export function createCliManagement({ db, now = Date.now, account }) {
10
+ db.exec(`CREATE TABLE IF NOT EXISTS cli_requests (
11
+ id TEXT PRIMARY KEY, driver_id TEXT NOT NULL, ticket_hash TEXT UNIQUE NOT NULL,
12
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, minutes INTEGER NOT NULL,
13
+ state TEXT NOT NULL DEFAULT 'pending');
14
+ CREATE TABLE IF NOT EXISTS cli_grants (
15
+ driver_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
16
+ revoked_at INTEGER, last_used_at INTEGER)`);
17
+ const descriptor = row => ({ driverId: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint });
18
+ const status = row => {
19
+ const grant = db.prepare('SELECT * FROM cli_grants WHERE driver_id=?').get(row.id);
20
+ return { authorized: Boolean(grant && grant.revoked_at == null && grant.expires_at > now()),
21
+ scopes: [...CLI_SCOPES], expiresAt: grant?.expires_at ?? null, revokedAt: grant?.revoked_at ?? null,
22
+ lastUsedAt: grant?.last_used_at ?? null, ...descriptor(row) };
23
+ };
24
+ function pending(input, tenantId) {
25
+ if (typeof input.ticket !== 'string' || !/^[a-f0-9]{64}$/.test(input.ticket)) fail('cli_request_unavailable', 404);
26
+ const request = db.prepare(`SELECT r.*, d.tenant_id, d.name, d.platform, d.fingerprint, d.revoked_at
27
+ FROM cli_requests r JOIN cloud_drivers d ON d.id=r.driver_id WHERE r.ticket_hash=?`).get(hash(input.ticket));
28
+ if (!request || request.tenant_id !== tenantId || request.revoked_at != null || request.expires_at <= now() || request.state !== 'pending') fail('cli_request_unavailable', 404);
29
+ return request;
30
+ }
31
+ return {
32
+ owner(tenantId) {
33
+ return {
34
+ lookup(input) {
35
+ object(input, ['ticket']); const row = pending(input, tenantId);
36
+ return { id: row.id, driverId: row.driver_id, name: row.name, platform: row.platform, fingerprint: row.fingerprint,
37
+ scopes: [...CLI_SCOPES], minutes: row.minutes, expiresAt: row.expires_at };
38
+ },
39
+ approve(input) {
40
+ object(input, ['ticket', 'fingerprint']); const row = pending(input, tenantId);
41
+ if (input.fingerprint !== row.fingerprint) fail('cli_confirmation_required', 400);
42
+ const expiresAt = now() + row.minutes * 60_000;
43
+ db.exec('BEGIN IMMEDIATE');
44
+ try {
45
+ db.prepare("UPDATE cli_requests SET state='approved' WHERE id=? AND state='pending'").run(row.id);
46
+ db.prepare(`INSERT INTO cli_grants VALUES (?,?,?,NULL,NULL) ON CONFLICT(driver_id)
47
+ DO UPDATE SET created_at=excluded.created_at, expires_at=excluded.expires_at, revoked_at=NULL, last_used_at=NULL`).run(row.driver_id, now(), expiresAt);
48
+ db.exec('COMMIT');
49
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
50
+ return { authorized: true, scopes: [...CLI_SCOPES], expiresAt };
51
+ },
52
+ deny(input) {
53
+ object(input, ['ticket']); const row = pending(input, tenantId);
54
+ db.prepare("UPDATE cli_requests SET state='denied' WHERE id=?").run(row.id);
55
+ return { ok: true };
56
+ },
57
+ grants() {
58
+ return { grants: db.prepare(`SELECT d.* FROM cloud_drivers d JOIN cli_grants g ON g.driver_id=d.id
59
+ WHERE d.tenant_id=? AND d.revoked_at IS NULL ORDER BY g.created_at DESC`).all(tenantId).map(status) };
60
+ },
61
+ revoke(input) {
62
+ object(input, ['driverId']);
63
+ if (typeof input.driverId !== 'string' || !db.prepare('SELECT 1 FROM cloud_drivers WHERE id=? AND tenant_id=?').get(input.driverId, tenantId)) fail('driver_unknown', 404);
64
+ db.prepare('UPDATE cli_grants SET revoked_at=COALESCE(revoked_at,?) WHERE driver_id=?').run(now(), input.driverId);
65
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(input.driverId);
66
+ return { ok: true };
67
+ },
68
+ };
69
+ },
70
+ machine(action, row, input) {
71
+ if (action === 'status') { object(input, []); return status(row); }
72
+ if (action === 'start') {
73
+ object(input, ['minutes']); const minutes = input.minutes ?? 480;
74
+ if (!Number.isInteger(minutes) || minutes < 5 || minutes > 480) fail('invalid_cli_duration');
75
+ // Only expired protocol handoffs are discarded, never account/device data.
76
+ db.prepare('DELETE FROM cli_requests WHERE expires_at<=?').run(now());
77
+ if (db.prepare('SELECT count(*) AS n FROM cli_requests').get().n >= 128 ||
78
+ db.prepare('SELECT count(*) AS n FROM cli_requests WHERE driver_id=? AND created_at>?').get(row.id, now() - 60_000).n >= 3) fail('cli_rate_limited', 429);
79
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(row.id);
80
+ const id = randomUUID(), ticket = randomBytes(32).toString('hex');
81
+ db.prepare('INSERT INTO cli_requests (id,driver_id,ticket_hash,created_at,expires_at,minutes) VALUES (?,?,?,?,?,?)').run(id, row.id, hash(ticket), now(), now() + AGE, minutes);
82
+ return { id, ticket, expiresAt: now() + AGE, fingerprint: row.fingerprint, scopes: [...CLI_SCOPES], minutes };
83
+ }
84
+ if (action === 'poll' || action === 'cancel') {
85
+ object(input, ['id']);
86
+ if (typeof input.id !== 'string') fail('cli_request_unavailable', 404);
87
+ const request = db.prepare('SELECT * FROM cli_requests WHERE id=? AND driver_id=?').get(input.id, row.id);
88
+ if (!request || request.expires_at <= now()) fail('cli_request_expired', 410);
89
+ if (action === 'cancel') db.prepare("UPDATE cli_requests SET state='cancelled' WHERE id=? AND state='pending'").run(request.id);
90
+ return { state: action === 'cancel' && request.state === 'pending' ? 'cancelled' : request.state, ...status(row) };
91
+ }
92
+ if (action === 'logout') {
93
+ object(input, []);
94
+ db.prepare('UPDATE cli_grants SET revoked_at=COALESCE(revoked_at,?) WHERE driver_id=?').run(now(), row.id);
95
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(row.id);
96
+ return { authorized: false, deviceBindingRetained: true };
97
+ }
98
+ if (!status(row).authorized) fail('cli_authorization_required', 403);
99
+ const owner = account(row.tenant_id);
100
+ let result;
101
+ if (action === 'list') {
102
+ object(input, ['offset', 'limit']); const offset = input.offset ?? 0, limit = input.limit ?? 10;
103
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 256 || !Number.isInteger(limit) || limit < 1 || limit > 10) fail('invalid_cli_page');
104
+ const devices = owner.devices();
105
+ result = { devices: devices.slice(offset, offset + limit), total: devices.length, nextOffset: offset + limit < devices.length ? offset + limit : null };
106
+ } else if (action === 'show') {
107
+ object(input, ['deviceId']);
108
+ if (typeof input.deviceId !== 'string') fail('invalid_device_id');
109
+ const device = owner.devices().find(d => d.id === input.deviceId);
110
+ if (!device) fail('device_not_found', 404);
111
+ result = { device };
112
+ } else if (action === 'profile') result = owner.saveProfile(input);
113
+ else if (action === 'collect') result = owner.collect(input);
114
+ else fail('cli_action_unavailable', 404);
115
+ db.prepare('UPDATE cli_grants SET last_used_at=? WHERE driver_id=?').run(now(), row.id);
116
+ return result;
117
+ },
118
+ };
119
+ }
@@ -3,6 +3,7 @@ import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } fr
3
3
  import { validateCertificate } from './certificates.mjs';
4
4
  import { createRelay } from './relay.mjs';
5
5
  import { createDeviceProfiles } from './deviceProfiles.mjs';
6
+ import { createCliManagement } from './cliManagement.mjs';
6
7
 
7
8
  const PAIR_AGE = 5 * 60_000;
8
9
  export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
@@ -197,10 +198,14 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
197
198
  });
198
199
  },
199
200
  };
201
+ const management = createCliManagement({ db, now, account: tenantId => ({
202
+ devices: () => registry.devices(tenantId), saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
203
+ }) });
200
204
  return {
201
205
  start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
202
206
  machine(action, input) {
203
207
  const row = driver(input, `/api/driver/${action}`);
208
+ if (action.startsWith('cli/')) return management.machine(action.slice(4), row, input.payload);
204
209
  if (action === 'control') {
205
210
  object(input.payload, []);
206
211
  if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
@@ -220,6 +225,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
220
225
  lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
221
226
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
222
227
  saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
228
+ cli: management.owner(tenantId),
223
229
  };
224
230
  },
225
231
  };
package/server/index.mjs CHANGED
@@ -53,7 +53,7 @@ async function serveStatic(pathname, response) {
53
53
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
54
54
  else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
55
55
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
56
- else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
56
+ else if (pathname === '/pair' || pathname === '/cli') filePath = resolve(PROJECT_ROOT, 'login.html');
57
57
  else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
58
58
  else if (pathname.startsWith('/src/')) {
59
59
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
@@ -96,6 +96,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
96
96
  // Public liveness and deployed version only; no account, device or configuration data.
97
97
  if (path === '/api/health' && request.method === 'GET') return sendJson(response, 200, health);
98
98
  if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
99
+ if (['/cli', '/src/cli-approval.js', '/src/cli-handoff.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
99
100
  // Public static installation reference only. No identity, configuration or owner API.
100
101
  if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
101
102
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
@@ -107,7 +108,8 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
107
108
  if (cloud) {
108
109
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
109
110
  if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
110
- if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
111
+ if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close',
112
+ 'cli/start','cli/poll','cli/cancel','cli/status','cli/logout','cli/list','cli/show','cli/profile','cli/collect'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
111
113
  if (['/login', '/src/login.js', '/src/login-flow.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
112
114
  if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
113
115
  if (path === '/api/auth/continue' && request.method === 'POST') {
@@ -144,6 +146,12 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
144
146
  accountRegistry = registry.forTenant(principal.tenantId);
145
147
  if (path === '/api/auth/session' && request.method === 'GET') return sendJson(response, 200, { authenticated: true });
146
148
  if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
149
+ if (path === '/api/cli/grants' && request.method === 'GET') return sendJson(response, 200, accountRegistry.cli.grants());
150
+ const cliOwnerActions = { '/api/cli/lookup': 'lookup', '/api/cli/approve': 'approve', '/api/cli/deny': 'deny', '/api/cli/revoke': 'revoke' };
151
+ if (Object.hasOwn(cliOwnerActions, path) && request.method === 'POST') {
152
+ if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
153
+ return sendJson(response, 200, accountRegistry.cli[cliOwnerActions[path]](await readJson(request)));
154
+ }
147
155
  const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
148
156
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
149
157
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
@@ -0,0 +1,49 @@
1
+ import { captureCli, clearCli } from './cli-handoff.js';
2
+ import { initLanguage, getLocale, h } from './i18n.js';
3
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
4
+ let handoff = captureCli(), request, phase = handoff ? 'loading' : 'missing', timer;
5
+ document.querySelector('link[href="/src/login.css"]').href = '/src/pair.css';
6
+ document.body.innerHTML = `<header><a class="brand" href="/start" data-language-link><img src="/favicon.svg" alt="" width="26" height="26">Device Center</a><div class="language-picker" data-language-picker></div></header><main id="cli-root" aria-live="polite"></main><footer data-i18n="cli.boundary"></footer>`;
7
+ document.querySelector('title').dataset.i18n = 'cli.title';
8
+ const root = document.querySelector('#cli-root');
9
+ async function api(path, input) {
10
+ const response = await fetch(path, { method: input === undefined ? 'GET' : 'POST', headers: input === undefined ? {} : { 'content-type': 'application/json' },
11
+ ...(input === undefined ? {} : { body: JSON.stringify(input) }), cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(12_000) });
12
+ if (response.status === 401) { location.replace(`/login?lang=${getLocale()}&continue=cli`); throw new Error('authentication_required'); }
13
+ if (!response.ok) throw new Error('request_failed');
14
+ return response.json();
15
+ }
16
+ function render() {
17
+ root.setAttribute('aria-busy', String(phase === 'loading' || phase === 'saving'));
18
+ let body = '';
19
+ if (phase === 'ready') body = `<p>${h('cli.intro')}</p><div class="device"><strong>${escape(request.name)}</strong><p>${escape(request.platform)} · ${h('cli.duration', { minutes: request.minutes })}</p></div><p>${h('pair.fingerprint')}</p><code class="fingerprint">${escape(request.fingerprint)}</code><ul><li>${h('cli.read')}</li><li>${h('cli.profile')}</li><li>${h('cli.collect')}</li></ul><p>${h('cli.boundary')}</p><button id="approve">${h('cli.approve')}</button><button id="deny" class="secondary">${h('pair.cancel')}</button>`;
20
+ else if (phase === 'done') body = `<div class="status-mark" aria-hidden="true">✓</div><p>${h('cli.done')}</p><p>${h('pair.noReply')}</p><a class="primary" href="/cli?lang=${getLocale()}">${h('cli.manage')}</a>`;
21
+ else if (phase === 'manage') body = `<p>${h('cli.manageBody')}</p>${request.grants.map(grant => `<div class="device"><strong>${escape(grant.name)}</strong><p>${escape(new Date(grant.expiresAt).toLocaleString(getLocale()))} · ${h(grant.authorized ? 'cli.active' : 'cli.inactive')}</p><code class="fingerprint">${escape(grant.fingerprint)}</code>${grant.authorized ? `<button data-revoke="${escape(grant.driverId)}" class="secondary">${h('cli.revoke')}</button>` : ''}</div>`).join('') || `<p>${h('cli.empty')}</p>`}`;
22
+ else body = `<p>${h(['loading', 'saving'].includes(phase) ? 'pair.wait' : phase === 'cancelled' ? 'pair.cancelledBody' : 'cli.missing')}</p>`;
23
+ root.innerHTML = `<p class="eyebrow">DEVICE CENTER / CLI</p><h1>${h(phase === 'manage' ? 'cli.manage' : 'cli.title')}</h1>${body}<p><a href="/?lang=${getLocale()}">${h('pair.dashboard')}</a></p>`;
24
+ }
25
+ root.addEventListener('click', async event => {
26
+ const action = event.target.id, revoke = event.target.dataset.revoke;
27
+ if (revoke && phase === 'manage') {
28
+ phase = 'saving'; render();
29
+ try { await api('/api/cli/revoke', { driverId: revoke }); request = await api('/api/cli/grants'); phase = 'manage'; }
30
+ catch (error) { if (error.message === 'authentication_required') return; phase = 'missing'; }
31
+ render(); return;
32
+ }
33
+ if (!['approve', 'deny'].includes(action) || phase !== 'ready' || !handoff) return;
34
+ phase = 'saving'; render();
35
+ try {
36
+ await api(action === 'approve' ? '/api/cli/approve' : '/api/cli/deny', { ticket: handoff.ticket, ...(action === 'approve' ? { fingerprint: request.fingerprint } : {}) });
37
+ phase = action === 'approve' ? 'done' : 'cancelled';
38
+ } catch (error) { if (error.message === 'authentication_required') return; phase = 'missing'; }
39
+ clearTimeout(timer); clearCli(); handoff = null; render();
40
+ });
41
+ initLanguage({ onChange: render }); render();
42
+ try {
43
+ if (handoff) {
44
+ request = await api('/api/cli/lookup', { ticket: handoff.ticket }); phase = 'ready';
45
+ timer = setTimeout(() => { if (phase === 'ready') { clearCli(); handoff = null; phase = 'missing'; render(); } }, Math.max(0, request.expiresAt - Date.now()));
46
+ } else { request = await api('/api/cli/grants'); phase = 'manage'; }
47
+ render();
48
+ } catch (error) { if (error.message !== 'authentication_required') { clearCli(); handoff = null; phase = 'missing'; render(); } }
49
+ window.addEventListener('pagehide', () => clearTimeout(timer));
@@ -0,0 +1,19 @@
1
+ const KEY = 'device-center-cli-v1';
2
+ const safeStorage = () => { try { return window.sessionStorage; } catch { return null; } };
3
+ const valid = (value, now) => value && /^[a-f0-9]{64}$/.test(value.ticket ?? '') && Number.isSafeInteger(value.expiresAt) && value.expiresAt > now && value.expiresAt <= now + 330_000;
4
+ export function pendingCli({ storage = safeStorage(), now = Date.now() } = {}) {
5
+ try { const value = JSON.parse(storage.getItem(KEY)); if (valid(value, now)) return value; storage.removeItem(KEY); } catch { /* no credentials in errors */ }
6
+ return null;
7
+ }
8
+ export function clearCli({ storage = safeStorage() } = {}) { try { storage.removeItem(KEY); } catch { /* memory remains with caller */ } }
9
+ export function captureCli({ location = window.location, history = window.history, storage = safeStorage(), now = Date.now() } = {}) {
10
+ if (!location.hash) return pendingCli({ storage, now });
11
+ const fragment = new URLSearchParams(location.hash.slice(1));
12
+ history.replaceState(null, '', location.pathname + location.search); clearCli({ storage });
13
+ if ([...fragment.keys()].sort().join(',') !== 'expires,ticket') return null;
14
+ const value = { ticket: fragment.get('ticket'), expiresAt: Number(fragment.get('expires')) };
15
+ if (!valid(value, now)) return null;
16
+ try { storage.removeItem('device-center-pairing-v1'); } catch { /* handoffs stay in memory */ }
17
+ try { storage.setItem(KEY, JSON.stringify(value)); } catch { /* signed-in use remains in memory */ }
18
+ return value;
19
+ }
package/src/login.js CHANGED
@@ -3,6 +3,8 @@ import { loginDestination } from './pairing-handoff.js';
3
3
  import { continueSession, rememberLoginNavigation, pendingLoginNavigation } from './login-flow.js';
4
4
  if (location.pathname === '/pair') {
5
5
  await import('./pair.js');
6
+ } else if (location.pathname === '/cli') {
7
+ await import('./cli-approval.js');
6
8
  } else {
7
9
  initLanguage();
8
10
  function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
package/src/main.js CHANGED
@@ -217,7 +217,7 @@ function recentMcpInitialize() {
217
217
  }
218
218
  function renderSidebar() {
219
219
  const projects = state.overview?.projects ?? [];
220
- if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
220
+ if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><a class="nav-item" href="/cli?lang=${getLocale()}">${icon('link')}<span>${h("cli.manage")}</span></a></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
221
221
  return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.194")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>${h("panel.195")}</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>${h("panel.196")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : `<p class="nav-empty">${h("panel.191")}</p>`}</nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.197")}<button class="text-button" data-action="resident">${h("panel.198")}</button></div></aside>`;
222
222
  }
223
223
  function renderConnection() {