@smartmemory/compose 0.2.46-beta → 0.2.48-beta

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 (71) hide show
  1. package/README.md +17 -0
  2. package/bin/compose.js +35 -1
  3. package/dist/assets/{App-CZNprfTP.js → App-D7E7S49Q.js} +163 -153
  4. package/dist/assets/{arc-D7Ql7gBh.js → arc-LOW60tiI.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-CMzqpLWw.js → architectureDiagram-3BPJPVTR-BDY00hRy.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-CbW2AYzk.js → blockDiagram-GPEHLZMM-DiC_6ktq.js} +1 -1
  7. package/dist/assets/browser-DW2JlJCC.js +8 -0
  8. package/dist/assets/{c4Diagram-AAUBKEIU-C89TIEqM.js → c4Diagram-AAUBKEIU-CXZC8woU.js} +1 -1
  9. package/dist/assets/channel-Dh96pu1k.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-jLQK9et3.js → chunk-2J33WTMH-b8Rdk4S-.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-bH25OCUA.js → chunk-4BX2VUAB-BSxn03f-.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-Dhb0bOrW.js → chunk-55IACEB6-uUk1WzZ1.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-CBfeQuSd.js → chunk-727SXJPM-zvXhT6iZ.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-CIxa5qRv.js → chunk-AQP2D5EJ-B6GCyrIp.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-CmXvjRAO.js → chunk-FMBD7UC4-DlhrqkBp.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-DRs6NTTH.js → chunk-ND2GUHAM-lo_8kNzw.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-BmkcOtBV.js → chunk-QZHKN3VN-on-yD-C0.js} +1 -1
  18. package/dist/assets/classDiagram-4FO5ZUOK-BCqpDcaS.js +1 -0
  19. package/dist/assets/classDiagram-v2-Q7XG4LA2-BCqpDcaS.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-BZwhzUqa.js → cose-bilkent-S5V4N54A-DxV7Y2bU.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-CEHe9vzv.js → dagre-BM42HDAG-BHf9sURy.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-Cq8RCIdY.js → diagram-2AECGRRQ-R9Puxzlh.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-DCaYgQ-u.js → diagram-5GNKFQAL-BbjlClKx.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF-DehQMQAE.js → diagram-KO2AKTUF-r89Mx4m1.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-BUJ8pJCq.js → diagram-LMA3HP47-CstVDJZ3.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-BrqALaH3.js → diagram-OG6HWLK6-xmX57FKh.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-Cz2hnPqN.js → erDiagram-TEJ5UH35-DhLPxvrl.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-BpPrbE4K.js → flowDiagram-I6XJVG4X-BExoUaNm.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-Dne2jtVu.js → ganttDiagram-6RSMTGT7-BEXa5a22.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-W8OpyxIG.js → gitGraphDiagram-PVQCEYII-Cbes9api.js} +1 -1
  31. package/dist/assets/{graph-viND0NdB.js → graph-BBXaecIU.js} +1 -1
  32. package/dist/assets/index-BwLfbbOu.css +1 -0
  33. package/dist/assets/{index-BVnbNZXC.js → index-DNuHtZwR.js} +22 -22
  34. package/dist/assets/{infoDiagram-5YYISTIA-4OeddgjS.js → infoDiagram-5YYISTIA-UBm4ssfm.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-YF4QCWOH-D0XwMcCq.js → ishikawaDiagram-YF4QCWOH-CksBYHCF.js} +1 -1
  36. package/dist/assets/{journeyDiagram-JHISSGLW-CO0wIeDT.js → journeyDiagram-JHISSGLW-CKHkIHky.js} +1 -1
  37. package/dist/assets/{kanban-definition-UN3LZRKU-CLU0ciYv.js → kanban-definition-UN3LZRKU-CEahMFzE.js} +1 -1
  38. package/dist/assets/{linear-BOFLe2h1.js → linear-CqLVtYRk.js} +1 -1
  39. package/dist/assets/{mindmap-definition-RKZ34NQL-ByuwlkdD.js → mindmap-definition-RKZ34NQL-C61zWt4M.js} +1 -1
  40. package/dist/assets/mobile-DwmxS_O4.js +17 -0
  41. package/dist/assets/{pieDiagram-4H26LBE5-CPm2BXke.js → pieDiagram-4H26LBE5-BIpZHyJW.js} +1 -1
  42. package/dist/assets/{quadrantDiagram-W4KKPZXB-CR3NLvwU.js → quadrantDiagram-W4KKPZXB-Db7nRbZm.js} +1 -1
  43. package/dist/assets/{requirementDiagram-4Y6WPE33-D4vpyryW.js → requirementDiagram-4Y6WPE33-BQqFCAr0.js} +1 -1
  44. package/dist/assets/{sankeyDiagram-5OEKKPKP--Clp0eo-.js → sankeyDiagram-5OEKKPKP-D-oAAMci.js} +1 -1
  45. package/dist/assets/{sequenceDiagram-3UESZ5HK-B1xrJkMo.js → sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js} +1 -1
  46. package/dist/assets/{stateDiagram-AJRCARHV-D5w5yQql.js → stateDiagram-AJRCARHV-B5DkQ8Pr.js} +1 -1
  47. package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +1 -0
  48. package/dist/assets/{timeline-definition-PNZ67QCA-DxOINQqf.js → timeline-definition-PNZ67QCA-DU9Fl-FW.js} +1 -1
  49. package/dist/assets/{vennDiagram-CIIHVFJN-B3Wss4do.js → vennDiagram-CIIHVFJN-Bthz7siw.js} +1 -1
  50. package/dist/assets/{wardley-L42UT6IY-nYDkwvlu.js → wardley-L42UT6IY-nqdlFSIA.js} +1 -1
  51. package/dist/assets/{wardleyDiagram-YWT4CUSO-BMM3myEL.js → wardleyDiagram-YWT4CUSO-BJ1BC5zc.js} +1 -1
  52. package/dist/assets/{xychartDiagram-2RQKCTM6-B5ElCNMx.js → xychartDiagram-2RQKCTM6-Bib5JrbR.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/lib/cli-remote.js +533 -0
  55. package/package.json +3 -1
  56. package/server/auth-middleware.js +309 -0
  57. package/server/auth-routes.js +195 -0
  58. package/server/auth-store.js +539 -0
  59. package/server/build-routes.js +1 -1
  60. package/server/index.js +134 -7
  61. package/server/remote-utils.js +134 -0
  62. package/server/security.js +46 -1
  63. package/server/supervisor.js +7 -1
  64. package/server/vision-routes.js +1 -1
  65. package/server/vision-server.js +1 -1
  66. package/dist/assets/channel-sjm4Ncyv.js +0 -1
  67. package/dist/assets/classDiagram-4FO5ZUOK-LkIQdBsc.js +0 -1
  68. package/dist/assets/classDiagram-v2-Q7XG4LA2-LkIQdBsc.js +0 -1
  69. package/dist/assets/index-D9M5IzwO.css +0 -1
  70. package/dist/assets/mobile-B-d4crm_.js +0 -17
  71. package/dist/assets/stateDiagram-v2-BHNVJYJU-TPU-mOqi.js +0 -1
@@ -0,0 +1,539 @@
1
+ /**
2
+ * auth-store.js — COMP-MOBILE-REMOTE S01
3
+ *
4
+ * Device store for remote pairing auth:
5
+ * - Persistent state at <dataDir>/remote-auth.json (atomic temp+rename writes)
6
+ * - HS256 JWT sign/verify on node:crypto (no external dep, alg hardcoded)
7
+ * - Pairing codes: in-memory Map, 5-min TTL, single-use
8
+ * - Refresh token rotation + history ring (5) + reuse-revoke
9
+ * - rotateSecret, listDevices, revokeDevice, touchDevice
10
+ * - Audit log: JSONL appended to <dataDir>/remote-auth-audit.log
11
+ *
12
+ * @module server/auth-store
13
+ */
14
+
15
+ import {
16
+ readFileSync,
17
+ writeFileSync,
18
+ mkdirSync,
19
+ appendFileSync,
20
+ existsSync,
21
+ renameSync,
22
+ } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { randomBytes, createHmac, timingSafeEqual, createHash } from 'node:crypto';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const STATE_FILE = 'remote-auth.json';
31
+ const AUDIT_FILE = 'remote-auth-audit.log';
32
+
33
+ /**
34
+ * Fixed accepted JWT header base64url: base64url({"alg":"HS256","typ":"JWT"})
35
+ * Any other header bytes are rejected — no algorithm negotiation.
36
+ */
37
+ const FIXED_HEADER_B64 = Buffer.from(
38
+ JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
39
+ ).toString('base64url');
40
+
41
+ const PAIRING_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
42
+ const REFRESH_HISTORY_MAX = 5;
43
+ const TOUCH_THROTTLE_MS = 60_000; // once per 60s per device
44
+
45
+ /**
46
+ * QR/typing-friendly alphabet: uppercase A-Z + digits 2-9.
47
+ * Omits 0, 1, O, I to avoid visual confusion.
48
+ * 32 characters → 5 bits of entropy per char; 9 bytes → 9 chars (~45 bits).
49
+ */
50
+ const CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Internal helpers (module-level, pure)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function toBase64url(buf) {
57
+ return buf.toString('base64url');
58
+ }
59
+
60
+ function fromBase64url(str) {
61
+ return Buffer.from(str, 'base64url');
62
+ }
63
+
64
+ /** SHA-256 of a Buffer, returned as hex string. */
65
+ function sha256hex(buf) {
66
+ return createHash('sha256').update(buf).digest('hex');
67
+ }
68
+
69
+ /**
70
+ * Map 9 random bytes to a 9-char code from CODE_ALPHABET.
71
+ * Each byte is taken modulo alphabet length.
72
+ */
73
+ function bytesToCode(bytes) {
74
+ let code = '';
75
+ for (const b of bytes) {
76
+ code += CODE_ALPHABET[b % CODE_ALPHABET.length];
77
+ }
78
+ return code;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Factory
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Create an auth store backed by <dataDir>.
87
+ *
88
+ * @param {string} dataDir Directory for persistent files (e.g. .compose/data)
89
+ * @returns {{ signAccessToken, verifyAccessToken, createPairingCode,
90
+ * getPairingCodeStatus, consumePairingCode, refresh,
91
+ * listDevices, revokeDevice, rotateSecret, touchDevice }}
92
+ */
93
+ export function createAuthStore(dataDir) {
94
+ /**
95
+ * In-memory pairing codes.
96
+ * code → { expires_ts: number, consumed: boolean }
97
+ */
98
+ const _pairingCodes = new Map();
99
+
100
+ /** Per-device last-touch timestamps for write-storm throttle. */
101
+ const _lastTouch = new Map();
102
+
103
+ // -------------------------------------------------------------------------
104
+ // Persistence helpers
105
+ // -------------------------------------------------------------------------
106
+
107
+ function _statePath() {
108
+ return join(dataDir, STATE_FILE);
109
+ }
110
+
111
+ function _auditPath() {
112
+ return join(dataDir, AUDIT_FILE);
113
+ }
114
+
115
+ /**
116
+ * Read state from disk. Returns a default skeleton if the file is missing
117
+ * or corrupted.
118
+ */
119
+ function _readState() {
120
+ const p = _statePath();
121
+ if (existsSync(p)) {
122
+ try {
123
+ return JSON.parse(readFileSync(p, 'utf8'));
124
+ } catch {
125
+ // corrupted — fall through to default
126
+ }
127
+ }
128
+ return { secret: null, devices: [] };
129
+ }
130
+
131
+ /**
132
+ * Atomic write: write to <path>.tmp then rename into place.
133
+ * Same pattern as lib/build.js writeActiveBuild.
134
+ */
135
+ function _persist(state) {
136
+ mkdirSync(dataDir, { recursive: true });
137
+ const target = _statePath();
138
+ const tmp = target + '.tmp';
139
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
140
+ renameSync(tmp, target);
141
+ }
142
+
143
+ // -------------------------------------------------------------------------
144
+ // Secret bootstrap
145
+ // -------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Ensure state has a secret; if not, generate and persist one.
149
+ * Mutates state in place.
150
+ */
151
+ function _ensureSecret(state) {
152
+ if (!state.secret) {
153
+ state.secret = randomBytes(32).toString('hex');
154
+ _persist(state);
155
+ }
156
+ return state.secret;
157
+ }
158
+
159
+ /** Read state and return the signing secret, generating if absent. */
160
+ function _getSecret() {
161
+ const state = _readState();
162
+ return _ensureSecret(state);
163
+ }
164
+
165
+ // -------------------------------------------------------------------------
166
+ // Audit log
167
+ // -------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Append a JSONL audit line. Never throws.
171
+ *
172
+ * @param {string} event - pair | refresh | revoke | reuse-revoke | rotate-secret
173
+ * @param {string} [device_id]
174
+ */
175
+ function _audit(event, device_id) {
176
+ try {
177
+ mkdirSync(dataDir, { recursive: true });
178
+ const record = { ts: new Date().toISOString(), event };
179
+ if (device_id) record.device_id = device_id;
180
+ appendFileSync(_auditPath(), JSON.stringify(record) + '\n');
181
+ } catch {
182
+ // best-effort — must not throw
183
+ }
184
+ }
185
+
186
+ // -------------------------------------------------------------------------
187
+ // JWT sign / verify
188
+ // -------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Sign an HS256 access JWT.
192
+ *
193
+ * Header is always {"alg":"HS256","typ":"JWT"}.
194
+ * Claims: { sub: device.id, name: device.name, iat, exp }
195
+ * TTL from ACCESS_TOKEN_TTL env (seconds), default 900 (15 min).
196
+ *
197
+ * @param {{ id: string, name: string }} device
198
+ * @returns {string}
199
+ */
200
+ function signAccessToken(device) {
201
+ const secret = _getSecret();
202
+ const ttl = parseInt(process.env.ACCESS_TOKEN_TTL || '900', 10);
203
+ const iat = Math.floor(Date.now() / 1000);
204
+ const exp = iat + ttl;
205
+
206
+ const header = FIXED_HEADER_B64;
207
+ const payload = toBase64url(
208
+ Buffer.from(JSON.stringify({ sub: device.id, name: device.name, iat, exp })),
209
+ );
210
+ const data = `${header}.${payload}`;
211
+ const sig = toBase64url(createHmac('sha256', secret).update(data).digest());
212
+ return `${data}.${sig}`;
213
+ }
214
+
215
+ /**
216
+ * Verify an HS256 JWT.
217
+ *
218
+ * Steps:
219
+ * 1. Must split into exactly 3 parts
220
+ * 2. Header must be byte-identical to FIXED_HEADER_B64 (rejects alg:none etc.)
221
+ * 3. HMAC-SHA256 signature validated with timingSafeEqual
222
+ * 4. exp checked with ±30s clock skew
223
+ *
224
+ * @param {string} jwt
225
+ * @returns {{ ok: true, device_id: string, name: string }
226
+ * |{ ok: false, code: 'TokenExpired'|'TokenInvalid' }}
227
+ */
228
+ function verifyAccessToken(jwt) {
229
+ if (typeof jwt !== 'string') return { ok: false, code: 'TokenInvalid' };
230
+
231
+ const parts = jwt.split('.');
232
+ if (parts.length !== 3) return { ok: false, code: 'TokenInvalid' };
233
+
234
+ const [headerB64, payloadB64, sigB64] = parts;
235
+
236
+ // 1. Reject any header that is not exactly the fixed accepted one
237
+ if (headerB64 !== FIXED_HEADER_B64) return { ok: false, code: 'TokenInvalid' };
238
+
239
+ // 2. Verify signature
240
+ let secret;
241
+ try {
242
+ secret = _getSecret();
243
+ } catch {
244
+ return { ok: false, code: 'TokenInvalid' };
245
+ }
246
+
247
+ const data = `${headerB64}.${payloadB64}`;
248
+ const expected = createHmac('sha256', secret).update(data).digest();
249
+
250
+ let provided;
251
+ try {
252
+ provided = fromBase64url(sigB64);
253
+ } catch {
254
+ return { ok: false, code: 'TokenInvalid' };
255
+ }
256
+ // timingSafeEqual requires equal-length buffers
257
+ if (expected.length !== provided.length) return { ok: false, code: 'TokenInvalid' };
258
+ if (!timingSafeEqual(expected, provided)) return { ok: false, code: 'TokenInvalid' };
259
+
260
+ // 3. Decode payload
261
+ let claims;
262
+ try {
263
+ claims = JSON.parse(fromBase64url(payloadB64).toString('utf8'));
264
+ } catch {
265
+ return { ok: false, code: 'TokenInvalid' };
266
+ }
267
+
268
+ // 4. Check expiry with ±30s skew
269
+ const now = Math.floor(Date.now() / 1000);
270
+ if (typeof claims.exp !== 'number' || now > claims.exp + 30) {
271
+ return { ok: false, code: 'TokenExpired' };
272
+ }
273
+
274
+ return { ok: true, device_id: claims.sub, name: claims.name };
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Pairing codes
279
+ // -------------------------------------------------------------------------
280
+
281
+ /** Sweep expired entries from the in-memory code Map. */
282
+ function _sweepCodes() {
283
+ const now = Date.now();
284
+ for (const [code, entry] of _pairingCodes) {
285
+ if (entry.expires_ts <= now) _pairingCodes.delete(code);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Generate a new single-use pairing code (5-min TTL).
291
+ *
292
+ * @returns {{ code: string, expires_at: string }}
293
+ */
294
+ function createPairingCode() {
295
+ _sweepCodes();
296
+ const bytes = randomBytes(9);
297
+ const code = bytesToCode(bytes);
298
+ const expires_ts = Date.now() + PAIRING_CODE_TTL_MS;
299
+ _pairingCodes.set(code, { expires_ts, consumed: false });
300
+ return { code, expires_at: new Date(expires_ts).toISOString() };
301
+ }
302
+
303
+ /**
304
+ * Get the status of a pairing code.
305
+ *
306
+ * @param {string} code
307
+ * @returns {'pending'|'consumed'|'expired'}
308
+ */
309
+ function getPairingCodeStatus(code) {
310
+ const entry = _pairingCodes.get(code);
311
+ if (!entry) return 'expired';
312
+ // Check expiry before consumed so an expired-and-consumed code reports expired
313
+ // (consistent with: if the code is past TTL, it can't be used regardless)
314
+ if (entry.expires_ts <= Date.now()) {
315
+ _pairingCodes.delete(code); // lazy cleanup
316
+ return 'expired';
317
+ }
318
+ if (entry.consumed) return 'consumed';
319
+ return 'pending';
320
+ }
321
+
322
+ /**
323
+ * Consume a pairing code, create a device, and return tokens.
324
+ *
325
+ * @param {string} code
326
+ * @param {{ name?: string, user_agent?: string }} opts
327
+ * @returns {{ device, access_token, refresh_token, expires_in }
328
+ * |{ error: 'CodeInvalid'|'CodeExpired' }}
329
+ */
330
+ function consumePairingCode(code, { name = 'Unknown Device', user_agent = '' } = {}) {
331
+ const entry = _pairingCodes.get(code);
332
+ if (!entry) {
333
+ // Could be a genuinely unknown code or one already swept.
334
+ // Treat as invalid (no oracle about whether it ever existed).
335
+ return { error: 'CodeInvalid' };
336
+ }
337
+ // Check expiry before the consumed flag so expiry wins
338
+ if (entry.expires_ts <= Date.now()) {
339
+ _pairingCodes.delete(code);
340
+ return { error: 'CodeExpired' };
341
+ }
342
+ if (entry.consumed) return { error: 'CodeInvalid' };
343
+
344
+ // Mark consumed before any async/persist so a race can't double-consume
345
+ entry.consumed = true;
346
+
347
+ const state = _readState();
348
+ _ensureSecret(state);
349
+
350
+ // Build device record
351
+ const device = {
352
+ id: 'dev_' + randomBytes(8).toString('hex'),
353
+ name: String(name || 'Unknown Device'),
354
+ user_agent: String(user_agent || '').slice(0, 512),
355
+ paired_at: new Date().toISOString(),
356
+ last_seen: new Date().toISOString(),
357
+ refresh_hash: null,
358
+ refresh_history: [],
359
+ revoked: false,
360
+ };
361
+
362
+ // Refresh token: <device_id>.<base64url(32 random bytes)>
363
+ const randomPart = randomBytes(32);
364
+ const randomB64 = toBase64url(randomPart);
365
+ device.refresh_hash = sha256hex(randomPart);
366
+
367
+ state.devices.push(device);
368
+ _persist(state);
369
+ _audit('pair', device.id);
370
+
371
+ const access_token = signAccessToken(device);
372
+ const ttl = parseInt(process.env.ACCESS_TOKEN_TTL || '900', 10);
373
+
374
+ return {
375
+ device,
376
+ access_token,
377
+ refresh_token: `${device.id}.${randomB64}`,
378
+ expires_in: ttl,
379
+ };
380
+ }
381
+
382
+ // -------------------------------------------------------------------------
383
+ // Refresh token rotation
384
+ // -------------------------------------------------------------------------
385
+
386
+ /**
387
+ * Rotate a refresh token, issuing new access + refresh tokens.
388
+ *
389
+ * Format: "<device_id>.<base64url(32 random bytes)>"
390
+ * Only sha256(random part) is stored server-side.
391
+ *
392
+ * Reuse detection: if the presented hash matches refresh_history,
393
+ * the device is immediately revoked and { error: 'TokenInvalid' } returned.
394
+ *
395
+ * @param {string} refreshToken
396
+ * @returns {{ access_token, refresh_token, expires_in }
397
+ * |{ error: 'TokenInvalid' }}
398
+ */
399
+ function refresh(refreshToken) {
400
+ if (typeof refreshToken !== 'string') return { error: 'TokenInvalid' };
401
+
402
+ const dotIdx = refreshToken.indexOf('.');
403
+ if (dotIdx < 0) return { error: 'TokenInvalid' };
404
+
405
+ const deviceId = refreshToken.slice(0, dotIdx);
406
+ const randomB64 = refreshToken.slice(dotIdx + 1);
407
+
408
+ let randomBuf;
409
+ try {
410
+ randomBuf = fromBase64url(randomB64);
411
+ } catch {
412
+ return { error: 'TokenInvalid' };
413
+ }
414
+ const presentedHash = sha256hex(randomBuf);
415
+
416
+ const state = _readState();
417
+ _ensureSecret(state);
418
+
419
+ const device = state.devices.find((d) => d.id === deviceId);
420
+ if (!device) return { error: 'TokenInvalid' };
421
+ if (device.revoked) return { error: 'TokenInvalid' };
422
+
423
+ // Check history first — reuse detection
424
+ const inHistory = (device.refresh_history || []).some((h) => h.hash === presentedHash);
425
+ if (inHistory) {
426
+ device.revoked = true;
427
+ _persist(state);
428
+ _audit('reuse-revoke', device.id);
429
+ return { error: 'TokenInvalid' };
430
+ }
431
+
432
+ // Check current hash
433
+ if (device.refresh_hash !== presentedHash) {
434
+ return { error: 'TokenInvalid' };
435
+ }
436
+
437
+ // Rotate: retire current into history (capped at REFRESH_HISTORY_MAX, newest first)
438
+ const retiredEntry = { hash: device.refresh_hash, retired_at: new Date().toISOString() };
439
+ device.refresh_history = [retiredEntry, ...(device.refresh_history || [])].slice(
440
+ 0,
441
+ REFRESH_HISTORY_MAX,
442
+ );
443
+
444
+ // Issue new refresh token
445
+ const newRandom = randomBytes(32);
446
+ const newRandomB64 = toBase64url(newRandom);
447
+ device.refresh_hash = sha256hex(newRandom);
448
+ device.last_seen = new Date().toISOString();
449
+
450
+ // Single atomic write for rotation + history
451
+ _persist(state);
452
+ _audit('refresh', device.id);
453
+
454
+ const access_token = signAccessToken(device);
455
+ const ttl = parseInt(process.env.ACCESS_TOKEN_TTL || '900', 10);
456
+
457
+ return {
458
+ access_token,
459
+ refresh_token: `${device.id}.${newRandomB64}`,
460
+ expires_in: ttl,
461
+ };
462
+ }
463
+
464
+ // -------------------------------------------------------------------------
465
+ // Device management
466
+ // -------------------------------------------------------------------------
467
+
468
+ /**
469
+ * List all devices without internal hash fields.
470
+ *
471
+ * @returns {object[]}
472
+ */
473
+ function listDevices() {
474
+ const state = _readState();
475
+ return (state.devices || []).map(
476
+ // eslint-disable-next-line no-unused-vars
477
+ ({ refresh_hash, refresh_history, ...rest }) => rest,
478
+ );
479
+ }
480
+
481
+ /**
482
+ * Revoke a device by id (sets revoked: true, persists, audits).
483
+ *
484
+ * @param {string} id
485
+ * @returns {boolean} true if found
486
+ */
487
+ function revokeDevice(id) {
488
+ const state = _readState();
489
+ const device = state.devices.find((d) => d.id === id);
490
+ if (!device) return false;
491
+ device.revoked = true;
492
+ _persist(state);
493
+ _audit('revoke', id);
494
+ return true;
495
+ }
496
+
497
+ /**
498
+ * Rotate the JWT signing secret.
499
+ * All outstanding JWTs become invalid immediately.
500
+ * Device records and refresh tokens are unchanged.
501
+ */
502
+ function rotateSecret() {
503
+ const state = _readState();
504
+ state.secret = randomBytes(32).toString('hex');
505
+ _persist(state);
506
+ _audit('rotate-secret');
507
+ }
508
+
509
+ /**
510
+ * Update last_seen for a device, throttled to once per 60s.
511
+ *
512
+ * @param {string} id
513
+ */
514
+ function touchDevice(id) {
515
+ const now = Date.now();
516
+ const last = _lastTouch.get(id) || 0;
517
+ if (now - last < TOUCH_THROTTLE_MS) return;
518
+ _lastTouch.set(id, now);
519
+
520
+ const state = _readState();
521
+ const device = state.devices.find((d) => d.id === id);
522
+ if (!device) return;
523
+ device.last_seen = new Date().toISOString();
524
+ _persist(state);
525
+ }
526
+
527
+ return {
528
+ signAccessToken,
529
+ verifyAccessToken,
530
+ createPairingCode,
531
+ getPairingCodeStatus,
532
+ consumePairingCode,
533
+ refresh,
534
+ listDevices,
535
+ revokeDevice,
536
+ rotateSecret,
537
+ touchDevice,
538
+ };
539
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { runBuild as defaultRunBuild, abortBuild as defaultAbortBuild } from '../lib/build.js';
17
- import { requireSensitiveToken } from './security.js';
17
+ import { requireSensitiveOrPaired as requireSensitiveToken } from './security.js';
18
18
  import { getDataDir as defaultGetDataDir } from './project-root.js';
19
19
  import { readBuildHistory } from '../lib/build-history.js';
20
20