incremnt 0.1.0
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 +66 -0
- package/package.json +17 -0
- package/src/auth.js +319 -0
- package/src/contract.js +40 -0
- package/src/format.js +72 -0
- package/src/index.js +6 -0
- package/src/lib.js +341 -0
- package/src/local.js +59 -0
- package/src/queries.js +335 -0
- package/src/remote.js +161 -0
- package/src/service-url.js +7 -0
- package/src/state.js +129 -0
- package/src/sync-service.js +1165 -0
- package/src/transport.js +56 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
|
|
3
|
+
import { executeReadCommand } from './queries.js';
|
|
4
|
+
|
|
5
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
6
|
+
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
7
|
+
const DEFAULT_RATE_LIMIT_RULES = {
|
|
8
|
+
'dev-login': 10,
|
|
9
|
+
'device-start': 20,
|
|
10
|
+
'device-poll': 300,
|
|
11
|
+
'device-approve': 30,
|
|
12
|
+
'google-start': 20,
|
|
13
|
+
'google-callback': 20,
|
|
14
|
+
'apple-start': 20,
|
|
15
|
+
'apple-callback': 20,
|
|
16
|
+
'session-login': 60,
|
|
17
|
+
'session-refresh': 30
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function json(response, statusCode, payload) {
|
|
21
|
+
response.writeHead(statusCode, { 'content-type': 'application/json' });
|
|
22
|
+
response.end(JSON.stringify(payload));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function logRequest(request, statusCode, extra = '') {
|
|
26
|
+
const method = request.method ?? '?';
|
|
27
|
+
const rawUrl = request.url ?? '/';
|
|
28
|
+
const path = rawUrl.split('?')[0];
|
|
29
|
+
const suffix = extra ? ` ${extra}` : '';
|
|
30
|
+
console.log(`${method} ${path} ${statusCode}${suffix}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function unauthorized(response, request) {
|
|
34
|
+
if (request) logRequest(request, 401);
|
|
35
|
+
json(response, 401, { error: 'Unauthorized' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notFound(response, message = 'Not found') {
|
|
39
|
+
json(response, 404, { error: message });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function badRequest(response, message) {
|
|
43
|
+
json(response, 400, { error: message });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function methodNotAllowed(response, message = 'Method not allowed') {
|
|
47
|
+
json(response, 405, { error: message });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function internalError(response, error) {
|
|
51
|
+
console.error('Internal error:', error.message);
|
|
52
|
+
json(response, 500, { error: 'Internal server error' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function constantTimeEqual(a, b) {
|
|
56
|
+
if (!a || !b) return false;
|
|
57
|
+
const bufA = Buffer.from(a);
|
|
58
|
+
const bufB = Buffer.from(b);
|
|
59
|
+
if (bufA.length !== bufB.length) return false;
|
|
60
|
+
return timingSafeEqual(bufA, bufB);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bearerToken(request) {
|
|
64
|
+
const value = request.headers.authorization ?? '';
|
|
65
|
+
return value.startsWith('Bearer ') ? value.slice('Bearer '.length) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function adminSecret(request) {
|
|
69
|
+
return request.headers['x-incremnt-admin-secret'] ?? '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clientAddress(request, { trustProxy = false } = {}) {
|
|
73
|
+
if (trustProxy) {
|
|
74
|
+
const forwarded = request.headers['x-forwarded-for'];
|
|
75
|
+
if (typeof forwarded === 'string' && forwarded.trim()) {
|
|
76
|
+
return forwarded.split(',')[0].trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return request.socket?.remoteAddress ?? 'unknown';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createRateLimiter({
|
|
84
|
+
windowMs = DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
85
|
+
rules = {},
|
|
86
|
+
trustProxy = false
|
|
87
|
+
} = {}) {
|
|
88
|
+
const mergedRules = { ...DEFAULT_RATE_LIMIT_RULES, ...rules };
|
|
89
|
+
const buckets = new Map();
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
check(request, command) {
|
|
93
|
+
const limit = mergedRules[command];
|
|
94
|
+
if (!limit) {
|
|
95
|
+
return { allowed: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const key = `${command}:${clientAddress(request, { trustProxy })}`;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const bucket = buckets.get(key);
|
|
101
|
+
|
|
102
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
103
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
104
|
+
return { allowed: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (bucket.count >= limit) {
|
|
108
|
+
return { allowed: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
bucket.count += 1;
|
|
112
|
+
buckets.set(key, bucket);
|
|
113
|
+
return { allowed: true };
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function routeRequest(url) {
|
|
119
|
+
const pathname = url.pathname;
|
|
120
|
+
|
|
121
|
+
if (pathname === '/healthz') {
|
|
122
|
+
return { command: 'healthz', options: {} };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (pathname === '/auth/dev-login') {
|
|
126
|
+
return { command: 'dev-login', options: {} };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (pathname === '/auth/config') {
|
|
130
|
+
return { command: 'auth-config', options: {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (pathname === '/auth/session') {
|
|
134
|
+
return { command: 'session-login', options: {} };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (pathname === '/auth/refresh') {
|
|
138
|
+
return { command: 'session-refresh', options: {} };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (pathname === '/auth/device/start') {
|
|
142
|
+
return { command: 'device-start', options: {} };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pathname === '/auth/device/poll') {
|
|
146
|
+
return { command: 'device-poll', options: {} };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (pathname === '/auth/device/approve') {
|
|
150
|
+
return { command: 'device-approve', options: {} };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (pathname === '/auth/google/start') {
|
|
154
|
+
return { command: 'google-start', options: {} };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pathname === '/auth/google/callback') {
|
|
158
|
+
return { command: 'google-callback', options: {} };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (pathname === '/auth/apple/start') {
|
|
162
|
+
return { command: 'apple-start', options: {} };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (pathname === '/auth/apple/callback') {
|
|
166
|
+
return { command: 'apple-callback', options: {} };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (pathname === '/admin/bootstrap-user') {
|
|
170
|
+
return { command: 'admin-bootstrap-user', options: {} };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (pathname === '/cli/contract') {
|
|
174
|
+
return { command: 'contract', options: {} };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (pathname === '/sync/snapshot') {
|
|
178
|
+
return { command: 'sync-upload', options: {} };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (pathname === '/cli/sessions') {
|
|
182
|
+
return {
|
|
183
|
+
command: 'session-insights',
|
|
184
|
+
options: {
|
|
185
|
+
limit: url.searchParams.get('limit') ?? undefined
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (pathname === '/cli/programs') {
|
|
191
|
+
return { command: 'program-list', options: {} };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (pathname === '/cli/programs/current') {
|
|
195
|
+
return { command: 'program-summary', options: {} };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (pathname === '/cli/exercises/history') {
|
|
199
|
+
return {
|
|
200
|
+
command: 'exercise-history',
|
|
201
|
+
options: {
|
|
202
|
+
name: url.searchParams.get('name') ?? undefined
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pathname === '/cli/records') {
|
|
208
|
+
return { command: 'records', options: {} };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const compareMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/compare$/);
|
|
212
|
+
if (compareMatch) {
|
|
213
|
+
return {
|
|
214
|
+
command: 'planned-vs-actual',
|
|
215
|
+
options: {
|
|
216
|
+
'session-id': decodeURIComponent(compareMatch[1])
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const explainMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/explain$/);
|
|
222
|
+
if (explainMatch) {
|
|
223
|
+
return {
|
|
224
|
+
command: 'why-did-this-change',
|
|
225
|
+
options: {
|
|
226
|
+
'session-id': decodeURIComponent(explainMatch[1])
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const showMatch = pathname.match(/^\/cli\/sessions\/([^/]+)$/);
|
|
232
|
+
if (showMatch) {
|
|
233
|
+
return {
|
|
234
|
+
command: 'session-show',
|
|
235
|
+
options: {
|
|
236
|
+
id: decodeURIComponent(showMatch[1])
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function readJsonBody(request) {
|
|
245
|
+
const chunks = [];
|
|
246
|
+
let totalSize = 0;
|
|
247
|
+
for await (const chunk of request) {
|
|
248
|
+
totalSize += chunk.length;
|
|
249
|
+
if (totalSize > MAX_BODY_BYTES) {
|
|
250
|
+
throw new Error('Request body too large.');
|
|
251
|
+
}
|
|
252
|
+
chunks.push(chunk);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
256
|
+
if (!raw.trim()) {
|
|
257
|
+
throw new Error('Request body is required.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
return JSON.parse(raw);
|
|
262
|
+
} catch {
|
|
263
|
+
throw new Error('Invalid JSON in request body.');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function readUrlEncodedBody(request) {
|
|
268
|
+
const chunks = [];
|
|
269
|
+
let totalSize = 0;
|
|
270
|
+
for await (const chunk of request) {
|
|
271
|
+
totalSize += chunk.length;
|
|
272
|
+
if (totalSize > MAX_BODY_BYTES) {
|
|
273
|
+
throw new Error('Request body too large.');
|
|
274
|
+
}
|
|
275
|
+
chunks.push(chunk);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
279
|
+
if (!raw.trim()) {
|
|
280
|
+
throw new Error('Request body is required.');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function html(response, statusCode, markup) {
|
|
287
|
+
response.writeHead(statusCode, { 'content-type': 'text/html; charset=utf-8' });
|
|
288
|
+
response.end(markup);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function deviceApprovalPage({
|
|
292
|
+
title,
|
|
293
|
+
message,
|
|
294
|
+
userCode = '',
|
|
295
|
+
email = '',
|
|
296
|
+
userId = '',
|
|
297
|
+
includeManualForm = true,
|
|
298
|
+
appleStartPath = null,
|
|
299
|
+
googleStartPath = null,
|
|
300
|
+
isError = false
|
|
301
|
+
}) {
|
|
302
|
+
const escapedTitle = escapeHtml(title);
|
|
303
|
+
const escapedMessage = escapeHtml(message);
|
|
304
|
+
const escapedUserCode = escapeHtml(userCode);
|
|
305
|
+
const escapedEmail = escapeHtml(email);
|
|
306
|
+
const escapedUserId = escapeHtml(userId);
|
|
307
|
+
const accent = isError ? '#7f1d1d' : '#16324f';
|
|
308
|
+
const panel = isError ? '#fef2f2' : '#f6fbff';
|
|
309
|
+
|
|
310
|
+
return `<!doctype html>
|
|
311
|
+
<html lang="en">
|
|
312
|
+
<head>
|
|
313
|
+
<meta charset="utf-8" />
|
|
314
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
315
|
+
<title>${escapedTitle}</title>
|
|
316
|
+
<style>
|
|
317
|
+
:root {
|
|
318
|
+
color-scheme: light;
|
|
319
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
320
|
+
}
|
|
321
|
+
body {
|
|
322
|
+
margin: 0;
|
|
323
|
+
min-height: 100vh;
|
|
324
|
+
display: grid;
|
|
325
|
+
place-items: center;
|
|
326
|
+
background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
|
|
327
|
+
color: #12212f;
|
|
328
|
+
}
|
|
329
|
+
main {
|
|
330
|
+
width: min(92vw, 28rem);
|
|
331
|
+
background: white;
|
|
332
|
+
border-radius: 24px;
|
|
333
|
+
box-shadow: 0 18px 50px rgba(17, 38, 57, 0.12);
|
|
334
|
+
padding: 1.5rem;
|
|
335
|
+
}
|
|
336
|
+
.badge {
|
|
337
|
+
display: inline-block;
|
|
338
|
+
padding: 0.35rem 0.65rem;
|
|
339
|
+
border-radius: 999px;
|
|
340
|
+
background: ${panel};
|
|
341
|
+
color: ${accent};
|
|
342
|
+
font-size: 0.85rem;
|
|
343
|
+
font-weight: 600;
|
|
344
|
+
}
|
|
345
|
+
h1 {
|
|
346
|
+
margin: 0.9rem 0 0.5rem;
|
|
347
|
+
font-size: 1.6rem;
|
|
348
|
+
line-height: 1.2;
|
|
349
|
+
}
|
|
350
|
+
p {
|
|
351
|
+
margin: 0 0 1rem;
|
|
352
|
+
color: #41576d;
|
|
353
|
+
}
|
|
354
|
+
form {
|
|
355
|
+
display: grid;
|
|
356
|
+
gap: 0.85rem;
|
|
357
|
+
margin-top: 1rem;
|
|
358
|
+
}
|
|
359
|
+
.actions {
|
|
360
|
+
display: grid;
|
|
361
|
+
gap: 0.75rem;
|
|
362
|
+
}
|
|
363
|
+
.oauth-link {
|
|
364
|
+
display: block;
|
|
365
|
+
text-align: center;
|
|
366
|
+
text-decoration: none;
|
|
367
|
+
border-radius: 14px;
|
|
368
|
+
background: #ffffff;
|
|
369
|
+
color: #12212f;
|
|
370
|
+
border: 1px solid #d0dded;
|
|
371
|
+
font-weight: 700;
|
|
372
|
+
padding: 0.9rem 1rem;
|
|
373
|
+
}
|
|
374
|
+
label {
|
|
375
|
+
display: grid;
|
|
376
|
+
gap: 0.35rem;
|
|
377
|
+
font-size: 0.95rem;
|
|
378
|
+
font-weight: 600;
|
|
379
|
+
}
|
|
380
|
+
input {
|
|
381
|
+
border: 1px solid #d0dded;
|
|
382
|
+
border-radius: 12px;
|
|
383
|
+
padding: 0.8rem 0.9rem;
|
|
384
|
+
font: inherit;
|
|
385
|
+
}
|
|
386
|
+
button {
|
|
387
|
+
margin-top: 0.4rem;
|
|
388
|
+
border: 0;
|
|
389
|
+
border-radius: 14px;
|
|
390
|
+
background: #0f4c81;
|
|
391
|
+
color: white;
|
|
392
|
+
font: inherit;
|
|
393
|
+
font-weight: 700;
|
|
394
|
+
padding: 0.9rem 1rem;
|
|
395
|
+
}
|
|
396
|
+
small {
|
|
397
|
+
color: #66798b;
|
|
398
|
+
}
|
|
399
|
+
</style>
|
|
400
|
+
</head>
|
|
401
|
+
<body>
|
|
402
|
+
<main>
|
|
403
|
+
<span class="badge">incremnt device login</span>
|
|
404
|
+
<h1>${escapedTitle}</h1>
|
|
405
|
+
<p>${escapedMessage}</p>
|
|
406
|
+
${(appleStartPath || googleStartPath) ? `
|
|
407
|
+
<div class="actions">
|
|
408
|
+
${appleStartPath ? `<a class="oauth-link" href="${escapeHtml(appleStartPath)}">Continue with Apple</a>` : ''}
|
|
409
|
+
${googleStartPath ? `<a class="oauth-link" href="${escapeHtml(googleStartPath)}">Continue with Google</a>` : ''}
|
|
410
|
+
</div>
|
|
411
|
+
` : ''}
|
|
412
|
+
${includeManualForm ? `
|
|
413
|
+
<form method="post" action="/auth/device/approve">
|
|
414
|
+
<label>
|
|
415
|
+
Approval code
|
|
416
|
+
<input name="userCode" value="${escapedUserCode}" autocapitalize="characters" autocomplete="one-time-code" required />
|
|
417
|
+
</label>
|
|
418
|
+
<label>
|
|
419
|
+
Email
|
|
420
|
+
<input name="email" type="email" value="${escapedEmail}" autocomplete="email" />
|
|
421
|
+
</label>
|
|
422
|
+
<label>
|
|
423
|
+
User ID
|
|
424
|
+
<input name="userId" value="${escapedUserId}" />
|
|
425
|
+
</label>
|
|
426
|
+
<button type="submit">Approve login</button>
|
|
427
|
+
</form>
|
|
428
|
+
<small>Enter the code shown by <code>incremnt login</code>. Provide either the email or user ID for the account that should own this session.</small>
|
|
429
|
+
` : `
|
|
430
|
+
<small>Continue with a configured identity provider to approve the code shown by <code>incremnt login</code>.</small>
|
|
431
|
+
`}
|
|
432
|
+
</main>
|
|
433
|
+
</body>
|
|
434
|
+
</html>`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function deviceApprovalSuccessPage({ email, userId }) {
|
|
438
|
+
const displayName = escapeHtml(email || userId || 'your account');
|
|
439
|
+
return `<!doctype html>
|
|
440
|
+
<html lang="en">
|
|
441
|
+
<head>
|
|
442
|
+
<meta charset="utf-8" />
|
|
443
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
444
|
+
<title>Connected</title>
|
|
445
|
+
<style>
|
|
446
|
+
:root {
|
|
447
|
+
color-scheme: light;
|
|
448
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
449
|
+
}
|
|
450
|
+
body {
|
|
451
|
+
margin: 0;
|
|
452
|
+
min-height: 100vh;
|
|
453
|
+
display: grid;
|
|
454
|
+
place-items: center;
|
|
455
|
+
background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
|
|
456
|
+
color: #12212f;
|
|
457
|
+
}
|
|
458
|
+
main {
|
|
459
|
+
width: min(92vw, 28rem);
|
|
460
|
+
background: white;
|
|
461
|
+
border-radius: 24px;
|
|
462
|
+
box-shadow: 0 18px 50px rgba(17, 38, 57, 0.12);
|
|
463
|
+
padding: 2rem 1.5rem;
|
|
464
|
+
text-align: center;
|
|
465
|
+
}
|
|
466
|
+
.checkmark {
|
|
467
|
+
font-size: 3rem;
|
|
468
|
+
margin-bottom: 0.5rem;
|
|
469
|
+
}
|
|
470
|
+
h1 {
|
|
471
|
+
margin: 0 0 0.5rem;
|
|
472
|
+
font-size: 1.6rem;
|
|
473
|
+
line-height: 1.2;
|
|
474
|
+
}
|
|
475
|
+
p {
|
|
476
|
+
margin: 0;
|
|
477
|
+
color: #41576d;
|
|
478
|
+
}
|
|
479
|
+
</style>
|
|
480
|
+
</head>
|
|
481
|
+
<body>
|
|
482
|
+
<main>
|
|
483
|
+
<div class="checkmark">✅</div>
|
|
484
|
+
<h1>You're all set</h1>
|
|
485
|
+
<p>Signed in as ${displayName}. You can close this tab and return to your terminal.</p>
|
|
486
|
+
</main>
|
|
487
|
+
</body>
|
|
488
|
+
</html>`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function escapeHtml(value) {
|
|
492
|
+
return String(value ?? '')
|
|
493
|
+
.replaceAll('&', '&')
|
|
494
|
+
.replaceAll('<', '<')
|
|
495
|
+
.replaceAll('>', '>')
|
|
496
|
+
.replaceAll('"', '"')
|
|
497
|
+
.replaceAll("'", ''');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function syncServiceContractPayload({
|
|
501
|
+
auth = {
|
|
502
|
+
tokenBootstrap: true,
|
|
503
|
+
deviceFlow: false,
|
|
504
|
+
browserApproval: false,
|
|
505
|
+
devEmail: false
|
|
506
|
+
},
|
|
507
|
+
providers = {
|
|
508
|
+
apple: {
|
|
509
|
+
available: true,
|
|
510
|
+
configured: false
|
|
511
|
+
},
|
|
512
|
+
google: {
|
|
513
|
+
available: true,
|
|
514
|
+
configured: false
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} = {}) {
|
|
518
|
+
return {
|
|
519
|
+
contractVersion,
|
|
520
|
+
binary: 'incremnt',
|
|
521
|
+
capabilities: {
|
|
522
|
+
...cliCapabilities,
|
|
523
|
+
localSnapshots: false,
|
|
524
|
+
remoteReads: true,
|
|
525
|
+
remoteBootstrap: false
|
|
526
|
+
},
|
|
527
|
+
auth,
|
|
528
|
+
providers,
|
|
529
|
+
officialCommands
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function createSyncServiceRequestHandler({
|
|
534
|
+
loadSnapshot,
|
|
535
|
+
token,
|
|
536
|
+
authenticateToken,
|
|
537
|
+
authenticateReadToken,
|
|
538
|
+
authenticateWriteToken,
|
|
539
|
+
loadSnapshotForAccount,
|
|
540
|
+
writeSnapshotForAccount,
|
|
541
|
+
issueDevLogin,
|
|
542
|
+
issueSession,
|
|
543
|
+
issueDeviceChallenge,
|
|
544
|
+
consumeDeviceChallenge,
|
|
545
|
+
readDeviceChallengeByUserCode,
|
|
546
|
+
approveDeviceChallenge,
|
|
547
|
+
completeGoogleDeviceApproval,
|
|
548
|
+
adminBootstrapSecret,
|
|
549
|
+
bootstrapAccount,
|
|
550
|
+
appleAuth = {
|
|
551
|
+
available: true,
|
|
552
|
+
configured: false
|
|
553
|
+
},
|
|
554
|
+
buildAppleAuthUrl = null,
|
|
555
|
+
completeAppleDeviceApproval,
|
|
556
|
+
googleAuth = {
|
|
557
|
+
available: true,
|
|
558
|
+
configured: false
|
|
559
|
+
},
|
|
560
|
+
buildGoogleAuthUrl = null,
|
|
561
|
+
refreshSession,
|
|
562
|
+
allowManualDeviceApproval = false,
|
|
563
|
+
rateLimitConfig = null
|
|
564
|
+
}) {
|
|
565
|
+
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
566
|
+
|
|
567
|
+
return async function handle(request, response) {
|
|
568
|
+
try {
|
|
569
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
|
|
570
|
+
const route = routeRequest(url);
|
|
571
|
+
if (!route) {
|
|
572
|
+
notFound(response);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (route.command === 'healthz') {
|
|
577
|
+
json(response, 200, { ok: true });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!rateLimiter.check(request, route.command).allowed) {
|
|
582
|
+
json(response, 429, { error: 'Too many requests' });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
logRequest(request, '-', route.command);
|
|
587
|
+
|
|
588
|
+
const providerApprovalAvailable = Boolean(appleAuth?.configured || googleAuth?.configured);
|
|
589
|
+
const manualDeviceApprovalEnabled = allowManualDeviceApproval || !providerApprovalAvailable;
|
|
590
|
+
|
|
591
|
+
if (route.command === 'auth-config') {
|
|
592
|
+
json(response, 200, {
|
|
593
|
+
ok: true,
|
|
594
|
+
auth: {
|
|
595
|
+
tokenBootstrap: Boolean(issueSession || token),
|
|
596
|
+
deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
|
|
597
|
+
browserApproval: Boolean(approveDeviceChallenge),
|
|
598
|
+
devEmail: Boolean(issueDevLogin)
|
|
599
|
+
},
|
|
600
|
+
providers: {
|
|
601
|
+
apple: {
|
|
602
|
+
available: Boolean(appleAuth?.available),
|
|
603
|
+
configured: Boolean(appleAuth?.configured)
|
|
604
|
+
},
|
|
605
|
+
google: {
|
|
606
|
+
available: Boolean(googleAuth?.available),
|
|
607
|
+
configured: Boolean(googleAuth?.configured)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (route.command === 'dev-login') {
|
|
615
|
+
if (request.method !== 'POST') {
|
|
616
|
+
methodNotAllowed(response, 'Use POST for /auth/dev-login.');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!issueDevLogin) {
|
|
621
|
+
methodNotAllowed(response, 'Dev login is not enabled for this service mode.');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
const body = await readJsonBody(request);
|
|
627
|
+
if (!body.email && !body.userId) {
|
|
628
|
+
badRequest(response, 'email or userId is required.');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const result = await issueDevLogin({
|
|
633
|
+
email: body.email ?? null,
|
|
634
|
+
userId: body.userId ?? null
|
|
635
|
+
});
|
|
636
|
+
json(response, 200, {
|
|
637
|
+
ok: true,
|
|
638
|
+
token: result.token,
|
|
639
|
+
account: result.account
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
} catch (error) {
|
|
643
|
+
badRequest(response, error.message);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (route.command === 'device-start') {
|
|
649
|
+
if (request.method !== 'POST') {
|
|
650
|
+
methodNotAllowed(response, 'Use POST for /auth/device/start.');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (!issueDeviceChallenge) {
|
|
655
|
+
methodNotAllowed(response, 'Device login is not enabled for this service mode.');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const challenge = await issueDeviceChallenge();
|
|
660
|
+
json(response, 200, {
|
|
661
|
+
ok: true,
|
|
662
|
+
deviceCode: challenge.deviceCode,
|
|
663
|
+
userCode: challenge.userCode,
|
|
664
|
+
expiresAt: challenge.expiresAt,
|
|
665
|
+
intervalSeconds: challenge.intervalSeconds,
|
|
666
|
+
verificationUri: '/auth/device/approve'
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (route.command === 'device-poll') {
|
|
672
|
+
if (request.method !== 'POST') {
|
|
673
|
+
methodNotAllowed(response, 'Use POST for /auth/device/poll.');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!consumeDeviceChallenge) {
|
|
678
|
+
methodNotAllowed(response, 'Device login is not enabled for this service mode.');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
try {
|
|
683
|
+
const body = await readJsonBody(request);
|
|
684
|
+
if (!body.deviceCode) {
|
|
685
|
+
badRequest(response, 'deviceCode is required.');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const result = await consumeDeviceChallenge(body.deviceCode);
|
|
690
|
+
if (result.status === 'pending') {
|
|
691
|
+
json(response, 202, {
|
|
692
|
+
ok: false,
|
|
693
|
+
error: 'authorization_pending',
|
|
694
|
+
intervalSeconds: result.intervalSeconds ?? 1
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (result.status === 'expired') {
|
|
700
|
+
json(response, 410, { ok: false, error: 'expired_token' });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (result.status === 'not_found') {
|
|
705
|
+
badRequest(response, 'Unknown deviceCode.');
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
json(response, 200, {
|
|
710
|
+
ok: true,
|
|
711
|
+
session: {
|
|
712
|
+
accessToken: result.session.accessToken,
|
|
713
|
+
expiresAt: result.session.expiresAt
|
|
714
|
+
},
|
|
715
|
+
account: result.session.account
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
} catch (error) {
|
|
719
|
+
badRequest(response, error.message);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (route.command === 'google-start') {
|
|
725
|
+
if (request.method !== 'GET') {
|
|
726
|
+
methodNotAllowed(response, 'Use GET for /auth/google/start.');
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!googleAuth?.configured || !buildGoogleAuthUrl || !readDeviceChallengeByUserCode) {
|
|
731
|
+
methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const userCode = url.searchParams.get('userCode') ?? '';
|
|
736
|
+
const challenge = await readDeviceChallengeByUserCode(userCode);
|
|
737
|
+
if (!challenge) {
|
|
738
|
+
badRequest(response, 'Unknown or expired userCode.');
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
response.writeHead(302, {
|
|
743
|
+
location: buildGoogleAuthUrl(googleAuth, {
|
|
744
|
+
userCode: challenge.userCode,
|
|
745
|
+
nonce: challenge.oauthStateNonce
|
|
746
|
+
})
|
|
747
|
+
});
|
|
748
|
+
response.end();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (route.command === 'google-callback') {
|
|
753
|
+
if (request.method !== 'GET') {
|
|
754
|
+
methodNotAllowed(response, 'Use GET for /auth/google/callback.');
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (!googleAuth?.configured || !completeGoogleDeviceApproval) {
|
|
759
|
+
methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const code = url.searchParams.get('code') ?? '';
|
|
764
|
+
const state = url.searchParams.get('state') ?? '';
|
|
765
|
+
if (!code || !state) {
|
|
766
|
+
badRequest(response, 'code and state are required.');
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
const result = await completeGoogleDeviceApproval({ code, state });
|
|
772
|
+
html(response, 200, deviceApprovalSuccessPage({
|
|
773
|
+
email: result.account.email ?? '',
|
|
774
|
+
userId: result.account.id
|
|
775
|
+
}));
|
|
776
|
+
return;
|
|
777
|
+
} catch (error) {
|
|
778
|
+
html(response, 400, deviceApprovalPage({
|
|
779
|
+
title: 'Approval failed',
|
|
780
|
+
message: error.message,
|
|
781
|
+
isError: true
|
|
782
|
+
}));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (route.command === 'apple-start') {
|
|
788
|
+
if (request.method !== 'GET') {
|
|
789
|
+
methodNotAllowed(response, 'Use GET for /auth/apple/start.');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (!appleAuth?.configured || !buildAppleAuthUrl || !readDeviceChallengeByUserCode) {
|
|
794
|
+
methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const userCode = url.searchParams.get('userCode') ?? '';
|
|
799
|
+
const challenge = await readDeviceChallengeByUserCode(userCode);
|
|
800
|
+
if (!challenge) {
|
|
801
|
+
badRequest(response, 'Unknown or expired userCode.');
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
response.writeHead(302, {
|
|
806
|
+
location: buildAppleAuthUrl(appleAuth, {
|
|
807
|
+
userCode: challenge.userCode,
|
|
808
|
+
nonce: challenge.oauthStateNonce
|
|
809
|
+
})
|
|
810
|
+
});
|
|
811
|
+
response.end();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (route.command === 'apple-callback') {
|
|
816
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
817
|
+
methodNotAllowed(response, 'Use GET or POST for /auth/apple/callback.');
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (!appleAuth?.configured || !completeAppleDeviceApproval) {
|
|
822
|
+
methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let code = url.searchParams.get('code') ?? '';
|
|
827
|
+
let state = url.searchParams.get('state') ?? '';
|
|
828
|
+
if (request.method === 'POST') {
|
|
829
|
+
const body = await readUrlEncodedBody(request);
|
|
830
|
+
code = body.code ?? code;
|
|
831
|
+
state = body.state ?? state;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (!code || !state) {
|
|
835
|
+
badRequest(response, 'code and state are required.');
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const result = await completeAppleDeviceApproval({ code, state });
|
|
841
|
+
html(response, 200, deviceApprovalSuccessPage({
|
|
842
|
+
email: result.account.email ?? '',
|
|
843
|
+
userId: result.account.id
|
|
844
|
+
}));
|
|
845
|
+
return;
|
|
846
|
+
} catch (error) {
|
|
847
|
+
console.error('Apple device approval failed', {
|
|
848
|
+
message: error?.message ?? String(error),
|
|
849
|
+
method: request.method,
|
|
850
|
+
hasCode: Boolean(code),
|
|
851
|
+
hasState: Boolean(state)
|
|
852
|
+
});
|
|
853
|
+
html(response, 400, deviceApprovalPage({
|
|
854
|
+
title: 'Approval failed',
|
|
855
|
+
message: error.message,
|
|
856
|
+
isError: true
|
|
857
|
+
}));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (route.command === 'device-approve') {
|
|
863
|
+
if (!approveDeviceChallenge) {
|
|
864
|
+
methodNotAllowed(response, 'Device approval is not enabled for this service mode.');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (request.method === 'GET') {
|
|
869
|
+
html(response, 200, deviceApprovalPage({
|
|
870
|
+
title: 'Approve incremnt login',
|
|
871
|
+
message: 'Enter the approval code shown by the CLI and the account identity that should own the session.',
|
|
872
|
+
userCode: url.searchParams.get('userCode') ?? '',
|
|
873
|
+
email: url.searchParams.get('email') ?? '',
|
|
874
|
+
userId: url.searchParams.get('userId') ?? '',
|
|
875
|
+
includeManualForm: manualDeviceApprovalEnabled,
|
|
876
|
+
appleStartPath: appleAuth?.configured
|
|
877
|
+
? `/auth/apple/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
|
|
878
|
+
: null,
|
|
879
|
+
googleStartPath: googleAuth?.configured
|
|
880
|
+
? `/auth/google/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
|
|
881
|
+
: null
|
|
882
|
+
}));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (request.method !== 'POST') {
|
|
887
|
+
methodNotAllowed(response, 'Use GET or POST for /auth/device/approve.');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!manualDeviceApprovalEnabled) {
|
|
892
|
+
methodNotAllowed(response, 'Manual device approval is disabled for this service.');
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
const contentType = request.headers['content-type'] ?? '';
|
|
898
|
+
const body = contentType.includes('application/json')
|
|
899
|
+
? await readJsonBody(request)
|
|
900
|
+
: await readUrlEncodedBody(request);
|
|
901
|
+
const result = await approveDeviceChallenge({
|
|
902
|
+
deviceCode: body.deviceCode ?? null,
|
|
903
|
+
userCode: body.userCode ?? body.user_code ?? null,
|
|
904
|
+
userId: body.userId ?? body.user_id ?? null,
|
|
905
|
+
email: body.email ?? null
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
if (contentType.includes('application/json')) {
|
|
909
|
+
json(response, 200, {
|
|
910
|
+
ok: true,
|
|
911
|
+
deviceCode: result.deviceCode,
|
|
912
|
+
userCode: result.userCode,
|
|
913
|
+
account: result.account,
|
|
914
|
+
expiresAt: result.expiresAt
|
|
915
|
+
});
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
html(response, 200, deviceApprovalPage({
|
|
920
|
+
title: 'Login approved',
|
|
921
|
+
message: `The session for ${result.account.email ?? result.account.id} is ready. Return to the CLI to finish login.`,
|
|
922
|
+
userCode: result.userCode,
|
|
923
|
+
email: result.account.email ?? '',
|
|
924
|
+
userId: result.account.id
|
|
925
|
+
}));
|
|
926
|
+
return;
|
|
927
|
+
} catch (error) {
|
|
928
|
+
html(response, 400, deviceApprovalPage({
|
|
929
|
+
title: 'Approval failed',
|
|
930
|
+
message: error.message,
|
|
931
|
+
userCode: url.searchParams.get('userCode') ?? '',
|
|
932
|
+
email: url.searchParams.get('email') ?? '',
|
|
933
|
+
userId: url.searchParams.get('userId') ?? '',
|
|
934
|
+
isError: true
|
|
935
|
+
}));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (route.command === 'admin-bootstrap-user') {
|
|
941
|
+
if (request.method !== 'POST') {
|
|
942
|
+
methodNotAllowed(response, 'Use POST for /admin/bootstrap-user.');
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (!adminBootstrapSecret || !bootstrapAccount) {
|
|
947
|
+
methodNotAllowed(response, 'Admin bootstrap is not enabled for this service mode.');
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!constantTimeEqual(adminSecret(request), adminBootstrapSecret)) {
|
|
952
|
+
unauthorized(response, request);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
try {
|
|
957
|
+
const body = await readJsonBody(request);
|
|
958
|
+
if (!body.userId) {
|
|
959
|
+
badRequest(response, 'userId is required.');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!body.bootstrapToken) {
|
|
964
|
+
badRequest(response, 'bootstrapToken is required.');
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const result = await bootstrapAccount({
|
|
969
|
+
userId: body.userId,
|
|
970
|
+
email: body.email ?? null,
|
|
971
|
+
bootstrapToken: body.bootstrapToken,
|
|
972
|
+
snapshot: body.snapshot ?? null
|
|
973
|
+
});
|
|
974
|
+
json(response, 200, {
|
|
975
|
+
ok: true,
|
|
976
|
+
userId: result.userId,
|
|
977
|
+
email: result.email,
|
|
978
|
+
snapshotPath: result.snapshotPath
|
|
979
|
+
});
|
|
980
|
+
return;
|
|
981
|
+
} catch (error) {
|
|
982
|
+
badRequest(response, error.message);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const requestToken = bearerToken(request);
|
|
988
|
+
if (route.command === 'session-login') {
|
|
989
|
+
if (request.method !== 'POST') {
|
|
990
|
+
methodNotAllowed(response, 'Use POST for /auth/session.');
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (!requestToken) {
|
|
995
|
+
unauthorized(response, request);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const session = issueSession
|
|
1000
|
+
? await issueSession(requestToken)
|
|
1001
|
+
: requestToken === token
|
|
1002
|
+
? {
|
|
1003
|
+
accessToken: token,
|
|
1004
|
+
expiresAt: '2999-01-01T00:00:00Z',
|
|
1005
|
+
account: { id: 'remote-user', email: null }
|
|
1006
|
+
}
|
|
1007
|
+
: null;
|
|
1008
|
+
|
|
1009
|
+
if (!session) {
|
|
1010
|
+
unauthorized(response, request);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
json(response, 200, {
|
|
1015
|
+
ok: true,
|
|
1016
|
+
session: {
|
|
1017
|
+
accessToken: session.accessToken,
|
|
1018
|
+
expiresAt: session.expiresAt
|
|
1019
|
+
},
|
|
1020
|
+
account: session.account
|
|
1021
|
+
});
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (route.command === 'session-refresh') {
|
|
1026
|
+
if (request.method !== 'POST') {
|
|
1027
|
+
methodNotAllowed(response, 'Use POST for /auth/refresh.');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (!requestToken) {
|
|
1032
|
+
unauthorized(response, request);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (!refreshSession) {
|
|
1037
|
+
methodNotAllowed(response, 'Session refresh is not enabled for this service mode.');
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const result = await refreshSession(requestToken);
|
|
1042
|
+
if (!result) {
|
|
1043
|
+
unauthorized(response, request);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
json(response, 200, {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
session: {
|
|
1050
|
+
accessToken: result.accessToken,
|
|
1051
|
+
expiresAt: result.expiresAt
|
|
1052
|
+
},
|
|
1053
|
+
account: result.account
|
|
1054
|
+
});
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const readAuthenticator = authenticateReadToken ?? authenticateToken;
|
|
1059
|
+
const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
|
|
1060
|
+
|
|
1061
|
+
if (route.command === 'contract') {
|
|
1062
|
+
const account = readAuthenticator
|
|
1063
|
+
? await readAuthenticator(requestToken)
|
|
1064
|
+
: requestToken === token
|
|
1065
|
+
? { id: 'remote-user', email: null }
|
|
1066
|
+
: null;
|
|
1067
|
+
if (!account) {
|
|
1068
|
+
unauthorized(response, request);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
json(response, 200, syncServiceContractPayload({
|
|
1072
|
+
auth: {
|
|
1073
|
+
tokenBootstrap: Boolean(issueSession || token),
|
|
1074
|
+
deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
|
|
1075
|
+
browserApproval: Boolean(approveDeviceChallenge),
|
|
1076
|
+
devEmail: Boolean(issueDevLogin)
|
|
1077
|
+
}
|
|
1078
|
+
}));
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (route.command === 'sync-upload') {
|
|
1083
|
+
if (request.method !== 'POST' && request.method !== 'PUT') {
|
|
1084
|
+
methodNotAllowed(response, 'Use POST or PUT for /sync/snapshot.');
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (!writeSnapshotForAccount) {
|
|
1089
|
+
methodNotAllowed(response, 'Snapshot uploads are not enabled for this service mode.');
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const account = writeAuthenticator
|
|
1094
|
+
? await writeAuthenticator(requestToken)
|
|
1095
|
+
: requestToken === token
|
|
1096
|
+
? { id: 'remote-user', email: null }
|
|
1097
|
+
: null;
|
|
1098
|
+
if (!account) {
|
|
1099
|
+
unauthorized(response, request);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
try {
|
|
1104
|
+
const snapshot = await readJsonBody(request);
|
|
1105
|
+
if (
|
|
1106
|
+
!snapshot ||
|
|
1107
|
+
typeof snapshot !== 'object' ||
|
|
1108
|
+
!Array.isArray(snapshot.sessions)
|
|
1109
|
+
) {
|
|
1110
|
+
badRequest(
|
|
1111
|
+
response,
|
|
1112
|
+
'Invalid snapshot: must be an object with a sessions array'
|
|
1113
|
+
);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const result = await writeSnapshotForAccount(account, snapshot);
|
|
1117
|
+
const sessionCount = snapshot.sessions?.length ?? 0;
|
|
1118
|
+
console.log(`snapshot uploaded (${sessionCount} sessions)`);
|
|
1119
|
+
json(response, 200, {
|
|
1120
|
+
ok: true,
|
|
1121
|
+
userId: account.id,
|
|
1122
|
+
snapshotPath: result.snapshotPath
|
|
1123
|
+
});
|
|
1124
|
+
return;
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
badRequest(response, error.message);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const account = readAuthenticator
|
|
1132
|
+
? await readAuthenticator(requestToken)
|
|
1133
|
+
: requestToken === token
|
|
1134
|
+
? { id: 'remote-user', email: null }
|
|
1135
|
+
: null;
|
|
1136
|
+
if (!account) {
|
|
1137
|
+
unauthorized(response, request);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
let snapshot;
|
|
1142
|
+
try {
|
|
1143
|
+
snapshot = loadSnapshotForAccount
|
|
1144
|
+
? await loadSnapshotForAccount(account)
|
|
1145
|
+
: await loadSnapshot();
|
|
1146
|
+
} catch {
|
|
1147
|
+
snapshot = { sessions: [], programs: [], activeProgramId: null };
|
|
1148
|
+
}
|
|
1149
|
+
const result = executeReadCommand(snapshot, route.command, route.options);
|
|
1150
|
+
if (!result.ok) {
|
|
1151
|
+
if (result.error.startsWith('Session not found')) {
|
|
1152
|
+
notFound(response, result.error);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
badRequest(response, result.error);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
json(response, 200, result.payload);
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
internalError(response, error);
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
}
|