incremnt 0.1.18 → 0.2.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.
@@ -1,6 +1,7 @@
1
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
+ import { fenceContent, sanitizeHistory, detectSystemPromptLeak } from './prompt-security.js';
4
5
 
5
6
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
6
7
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
@@ -11,6 +12,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
11
12
  'vitals-summary-ai': 3,
12
13
  'checkpoint-summary-ai': 3,
13
14
  'ask-ai': 5,
15
+ 'ai-feedback': 60,
16
+ 'coach-memory': 30,
14
17
  'dev-login': 10,
15
18
  'device-start': 20,
16
19
  'device-poll': 300,
@@ -23,11 +26,70 @@ const DEFAULT_RATE_LIMIT_RULES = {
23
26
  'web-auth-callback': 20,
24
27
  'session-login': 60,
25
28
  'session-refresh': 30,
29
+ 'delete-account': 1,
26
30
  'sync-account-preferences': 30,
27
31
  'proposals': 30,
28
- 'proposal-update': 30
32
+ 'proposal-update': 30,
33
+ 'social-invite': 20,
34
+ 'social-groups': 60,
35
+ 'social-group-create': 20,
36
+ 'social-group-update': 20,
37
+ 'social-group-detail': 60,
38
+ 'social-group-invite': 20,
39
+ 'social-group-join': 20,
40
+ 'social-group-leave': 20,
41
+ 'social-group-feed': 60,
42
+ 'social-group-leaderboard': 60,
43
+ 'social-follow': 20,
44
+ 'social-follow-account': 20,
45
+ 'social-unfollow': 20,
46
+ 'social-following': 60,
47
+ 'social-followers': 60,
48
+ 'social-me-profile': 60,
49
+ 'social-profile-update': 20,
50
+ 'social-user-profile': 60,
51
+ 'social-user-search': 60,
52
+ 'social-user-suggestions': 60,
53
+ 'social-user-report': 60,
54
+ 'social-user-exercise-history': 60,
55
+ 'social-user-mute': 20,
56
+ 'social-user-block': 20,
57
+ 'social-report': 20,
58
+ 'social-follow-requests': 60,
59
+ 'social-follow-request-approve': 20,
60
+ 'social-follow-request-decline': 20,
61
+ 'social-media-upload-url': 30,
62
+ 'social-media-complete': 30,
63
+ 'social-media-delete': 30,
64
+ 'social-post-update': 30,
65
+ 'social-notifications': 60,
66
+ 'social-notification-read': 60,
67
+ 'social-notification-read-all': 30,
68
+ 'social-feed': 60,
69
+ 'social-feed-groups': 60,
70
+ 'social-group-add-member': 30,
71
+ 'social-feed-detail': 60,
72
+ 'social-like': 60,
73
+ 'social-unlike': 60,
74
+ 'social-comments': 60,
75
+ 'social-comment-like': 60,
76
+ 'social-comment-unlike': 60,
77
+ 'social-likes': 60,
78
+ 'social-leaderboard': 60,
79
+ 'social-gym-leaderboard': 60,
80
+ 'sync-push-device-register': 30,
81
+ 'sync-push-device-revoke': 30
29
82
  };
30
83
 
