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.
- package/README.md +63 -9
- package/package.json +3 -1
- package/src/browse.js +1103 -0
- package/src/contract.js +3 -1
- package/src/format.js +132 -0
- package/src/lib.js +13 -1
- package/src/logo.js +49 -33
- package/src/mcp.js +37 -4
- package/src/openrouter.js +241 -68
- package/src/prompt-security.js +70 -0
- package/src/queries.js +396 -142
- package/src/sync-service.js +2163 -56
package/src/sync-service.js
CHANGED
|
@@ -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
|
-
|
|
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, '-',
|
|
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 === '
|
|
1511
|
-
if (request.method
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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
|
|
1710
|
-
badRequest(response, '
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1986
|
-
const
|
|
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
|
-
|
|
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')) {
|