@smartmemory/compose 0.2.45-beta → 0.2.47-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.
- package/README.md +17 -0
- package/bin/compose.js +35 -1
- package/dist/assets/{App-DQblyoua.js → App-D7E7S49Q.js} +163 -153
- package/dist/assets/{arc-B4fPSjcd.js → arc-LOW60tiI.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-SiqLJ2Dv.js → architectureDiagram-3BPJPVTR-BDY00hRy.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-DgA84B2f.js → blockDiagram-GPEHLZMM-DiC_6ktq.js} +1 -1
- package/dist/assets/browser-DW2JlJCC.js +8 -0
- package/dist/assets/{c4Diagram-AAUBKEIU-B-tXpPJU.js → c4Diagram-AAUBKEIU-CXZC8woU.js} +1 -1
- package/dist/assets/channel-Dh96pu1k.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-Ch2s7Kg8.js → chunk-2J33WTMH-b8Rdk4S-.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-B0CaezI4.js → chunk-4BX2VUAB-BSxn03f-.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B7bap9LR.js → chunk-55IACEB6-uUk1WzZ1.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-CMksbsjL.js → chunk-727SXJPM-zvXhT6iZ.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-ChOCnmMe.js → chunk-AQP2D5EJ-B6GCyrIp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-qcUlmmEN.js → chunk-FMBD7UC4-DlhrqkBp.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-BWwR4JBR.js → chunk-ND2GUHAM-lo_8kNzw.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DuRbBL0q.js → chunk-QZHKN3VN-on-yD-C0.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-BCqpDcaS.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-BCqpDcaS.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Dd3Ids4E.js → cose-bilkent-S5V4N54A-DxV7Y2bU.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-C2fmFG1L.js → dagre-BM42HDAG-BHf9sURy.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-gZjYzjSJ.js → diagram-2AECGRRQ-R9Puxzlh.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-uxJ9zL35.js → diagram-5GNKFQAL-BbjlClKx.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF--9DBGjk3.js → diagram-KO2AKTUF-r89Mx4m1.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-DkhUcRJZ.js → diagram-LMA3HP47-CstVDJZ3.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-BaJ__gNW.js → diagram-OG6HWLK6-xmX57FKh.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-DWszA5vM.js → erDiagram-TEJ5UH35-DhLPxvrl.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-DKGbnIHA.js → flowDiagram-I6XJVG4X-BExoUaNm.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-D5CoNr7u.js → ganttDiagram-6RSMTGT7-BEXa5a22.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-wraCb0iO.js → gitGraphDiagram-PVQCEYII-Cbes9api.js} +1 -1
- package/dist/assets/{graph-BlEwG9Ro.js → graph-BBXaecIU.js} +1 -1
- package/dist/assets/index-BwLfbbOu.css +1 -0
- package/dist/assets/{index-CE1ehTQn.js → index-DNuHtZwR.js} +22 -22
- package/dist/assets/{infoDiagram-5YYISTIA-CLT2YJ_w.js → infoDiagram-5YYISTIA-UBm4ssfm.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-1WQ5ViOx.js → ishikawaDiagram-YF4QCWOH-CksBYHCF.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-CbYKANnV.js → journeyDiagram-JHISSGLW-CKHkIHky.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-CYNaaEmw.js → kanban-definition-UN3LZRKU-CEahMFzE.js} +1 -1
- package/dist/assets/{linear-C6l6Hee2.js → linear-CqLVtYRk.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-DzXKDTRa.js → mindmap-definition-RKZ34NQL-C61zWt4M.js} +1 -1
- package/dist/assets/{mobile-D8UQvJ1i.css → mobile-Cag5dHlF.css} +1 -1
- package/dist/assets/mobile-DwmxS_O4.js +17 -0
- package/dist/assets/{pieDiagram-4H26LBE5-CppszhAX.js → pieDiagram-4H26LBE5-BIpZHyJW.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-jpp555QD.js → quadrantDiagram-W4KKPZXB-Db7nRbZm.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-nKJCyHfW.js → requirementDiagram-4Y6WPE33-BQqFCAr0.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-D-oX8o5g.js → sankeyDiagram-5OEKKPKP-D-oAAMci.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-CcOMecR8.js → sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-D0yHv9PM.js → stateDiagram-AJRCARHV-B5DkQ8Pr.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-hsR-T0Te.js → timeline-definition-PNZ67QCA-DU9Fl-FW.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-C9b10oqu.js → vennDiagram-CIIHVFJN-Bthz7siw.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-BHnzDf0e.js → wardley-L42UT6IY-nqdlFSIA.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-DReamCcN.js → wardleyDiagram-YWT4CUSO-BJ1BC5zc.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-CrSsH0ZL.js → xychartDiagram-2RQKCTM6-Bib5JrbR.js} +1 -1
- package/dist/index.html +4 -4
- package/lib/build-history.js +40 -0
- package/lib/build.js +56 -8
- package/lib/cli-remote.js +533 -0
- package/package.json +3 -1
- package/server/auth-middleware.js +309 -0
- package/server/auth-routes.js +195 -0
- package/server/auth-store.js +539 -0
- package/server/build-routes.js +1 -1
- package/server/index.js +134 -7
- package/server/remote-utils.js +134 -0
- package/server/security.js +46 -1
- package/server/supervisor.js +7 -1
- package/server/vision-routes.js +1 -1
- package/server/vision-server.js +1 -1
- package/dist/assets/channel-D60Ea_Qi.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-Vt88epYI.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-Vt88epYI.js +0 -1
- package/dist/assets/index-D9M5IzwO.css +0 -1
- package/dist/assets/mobile-4lQex-UI.js +0 -17
- package/dist/assets/stateDiagram-v2-BHNVJYJU-CFwFamMD.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
|
+
}
|
package/server/build-routes.js
CHANGED
|
@@ -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
|
|