@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.
Files changed (74) hide show
  1. package/README.md +17 -0
  2. package/bin/compose.js +35 -1
  3. package/dist/assets/{App-DQblyoua.js → App-D7E7S49Q.js} +163 -153
  4. package/dist/assets/{arc-B4fPSjcd.js → arc-LOW60tiI.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-SiqLJ2Dv.js → architectureDiagram-3BPJPVTR-BDY00hRy.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-DgA84B2f.js → blockDiagram-GPEHLZMM-DiC_6ktq.js} +1 -1
  7. package/dist/assets/browser-DW2JlJCC.js +8 -0
  8. package/dist/assets/{c4Diagram-AAUBKEIU-B-tXpPJU.js → c4Diagram-AAUBKEIU-CXZC8woU.js} +1 -1
  9. package/dist/assets/channel-Dh96pu1k.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-Ch2s7Kg8.js → chunk-2J33WTMH-b8Rdk4S-.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-B0CaezI4.js → chunk-4BX2VUAB-BSxn03f-.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-B7bap9LR.js → chunk-55IACEB6-uUk1WzZ1.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-CMksbsjL.js → chunk-727SXJPM-zvXhT6iZ.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-ChOCnmMe.js → chunk-AQP2D5EJ-B6GCyrIp.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-qcUlmmEN.js → chunk-FMBD7UC4-DlhrqkBp.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-BWwR4JBR.js → chunk-ND2GUHAM-lo_8kNzw.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-DuRbBL0q.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-Dd3Ids4E.js → cose-bilkent-S5V4N54A-DxV7Y2bU.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-C2fmFG1L.js → dagre-BM42HDAG-BHf9sURy.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-gZjYzjSJ.js → diagram-2AECGRRQ-R9Puxzlh.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-uxJ9zL35.js → diagram-5GNKFQAL-BbjlClKx.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF--9DBGjk3.js → diagram-KO2AKTUF-r89Mx4m1.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-DkhUcRJZ.js → diagram-LMA3HP47-CstVDJZ3.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-BaJ__gNW.js → diagram-OG6HWLK6-xmX57FKh.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-DWszA5vM.js → erDiagram-TEJ5UH35-DhLPxvrl.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-DKGbnIHA.js → flowDiagram-I6XJVG4X-BExoUaNm.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-D5CoNr7u.js → ganttDiagram-6RSMTGT7-BEXa5a22.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-wraCb0iO.js → gitGraphDiagram-PVQCEYII-Cbes9api.js} +1 -1
  31. package/dist/assets/{graph-BlEwG9Ro.js → graph-BBXaecIU.js} +1 -1
  32. package/dist/assets/index-BwLfbbOu.css +1 -0
  33. package/dist/assets/{index-CE1ehTQn.js → index-DNuHtZwR.js} +22 -22
  34. package/dist/assets/{infoDiagram-5YYISTIA-CLT2YJ_w.js → infoDiagram-5YYISTIA-UBm4ssfm.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-YF4QCWOH-1WQ5ViOx.js → ishikawaDiagram-YF4QCWOH-CksBYHCF.js} +1 -1
  36. package/dist/assets/{journeyDiagram-JHISSGLW-CbYKANnV.js → journeyDiagram-JHISSGLW-CKHkIHky.js} +1 -1
  37. package/dist/assets/{kanban-definition-UN3LZRKU-CYNaaEmw.js → kanban-definition-UN3LZRKU-CEahMFzE.js} +1 -1
  38. package/dist/assets/{linear-C6l6Hee2.js → linear-CqLVtYRk.js} +1 -1
  39. package/dist/assets/{mindmap-definition-RKZ34NQL-DzXKDTRa.js → mindmap-definition-RKZ34NQL-C61zWt4M.js} +1 -1
  40. package/dist/assets/{mobile-D8UQvJ1i.css → mobile-Cag5dHlF.css} +1 -1
  41. package/dist/assets/mobile-DwmxS_O4.js +17 -0
  42. package/dist/assets/{pieDiagram-4H26LBE5-CppszhAX.js → pieDiagram-4H26LBE5-BIpZHyJW.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-W4KKPZXB-jpp555QD.js → quadrantDiagram-W4KKPZXB-Db7nRbZm.js} +1 -1
  44. package/dist/assets/{requirementDiagram-4Y6WPE33-nKJCyHfW.js → requirementDiagram-4Y6WPE33-BQqFCAr0.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-5OEKKPKP-D-oX8o5g.js → sankeyDiagram-5OEKKPKP-D-oAAMci.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-3UESZ5HK-CcOMecR8.js → sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js} +1 -1
  47. package/dist/assets/{stateDiagram-AJRCARHV-D0yHv9PM.js → stateDiagram-AJRCARHV-B5DkQ8Pr.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +1 -0
  49. package/dist/assets/{timeline-definition-PNZ67QCA-hsR-T0Te.js → timeline-definition-PNZ67QCA-DU9Fl-FW.js} +1 -1
  50. package/dist/assets/{vennDiagram-CIIHVFJN-C9b10oqu.js → vennDiagram-CIIHVFJN-Bthz7siw.js} +1 -1
  51. package/dist/assets/{wardley-L42UT6IY-BHnzDf0e.js → wardley-L42UT6IY-nqdlFSIA.js} +1 -1
  52. package/dist/assets/{wardleyDiagram-YWT4CUSO-DReamCcN.js → wardleyDiagram-YWT4CUSO-BJ1BC5zc.js} +1 -1
  53. package/dist/assets/{xychartDiagram-2RQKCTM6-CrSsH0ZL.js → xychartDiagram-2RQKCTM6-Bib5JrbR.js} +1 -1
  54. package/dist/index.html +4 -4
  55. package/lib/build-history.js +40 -0
  56. package/lib/build.js +56 -8
  57. package/lib/cli-remote.js +533 -0
  58. package/package.json +3 -1
  59. package/server/auth-middleware.js +309 -0
  60. package/server/auth-routes.js +195 -0
  61. package/server/auth-store.js +539 -0
  62. package/server/build-routes.js +1 -1
  63. package/server/index.js +134 -7
  64. package/server/remote-utils.js +134 -0
  65. package/server/security.js +46 -1
  66. package/server/supervisor.js +7 -1
  67. package/server/vision-routes.js +1 -1
  68. package/server/vision-server.js +1 -1
  69. package/dist/assets/channel-D60Ea_Qi.js +0 -1
  70. package/dist/assets/classDiagram-4FO5ZUOK-Vt88epYI.js +0 -1
  71. package/dist/assets/classDiagram-v2-Q7XG4LA2-Vt88epYI.js +0 -1
  72. package/dist/assets/index-D9M5IzwO.css +0 -1
  73. package/dist/assets/mobile-4lQex-UI.js +0 -17
  74. package/dist/assets/stateDiagram-v2-BHNVJYJU-CFwFamMD.js +0 -1
@@ -0,0 +1,309 @@
1
+ /**
2
+ * auth-middleware.js — COMP-MOBILE-REMOTE S01
3
+ *
4
+ * Express middleware for remote auth:
5
+ * - createRateLimiter — fixed-window per-IP limiter (no deps)
6
+ * - requirePairingToken — JWT-only gate, attaches req.device
7
+ * - requireSensitiveOrPaired — composite: sensitive token OR valid JWT
8
+ * - createAuthGate — default-deny gate with allowlist
9
+ * - wsUpgradeTokenOk — check ?token= on WS upgrade requests
10
+ *
11
+ * @module server/auth-middleware
12
+ */
13
+
14
+ import { parse as parseUrl } from 'node:url';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Rate limiter
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Create a fixed-window per-IP rate limiter.
22
+ *
23
+ * @param {{ windowMs?: number, max?: number }} [opts]
24
+ * @param {number} [opts.windowMs=60000] Window size in ms
25
+ * @param {number} [opts.max=10] Max requests per window per IP
26
+ * @returns {function} Express middleware
27
+ */
28
+ export function createRateLimiter({ windowMs = 60_000, max = 10 } = {}) {
29
+ /** ip → { count: number, windowStart: number } */
30
+ const _windows = new Map();
31
+
32
+ function _sweep() {
33
+ const now = Date.now();
34
+ for (const [ip, entry] of _windows) {
35
+ if (now - entry.windowStart >= windowMs) _windows.delete(ip);
36
+ }
37
+ }
38
+
39
+ return function rateLimiter(req, res, next) {
40
+ const ip = req.ip || req.socket?.remoteAddress || 'unknown';
41
+ const now = Date.now();
42
+
43
+ // Opportunistic sweep (amortized)
44
+ if (_windows.size > 500) _sweep();
45
+
46
+ let entry = _windows.get(ip);
47
+ if (!entry || now - entry.windowStart >= windowMs) {
48
+ entry = { count: 0, windowStart: now };
49
+ _windows.set(ip, entry);
50
+ }
51
+
52
+ entry.count += 1;
53
+ if (entry.count > max) {
54
+ const retryAfter = Math.ceil((windowMs - (now - entry.windowStart)) / 1000);
55
+ res.set('Retry-After', String(retryAfter));
56
+ return res.status(429).json({
57
+ error: 'Too many requests',
58
+ code: 'RateLimited',
59
+ });
60
+ }
61
+
62
+ next();
63
+ };
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // requirePairingToken
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Middleware: require a valid pairing JWT.
72
+ *
73
+ * Reads from:
74
+ * 1. Authorization: Bearer <jwt>
75
+ * 2. x-compose-token: <jwt> (back-compat)
76
+ *
77
+ * On success: attaches req.device = { id, name }, calls next().
78
+ * On failure: 401 { error, code: 'TokenExpired'|'TokenInvalid' }
79
+ *
80
+ * @param {object} store Auth store from createAuthStore
81
+ * @returns {function}
82
+ */
83
+ export function requirePairingToken(store) {
84
+ return function pairingTokenMiddleware(req, res, next) {
85
+ const jwt = _extractBearer(req);
86
+ if (!jwt) {
87
+ return res.status(401).json({ error: 'Unauthorized', code: 'TokenInvalid' });
88
+ }
89
+ const result = store.verifyAccessToken(jwt);
90
+ if (!result.ok) {
91
+ return res.status(401).json({ error: 'Unauthorized', code: result.code });
92
+ }
93
+ req.device = { id: result.device_id, name: result.name };
94
+ next();
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // requireSensitiveOrPaired
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Composite middleware factory: accepts EITHER the sensitive token OR a valid JWT.
104
+ *
105
+ * Behavior mirrors requireSensitiveToken exactly when no JWT is presented:
106
+ * - 503 when COMPOSE_API_TOKEN is unset (existing tests depend on this)
107
+ * - 401 when token mismatch
108
+ *
109
+ * @param {object} store Auth store from createAuthStore
110
+ * @returns {function} Express middleware
111
+ */
112
+ export function requireSensitiveOrPaired(store) {
113
+ return function sensitiveOrPairedMiddleware(req, res, next) {
114
+ const expected = process.env.COMPOSE_API_TOKEN;
115
+
116
+ // Check if a JWT is presented first
117
+ const jwt = _extractBearer(req);
118
+ if (jwt) {
119
+ const result = store.verifyAccessToken(jwt);
120
+ if (result.ok) {
121
+ req.device = { id: result.device_id, name: result.name };
122
+ return next();
123
+ }
124
+ // JWT presented but invalid/expired — fall through to sensitive token check
125
+ // but only if no env token (503 parity): when no env token, the sensitive
126
+ // check would have returned 503, so we preserve that.
127
+ }
128
+
129
+ // Exact sensitive-token check — mirrors security.js requireSensitiveToken
130
+ if (!expected) {
131
+ return res.status(503).json({
132
+ error:
133
+ 'Sensitive endpoint disabled: missing COMPOSE_API_TOKEN (run via supervisor or set it manually)',
134
+ });
135
+ }
136
+
137
+ const provided = req.get('x-compose-token');
138
+ if (provided && provided === expected) {
139
+ return next();
140
+ }
141
+
142
+ return res.status(401).json({ error: 'Unauthorized' });
143
+ };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // createAuthGate
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Create the default-deny auth gate for remote mode.
152
+ *
153
+ * Mounted ONLY when COMPOSE_REMOTE_AUTH=enabled. When off, this is never called.
154
+ *
155
+ * Allowlist entries can be:
156
+ * - Exact strings: '/api/health'
157
+ * - Prefix strings ending with '/': '/assets/'
158
+ * - Plain prefix strings (no trailing slash): '/m' matches '/m', '/m/', '/m/pair'
159
+ *
160
+ * Token sources accepted:
161
+ * - x-compose-token header (sensitive token)
162
+ * - Authorization: Bearer <jwt>
163
+ * - x-compose-token: <jwt> (back-compat)
164
+ * - ?token=<jwt or sensitive> query param ONLY for:
165
+ * - GET requests with Accept: text/event-stream
166
+ * - The SSE proxy path /api/agent/proxy/stream
167
+ *
168
+ * @param {{ store: object, allowlist: string[] }} opts
169
+ * @returns {function} Express middleware
170
+ */
171
+ export function createAuthGate({ store, allowlist = [], streamPaths = [] }) {
172
+ const _exactPaths = new Set();
173
+ const _prefixes = [];
174
+
175
+ for (const entry of allowlist) {
176
+ if (entry.endsWith('/')) {
177
+ _prefixes.push(entry.slice(0, -1)); // strip trailing slash for comparison
178
+ } else {
179
+ _exactPaths.add(entry);
180
+ // Also register as prefix for sub-paths (e.g. '/m' covers '/m/pair')
181
+ _prefixes.push(entry);
182
+ }
183
+ }
184
+
185
+ function _allowed(path) {
186
+ if (_exactPaths.has(path)) return true;
187
+ return _prefixes.some(
188
+ (p) => path === p || path.startsWith(p + '/') || path.startsWith(p + '?'),
189
+ );
190
+ }
191
+
192
+ return function authGate(req, res, next) {
193
+ const path = req.path || '/';
194
+
195
+ // 1. Allowlist check
196
+ if (_allowed(path)) return next();
197
+
198
+ // 2. Sensitive token check
199
+ const expected = process.env.COMPOSE_API_TOKEN;
200
+ const sensitiveHeader = req.get('x-compose-token');
201
+ if (expected && sensitiveHeader === expected) return next();
202
+
203
+ // 3. JWT check (Authorization: Bearer or x-compose-token back-compat)
204
+ const jwt = _extractBearer(req);
205
+ if (jwt) {
206
+ const result = store.verifyAccessToken(jwt);
207
+ if (result.ok) {
208
+ req.device = { id: result.device_id, name: result.name };
209
+ return next();
210
+ }
211
+ // JWT present but invalid — return its specific error code
212
+ return res.status(401).json({ error: 'Unauthorized', code: result.code });
213
+ }
214
+
215
+ // 4. Query-param token — ONLY on the explicit stream-path allowlist.
216
+ // Never keyed on the Accept header: a spoofed `Accept: text/event-stream`
217
+ // must not let arbitrary GETs authenticate via URL (credentials would
218
+ // leak into URLs/logs that the header-only contract keeps out of band).
219
+ const isStreamPath = req.method === 'GET' && streamPaths.includes(path);
220
+
221
+ if (isStreamPath) {
222
+ const queryToken = _extractQueryToken(req);
223
+ if (queryToken) {
224
+ // Accept sensitive token or JWT
225
+ if (expected && queryToken === expected) return next();
226
+ const result = store.verifyAccessToken(queryToken);
227
+ if (result.ok) {
228
+ req.device = { id: result.device_id, name: result.name };
229
+ return next();
230
+ }
231
+ return res.status(401).json({ error: 'Unauthorized', code: result.code });
232
+ }
233
+ }
234
+
235
+ // 5. Default deny
236
+ return res.status(401).json({ error: 'Unauthorized', code: 'TokenInvalid' });
237
+ };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // wsUpgradeTokenOk
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Check if a WS upgrade request carries a valid token in its query string.
246
+ *
247
+ * Accepts `?token=<sensitive token>` or `?token=<valid JWT>`.
248
+ * Called from the server's manual upgrade handler (server/index.js:143-156).
249
+ * Token value is never logged.
250
+ *
251
+ * @param {object} store Auth store
252
+ * @param {object} req Node http.IncomingMessage
253
+ * @returns {boolean}
254
+ */
255
+ export function wsUpgradeTokenOk(store, req) {
256
+ const queryToken = _extractQueryTokenFromUrl(req.url || '');
257
+ if (!queryToken) return false;
258
+
259
+ const expected = process.env.COMPOSE_API_TOKEN;
260
+ if (expected && queryToken === expected) return true;
261
+
262
+ const result = store.verifyAccessToken(queryToken);
263
+ return result.ok === true;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Internal helpers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Extract JWT from Authorization: Bearer <jwt> or x-compose-token header.
272
+ * Returns null if not present or not in Bearer format.
273
+ */
274
+ function _extractBearer(req) {
275
+ const auth = req.get('authorization') || req.get('Authorization') || '';
276
+ if (auth.startsWith('Bearer ')) {
277
+ return auth.slice(7).trim() || null;
278
+ }
279
+ // Back-compat: x-compose-token may carry a JWT (not the sensitive key)
280
+ // We let the caller decide what to do with it; return it here only if
281
+ // it does NOT look like the sensitive token (no env check here — store.verify handles it).
282
+ // Actually the design says x-compose-token back-compat for pairingToken — return it.
283
+ // requirePairingToken reads it; the gate handles the sensitive-token check separately.
284
+ const xt = req.get('x-compose-token') || '';
285
+ if (xt && xt !== (process.env.COMPOSE_API_TOKEN || '')) {
286
+ return xt;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ /**
292
+ * Extract ?token= from a parsed req (Express req has req.query).
293
+ */
294
+ function _extractQueryToken(req) {
295
+ return (req.query && req.query.token) || _extractQueryTokenFromUrl(req.url || '');
296
+ }
297
+
298
+ /**
299
+ * Extract ?token= from a raw URL string (used for WS upgrade requests which
300
+ * are plain IncomingMessage, not Express req).
301
+ */
302
+ function _extractQueryTokenFromUrl(url) {
303
+ try {
304
+ const parsed = parseUrl(url, true);
305
+ return parsed.query?.token || null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * auth-routes.js — COMP-MOBILE-REMOTE S01
3
+ *
4
+ * Remote pairing and device management routes.
5
+ * Attach with attachAuthRoutes(app, { store, broadcast, requireSensitive }).
6
+ *
7
+ * Routes:
8
+ * POST /api/auth/pair/init — requires sensitive token
9
+ * GET /api/auth/pair/status — requires sensitive token
10
+ * POST /api/auth/pair/complete — public, rate-limited 10/min
11
+ * POST /api/auth/refresh — public, rate-limited 10/min
12
+ * GET /api/auth/devices — requires sensitive token
13
+ * DELETE /api/auth/devices/:id — requires sensitive token
14
+ *
15
+ * @module server/auth-routes
16
+ */
17
+
18
+ import { createRateLimiter } from './auth-middleware.js';
19
+
20
+ /**
21
+ * Attach auth routes to an Express app.
22
+ *
23
+ * @param {object} app Express application
24
+ * @param {{ store: object,
25
+ * broadcast?: function,
26
+ * requireSensitive: function }} opts
27
+ * - store: auth store from createAuthStore
28
+ * - broadcast: optional function(msg) for WS broadcast (e.g. vision-server's broadcastMessage)
29
+ * - requireSensitive: middleware that enforces COMPOSE_API_TOKEN (requireSensitiveToken or composite)
30
+ */
31
+ export function attachAuthRoutes(app, { store, broadcast = null, requireSensitive, getPublicHost = () => null }) {
32
+ const publicLimiter = createRateLimiter({ windowMs: 60_000, max: 10 });
33
+
34
+ // -------------------------------------------------------------------------
35
+ // POST /api/auth/pair/init
36
+ // -------------------------------------------------------------------------
37
+ /**
38
+ * Initiate a pairing session.
39
+ * Returns { code, expires_at, pair_url, public_host } — pair_url is composed
40
+ * server-side when a public_host is configured (.compose/compose.json
41
+ * remote.public_host, persisted by `compose remote pair --public-host=`),
42
+ * else null and the caller falls back/warns.
43
+ *
44
+ * BP-gate finding #2: only CLI/cockpit poll status; both hold the sensitive token.
45
+ * pair/init is NOT allowlisted.
46
+ */
47
+ app.post('/api/auth/pair/init', requireSensitive, (req, res) => {
48
+ const { code, expires_at } = store.createPairingCode();
49
+ const publicHost = getPublicHost();
50
+ const pair_url = publicHost
51
+ ? `${publicHost.replace(/\/$/, '')}/m/pair?code=${encodeURIComponent(code)}`
52
+ : null;
53
+ res.json({ code, expires_at, pair_url, public_host: publicHost });
54
+ });
55
+
56
+ // -------------------------------------------------------------------------
57
+ // GET /api/auth/pair/status?code=
58
+ // -------------------------------------------------------------------------
59
+ /**
60
+ * Poll pairing code status (sensitive token required — not public).
61
+ * Returns { status: 'pending'|'consumed'|'expired' }
62
+ */
63
+ app.get('/api/auth/pair/status', requireSensitive, (req, res) => {
64
+ const code = req.query?.code;
65
+ if (!code) {
66
+ return res.status(400).json({ error: 'Missing code parameter' });
67
+ }
68
+ const status = store.getPairingCodeStatus(code);
69
+ res.json({ status });
70
+ });
71
+
72
+ // -------------------------------------------------------------------------
73
+ // POST /api/auth/pair/complete
74
+ // -------------------------------------------------------------------------
75
+ /**
76
+ * Consume a pairing code and create a device.
77
+ * PUBLIC (code is the auth), rate-limited.
78
+ * Body: { code, device_name? }
79
+ * Returns: { access_token, refresh_token, device_id, expires_in }
80
+ * Broadcasts: { type: 'devicePaired', device_id, name, timestamp } on success.
81
+ */
82
+ app.post('/api/auth/pair/complete', publicLimiter, (req, res) => {
83
+ const { code, device_name } = req.body || {};
84
+
85
+ if (!code) {
86
+ return res.status(400).json({ error: 'Missing code', code: 'CodeInvalid' });
87
+ }
88
+
89
+ const ua = req.get('user-agent') || '';
90
+ const result = store.consumePairingCode(code, {
91
+ name: device_name || ua.slice(0, 80) || 'Unknown Device',
92
+ user_agent: ua,
93
+ });
94
+
95
+ if (result.error) {
96
+ const status = result.error === 'CodeExpired' ? 400 : 400;
97
+ return res.status(status).json({ error: result.error, code: result.error });
98
+ }
99
+
100
+ // Broadcast to cockpit watchers (null-safe)
101
+ if (typeof broadcast === 'function') {
102
+ try {
103
+ broadcast({
104
+ type: 'devicePaired',
105
+ device_id: result.device.id,
106
+ name: result.device.name,
107
+ timestamp: new Date().toISOString(),
108
+ });
109
+ } catch {
110
+ // best-effort broadcast
111
+ }
112
+ }
113
+
114
+ res.json({
115
+ access_token: result.access_token,
116
+ refresh_token: result.refresh_token,
117
+ device_id: result.device.id,
118
+ expires_in: result.expires_in,
119
+ });
120
+ });
121
+
122
+ // -------------------------------------------------------------------------
123
+ // POST /api/auth/refresh
124
+ // -------------------------------------------------------------------------
125
+ /**
126
+ * Rotate a refresh token and issue new tokens.
127
+ * PUBLIC (refresh token is the auth), rate-limited.
128
+ * Body: { refresh_token }
129
+ * Returns: { access_token, refresh_token, expires_in }
130
+ */
131
+ app.post('/api/auth/refresh', publicLimiter, (req, res) => {
132
+ const { refresh_token } = req.body || {};
133
+
134
+ if (!refresh_token) {
135
+ return res.status(401).json({ error: 'Missing refresh_token', code: 'TokenInvalid' });
136
+ }
137
+
138
+ const result = store.refresh(refresh_token);
139
+ if (result.error) {
140
+ return res.status(401).json({ error: result.error, code: 'TokenInvalid' });
141
+ }
142
+
143
+ res.json({
144
+ access_token: result.access_token,
145
+ refresh_token: result.refresh_token,
146
+ expires_in: result.expires_in,
147
+ });
148
+ });
149
+
150
+ // -------------------------------------------------------------------------
151
+ // GET /api/auth/devices
152
+ // -------------------------------------------------------------------------
153
+ /**
154
+ * List all paired devices (without hash fields).
155
+ * Requires sensitive token.
156
+ */
157
+ app.get('/api/auth/devices', requireSensitive, (req, res) => {
158
+ res.json({ devices: store.listDevices() });
159
+ });
160
+
161
+ // -------------------------------------------------------------------------
162
+ // DELETE /api/auth/devices/:id
163
+ // -------------------------------------------------------------------------
164
+ /**
165
+ * Revoke a device.
166
+ * Requires sensitive token.
167
+ * Returns { ok: true } or 404.
168
+ */
169
+ app.delete('/api/auth/devices/:id', requireSensitive, (req, res) => {
170
+ const found = store.revokeDevice(req.params.id);
171
+ if (!found) {
172
+ return res.status(404).json({ error: 'Device not found' });
173
+ }
174
+ res.json({ ok: true });
175
+ });
176
+
177
+ // -------------------------------------------------------------------------
178
+ // POST /api/auth/rotate-secret
179
+ // -------------------------------------------------------------------------
180
+ /**
181
+ * Rotate the JWT signing secret.
182
+ * Invalidates ALL outstanding access tokens immediately.
183
+ * Device records and refresh tokens are preserved (but re-paired access
184
+ * tokens must be re-issued under the new secret — clients will get 401s
185
+ * until they re-pair or refresh, which will now issue tokens signed with
186
+ * the new secret).
187
+ *
188
+ * Deviation note: S01 omitted this route; added in S03 for CLI support.
189
+ * Requires sensitive token.
190
+ */
191
+ app.post('/api/auth/rotate-secret', requireSensitive, (req, res) => {
192
+ store.rotateSecret();
193
+ res.json({ ok: true });
194
+ });
195
+ }