84
+ export function isNoInsightResponse(text) {
85
+ const normalized = String(text ?? '')
86
+ .trim()
87
+ .replace(/^["'`]+|["'`]+$/g, '')
88
+ .replace(/[.\s]+$/g, '')
89
+ .toUpperCase();
90
+ return normalized === 'NO_INSIGHT' || normalized.startsWith('NO_INSIGHT\n');
91
+ }
92
+
31
93
  function json(response, statusCode, payload) {
32
94
  response.writeHead(statusCode, { 'content-type': 'application/json' });
33
95
  response.end(JSON.stringify(payload));
@@ -49,6 +111,80 @@ function logRequest(request, statusCode, extra = '') {
49
111
  console.log(`${method} ${path} ${statusCode}${suffix}`);
50
112
  }
51
113
 
114
+ function anonymizeAccountId(accountId) {
115
+ if (typeof accountId !== 'string' || !accountId.trim()) {
116
+ return 'anon:unknown';
117
+ }
118
+ const digest = createHash('sha256')
119
+ .update(`account:${accountId}`)
120
+ .digest('hex')
121
+ .slice(0, 12);
122
+ return `anon:${digest}`;
123
+ }
124
+
125
+ function anonymizeSessionToken(sessionToken) {
126
+ if (typeof sessionToken !== 'string' || !sessionToken.trim()) {
127
+ return 'sess:unknown';
128
+ }
129
+ const digest = createHash('sha256')
130
+ .update(`token:${sessionToken}`)
131
+ .digest('hex')
132
+ .slice(0, 12);
133
+ return `sess:${digest}`;
134
+ }
135
+
136
+ function sanitizeSocialLogValue(value) {
137
+ if (typeof value === 'string') return value;
138
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
139
+ if (value == null) return null;
140
+ return String(value);
141
+ }
142
+
143
+ function socialLogSuffix(request, accountId, fields = {}) {
144
+ const safeFields = {
145
+ aid: anonymizeAccountId(accountId),
146
+ sid: anonymizeSessionToken(bearerToken(request))
147
+ };
148
+ for (const [key, value] of Object.entries(fields)) {
149
+ safeFields[key] = sanitizeSocialLogValue(value);
150
+ }
151
+ return `social-meta ${JSON.stringify(safeFields)}`;
152
+ }
153
+
154
+ function anonymizeOwnerIds(items, { max = 5 } = {}) {
155
+ if (!Array.isArray(items) || items.length === 0) {
156
+ return '';
157
+ }
158
+
159
+ const uniqueIds = [...new Set(
160
+ items
161
+ .map((item) => item?.userId)
162
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
163
+ )];
164
+
165
+ return uniqueIds
166
+ .slice(0, max)
167
+ .map(anonymizeAccountId)
168
+ .join(',');
169
+ }
170
+
171
+ function anonymizeRelationIds(items, { max = 5 } = {}) {
172
+ if (!Array.isArray(items) || items.length === 0) {
173
+ return '';
174
+ }
175
+
176
+ const uniqueIds = [...new Set(
177
+ items
178
+ .map((item) => item?.accountId ?? item?.userId ?? item?.id)
179
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
180
+ )];
181
+
182
+ return uniqueIds
183
+ .slice(0, max)
184
+ .map(anonymizeAccountId)
185
+ .join(',');
186
+ }
187
+
52
188
  function unauthorized(response, request) {
53
189
  if (request) logRequest(request, 401);
54
190
  json(response, 401, { error: 'Unauthorized' });
@@ -62,12 +198,16 @@ function badRequest(response, message) {
62
198
  json(response, 400, { error: message });
63
199
  }
64
200
 
201
+ function forbidden(response, message = 'Forbidden') {
202
+ json(response, 403, { error: message });
203
+ }
204
+
65
205
  function methodNotAllowed(response, message = 'Method not allowed') {
66
206
  json(response, 405, { error: message });
67
207
  }
68
208
 
69
209
  function internalError(response, error, onError) {
70
- console.error('Internal error:', error.message);
210
+ console.error('Internal error:', error.message, error.stack ?? '');
71
211
  if (onError) onError(error);
72
212
  json(response, 500, { error: 'Internal server error' });
73
213
  }
@@ -152,7 +292,7 @@ function createRateLimiter({
152
292
  };
153
293
  }
154
294
 
155
- function routeRequest(url) {
295
+ function routeRequest(url, method) {
156
296
  const pathname = url.pathname;
157
297
 
158
298
  if (pathname === '/healthz') {
@@ -219,10 +359,30 @@ function routeRequest(url) {
219
359
  return { command: 'sync-upload', options: {} };
220
360
  }
221
361
 
362
+ if (pathname === '/cli/account') {
363
+ return { command: 'delete-account', options: {} };
364
+ }
365
+
222
366
  if (pathname === '/sync/account/preferences') {
223
367
  return { command: 'sync-account-preferences', options: {} };
224
368
  }
225
369
 
370
+ if (pathname === '/sync/push/device') {
371
+ return { command: 'sync-push-device', options: {} };
372
+ }
373
+
374
+ {
375
+ const pushDeviceMatch = pathname.match(/^\/sync\/push\/device\/([^/]+)$/);
376
+ if (pushDeviceMatch) {
377
+ return {
378
+ command: 'sync-push-device-revoke',
379
+ options: {
380
+ deviceToken: decodeURIComponent(pushDeviceMatch[1])
381
+ }
382
+ };
383
+ }
384
+ }
385
+
226
386
  if (pathname === '/cli/sessions') {
227
387
  return {
228
388
  command: 'session-insights',
@@ -324,11 +484,16 @@ function routeRequest(url) {
324
484
  };
325
485
  }
326
486
 
487
+ const VALID_TONES = new Set(['default', 'hype', 'numbers-only']);
488
+ const parseTone = (raw) => VALID_TONES.has(raw) ? raw : undefined;
489
+
327
490
  if (pathname === '/cli/workout-summary/ai') {
328
491
  return {
329
492
  command: 'workout-summary-ai',
330
493
  options: {
331
- 'session-id': url.searchParams.get('session-id') ?? undefined
494
+ 'session-id': url.searchParams.get('session-id') ?? undefined,
495
+ 'exclude': url.searchParams.get('exclude') ?? undefined,
496
+ 'tone': parseTone(url.searchParams.get('tone'))
332
497
  }
333
498
  };
334
499
  }
@@ -337,13 +502,15 @@ function routeRequest(url) {
337
502
  return {
338
503
  command: 'cycle-summary-ai',
339
504
  options: {
340
- 'program-id': url.searchParams.get('program-id') ?? undefined
505
+ 'program-id': url.searchParams.get('program-id') ?? undefined,
506
+ 'exclude': url.searchParams.get('exclude') ?? undefined,
507
+ 'tone': parseTone(url.searchParams.get('tone'))
341
508
  }
342
509
  };
343
510
  }
344
511
 
345
512
  if (pathname === '/cli/vitals-summary/ai') {
346
- return { command: 'vitals-summary-ai', options: {} };
513
+ return { command: 'vitals-summary-ai', options: { 'exclude': url.searchParams.get('exclude') ?? undefined, 'tone': parseTone(url.searchParams.get('tone')) } };
347
514
  }
348
515
 
349
516
  if (pathname === '/cli/checkpoint-summary/ai') {
@@ -351,11 +518,17 @@ function routeRequest(url) {
351
518
  command: 'checkpoint-summary-ai',
352
519
  options: {
353
520
  'program-id': url.searchParams.get('program-id') ?? undefined,
354
- 'checkpoint-week': url.searchParams.get('checkpoint-week') ?? undefined
521
+ 'checkpoint-week': url.searchParams.get('checkpoint-week') ?? undefined,
522
+ 'exclude': url.searchParams.get('exclude') ?? undefined,
523
+ 'tone': parseTone(url.searchParams.get('tone'))
355
524
  }
356
525
  };
357
526
  }
358
527
 
528
+ if (pathname === '/cli/coach-memory') {
529
+ return { command: 'coach-memory', options: {} };
530
+ }
531
+
359
532
  if (pathname === '/cli/ask') {
360
533
  return { command: 'ask-ai', options: {} };
361
534
  }
@@ -371,6 +544,10 @@ function routeRequest(url) {
371
544
  }
372
545
  }
373
546
 
547
+ if (pathname === '/cli/ai/feedback') {
548
+ return { command: 'ai-feedback', options: {} };
549
+ }
550
+
374
551
  if (pathname === '/cli/health/ai') {
375
552
  return {
376
553
  command: 'health-ai',
@@ -391,6 +568,343 @@ function routeRequest(url) {
391
568
  return { command: 'training-load', options: {} };
392
569
  }
393
570
 
571
+ // --- Social endpoints ---
572
+ if (pathname === '/cli/social/me/profile') {
573
+ return { command: 'social-me-profile', options: {} };
574
+ }
575
+
576
+ if (pathname === '/cli/social/users/search') {
577
+ return {
578
+ command: 'social-user-search',
579
+ options: {
580
+ q: url.searchParams.get('q') ?? undefined,
581
+ limit: url.searchParams.get('limit') ?? undefined
582
+ }
583
+ };
584
+ }
585
+
586
+ if (pathname === '/cli/social/users/suggestions') {
587
+ return {
588
+ command: 'social-user-suggestions',
589
+ options: {
590
+ limit: url.searchParams.get('limit') ?? undefined
591
+ }
592
+ };
593
+ }
594
+
595
+ {
596
+ const exerciseHistoryMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/exercises\/history$/);
597
+ if (exerciseHistoryMatch) {
598
+ return {
599
+ command: 'social-user-exercise-history',
600
+ options: {
601
+ accountId: decodeURIComponent(exerciseHistoryMatch[1]),
602
+ name: url.searchParams.get('name') ?? undefined
603
+ }
604
+ };
605
+ }
606
+ }
607
+
608
+ {
609
+ const userReportMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/report$/);
610
+ if (userReportMatch) {
611
+ return {
612
+ command: 'social-user-report',
613
+ options: { accountId: decodeURIComponent(userReportMatch[1]) }
614
+ };
615
+ }
616
+ }
617
+
618
+ {
619
+ const userProfileMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)$/);
620
+ if (userProfileMatch && userProfileMatch[1] !== 'search') {
621
+ return {
622
+ command: 'social-user-profile',
623
+ options: { identifier: decodeURIComponent(userProfileMatch[1]) }
624
+ };
625
+ }
626
+ }
627
+
628
+ {
629
+ const userMuteMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/mute$/);
630
+ if (userMuteMatch) {
631
+ return {
632
+ command: 'social-user-mute',
633
+ options: { userId: decodeURIComponent(userMuteMatch[1]) }
634
+ };
635
+ }
636
+ }
637
+
638
+ {
639
+ const userBlockMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/block$/);
640
+ if (userBlockMatch) {
641
+ return {
642
+ command: 'social-user-block',
643
+ options: { userId: decodeURIComponent(userBlockMatch[1]) }
644
+ };
645
+ }
646
+ }
647
+
648
+ if (pathname === '/cli/social/report') {
649
+ return { command: 'social-report', options: {} };
650
+ }
651
+
652
+ if (pathname === '/cli/social/invite') {
653
+ return { command: 'social-invite', options: {} };
654
+ }
655
+
656
+ if (pathname === '/cli/social/groups') {
657
+ return { command: method === 'POST' ? 'social-group-create' : 'social-groups', options: {} };
658
+ }
659
+
660
+ if (pathname === '/cli/social/groups/join') {
661
+ return { command: 'social-group-join', options: {} };
662
+ }
663
+
664
+ {
665
+ const socialGroupInviteMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)\/invite$/);
666
+ if (socialGroupInviteMatch) {
667
+ return {
668
+ command: 'social-group-invite',
669
+ options: { groupId: decodeURIComponent(socialGroupInviteMatch[1]) }
670
+ };
671
+ }
672
+ }
673
+
674
+ {
675
+ const socialGroupLeaveMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)\/membership$/);
676
+ if (socialGroupLeaveMatch) {
677
+ return {
678
+ command: 'social-group-leave',
679
+ options: { groupId: decodeURIComponent(socialGroupLeaveMatch[1]) }
680
+ };
681
+ }
682
+ }
683
+
684
+ {
685
+ const socialGroupFeedMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)\/feed$/);
686
+ if (socialGroupFeedMatch) {
687
+ return {
688
+ command: 'social-group-feed',
689
+ options: { groupId: decodeURIComponent(socialGroupFeedMatch[1]), ...Object.fromEntries(url.searchParams) }
690
+ };
691
+ }
692
+ }
693
+
694
+ {
695
+ const socialGroupLeaderboardMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)\/leaderboard$/);
696
+ if (socialGroupLeaderboardMatch) {
697
+ return {
698
+ command: 'social-group-leaderboard',
699
+ options: { groupId: decodeURIComponent(socialGroupLeaderboardMatch[1]), ...Object.fromEntries(url.searchParams) }
700
+ };
701
+ }
702
+ }
703
+
704
+ {
705
+ const socialGroupMembersMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)\/members$/);
706
+ if (socialGroupMembersMatch) {
707
+ return {
708
+ command: 'social-group-add-member',
709
+ options: { groupId: decodeURIComponent(socialGroupMembersMatch[1]) }
710
+ };
711
+ }
712
+ }
713
+
714
+ {
715
+ const socialGroupDetailMatch = pathname.match(/^\/cli\/social\/groups\/([^/]+)$/);
716
+ if (socialGroupDetailMatch) {
717
+ const groupId = decodeURIComponent(socialGroupDetailMatch[1]);
718
+ return {
719
+ command: method === 'PATCH' ? 'social-group-update' : 'social-group-detail',
720
+ options: { groupId }
721
+ };
722
+ }
723
+ }
724
+
725
+ if (pathname === '/cli/social/follow') {
726
+ return { command: 'social-follow', options: {} };
727
+ }
728
+
729
+ if (pathname === '/cli/social/follow/requests') {
730
+ return {
731
+ command: 'social-follow-requests',
732
+ options: {
733
+ type: url.searchParams.get('type') ?? undefined,
734
+ status: url.searchParams.get('status') ?? undefined,
735
+ limit: url.searchParams.get('limit') ?? undefined
736
+ }
737
+ };
738
+ }
739
+
740
+ {
741
+ const requestActionMatch = pathname.match(/^\/cli\/social\/follow\/requests\/([^/]+)\/(approve|decline)$/);
742
+ if (requestActionMatch) {
743
+ return {
744
+ command: requestActionMatch[2] === 'approve'
745
+ ? 'social-follow-request-approve'
746
+ : 'social-follow-request-decline',
747
+ options: {
748
+ followerId: decodeURIComponent(requestActionMatch[1])
749
+ }
750
+ };
751
+ }
752
+ }
753
+
754
+ {
755
+ const socialFollowAccountMatch = pathname.match(/^\/cli\/social\/follow\/([^/]+)$/);
756
+ if (socialFollowAccountMatch) {
757
+ return {
758
+ command: 'social-follow-account',
759
+ options: { accountId: decodeURIComponent(socialFollowAccountMatch[1]) }
760
+ };
761
+ }
762
+ }
763
+
764
+ if (pathname === '/cli/social/following') {
765
+ return { command: 'social-following', options: {} };
766
+ }
767
+
768
+ if (pathname === '/cli/social/followers') {
769
+ return { command: 'social-followers', options: {} };
770
+ }
771
+
772
+ if (pathname === '/cli/social/leaderboard') {
773
+ return { command: 'social-leaderboard', options: { ...Object.fromEntries(url.searchParams) } };
774
+ }
775
+
776
+ if (pathname === '/cli/social/gym/leaderboard') {
777
+ return { command: 'social-gym-leaderboard', options: { ...Object.fromEntries(url.searchParams) } };
778
+ }
779
+
780
+ if (pathname === '/cli/social/media/upload-url') {
781
+ return { command: 'social-media-upload-url', options: {} };
782
+ }
783
+
784
+ {
785
+ const mediaCompleteMatch = pathname.match(/^\/cli\/social\/media\/([^/]+)\/complete$/);
786
+ if (mediaCompleteMatch) {
787
+ return {
788
+ command: 'social-media-complete',
789
+ options: { assetId: decodeURIComponent(mediaCompleteMatch[1]) }
790
+ };
791
+ }
792
+ }
793
+
794
+ {
795
+ const mediaDeleteMatch = pathname.match(/^\/cli\/social\/media\/([^/]+)$/);
796
+ if (mediaDeleteMatch) {
797
+ return {
798
+ command: 'social-media-delete',
799
+ options: { assetId: decodeURIComponent(mediaDeleteMatch[1]) }
800
+ };
801
+ }
802
+ }
803
+
804
+ if (pathname === '/cli/social/notifications') {
805
+ return {
806
+ command: 'social-notifications',
807
+ options: {
808
+ limit: url.searchParams.get('limit') ?? undefined,
809
+ before: url.searchParams.get('before') ?? undefined
810
+ }
811
+ };
812
+ }
813
+
814
+ if (pathname === '/cli/social/notifications/read-all') {
815
+ return { command: 'social-notification-read-all', options: {} };
816
+ }
817
+
818
+ {
819
+ const notificationReadMatch = pathname.match(/^\/cli\/social\/notifications\/([^/]+)\/read$/);
820
+ if (notificationReadMatch) {
821
+ return {
822
+ command: 'social-notification-read',
823
+ options: { notificationId: decodeURIComponent(notificationReadMatch[1]) }
824
+ };
825
+ }
826
+ }
827
+
828
+ if (pathname === '/cli/social/feed') {
829
+ return {
830
+ command: 'social-feed',
831
+ options: {
832
+ limit: url.searchParams.get('limit') ?? undefined,
833
+ before: url.searchParams.get('before') ?? undefined
834
+ }
835
+ };
836
+ }
837
+
838
+ if (pathname === '/cli/social/feed/groups') {
839
+ return {
840
+ command: 'social-feed-groups',
841
+ options: {
842
+ limit: url.searchParams.get('limit') ?? undefined,
843
+ before: url.searchParams.get('before') ?? undefined
844
+ }
845
+ };
846
+ }
847
+
848
+ {
849
+ const feedPostEditMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)\/post$/);
850
+ if (feedPostEditMatch) {
851
+ return { command: 'social-post-update', options: { activityId: decodeURIComponent(feedPostEditMatch[1]) } };
852
+ }
853
+ }
854
+
855
+ {
856
+ const feedCommentsMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)\/comments$/);
857
+ if (feedCommentsMatch) {
858
+ return {
859
+ command: 'social-comments',
860
+ options: {
861
+ activityId: decodeURIComponent(feedCommentsMatch[1]),
862
+ limit: url.searchParams.get('limit') ?? undefined
863
+ }
864
+ };
865
+ }
866
+ }
867
+
868
+ {
869
+ const feedCommentLikeMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)\/comments\/([^/]+)\/like$/);
870
+ if (feedCommentLikeMatch) {
871
+ return {
872
+ command: 'social-comment-like',
873
+ options: {
874
+ activityId: decodeURIComponent(feedCommentLikeMatch[1]),
875
+ commentId: decodeURIComponent(feedCommentLikeMatch[2])
876
+ }
877
+ };
878
+ }
879
+ }
880
+
881
+ {
882
+ const feedDetailMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)$/);
883
+ if (feedDetailMatch) {
884
+ return { command: 'social-feed-detail', options: { activityId: decodeURIComponent(feedDetailMatch[1]) } };
885
+ }
886
+ }
887
+
888
+ {
889
+ const feedLikesMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)\/likes$/);
890
+ if (feedLikesMatch) {
891
+ return {
892
+ command: 'social-likes',
893
+ options: {
894
+ activityId: decodeURIComponent(feedLikesMatch[1]),
895
+ limit: url.searchParams.get('limit') ?? undefined
896
+ }
897
+ };
898
+ }
899
+ }
900
+
901
+ {
902
+ const feedLikeMatch = pathname.match(/^\/cli\/social\/feed\/([^/]+)\/like$/);
903
+ if (feedLikeMatch) {
904
+ return { command: 'social-like', options: { activityId: decodeURIComponent(feedLikeMatch[1]) } };
905
+ }
906
+ }
907
+
394
908
  return null;
395
909
  }
396
910
 
@@ -862,9 +1376,16 @@ export function createSyncServiceRequestHandler({
862
1376
  listProposalsForAccount = null,
863
1377
  updateProposalForAccount = null,
864
1378
  updateAnalysisConsentForAccount = null,
1379
+ updateDisplayNameForAccount = null,
865
1380
  saveAskConversationForAccount = null,
866
1381
  listAskConversationsForAccount = null,
867
1382
  getAskConversationForAccount = null,
1383
+ readCoachMemoryForAccount = null,
1384
+ writeCoachMemoryForAccount = null,
1385
+ saveAIFeedbackForAccount = null,
1386
+ deleteAccountForUser = null,
1387
+ // Social
1388
+ social = null,
868
1389
  onError = null
869
1390
  }) {
870
1391
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
@@ -881,7 +1402,7 @@ export function createSyncServiceRequestHandler({
881
1402
  if (corsAllowed) {
882
1403
  response.setHeader('Access-Control-Allow-Origin', origin);
883
1404
  response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
884
- response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, OPTIONS');
1405
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
885
1406
  }
886
1407
 
887
1408
  if (request.method === 'OPTIONS') {
@@ -891,7 +1412,7 @@ export function createSyncServiceRequestHandler({
891
1412
  }
892
1413
 
893
1414
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
894
- const route = routeRequest(url);
1415
+ const route = routeRequest(url, request.method);
895
1416
  if (!route) {
896
1417
  notFound(response);
897
1418
  return;
@@ -902,7 +1423,20 @@ export function createSyncServiceRequestHandler({
902
1423
  return;
903
1424
  }
904
1425
 
905
- const rateLimitResult = rateLimiter.check(request, route.command);
1426
+ let rateLimitCommand = route.command;
1427
+ if (route.command === 'social-like' && request.method === 'DELETE') {
1428
+ rateLimitCommand = 'social-unlike';
1429
+ } else if (route.command === 'social-comment-like' && request.method === 'DELETE') {
1430
+ rateLimitCommand = 'social-comment-unlike';
1431
+ } else if (route.command === 'social-follow-account' && request.method === 'DELETE') {
1432
+ rateLimitCommand = 'social-unfollow';
1433
+ } else if (route.command === 'social-me-profile' && request.method === 'PUT') {
1434
+ rateLimitCommand = 'social-profile-update';
1435
+ } else if (route.command === 'sync-push-device') {
1436
+ rateLimitCommand = 'sync-push-device-register';
1437
+ }
1438
+
1439
+ const rateLimitResult = rateLimiter.check(request, rateLimitCommand);
906
1440
  if (!rateLimitResult.allowed) {
907
1441
  jsonWithHeaders(
908
1442
  response,
@@ -913,7 +1447,7 @@ export function createSyncServiceRequestHandler({
913
1447
  return;
914
1448
  }
915
1449
 
916
- logRequest(request, '-', route.command);
1450
+ logRequest(request, '-', rateLimitCommand);
917
1451
 
918
1452
  const providerApprovalAvailable = Boolean(appleAuth?.configured || googleAuth?.configured);
919
1453
  const manualDeviceApprovalEnabled = allowManualDeviceApproval || !providerApprovalAvailable;
@@ -1507,21 +2041,51 @@ export function createSyncServiceRequestHandler({
1507
2041
  const readAuthenticator = authenticateReadToken ?? authenticateToken;
1508
2042
  const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
1509
2043
 
1510
- if (route.command === 'proposals') {
1511
- if (request.method === 'POST') {
1512
- if (!createProposalForAccount) {
1513
- methodNotAllowed(response, 'Proposal creation is not enabled for this service mode.');
1514
- return;
1515
- }
2044
+ if (route.command === 'delete-account') {
2045
+ if (request.method !== 'DELETE') {
2046
+ methodNotAllowed(response, 'Use DELETE for /cli/account.');
2047
+ return;
2048
+ }
1516
2049
 
1517
- const proposalAccount = writeAuthenticator
1518
- ? await writeAuthenticator(requestToken)
1519
- : requestToken === token
1520
- ? { id: 'remote-user', email: null }
1521
- : null;
1522
- if (!proposalAccount) {
1523
- unauthorized(response, request);
1524
- return;
2050
+ if (!deleteAccountForUser) {
2051
+ methodNotAllowed(response, 'Account deletion is not enabled for this service mode.');
2052
+ return;
2053
+ }
2054
+
2055
+ const deleteAccount = writeAuthenticator
2056
+ ? await writeAuthenticator(requestToken)
2057
+ : null;
2058
+ if (!deleteAccount) {
2059
+ unauthorized(response, request);
2060
+ return;
2061
+ }
2062
+
2063
+ try {
2064
+ await deleteAccountForUser(deleteAccount.id);
2065
+ logRequest(request, 200);
2066
+ json(response, 200, { deleted: true });
2067
+ return;
2068
+ } catch (error) {
2069
+ internalError(response, error, onError);
2070
+ return;
2071
+ }
2072
+ }
2073
+
2074
+ if (route.command === 'proposals') {
2075
+ if (request.method === 'POST') {
2076
+ if (!createProposalForAccount) {
2077
+ methodNotAllowed(response, 'Proposal creation is not enabled for this service mode.');
2078
+ return;
2079
+ }
2080
+
2081
+ const proposalAccount = writeAuthenticator
2082
+ ? await writeAuthenticator(requestToken)
2083
+ : requestToken === token
2084
+ ? { id: 'remote-user', email: null }
2085
+ : null;
2086
+ if (!proposalAccount) {
2087
+ unauthorized(response, request);
2088
+ return;
1525
2089
  }
1526
2090
 
1527
2091
  try {
@@ -1670,7 +2234,30 @@ export function createSyncServiceRequestHandler({
1670
2234
  }
1671
2235
  const result = await writeSnapshotForAccount(account, snapshot);
1672
2236
  const sessionCount = snapshot.sessions?.length ?? 0;
1673
- console.log(`snapshot uploaded (${sessionCount} sessions)`);
2237
+ const prevSessionCount = result.prevSessionCount ?? null;
2238
+ const delta = prevSessionCount != null ? sessionCount - prevSessionCount : null;
2239
+
2240
+ const emptySessions = (snapshot.sessions ?? []).filter(s => !s.exercises || s.exercises.length === 0).length;
2241
+ const emptyExercises = (snapshot.sessions ?? []).reduce((count, s) =>
2242
+ count + (s.exercises ?? []).filter(e => !(e.sets ?? []).some(set => set.isComplete)).length, 0);
2243
+ const missingDates = (snapshot.sessions ?? []).filter(s => !s.completedAt && !s.summary?.date && !s.date).length;
2244
+ const duplicateIds = sessionCount - new Set((snapshot.sessions ?? []).map(s => s.id)).size;
2245
+
2246
+ const meta = {
2247
+ aid: anonymizeAccountId(account.id),
2248
+ sessions: sessionCount,
2249
+ ...(delta != null && { delta }),
2250
+ ...(result.activityCount != null && { newEvents: result.activityCount }),
2251
+ appVersion: snapshot.appVersion ?? null,
2252
+ schemaVersion: snapshot.schemaVersion ?? null,
2253
+ programs: snapshot.programs?.length ?? 0,
2254
+ hasProfile: Boolean(snapshot.profile?.name),
2255
+ ...(emptySessions > 0 && { emptySessions }),
2256
+ ...(emptyExercises > 0 && { emptyExercises }),
2257
+ ...(missingDates > 0 && { missingDates }),
2258
+ ...(duplicateIds > 0 && { duplicateIds })
2259
+ };
2260
+ console.log(`snapshot-meta ${JSON.stringify(meta)}`);
1674
2261
  json(response, 200, {
1675
2262
  ok: true,
1676
2263
  userId: account.id,
@@ -1689,7 +2276,7 @@ export function createSyncServiceRequestHandler({
1689
2276
  return;
1690
2277
  }
1691
2278
 
1692
- if (!updateAnalysisConsentForAccount) {
2279
+ if (!updateAnalysisConsentForAccount && !updateDisplayNameForAccount) {
1693
2280
  methodNotAllowed(response, 'Account preference updates are not enabled for this service mode.');
1694
2281
  return;
1695
2282
  }
@@ -1706,15 +2293,46 @@ export function createSyncServiceRequestHandler({
1706
2293
 
1707
2294
  try {
1708
2295
  const body = await readJsonBody(request);
1709
- if (typeof body.analysisConsentEnabled !== 'boolean') {
1710
- badRequest(response, 'analysisConsentEnabled must be a boolean.');
2296
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
2297
+ badRequest(response, 'Request body must be a JSON object.');
1711
2298
  return;
1712
2299
  }
2300
+ const wantsAnalysisConsent = Object.prototype.hasOwnProperty.call(body, 'analysisConsentEnabled');
2301
+ const wantsDisplayName = Object.prototype.hasOwnProperty.call(body, 'displayName');
2302
+
2303
+ if (!wantsAnalysisConsent && !wantsDisplayName) {
2304
+ badRequest(response, 'Provide analysisConsentEnabled and/or displayName.');
2305
+ return;
2306
+ }
2307
+
2308
+ let updatedAccount = account;
2309
+ if (wantsAnalysisConsent) {
2310
+ if (!updateAnalysisConsentForAccount) {
2311
+ badRequest(response, 'analysis consent updates are not enabled.');
2312
+ return;
2313
+ }
2314
+ if (typeof body.analysisConsentEnabled !== 'boolean') {
2315
+ badRequest(response, 'analysisConsentEnabled must be a boolean.');
2316
+ return;
2317
+ }
2318
+ updatedAccount = await updateAnalysisConsentForAccount(account, {
2319
+ enabled: body.analysisConsentEnabled,
2320
+ version: body.consentVersion ?? 1
2321
+ });
2322
+ }
2323
+
2324
+ if (wantsDisplayName) {
2325
+ if (!updateDisplayNameForAccount) {
2326
+ badRequest(response, 'display name updates are not enabled.');
2327
+ return;
2328
+ }
2329
+ if (typeof body.displayName !== 'string') {
2330
+ badRequest(response, 'displayName must be a string.');
2331
+ return;
2332
+ }
2333
+ updatedAccount = await updateDisplayNameForAccount(updatedAccount, body.displayName);
2334
+ }
1713
2335
 
1714
- const updatedAccount = await updateAnalysisConsentForAccount(account, {
1715
- enabled: body.analysisConsentEnabled,
1716
- version: body.consentVersion ?? 1
1717
- });
1718
2336
  json(response, 200, {
1719
2337
  ok: true,
1720
2338
  account: updatedAccount
@@ -1726,6 +2344,111 @@ export function createSyncServiceRequestHandler({
1726
2344
  }
1727
2345
  }
1728
2346
 
2347
+ if (route.command === 'sync-push-device') {
2348
+ if (request.method !== 'PUT' && request.method !== 'DELETE') {
2349
+ methodNotAllowed(response, 'Use PUT or DELETE for /sync/push/device.');
2350
+ return;
2351
+ }
2352
+
2353
+ if (request.method === 'PUT' && !social?.registerPushDevice) {
2354
+ methodNotAllowed(response, 'Push device registration is not enabled for this service mode.');
2355
+ return;
2356
+ }
2357
+ if (request.method === 'DELETE' && !social?.revokePushDevice) {
2358
+ methodNotAllowed(response, 'Push device revoke is not enabled for this service mode.');
2359
+ return;
2360
+ }
2361
+
2362
+ const writeAccount = writeAuthenticator
2363
+ ? await writeAuthenticator(requestToken)
2364
+ : requestToken === token
2365
+ ? { id: 'remote-user', email: null }
2366
+ : null;
2367
+ if (!writeAccount) {
2368
+ unauthorized(response, request);
2369
+ return;
2370
+ }
2371
+
2372
+ if (request.method === 'PUT') {
2373
+ try {
2374
+ const body = await readJsonBody(request);
2375
+ const registered = await social.registerPushDevice(writeAccount.id, body ?? {});
2376
+ logRequest(request, 200);
2377
+ json(response, 200, registered);
2378
+ return;
2379
+ } catch (error) {
2380
+ if (
2381
+ error?.message === 'deviceToken is required.' ||
2382
+ error?.message === 'platform must be ios.'
2383
+ ) {
2384
+ badRequest(response, error.message);
2385
+ return;
2386
+ }
2387
+ internalError(response, error, onError);
2388
+ return;
2389
+ }
2390
+ }
2391
+
2392
+ try {
2393
+ const body = await readJsonBody(request);
2394
+ const revoked = await social.revokePushDevice(writeAccount.id, body?.deviceToken);
2395
+ if (!revoked) {
2396
+ notFound(response, 'Push device token not found.');
2397
+ return;
2398
+ }
2399
+ logRequest(request, 200);
2400
+ json(response, 200, revoked);
2401
+ return;
2402
+ } catch (error) {
2403
+ if (error?.message === 'deviceToken is required.') {
2404
+ badRequest(response, error.message);
2405
+ return;
2406
+ }
2407
+ internalError(response, error, onError);
2408
+ return;
2409
+ }
2410
+ }
2411
+
2412
+ if (route.command === 'sync-push-device-revoke') {
2413
+ if (request.method !== 'DELETE') {
2414
+ methodNotAllowed(response, 'Use DELETE for /sync/push/device/:token.');
2415
+ return;
2416
+ }
2417
+
2418
+ if (!social?.revokePushDevice) {
2419
+ methodNotAllowed(response, 'Push device revoke is not enabled for this service mode.');
2420
+ return;
2421
+ }
2422
+
2423
+ const writeAccount = writeAuthenticator
2424
+ ? await writeAuthenticator(requestToken)
2425
+ : requestToken === token
2426
+ ? { id: 'remote-user', email: null }
2427
+ : null;
2428
+ if (!writeAccount) {
2429
+ unauthorized(response, request);
2430
+ return;
2431
+ }
2432
+
2433
+ try {
2434
+ const revoked = await social.revokePushDevice(writeAccount.id, route.options.deviceToken);
2435
+ if (!revoked) {
2436
+ notFound(response, 'Push device token not found.');
2437
+ return;
2438
+ }
2439
+ logRequest(request, 200);
2440
+ json(response, 200, revoked);
2441
+ return;
2442
+ } catch (error) {
2443
+ if (error?.message === 'deviceToken is required.') {
2444
+ badRequest(response, error.message);
2445
+ return;
2446
+ }
2447
+ internalError(response, error, onError);
2448
+ return;
2449
+ }
2450
+ }
2451
+
1729
2452
  const account = readAuthenticator
1730
2453
  ? await readAuthenticator(requestToken)
1731
2454
  : requestToken === token
@@ -1744,6 +2467,9 @@ export function createSyncServiceRequestHandler({
1744
2467
  } catch {
1745
2468
  snapshot = { sessions: [], programs: [], activeProgramId: null };
1746
2469
  }
2470
+ // Parse comma-separated exclude param into a Set for AI context builders
2471
+ const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
2472
+
1747
2473
  if (route.command === 'workout-summary-ai') {
1748
2474
  const sessionId = route.options['session-id'];
1749
2475
  if (!sessionId) {
@@ -1752,7 +2478,7 @@ export function createSyncServiceRequestHandler({
1752
2478
  }
1753
2479
 
1754
2480
  const { workoutSummaryContext } = await import('./queries.js');
1755
- const ctx = workoutSummaryContext(snapshot, sessionId);
2481
+ const ctx = workoutSummaryContext(snapshot, sessionId, { exclude: parseExclude(route.options['exclude']) });
1756
2482
  if (!ctx) {
1757
2483
  notFound(response, `Session not found: ${sessionId}`);
1758
2484
  return;
@@ -1766,7 +2492,11 @@ export function createSyncServiceRequestHandler({
1766
2492
 
1767
2493
  try {
1768
2494
  const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
1769
- const result = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey });
2495
+ const result = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
2496
+ if (isNoInsightResponse(result.text)) {
2497
+ response.writeHead(204).end();
2498
+ return;
2499
+ }
1770
2500
  if (result.fallback && onError) {
1771
2501
  const warning = new Error(`AI workout-summary used fallback model ${result.model}`);
1772
2502
  warning.level = 'warning';
@@ -1777,6 +2507,13 @@ export function createSyncServiceRequestHandler({
1777
2507
  fallbackModel: result.model
1778
2508
  });
1779
2509
  }
2510
+ const { SYSTEM_PROMPTS_FOR_LEAK_CHECK: leakPromptsW } = await import('./openrouter.js');
2511
+ if (detectSystemPromptLeak(result.text, leakPromptsW)) {
2512
+ console.error('SECURITY: System prompt leak detected in workout-summary response, blocking');
2513
+ onError?.(new Error('System prompt leak detected in AI response'), { feature: 'workout-summary', security: true });
2514
+ json(response, 200, { summary: null, model: result.model, filtered: true });
2515
+ return;
2516
+ }
1780
2517
  json(response, 200, { summary: result.text, model: result.model });
1781
2518
  } catch (err) {
1782
2519
  console.error('AI workout summary error:', err.message);
@@ -1790,6 +2527,30 @@ export function createSyncServiceRequestHandler({
1790
2527
  return;
1791
2528
  }
1792
2529
 
2530
+ if (route.command === 'coach-memory') {
2531
+ if (request.method !== 'GET') {
2532
+ methodNotAllowed(response, 'Use GET for /cli/coach-memory.');
2533
+ return;
2534
+ }
2535
+ if (!readCoachMemoryForAccount) {
2536
+ json(response, 503, { error: 'Coach memory not available' });
2537
+ return;
2538
+ }
2539
+ try {
2540
+ const memory = await readCoachMemoryForAccount(account);
2541
+ if (!memory) {
2542
+ response.writeHead(204);
2543
+ response.end();
2544
+ return;
2545
+ }
2546
+ json(response, 200, { memory: memory.content, version: memory.version, updatedAt: memory.updatedAt });
2547
+ } catch (err) {
2548
+ console.error('Coach memory read error:', err.message);
2549
+ json(response, 500, { error: 'Failed to read coach memory' });
2550
+ }
2551
+ return;
2552
+ }
2553
+
1793
2554
  if (route.command === 'cycle-summary-ai') {
1794
2555
  const programId = route.options['program-id'];
1795
2556
  if (!programId) {
@@ -1798,12 +2559,22 @@ export function createSyncServiceRequestHandler({
1798
2559
  }
1799
2560
 
1800
2561
  const { cycleSummaryContext } = await import('./queries.js');
1801
- const ctx = cycleSummaryContext(snapshot, programId);
2562
+ const ctx = cycleSummaryContext(snapshot, programId, { exclude: parseExclude(route.options['exclude']) });
1802
2563
  if (!ctx) {
1803
2564
  notFound(response, `No completed cycle found for program: ${programId}`);
1804
2565
  return;
1805
2566
  }
1806
2567
 
2568
+ // Inject coach memory into cycle summary context if available
2569
+ let coachMemory = null;
2570
+ if (readCoachMemoryForAccount) {
2571
+ try {
2572
+ coachMemory = await readCoachMemoryForAccount(account);
2573
+ } catch (memErr) {
2574
+ console.error('Coach memory read error (cycle-summary):', memErr.message);
2575
+ }
2576
+ }
2577
+
1807
2578
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1808
2579
  if (!openrouterKey) {
1809
2580
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -1812,7 +2583,10 @@ export function createSyncServiceRequestHandler({
1812
2583
 
1813
2584
  try {
1814
2585
  const { generateCoachingSummary } = await import('./openrouter.js');
1815
- const result = await generateCoachingSummary(ctx, { apiKey: openrouterKey });
2586
+ if (coachMemory?.content) {
2587
+ ctx.coachMemory = coachMemory.content;
2588
+ }
2589
+ const result = await generateCoachingSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
1816
2590
  if (result.fallback && onError) {
1817
2591
  const warning = new Error(`AI cycle-summary used fallback model ${result.model}`);
1818
2592
  warning.level = 'warning';
@@ -1823,7 +2597,39 @@ export function createSyncServiceRequestHandler({
1823
2597
  fallbackModel: result.model
1824
2598
  });
1825
2599
  }
2600
+ const { SYSTEM_PROMPTS_FOR_LEAK_CHECK: leakPromptsC } = await import('./openrouter.js');
2601
+ if (detectSystemPromptLeak(result.text, leakPromptsC)) {
2602
+ console.error('SECURITY: System prompt leak detected in cycle-summary response, blocking');
2603
+ onError?.(new Error('System prompt leak detected in AI response'), { feature: 'cycle-summary', security: true });
2604
+ json(response, 200, { summary: null, model: result.model, filtered: true });
2605
+ return;
2606
+ }
1826
2607
  json(response, 200, { summary: result.text, model: result.model });
2608
+
2609
+ // Background: update coach memory after responding
2610
+ if (writeCoachMemoryForAccount && readCoachMemoryForAccount) {
2611
+ setImmediate(async () => {
2612
+ try {
2613
+ const { generateMemoryUpdate } = await import('./openrouter.js');
2614
+ // Build recent context from previous cycle summaries
2615
+ const recentLines = (ctx.previousCycles || [])
2616
+ .filter((pc) => pc.previousAISummary)
2617
+ .map((pc) => `Week ${pc.weekNumber}: ${pc.previousAISummary.split('\n')[0].slice(0, 200)}`)
2618
+ .join('\n');
2619
+ const memResult = await generateMemoryUpdate(
2620
+ coachMemory?.content || '',
2621
+ result.text,
2622
+ recentLines || null,
2623
+ { apiKey: openrouterKey }
2624
+ );
2625
+ await writeCoachMemoryForAccount(account, memResult.text);
2626
+ console.log(`Coach memory updated for account (v${(coachMemory?.version ?? 0) + 1})`);
2627
+ } catch (memErr) {
2628
+ console.error('Background coach memory update failed:', memErr.message);
2629
+ onError?.(memErr, { feature: 'coach-memory-update' });
2630
+ }
2631
+ });
2632
+ }
1827
2633
  } catch (err) {
1828
2634
  console.error('AI cycle summary error:', err.message);
1829
2635
  onError?.(err, {
@@ -1844,11 +2650,11 @@ export function createSyncServiceRequestHandler({
1844
2650
  }
1845
2651
 
1846
2652
  const { vitalsSummaryContext } = await import('./queries.js');
1847
- const ctx = vitalsSummaryContext(snapshot);
2653
+ const ctx = vitalsSummaryContext(snapshot, { exclude: parseExclude(route.options['exclude']) });
1848
2654
 
1849
2655
  try {
1850
2656
  const { generateVitalsSummary } = await import('./openrouter.js');
1851
- const result = await generateVitalsSummary(ctx, { apiKey: openrouterKey });
2657
+ const result = await generateVitalsSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
1852
2658
  if (result.fallback && onError) {
1853
2659
  const warning = new Error(`AI vitals-summary used fallback model ${result.model}`);
1854
2660
  warning.level = 'warning';
@@ -1859,6 +2665,13 @@ export function createSyncServiceRequestHandler({
1859
2665
  fallbackModel: result.model
1860
2666
  });
1861
2667
  }
2668
+ const { SYSTEM_PROMPTS_FOR_LEAK_CHECK: leakPromptsV } = await import('./openrouter.js');
2669
+ if (detectSystemPromptLeak(result.text, leakPromptsV)) {
2670
+ console.error('SECURITY: System prompt leak detected in vitals-summary response, blocking');
2671
+ onError?.(new Error('System prompt leak detected in AI response'), { feature: 'vitals-summary', security: true });
2672
+ json(response, 200, { summary: null, model: result.model, filtered: true });
2673
+ return;
2674
+ }
1862
2675
  json(response, 200, { summary: result.text, model: result.model });
1863
2676
  } catch (err) {
1864
2677
  console.error('AI vitals summary error:', err.message);
@@ -1885,12 +2698,24 @@ export function createSyncServiceRequestHandler({
1885
2698
  }
1886
2699
 
1887
2700
  const { checkpointContext } = await import('./queries.js');
1888
- const ctx = checkpointContext(snapshot, programId, checkpointWeek);
2701
+ const ctx = checkpointContext(snapshot, programId, checkpointWeek, { exclude: parseExclude(route.options['exclude']) });
1889
2702
  if (!ctx) {
1890
2703
  notFound(response, 'No strength plan found for program');
1891
2704
  return;
1892
2705
  }
1893
2706
 
2707
+ // Inject coach memory into checkpoint context
2708
+ if (readCoachMemoryForAccount) {
2709
+ try {
2710
+ const mem = await readCoachMemoryForAccount(account);
2711
+ if (mem?.content) {
2712
+ ctx.coachMemory = mem.content;
2713
+ }
2714
+ } catch (memErr) {
2715
+ console.error('Coach memory read error (checkpoint):', memErr.message);
2716
+ }
2717
+ }
2718
+
1894
2719
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1895
2720
  if (!openrouterKey) {
1896
2721
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -1899,7 +2724,7 @@ export function createSyncServiceRequestHandler({
1899
2724
 
1900
2725
  try {
1901
2726
  const { generateCheckpointSummary } = await import('./openrouter.js');
1902
- const result = await generateCheckpointSummary(ctx, { apiKey: openrouterKey });
2727
+ const result = await generateCheckpointSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
1903
2728
  if (result.fallback && onError) {
1904
2729
  const warning = new Error(`AI checkpoint-summary used fallback model ${result.model}`);
1905
2730
  warning.level = 'warning';
@@ -1910,6 +2735,13 @@ export function createSyncServiceRequestHandler({
1910
2735
  fallbackModel: result.model
1911
2736
  });
1912
2737
  }
2738
+ const { SYSTEM_PROMPTS_FOR_LEAK_CHECK: leakPromptsCP } = await import('./openrouter.js');
2739
+ if (detectSystemPromptLeak(result.text, leakPromptsCP)) {
2740
+ console.error('SECURITY: System prompt leak detected in checkpoint-summary response, blocking');
2741
+ onError?.(new Error('System prompt leak detected in AI response'), { feature: 'checkpoint-summary', security: true });
2742
+ json(response, 200, { summary: null, model: result.model, filtered: true });
2743
+ return;
2744
+ }
1913
2745
  json(response, 200, { summary: result.text, model: result.model });
1914
2746
  } catch (err) {
1915
2747
  console.error('AI checkpoint summary error:', err.message);
@@ -1953,23 +2785,14 @@ export function createSyncServiceRequestHandler({
1953
2785
  return;
1954
2786
  }
1955
2787
 
1956
- const history = Array.isArray(body?.history) ? body.history : [];
1957
- const validHistory = history.every(
1958
- (m) => m && typeof m.role === 'string' && typeof m.content === 'string'
1959
- );
1960
- if (!validHistory) {
1961
- badRequest(response, 'history must be an array of {role, content} objects');
1962
- return;
1963
- }
2788
+ const history = sanitizeHistory(body?.history);
1964
2789
  const persistedConversation = getAskConversationForAccount
1965
2790
  ? await getAskConversationForAccount(account, conversationId)
1966
2791
  : null;
1967
2792
  const persistedMessages = Array.isArray(persistedConversation?.messages)
1968
- ? persistedConversation.messages.filter(
1969
- (m) => m && typeof m.role === 'string' && typeof m.content === 'string'
1970
- )
2793
+ ? sanitizeHistory(persistedConversation.messages)
1971
2794
  : null;
1972
- const canonicalHistory = persistedMessages ?? history;
2795
+ const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
1973
2796
  const priorUserTurns = canonicalHistory.filter((m) => m.role === 'user').length;
1974
2797
  if (priorUserTurns >= MAX_ASK_USER_TURNS) {
1975
2798
  json(response, 400, { error: `Ask Coach supports up to ${MAX_ASK_USER_TURNS} questions per conversation. Start a new conversation.`, code: 'conversation_limit' });
@@ -1982,12 +2805,41 @@ export function createSyncServiceRequestHandler({
1982
2805
  return;
1983
2806
  }
1984
2807
 
1985
- const { askContext } = await import('./queries.js');
1986
- const ctx = askContext(snapshot);
2808
+ const queries = await import('./queries.js');
2809
+ const exclude = parseExclude(body?.exclude);
2810
+ let ctx = queries.askContext(snapshot, { exclude });
2811
+
2812
+ // Inject coach memory into ask context
2813
+ if (readCoachMemoryForAccount) {
2814
+ try {
2815
+ const mem = await readCoachMemoryForAccount(account);
2816
+ if (mem?.content) {
2817
+ ctx = ctx + '\n\n' + fenceContent('coach_memory', mem.content);
2818
+ }
2819
+ } catch (memErr) {
2820
+ console.error('Coach memory read error (ask):', memErr.message);
2821
+ }
2822
+ }
2823
+
2824
+ const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
1987
2825
 
1988
2826
  try {
1989
2827
  const { generateAskAnswer } = await import('./openrouter.js');
1990
- const askResult = await generateAskAnswer(ctx, question, { apiKey: openrouterKey, history: canonicalHistory });
2828
+
2829
+ const askResult = await generateAskAnswer(ctx, question, {
2830
+ apiKey: openrouterKey, history: canonicalHistory, tone: askTone
2831
+ });
2832
+
2833
+ // Check for system prompt leakage BEFORE persisting — a leaked
2834
+ // response must never be saved to the conversation history.
2835
+ const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
2836
+ if (detectSystemPromptLeak(askResult.text, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
2837
+ console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
2838
+ onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
2839
+ json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
2840
+ return;
2841
+ }
2842
+
1991
2843
  const updatedMessages = [
1992
2844
  ...canonicalHistory,
1993
2845
  { role: 'user', content: question },
@@ -2027,6 +2879,51 @@ export function createSyncServiceRequestHandler({
2027
2879
  return;
2028
2880
  }
2029
2881
 
2882
+ if (route.command === 'ai-feedback') {
2883
+ if (request.method !== 'POST') {
2884
+ methodNotAllowed(response, 'Use POST for /cli/ai/feedback.');
2885
+ return;
2886
+ }
2887
+
2888
+ if (!saveAIFeedbackForAccount) {
2889
+ json(response, 503, { error: 'Feedback not available' });
2890
+ return;
2891
+ }
2892
+
2893
+ let body;
2894
+ try {
2895
+ body = await readJsonBody(request);
2896
+ } catch {
2897
+ badRequest(response, 'Invalid request body.');
2898
+ return;
2899
+ }
2900
+
2901
+ const { feedbackKey, choice, reason, surface, model } = body ?? {};
2902
+ if (typeof feedbackKey !== 'string' || !feedbackKey) {
2903
+ badRequest(response, 'feedbackKey is required.');
2904
+ return;
2905
+ }
2906
+ if (typeof choice !== 'string' || !['positive', 'negative'].includes(choice)) {
2907
+ badRequest(response, 'choice must be "positive" or "negative".');
2908
+ return;
2909
+ }
2910
+
2911
+ try {
2912
+ await saveAIFeedbackForAccount(account, {
2913
+ feedbackKey,
2914
+ choice,
2915
+ reason: typeof reason === 'string' ? reason : null,
2916
+ surface: typeof surface === 'string' ? surface : null,
2917
+ model: typeof model === 'string' ? model : null
2918
+ });
2919
+ json(response, 200, { ok: true });
2920
+ } catch (err) {
2921
+ console.error('Failed to save AI feedback:', err.message);
2922
+ json(response, 500, { error: 'Failed to save feedback' });
2923
+ }
2924
+ return;
2925
+ }
2926
+
2030
2927
  if (route.command === 'ask-history') {
2031
2928
  if (request.method !== 'GET') {
2032
2929
  methodNotAllowed(response, 'Use GET for /cli/ask/history.');
@@ -2091,6 +2988,1216 @@ export function createSyncServiceRequestHandler({
2091
2988
  return;
2092
2989
  }
2093
2990
 
2991
+ // --- Social endpoints ---
2992
+ if (social && route.command === 'social-me-profile') {
2993
+ if (request.method === 'GET') {
2994
+ const profile = await social.getMyProfile(account.id);
2995
+ if (!profile) {
2996
+ notFound(response, 'Social profile not found.');
2997
+ return;
2998
+ }
2999
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3000
+ cmd: route.command,
3001
+ hasUsername: Boolean(profile.username),
3002
+ hasDisplayName: Boolean(profile.displayName),
3003
+ privacy: profile.privacy ?? null
3004
+ }));
3005
+ json(response, 200, profile);
3006
+ return;
3007
+ }
3008
+
3009
+ if (request.method === 'PUT') {
3010
+ const body = await readJsonBody(request);
3011
+ try {
3012
+ const profile = await social.updateMyProfile(account.id, body ?? {});
3013
+ if (!profile) {
3014
+ notFound(response, 'Social profile not found.');
3015
+ return;
3016
+ }
3017
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3018
+ cmd: route.command,
3019
+ hasUsername: Boolean(profile.username),
3020
+ hasDisplayName: Boolean(profile.displayName),
3021
+ privacy: profile.privacy ?? null
3022
+ }));
3023
+ json(response, 200, profile);
3024
+ return;
3025
+ } catch (error) {
3026
+ if (error?.message === 'username is already taken.') {
3027
+ json(response, 409, { error: error.message });
3028
+ return;
3029
+ }
3030
+ if (
3031
+ error?.message?.startsWith('Provide at least one of') ||
3032
+ error?.message?.startsWith('displayName') ||
3033
+ error?.message?.startsWith('username') ||
3034
+ error?.message?.startsWith('privacy') ||
3035
+ error?.message?.startsWith('avatarUrl')
3036
+ ) {
3037
+ badRequest(response, error.message);
3038
+ return;
3039
+ }
3040
+ internalError(response, error, onError);
3041
+ return;
3042
+ }
3043
+ }
3044
+
3045
+ methodNotAllowed(response, 'Use GET or PUT for /cli/social/me/profile.');
3046
+ return;
3047
+ }
3048
+
3049
+ if (social && route.command === 'social-user-search') {
3050
+ if (request.method !== 'GET') {
3051
+ methodNotAllowed(response, 'Use GET for /cli/social/users/search.');
3052
+ return;
3053
+ }
3054
+ const query = route.options.q ?? '';
3055
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
3056
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 50) : 20;
3057
+ const users = await social.searchUsers(account.id, {
3058
+ query,
3059
+ limit
3060
+ });
3061
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3062
+ cmd: route.command,
3063
+ count: users.length,
3064
+ limit,
3065
+ queryLen: query.length,
3066
+ queryHasAt: query.includes('@'),
3067
+ queryHasSpace: /\s/.test(query)
3068
+ }));
3069
+ json(response, 200, { users });
3070
+ return;
3071
+ }
3072
+
3073
+ if (social && route.command === 'social-user-suggestions') {
3074
+ if (request.method !== 'GET') {
3075
+ methodNotAllowed(response, 'Use GET for /cli/social/users/suggestions.');
3076
+ return;
3077
+ }
3078
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 10;
3079
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 20) : 10;
3080
+ const users = await social.suggestUsers(account.id, {
3081
+ limit
3082
+ });
3083
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3084
+ cmd: route.command,
3085
+ count: users.length,
3086
+ limit
3087
+ }));
3088
+ json(response, 200, { users });
3089
+ return;
3090
+ }
3091
+
3092
+ if (social && route.command === 'social-user-profile') {
3093
+ if (request.method !== 'GET') {
3094
+ methodNotAllowed(response, 'Use GET for /cli/social/users/:identifier.');
3095
+ return;
3096
+ }
3097
+ const user = await social.getUserProfile(account.id, route.options.identifier);
3098
+ if (!user) {
3099
+ notFound(response, 'User not found.');
3100
+ return;
3101
+ }
3102
+ logRequest(request, 200);
3103
+ json(response, 200, user);
3104
+ return;
3105
+ }
3106
+
3107
+ if (social && route.command === 'social-user-report') {
3108
+ if (request.method !== 'GET') {
3109
+ methodNotAllowed(response, 'Use GET for /cli/social/users/:accountId/report.');
3110
+ return;
3111
+ }
3112
+ const report = await social.getUserReport(account.id, route.options.accountId);
3113
+ if (!report) {
3114
+ notFound(response, 'User report not found.');
3115
+ return;
3116
+ }
3117
+ logRequest(request, 200);
3118
+ json(response, 200, report);
3119
+ return;
3120
+ }
3121
+
3122
+ if (social && route.command === 'social-user-exercise-history') {
3123
+ if (request.method !== 'GET') {
3124
+ methodNotAllowed(response, 'Use GET for /cli/social/users/:accountId/exercises/history?name=...');
3125
+ return;
3126
+ }
3127
+ if (!route.options.name) {
3128
+ json(response, 400, { ok: false, error: 'name query parameter is required' });
3129
+ return;
3130
+ }
3131
+ const result = await social.getExerciseHistory(account.id, route.options.accountId, route.options.name);
3132
+ if (result.error === 'forbidden') {
3133
+ json(response, 403, { ok: false, error: 'Access denied' });
3134
+ return;
3135
+ }
3136
+ if (result.error === 'not_found') {
3137
+ notFound(response, 'No exercise history found.');
3138
+ return;
3139
+ }
3140
+ logRequest(request, 200);
3141
+ json(response, 200, result);
3142
+ return;
3143
+ }
3144
+
3145
+ if (social && route.command === 'social-invite') {
3146
+ if (request.method !== 'POST') {
3147
+ methodNotAllowed(response, 'Use POST for /cli/social/invite.');
3148
+ return;
3149
+ }
3150
+ try {
3151
+ const result = await social.createFollowInvite(account.id);
3152
+ logRequest(request, 200);
3153
+ json(response, 200, result);
3154
+ } catch (err) {
3155
+ internalError(response, err, onError);
3156
+ }
3157
+ return;
3158
+ }
3159
+
3160
+ if (social && route.command === 'social-groups') {
3161
+ if (request.method !== 'GET') {
3162
+ methodNotAllowed(response, 'Use GET for /cli/social/groups.');
3163
+ return;
3164
+ }
3165
+ const groups = await social.listGroups(account.id);
3166
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3167
+ cmd: route.command,
3168
+ count: groups.length
3169
+ }));
3170
+ json(response, 200, { groups });
3171
+ return;
3172
+ }
3173
+
3174
+ if (social && route.command === 'social-group-create') {
3175
+ if (request.method !== 'POST') {
3176
+ methodNotAllowed(response, 'Use POST for /cli/social/groups.');
3177
+ return;
3178
+ }
3179
+ try {
3180
+ const body = await readJsonBody(request);
3181
+ const group = await social.createGroup(account.id, body ?? {});
3182
+ logRequest(request, 201, socialLogSuffix(request, account.id, {
3183
+ cmd: route.command,
3184
+ groupId: group?.id ?? null
3185
+ }));
3186
+ json(response, 201, group);
3187
+ return;
3188
+ } catch (error) {
3189
+ const message = error?.message ?? '';
3190
+ if (message.startsWith('name ') || message.startsWith('description ')) {
3191
+ badRequest(response, message);
3192
+ return;
3193
+ }
3194
+ internalError(response, error, onError);
3195
+ return;
3196
+ }
3197
+ }
3198
+
3199
+ if (social && route.command === 'social-group-detail') {
3200
+ if (request.method !== 'GET') {
3201
+ methodNotAllowed(response, 'Use GET for /cli/social/groups/:groupId.');
3202
+ return;
3203
+ }
3204
+ const group = await social.getGroupDetail(account.id, route.options.groupId);
3205
+ if (!group) {
3206
+ notFound(response, 'Group not found.');
3207
+ return;
3208
+ }
3209
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3210
+ cmd: route.command,
3211
+ groupId: group.id
3212
+ }));
3213
+ json(response, 200, group);
3214
+ return;
3215
+ }
3216
+
3217
+ if (social && route.command === 'social-group-update') {
3218
+ if (request.method !== 'PATCH') {
3219
+ methodNotAllowed(response, 'Use PATCH for /cli/social/groups/:groupId.');
3220
+ return;
3221
+ }
3222
+ try {
3223
+ const body = await readJsonBody(request);
3224
+ const group = await social.updateGroup(account.id, route.options.groupId, body ?? {});
3225
+ if (!group) {
3226
+ notFound(response, 'Group not found.');
3227
+ return;
3228
+ }
3229
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3230
+ cmd: route.command,
3231
+ groupId: group.id
3232
+ }));
3233
+ json(response, 200, group);
3234
+ return;
3235
+ } catch (error) {
3236
+ const message = error?.message ?? '';
3237
+ if (
3238
+ message === 'Request body is required.' ||
3239
+ message === 'Invalid JSON in request body.' ||
3240
+ message === 'Request body too large.' ||
3241
+ message.startsWith('name ') ||
3242
+ message.startsWith('description ')
3243
+ ) {
3244
+ badRequest(response, message);
3245
+ return;
3246
+ }
3247
+ if (message === 'Group not found.') {
3248
+ notFound(response, message);
3249
+ return;
3250
+ }
3251
+ if (message === 'Only group owners can edit the group.') {
3252
+ forbidden(response, message);
3253
+ return;
3254
+ }
3255
+ internalError(response, error, onError);
3256
+ return;
3257
+ }
3258
+ }
3259
+
3260
+ if (social && route.command === 'social-group-add-member') {
3261
+ if (request.method !== 'POST') {
3262
+ methodNotAllowed(response, 'Use POST for /cli/social/groups/:groupId/members.');
3263
+ return;
3264
+ }
3265
+ try {
3266
+ const body = await readJsonBody(request);
3267
+ const accountId = body?.accountId;
3268
+ if (!accountId) {
3269
+ badRequest(response, 'accountId is required.');
3270
+ return;
3271
+ }
3272
+ const group = await social.addMember(account.id, route.options.groupId, accountId);
3273
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3274
+ cmd: route.command,
3275
+ groupId: route.options.groupId,
3276
+ targetAccountId: accountId
3277
+ }));
3278
+ json(response, 200, group);
3279
+ return;
3280
+ } catch (error) {
3281
+ const message = error?.message ?? '';
3282
+ if (message === 'Group not found.' || message === 'User not found.') {
3283
+ notFound(response, message);
3284
+ return;
3285
+ }
3286
+ if (message === 'Only group owners can add members.') {
3287
+ forbidden(response, message);
3288
+ return;
3289
+ }
3290
+ if (message === 'Already a member of this group.') {
3291
+ badRequest(response, message);
3292
+ return;
3293
+ }
3294
+ internalError(response, error, onError);
3295
+ return;
3296
+ }
3297
+ }
3298
+
3299
+ if (social && route.command === 'social-group-invite') {
3300
+ if (request.method !== 'POST') {
3301
+ methodNotAllowed(response, 'Use POST for /cli/social/groups/:groupId/invite.');
3302
+ return;
3303
+ }
3304
+ try {
3305
+ const invite = await social.createGroupInvite(account.id, route.options.groupId);
3306
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3307
+ cmd: route.command,
3308
+ groupId: route.options.groupId
3309
+ }));
3310
+ json(response, 200, invite);
3311
+ return;
3312
+ } catch (error) {
3313
+ if (
3314
+ error?.message === 'Group not found.' ||
3315
+ error?.message === 'Only group owners can invite members.'
3316
+ ) {
3317
+ badRequest(response, error.message);
3318
+ return;
3319
+ }
3320
+ internalError(response, error, onError);
3321
+ return;
3322
+ }
3323
+ }
3324
+
3325
+ if (social && route.command === 'social-group-join') {
3326
+ if (request.method !== 'POST') {
3327
+ methodNotAllowed(response, 'Use POST for /cli/social/groups/join.');
3328
+ return;
3329
+ }
3330
+ try {
3331
+ const body = await readJsonBody(request);
3332
+ if (!body?.inviteCode) {
3333
+ badRequest(response, 'inviteCode is required.');
3334
+ return;
3335
+ }
3336
+ const group = await social.joinGroup(account.id, body.inviteCode);
3337
+ if (!group) {
3338
+ notFound(response, 'Group invite not found.');
3339
+ return;
3340
+ }
3341
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3342
+ cmd: route.command,
3343
+ groupId: group.id
3344
+ }));
3345
+ json(response, 200, group);
3346
+ return;
3347
+ } catch (error) {
3348
+ if (error?.message === 'Already a member of this group.') {
3349
+ badRequest(response, error.message);
3350
+ return;
3351
+ }
3352
+ internalError(response, error, onError);
3353
+ return;
3354
+ }
3355
+ }
3356
+
3357
+ if (social && route.command === 'social-group-leave') {
3358
+ if (request.method !== 'DELETE') {
3359
+ methodNotAllowed(response, 'Use DELETE for /cli/social/groups/:groupId/membership.');
3360
+ return;
3361
+ }
3362
+ try {
3363
+ const left = await social.leaveGroup(account.id, route.options.groupId);
3364
+ if (!left) {
3365
+ notFound(response, 'Group membership not found.');
3366
+ return;
3367
+ }
3368
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3369
+ cmd: route.command,
3370
+ groupId: route.options.groupId
3371
+ }));
3372
+ json(response, 200, { ok: true });
3373
+ return;
3374
+ } catch (error) {
3375
+ if (error?.message === 'Group owners cannot leave their group.') {
3376
+ badRequest(response, error.message);
3377
+ return;
3378
+ }
3379
+ internalError(response, error, onError);
3380
+ return;
3381
+ }
3382
+ }
3383
+
3384
+ if (social && route.command === 'social-group-feed') {
3385
+ if (request.method !== 'GET') {
3386
+ methodNotAllowed(response, 'Use GET for /cli/social/groups/:groupId/feed.');
3387
+ return;
3388
+ }
3389
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
3390
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
3391
+ const before = route.options.before ?? null;
3392
+ const result = await social.getGroupFeed(account.id, route.options.groupId, { limit, before });
3393
+ if (result === null) {
3394
+ notFound(response, 'Group not found or you are not a member.');
3395
+ return;
3396
+ }
3397
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3398
+ cmd: route.command,
3399
+ groupId: route.options.groupId,
3400
+ count: result.length,
3401
+ limit,
3402
+ hasBefore: Boolean(before)
3403
+ }));
3404
+ json(response, 200, { items: result });
3405
+ return;
3406
+ }
3407
+
3408
+ if (social && route.command === 'social-group-leaderboard') {
3409
+ if (request.method !== 'GET') {
3410
+ methodNotAllowed(response, 'Use GET for /cli/social/groups/:groupId/leaderboard.');
3411
+ return;
3412
+ }
3413
+ const result = await social.weeklyGroupLeaderboard(account.id, route.options.groupId, { weekOffset: route.options.week });
3414
+ if (result === null) {
3415
+ notFound(response, 'Group not found or you are not a member.');
3416
+ return;
3417
+ }
3418
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3419
+ cmd: route.command,
3420
+ groupId: route.options.groupId,
3421
+ count: result.length
3422
+ }));
3423
+ json(response, 200, { leaderboard: result });
3424
+ return;
3425
+ }
3426
+
3427
+ if (social && route.command === 'social-follow') {
3428
+ if (request.method !== 'POST') {
3429
+ methodNotAllowed(response, 'Use POST for /cli/social/follow.');
3430
+ return;
3431
+ }
3432
+ const body = await readJsonBody(request);
3433
+ if (!body?.inviteCode) {
3434
+ badRequest(response, 'inviteCode is required.');
3435
+ return;
3436
+ }
3437
+ let result;
3438
+ try {
3439
+ result = await social.acceptFollowInvite(account.id, body.inviteCode.toUpperCase());
3440
+ } catch (error) {
3441
+ if (error?.message === 'Cannot follow yourself.') {
3442
+ logRequest(request, 400, socialLogSuffix(request, account.id, {
3443
+ cmd: route.command,
3444
+ outcome: 'self_blocked'
3445
+ }));
3446
+ badRequest(response, error.message);
3447
+ return;
3448
+ }
3449
+ if (error?.message === 'Already following this user.') {
3450
+ logRequest(request, 409, socialLogSuffix(request, account.id, {
3451
+ cmd: route.command,
3452
+ outcome: 'already_following'
3453
+ }));
3454
+ json(response, 409, { error: error.message });
3455
+ return;
3456
+ }
3457
+ if (error?.message === 'Follow invite has already been used.') {
3458
+ logRequest(request, 409, socialLogSuffix(request, account.id, {
3459
+ cmd: route.command,
3460
+ outcome: 'invite_already_used'
3461
+ }));
3462
+ json(response, 409, { error: error.message });
3463
+ return;
3464
+ }
3465
+ internalError(response, error, onError);
3466
+ return;
3467
+ }
3468
+ if (!result) {
3469
+ logRequest(request, 404, socialLogSuffix(request, account.id, {
3470
+ cmd: route.command,
3471
+ outcome: 'invite_not_found'
3472
+ }));
3473
+ notFound(response, 'Invite code not found or expired.');
3474
+ return;
3475
+ }
3476
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3477
+ cmd: route.command,
3478
+ followStatus: result.status ?? null,
3479
+ targetAid: anonymizeAccountId(result.followingId)
3480
+ }));
3481
+ json(response, 200, result);
3482
+ return;
3483
+ }
3484
+
3485
+ if (social && route.command === 'social-follow-account') {
3486
+ if (request.method === 'POST') {
3487
+ const targetAid = anonymizeAccountId(route.options.accountId);
3488
+ try {
3489
+ const result = await social.followUserByAccountId(account.id, route.options.accountId);
3490
+ if (!result) {
3491
+ logRequest(request, 404, socialLogSuffix(request, account.id, {
3492
+ cmd: route.command,
3493
+ targetAid,
3494
+ outcome: 'target_not_found'
3495
+ }));
3496
+ notFound(response, 'User not found.');
3497
+ return;
3498
+ }
3499
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3500
+ cmd: route.command,
3501
+ followStatus: result.status ?? null,
3502
+ targetAid
3503
+ }));
3504
+ json(response, 200, result);
3505
+ return;
3506
+ } catch (error) {
3507
+ if (error?.message === 'Cannot follow yourself.') {
3508
+ logRequest(request, 400, socialLogSuffix(request, account.id, {
3509
+ cmd: route.command,
3510
+ targetAid,
3511
+ outcome: 'self_blocked'
3512
+ }));
3513
+ badRequest(response, error.message);
3514
+ return;
3515
+ }
3516
+ if (error?.message === 'Already following this user.') {
3517
+ logRequest(request, 409, socialLogSuffix(request, account.id, {
3518
+ cmd: route.command,
3519
+ targetAid,
3520
+ outcome: 'already_following'
3521
+ }));
3522
+ json(response, 409, { error: error.message });
3523
+ return;
3524
+ }
3525
+ if (error?.message === 'Account not found.') {
3526
+ logRequest(request, 404, socialLogSuffix(request, account.id, {
3527
+ cmd: route.command,
3528
+ targetAid,
3529
+ outcome: 'target_not_found'
3530
+ }));
3531
+ notFound(response, error.message);
3532
+ return;
3533
+ }
3534
+ internalError(response, error, onError);
3535
+ return;
3536
+ }
3537
+ }
3538
+
3539
+ if (request.method === 'DELETE') {
3540
+ const targetAid = anonymizeAccountId(route.options.accountId);
3541
+ const result = await social.unfollowUser(account.id, route.options.accountId);
3542
+ if (!result) {
3543
+ logRequest(request, 404, socialLogSuffix(request, account.id, {
3544
+ cmd: 'social-unfollow',
3545
+ targetAid,
3546
+ outcome: 'relationship_not_found'
3547
+ }));
3548
+ notFound(response, 'Follow relationship not found.');
3549
+ return;
3550
+ }
3551
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3552
+ cmd: 'social-unfollow',
3553
+ targetAid
3554
+ }));
3555
+ json(response, 200, { ok: true });
3556
+ return;
3557
+ }
3558
+
3559
+ methodNotAllowed(response, 'Use POST or DELETE for /cli/social/follow/:accountId.');
3560
+ return;
3561
+ }
3562
+
3563
+ if (social && route.command === 'social-following') {
3564
+ if (request.method !== 'GET') {
3565
+ methodNotAllowed(response, 'Use GET for /cli/social/following.');
3566
+ return;
3567
+ }
3568
+ const result = await social.listFollowing(account.id);
3569
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3570
+ cmd: route.command,
3571
+ count: result.length,
3572
+ targets: anonymizeRelationIds(result)
3573
+ }));
3574
+ json(response, 200, { following: result });
3575
+ return;
3576
+ }
3577
+
3578
+ if (social && route.command === 'social-followers') {
3579
+ if (request.method !== 'GET') {
3580
+ methodNotAllowed(response, 'Use GET for /cli/social/followers.');
3581
+ return;
3582
+ }
3583
+ const result = await social.listFollowers(account.id);
3584
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3585
+ cmd: route.command,
3586
+ count: result.length,
3587
+ followers: anonymizeRelationIds(result)
3588
+ }));
3589
+ json(response, 200, { followers: result });
3590
+ return;
3591
+ }
3592
+
3593
+ if (social && route.command === 'social-leaderboard') {
3594
+ if (request.method !== 'GET') {
3595
+ methodNotAllowed(response, 'Use GET for /cli/social/leaderboard.');
3596
+ return;
3597
+ }
3598
+ const leaderboard = await social.weeklyLeaderboard(account.id, { weekOffset: route.options.week });
3599
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3600
+ cmd: route.command,
3601
+ count: leaderboard.length
3602
+ }));
3603
+ json(response, 200, { leaderboard });
3604
+ return;
3605
+ }
3606
+
3607
+ if (social && route.command === 'social-gym-leaderboard') {
3608
+ if (request.method !== 'GET') {
3609
+ methodNotAllowed(response, 'Use GET for /cli/social/gym/leaderboard.');
3610
+ return;
3611
+ }
3612
+ const leaderboard = await social.weeklyGymLeaderboard(account.id, { weekOffset: route.options.week });
3613
+ if (leaderboard === null) {
3614
+ json(response, 200, { leaderboard: [] });
3615
+ return;
3616
+ }
3617
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3618
+ cmd: route.command,
3619
+ count: leaderboard.length
3620
+ }));
3621
+ json(response, 200, { leaderboard });
3622
+ return;
3623
+ }
3624
+
3625
+ if (social && route.command === 'social-feed') {
3626
+ if (request.method !== 'GET') {
3627
+ methodNotAllowed(response, 'Use GET for /cli/social/feed.');
3628
+ return;
3629
+ }
3630
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
3631
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
3632
+ const before = route.options.before ?? null;
3633
+ const result = await social.getFeedActivities(account.id, { limit, before });
3634
+ const firstItem = result[0] ?? null;
3635
+ const includesOwn = result.some((item) => item?.userId === account.id);
3636
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3637
+ cmd: route.command,
3638
+ count: result.length,
3639
+ owners: anonymizeOwnerIds(result),
3640
+ limit,
3641
+ hasBefore: Boolean(before),
3642
+ hasMoreCandidate: result.length === limit,
3643
+ includesOwn,
3644
+ firstOwnerAid: firstItem?.userId ? anonymizeAccountId(firstItem.userId) : null,
3645
+ firstEventType: firstItem?.eventType ?? null
3646
+ }));
3647
+ json(response, 200, { items: result });
3648
+ return;
3649
+ }
3650
+
3651
+ if (social && route.command === 'social-feed-groups') {
3652
+ if (request.method !== 'GET') {
3653
+ methodNotAllowed(response, 'Use GET for /cli/social/feed/groups.');
3654
+ return;
3655
+ }
3656
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
3657
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
3658
+ const before = route.options.before ?? null;
3659
+ const result = await social.getAllGroupsFeed(account.id, { limit, before });
3660
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3661
+ cmd: route.command,
3662
+ count: result.length,
3663
+ limit,
3664
+ hasBefore: Boolean(before)
3665
+ }));
3666
+ json(response, 200, { items: result });
3667
+ return;
3668
+ }
3669
+
3670
+ if (social && route.command === 'social-feed-detail') {
3671
+ if (request.method !== 'GET') {
3672
+ methodNotAllowed(response, 'Use GET for /cli/social/feed/:activityId.');
3673
+ return;
3674
+ }
3675
+ const result = await social.getActivityDetail(account.id, route.options.activityId);
3676
+ if (!result) {
3677
+ notFound(response, 'Activity not found.');
3678
+ return;
3679
+ }
3680
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3681
+ cmd: route.command,
3682
+ ownerAid: anonymizeAccountId(result.userId),
3683
+ eventType: result.eventType ?? null
3684
+ }));
3685
+ json(response, 200, result);
3686
+ return;
3687
+ }
3688
+
3689
+ if (social && route.command === 'social-like') {
3690
+ if (request.method === 'POST') {
3691
+ const result = await social.likeActivity(account.id, route.options.activityId);
3692
+ if (!result) {
3693
+ notFound(response, 'Activity not found.');
3694
+ return;
3695
+ }
3696
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3697
+ cmd: route.command,
3698
+ activityIdPresent: Boolean(route.options.activityId)
3699
+ }));
3700
+ json(response, 200, { liked: true });
3701
+ return;
3702
+ }
3703
+ if (request.method === 'DELETE') {
3704
+ const result = await social.unlikeActivity(account.id, route.options.activityId);
3705
+ if (!result) {
3706
+ notFound(response, 'Activity not found.');
3707
+ return;
3708
+ }
3709
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3710
+ cmd: 'social-unlike',
3711
+ activityIdPresent: Boolean(route.options.activityId)
3712
+ }));
3713
+ json(response, 200, { liked: false });
3714
+ return;
3715
+ }
3716
+ methodNotAllowed(response, 'Use POST or DELETE for /cli/social/feed/:activityId/like.');
3717
+ return;
3718
+ }
3719
+
3720
+ if (social && route.command === 'social-likes') {
3721
+ if (request.method === 'GET') {
3722
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
3723
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
3724
+ const likes = await social.listActivityLikes(account.id, route.options.activityId, { limit });
3725
+ if (!likes) {
3726
+ notFound(response, 'Activity not found.');
3727
+ return;
3728
+ }
3729
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3730
+ cmd: route.command,
3731
+ count: likes.length,
3732
+ limit
3733
+ }));
3734
+ json(response, 200, { likes });
3735
+ return;
3736
+ }
3737
+ methodNotAllowed(response, 'Use GET for /cli/social/feed/:activityId/likes.');
3738
+ return;
3739
+ }
3740
+
3741
+ if (social && route.command === 'social-comments') {
3742
+ if (request.method === 'GET') {
3743
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
3744
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
3745
+ const comments = await social.listActivityComments(account.id, route.options.activityId, { limit });
3746
+ if (!comments) {
3747
+ notFound(response, 'Activity not found.');
3748
+ return;
3749
+ }
3750
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3751
+ cmd: route.command,
3752
+ count: comments.length,
3753
+ limit
3754
+ }));
3755
+ json(response, 200, { comments });
3756
+ return;
3757
+ }
3758
+
3759
+ if (request.method === 'POST') {
3760
+ const body = await readJsonBody(request);
3761
+ if (typeof body?.text !== 'string' || !body.text.trim()) {
3762
+ badRequest(response, 'text is required.');
3763
+ return;
3764
+ }
3765
+ try {
3766
+ const comment = await social.createActivityComment(account.id, route.options.activityId, body.text);
3767
+ if (!comment) {
3768
+ notFound(response, 'Activity not found.');
3769
+ return;
3770
+ }
3771
+ logRequest(request, 201, socialLogSuffix(request, account.id, {
3772
+ cmd: route.command,
3773
+ created: true
3774
+ }));
3775
+ json(response, 201, comment);
3776
+ return;
3777
+ } catch (error) {
3778
+ if (error?.message === 'Comment text is required.' ||
3779
+ error?.message === 'Comment must be 280 characters or fewer.') {
3780
+ badRequest(response, error.message);
3781
+ return;
3782
+ }
3783
+ internalError(response, error, onError);
3784
+ return;
3785
+ }
3786
+ }
3787
+
3788
+ methodNotAllowed(response, 'Use GET or POST for /cli/social/feed/:activityId/comments.');
3789
+ return;
3790
+ }
3791
+
3792
+ if (social && route.command === 'social-comment-like') {
3793
+ if (request.method === 'POST') {
3794
+ const result = await social.likeComment(account.id, route.options.commentId);
3795
+ if (!result) {
3796
+ notFound(response, 'Comment not found.');
3797
+ return;
3798
+ }
3799
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3800
+ cmd: route.command,
3801
+ activityIdPresent: Boolean(route.options.activityId),
3802
+ commentIdPresent: Boolean(route.options.commentId)
3803
+ }));
3804
+ json(response, 200, { liked: true });
3805
+ return;
3806
+ }
3807
+ if (request.method === 'DELETE') {
3808
+ const result = await social.unlikeComment(account.id, route.options.commentId);
3809
+ if (!result) {
3810
+ notFound(response, 'Comment not found.');
3811
+ return;
3812
+ }
3813
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3814
+ cmd: 'social-comment-unlike',
3815
+ activityIdPresent: Boolean(route.options.activityId),
3816
+ commentIdPresent: Boolean(route.options.commentId)
3817
+ }));
3818
+ json(response, 200, { liked: false });
3819
+ return;
3820
+ }
3821
+ methodNotAllowed(response, 'Use POST or DELETE for /cli/social/feed/:activityId/comments/:commentId/like.');
3822
+ return;
3823
+ }
3824
+
3825
+ if (social && route.command === 'social-follow-requests') {
3826
+ if (request.method !== 'GET') {
3827
+ methodNotAllowed(response, 'Use GET for /cli/social/follow/requests.');
3828
+ return;
3829
+ }
3830
+ try {
3831
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
3832
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
3833
+ const requests = await social.listFollowRequests(account.id, {
3834
+ type: route.options.type ?? 'incoming',
3835
+ status: route.options.status ?? 'pending',
3836
+ limit
3837
+ });
3838
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3839
+ cmd: route.command,
3840
+ count: requests.length,
3841
+ type: route.options.type ?? 'incoming',
3842
+ status: route.options.status ?? 'pending',
3843
+ limit
3844
+ }));
3845
+ json(response, 200, { requests });
3846
+ return;
3847
+ } catch (error) {
3848
+ if (
3849
+ error?.message === 'type must be incoming or outgoing.' ||
3850
+ error?.message?.startsWith('status must be one of:')
3851
+ ) {
3852
+ badRequest(response, error.message);
3853
+ return;
3854
+ }
3855
+ internalError(response, error, onError);
3856
+ return;
3857
+ }
3858
+ }
3859
+
3860
+ if (social && route.command === 'social-follow-request-approve') {
3861
+ if (request.method !== 'POST') {
3862
+ methodNotAllowed(response, 'Use POST for /cli/social/follow/requests/:followerId/approve.');
3863
+ return;
3864
+ }
3865
+ try {
3866
+ const approved = await social.approveFollowRequest(account.id, route.options.followerId);
3867
+ if (!approved) {
3868
+ notFound(response, 'Pending follow request not found.');
3869
+ return;
3870
+ }
3871
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3872
+ cmd: route.command,
3873
+ followerAid: anonymizeAccountId(route.options.followerId)
3874
+ }));
3875
+ json(response, 200, approved);
3876
+ return;
3877
+ } catch (error) {
3878
+ internalError(response, error, onError);
3879
+ return;
3880
+ }
3881
+ }
3882
+
3883
+ if (social && route.command === 'social-follow-request-decline') {
3884
+ if (request.method !== 'POST') {
3885
+ methodNotAllowed(response, 'Use POST for /cli/social/follow/requests/:followerId/decline.');
3886
+ return;
3887
+ }
3888
+ try {
3889
+ const declined = await social.declineFollowRequest(account.id, route.options.followerId);
3890
+ if (!declined) {
3891
+ notFound(response, 'Pending follow request not found.');
3892
+ return;
3893
+ }
3894
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
3895
+ cmd: route.command,
3896
+ followerAid: anonymizeAccountId(route.options.followerId)
3897
+ }));
3898
+ json(response, 200, declined);
3899
+ return;
3900
+ } catch (error) {
3901
+ internalError(response, error, onError);
3902
+ return;
3903
+ }
3904
+ }
3905
+
3906
+ if (social && route.command === 'social-media-upload-url') {
3907
+ if (request.method !== 'POST') {
3908
+ methodNotAllowed(response, 'Use POST for /cli/social/media/upload-url.');
3909
+ return;
3910
+ }
3911
+ try {
3912
+ const body = await readJsonBody(request);
3913
+ const intent = await social.createMediaUploadUrl(account.id, body ?? {});
3914
+ logRequest(request, 200);
3915
+ json(response, 200, intent);
3916
+ return;
3917
+ } catch (error) {
3918
+ const message = error?.message ?? '';
3919
+ if (
3920
+ message === 'Social media storage is not configured.' ||
3921
+ message.startsWith('kind must be') ||
3922
+ message.startsWith('mimeType must be') ||
3923
+ message.startsWith('byteSize must be') ||
3924
+ message === 'objectKey is required.' ||
3925
+ message === 'publicUrl is required.'
3926
+ ) {
3927
+ badRequest(response, error.message);
3928
+ return;
3929
+ }
3930
+ internalError(response, error, onError);
3931
+ return;
3932
+ }
3933
+ }
3934
+
3935
+ if (social && route.command === 'social-media-complete') {
3936
+ if (request.method !== 'POST') {
3937
+ methodNotAllowed(response, 'Use POST for /cli/social/media/:assetId/complete.');
3938
+ return;
3939
+ }
3940
+ try {
3941
+ const body = await readJsonBody(request);
3942
+ const uploadToken = body?.uploadToken ?? null;
3943
+ const asset = await social.completeMediaUpload(account.id, route.options.assetId, uploadToken);
3944
+ logRequest(request, 200);
3945
+ json(response, 200, asset);
3946
+ return;
3947
+ } catch (error) {
3948
+ if (
3949
+ error?.message === 'assetId is required.' ||
3950
+ error?.message === 'uploadToken is required.'
3951
+ ) {
3952
+ badRequest(response, error.message);
3953
+ return;
3954
+ }
3955
+ if (
3956
+ error?.message === 'Upload intent already completed.' ||
3957
+ error?.message === 'Uploaded media object not found.'
3958
+ ) {
3959
+ json(response, 409, { error: error.message });
3960
+ return;
3961
+ }
3962
+ if (error?.message === 'Upload intent expired.') {
3963
+ json(response, 410, { error: error.message });
3964
+ return;
3965
+ }
3966
+ if (
3967
+ error?.message === 'Upload intent not found.' ||
3968
+ error?.message === 'Media asset not found.'
3969
+ ) {
3970
+ notFound(response, error.message);
3971
+ return;
3972
+ }
3973
+ internalError(response, error, onError);
3974
+ return;
3975
+ }
3976
+ }
3977
+
3978
+ if (social && route.command === 'social-media-delete') {
3979
+ if (request.method !== 'DELETE') {
3980
+ methodNotAllowed(response, 'Use DELETE for /cli/social/media/:assetId.');
3981
+ return;
3982
+ }
3983
+ try {
3984
+ const deleted = await social.deleteMediaAsset(account.id, route.options.assetId);
3985
+ if (!deleted) {
3986
+ notFound(response, 'Media asset not found.');
3987
+ return;
3988
+ }
3989
+ logRequest(request, 200);
3990
+ json(response, 200, deleted);
3991
+ return;
3992
+ } catch (error) {
3993
+ if (error?.message === 'assetId is required.') {
3994
+ badRequest(response, error.message);
3995
+ return;
3996
+ }
3997
+ internalError(response, error, onError);
3998
+ return;
3999
+ }
4000
+ }
4001
+
4002
+ if (social && route.command === 'social-post-update') {
4003
+ if (request.method !== 'PUT') {
4004
+ methodNotAllowed(response, 'Use PUT for /cli/social/feed/:activityId/post.');
4005
+ return;
4006
+ }
4007
+ try {
4008
+ const body = await readJsonBody(request);
4009
+ const post = await social.updateActivityPost(account.id, route.options.activityId, body ?? {});
4010
+ if (!post) {
4011
+ notFound(response, 'Activity not found.');
4012
+ return;
4013
+ }
4014
+ logRequest(request, 200);
4015
+ json(response, 200, post);
4016
+ return;
4017
+ } catch (error) {
4018
+ if (error?.message === 'Only the activity owner can edit this post.') {
4019
+ json(response, 403, { error: error.message });
4020
+ return;
4021
+ }
4022
+ if (
4023
+ error?.message === 'activityId is required.' ||
4024
+ error?.message === 'Provide at least one of caption or mediaAssetIds.' ||
4025
+ error?.message === 'Only workout_completed activities are editable.' ||
4026
+ error?.message === 'caption must be a string.' ||
4027
+ error?.message?.startsWith('caption must be') ||
4028
+ error?.message === 'mediaAssetIds must be an array.' ||
4029
+ error?.message?.startsWith('mediaAssetIds must contain at most') ||
4030
+ error?.message === 'One or more mediaAssetIds are invalid.'
4031
+ ) {
4032
+ badRequest(response, error.message);
4033
+ return;
4034
+ }
4035
+ internalError(response, error, onError);
4036
+ return;
4037
+ }
4038
+ }
4039
+
4040
+ if (social && route.command === 'social-notifications') {
4041
+ if (request.method !== 'GET') {
4042
+ methodNotAllowed(response, 'Use GET for /cli/social/notifications.');
4043
+ return;
4044
+ }
4045
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
4046
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
4047
+ try {
4048
+ const payload = await social.listNotifications(account.id, {
4049
+ limit,
4050
+ before: route.options.before ?? null
4051
+ });
4052
+ logRequest(request, 200);
4053
+ json(response, 200, payload);
4054
+ return;
4055
+ } catch (error) {
4056
+ internalError(response, error, onError);
4057
+ return;
4058
+ }
4059
+ }
4060
+
4061
+ if (social && route.command === 'social-notification-read') {
4062
+ if (request.method !== 'POST') {
4063
+ methodNotAllowed(response, 'Use POST for /cli/social/notifications/:id/read.');
4064
+ return;
4065
+ }
4066
+ try {
4067
+ const notification = await social.markNotificationRead(account.id, route.options.notificationId);
4068
+ if (!notification) {
4069
+ notFound(response, 'Notification not found.');
4070
+ return;
4071
+ }
4072
+ logRequest(request, 200);
4073
+ json(response, 200, notification);
4074
+ return;
4075
+ } catch (error) {
4076
+ if (error?.message === 'notificationId is required.') {
4077
+ badRequest(response, error.message);
4078
+ return;
4079
+ }
4080
+ internalError(response, error, onError);
4081
+ return;
4082
+ }
4083
+ }
4084
+
4085
+ if (social && route.command === 'social-notification-read-all') {
4086
+ if (request.method !== 'POST') {
4087
+ methodNotAllowed(response, 'Use POST for /cli/social/notifications/read-all.');
4088
+ return;
4089
+ }
4090
+ try {
4091
+ const result = await social.markAllNotificationsRead(account.id);
4092
+ logRequest(request, 200);
4093
+ json(response, 200, result);
4094
+ return;
4095
+ } catch (error) {
4096
+ internalError(response, error, onError);
4097
+ return;
4098
+ }
4099
+ }
4100
+
4101
+ if (social && route.command === 'social-user-mute') {
4102
+ if (request.method === 'POST') {
4103
+ try {
4104
+ const result = await social.muteUser(account.id, route.options.userId);
4105
+ logRequest(request, 200);
4106
+ json(response, 200, result);
4107
+ return;
4108
+ } catch (error) {
4109
+ if (error?.message === 'Cannot moderate yourself.' || error?.message === 'Account not found.') {
4110
+ const statusCode = error.message === 'Account not found.' ? 404 : 400;
4111
+ json(response, statusCode, { error: error.message });
4112
+ return;
4113
+ }
4114
+ internalError(response, error, onError);
4115
+ return;
4116
+ }
4117
+ }
4118
+ if (request.method === 'DELETE') {
4119
+ try {
4120
+ const result = await social.unmuteUser(account.id, route.options.userId);
4121
+ logRequest(request, 200);
4122
+ json(response, 200, result);
4123
+ return;
4124
+ } catch (error) {
4125
+ internalError(response, error, onError);
4126
+ return;
4127
+ }
4128
+ }
4129
+ methodNotAllowed(response, 'Use POST or DELETE for /cli/social/users/:userId/mute.');
4130
+ return;
4131
+ }
4132
+
4133
+ if (social && route.command === 'social-user-block') {
4134
+ if (request.method === 'POST') {
4135
+ try {
4136
+ const result = await social.blockUser(account.id, route.options.userId);
4137
+ logRequest(request, 200);
4138
+ json(response, 200, result);
4139
+ return;
4140
+ } catch (error) {
4141
+ if (error?.message === 'Cannot moderate yourself.' || error?.message === 'Account not found.') {
4142
+ const statusCode = error.message === 'Account not found.' ? 404 : 400;
4143
+ json(response, statusCode, { error: error.message });
4144
+ return;
4145
+ }
4146
+ internalError(response, error, onError);
4147
+ return;
4148
+ }
4149
+ }
4150
+ if (request.method === 'DELETE') {
4151
+ try {
4152
+ const result = await social.unblockUser(account.id, route.options.userId);
4153
+ logRequest(request, 200);
4154
+ json(response, 200, result);
4155
+ return;
4156
+ } catch (error) {
4157
+ internalError(response, error, onError);
4158
+ return;
4159
+ }
4160
+ }
4161
+ methodNotAllowed(response, 'Use POST or DELETE for /cli/social/users/:userId/block.');
4162
+ return;
4163
+ }
4164
+
4165
+ if (social && route.command === 'social-report') {
4166
+ if (request.method !== 'POST') {
4167
+ methodNotAllowed(response, 'Use POST for /cli/social/report.');
4168
+ return;
4169
+ }
4170
+ try {
4171
+ const body = await readJsonBody(request);
4172
+ if (!body?.targetType || !body?.targetId || !body?.reason) {
4173
+ badRequest(response, 'targetType, targetId, and reason are required.');
4174
+ return;
4175
+ }
4176
+ const validTypes = ['activity', 'comment', 'user'];
4177
+ if (!validTypes.includes(body.targetType)) {
4178
+ badRequest(response, `targetType must be one of: ${validTypes.join(', ')}.`);
4179
+ return;
4180
+ }
4181
+ const validReasons = ['spam', 'harassment', 'inappropriate', 'other'];
4182
+ if (!validReasons.includes(body.reason)) {
4183
+ badRequest(response, `reason must be one of: ${validReasons.join(', ')}.`);
4184
+ return;
4185
+ }
4186
+ const result = await social.createReport(account.id, {
4187
+ targetType: body.targetType,
4188
+ targetId: body.targetId,
4189
+ reason: body.reason,
4190
+ detail: body.detail ?? null
4191
+ });
4192
+ logRequest(request, 200);
4193
+ json(response, 200, result);
4194
+ return;
4195
+ } catch (error) {
4196
+ internalError(response, error, onError);
4197
+ return;
4198
+ }
4199
+ }
4200
+
2094
4201
  const result = executeReadCommand(snapshot, route.command, route.options);
2095
4202
  if (!result.ok) {
2096
4203
  if (result.error.startsWith('Session not found')) {