device-center 0.1.0-beta.1 → 0.2.0-beta.2

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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/Dockerfile +2 -2
  3. package/README.md +37 -127
  4. package/SECURITY.md +7 -15
  5. package/bin/device-center.mjs +6 -3
  6. package/docs/agent-install.md +105 -0
  7. package/docs/architecture.md +25 -57
  8. package/docs/connection-routing.md +1 -0
  9. package/docs/device-profiles.md +56 -0
  10. package/docs/driver-binding.md +6 -1
  11. package/docs/friend-trial.md +42 -0
  12. package/docs/hosted-access.md +52 -0
  13. package/docs/npm.md +19 -35
  14. package/docs/onboarding.md +8 -5
  15. package/docs/remote-access.md +76 -0
  16. package/docs/roadmap.md +17 -28
  17. package/docs/security-and-sync.md +9 -64
  18. package/login.html +1 -1
  19. package/npm-shrinkwrap.json +304 -0
  20. package/package.json +22 -4
  21. package/scripts/access.mjs +48 -0
  22. package/scripts/driver.mjs +4 -3
  23. package/scripts/release-check.mjs +4 -0
  24. package/server/cloudAccess.mjs +13 -6
  25. package/server/connector.mjs +11 -5
  26. package/server/drivers/access.mjs +84 -0
  27. package/server/drivers/certificates.mjs +32 -0
  28. package/server/drivers/cloudLink.mjs +46 -4
  29. package/server/drivers/cloudRegistry.mjs +107 -20
  30. package/server/drivers/deviceProfiles.mjs +47 -0
  31. package/server/drivers/driverProtocol.mjs +3 -2
  32. package/server/drivers/inventory.mjs +21 -2
  33. package/server/drivers/relay.mjs +59 -0
  34. package/server/drivers/remote.mjs +111 -0
  35. package/server/index.mjs +44 -13
  36. package/server/mcp.mjs +5 -3
  37. package/server/unifiedAccess.mjs +33 -17
  38. package/src/api.js +4 -1
  39. package/src/device-state.js +19 -0
  40. package/src/login.css +4 -0
  41. package/src/login.js +1 -0
  42. package/src/main.js +197 -42
  43. package/src/onboarding.js +62 -0
  44. package/src/start.css +47 -0
  45. package/src/start.js +23 -0
  46. package/src/styles.css +22 -0
  47. package/start.html +45 -0
