incremnt 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -8,15 +8,85 @@ export const capabilities = {
8
8
  remoteBootstrap: true
9
9
  };
10
10
 
11
+ // Single source of truth for all read commands.
12
+ // Drives: officialCommands, readCommands, help text, and the contract JSON schema.
13
+ // When adding a command: add one entry here — everything else updates automatically.
14
+ export const commandSchema = [
15
+ {
16
+ command: 'sessions list',
17
+ id: 'session-insights',
18
+ description: 'List recent sessions',
19
+ options: [
20
+ { name: 'limit', type: 'number', required: false, description: 'Max sessions to return (default: 10)' }
21
+ ]
22
+ },
23
+ {
24
+ command: 'sessions show',
25
+ id: 'session-show',
26
+ description: 'Show session details',
27
+ usage: 'sessions show --id <session-id>',
28
+ options: [
29
+ { name: 'id', type: 'string', required: true, description: 'Session ID' }
30
+ ]
31
+ },
32
+ {
33
+ command: 'sessions compare',
34
+ id: 'planned-vs-actual',
35
+ description: 'Compare planned vs actual',
36
+ usage: 'sessions compare --session-id <session-id>',
37
+ options: [
38
+ { name: 'session-id', type: 'string', required: true, description: 'Session ID' }
39
+ ]
40
+ },
41
+ {
42
+ command: 'sessions explain',
43
+ id: 'why-did-this-change',
44
+ description: 'Explain session context',
45
+ usage: 'sessions explain --session-id <session-id>',
46
+ options: [
47
+ { name: 'session-id', type: 'string', required: true, description: 'Session ID' }
48
+ ]
49
+ },
50
+ {
51
+ command: 'programs list',
52
+ id: 'program-list',
53
+ description: 'List all programs',
54
+ options: []
55
+ },
56
+ {
57
+ command: 'programs current',
58
+ id: 'program-summary',
59
+ description: 'Show active program',
60
+ options: []
61
+ },
62
+ {
63
+ command: 'programs show',
64
+ id: 'program-detail',
65
+ description: 'Show program with all exercises',
66
+ usage: 'programs show --id <program-id>',
67
+ options: [
68
+ { name: 'id', type: 'string', required: false, description: 'Program ID (defaults to active program)' }
69
+ ]
70
+ },
71
+ {
72
+ command: 'exercises history',
73
+ id: 'exercise-history',
74
+ description: 'Exercise history',
75
+ usage: 'exercises history --name <exercise-name>',
76
+ options: [
77
+ { name: 'name', type: 'string', required: true, description: 'Exercise name' }
78
+ ]
79
+ },
80
+ {
81
+ command: 'records',
82
+ id: 'records',
83
+ description: 'Personal records',
84
+ options: []
85
+ }
86
+ ];
87
+
11
88
  export const officialCommands = [
12
- 'sessions list',
13
- 'sessions show --id <session-id>',
14
- 'sessions compare --session-id <session-id>',
15
- 'sessions explain --session-id <session-id>',
16
- 'programs list',
17
- 'programs current',
18
- 'exercises history --name <exercise-name>',
19
- 'records',
89
+ ...commandSchema.map((c) => c.usage ?? c.command),
20
90
  'status',
21
91
  'contract',
22
92
  'login',
@@ -28,13 +98,4 @@ export const officialCommands = [
28
98
  'logout'
29
99
  ];
30
100
 
31
- export const readCommands = new Set([
32
- 'session-insights',
33
- 'session-show',
34
- 'exercise-history',
35
- 'records',
36
- 'program-list',
37
- 'program-summary',
38
- 'planned-vs-actual',
39
- 'why-did-this-change'
40
- ]);
101
+ export const readCommands = new Set(commandSchema.map((c) => c.id));
package/src/format.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { commandSchema } from './contract.js';
2
3
 
3
4
  const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
4
5
  const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -212,6 +213,57 @@ function formatProgramList(payload) {
212
213
  return lines.join('\n');
213
214
  }
214
215
 
216
+ function formatProgramDetail(payload) {
217
+ if (!payload) {
218
+ return 'Program not found.';
219
+ }
220
+
221
+ const dot = payload.isActive ? chalk.green('\u25cf') : chalk.dim('\u25cb');
222
+ const lines = [` ${dot} ${chalk.bold(payload.programName)}${dimDot()}${chalk.dim(`Week ${payload.currentWeek}`)}`, ''];
223
+
224
+ if (payload.trainingWeekdays?.length > 0) {
225
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
226
+ const schedule = payload.trainingWeekdays.map((d) => dayNames[d] ?? d).join(', ');
227
+ lines.push(keyValue('Schedule', schedule));
228
+ lines.push('');
229
+ }
230
+
231
+ for (const day of payload.days ?? []) {
232
+ lines.push(` ${chalk.bold(`Day ${day.dayIndex + 1}`)}${dimDot()}${chalk.bold(day.title ?? 'Untitled')}`);
233
+
234
+ if (day.exercises.length === 0) {
235
+ lines.push(` ${chalk.dim('No exercises')}`);
236
+ }
237
+
238
+ for (const exercise of day.exercises) {
239
+ const setCount = exercise.sets.length;
240
+ const weights = [...new Set(exercise.sets.map((s) => s.weight).filter((w) => w != null))];
241
+ const reps = [...new Set(exercise.sets.map((s) => s.reps).filter((r) => r != null))];
242
+
243
+ let setStr = `${setCount} sets`;
244
+ if (reps.length === 1) {
245
+ setStr = `${setCount}\u00d7${reps[0]}`;
246
+ } else if (reps.length > 1) {
247
+ setStr = `${setCount}\u00d7${reps.join('/')}`;
248
+ }
249
+
250
+ const weightStr = weights.length === 1
251
+ ? `@ ${weights[0]} kg`
252
+ : weights.length > 1
253
+ ? `@ ${Math.min(...weights)}-${Math.max(...weights)} kg`
254
+ : '';
255
+
256
+ const muscle = exercise.muscleGroup ? chalk.dim(` (${exercise.muscleGroup})`) : '';
257
+ lines.push(` ${exercise.name}${muscle}${dimDot()}${chalk.dim([setStr, weightStr].filter(Boolean).join(' '))}`);
258
+ }
259
+
260
+ lines.push('');
261
+ }
262
+
263
+ if (lines.at(-1) === '') lines.pop();
264
+ return lines.join('\n');
265
+ }
266
+
215
267
  function formatPlannedVsActual(payload) {
216
268
  if (!payload) {
217
269
  return 'No comparison data found.';
@@ -326,17 +378,11 @@ export function formatHelp() {
326
378
  ` incremnt <command> [options]`,
327
379
  '',
328
380
  header('COMMANDS'),
329
- cmd('sessions list', 'List recent sessions'),
330
- cmd('sessions show --id <id>', 'Show session details'),
331
- cmd('sessions compare --id <id>', 'Compare planned vs actual'),
332
- cmd('sessions explain --id <id>', 'Explain session context'),
333
- cmd('programs list', 'List all programs'),
334
- cmd('programs current', 'Show active program'),
335
- cmd('exercises history --name <name>', 'Exercise history'),
336
- cmd('records', 'Personal records'),
381
+ ...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
337
382
  '',
338
383
  header('AUTH'),
339
- cmd('login --base-url <url>', 'Sign in (device flow)'),
384
+ cmd('login', 'Sign in (opens browser)'),
385
+ cmd('login --base-url <url>', 'Sign in to a specific server'),
340
386
  cmd('login --base-url <url> --token <t>', 'Sign in with token'),
341
387
  cmd('login --snapshot <file>', 'Use local snapshot'),
342
388
  cmd('login --session-file <file>', 'Import session file'),
@@ -365,6 +411,7 @@ export function formatPretty(command, payload) {
365
411
  'exercise-history': formatExerciseHistory,
366
412
  'program-summary': formatProgramSummary,
367
413
  'program-list': formatProgramList,
414
+ 'program-detail': formatProgramDetail,
368
415
  'planned-vs-actual': formatPlannedVsActual,
369
416
  'why-did-this-change': formatWhyDidThisChange
370
417
  }[command];
package/src/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
- import { capabilities, contractVersion, officialCommands, readCommands } from './contract.js';
3
+ import { capabilities, commandSchema, contractVersion, officialCommands, readCommands } from './contract.js';
4
4
  import {
5
5
  bootstrapSessionFromRemoteBaseUrl,
6
6
  bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
@@ -53,13 +53,7 @@ function parseArgs(argv) {
53
53
  export async function runCli(argv, stdout, stderr) {
54
54
  const { command, options } = parseArgs(argv);
55
55
  const normalizedCommand = ({
56
- 'sessions list': 'session-insights',
57
- 'sessions show': 'session-show',
58
- 'programs list': 'program-list',
59
- 'programs current': 'program-summary',
60
- 'exercises history': 'exercise-history',
61
- 'sessions compare': 'planned-vs-actual',
62
- 'sessions explain': 'why-did-this-change',
56
+ ...Object.fromEntries(commandSchema.map((c) => [c.command, c.id])),
63
57
  insights: 'session-insights',
64
58
  history: 'exercise-history',
65
59
  prs: 'records',
@@ -114,7 +108,8 @@ export async function runCli(argv, stdout, stderr) {
114
108
  contractVersion,
115
109
  binary: 'incremnt',
116
110
  capabilities,
117
- officialCommands
111
+ officialCommands,
112
+ schema: commandSchema
118
113
  }, null, 2)}\n`);
119
114
  return 0;
120
115
  }
@@ -135,6 +130,37 @@ export async function runCli(argv, stdout, stderr) {
135
130
  }
136
131
 
137
132
  if (normalizedCommand === 'login') {
133
+ if (options.snapshot) {
134
+ try {
135
+ const result = await bootstrapSessionFromSnapshot(options.snapshot);
136
+ stdout.write(`${JSON.stringify({
137
+ ok: true,
138
+ sessionPath: result.path,
139
+ account: result.session.account,
140
+ transport: result.session.transport
141
+ }, null, 2)}\n`);
142
+ return 0;
143
+ } catch (error) {
144
+ stderr.write(`${error.message}\n`);
145
+ return 1;
146
+ }
147
+ }
148
+
149
+ if (options['session-file']) {
150
+ try {
151
+ const result = await importSessionFile(options['session-file']);
152
+ stdout.write(`${JSON.stringify({
153
+ ok: true,
154
+ sessionPath: result.path,
155
+ account: result.session.account
156
+ }, null, 2)}\n`);
157
+ return 0;
158
+ } catch (error) {
159
+ stderr.write(`${error.message}\n`);
160
+ return 1;
161
+ }
162
+ }
163
+
138
164
  const loginBaseUrl = resolveLoginBaseUrl(options, sessionState);
139
165
 
140
166
  if (loginBaseUrl) {
@@ -228,49 +254,28 @@ export async function runCli(argv, stdout, stderr) {
228
254
  }
229
255
  }
230
256
 
231
- if (options.snapshot) {
232
- try {
233
- const result = await bootstrapSessionFromSnapshot(options.snapshot);
234
- stdout.write(`${JSON.stringify({
235
- ok: true,
236
- sessionPath: result.path,
237
- account: result.session.account,
238
- transport: result.session.transport
239
- }, null, 2)}\n`);
240
- return 0;
241
- } catch (error) {
242
- stderr.write(`${error.message}\n`);
243
- return 1;
244
- }
245
- }
246
-
247
- if (options['session-file']) {
248
- try {
249
- const result = await importSessionFile(options['session-file']);
250
- stdout.write(`${JSON.stringify({
251
- ok: true,
252
- sessionPath: result.path,
253
- account: result.session.account
254
- }, null, 2)}\n`);
255
- return 0;
256
- } catch (error) {
257
- stderr.write(`${error.message}\n`);
258
- return 1;
259
- }
260
- }
261
-
262
- stderr.write('Pass --base-url, --snapshot, or --session-file to login, or set INCREMNT_BASE_URL.\n');
257
+ // loginBaseUrl always resolves (falls back to DEFAULT_BASE_URL) so this is unreachable
258
+ // unless INCREMNT_BASE_URL is explicitly set to empty string to disable the default
259
+ stderr.write('Pass --snapshot or --session-file, or run incremnt login to sign in.\n');
263
260
  return 1;
264
261
  }
265
262
 
263
+ const wantJson = options.json || !(stdout.isTTY ?? false);
264
+ const explicitJson = Boolean(options.json);
265
+
266
266
  if (!readCommands.has(normalizedCommand)) {
267
- stderr.write(`Unknown command: ${command}\n`);
267
+ const message = `Unknown command: ${command}`;
268
+ if (explicitJson) {
269
+ stdout.write(`${JSON.stringify({ error: message, code: 'UNKNOWN_COMMAND' }, null, 2)}\n`);
270
+ } else {
271
+ stderr.write(`${message}\n`);
272
+ }
273
+
268
274
  return 1;
269
275
  }
270
276
 
271
277
  try {
272
278
  const payload = await transport.executeReadCommand(normalizedCommand, options);
273
- const wantJson = options.json || !(stdout.isTTY ?? false);
274
279
  if (wantJson) {
275
280
  stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
276
281
  } else {
@@ -280,16 +285,24 @@ export async function runCli(argv, stdout, stderr) {
280
285
 
281
286
  return 0;
282
287
  } catch (error) {
283
- stderr.write(`${error.message}\n`);
288
+ if (explicitJson) {
289
+ stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
290
+ } else {
291
+ stderr.write(`${error.message}\n`);
292
+ }
293
+
284
294
  return 1;
285
295
  }
286
296
  }
287
297
 
298
+ const DEFAULT_BASE_URL = 'https://incremnt-sync.onrender.com';
299
+
288
300
  function resolveLoginBaseUrl(options, sessionState) {
289
- return options['base-url']
290
- ?? process.env.INCREMNT_BASE_URL
291
- ?? sessionState.session?.transport?.baseUrl
292
- ?? null;
301
+ if (options['base-url']) return options['base-url'];
302
+ // If INCREMNT_BASE_URL is explicitly set (even to empty string), respect it — empty = no URL
303
+ if ('INCREMNT_BASE_URL' in process.env) return process.env.INCREMNT_BASE_URL || null;
304
+ if (sessionState.session?.transport?.baseUrl) return sessionState.session.transport.baseUrl;
305
+ return DEFAULT_BASE_URL;
293
306
  }
294
307
 
295
308
  async function maybeOpenBrowser(url) {
package/src/queries.js CHANGED
@@ -101,6 +101,7 @@ export function exerciseHistory(snapshot, exerciseName) {
101
101
  muscleGroup: exercise.muscleGroup,
102
102
  weight: set.weight,
103
103
  reps: set.reps,
104
+ estimatedOneRM: Number(set.weight) * (1 + Number(set.reps) / 30),
104
105
  rir: exercise.rir ?? null,
105
106
  recommendation: session.recommendations?.[exercise.name] ?? null,
106
107
  historicalContext: session.historicalContext ?? null
@@ -200,7 +201,19 @@ export function findSession(snapshot, sessionId) {
200
201
 
201
202
  export function sessionDetails(snapshot, sessionId) {
202
203
  const session = findSession(snapshot, sessionId);
203
- return session ? sessionSummary(session) : null;
204
+ if (!session) return null;
205
+
206
+ const summary = sessionSummary(session);
207
+ summary.exercises = (session.exercises ?? []).map((exercise) => ({
208
+ name: exercise.name,
209
+ muscleGroup: exercise.muscleGroup ?? null,
210
+ sets: (exercise.sets ?? []).filter((s) => s.isComplete).map((s) => ({
211
+ weight: s.weight ?? null,
212
+ reps: s.reps ?? null,
213
+ rpe: s.rpe ?? null
214
+ }))
215
+ }));
216
+ return summary;
204
217
  }
205
218
 
206
219
  export function plannedVsActual(snapshot, sessionId) {
@@ -252,6 +265,37 @@ export function whyDidThisChange(snapshot, sessionId) {
252
265
  };
253
266
  }
254
267
 
268
+ export function programDetail(snapshot, programId) {
269
+ const programs = snapshot.programs ?? [];
270
+ const program = programId
271
+ ? programs.find((p) => p.id === programId)
272
+ : programs.find((p) => p.id === snapshot.activeProgramId) ?? programs[0];
273
+
274
+ if (!program) return null;
275
+
276
+ return {
277
+ programId: program.id,
278
+ programName: program.name,
279
+ isActive: program.id === snapshot.activeProgramId,
280
+ currentWeek: Number(program.completedCyclesCount ?? 0) + 1,
281
+ currentDayIndex: program.currentDayIndex ?? 0,
282
+ trainingWeekdays: program.trainingWeekdays ?? [],
283
+ completedCyclesCount: program.completedCyclesCount ?? 0,
284
+ days: (program.days ?? []).map((day, index) => ({
285
+ dayIndex: index,
286
+ title: day.title ?? null,
287
+ exercises: (day.exercises ?? []).map((exercise) => ({
288
+ name: exercise.name,
289
+ muscleGroup: exercise.muscleGroup ?? null,
290
+ sets: (exercise.sets ?? []).map((set) => ({
291
+ reps: set.reps ?? null,
292
+ weight: set.weight ?? null
293
+ }))
294
+ }))
295
+ }))
296
+ };
297
+ }
298
+
255
299
  function requiredOption(options, primaryKey, legacyKey = null) {
256
300
  return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
257
301
  }
@@ -303,6 +347,16 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
303
347
  return { ok: true, payload: programSummary(snapshot) };
304
348
  }
305
349
 
350
+ if (normalizedCommand === 'program-detail') {
351
+ const programId = requiredOption(options, 'id');
352
+ const payload = programDetail(snapshot, programId ?? null);
353
+ if (!payload) {
354
+ return { ok: false, error: programId ? `Program not found: ${programId}` : 'No programs found' };
355
+ }
356
+
357
+ return { ok: true, payload };
358
+ }
359
+
306
360
  if (normalizedCommand === 'planned-vs-actual') {
307
361
  const sessionId = requiredOption(options, 'session-id');
308
362
  if (!sessionId) {
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
3
3
  import { executeReadCommand } from './queries.js';
4
4
 
@@ -13,6 +13,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
13
13
  'google-callback': 20,
14
14
  'apple-start': 20,
15
15
  'apple-callback': 20,
16
+ 'web-auth-start': 20,
17
+ 'web-auth-callback': 20,
16
18
  'session-login': 60,
17
19
  'session-refresh': 30
18
20
  };
@@ -54,10 +56,10 @@ function internalError(response, error) {
54
56
 
55
57
  function constantTimeEqual(a, b) {
56
58
  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);
59
+ // Hash both values to fixed length to avoid leaking length information
60
+ const hashA = createHash('sha256').update(a).digest();
61
+ const hashB = createHash('sha256').update(b).digest();
62
+ return timingSafeEqual(hashA, hashB);
61
63
  }
62
64
 
63
65
  function bearerToken(request) {
@@ -88,6 +90,17 @@ function createRateLimiter({
88
90
  const mergedRules = { ...DEFAULT_RATE_LIMIT_RULES, ...rules };
89
91
  const buckets = new Map();
90
92
 
93
+ // Periodically evict expired buckets to prevent memory leak
94
+ const cleanupInterval = setInterval(() => {
95
+ const now = Date.now();
96
+ for (const [key, bucket] of buckets) {
97
+ if (bucket.resetAt <= now) {
98
+ buckets.delete(key);
99
+ }
100
+ }
101
+ }, 5 * 60_000);
102
+ if (cleanupInterval.unref) cleanupInterval.unref();
103
+
91
104
  return {
92
105
  check(request, command) {
93
106
  const limit = mergedRules[command];
@@ -166,6 +179,10 @@ function routeRequest(url) {
166
179
  return { command: 'apple-callback', options: {} };
167
180
  }
168
181
 
182
+ if (pathname === '/auth/web/start') {
183
+ return { command: 'web-auth-start', options: {} };
184
+ }
185
+
169
186
  if (pathname === '/admin/bootstrap-user') {
170
187
  return { command: 'admin-bootstrap-user', options: {} };
171
188
  }
@@ -195,6 +212,11 @@ function routeRequest(url) {
195
212
  return { command: 'program-summary', options: {} };
196
213
  }
197
214
 
215
+ const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
216
+ if (programShowMatch) {
217
+ return { command: 'program-detail', options: { id: programShowMatch[1] } };
218
+ }
219
+
198
220
  if (pathname === '/cli/exercises/history') {
199
221
  return {
200
222
  command: 'exercise-history',
@@ -558,14 +580,38 @@ export function createSyncServiceRequestHandler({
558
580
  configured: false
559
581
  },
560
582
  buildGoogleAuthUrl = null,
583
+ buildAppleWebAuthUrl = null,
584
+ buildGoogleWebAuthUrl = null,
585
+ completeAppleWebAuth = null,
586
+ completeGoogleWebAuth = null,
561
587
  refreshSession,
562
588
  allowManualDeviceApproval = false,
563
- rateLimitConfig = null
589
+ rateLimitConfig = null,
590
+ corsOrigins = []
564
591
  }) {
565
592
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
566
593
 
567
594
  return async function handle(request, response) {
568
595
  try {
596
+ // Security headers on all responses
597
+ response.setHeader('X-Content-Type-Options', 'nosniff');
598
+ response.setHeader('X-Frame-Options', 'DENY');
599
+ response.setHeader('Cache-Control', 'no-store');
600
+
601
+ const origin = request.headers.origin ?? '';
602
+ const corsAllowed = corsOrigins.length > 0 && corsOrigins.includes(origin);
603
+ if (corsAllowed) {
604
+ response.setHeader('Access-Control-Allow-Origin', origin);
605
+ response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
606
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
607
+ }
608
+
609
+ if (request.method === 'OPTIONS') {
610
+ response.writeHead(corsAllowed ? 204 : 404);
611
+ response.end();
612
+ return;
613
+ }
614
+
569
615
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
570
616
  const route = routeRequest(url);
571
617
  if (!route) {
@@ -755,11 +801,6 @@ export function createSyncServiceRequestHandler({
755
801
  return;
756
802
  }
757
803
 
758
- if (!googleAuth?.configured || !completeGoogleDeviceApproval) {
759
- methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
760
- return;
761
- }
762
-
763
804
  const code = url.searchParams.get('code') ?? '';
764
805
  const state = url.searchParams.get('state') ?? '';
765
806
  if (!code || !state) {
@@ -767,6 +808,42 @@ export function createSyncServiceRequestHandler({
767
808
  return;
768
809
  }
769
810
 
811
+ let parsedState;
812
+ try {
813
+ parsedState = JSON.parse(state);
814
+ } catch {
815
+ badRequest(response, 'Invalid state parameter.');
816
+ return;
817
+ }
818
+
819
+ if (parsedState?.flow === 'web') {
820
+ if (!completeGoogleWebAuth) {
821
+ methodNotAllowed(response, 'Web auth is not enabled for this service mode.');
822
+ return;
823
+ }
824
+
825
+ try {
826
+ const result = await completeGoogleWebAuth({ code, state });
827
+ const returnUrl = new URL(result.returnUrl);
828
+ returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
829
+ response.writeHead(302, { location: returnUrl.toString() });
830
+ response.end();
831
+ return;
832
+ } catch (error) {
833
+ html(response, 400, deviceApprovalPage({
834
+ title: 'Login failed',
835
+ message: error.message,
836
+ isError: true
837
+ }));
838
+ return;
839
+ }
840
+ }
841
+
842
+ if (!googleAuth?.configured || !completeGoogleDeviceApproval) {
843
+ methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
844
+ return;
845
+ }
846
+
770
847
  try {
771
848
  const result = await completeGoogleDeviceApproval({ code, state });
772
849
  html(response, 200, deviceApprovalSuccessPage({
@@ -818,11 +895,6 @@ export function createSyncServiceRequestHandler({
818
895
  return;
819
896
  }
820
897
 
821
- if (!appleAuth?.configured || !completeAppleDeviceApproval) {
822
- methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
823
- return;
824
- }
825
-
826
898
  let code = url.searchParams.get('code') ?? '';
827
899
  let state = url.searchParams.get('state') ?? '';
828
900
  if (request.method === 'POST') {
@@ -836,6 +908,42 @@ export function createSyncServiceRequestHandler({
836
908
  return;
837
909
  }
838
910
 
911
+ let parsedState;
912
+ try {
913
+ parsedState = JSON.parse(state);
914
+ } catch {
915
+ badRequest(response, 'Invalid state parameter.');
916
+ return;
917
+ }
918
+
919
+ if (parsedState?.flow === 'web') {
920
+ if (!completeAppleWebAuth) {
921
+ methodNotAllowed(response, 'Web auth is not enabled for this service mode.');
922
+ return;
923
+ }
924
+
925
+ try {
926
+ const result = await completeAppleWebAuth({ code, state });
927
+ const returnUrl = new URL(result.returnUrl);
928
+ returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
929
+ response.writeHead(302, { location: returnUrl.toString() });
930
+ response.end();
931
+ return;
932
+ } catch (error) {
933
+ html(response, 400, deviceApprovalPage({
934
+ title: 'Login failed',
935
+ message: error.message,
936
+ isError: true
937
+ }));
938
+ return;
939
+ }
940
+ }
941
+
942
+ if (!appleAuth?.configured || !completeAppleDeviceApproval) {
943
+ methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
944
+ return;
945
+ }
946
+
839
947
  try {
840
948
  const result = await completeAppleDeviceApproval({ code, state });
841
949
  html(response, 200, deviceApprovalSuccessPage({
@@ -859,6 +967,63 @@ export function createSyncServiceRequestHandler({
859
967
  }
860
968
  }
861
969
 
970
+ if (route.command === 'web-auth-start') {
971
+ if (request.method !== 'GET') {
972
+ methodNotAllowed(response, 'Use GET for /auth/web/start.');
973
+ return;
974
+ }
975
+
976
+ const provider = url.searchParams.get('provider') ?? '';
977
+ const returnUrl = url.searchParams.get('returnUrl') ?? '';
978
+ if (!provider || !returnUrl) {
979
+ badRequest(response, 'provider and returnUrl are required.');
980
+ return;
981
+ }
982
+
983
+ if (provider !== 'apple' && provider !== 'google') {
984
+ badRequest(response, 'provider must be apple or google.');
985
+ return;
986
+ }
987
+
988
+ // Validate returnUrl against allowed CORS origins to prevent open redirect token theft.
989
+ // Fail closed: if no origins are configured, reject web auth entirely.
990
+ try {
991
+ const parsedReturnUrl = new URL(returnUrl);
992
+ const returnOrigin = parsedReturnUrl.origin;
993
+ if (corsOrigins.length === 0 || !corsOrigins.includes(returnOrigin)) {
994
+ badRequest(response, 'returnUrl origin is not in the allowed origins list.');
995
+ return;
996
+ }
997
+ } catch {
998
+ badRequest(response, 'returnUrl must be a valid URL.');
999
+ return;
1000
+ }
1001
+
1002
+ const nonce = randomUUID();
1003
+
1004
+ if (provider === 'apple') {
1005
+ if (!buildAppleWebAuthUrl || !appleAuth?.configured) {
1006
+ methodNotAllowed(response, 'Apple web auth is not enabled for this service mode.');
1007
+ return;
1008
+ }
1009
+ const authUrl = buildAppleWebAuthUrl(appleAuth, { nonce, returnUrl });
1010
+ response.writeHead(302, { location: authUrl });
1011
+ response.end();
1012
+ return;
1013
+ }
1014
+
1015
+ if (provider === 'google') {
1016
+ if (!buildGoogleWebAuthUrl || !googleAuth?.configured) {
1017
+ methodNotAllowed(response, 'Google web auth is not enabled for this service mode.');
1018
+ return;
1019
+ }
1020
+ const authUrl = buildGoogleWebAuthUrl(googleAuth, { nonce, returnUrl });
1021
+ response.writeHead(302, { location: authUrl });
1022
+ response.end();
1023
+ return;
1024
+ }
1025
+ }
1026
+
862
1027
  if (route.command === 'device-approve') {
863
1028
  if (!approveDeviceChallenge) {
864
1029
  methodNotAllowed(response, 'Device approval is not enabled for this service mode.');