custody-client.mjs
135 lines 4.5 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days ago
1 import crypto from 'node:crypto';
2 import net from 'node:net';
3
4 const MAX_FRAME_BYTES = 16_384;
5 const REQUEST_TIMEOUT_MS = 10_000;
6 const ALLOWED_ACCOUNTS = new Set([
7 'knowtation.companion.accessToken',
8 'knowtation.companion.refreshToken',
9 'knowtation.companion.sessionMeta',
10 'knowtation.companion.loopbackToken',
11 'knowtation.companion.updateFloor',
12 ]);
13
14 function requireAccount(account) {
15 if (typeof account !== 'string' || !ALLOWED_ACCOUNTS.has(account)) {
16 throw new TypeError('custody_client_invalid_account');
17 }
18 return account;
19 }
20
21 function requireFd(env = process.env) {
22 const raw = env.KNOWTATION_CUSTODY_FD;
23 if (!/^[0-9]+$/.test(raw ?? '')) throw new Error('custody_capability_unavailable');
24 const fd = Number(raw);
25 if (!Number.isInteger(fd) || fd < 3) throw new Error('custody_capability_unavailable');
26 return fd;
27 }
28
29 export function createCustodyClient({ fd = requireFd(), timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
30 if (!Number.isInteger(fd) || fd < 3) throw new TypeError('custody_client_invalid_fd');
31 if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
32 throw new TypeError('custody_client_invalid_timeout');
33 }
34
35 const socket = new net.Socket({ fd, readable: true, writable: true });
36 socket.unref();
37 let buffer = Buffer.alloc(0);
38 let closed = false;
39 const pending = new Map();
40
41 function rejectPending(reason = 'custody_connection_closed') {
42 for (const entry of pending.values()) {
43 clearTimeout(entry.timer);
44 entry.reject(new Error(reason));
45 }
46 pending.clear();
47 }
48
49 socket.on('data', (chunk) => {
50 buffer = Buffer.concat([buffer, chunk]);
51 if (buffer.length > 65_536) {
52 socket.destroy(new Error('custody_response_too_large'));
53 return;
54 }
55 for (;;) {
56 const newline = buffer.indexOf(0x0a);
57 if (newline < 0) break;
58 const frame = buffer.subarray(0, newline);
59 buffer = buffer.subarray(newline + 1);
60 if (frame.length > MAX_FRAME_BYTES) continue;
61 let response;
62 try {
63 response = JSON.parse(frame.toString('utf8'));
64 } catch {
65 continue;
66 }
67 if (!response || typeof response.id !== 'string') continue;
68 const entry = pending.get(response.id);
69 if (!entry) continue;
70 pending.delete(response.id);
71 clearTimeout(entry.timer);
72 if (response.ok === true) entry.resolve(response.value ?? null);
73 else entry.reject(new Error('custody_operation_failed'));
74 }
75 });
76 socket.on('error', () => rejectPending());
77 socket.on('close', () => {
78 closed = true;
79 rejectPending();
80 });
81
82 function request(operation, account = null, value = null) {
83 if (closed) return Promise.reject(new Error('custody_connection_closed'));
84 const id = crypto.randomUUID();
85 const message = JSON.stringify({ id, operation, account, value });
86 if (Buffer.byteLength(message) > MAX_FRAME_BYTES) {
87 return Promise.reject(new Error('custody_request_too_large'));
88 }
89 return new Promise((resolve, reject) => {
90 const timer = setTimeout(() => {
91 pending.delete(id);
92 reject(new Error('custody_request_timeout'));
93 }, timeoutMs);
94 timer.unref?.();
95 pending.set(id, { resolve, reject, timer });
96 socket.write(`${message}\n`, 'utf8', (error) => {
97 if (!error) return;
98 const entry = pending.get(id);
99 if (!entry) return;
100 pending.delete(id);
101 clearTimeout(timer);
102 reject(new Error('custody_connection_closed'));
103 });
104 });
105 }
106
107 return Object.freeze({
108 status: () => request('status'),
109 get: (account) => request('get', requireAccount(account)),
110 set: (account, value) => {
111 requireAccount(account);
112 if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value) > 8192) {
113 throw new TypeError('custody_client_invalid_value');
114 }
115 return request('set', account, value).then(() => undefined);
116 },
117 delete: (account) => request('delete', requireAccount(account)).then(() => undefined),
118 close: () => {
119 if (!closed) socket.destroy();
120 },
121 });
122 }
123
124 export function createCustodyKeychainAdapter(options) {
125 const client = createCustodyClient(options);
126 return Object.freeze({
127 get: (account) => client.get(account),
128 set: (account, value) => client.set(account, value),
129 delete: (account) => client.delete(account),
130 status: () => client.status(),
131 close: () => client.close(),
132 });
133 }
134
135 export const CUSTODY_ALLOWED_ACCOUNTS = Object.freeze([...ALLOWED_ACCOUNTS].sort());
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago