@smartmemory/compose 0.2.46-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-CZNprfTP.js → App-D7E7S49Q.js} +163 -153
- package/dist/assets/{arc-D7Ql7gBh.js → arc-LOW60tiI.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-CMzqpLWw.js → architectureDiagram-3BPJPVTR-BDY00hRy.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-CbW2AYzk.js → blockDiagram-GPEHLZMM-DiC_6ktq.js} +1 -1
- package/dist/assets/browser-DW2JlJCC.js +8 -0
- package/dist/assets/{c4Diagram-AAUBKEIU-C89TIEqM.js → c4Diagram-AAUBKEIU-CXZC8woU.js} +1 -1
- package/dist/assets/channel-Dh96pu1k.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-jLQK9et3.js → chunk-2J33WTMH-b8Rdk4S-.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-bH25OCUA.js → chunk-4BX2VUAB-BSxn03f-.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Dhb0bOrW.js → chunk-55IACEB6-uUk1WzZ1.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-CBfeQuSd.js → chunk-727SXJPM-zvXhT6iZ.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-CIxa5qRv.js → chunk-AQP2D5EJ-B6GCyrIp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CmXvjRAO.js → chunk-FMBD7UC4-DlhrqkBp.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-DRs6NTTH.js → chunk-ND2GUHAM-lo_8kNzw.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-BmkcOtBV.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-BZwhzUqa.js → cose-bilkent-S5V4N54A-DxV7Y2bU.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-CEHe9vzv.js → dagre-BM42HDAG-BHf9sURy.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-Cq8RCIdY.js → diagram-2AECGRRQ-R9Puxzlh.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-DCaYgQ-u.js → diagram-5GNKFQAL-BbjlClKx.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-DehQMQAE.js → diagram-KO2AKTUF-r89Mx4m1.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-BUJ8pJCq.js → diagram-LMA3HP47-CstVDJZ3.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-BrqALaH3.js → diagram-OG6HWLK6-xmX57FKh.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-Cz2hnPqN.js → erDiagram-TEJ5UH35-DhLPxvrl.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-BpPrbE4K.js → flowDiagram-I6XJVG4X-BExoUaNm.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-Dne2jtVu.js → ganttDiagram-6RSMTGT7-BEXa5a22.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-W8OpyxIG.js → gitGraphDiagram-PVQCEYII-Cbes9api.js} +1 -1
- package/dist/assets/{graph-viND0NdB.js → graph-BBXaecIU.js} +1 -1
- package/dist/assets/index-BwLfbbOu.css +1 -0
- package/dist/assets/{index-BVnbNZXC.js → index-DNuHtZwR.js} +22 -22
- package/dist/assets/{infoDiagram-5YYISTIA-4OeddgjS.js → infoDiagram-5YYISTIA-UBm4ssfm.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-D0XwMcCq.js → ishikawaDiagram-YF4QCWOH-CksBYHCF.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-CO0wIeDT.js → journeyDiagram-JHISSGLW-CKHkIHky.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-CLU0ciYv.js → kanban-definition-UN3LZRKU-CEahMFzE.js} +1 -1
- package/dist/assets/{linear-BOFLe2h1.js → linear-CqLVtYRk.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-ByuwlkdD.js → mindmap-definition-RKZ34NQL-C61zWt4M.js} +1 -1
- package/dist/assets/mobile-DwmxS_O4.js +17 -0
- package/dist/assets/{pieDiagram-4H26LBE5-CPm2BXke.js → pieDiagram-4H26LBE5-BIpZHyJW.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-CR3NLvwU.js → quadrantDiagram-W4KKPZXB-Db7nRbZm.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-D4vpyryW.js → requirementDiagram-4Y6WPE33-BQqFCAr0.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP--Clp0eo-.js → sankeyDiagram-5OEKKPKP-D-oAAMci.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-B1xrJkMo.js → sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-D5w5yQql.js → stateDiagram-AJRCARHV-B5DkQ8Pr.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-DxOINQqf.js → timeline-definition-PNZ67QCA-DU9Fl-FW.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-B3Wss4do.js → vennDiagram-CIIHVFJN-Bthz7siw.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-nYDkwvlu.js → wardley-L42UT6IY-nqdlFSIA.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-BMM3myEL.js → wardleyDiagram-YWT4CUSO-BJ1BC5zc.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-B5ElCNMx.js → xychartDiagram-2RQKCTM6-Bib5JrbR.js} +1 -1
- package/dist/index.html +3 -3
- 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-sjm4Ncyv.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-LkIQdBsc.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-LkIQdBsc.js +0 -1
- package/dist/assets/index-D9M5IzwO.css +0 -1
- package/dist/assets/mobile-B-d4crm_.js +0 -17
- package/dist/assets/stateDiagram-v2-BHNVJYJU-TPU-mOqi.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
|
+
}
|