@@ -0,0 +1,84 @@
1
+ import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, readSync, writeSync } from 'node:fs';
2
+ import { dirname, isAbsolute, resolve, join } from 'node:path';
3
+ import { isIP } from 'node:net';
4
+ import { publicIdentity, object, fail } from './driverProtocol.mjs';
5
+ import { validateCertificate } from './certificates.mjs';
6
+ import { ensurePrivateDirectory, writeLink } from './cloudLink.mjs';
7
+
8
+ export const accessFile = linkFile => join(dirname(linkFile), 'access.json');
9
+ export const validFingerprint = fp => typeof fp === 'string' && /^[a-f0-9]{64}$/.test(fp);
10
+ export function privateJson(file, fallback) {
11
+ let fd;
12
+ try {
13
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
14
+ const s = fstatSync(fd);
15
+ if (!s.isFile() || s.nlink !== 1 || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('access_private_file_required');
16
+ return JSON.parse(readFileSync(fd, 'utf8'));
17
+ } catch(e) { if(e.code === 'ENOENT') return fallback; throw e; }
18
+ finally { if(fd !== undefined) closeSync(fd); }
19
+ }
20
+ export function readAccess(file) {
21
+ const value = privateJson(file, { trusts: [], grants: [], listen: null });
22
+ object(value, ['trusts', 'grants', 'listen']);
23
+ if (!Array.isArray(value.trusts) || !Array.isArray(value.grants) || value.trusts.length > 16 || value.grants.length > 16) fail('access_invalid');
24
+ for (const entry of [...value.trusts, ...value.grants]) {
25
+ if (!validFingerprint(entry.fingerprint) || !/^[a-f0-9-]{36}$/.test(entry.id)) fail('access_invalid');
26
+ validateCertificate(entry.certificate, entry.fingerprint, Date.now(), true);
27
+ }
28
+ return value;
29
+ }
30
+ export function lanEndpoint(value) {
31
+ object(value, ['host', 'port']);
32
+ const ip = isIP(value.host);
33
+ const parts = ip === 4 ? value.host.split('.').map(Number) : [];
34
+ if (!(ip === 4 && (parts[0] === 10 || parts[0] === 192 && parts[1] === 168 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || ip === 6 && /^(fc|fd)/i.test(value.host)) || !Number.isInteger(value.port) || value.port < 1024 || value.port > 65535) fail('access_private_lan_endpoint_required');
35
+ return { host: value.host, port: value.port };
36
+ }
37
+ export function safeLogPath(path) {
38
+ if (typeof path !== 'string' || !isAbsolute(path) || path.length > 4096) fail('access_absolute_log_required');
39
+ const canonical = realpathSync(path);
40
+ checkLog(canonical); return canonical;
41
+ }
42
+ function checkLog(path) {
43
+ if (resolve(path) !== path || realpathSync(path) !== path) fail('access_log_changed');
44
+ for (let cursor = dirname(path); cursor !== dirname(cursor); cursor = dirname(cursor)) if(lstatSync(cursor).isSymbolicLink()) fail('access_log_changed');
45
+ const info = lstatSync(path);
46
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail('access_regular_log_required');
47
+ }
48
+ export function peerFingerprint(socket) {
49
+ const raw = socket.getPeerX509Certificate();
50
+ if (!raw || !socket.authorized) fail('access_peer_unauthorized', 403);
51
+ return publicIdentity(raw.publicKey.export({type:'spki',format:'pem'}).toString()).fingerprint;
52
+ }
53
+ export function audit(file, entry) {
54
+ ensurePrivateDirectory(dirname(file));
55
+ const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
56
+ try {
57
+ const s = fstatSync(fd);
58
+ if (!s.isFile() || s.nlink !== 1 || s.mode & 0o077 || s.size > 1024 ** 2 || process.getuid && s.uid !== process.getuid()) fail('access_audit_unavailable');
59
+ writeSync(fd, JSON.stringify({ ...entry, at: Date.now() }) + '\n');
60
+ } finally { closeSync(fd); }
61
+ }
62
+ export function readGrantedLog({ file, fingerprint, request, route, now = Date.now() }) {
63
+ object(request, ['operation', 'label', 'bytes']);
64
+ if (request.operation !== 'read-log' || typeof request.label !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(request.label) || !Number.isInteger(request.bytes) || request.bytes < 1 || request.bytes > 65536) fail('access_invalid_operation');
65
+ const grant = readAccess(file).grants.find(g => g.fingerprint === fingerprint && g.label === request.label && g.expiresAt > now);
66
+ if (!grant) fail('access_log_not_granted', 403);
67
+ const auditFile = join(dirname(file), 'access-audit.jsonl');
68
+ audit(auditFile, { fingerprint, label: request.label, route, outcome: 'requested' });
69
+ let fd;
70
+ try {
71
+ checkLog(grant.path);
72
+ fd = openSync(grant.path, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ const s = fstatSync(fd), current = lstatSync(grant.path);
74
+ if (!s.isFile() || s.nlink !== 1 || s.ino !== current.ino || s.dev !== current.dev || realpathSync(grant.path) !== grant.path) fail('access_log_changed');
75
+ const data = Buffer.alloc(Math.min(request.bytes, s.size));
76
+ const count = readSync(fd, data, 0, data.length, Math.max(0,s.size-data.length));
77
+ // Revocation/expiry is checked again before releasing file contents.
78
+ if (!readAccess(file).grants.some(g => g.fingerprint === fingerprint && g.label === request.label && g.path === grant.path && g.expiresAt > Date.now())) fail('access_log_not_granted', 403);
79
+ audit(auditFile, { fingerprint, label: request.label, route, bytes: count, outcome: 'allowed' });
80
+ return { label: request.label, bytes: count, text: data.subarray(0,count).toString('utf8'), route, observedAt: Date.now() };
81
+ } catch(error) { audit(auditFile, { fingerprint, label: request.label, route, outcome: 'denied' }); throw error; }
82
+ finally { if(fd !== undefined) closeSync(fd); }
83
+ }
84
+ export const writeAccess = (file,value) => writeLink(file,value);
@@ -0,0 +1,32 @@
1
+ import 'reflect-metadata';
2
+ import { X509Certificate, createPrivateKey, createPublicKey, webcrypto, randomBytes } from 'node:crypto';
3
+ import { X509CertificateGenerator, BasicConstraintsExtension, KeyUsagesExtension, KeyUsageFlags, ExtendedKeyUsageExtension, SubjectAlternativeNameExtension, cryptoProvider } from '@peculiar/x509';
4
+ import { publicIdentity, fail } from './driverProtocol.mjs';
5
+
6
+ cryptoProvider.set(webcrypto);
7
+ export const certificateName = fingerprint => `dc-${fingerprint.slice(0,32)}.${fingerprint.slice(32)}.invalid`;
8
+ export function validateCertificate(pem, fingerprint, now = Date.now(), allowExpired = false) {
9
+ if (typeof pem !== 'string' || pem.length > 4096 || (pem.match(/BEGIN CERTIFICATE/g) ?? []).length !== 1) fail('driver_certificate_invalid');
10
+ let certificate;
11
+ try { certificate = new X509Certificate(pem); } catch { fail('driver_certificate_invalid'); }
12
+ const identity = publicIdentity(certificate.publicKey.export({ type: 'spki', format: 'pem' }).toString());
13
+ if (identity.fingerprint !== fingerprint || !certificate.verify(certificate.publicKey) ||
14
+ Date.parse(certificate.validFrom) > now || !allowExpired && Date.parse(certificate.validTo) <= now ||
15
+ certificate.checkHost(certificateName(fingerprint)) !== certificateName(fingerprint)) fail('driver_certificate_invalid');
16
+ return certificate.toString();
17
+ }
18
+ export async function issueCertificate(state) {
19
+ const privateDer = createPrivateKey(state.privateKey).export({ type: 'pkcs8', format: 'der' });
20
+ const publicDer = createPublicKey(state.publicKey).export({ type: 'spki', format: 'der' });
21
+ const keys = {
22
+ privateKey: await webcrypto.subtle.importKey('pkcs8', privateDer, 'Ed25519', false, ['sign']),
23
+ publicKey: await webcrypto.subtle.importKey('spki', publicDer, 'Ed25519', true, ['verify']),
24
+ };
25
+ const certificate = await X509CertificateGenerator.createSelfSigned({
26
+ serialNumber: randomBytes(16).toString('hex'), name: `CN=${certificateName(state.fingerprint)}`,
27
+ notBefore: new Date(Date.now() - 60_000), notAfter: new Date(Date.now() + 365 * 86_400_000),
28
+ signingAlgorithm: { name: 'Ed25519' }, keys,
29
+ extensions: [new BasicConstraintsExtension(true, 0, true), new KeyUsagesExtension(KeyUsageFlags.digitalSignature | KeyUsageFlags.keyCertSign, true), new ExtendedKeyUsageExtension(['1.3.6.1.5.5.7.3.1','1.3.6.1.5.5.7.3.2'], true), new SubjectAlternativeNameExtension([{ type: 'dns', value: certificateName(state.fingerprint) }])],
30
+ });
31
+ return validateCertificate(certificate.toString('pem'), state.fingerprint);
32
+ }
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
5
5
  import { cloudOrigin, signedRequest, publicIdentity } from './driverProtocol.mjs';
6
6
  import { observeLocalDriver } from './local.mjs';
7
+ import { validateCertificate } from './certificates.mjs';
7
8
 
8
9
  export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : join(home, '.local', 'share', 'device-center'), 'driver-link.json');
9
10
  export function readLink(file) {
@@ -16,6 +17,7 @@ export function readLink(file) {
16
17
  cloudOrigin(state.origin);
17
18
  const key = createPrivateKey(state.privateKey), identity = publicIdentity(state.publicKey);
18
19
  if (key.asymmetricKeyType !== 'ed25519' || createPublicKey(key).export({ type: 'spki', format: 'pem' }).toString() !== identity.publicKey || identity.fingerprint !== state.fingerprint || typeof state.shareSsh !== 'boolean' || typeof state.disabled !== 'boolean' || (state.driverId != null && !/^[a-f0-9-]{36}$/.test(state.driverId))) throw new Error('driver_private_file_invalid');
20
+ if (state.certificate) validateCertificate(state.certificate, state.fingerprint, Date.now(), true);
19
21
  return state;
20
22
  } catch (error) { if (error.code === 'ENOENT') return null; throw new Error('driver_private_file_invalid'); }
21
23
  finally { if (fd !== undefined) closeSync(fd); }
@@ -33,7 +35,7 @@ export function writeLink(file, state, { create = false } = {}) {
33
35
  renameSync(temp, file);
34
36
  }
35
37
  export function linkSummary(state) {
36
- return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
38
+ return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, secureTransportReady: Boolean(state?.certificate), shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
37
39
  }
38
40
  export async function cloudPost(origin, path, input, fetchImpl = fetch) {
39
41
  cloudOrigin(origin);
@@ -42,7 +44,7 @@ export async function cloudPost(origin, path, input, fetchImpl = fetch) {
42
44
  const response = await fetchImpl(origin + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body, redirect: 'error', signal: AbortSignal.timeout(10_000) });
43
45
  if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw new Error('driver_cloud_unavailable');
44
46
  const chunks = []; let size = 0;
45
- for await (const chunk of response.body) { size += chunk.length; if (size > 16_384) throw new Error('driver_response_too_large'); chunks.push(chunk); }
47
+ for await (const chunk of response.body) { size += chunk.length; if (size > 65_536) throw new Error('driver_response_too_large'); chunks.push(chunk); }
46
48
  const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
47
49
  if (!response.ok) throw Object.assign(new Error(typeof data.error === 'string' && /^[a-z_]+$/.test(data.error) ? data.error : 'driver_cloud_unavailable'), { statusCode: response.status });
48
50
  return data;
@@ -60,8 +62,9 @@ export function reportPayload({ inventory, shareSsh = false, observe = observeLo
60
62
  })) };
61
63
  }
62
64
  export function createPublisher({ file, inventory, fetchImpl = fetch, read = readLink, write = writeLink, observe = observeLocalDriver }) {
63
- let stopped = true, timer, busy = false, backoff = 30_000;
65
+ let stopped = true, timer, controlTimer, collectionTimer, controlBusy = false, controlUnsupported = false, busy = false, backoff = 30_000;
64
66
  const status = { state: 'waiting', lastSuccess: null, error: null };
67
+ let pendingCollection = null;
65
68
  async function tick() {
66
69
  if (stopped || busy) return;
67
70
  clearTimeout(timer);
@@ -70,7 +73,10 @@ export function createPublisher({ file, inventory, fetchImpl = fetch, read = rea
70
73
  const state = read(file);
71
74
  if (!state?.driverId || state.disabled) { status.state = state?.disabled ? 'disabled' : 'waiting'; backoff = 30_000; return; }
72
75
  const payload = reportPayload({ inventory, shareSsh: state.shareSsh, observe });
76
+ const collection = pendingCollection?.expiresAt > Date.now() ? pendingCollection : null;
77
+ if (collection) payload.collectionRequestId = collection.id;
73
78
  await cloudPost(state.origin, '/api/driver/report', signedRequest(state.privateKey, state.origin, '/api/driver/report', state.driverId, payload), fetchImpl);
79
+ if (pendingCollection === collection) pendingCollection = null;
74
80
  Object.assign(status, { state: 'connected', lastSuccess: Date.now(), error: null }); backoff = 30_000;
75
81
  } catch (error) {
76
82
  Object.assign(status, { state: 'retrying', error: ['driver_revoked', 'driver_clock_skew', 'driver_scope_violation', 'driver_private_file_invalid'].includes(error.message) ? error.message : 'driver_cloud_unavailable' });
@@ -81,5 +87,41 @@ export function createPublisher({ file, inventory, fetchImpl = fetch, read = rea
81
87
  }
82
88
  } finally { busy = false; if (!stopped) timer = setTimeout(() => void tick(), backoff); }
83
89
  }
84
- return { status: () => ({ ...status }), start() { if (stopped) { stopped = false; void tick(); } }, stop() { stopped = true; clearTimeout(timer); }, tick };
90
+ function requestCollection(expiresAt = Date.now() + 75_000) {
91
+ clearTimeout(collectionTimer);
92
+ if (stopped || Date.now() >= expiresAt) return;
93
+ // Respect the existing signed telemetry rate limit, including manual collection.
94
+ const delay = Math.max(busy ? 250 : 0, (status.lastSuccess ?? 0) + 15_050 - Date.now());
95
+ collectionTimer = setTimeout(() => {
96
+ if (stopped || Date.now() >= expiresAt) return;
97
+ if (busy) requestCollection(expiresAt); else void tick();
98
+ }, delay);
99
+ }
100
+ async function controlTick() {
101
+ if (stopped || controlBusy || controlUnsupported) return;
102
+ controlBusy = true;
103
+ try {
104
+ const state = read(file);
105
+ if (!state?.driverId || state.disabled) return;
106
+ const path = '/api/driver/control';
107
+ const answer = await cloudPost(state.origin, path, signedRequest(state.privateKey, state.origin, path, state.driverId, {}), fetchImpl);
108
+ const command = answer.request;
109
+ if (command != null) {
110
+ // This channel has exactly one operation: collect already-authorized resource fields.
111
+ if (!command || Object.keys(command).some(k => !['id', 'action', 'expiresAt'].includes(k)) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(command.id) || command.action !== 'collect-resources' || !Number.isSafeInteger(command.expiresAt) || command.expiresAt > Date.now() + 90_000) throw new Error('driver_invalid_collection');
112
+ if (command.expiresAt <= Date.now()) return;
113
+ pendingCollection = command;
114
+ requestCollection(command.expiresAt);
115
+ }
116
+ } catch (error) {
117
+ // Old control planes keep receiving normal reports; this optional capability fails closed.
118
+ if (error.statusCode === 404) controlUnsupported = true;
119
+ } finally {
120
+ controlBusy = false;
121
+ if (!stopped && !controlUnsupported) controlTimer = setTimeout(() => void controlTick(), 5000);
122
+ }
123
+ }
124
+ return { status: () => ({ ...status }), requestCollection, controlTick,
125
+ start() { if (stopped) { stopped = false; void tick(); controlTimer = setTimeout(() => void controlTick(), 5000); } },
126
+ stop() { stopped = true; pendingCollection = null; clearTimeout(timer); clearTimeout(controlTimer); clearTimeout(collectionTimer); }, tick };
85
127
  }
@@ -1,8 +1,11 @@
1
1
  import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
2
  import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } from './driverProtocol.mjs';
3
+ import { validateCertificate } from './certificates.mjs';
4
+ import { createRelay } from './relay.mjs';
5
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
3
6
 
4
7
  const PAIR_AGE = 5 * 60_000;
5
- export function createCloudRegistry({ db, origin, now = Date.now }) {
8
+ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
6
9
  db.exec(`CREATE TABLE IF NOT EXISTS driver_pairings (
7
10
  id TEXT PRIMARY KEY, code_hash TEXT UNIQUE NOT NULL, public_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
8
11
  name TEXT NOT NULL, platform TEXT NOT NULL, requested_ssh INTEGER NOT NULL, created_at INTEGER NOT NULL,
@@ -11,9 +14,26 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
11
14
  id TEXT PRIMARY KEY, public_key TEXT NOT NULL, fingerprint TEXT NOT NULL, name TEXT NOT NULL, platform TEXT NOT NULL,
12
15
  share_ssh INTEGER NOT NULL, created_at INTEGER NOT NULL, revoked_at INTEGER, last_seen INTEGER, report_json TEXT);
13
16
  CREATE TABLE IF NOT EXISTS driver_nonces (scope TEXT NOT NULL, nonce TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(scope, nonce));`);
14
- let starts = [], lookups = [];
17
+ // Additive migration: historical personal rows keep their owner. Never transfer existing devices.
18
+ if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(column => column.name === 'tenant_id')) {
19
+ db.exec("ALTER TABLE cloud_drivers ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'cloud-owner'");
20
+ }
21
+ db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
22
+ for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
23
+ if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
24
+ db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
25
+ requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
26
+ const profiles = createDeviceProfiles({ db, now, initialize: true });
27
+ if (multiUser && !legacyOwnerMapped && db.prepare("SELECT 1 FROM cloud_drivers WHERE tenant_id='cloud-owner' LIMIT 1").get()) throw new Error('cloud_legacy_owner_mapping_required');
28
+ let starts = [], globalLookups = [], globalApprovals = [];
29
+ const lookups = new Map(), approvals = new Map();
15
30
  const polls = new Map();
16
31
  const limited = (times, max) => { const recent = times.filter(at => at > now() - 60_000); if (recent.length >= max) fail('driver_rate_limited', 429); recent.push(now()); return recent; };
32
+ function limitAccount(map, tenantId) {
33
+ for (const [id, times] of map) if (!times.some(at => at > now() - 60_000)) map.delete(id);
34
+ if (!map.has(tenantId) && map.size >= 256) fail('driver_rate_limited', 429);
35
+ map.set(tenantId, limited(map.get(tenantId) ?? [], 8));
36
+ }
17
37
  const hashCode = (code) => {
18
38
  if (typeof code !== 'string' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(code)) fail('invalid_pairing_code');
19
39
  return publicCodeHash(code);
@@ -39,12 +59,26 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
39
59
  if (row.revoked_at != null) fail('driver_revoked', 403);
40
60
  return row;
41
61
  }
42
- const descriptor = row => ({ id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen });
43
- return {
62
+ const descriptor = row => {
63
+ const profile = profiles.get(row.tenant_id, `${row.id}:host`);
64
+ return { id: row.id, name: profile.displayName || row.name, reportedName: row.name, profile, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen };
65
+ };
66
+ const collection = row => {
67
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=?').get(row.id);
68
+ const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
69
+ const supported = connected && row.control_seen_at != null && now() - row.control_seen_at <= 30_000;
70
+ return { supported, reason: !connected ? 'driver_not_connected' : row.control_seen_at == null ? 'driver_upgrade_required' : !supported ? 'driver_control_unavailable' : null,
71
+ status: !request ? 'idle' : request.completed_at != null ? 'completed' : request.expires_at <= now() ? 'timeout' : 'pending',
72
+ requestedAt: request?.requested_at ?? null, completedAt: request?.completed_at ?? null, expiresAt: request?.expires_at ?? null };
73
+ };
74
+ const peers = row => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL AND certificate IS NOT NULL').all(row.tenant_id).map(peer => ({ ...descriptor(peer), certificate: peer.certificate }));
75
+ const relay = createRelay({ db, now, getDriver: id => db.prepare('SELECT * FROM cloud_drivers WHERE id=?').get(id) });
76
+ const registry = {
44
77
  start(input) {
45
78
  starts = limited(starts, 8);
46
- object(input, ['publicKey', 'name', 'platform', 'shareSsh']);
79
+ object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
47
80
  const identity = publicIdentity(input.publicKey);
81
+ const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
48
82
  const name = text(input.name), platform = text(input.platform, 64);
49
83
  if (typeof input.shareSsh !== 'boolean') fail('invalid_driver_scope');
50
84
  // Only protocol-expired pending requests are removed; registered devices and reports are retained.
@@ -52,7 +86,7 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
52
86
  for (const [id, at] of polls) if (at <= now() - PAIR_AGE) polls.delete(id);
53
87
  if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
54
88
  const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
55
- db.prepare('INSERT INTO driver_pairings VALUES (?,?,?,?,?,?,?,?,?,NULL)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE);
89
+ db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
56
90
  return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
57
91
  },
58
92
  poll(input) {
@@ -66,23 +100,31 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
66
100
  if (registered.revoked_at != null) fail('driver_revoked', 403);
67
101
  return { state: 'approved', driverId: registered.id, shareSsh: Boolean(registered.share_ssh) };
68
102
  },
69
- lookup(input) {
70
- lookups = limited(lookups, 8); object(input, ['code']);
103
+ lookup(input, tenantId) {
104
+ globalLookups = limited(globalLookups, 64);
105
+ limitAccount(lookups, tenantId); object(input, ['code']);
71
106
  const row = db.prepare('SELECT * FROM driver_pairings WHERE code_hash=?').get(hashCode(input.code));
72
107
  if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
73
108
  return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
74
109
  },
75
- approve(input) {
76
- object(input, ['id', 'shareSsh']);
110
+ approve(input, tenantId) {
111
+ globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
112
+ object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
77
113
  if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
114
+ if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
78
115
  db.exec('BEGIN IMMEDIATE');
79
116
  try {
80
117
  const row = pairing(input.id);
81
- if (row.driver_id) fail('driver_pairing_consumed', 409);
118
+ if (row.driver_id) {
119
+ if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
120
+ fail('driver_pairing_consumed', 409);
121
+ }
122
+ if (input.code !== undefined && hashCode(input.code) !== row.code_hash) fail('driver_pairing_unavailable', 404);
123
+ if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
82
124
  if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
83
- if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE revoked_at IS NULL').get().n >= 16) fail('driver_limit', 409);
125
+ if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
84
126
  const id = randomUUID();
85
- db.prepare('INSERT INTO cloud_drivers VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)').run(id, row.public_key, row.fingerprint, row.name, row.platform, Number(input.shareSsh), now());
127
+ db.prepare('INSERT INTO cloud_drivers (id,public_key,fingerprint,name,platform,share_ssh,created_at,tenant_id,certificate) VALUES (?,?,?,?,?,?,?,?,?)').run(id, row.public_key, row.fingerprint, row.name, row.platform, Number(input.shareSsh), now(), tenantId, row.certificate);
86
128
  db.prepare('UPDATE driver_pairings SET driver_id=? WHERE id=? AND driver_id IS NULL').run(id, row.id);
87
129
  db.exec('COMMIT'); return { ok: true, driverId: id };
88
130
  } catch (error) { db.exec('ROLLBACK'); throw error; }
@@ -92,6 +134,7 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
92
134
  const report = normalizeReport(input.payload, Boolean(row.share_ssh), now());
93
135
  if (row.last_seen != null && now() - row.last_seen < 15_000) fail('driver_report_too_frequent', 429);
94
136
  db.prepare('UPDATE cloud_drivers SET last_seen=?, report_json=? WHERE id=?').run(now(), JSON.stringify(report), row.id);
137
+ if (report.collectionRequestId && report.resources[0].observedAt != null) db.prepare('UPDATE driver_collection_requests SET completed_at=? WHERE driver_id=? AND request_id=? AND completed_at IS NULL AND expires_at>?').run(now(), row.id, report.collectionRequestId, now());
95
138
  return { ok: true, receivedAt: now() };
96
139
  },
97
140
  disconnect(input) {
@@ -99,25 +142,69 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
99
142
  db.prepare('UPDATE cloud_drivers SET revoked_at=? WHERE id=?').run(now(), row.id);
100
143
  return { ok: true };
101
144
  },
102
- revoke(input) {
145
+ revoke(input, tenantId) {
103
146
  object(input, ['id']); if (typeof input.id !== 'string') fail('invalid_driver_id');
104
- const row = db.prepare('SELECT id FROM cloud_drivers WHERE id=?').get(input.id);
147
+ const row = db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(input.id, tenantId);
105
148
  if (!row) fail('driver_unknown', 404);
106
- db.prepare('UPDATE cloud_drivers SET revoked_at=COALESCE(revoked_at,?) WHERE id=?').run(now(), input.id);
149
+ db.prepare('UPDATE cloud_drivers SET revoked_at=COALESCE(revoked_at,?) WHERE id=? AND tenant_id=?').run(now(), input.id, tenantId);
107
150
  return { ok: true };
108
151
  },
109
- bindings: () => db.prepare('SELECT * FROM cloud_drivers ORDER BY created_at').all().map(descriptor),
110
- devices() {
111
- return db.prepare('SELECT * FROM cloud_drivers WHERE revoked_at IS NULL ORDER BY created_at').all().flatMap(row => {
152
+ bindings: tenantId => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? ORDER BY created_at').all(tenantId).map(descriptor),
153
+ saveProfile(input, tenantId) {
154
+ if (!registry.devices(tenantId).some(d => d.id === input?.deviceId)) fail('device_not_found', 404);
155
+ return { profile: profiles.save(tenantId, input) };
156
+ },
157
+ collect(input, tenantId) {
158
+ object(input, ['deviceId']);
159
+ if (typeof input.deviceId !== 'string') fail('invalid_device_id');
160
+ const device = registry.devices(tenantId).find(d => d.id === input.deviceId);
161
+ if (!device) fail('device_not_found', 404);
162
+ if (device.kind !== 'management') fail('collection_requires_local_ssh_check', 409);
163
+ const row = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(device.managementDriver.id, tenantId);
164
+ const current = collection(row);
165
+ if (!current.supported) fail(current.reason, 409);
166
+ if (current.status === 'pending') return { status: 'pending', collection: current };
167
+ if (current.requestedAt != null && now() - current.requestedAt < 10_000) fail('collection_rate_limited', 429);
168
+ db.prepare('INSERT INTO driver_collection_requests VALUES (?,?,?,?,NULL) ON CONFLICT(driver_id) DO UPDATE SET request_id=excluded.request_id,requested_at=excluded.requested_at,expires_at=excluded.expires_at,completed_at=NULL').run(row.id, randomUUID(), now(), now() + 75_000);
169
+ return { status: 'pending', collection: collection(row) };
170
+ },
171
+ devices(tenantId) {
172
+ return db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL ORDER BY created_at').all(tenantId).flatMap(row => {
112
173
  const report = row.report_json ? JSON.parse(row.report_json).resources : [{ id: 'host', name: row.name, platform: row.platform, kind: 'management', observedAt: null, memory: { status: 'unobserved', usedMb: null, totalMb: null }, disk: { status: 'unobserved', usedGb: null, totalGb: null } }];
113
174
  return report.map(resource => {
114
175
  const latest = resource.observedAt == null || row.last_seen == null ? null : Math.min(resource.observedAt, row.last_seen);
115
176
  const age = latest == null ? null : Math.max(0, now() - latest);
116
177
  const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
117
- return { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, capabilities: { observation: true, remoteExecution: false } };
178
+ return profiles.apply(tenantId, { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, collection: resource.kind === 'management' ? collection(row) : { supported: false, reason: 'collection_requires_local_ssh_check', status: 'idle' }, capabilities: { observation: true, remoteExecution: false } });
118
179
  });
119
180
  });
120
181
  },
121
182
  };
183
+ return {
184
+ start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
185
+ machine(action, input) {
186
+ const row = driver(input, `/api/driver/${action}`);
187
+ if (action === 'control') {
188
+ object(input.payload, []);
189
+ if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
190
+ db.prepare('UPDATE cloud_drivers SET control_seen_at=? WHERE id=?').run(now(), row.id);
191
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=? AND completed_at IS NULL AND expires_at>?').get(row.id, now());
192
+ return { request: request ? { id: request.request_id, action: 'collect-resources', expiresAt: request.expires_at } : null };
193
+ }
194
+ if (action === 'certificate') { object(input.payload, ['certificate']); const cert = validateCertificate(input.payload.certificate, row.fingerprint, now()); db.prepare('UPDATE cloud_drivers SET certificate=? WHERE id=?').run(cert, row.id); return { ok: true }; }
195
+ if (action === 'peers') { object(input.payload, []); return { peers: peers(row) }; }
196
+ if (!row.certificate) fail('driver_certificate_required', 409);
197
+ return relay.handle(action, row, input.payload);
198
+ },
199
+ // Only server-verified principals reach this scope; no default global inventory API.
200
+ forTenant(tenantId) {
201
+ if (typeof tenantId !== 'string' || !/^(cloud-owner|account-[a-f0-9]{64})$/.test(tenantId)) fail('driver_account_required', 403);
202
+ return {
203
+ lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
204
+ revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
205
+ saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
206
+ };
207
+ },
208
+ };
122
209
  }
123
210
  const publicCodeHash = code => createHash('sha256').update(code).digest('hex');
@@ -0,0 +1,47 @@
1
+ // Descriptive owner metadata. It never changes device identity or grants access.
2
+ export const DEVICE_TYPES = ['unspecified', 'personal', 'server', 'workstation', 'other'];
3
+ const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
+ function string(value, max, multiline = false) {
5
+ if (typeof value !== 'string' || value.length > max || (multiline ? /[\x00-\x09\x0b-\x1f\x7f]/ : /[\x00-\x1f\x7f]/).test(value)) fail('invalid_device_profile');
6
+ return value.trim();
7
+ }
8
+ export function normalizeDeviceProfile(value) {
9
+ if (!value || Array.isArray(value) || Object.keys(value).some(k => !['displayName', 'type', 'purpose', 'tags', 'services'].includes(k)) || !DEVICE_TYPES.includes(value.type)) fail('invalid_device_profile');
10
+ const list = (items, max, length) => {
11
+ if (!Array.isArray(items) || items.length > max) fail('invalid_device_profile');
12
+ const result = items.map(v => string(v, length));
13
+ if (result.some(v => !v)) fail('invalid_device_profile');
14
+ return [...new Set(result)];
15
+ };
16
+ return { displayName: string(value.displayName, 80), type: value.type, purpose: string(value.purpose, 240, true), tags: list(value.tags, 8, 24), services: list(value.services, 12, 40) };
17
+ }
18
+ const EMPTY = { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0, updatedAt: null, servicesSource: 'user-declared' };
19
+ export function createDeviceProfiles({ db, now = Date.now, initialize = false }) {
20
+ const ensure = () => db.exec(`CREATE TABLE IF NOT EXISTS device_profiles (
21
+ tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, profile_json TEXT NOT NULL,
22
+ revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(tenant_id, device_id))`);
23
+ if (initialize) ensure();
24
+ const get = (tenantId, id) => {
25
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='device_profiles'").get()) return { ...EMPTY, tags: [], services: [] };
26
+ const row = db.prepare('SELECT * FROM device_profiles WHERE tenant_id=? AND device_id=?').get(tenantId, id);
27
+ return row ? { ...normalizeDeviceProfile(JSON.parse(row.profile_json)), revision: row.revision, updatedAt: row.updated_at, servicesSource: 'user-declared' } : { ...EMPTY, tags: [], services: [] };
28
+ };
29
+ return {
30
+ get,
31
+ apply(tenantId, device) {
32
+ const profile = get(tenantId, device.id);
33
+ return { ...device, reportedName: device.reportedName ?? device.name, name: profile.displayName || device.name, profile };
34
+ },
35
+ save(tenantId, input) {
36
+ if (!input || Array.isArray(input) || Object.keys(input).some(k => !['deviceId', 'profile', 'revision'].includes(k)) || typeof input.deviceId !== 'string' || !input.deviceId || input.deviceId.length > 200 || !Number.isSafeInteger(input.revision) || input.revision < 0) fail('invalid_device_profile');
37
+ const profile = normalizeDeviceProfile(input.profile); ensure();
38
+ db.exec('BEGIN IMMEDIATE');
39
+ try {
40
+ if (get(tenantId, input.deviceId).revision !== input.revision) fail('device_profile_conflict', 409);
41
+ db.prepare('INSERT INTO device_profiles VALUES (?,?,?,?,?) ON CONFLICT(tenant_id,device_id) DO UPDATE SET profile_json=excluded.profile_json,revision=excluded.revision,updated_at=excluded.updated_at').run(tenantId, input.deviceId, JSON.stringify(profile), input.revision + 1, now());
42
+ db.exec('COMMIT');
43
+ return get(tenantId, input.deviceId);
44
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
45
+ },
46
+ };
47
+ }
@@ -50,7 +50,8 @@ function metric(input, kind) {
50
50
  return { status: input.status, [used]: input.status === 'ok' ? input[used] : null, [total]: input.status === 'ok' ? input[total] : null };
51
51
  }
52
52
  export function normalizeReport(payload, shareSsh, now) {
53
- object(payload, ['resources']);
53
+ object(payload, ['resources', 'collectionRequestId']);
54
+ if (payload.collectionRequestId !== undefined && (typeof payload.collectionRequestId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(payload.collectionRequestId))) fail('invalid_collection_request');
54
55
  if (!Array.isArray(payload.resources) || !payload.resources.length || payload.resources.length > 16) fail('invalid_driver_resources');
55
56
  const ids = new Set();
56
57
  const resources = payload.resources.map((value, index) => {
@@ -62,5 +63,5 @@ export function normalizeReport(payload, shareSsh, now) {
62
63
  if (observedAt !== null && (!Number.isSafeInteger(observedAt) || observedAt < 0 || observedAt > now + 90_000)) fail('invalid_driver_observation_time');
63
64
  return { id: value.id, name: text(value.name), platform: text(value.platform, 64), kind: value.kind, observedAt: observedAt == null ? null : Math.min(observedAt, now), memory: metric(value.memory, 'memory'), disk: metric(value.disk, 'disk') };
64
65
  });
65
- return { resources };
66
+ return { resources, ...(payload.collectionRequestId ? { collectionRequestId: payload.collectionRequestId } : {}) };
66
67
  }
@@ -4,12 +4,14 @@ import { discoverSsh } from './sshConfig.mjs';
4
4
  import { prepareSshProfile, probeSsh } from './sshProbe.mjs';
5
5
  import { normalizeConnectionPlan, readConnectionPlan, writeConnectionPlan } from './connectionPlans.mjs';
6
6
  import { inspectSshImport } from './sshInitialization.mjs';
7
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
7
8
 
8
9
  function associations(db, tenantId) {
9
10
  if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_associations'").get()) return [];
10
11
  return db.prepare('SELECT alias, created_at AS createdAt FROM ssh_associations WHERE tenant_id = ? ORDER BY alias COLLATE NOCASE').all(tenantId);
11
12
  }
12
13
  export function createInventory({ db, tenantId, runtime = 'native', sshHome, sshProbe = probeSsh }) {
14
+ const profiles = createDeviceProfiles({ db });
13
15
  const activeChecks = new Set();
14
16
  const pendingChecks = new Set();
15
17
  const exclusions = () => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_exclusions'").get()
@@ -48,7 +50,7 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
48
50
  capabilities: { observation: Boolean(profile), remoteExecution: false },
49
51
  };
50
52
  });
51
- return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)];
53
+ return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)].map(device => profiles.apply(tenantId, device));
52
54
  };
53
55
  const check = async (input) => {
54
56
  if (!input || Array.isArray(input) || Object.keys(input).some((key) => key !== 'alias') || typeof input.alias !== 'string') throw Object.assign(new Error('invalid_ssh_check'), { statusCode: 400 });
@@ -149,5 +151,22 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
149
151
  return { removed, excluded: chosen.length, systemConfigurationChanged: false };
150
152
  } catch (cause) { db.exec('ROLLBACK'); throw cause; }
151
153
  };
152
- return { list, discovery, associate, check, checkBatch, remove, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
154
+ const find = input => {
155
+ if (!input || Array.isArray(input) || typeof input.deviceId !== 'string') throw Object.assign(new Error('invalid_device_id'), { statusCode: 400 });
156
+ const device = list().find(d => d.id === input.deviceId);
157
+ if (!device) throw Object.assign(new Error('device_not_found'), { statusCode: 404 });
158
+ return device;
159
+ };
160
+ const saveProfile = input => { find(input); return { profile: profiles.save(tenantId, input) }; };
161
+ const collect = async input => {
162
+ if (Object.keys(input ?? {}).some(k => k !== 'deviceId')) throw Object.assign(new Error('invalid_collection_request'), { statusCode: 400 });
163
+ const device = find(input);
164
+ if (device.transport === 'ssh') {
165
+ const result = await check({ alias: device.ssh.alias });
166
+ if (!result.ok) return { status: 'failed', error: result.error, device: list().find(d => d.id === device.id) };
167
+ }
168
+ else if (device.source !== 'local-runtime' || runtime !== 'native') throw Object.assign(new Error('collection_unavailable'), { statusCode: 409 });
169
+ return { status: 'completed', device: list().find(d => d.id === device.id) };
170
+ };
171
+ return { list, discovery, associate, check, checkBatch, remove, saveProfile, collect, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
153
172
  }
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { fail, object } from './driverProtocol.mjs';
3
+
4
+ // Opaque TLS records only. Membership, revocation and persisted quotas are checked on every exchange.
5
+ export function createRelay({ db, getDriver, now = Date.now, accountLimit = 100 * 1024 ** 2, globalLimit = 1024 ** 3 }) {
6
+ db.exec('CREATE TABLE IF NOT EXISTS relay_usage (day TEXT NOT NULL, tenant TEXT NOT NULL, bytes INTEGER NOT NULL, PRIMARY KEY(day,tenant))');
7
+ const channels = new Map(), lastAccept = new Map();
8
+ const day = () => new Date(now()).toISOString().slice(0,10);
9
+ function usage(tenant) {
10
+ const used = db.prepare('SELECT bytes FROM relay_usage WHERE day=? AND tenant=?').get(day(), tenant)?.bytes ?? 0;
11
+ return { day: day(), bytes: used, limitBytes: accountLimit, bytesPerSecond: 16_384, maxSessionSeconds: 60, active: [...channels.values()].filter(c => c.tenant === tenant).length };
12
+ }
13
+ function charge(tenant, bytes) {
14
+ if (usage(tenant).bytes + bytes > accountLimit || (db.prepare('SELECT sum(bytes) AS n FROM relay_usage WHERE day=?').get(day()).n ?? 0) + bytes > globalLimit) fail('relay_quota_exceeded', 429);
15
+ db.prepare('INSERT INTO relay_usage VALUES (?,?,?) ON CONFLICT(day,tenant) DO UPDATE SET bytes=bytes+excluded.bytes').run(day(), tenant, bytes);
16
+ }
17
+ function sweep() {
18
+ for (const [id,c] of channels) if (c.expires <= now() || [c.a,c.b].some(id => { const d = getDriver(id); return !d || d.revoked_at != null || d.tenant_id !== c.tenant; })) channels.delete(id);
19
+ for (const [id,at] of lastAccept) if (at < now()-60_000) lastAccept.delete(id);
20
+ }
21
+ function channel(row, id) {
22
+ const c = channels.get(id);
23
+ if (!c || c.tenant !== row.tenant_id || ![c.a,c.b].includes(row.id)) fail('relay_unavailable', 404);
24
+ return c;
25
+ }
26
+ return { usage, handle(action, row, payload) {
27
+ sweep();
28
+ if (action === 'relay/open') {
29
+ object(payload, ['targetId']);
30
+ const target = typeof payload.targetId === 'string' ? getDriver(payload.targetId) : null;
31
+ if (!target || target.id === row.id || target.tenant_id !== row.tenant_id || target.revoked_at != null || !target.certificate) fail('relay_peer_unavailable', 404);
32
+ if (usage(row.tenant_id).bytes >= accountLimit) fail('relay_quota_exceeded', 429);
33
+ if (channels.size >= 8 || [...channels.values()].filter(c => c.tenant === row.tenant_id).length >= 2 || [...channels.values()].some(c => [c.a,c.b].includes(row.id))) fail('relay_busy', 429);
34
+ const id = randomUUID(); channels.set(id, { a: row.id, b: target.id, tenant: row.tenant_id, expires: now()+60_000, accepted: false, qa: [], qb: [], last: {} });
35
+ return { channelId: id, expiresAt: now()+60_000 };
36
+ }
37
+ if (action === 'relay/accept') {
38
+ object(payload, []);
39
+ if (now()-(lastAccept.get(row.id) ?? -Infinity) < 2000) fail('relay_rate_limited', 429);
40
+ lastAccept.set(row.id, now());
41
+ const entry = [...channels].find(([,c]) => c.b === row.id && !c.accepted);
42
+ if (!entry) return { incoming: null };
43
+ const [id,c] = entry; c.accepted = true;
44
+ const peer = getDriver(c.a);
45
+ return { incoming: { channelId: id, fingerprint: peer.fingerprint, certificate: peer.certificate } };
46
+ }
47
+ if (action === 'relay/close') { object(payload, ['channelId']); channel(row, payload.channelId); channels.delete(payload.channelId); return { ok: true }; }
48
+ if (action !== 'relay/exchange') fail('not_found', 404);
49
+ object(payload, ['channelId', 'frames']);
50
+ const c = channel(row, payload.channelId);
51
+ if (now()-(c.last[row.id] ?? -Infinity) < 500) fail('relay_rate_limited', 429);
52
+ if (!Array.isArray(payload.frames) || payload.frames.length > 2 || payload.frames.some(f => typeof f !== 'string' || f.length > 5464 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(f) || Buffer.from(f,'base64').length > 4096)) fail('relay_invalid_frames');
53
+ const into = row.id === c.a ? c.qb : c.qa, out = row.id === c.a ? c.qa : c.qb;
54
+ if (into.length + payload.frames.length > 32) fail('relay_backpressure', 429);
55
+ charge(c.tenant, payload.frames.reduce((n,f) => n+Buffer.from(f,'base64').length, 0));
56
+ c.last[row.id] = now(); into.push(...payload.frames);
57
+ return { frames: out.splice(0,2), expiresAt: c.expires };
58
+ } };
59
+ }