+2
-1
.claude/settings.local.json
+2
-1
.claude/settings.local.json
+2
-2
client/src/components/metrics-cards.tsx
+2
-2
client/src/components/metrics-cards.tsx
···
103
103
<CardContent className="p-6">
104
104
<div className="flex items-center justify-between mb-4">
105
105
<h3 className="text-sm font-medium text-muted-foreground">
106
-
Active Users
106
+
Total Users
107
107
</h3>
108
108
<div className="w-10 h-10 bg-warning/10 rounded-lg flex items-center justify-center">
109
109
<Users className="h-5 w-5 text-warning" />
···
116
116
>
117
117
{activeUsers.toLocaleString()}
118
118
</p>
119
-
<p className="text-xs text-muted-foreground">24h active</p>
119
+
<p className="text-xs text-muted-foreground">In database</p>
120
120
</div>
121
121
</CardContent>
122
122
</Card>
+8
-16
server/routes.ts
+8
-16
server/routes.ts
···
3843
3843
}
3844
3844
});
3845
3845
3846
-
// AT Protocol server metadata endpoint (required for service discovery)
3846
+
// AT Protocol server metadata endpoint
3847
+
// NOTE: This endpoint is for PDS (Personal Data Server), not AppView
3847
3848
app.get('/xrpc/com.atproto.server.describeServer', async (_req, res) => {
3848
3849
try {
3849
-
const appviewDid = process.env.APPVIEW_DID;
3850
-
3851
-
// In production, APPVIEW_DID is required - fail fast if missing
3852
-
if (process.env.NODE_ENV === 'production' && !appviewDid) {
3853
-
return res.status(500).json({
3854
-
error: 'APPVIEW_DID environment variable is required in production',
3855
-
});
3856
-
}
3857
-
3858
-
// Return standard AT Protocol response - no custom fields allowed
3859
-
res.json({
3860
-
did: appviewDid,
3861
-
availableUserDomains: [],
3862
-
inviteCodeRequired: false,
3863
-
phoneVerificationRequired: false,
3850
+
res.status(501).json({
3851
+
error: 'NotImplemented',
3852
+
message: 'This endpoint is for Personal Data Servers (PDS), not AppView. ' +
3853
+
'Per ATProto specification, describeServer describes account creation requirements and capabilities. ' +
3854
+
'AppView aggregates public data but does not manage user accounts or provide account creation services. ' +
3855
+
'Please query your PDS for server description and account creation policies.',
3864
3856
});
3865
3857
} catch {
3866
3858
res.status(500).json({ error: 'Failed to describe server' });
+136
-13
server/services/search.ts
+136
-13
server/services/search.ts
···
30
30
31
31
class SearchService {
32
32
/**
33
-
* Search for posts using full-text search
33
+
* Search for posts using full-text search with PostgreSQL
34
34
* @param query - Search query string
35
-
* @param limit - Maximum number of results (default 25)
36
-
* @param cursor - Pagination cursor (rank threshold)
35
+
* @param options - Search options including filters and pagination
37
36
* @param userDid - Optional user DID for personalized filtering
38
37
*/
39
38
async searchPosts(
40
39
query: string,
41
-
limit = 25,
42
-
cursor?: string,
40
+
options: {
41
+
limit?: number;
42
+
cursor?: string;
43
+
sort?: 'top' | 'latest';
44
+
since?: string;
45
+
until?: string;
46
+
mentions?: string;
47
+
author?: string;
48
+
lang?: string;
49
+
domain?: string;
50
+
url?: string;
51
+
tag?: string[];
52
+
},
43
53
userDid?: string
44
54
): Promise<{ posts: PostSearchResult[]; cursor?: string }> {
45
55
const trimmedQuery = query.trim();
56
+
const {
57
+
limit = 25,
58
+
cursor,
59
+
sort = 'top',
60
+
since,
61
+
until,
62
+
mentions,
63
+
author,
64
+
lang,
65
+
domain,
66
+
url,
67
+
tag,
68
+
} = options;
46
69
47
70
if (!trimmedQuery) {
48
71
return { posts: [] };
49
72
}
50
73
51
-
// Use plainto_tsquery which safely handles Unicode, punctuation, and special characters
52
-
const sqlQuery = cursor
53
-
? `SELECT uri, cid, author_did as "authorDid", text, embed, parent_uri as "parentUri", root_uri as "rootUri", created_at as "createdAt", indexed_at as "indexedAt", ts_rank(search_vector, plainto_tsquery('english', $1)) as rank FROM posts WHERE search_vector @@ plainto_tsquery('english', $1) AND ts_rank(search_vector, plainto_tsquery('english', $1)) < $2 ORDER BY rank DESC LIMIT $3`
54
-
: `SELECT uri, cid, author_did as "authorDid", text, embed, parent_uri as "parentUri", root_uri as "rootUri", created_at as "createdAt", indexed_at as "indexedAt", ts_rank(search_vector, plainto_tsquery('english', $1)) as rank FROM posts WHERE search_vector @@ plainto_tsquery('english', $1) ORDER BY rank DESC LIMIT $2`;
74
+
// Build WHERE conditions
75
+
const conditions: string[] = [
76
+
`search_vector @@ plainto_tsquery('english', $1)`,
77
+
];
78
+
const params: any[] = [trimmedQuery];
79
+
let paramIndex = 2;
80
+
81
+
// Time range filters
82
+
if (since) {
83
+
conditions.push(`created_at >= $${paramIndex}`);
84
+
params.push(new Date(since));
85
+
paramIndex++;
86
+
}
87
+
if (until) {
88
+
conditions.push(`created_at <= $${paramIndex}`);
89
+
params.push(new Date(until));
90
+
paramIndex++;
91
+
}
92
+
93
+
// Author filter
94
+
if (author) {
95
+
conditions.push(`author_did = $${paramIndex}`);
96
+
params.push(author);
97
+
paramIndex++;
98
+
}
99
+
100
+
// Mentions filter - check if text contains the DID or handle
101
+
if (mentions) {
102
+
conditions.push(`(text ILIKE $${paramIndex} OR embed::text ILIKE $${paramIndex})`);
103
+
params.push(`%${mentions}%`);
104
+
paramIndex++;
105
+
}
106
+
107
+
// Language filter (stored in langs column)
108
+
if (lang) {
109
+
conditions.push(`langs @> ARRAY[$${paramIndex}]::varchar[]`);
110
+
params.push(lang);
111
+
paramIndex++;
112
+
}
113
+
114
+
// Domain filter - check if embed contains domain
115
+
if (domain) {
116
+
conditions.push(`embed::text ILIKE $${paramIndex}`);
117
+
params.push(`%${domain}%`);
118
+
paramIndex++;
119
+
}
120
+
121
+
// URL filter - check if embed contains URL
122
+
if (url) {
123
+
conditions.push(`embed::text ILIKE $${paramIndex}`);
124
+
params.push(`%${url}%`);
125
+
paramIndex++;
126
+
}
127
+
128
+
// Tag filter - check if tags column contains any of the specified tags
129
+
if (tag && tag.length > 0) {
130
+
conditions.push(`tags && ARRAY[${tag.map((_, i) => `$${paramIndex + i}`).join(', ')}]::varchar[]`);
131
+
params.push(...tag);
132
+
paramIndex += tag.length;
133
+
}
134
+
135
+
// Determine sort and cursor handling
136
+
let orderBy: string;
137
+
let cursorCondition: string | null = null;
55
138
56
-
const params = cursor
57
-
? [trimmedQuery, parseFloat(cursor), limit + 1]
58
-
: [trimmedQuery, limit + 1];
139
+
if (sort === 'latest') {
140
+
orderBy = 'ORDER BY created_at DESC';
141
+
if (cursor) {
142
+
cursorCondition = `created_at < $${paramIndex}`;
143
+
params.push(new Date(cursor));
144
+
paramIndex++;
145
+
}
146
+
} else {
147
+
// Default: sort by relevance (top)
148
+
orderBy = `ORDER BY ts_rank(search_vector, plainto_tsquery('english', $1)) DESC`;
149
+
if (cursor) {
150
+
cursorCondition = `ts_rank(search_vector, plainto_tsquery('english', $1)) < $${paramIndex}`;
151
+
params.push(parseFloat(cursor));
152
+
paramIndex++;
153
+
}
154
+
}
155
+
156
+
if (cursorCondition) {
157
+
conditions.push(cursorCondition);
158
+
}
159
+
160
+
// Build and execute query
161
+
const sqlQuery = `
162
+
SELECT
163
+
uri,
164
+
cid,
165
+
author_did as "authorDid",
166
+
text,
167
+
embed,
168
+
parent_uri as "parentUri",
169
+
root_uri as "rootUri",
170
+
created_at as "createdAt",
171
+
indexed_at as "indexedAt",
172
+
${sort === 'top' ? `ts_rank(search_vector, plainto_tsquery('english', $1)) as rank` : `EXTRACT(EPOCH FROM created_at) as rank`}
173
+
FROM posts
174
+
WHERE ${conditions.join(' AND ')}
175
+
${orderBy}
176
+
LIMIT $${paramIndex}
177
+
`;
178
+
params.push(limit + 1);
179
+
59
180
const queryResult = await pool.query(sqlQuery, params);
60
181
const results = {
61
182
rows: queryResult.rows as (PostSearchResult & { rank: number })[],
···
78
199
const postsToReturn = filteredResults.slice(0, limit);
79
200
const nextCursor =
80
201
hasMore && postsToReturn.length > 0
81
-
? postsToReturn[postsToReturn.length - 1].rank.toString()
202
+
? sort === 'latest'
203
+
? postsToReturn[postsToReturn.length - 1].createdAt.toISOString()
204
+
: postsToReturn[postsToReturn.length - 1].rank.toString()
82
205
: undefined;
83
206
84
207
return {
-192
server/services/xrpc-api.ts
-192
server/services/xrpc-api.ts
···
354
354
token: z.string(),
355
355
});
356
356
357
-
// Actor preferences schemas - proper validation like Bluesky
358
-
const putActorPreferencesSchema = z.object({
359
-
preferences: z
360
-
.array(
361
-
z
362
-
.object({
363
-
$type: z.string().min(1, 'Preference must have a $type'),
364
-
// Allow any additional properties for flexibility
365
-
})
366
-
.passthrough()
367
-
)
368
-
.default([]),
369
-
});
370
-
371
357
const getActorStarterPacksSchema = z.object({
372
358
actor: z.string(),
373
359
limit: z.coerce.number().min(1).max(100).default(50),
···
422
408
const unspeccedNoParamsSchema = z.object({});
423
409
424
410
export class XRPCApi {
425
-
// Preferences cache: DID -> { preferences: any[], timestamp: number }
426
-
private preferencesCache = new Map<
427
-
string,
428
-
{ preferences: any[]; timestamp: number }
429
-
>();
430
-
private readonly PREFERENCES_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
431
-
432
411
// Handle resolution cache: handle -> { did: string, timestamp: number }
433
412
private handleResolutionCache = new Map<
434
413
string,
···
439
418
constructor() {
440
419
// Clear expired cache entries every minute
441
420
setInterval(() => {
442
-
this.cleanExpiredPreferencesCache();
443
421
this.cleanExpiredHandleResolutionCache();
444
422
}, 60 * 1000);
445
423
}
···
461
439
}
462
440
463
441
/**
464
-
* Check if preferences cache entry is expired
465
-
*/
466
-
private isPreferencesCacheExpired(cached: {
467
-
preferences: any[];
468
-
timestamp: number;
469
-
}): boolean {
470
-
return Date.now() - cached.timestamp > this.PREFERENCES_CACHE_TTL;
471
-
}
472
-
473
-
/**
474
-
* Clean expired entries from preferences cache
475
-
*/
476
-
private cleanExpiredPreferencesCache(): void {
477
-
const now = Date.now();
478
-
const expiredDids: string[] = [];
479
-
480
-
this.preferencesCache.forEach((cached, did) => {
481
-
if (now - cached.timestamp > this.PREFERENCES_CACHE_TTL) {
482
-
expiredDids.push(did);
483
-
}
484
-
});
485
-
486
-
expiredDids.forEach((did) => {
487
-
this.preferencesCache.delete(did);
488
-
});
489
-
}
490
-
491
-
/**
492
442
* Check if handle resolution cache entry is expired
493
443
*/
494
444
private isHandleResolutionCacheExpired(cached: {
···
530
480
}
531
481
}
532
482
return null;
533
-
}
534
-
535
-
/**
536
-
* Invalidate preferences cache for a specific user
537
-
*/
538
-
public invalidatePreferencesCache(userDid: string): void {
539
-
this.preferencesCache.delete(userDid);
540
-
console.log(`[PREFERENCES] Cache invalidated for ${userDid}`);
541
483
}
542
484
543
485
private async getUserPdsEndpoint(userDid: string): Promise<string | null> {
···
2066
2008
return profiles;
2067
2009
}
2068
2010
2069
-
async getPreferences(req: Request, res: Response) {
2070
-
try {
2071
-
// Get authenticated user DID using OAuth token verification
2072
-
const userDid = await this.requireAuthDid(req, res);
2073
-
if (!userDid) return;
2074
-
2075
-
// Check cache first
2076
-
const cached = this.preferencesCache.get(userDid);
2077
-
if (cached && !this.isPreferencesCacheExpired(cached)) {
2078
-
console.log(`[PREFERENCES] Cache hit for ${userDid}`);
2079
-
return res.json({ preferences: cached.preferences });
2080
-
}
2081
-
2082
-
// Cache miss - fetch from user's PDS
2083
-
console.log(`[PREFERENCES] Cache miss for ${userDid}, fetching from PDS`);
2084
-
2085
-
try {
2086
-
// Get user's PDS endpoint from DID document
2087
-
const pdsEndpoint = await this.getUserPdsEndpoint(userDid);
2088
-
if (!pdsEndpoint) {
2089
-
console.log(
2090
-
`[PREFERENCES] No PDS endpoint found for ${userDid}, returning empty preferences`
2091
-
);
2092
-
return res.json({ preferences: [] });
2093
-
}
2094
-
2095
-
// Forward request to user's PDS
2096
-
const pdsResponse = await fetch(
2097
-
`${pdsEndpoint}/xrpc/app.bsky.actor.getPreferences`,
2098
-
{
2099
-
headers: {
2100
-
Authorization: req.headers.authorization || '',
2101
-
'Content-Type': 'application/json',
2102
-
},
2103
-
}
2104
-
);
2105
-
2106
-
if (pdsResponse.ok) {
2107
-
const pdsData = await pdsResponse.json();
2108
-
2109
-
// Cache the response
2110
-
this.preferencesCache.set(userDid, {
2111
-
preferences: pdsData.preferences || [],
2112
-
timestamp: Date.now(),
2113
-
});
2114
-
2115
-
console.log(
2116
-
`[PREFERENCES] Retrieved ${pdsData.preferences?.length || 0} preferences from PDS for ${userDid}`
2117
-
);
2118
-
return res.json({ preferences: pdsData.preferences || [] });
2119
-
} else {
2120
-
console.warn(
2121
-
`[PREFERENCES] PDS request failed for ${userDid}:`,
2122
-
pdsResponse.status
2123
-
);
2124
-
return res.json({ preferences: [] });
2125
-
}
2126
-
} catch (pdsError) {
2127
-
console.error(
2128
-
`[PREFERENCES] Error fetching from PDS for ${userDid}:`,
2129
-
pdsError
2130
-
);
2131
-
return res.json({ preferences: [] });
2132
-
}
2133
-
} catch (error) {
2134
-
this._handleError(res, error, 'getPreferences');
2135
-
}
2136
-
}
2137
-
2138
-
async putPreferences(req: Request, res: Response) {
2139
-
try {
2140
-
// Get authenticated user DID using OAuth token verification
2141
-
const userDid = await this.requireAuthDid(req, res);
2142
-
if (!userDid) return;
2143
-
2144
-
// Parse the preferences from request body
2145
-
const body = putActorPreferencesSchema.parse(req.body);
2146
-
2147
-
try {
2148
-
// Get user's PDS endpoint from DID document
2149
-
const pdsEndpoint = await this.getUserPdsEndpoint(userDid);
2150
-
if (!pdsEndpoint) {
2151
-
return res.status(400).json({
2152
-
error: 'InvalidRequest',
2153
-
message: 'No PDS endpoint found for user',
2154
-
});
2155
-
}
2156
-
2157
-
// Forward request to user's PDS (let PDS handle validation)
2158
-
const pdsResponse = await fetch(
2159
-
`${pdsEndpoint}/xrpc/app.bsky.actor.putPreferences`,
2160
-
{
2161
-
method: 'POST',
2162
-
headers: {
2163
-
Authorization: req.headers.authorization || '',
2164
-
'Content-Type': 'application/json',
2165
-
},
2166
-
body: JSON.stringify(body),
2167
-
}
2168
-
);
2169
-
2170
-
if (pdsResponse.ok) {
2171
-
// Invalidate cache after successful update
2172
-
this.invalidatePreferencesCache(userDid);
2173
-
2174
-
console.log(
2175
-
`[PREFERENCES] Updated preferences via PDS for ${userDid}`
2176
-
);
2177
-
2178
-
// Return success response (no body, like Bluesky)
2179
-
return res.status(200).end();
2180
-
} else {
2181
-
const errorText = await pdsResponse.text();
2182
-
console.error(
2183
-
`[PREFERENCES] PDS request failed for ${userDid}:`,
2184
-
pdsResponse.status,
2185
-
errorText
2186
-
);
2187
-
return res.status(pdsResponse.status).send(errorText);
2188
-
}
2189
-
} catch (pdsError) {
2190
-
console.error(
2191
-
`[PREFERENCES] Error updating preferences via PDS for ${userDid}:`,
2192
-
pdsError
2193
-
);
2194
-
return res.status(500).json({
2195
-
error: 'InternalServerError',
2196
-
message: 'Failed to update preferences',
2197
-
});
2198
-
}
2199
-
} catch (error) {
2200
-
this._handleError(res, error, 'putPreferences');
2201
-
}
2202
-
}
2203
2011
2204
2012
async getFollows(req: Request, res: Response) {
2205
2013
try {
+2
-2
server/services/xrpc/schemas/actor-schemas.ts
+2
-2
server/services/xrpc/schemas/actor-schemas.ts
+4
server/services/xrpc/schemas/feed-generator-schemas.ts
+4
server/services/xrpc/schemas/feed-generator-schemas.ts
+5
server/services/xrpc/schemas/index.ts
+5
server/services/xrpc/schemas/index.ts
···
63
63
// Notification Schemas
64
64
export {
65
65
listNotificationsSchema,
66
+
getUnreadCountSchema,
66
67
updateSeenSchema,
67
68
registerPushSchema,
68
69
unregisterPushSchema,
69
70
getNotificationPreferencesSchema,
70
71
putNotificationPreferencesSchema,
71
72
putNotificationPreferencesV2Schema,
73
+
getNotificationPreferencesV2Schema,
72
74
listActivitySubscriptionsSchema,
73
75
putActivitySubscriptionSchema,
74
76
} from './notification-schemas';
···
82
84
getSuggestedFeedsSchema,
83
85
getPopularFeedGeneratorsSchema,
84
86
describeFeedGeneratorSchema,
87
+
getSuggestedFeedsUnspeccedSchema,
85
88
} from './feed-generator-schemas';
86
89
87
90
// Starter Pack Schemas
···
91
94
getActorStarterPacksSchema,
92
95
getStarterPacksWithMembershipSchema,
93
96
searchStarterPacksSchema,
97
+
getOnboardingSuggestedStarterPacksSchema,
94
98
} from './starter-pack-schemas';
95
99
96
100
// Search Schemas
···
102
106
getJobStatusSchema,
103
107
sendInteractionsSchema,
104
108
unspeccedNoParamsSchema,
109
+
getTrendsSchema,
105
110
} from './utility-schemas';
+4
-2
server/services/xrpc/schemas/list-schemas.ts
+4
-2
server/services/xrpc/schemas/list-schemas.ts
···
6
6
*/
7
7
8
8
export const getListSchema = z.object({
9
-
list: z.string(),
9
+
list: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
10
10
limit: z.coerce.number().min(1).max(100).default(50),
11
11
cursor: z.string().optional(),
12
12
});
···
15
15
actor: z.string(),
16
16
limit: z.coerce.number().min(1).max(100).default(50),
17
17
cursor: z.string().optional(),
18
+
purposes: z.array(z.string()).optional(), // Filter by list purpose (modlist, curatelist, etc.)
18
19
});
19
20
20
21
export const getListFeedSchema = z.object({
21
-
list: z.string(),
22
+
list: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
22
23
limit: z.coerce.number().min(1).max(100).default(50),
23
24
cursor: z.string().optional(),
24
25
});
···
27
28
actor: z.string(),
28
29
limit: z.coerce.number().min(1).max(100).default(50),
29
30
cursor: z.string().optional(),
31
+
purposes: z.array(z.string()).optional(), // Filter by list purpose (modlist, curatelist, etc.)
30
32
});
31
33
32
34
export const getListMutesSchema = z.object({
+1
-1
server/services/xrpc/schemas/moderation-schemas.ts
+1
-1
server/services/xrpc/schemas/moderation-schemas.ts
+58
-8
server/services/xrpc/schemas/notification-schemas.ts
+58
-8
server/services/xrpc/schemas/notification-schemas.ts
···
11
11
seenAt: z.string().optional(),
12
12
});
13
13
14
+
export const getUnreadCountSchema = z.object({
15
+
seenAt: z.string().datetime().optional(), // ISO datetime - only count notifications after this time
16
+
});
17
+
14
18
export const updateSeenSchema = z.object({
15
-
seenAt: z.string(),
19
+
seenAt: z.string().datetime(), // ISO datetime when user last viewed notifications
16
20
});
17
21
18
22
export const registerPushSchema = z.object({
19
-
serviceDid: z.string(),
20
-
token: z.string(),
23
+
serviceDid: z.string(), // The DID of the service (AppView) handling push
24
+
token: z.string(), // Device token (FCM/APNs) or subscription endpoint (web)
21
25
platform: z.enum(['ios', 'android', 'web']),
22
26
appId: z.string().optional(),
27
+
// Web push specific fields
28
+
endpoint: z.string().url().optional(), // Web push subscription endpoint
29
+
keys: z.object({
30
+
p256dh: z.string(),
31
+
auth: z.string(),
32
+
}).optional(), // Web push encryption keys
23
33
});
24
34
25
35
export const unregisterPushSchema = z.object({
26
-
token: z.string(),
36
+
serviceDid: z.string(), // The DID of the service (AppView) handling push
37
+
token: z.string(), // Device token to unregister
38
+
platform: z.enum(['ios', 'android', 'web']),
39
+
appId: z.string(), // Application identifier
27
40
});
28
41
29
42
export const getNotificationPreferencesSchema = z.object({});
···
32
45
priority: z.boolean().optional(),
33
46
});
34
47
48
+
// ATProto notification preference types
49
+
const preferenceSchema = z.object({
50
+
list: z.boolean(),
51
+
push: z.boolean(),
52
+
});
53
+
54
+
const filterablePreferenceSchema = z.object({
55
+
list: z.boolean(),
56
+
push: z.boolean(),
57
+
include: z.enum(['all', 'follows']),
58
+
});
59
+
60
+
const chatPreferenceSchema = z.object({
61
+
include: z.enum(['all', 'accepted']),
62
+
push: z.boolean(),
63
+
});
64
+
35
65
export const putNotificationPreferencesV2Schema = z.object({
36
-
priority: z.boolean().optional(),
66
+
chat: chatPreferenceSchema.optional(),
67
+
follow: filterablePreferenceSchema.optional(),
68
+
like: filterablePreferenceSchema.optional(),
69
+
mention: filterablePreferenceSchema.optional(),
70
+
reply: filterablePreferenceSchema.optional(),
71
+
repost: filterablePreferenceSchema.optional(),
72
+
quote: filterablePreferenceSchema.optional(),
73
+
likeViaRepost: filterablePreferenceSchema.optional(),
74
+
repostViaRepost: filterablePreferenceSchema.optional(),
75
+
starterpackJoined: preferenceSchema.optional(),
76
+
subscribedPost: preferenceSchema.optional(),
77
+
unverified: preferenceSchema.optional(),
78
+
verified: preferenceSchema.optional(),
37
79
});
38
80
39
-
export const listActivitySubscriptionsSchema = z.object({});
81
+
export const getNotificationPreferencesV2Schema = z.object({});
82
+
83
+
export const listActivitySubscriptionsSchema = z.object({
84
+
limit: z.coerce.number().min(1).max(100).default(50),
85
+
cursor: z.string().optional(),
86
+
});
40
87
41
88
export const putActivitySubscriptionSchema = z.object({
42
-
subject: z.string().optional(),
43
-
notifications: z.boolean().optional(),
89
+
subject: z.string(), // DID of the account to subscribe to
90
+
activitySubscription: z.object({
91
+
post: z.boolean(), // Notify on posts
92
+
reply: z.boolean(), // Notify on replies
93
+
}),
44
94
});
+12
server/services/xrpc/schemas/search-schemas.ts
+12
server/services/xrpc/schemas/search-schemas.ts
···
9
9
q: z.string().min(1),
10
10
limit: z.coerce.number().min(1).max(100).default(25),
11
11
cursor: z.string().optional(),
12
+
sort: z.enum(['top', 'latest']).default('top').optional(),
13
+
since: z.string().datetime().optional(), // ISO datetime string
14
+
until: z.string().datetime().optional(), // ISO datetime string
15
+
mentions: z.string().optional(), // DID of mentioned user
16
+
author: z.string().optional(), // DID of author
17
+
lang: z.string().optional(), // Language code (e.g., "en", "ja")
18
+
domain: z.string().optional(), // Domain for link embed filtering
19
+
url: z.string().url().optional(), // URL for link embed filtering
20
+
tag: z
21
+
.union([z.string(), z.array(z.string())])
22
+
.transform((val) => (typeof val === 'string' ? [val] : val))
23
+
.optional(), // Tag(s) to filter by
12
24
});
+5
-1
server/services/xrpc/schemas/starter-pack-schemas.ts
+5
-1
server/services/xrpc/schemas/starter-pack-schemas.ts
···
22
22
});
23
23
24
24
export const getStarterPacksWithMembershipSchema = z.object({
25
-
actor: z.string().optional(),
25
+
actor: z.string(), // Required - the account to check for membership
26
26
limit: z.coerce.number().min(1).max(100).default(50),
27
27
cursor: z.string().optional(),
28
28
});
···
32
32
limit: z.coerce.number().min(1).max(100).default(25),
33
33
cursor: z.string().optional(),
34
34
});
35
+
36
+
export const getOnboardingSuggestedStarterPacksSchema = z.object({
37
+
limit: z.coerce.number().min(1).max(25).default(10),
38
+
});
+13
-13
server/services/xrpc/schemas/timeline-schemas.ts
+13
-13
server/services/xrpc/schemas/timeline-schemas.ts
···
38
38
.transform((val) => (Array.isArray(val) ? val : [val]))
39
39
.pipe(
40
40
z
41
-
.array(z.string())
41
+
.array(z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'))
42
42
.min(1, 'uris parameter cannot be empty')
43
43
.max(25, 'Maximum 25 uris allowed')
44
44
),
45
45
});
46
46
47
47
export const getLikesSchema = z.object({
48
-
uri: z.string(),
48
+
uri: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
49
49
cid: z.string().optional(),
50
50
limit: z.coerce.number().min(1).max(100).default(50),
51
51
cursor: z.string().optional(),
52
52
});
53
53
54
54
export const getRepostedBySchema = z.object({
55
-
uri: z.string(),
55
+
uri: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
56
56
cid: z.string().optional(),
57
57
limit: z.coerce.number().min(1).max(100).default(50),
58
58
cursor: z.string().optional(),
59
59
});
60
60
61
61
export const getQuotesSchema = z.object({
62
-
uri: z.string(),
62
+
uri: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
63
63
cid: z.string().optional(),
64
-
limit: z.coerce.number().min(1).max(50).default(50),
64
+
limit: z.coerce.number().min(1).max(100).default(50),
65
65
cursor: z.string().optional(),
66
66
});
67
67
···
73
73
74
74
// V2 Thread schemas (unspecced but compatible)
75
75
export const getPostThreadV2Schema = z.object({
76
-
anchor: z.string(),
77
-
depth: z.coerce.number().min(0).max(50).default(6),
78
-
prioritizeFollowedUsers: z.coerce.boolean().optional(),
79
-
sort: z.string().optional(),
80
-
branchStartDepth: z.coerce.number().optional(),
81
-
branchEndDepth: z.coerce.number().optional(),
76
+
anchor: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
77
+
above: z.coerce.boolean().default(true),
78
+
below: z.coerce.number().min(0).max(20).default(6),
79
+
branchingFactor: z.coerce.number().min(0).max(100).default(10),
80
+
prioritizeFollowedUsers: z.coerce.boolean().default(false),
81
+
sort: z.enum(['newest', 'oldest', 'top']).default('oldest'),
82
82
});
83
83
84
84
export const getPostThreadOtherV2Schema = z.object({
85
-
anchor: z.string(),
86
-
depth: z.coerce.number().min(0).max(50).default(3),
85
+
anchor: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI'),
86
+
prioritizeFollowedUsers: z.coerce.boolean().default(false),
87
87
});
+24
-5
server/services/xrpc/schemas/utility-schemas.ts
+24
-5
server/services/xrpc/schemas/utility-schemas.ts
···
20
20
interactions: z
21
21
.array(
22
22
z.object({
23
-
$type: z.string().optional(),
24
-
subject: z.any().optional(),
25
-
event: z.string().optional(),
26
-
createdAt: z.string().optional(),
23
+
item: z.string().regex(/^at:\/\//, 'Must be a valid AT-URI').optional(),
24
+
event: z
25
+
.enum([
26
+
'requestLess',
27
+
'requestMore',
28
+
'clickthroughItem',
29
+
'clickthroughAuthor',
30
+
'clickthroughReposter',
31
+
'clickthroughEmbed',
32
+
'interactionSeen',
33
+
'interactionLike',
34
+
'interactionRepost',
35
+
'interactionReply',
36
+
'interactionQuote',
37
+
'interactionShare',
38
+
])
39
+
.optional(),
40
+
feedContext: z.string().max(2000).optional(),
41
+
reqId: z.string().max(100).optional(),
27
42
})
28
43
)
29
-
.default([]),
44
+
.min(1, 'interactions array cannot be empty'),
30
45
});
31
46
32
47
export const unspeccedNoParamsSchema = z.object({});
48
+
49
+
export const getTrendsSchema = z.object({
50
+
limit: z.coerce.number().min(1).max(25).default(10),
51
+
});
+63
-27
server/services/xrpc/services/actor-service.ts
+63
-27
server/services/xrpc/services/actor-service.ts
···
75
75
const userDid = await requireAuthDid(req, res);
76
76
if (!userDid) return;
77
77
78
-
const users = await storage.getSuggestedUsers(userDid, params.limit);
78
+
// Get suggested users with pagination support
79
+
const { users, cursor } = await storage.getSuggestedUsers(
80
+
userDid,
81
+
params.limit,
82
+
params.cursor
83
+
);
84
+
85
+
// Convert users to DIDs for profile hydration
86
+
const userDids = users.map((u) => u.did);
87
+
88
+
// Use the full _getProfiles helper to build complete profileView objects
89
+
const actors = await (xrpcApi as any)._getProfiles(userDids, req);
90
+
91
+
// Build response with optional cursor and recId
92
+
const response: {
93
+
actors: any[];
94
+
cursor?: string;
95
+
recId?: number;
96
+
} = {
97
+
actors,
98
+
};
99
+
100
+
if (cursor) {
101
+
response.cursor = cursor;
102
+
}
103
+
104
+
// Generate recId for recommendation tracking (snowflake-like ID)
105
+
// Using timestamp + random component for uniqueness
106
+
response.recId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
79
107
80
-
res.json({
81
-
actors: users.map((user) => ({
82
-
did: user.did,
83
-
handle: user.handle,
84
-
displayName: user.displayName || user.handle,
85
-
...(user.description && { description: user.description }),
86
-
...maybeAvatar(user.avatarUrl, user.did, req),
87
-
})),
88
-
});
108
+
res.json(response);
89
109
} catch (error) {
90
110
handleError(res, error, 'getSuggestions');
91
111
}
···
109
129
params.limit
110
130
);
111
131
132
+
// Check if we have suggestions (not fallback)
133
+
if (suggestions.length === 0) {
134
+
return res.json({
135
+
suggestions: [],
136
+
isFallback: true,
137
+
});
138
+
}
139
+
140
+
// Build full profileView objects using _getProfiles helper
141
+
const suggestionDids = suggestions.map((u) => u.did);
142
+
const profiles = await (xrpcApi as any)._getProfiles(suggestionDids, req);
143
+
144
+
// Generate recId for recommendation tracking (snowflake-like ID)
145
+
// Using timestamp + random component for uniqueness
146
+
const recId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
147
+
112
148
res.json({
113
-
suggestions: suggestions.map((user) => ({
114
-
did: user.did,
115
-
handle: user.handle,
116
-
displayName: user.displayName || user.handle,
117
-
...(user.description && { description: user.description }),
118
-
...maybeAvatar(user.avatarUrl, user.did, req),
119
-
})),
149
+
suggestions: profiles,
150
+
isFallback: false,
151
+
recId,
120
152
});
121
153
} catch (error) {
122
154
handleError(res, error, 'getSuggestedFollowsByActor');
···
125
157
126
158
/**
127
159
* Get suggested users (unspecced)
128
-
* GET /xrpc/app.bsky.unspecced.getSuggestedUsersUnspecced
160
+
* GET /xrpc/app.bsky.unspecced.getSuggestedUsers
161
+
*
162
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
163
+
* Returns a list of suggested users with complete profileView objects.
129
164
*/
130
165
export async function getSuggestedUsersUnspecced(
131
166
req: Request,
···
136
171
const userDid = await requireAuthDid(req, res);
137
172
if (!userDid) return;
138
173
139
-
const users = await storage.getSuggestedUsers(userDid, params.limit);
174
+
// TODO: Implement category-based filtering when params.category is provided
175
+
// For now, category parameter is accepted but not used
176
+
const { users } = await storage.getSuggestedUsers(userDid, params.limit);
140
177
141
-
res.json({
142
-
users: users.map((u) => ({
143
-
did: u.did,
144
-
handle: u.handle,
145
-
displayName: u.displayName,
146
-
...maybeAvatar(u.avatarUrl, u.did, req),
147
-
})),
148
-
});
178
+
// Convert users to DIDs for profile hydration
179
+
const userDids = users.map((u) => u.did);
180
+
181
+
// Use the full _getProfiles helper to build complete profileView objects
182
+
const actors = await (xrpcApi as any)._getProfiles(userDids, req);
183
+
184
+
res.json({ actors });
149
185
} catch (error) {
150
186
handleError(res, error, 'getSuggestedUsersUnspecced');
151
187
}
+52
-104
server/services/xrpc/services/bookmark-service.ts
+52
-104
server/services/xrpc/services/bookmark-service.ts
···
1
1
/**
2
2
* Bookmark Service
3
-
* Handles bookmark creation, deletion, and retrieval
3
+
*
4
+
* NOTE: Bookmark endpoints are not part of the official ATProto specification.
5
+
* Per ATProto architecture, if/when bookmarks are officially implemented, they will be:
6
+
* - Stored as private records on the PDS (in a "personal" namespace)
7
+
* - Not broadcast to the public firehose
8
+
* - Not accessible to AppView services
9
+
* - Similar to mutes and preferences (private user data)
10
+
*
11
+
* AppView aggregates public data and should not store or serve private user bookmarks.
12
+
* These endpoints return 501 to maintain proper architectural boundaries.
4
13
*/
5
14
6
15
import type { Request, Response } from 'express';
7
-
import { storage } from '../../../storage';
8
-
import { requireAuthDid, getAuthenticatedDid } from '../utils/auth-helpers';
16
+
import { requireAuthDid } from '../utils/auth-helpers';
9
17
import { handleError } from '../utils/error-handler';
10
-
import { serializePostsEnhanced } from '../utils/serializers';
11
-
import type { PostModel, PostView } from '../types';
12
-
13
-
/**
14
-
* Serialize posts with optional enhanced hydration
15
-
* Uses environment flag to determine which serialization method to use
16
-
*/
17
-
async function serializePosts(
18
-
posts: PostModel[],
19
-
viewerDid?: string,
20
-
req?: Request
21
-
): Promise<PostView[]> {
22
-
const useEnhancedHydration =
23
-
process.env.ENHANCED_HYDRATION_ENABLED === 'true';
24
-
25
-
if (useEnhancedHydration) {
26
-
return serializePostsEnhanced(posts, viewerDid, req) as Promise<PostView[]>;
27
-
}
28
-
29
-
// For now, we'll use enhanced serialization as the default
30
-
// The legacy serialization is complex and will be extracted later
31
-
return serializePostsEnhanced(posts, viewerDid, req) as Promise<PostView[]>;
32
-
}
18
+
import { getUserPdsEndpoint } from '../utils/pds-helpers';
33
19
34
20
/**
35
21
* Create a new bookmark
36
22
* POST /xrpc/app.bsky.bookmark.create
23
+
*
24
+
* NOTE: Not part of official ATProto specification. Bookmarks are private user data
25
+
* that should be stored on the PDS, not on AppView. This is similar to mutes and
26
+
* preferences - private metadata that belongs on the user's Personal Data Server.
37
27
*/
38
28
export async function createBookmark(
39
29
req: Request,
···
43
33
const userDid = await requireAuthDid(req, res);
44
34
if (!userDid) return;
45
35
46
-
const body = req.body as {
47
-
subject?: { uri?: string; cid?: string };
48
-
postUri?: string;
49
-
postCid?: string;
50
-
};
51
-
const postUri: string | undefined = body?.subject?.uri || body?.postUri;
52
-
const postCid: string | undefined = body?.subject?.cid || body?.postCid;
53
-
54
-
if (!postUri) {
55
-
res.status(400).json({
56
-
error: 'InvalidRequest',
57
-
message: 'subject.uri is required',
58
-
});
59
-
return;
60
-
}
36
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
61
37
62
-
const rkey = `bmk_${Date.now()}`;
63
-
const uri = `at://${userDid}/app.bsky.bookmark.bookmark/${rkey}`;
64
-
65
-
// Ensure post exists locally; if not, try to fetch via PDS data fetcher
66
-
const post = await storage.getPost(postUri);
67
-
if (!post) {
68
-
try {
69
-
const { pdsDataFetcher } = await import('../../pds-data-fetcher');
70
-
pdsDataFetcher.markIncomplete('post', userDid, postUri);
71
-
} catch {
72
-
// Ignore errors if pdsDataFetcher is unavailable
73
-
}
74
-
}
75
-
76
-
await storage.createBookmark({
77
-
uri,
78
-
userDid,
79
-
postUri,
80
-
createdAt: new Date(),
38
+
res.status(501).json({
39
+
error: 'NotImplemented',
40
+
message: 'Bookmarks are not part of the official ATProto specification and should not be stored on AppView. ' +
41
+
'Per ATProto architecture, bookmarks are private user data that should be stored on the PDS. ' +
42
+
'Unlike likes (which are public records), bookmarks are private metadata similar to mutes. ' +
43
+
(pdsEndpoint
44
+
? `If your PDS supports bookmarks, please use: ${pdsEndpoint}/xrpc/app.bsky.bookmark.create`
45
+
: 'Please check if your PDS supports bookmark functionality.'),
46
+
pdsEndpoint: pdsEndpoint || undefined,
81
47
});
82
-
83
-
res.json({ uri, cid: postCid });
84
48
} catch (error) {
85
49
handleError(res, error, 'createBookmark');
86
50
}
···
89
53
/**
90
54
* Delete a bookmark
91
55
* POST /xrpc/app.bsky.bookmark.delete
56
+
*
57
+
* NOTE: Not part of official ATProto specification. Bookmarks are private user data
58
+
* that should be stored on the PDS, not on AppView.
92
59
*/
93
60
export async function deleteBookmark(
94
61
req: Request,
···
98
65
const userDid = await requireAuthDid(req, res);
99
66
if (!userDid) return;
100
67
101
-
const body = req.body as { uri?: string };
102
-
const uri: string | undefined = body?.uri;
68
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
103
69
104
-
if (!uri) {
105
-
res
106
-
.status(400)
107
-
.json({ error: 'InvalidRequest', message: 'uri is required' });
108
-
return;
109
-
}
110
-
111
-
await storage.deleteBookmark(uri);
112
-
res.json({ success: true });
70
+
res.status(501).json({
71
+
error: 'NotImplemented',
72
+
message: 'Bookmarks are not part of the official ATProto specification and should not be stored on AppView. ' +
73
+
'Per ATProto architecture, bookmarks are private user data that should be stored on the PDS. ' +
74
+
(pdsEndpoint
75
+
? `If your PDS supports bookmarks, please use: ${pdsEndpoint}/xrpc/app.bsky.bookmark.delete`
76
+
: 'Please check if your PDS supports bookmark functionality.'),
77
+
pdsEndpoint: pdsEndpoint || undefined,
78
+
});
113
79
} catch (error) {
114
80
handleError(res, error, 'deleteBookmark');
115
81
}
···
118
84
/**
119
85
* Get user's bookmarks
120
86
* GET /xrpc/app.bsky.bookmark.list
87
+
*
88
+
* NOTE: Not part of official ATProto specification. Bookmarks are private user data
89
+
* that should be stored on the PDS, not on AppView.
121
90
*/
122
91
export async function getBookmarks(req: Request, res: Response): Promise<void> {
123
92
try {
124
93
const userDid = await requireAuthDid(req, res);
125
94
if (!userDid) return;
126
95
127
-
const limit = Math.min(100, Number(req.query.limit) || 50);
128
-
const cursor =
129
-
typeof req.query.cursor === 'string' ? req.query.cursor : undefined;
130
-
131
-
const { bookmarks, cursor: nextCursor } = await storage.getBookmarks(
132
-
userDid,
133
-
limit,
134
-
cursor
135
-
);
96
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
136
97
137
-
type BookmarkRecord = {
138
-
uri: string;
139
-
postUri: string;
140
-
createdAt: Date;
141
-
};
142
-
143
-
const bookmarkRecords = bookmarks as BookmarkRecord[];
144
-
const postUris = bookmarkRecords.map((b) => b.postUri);
145
-
const viewerDid = (await getAuthenticatedDid(req)) || undefined;
146
-
const posts: PostModel[] = await storage.getPosts(postUris);
147
-
const serialized = await serializePosts(posts, viewerDid, req);
148
-
const byUri = new Map(serialized.map((p) => [p.uri, p]));
149
-
150
-
res.json({
151
-
cursor: nextCursor,
152
-
bookmarks: bookmarkRecords
153
-
.map((b) => ({
154
-
uri: b.uri,
155
-
createdAt: b.createdAt.toISOString(),
156
-
post: byUri.get(b.postUri),
157
-
}))
158
-
.filter((b) => !!b.post),
98
+
res.status(501).json({
99
+
error: 'NotImplemented',
100
+
message: 'Bookmarks are not part of the official ATProto specification and should not be stored on AppView. ' +
101
+
'Per ATProto architecture, bookmarks are private user data that should be fetched from the PDS. ' +
102
+
'Unlike public data (posts, likes), bookmarks are private metadata similar to mutes. ' +
103
+
(pdsEndpoint
104
+
? `If your PDS supports bookmarks, please use: ${pdsEndpoint}/xrpc/app.bsky.bookmark.list`
105
+
: 'Please check if your PDS supports bookmark functionality.'),
106
+
pdsEndpoint: pdsEndpoint || undefined,
159
107
});
160
108
} catch (error) {
161
109
handleError(res, error, 'getBookmarks');
+203
-205
server/services/xrpc/services/feed-generator-service.ts
+203
-205
server/services/xrpc/services/feed-generator-service.ts
···
15
15
getSuggestedFeedsSchema,
16
16
describeFeedGeneratorSchema,
17
17
getPopularFeedGeneratorsSchema,
18
+
getSuggestedFeedsUnspeccedSchema,
18
19
} from '../schemas';
20
+
import { xrpcApi } from '../../xrpc-api';
19
21
20
22
/**
21
23
* Helper to serialize a feed generator view
24
+
* Now accepts full profileView from _getProfiles for complete creator data
22
25
*/
23
26
function serializeFeedGeneratorView(
24
27
generator: {
···
32
35
likeCount: number;
33
36
indexedAt: Date;
34
37
},
35
-
creator: {
36
-
did: string;
37
-
handle: string;
38
-
displayName?: string;
39
-
avatarUrl?: string;
40
-
},
38
+
creatorProfile: any, // Full profileView from _getProfiles
41
39
req?: Request
42
40
) {
43
-
const creatorView: {
44
-
did: string;
45
-
handle: string;
46
-
displayName?: string;
47
-
avatar?: string;
48
-
} = {
49
-
did: generator.creatorDid,
50
-
handle: creator.handle,
51
-
};
52
-
if (creator.displayName) creatorView.displayName = creator.displayName;
53
-
if (creator.avatarUrl) {
54
-
const avatarUri = transformBlobToCdnUrl(
55
-
creator.avatarUrl,
56
-
creator.did,
57
-
'avatar',
58
-
req
59
-
);
60
-
if (avatarUri && typeof avatarUri === 'string' && avatarUri.trim() !== '') {
61
-
creatorView.avatar = avatarUri;
62
-
}
63
-
}
64
-
65
-
const view: {
66
-
uri: string;
67
-
cid: string;
68
-
did: string;
69
-
creator: typeof creatorView;
70
-
displayName: string;
71
-
likeCount: number;
72
-
indexedAt: string;
73
-
description?: string;
74
-
avatar?: string;
75
-
} = {
41
+
const view: any = {
76
42
uri: generator.uri,
77
43
cid: generator.cid,
78
44
did: generator.did,
79
-
creator: creatorView,
45
+
creator: creatorProfile, // Full profileView object
80
46
displayName: generator.displayName || 'Unnamed Feed',
81
47
likeCount: generator.likeCount,
82
48
indexedAt: generator.indexedAt.toISOString(),
83
49
};
50
+
84
51
if (generator.description) view.description = generator.description;
85
52
if (generator.avatarUrl) {
86
53
const avatarUri = transformBlobToCdnUrl(
···
125
92
indexedAt: Date;
126
93
};
127
94
128
-
// Creator profile should be available from firehose events
129
-
const creator = await storage.getUser(generatorData.creatorDid);
95
+
// Use _getProfiles for complete creator profileView
96
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(
97
+
[generatorData.creatorDid],
98
+
req
99
+
);
130
100
131
-
if (!creator || !(creator as { handle?: string }).handle) {
101
+
if (creatorProfiles.length === 0) {
132
102
return res.status(500).json({
133
103
error: 'Feed generator creator profile not available',
134
104
message: 'Unable to load creator information',
···
137
107
138
108
const view = serializeFeedGeneratorView(
139
109
generatorData,
140
-
creator as {
141
-
did: string;
142
-
handle: string;
143
-
displayName?: string;
144
-
avatarUrl?: string;
145
-
},
110
+
creatorProfiles[0],
146
111
req
147
112
);
148
113
···
167
132
try {
168
133
const params = getFeedGeneratorsSchema.parse(req.query);
169
134
170
-
const generators = await storage.getFeedGenerators(params.feeds);
135
+
const generators = await storage.getFeedGenerators(params.feeds) as {
136
+
uri: string;
137
+
cid: string;
138
+
did: string;
139
+
creatorDid: string;
140
+
displayName?: string;
141
+
description?: string;
142
+
avatarUrl?: string;
143
+
likeCount: number;
144
+
indexedAt: Date;
145
+
}[];
146
+
147
+
if (generators.length === 0) {
148
+
return res.json({ feeds: [] });
149
+
}
171
150
172
-
// Creator profiles should be available from firehose events
173
-
const views = await Promise.all(
174
-
(
175
-
generators as {
176
-
uri: string;
177
-
cid: string;
178
-
did: string;
179
-
creatorDid: string;
180
-
displayName?: string;
181
-
description?: string;
182
-
avatarUrl?: string;
183
-
likeCount: number;
184
-
indexedAt: Date;
185
-
}[]
186
-
).map(async (generator) => {
187
-
const creator = await storage.getUser(generator.creatorDid);
151
+
// Batch fetch all creator profiles
152
+
const creatorDids = [...new Set(generators.map(g => g.creatorDid))];
153
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
188
154
189
-
// Skip generators from creators without valid handles
190
-
if (!creator || !(creator as { handle?: string }).handle) {
155
+
// Create map for quick lookup
156
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
157
+
158
+
// Build views with complete creator profiles
159
+
const views = generators
160
+
.map((generator) => {
161
+
const creatorProfile = profileMap.get(generator.creatorDid);
162
+
if (!creatorProfile) {
191
163
console.warn(
192
-
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} has no handle`
164
+
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} profile not found`
193
165
);
194
166
return null;
195
167
}
196
168
197
-
return serializeFeedGeneratorView(
198
-
generator,
199
-
creator as {
200
-
did: string;
201
-
handle: string;
202
-
displayName?: string;
203
-
avatarUrl?: string;
204
-
},
205
-
req
206
-
);
169
+
return serializeFeedGeneratorView(generator, creatorProfile, req);
207
170
})
208
-
);
171
+
.filter(Boolean);
209
172
210
-
// Filter out null entries (generators from creators without valid handles)
211
-
const validViews = views.filter((view) => view !== null);
212
-
213
-
res.json({ feeds: validViews });
173
+
res.json({ feeds: views });
214
174
} catch (error) {
215
175
handleError(res, error, 'getFeedGenerators');
216
176
}
···
234
194
actorDid,
235
195
params.limit,
236
196
params.cursor
237
-
);
197
+
) as {
198
+
generators: {
199
+
uri: string;
200
+
cid: string;
201
+
did: string;
202
+
creatorDid: string;
203
+
displayName?: string;
204
+
description?: string;
205
+
avatarUrl?: string;
206
+
likeCount: number;
207
+
indexedAt: Date;
208
+
}[];
209
+
cursor?: string;
210
+
};
238
211
239
-
// Creator profiles should be available from firehose events
240
-
const feeds = await Promise.all(
241
-
(
242
-
generators as {
243
-
uri: string;
244
-
cid: string;
245
-
did: string;
246
-
creatorDid: string;
247
-
displayName?: string;
248
-
description?: string;
249
-
avatarUrl?: string;
250
-
likeCount: number;
251
-
indexedAt: Date;
252
-
}[]
253
-
).map(async (generator) => {
254
-
const creator = await storage.getUser(generator.creatorDid);
212
+
if (generators.length === 0) {
213
+
return res.json({ cursor, feeds: [] });
214
+
}
255
215
256
-
// Skip generators from creators without valid handles
257
-
if (!creator || !(creator as { handle?: string }).handle) {
216
+
// Batch fetch all creator profiles
217
+
const creatorDids = [...new Set(generators.map(g => g.creatorDid))];
218
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
219
+
220
+
// Create map for quick lookup
221
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
222
+
223
+
// Build views with complete creator profiles
224
+
const feeds = generators
225
+
.map((generator) => {
226
+
const creatorProfile = profileMap.get(generator.creatorDid);
227
+
if (!creatorProfile) {
258
228
console.warn(
259
-
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} has no handle`
229
+
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} profile not found`
260
230
);
261
231
return null;
262
232
}
263
233
264
-
return serializeFeedGeneratorView(
265
-
generator,
266
-
creator as {
267
-
did: string;
268
-
handle: string;
269
-
displayName?: string;
270
-
avatarUrl?: string;
271
-
},
272
-
req
273
-
);
234
+
return serializeFeedGeneratorView(generator, creatorProfile, req);
274
235
})
275
-
);
236
+
.filter(Boolean);
276
237
277
238
res.json({ cursor, feeds });
278
239
} catch (error) {
···
294
255
const { generators, cursor } = await storage.getSuggestedFeeds(
295
256
params.limit,
296
257
params.cursor
297
-
);
258
+
) as {
259
+
generators: {
260
+
uri: string;
261
+
cid: string;
262
+
did: string;
263
+
creatorDid: string;
264
+
displayName?: string;
265
+
description?: string;
266
+
avatarUrl?: string;
267
+
likeCount: number;
268
+
indexedAt: Date;
269
+
}[];
270
+
cursor?: string;
271
+
};
298
272
299
-
// Creator profiles should be available from firehose events
300
-
const feeds = await Promise.all(
301
-
(
302
-
generators as {
303
-
uri: string;
304
-
cid: string;
305
-
did: string;
306
-
creatorDid: string;
307
-
displayName?: string;
308
-
description?: string;
309
-
avatarUrl?: string;
310
-
likeCount: number;
311
-
indexedAt: Date;
312
-
}[]
313
-
).map(async (generator) => {
314
-
const creator = await storage.getUser(generator.creatorDid);
273
+
if (generators.length === 0) {
274
+
return res.json({ cursor, feeds: [] });
275
+
}
276
+
277
+
// Batch fetch all creator profiles
278
+
const creatorDids = [...new Set(generators.map(g => g.creatorDid))];
279
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
280
+
281
+
// Create map for quick lookup
282
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
315
283
316
-
// Skip generators from creators without valid handles
317
-
if (!creator || !(creator as { handle?: string }).handle) {
284
+
// Build views with complete creator profiles
285
+
const feeds = generators
286
+
.map((generator) => {
287
+
const creatorProfile = profileMap.get(generator.creatorDid);
288
+
if (!creatorProfile) {
318
289
console.warn(
319
-
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} has no handle`
290
+
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} profile not found`
320
291
);
321
292
return null;
322
293
}
323
294
324
-
return serializeFeedGeneratorView(
325
-
generator,
326
-
creator as {
327
-
did: string;
328
-
handle: string;
329
-
displayName?: string;
330
-
avatarUrl?: string;
331
-
},
332
-
req
333
-
);
295
+
return serializeFeedGeneratorView(generator, creatorProfile, req);
334
296
})
335
-
);
336
-
337
-
// Filter out null entries (generators from creators without valid handles)
338
-
const validFeeds = feeds.filter((feed) => feed !== null);
297
+
.filter(Boolean);
339
298
340
-
res.json({ cursor, feeds: validFeeds });
299
+
res.json({ cursor, feeds });
341
300
} catch (error) {
342
301
handleError(res, error, 'getSuggestedFeeds');
343
302
}
···
346
305
/**
347
306
* Describe the feed generator service
348
307
* GET /xrpc/app.bsky.feed.describeFeedGenerator
308
+
*
309
+
* NOTE: Per ATProto spec, this endpoint is "implemented by Feed Generator services (not App View)."
310
+
* This is an AppView, not a Feed Generator service. Feed Generator services are external
311
+
* services that generate custom feeds, which this AppView consumes via feedGeneratorClient.
312
+
*
313
+
* Returns 501 Not Implemented to indicate this endpoint belongs on Feed Generator services.
349
314
*/
350
315
export async function describeFeedGenerator(
351
316
req: Request,
···
354
319
try {
355
320
describeFeedGeneratorSchema.parse(req.query);
356
321
357
-
const appviewDid = process.env.APPVIEW_DID;
358
-
if (!appviewDid) {
359
-
return res.status(500).json({ error: 'APPVIEW_DID not configured' });
360
-
}
361
-
362
-
res.json({
363
-
did: appviewDid,
364
-
feeds: [
365
-
{
366
-
uri: `at://${appviewDid}/app.bsky.feed.generator/reverse-chron`,
367
-
},
368
-
],
322
+
res.status(501).json({
323
+
error: 'NotImplemented',
324
+
message: 'This endpoint is for Feed Generator services, not AppView. ' +
325
+
'Feed Generator services implement this endpoint to describe their feed offerings. ' +
326
+
'This is an AppView that consumes feeds from external Feed Generator services.',
369
327
});
370
328
} catch (error) {
371
329
handleError(res, error, 'describeFeedGenerator');
···
383
341
try {
384
342
const params = getPopularFeedGeneratorsSchema.parse(req.query);
385
343
386
-
let generators: unknown[];
344
+
let generators: {
345
+
uri: string;
346
+
cid: string;
347
+
did: string;
348
+
creatorDid: string;
349
+
displayName?: string;
350
+
description?: string;
351
+
avatarUrl?: string;
352
+
likeCount: number;
353
+
indexedAt: Date;
354
+
}[];
387
355
let cursor: string | undefined;
388
356
389
357
// If query is provided, search for feed generators by name/description
···
394
362
params.limit,
395
363
params.cursor
396
364
);
397
-
generators = (searchResults as { feedGenerators: unknown[] })
365
+
generators = (searchResults as { feedGenerators: typeof generators })
398
366
.feedGenerators;
399
367
cursor = (searchResults as { cursor?: string }).cursor;
400
368
} else {
···
402
370
params.limit,
403
371
params.cursor
404
372
);
405
-
generators = (suggestedResults as { generators: unknown[] }).generators;
373
+
generators = (suggestedResults as { generators: typeof generators }).generators;
406
374
cursor = (suggestedResults as { cursor?: string }).cursor;
407
375
}
408
376
409
-
// Creator profiles should be available from firehose events
410
-
const feeds = await Promise.all(
411
-
(
412
-
generators as {
413
-
uri: string;
414
-
cid: string;
415
-
did: string;
416
-
creatorDid: string;
417
-
displayName?: string;
418
-
description?: string;
419
-
avatarUrl?: string;
420
-
likeCount: number;
421
-
indexedAt: Date;
422
-
}[]
423
-
).map(async (generator) => {
424
-
const creator = await storage.getUser(generator.creatorDid);
377
+
if (generators.length === 0) {
378
+
return res.json({ cursor, feeds: [] });
379
+
}
425
380
426
-
// Skip generators from creators without valid handles
427
-
if (!creator || !(creator as { handle?: string }).handle) {
381
+
// Batch fetch all creator profiles
382
+
const creatorDids = [...new Set(generators.map(g => g.creatorDid))];
383
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
384
+
385
+
// Create map for quick lookup
386
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
387
+
388
+
// Build views with complete creator profiles
389
+
const feeds = generators
390
+
.map((generator) => {
391
+
const creatorProfile = profileMap.get(generator.creatorDid);
392
+
if (!creatorProfile) {
428
393
console.warn(
429
-
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} has no handle`
394
+
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} profile not found`
430
395
);
431
396
return null;
432
397
}
433
398
434
-
return serializeFeedGeneratorView(
435
-
generator,
436
-
creator as {
437
-
did: string;
438
-
handle: string;
439
-
displayName?: string;
440
-
avatarUrl?: string;
441
-
},
442
-
req
443
-
);
399
+
return serializeFeedGeneratorView(generator, creatorProfile, req);
444
400
})
445
-
);
401
+
.filter(Boolean);
446
402
447
-
// Filter out null entries (generators from creators without valid handles)
448
-
const validFeeds = feeds.filter((feed) => feed !== null);
449
-
450
-
res.json({ cursor, feeds: validFeeds });
403
+
res.json({ cursor, feeds });
451
404
} catch (error) {
452
405
handleError(res, error, 'getPopularFeedGenerators');
453
406
}
454
407
}
455
408
456
409
/**
457
-
* Get suggested feeds (unspecced version - minimal response)
458
-
* GET /xrpc/app.bsky.unspecced.getSuggestedFeedsUnspecced
410
+
* Get suggested feeds (unspecced)
411
+
* GET /xrpc/app.bsky.unspecced.getSuggestedFeeds
412
+
*
413
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
414
+
* Returns a list of suggested feed generators with complete generatorView objects.
459
415
*/
460
416
export async function getSuggestedFeedsUnspecced(
461
417
req: Request,
462
418
res: Response
463
419
): Promise<void> {
464
420
try {
465
-
const { generators } = await storage.getSuggestedFeeds(10);
466
-
res.json({
467
-
feeds: (generators as { uri: string }[]).map((g) => g.uri),
468
-
});
421
+
const params = getSuggestedFeedsUnspeccedSchema.parse(req.query);
422
+
423
+
const { generators } = (await storage.getSuggestedFeeds(params.limit)) as {
424
+
generators: {
425
+
uri: string;
426
+
cid: string;
427
+
did: string;
428
+
creatorDid: string;
429
+
displayName?: string;
430
+
description?: string;
431
+
avatarUrl?: string;
432
+
likeCount: number;
433
+
indexedAt: Date;
434
+
}[];
435
+
};
436
+
437
+
if (generators.length === 0) {
438
+
return res.json({ feeds: [] });
439
+
}
440
+
441
+
// Batch fetch all creator profiles
442
+
const creatorDids = [...new Set(generators.map((g) => g.creatorDid))];
443
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(
444
+
creatorDids,
445
+
req
446
+
);
447
+
448
+
// Create map for quick lookup
449
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
450
+
451
+
// Build views with complete creator profiles
452
+
const feeds = generators
453
+
.map((generator) => {
454
+
const creatorProfile = profileMap.get(generator.creatorDid);
455
+
if (!creatorProfile) {
456
+
console.warn(
457
+
`[XRPC] Skipping feed generator ${generator.uri} - creator ${generator.creatorDid} profile not found`
458
+
);
459
+
return null;
460
+
}
461
+
462
+
return serializeFeedGeneratorView(generator, creatorProfile, req);
463
+
})
464
+
.filter(Boolean);
465
+
466
+
res.json({ feeds });
469
467
} catch (error) {
470
468
handleError(res, error, 'getSuggestedFeedsUnspecced');
471
469
}
+111
-92
server/services/xrpc/services/graph-service.ts
+111
-92
server/services/xrpc/services/graph-service.ts
···
14
14
getKnownFollowersSchema,
15
15
getFollowsSchema,
16
16
} from '../schemas';
17
+
import { xrpcApi } from '../../xrpc-api';
17
18
18
19
/**
19
20
* Get relationships between an actor and other actors
20
21
* GET /xrpc/app.bsky.graph.getRelationships
22
+
*
23
+
* NOTE: Per ATProto spec, relationship objects only include follow relationships.
24
+
* Blocks and mutes are intentionally excluded from this endpoint.
25
+
* - Blocks: Public records but not exposed via getRelationships
26
+
* - Mutes: Private preferences that should never be exposed by AppView
21
27
*/
22
28
export async function getRelationships(
23
29
req: Request,
···
38
44
did,
39
45
following: rel.following || undefined,
40
46
followedBy: rel.followedBy || undefined,
41
-
blocking: rel.blocking || undefined,
42
-
blockedBy: rel.blockedBy || undefined,
43
-
muted: rel.muting || undefined,
47
+
// Per ATProto spec: blocking, blockedBy, muted are NOT included
44
48
})),
45
49
});
46
50
} catch (error) {
···
71
75
params.cursor
72
76
);
73
77
74
-
// Get the actor's handle for the subject
75
-
const actor = await storage.getUser(actorDid);
78
+
// Build full profileView objects using _getProfiles helper
79
+
const followerDids = followers.map((f) => f.did);
80
+
const allDids = [actorDid, ...followerDids];
81
+
const profiles = await (xrpcApi as any)._getProfiles(allDids, req);
82
+
83
+
// Create a map of DID -> profile for quick lookup
84
+
const profileMap = new Map(profiles.map((p: any) => [p.did, p]));
85
+
86
+
// Extract subject profile
87
+
const subject = profileMap.get(actorDid);
88
+
89
+
// Extract follower profiles in order
90
+
const followerProfiles = followerDids
91
+
.map((did) => profileMap.get(did))
92
+
.filter(Boolean);
76
93
77
94
res.json({
78
-
subject: {
95
+
subject: subject || {
79
96
$type: 'app.bsky.actor.defs#profileView',
80
97
did: actorDid,
81
-
handle: actor?.handle || params.actor,
82
-
displayName: actor?.displayName || actor?.handle || params.actor,
83
-
...maybeAvatar(actor?.avatarUrl, actor?.did || actorDid, req),
84
-
indexedAt: actor?.indexedAt?.toISOString(),
85
-
viewer: {
86
-
muted: false,
87
-
blockedBy: false,
88
-
blocking: undefined,
89
-
following: undefined,
90
-
followedBy: undefined,
91
-
},
98
+
handle: actorDid,
92
99
},
93
100
cursor,
94
-
followers: followers.map((user) => ({
95
-
$type: 'app.bsky.actor.defs#profileView',
96
-
did: user.did,
97
-
handle: user.handle,
98
-
displayName: user.displayName || user.handle,
99
-
...maybeAvatar(user.avatarUrl, user.did, req),
100
-
indexedAt: user.indexedAt?.toISOString(),
101
-
viewer: {
102
-
muted: false,
103
-
blockedBy: false,
104
-
blocking: undefined,
105
-
following: undefined,
106
-
followedBy: undefined,
107
-
},
108
-
})),
101
+
followers: followerProfiles,
109
102
});
110
103
} catch (error) {
111
104
handleError(res, error, 'getKnownFollowers');
···
152
145
followedBy: undefined,
153
146
},
154
147
},
155
-
follows: followsList
156
-
.map((f) => {
157
-
const user = userMap.get(f.followingDid);
158
-
if (!user) return null;
148
+
follows: followsList.map((f) => {
149
+
const user = userMap.get(f.followingDid);
159
150
160
-
const viewerState = viewerDid
161
-
? relationships.get(f.followingDid)
162
-
: null;
163
-
const viewer: {
164
-
muted: boolean;
165
-
blockedBy: boolean;
166
-
blocking?: string;
167
-
following?: string;
168
-
followedBy?: string;
169
-
} = {
170
-
muted: viewerState ? !!viewerState.muting : false,
171
-
blockedBy: viewerState?.blockedBy || false,
172
-
};
173
-
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
174
-
if (viewerState?.following) viewer.following = viewerState.following;
175
-
if (viewerState?.followedBy)
176
-
viewer.followedBy = viewerState.followedBy;
177
-
151
+
// If user profile not found, create minimal profile with DID
152
+
// This ensures follows always show up even if profile fetch is pending
153
+
if (!user) {
178
154
return {
179
155
$type: 'app.bsky.actor.defs#profileView',
180
-
did: user.did,
181
-
handle: user.handle,
182
-
displayName: user.displayName || user.handle,
183
-
...maybeAvatar(user.avatarUrl, user.did, req),
184
-
indexedAt: user.indexedAt?.toISOString(),
185
-
viewer,
156
+
did: f.followingDid,
157
+
handle: f.followingDid, // Use DID as fallback handle
158
+
displayName: f.followingDid,
159
+
indexedAt: f.indexedAt?.toISOString(),
160
+
viewer: {
161
+
muted: false,
162
+
blockedBy: false,
163
+
},
186
164
};
187
-
})
188
-
.filter((follow) => follow !== null),
165
+
}
166
+
167
+
const viewerState = viewerDid
168
+
? relationships.get(f.followingDid)
169
+
: null;
170
+
const viewer: {
171
+
muted: boolean;
172
+
blockedBy: boolean;
173
+
blocking?: string;
174
+
following?: string;
175
+
followedBy?: string;
176
+
} = {
177
+
muted: viewerState ? !!viewerState.muting : false,
178
+
blockedBy: viewerState?.blockedBy || false,
179
+
};
180
+
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
181
+
if (viewerState?.following) viewer.following = viewerState.following;
182
+
if (viewerState?.followedBy)
183
+
viewer.followedBy = viewerState.followedBy;
184
+
185
+
return {
186
+
$type: 'app.bsky.actor.defs#profileView',
187
+
did: user.did,
188
+
handle: user.handle,
189
+
displayName: user.displayName || user.handle,
190
+
...maybeAvatar(user.avatarUrl, user.did, req),
191
+
indexedAt: user.indexedAt?.toISOString(),
192
+
viewer,
193
+
};
194
+
}),
189
195
cursor: nextCursor,
190
196
});
191
197
} catch (error) {
···
233
239
followedBy: undefined,
234
240
},
235
241
},
236
-
followers: followersList
237
-
.map((f) => {
238
-
const user = userMap.get(f.followerDid);
239
-
if (!user) return null;
242
+
followers: followersList.map((f) => {
243
+
const user = userMap.get(f.followerDid);
240
244
241
-
const viewerState = viewerDid
242
-
? relationships.get(f.followerDid)
243
-
: null;
244
-
const viewer: {
245
-
muted: boolean;
246
-
blockedBy: boolean;
247
-
blocking?: string;
248
-
following?: string;
249
-
followedBy?: string;
250
-
} = {
251
-
muted: viewerState ? !!viewerState.muting : false,
252
-
blockedBy: viewerState?.blockedBy || false,
253
-
};
254
-
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
255
-
if (viewerState?.following) viewer.following = viewerState.following;
256
-
if (viewerState?.followedBy)
257
-
viewer.followedBy = viewerState.followedBy;
258
-
245
+
// If user profile not found, create minimal profile with DID
246
+
// This ensures followers always show up even if profile fetch is pending
247
+
if (!user) {
259
248
return {
260
249
$type: 'app.bsky.actor.defs#profileView',
261
-
did: user.did,
262
-
handle: user.handle,
263
-
displayName: user.displayName || user.handle,
264
-
...maybeAvatar(user.avatarUrl, user.did, req),
265
-
indexedAt: user.indexedAt?.toISOString(),
266
-
viewer,
250
+
did: f.followerDid,
251
+
handle: f.followerDid, // Use DID as fallback handle
252
+
displayName: f.followerDid,
253
+
indexedAt: f.indexedAt?.toISOString(),
254
+
viewer: {
255
+
muted: false,
256
+
blockedBy: false,
257
+
},
267
258
};
268
-
})
269
-
.filter((follower) => follower !== null),
259
+
}
260
+
261
+
const viewerState = viewerDid
262
+
? relationships.get(f.followerDid)
263
+
: null;
264
+
const viewer: {
265
+
muted: boolean;
266
+
blockedBy: boolean;
267
+
blocking?: string;
268
+
following?: string;
269
+
followedBy?: string;
270
+
} = {
271
+
muted: viewerState ? !!viewerState.muting : false,
272
+
blockedBy: viewerState?.blockedBy || false,
273
+
};
274
+
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
275
+
if (viewerState?.following) viewer.following = viewerState.following;
276
+
if (viewerState?.followedBy)
277
+
viewer.followedBy = viewerState.followedBy;
278
+
279
+
return {
280
+
$type: 'app.bsky.actor.defs#profileView',
281
+
did: user.did,
282
+
handle: user.handle,
283
+
displayName: user.displayName || user.handle,
284
+
...maybeAvatar(user.avatarUrl, user.did, req),
285
+
indexedAt: user.indexedAt?.toISOString(),
286
+
viewer,
287
+
};
288
+
}),
270
289
cursor: nextCursor,
271
290
});
272
291
} catch (error) {
+473
-53
server/services/xrpc/services/list-service.ts
+473
-53
server/services/xrpc/services/list-service.ts
···
19
19
import { xrpcApi } from '../../xrpc-api';
20
20
21
21
/**
22
-
* Get a specific list by URI
22
+
* Get a specific list by URI with items
23
23
* GET /xrpc/app.bsky.graph.getList
24
24
*/
25
25
export async function getList(req: Request, res: Response): Promise<void> {
26
26
try {
27
27
const params = getListSchema.parse(req.query);
28
+
const viewerDid = await getAuthenticatedDid(req);
29
+
30
+
// Get list metadata
28
31
const list = await storage.getList(params.list);
29
32
30
33
if (!list) {
···
35
38
return;
36
39
}
37
40
41
+
// Get list items with pagination
42
+
const { items: listItems, cursor: nextCursor } = await storage.getListItemsWithPagination(
43
+
params.list,
44
+
params.limit,
45
+
params.cursor
46
+
);
47
+
48
+
// Hydrate creator profile
49
+
const creatorProfiles = await (xrpcApi as any)._getProfiles([list.creatorDid], req);
50
+
const creator = creatorProfiles[0];
51
+
52
+
if (!creator) {
53
+
res.status(500).json({
54
+
error: 'InternalServerError',
55
+
message: 'Failed to fetch list creator profile',
56
+
});
57
+
return;
58
+
}
59
+
60
+
// Hydrate subject profiles for list items
61
+
const subjectDids = listItems.map((item) => item.subjectDid);
62
+
let subjects: any[] = [];
63
+
64
+
if (subjectDids.length > 0) {
65
+
subjects = await (xrpcApi as any)._getProfiles(subjectDids, req);
66
+
}
67
+
68
+
// Create subject map for quick lookup
69
+
const subjectMap = new Map(subjects.map((s) => [s.did, s]));
70
+
71
+
// Build list item views
72
+
const itemViews = listItems
73
+
.map((item) => {
74
+
const subject = subjectMap.get(item.subjectDid);
75
+
if (!subject) return null;
76
+
77
+
return {
78
+
uri: item.uri,
79
+
subject,
80
+
};
81
+
})
82
+
.filter((item): item is NonNullable<typeof item> => item !== null);
83
+
84
+
// Count total list items (for listItemCount field)
85
+
const allItems = await storage.getListItems(params.list, 10000);
86
+
const listItemCount = allItems.length;
87
+
88
+
// Build viewer state if authenticated
89
+
let viewer: any = undefined;
90
+
if (viewerDid) {
91
+
// Check if viewer has muted this list
92
+
const { mutes } = await storage.getListMutes(viewerDid, 1000, undefined);
93
+
const isMuted = mutes.some((m) => m.listUri === params.list);
94
+
95
+
// Check if viewer has blocked this list
96
+
const { blocks } = await storage.getListBlocks(viewerDid, 1000, undefined);
97
+
const isBlocked = blocks.some((b) => b.listUri === params.list);
98
+
99
+
if (isMuted || isBlocked) {
100
+
viewer = {
101
+
muted: isMuted || undefined,
102
+
blocked: isBlocked ? params.list : undefined,
103
+
};
104
+
}
105
+
}
106
+
107
+
// Build ATProto-compliant response
38
108
res.json({
109
+
cursor: nextCursor,
39
110
list: {
40
111
uri: list.uri,
41
112
cid: list.cid,
113
+
creator,
42
114
name: list.name,
43
115
purpose: list.purpose,
44
-
createdAt: list.createdAt.toISOString(),
116
+
description: list.description || undefined,
117
+
avatar: list.avatarUrl || undefined,
118
+
listItemCount,
45
119
indexedAt: list.indexedAt.toISOString(),
120
+
...(viewer && { viewer }),
46
121
},
122
+
items: itemViews,
47
123
});
48
124
} catch (error) {
49
125
handleError(res, error, 'getList');
···
57
133
export async function getLists(req: Request, res: Response): Promise<void> {
58
134
try {
59
135
const params = getListsSchema.parse(req.query);
136
+
const viewerDid = await getAuthenticatedDid(req);
137
+
138
+
// Resolve actor to DID
60
139
const did = await resolveActor(res, params.actor);
61
140
if (!did) return;
62
141
63
-
const lists = await storage.getUserLists(did, params.limit);
142
+
// Get lists with pagination and optional purpose filtering
143
+
const { lists: userLists, cursor: nextCursor } = await storage.getUserListsWithPagination(
144
+
did,
145
+
params.limit,
146
+
params.cursor,
147
+
params.purposes
148
+
);
149
+
150
+
if (userLists.length === 0) {
151
+
res.json({
152
+
cursor: nextCursor,
153
+
lists: [],
154
+
});
155
+
return;
156
+
}
157
+
158
+
// Hydrate creator profile for all lists (should be same creator)
159
+
const creatorProfiles = await (xrpcApi as any)._getProfiles([did], req);
160
+
const creator = creatorProfiles[0];
161
+
162
+
if (!creator) {
163
+
res.status(500).json({
164
+
error: 'InternalServerError',
165
+
message: 'Failed to fetch list creator profile',
166
+
});
167
+
return;
168
+
}
169
+
170
+
// Build viewer states if authenticated
171
+
let viewerMutes: Set<string> = new Set();
172
+
let viewerBlocks: Set<string> = new Set();
173
+
174
+
if (viewerDid) {
175
+
const { mutes } = await storage.getListMutes(viewerDid, 1000, undefined);
176
+
viewerMutes = new Set(mutes.map((m) => m.listUri));
177
+
178
+
const { blocks } = await storage.getListBlocks(viewerDid, 1000, undefined);
179
+
viewerBlocks = new Set(blocks.map((b) => b.listUri));
180
+
}
181
+
182
+
// Get list item counts for all lists
183
+
const listItemCounts = await Promise.all(
184
+
userLists.map(async (list) => {
185
+
const items = await storage.getListItems(list.uri, 10000);
186
+
return { uri: list.uri, count: items.length };
187
+
})
188
+
);
189
+
const countMap = new Map(listItemCounts.map((c) => [c.uri, c.count]));
190
+
191
+
// Build full listView objects
192
+
const listViews = userLists.map((list) => {
193
+
const listItemCount = countMap.get(list.uri) || 0;
194
+
195
+
// Build viewer state if authenticated
196
+
let viewer: any = undefined;
197
+
if (viewerDid) {
198
+
const isMuted = viewerMutes.has(list.uri);
199
+
const isBlocked = viewerBlocks.has(list.uri);
200
+
201
+
if (isMuted || isBlocked) {
202
+
viewer = {
203
+
muted: isMuted || undefined,
204
+
blocked: isBlocked ? list.uri : undefined,
205
+
};
206
+
}
207
+
}
208
+
209
+
return {
210
+
uri: list.uri,
211
+
cid: list.cid,
212
+
creator,
213
+
name: list.name,
214
+
purpose: list.purpose,
215
+
description: list.description || undefined,
216
+
avatar: list.avatarUrl || undefined,
217
+
listItemCount,
218
+
indexedAt: list.indexedAt.toISOString(),
219
+
...(viewer && { viewer }),
220
+
};
221
+
});
64
222
65
223
res.json({
66
-
lists: lists.map((l) => ({
67
-
uri: l.uri,
68
-
cid: l.cid,
69
-
name: l.name,
70
-
purpose: l.purpose,
71
-
createdAt: l.createdAt.toISOString(),
72
-
indexedAt: l.indexedAt.toISOString(),
73
-
})),
224
+
cursor: nextCursor,
225
+
lists: listViews,
74
226
});
75
227
} catch (error) {
76
228
handleError(res, error, 'getLists');
···
84
236
export async function getListFeed(req: Request, res: Response): Promise<void> {
85
237
try {
86
238
const params = getListFeedSchema.parse(req.query);
239
+
240
+
// Check if list exists (ATProto spec requires UnknownList error)
241
+
const list = await storage.getList(params.list);
242
+
if (!list) {
243
+
res.status(400).json({
244
+
error: 'UnknownList',
245
+
message: 'List not found',
246
+
});
247
+
return;
248
+
}
249
+
250
+
// Fetch posts from list members with limit+1 for pagination
87
251
const posts = await storage.getListFeed(
88
252
params.list,
89
253
params.limit,
···
92
256
93
257
const viewerDid = await getAuthenticatedDid(req);
94
258
95
-
// Use legacy API for complex post serialization
96
-
// TODO: Extract serializePosts to utils in future iteration
259
+
// Use serializePosts for proper post hydration with viewer context
260
+
// This handles: embeds, author profiles, viewer state (likes/reposts),
261
+
// reply counts, repost counts, quote counts, labels, and thread context
97
262
const serialized = await (xrpcApi as any).serializePosts(
98
263
posts,
99
264
viewerDid || undefined,
100
265
req
101
266
);
102
267
103
-
const oldest = posts.length ? posts[posts.length - 1] : null;
268
+
// Generate cursor from last post if results exist
269
+
const cursor = posts.length > 0
270
+
? posts[posts.length - 1].indexedAt.toISOString()
271
+
: undefined;
104
272
105
273
res.json({
106
-
cursor: oldest ? oldest.indexedAt.toISOString() : undefined,
274
+
cursor,
107
275
feed: serialized.map((p: any) => ({ post: p })),
108
276
});
109
277
} catch (error) {
···
114
282
/**
115
283
* Get lists with membership information for an actor
116
284
* GET /xrpc/app.bsky.graph.getListsWithMembership
285
+
*
286
+
* Returns lists created by the authenticated user, with membership info
287
+
* about the specified actor in each list.
117
288
*/
118
289
export async function getListsWithMembership(
119
290
req: Request,
···
121
292
): Promise<void> {
122
293
try {
123
294
const params = getListsWithMembershipSchema.parse(req.query);
124
-
const did = await resolveActor(res, params.actor);
125
-
if (!did) return;
295
+
296
+
// Requires authentication - lists are created by session user
297
+
const sessionDid = await requireAuthDid(req, res);
298
+
if (!sessionDid) return;
299
+
300
+
// Resolve the actor to check for membership
301
+
const actorDid = await resolveActor(res, params.actor);
302
+
if (!actorDid) return;
303
+
304
+
// Get lists created by authenticated user with pagination and optional filtering
305
+
const { lists: userLists, cursor: nextCursor } = await storage.getUserListsWithPagination(
306
+
sessionDid,
307
+
params.limit,
308
+
params.cursor,
309
+
params.purposes
310
+
);
311
+
312
+
if (userLists.length === 0) {
313
+
res.json({
314
+
cursor: nextCursor,
315
+
listsWithMembership: [],
316
+
});
317
+
return;
318
+
}
319
+
320
+
// Get creator profile (session user)
321
+
const creator = await storage.getUser(sessionDid);
322
+
if (!creator || !(creator as { handle?: string }).handle) {
323
+
res.status(500).json({
324
+
error: 'InternalServerError',
325
+
message: 'Creator profile not available',
326
+
});
327
+
return;
328
+
}
126
329
127
-
const lists = await storage.getUserLists(did, params.limit);
330
+
const creatorData = creator as {
331
+
handle: string;
332
+
displayName?: string;
333
+
avatarUrl?: string;
334
+
did: string;
335
+
};
336
+
337
+
// Build creator ProfileView (will be same for all lists)
338
+
const creatorProfiles = await (xrpcApi as any)._getProfiles([sessionDid], req);
339
+
const creatorView = creatorProfiles[0];
340
+
341
+
if (!creatorView) {
342
+
res.status(500).json({
343
+
error: 'InternalServerError',
344
+
message: 'Failed to fetch creator profile',
345
+
});
346
+
return;
347
+
}
348
+
349
+
// Get all list URIs to check membership
350
+
const listUris = userLists.map((l) => l.uri);
351
+
352
+
// Batch fetch list items to check membership
353
+
const membershipPromises = listUris.map(async (listUri) => {
354
+
const items = await storage.getListItems(listUri, 10000);
355
+
return { listUri, items };
356
+
});
357
+
const membershipResults = await Promise.all(membershipPromises);
358
+
const membershipMap = new Map(
359
+
membershipResults.map((r) => [r.listUri, r.items])
360
+
);
361
+
362
+
// Get actor profile for listItem views
363
+
const actorProfiles = await (xrpcApi as any)._getProfiles([actorDid], req);
364
+
const actorProfile = actorProfiles[0];
365
+
366
+
// Batch fetch list item counts
367
+
const listItemCounts = await Promise.all(
368
+
userLists.map(async (list) => {
369
+
const items = await storage.getListItems(list.uri, 10000);
370
+
return { uri: list.uri, count: items.length };
371
+
})
372
+
);
373
+
const countMap = new Map(listItemCounts.map((c) => [c.uri, c.count]));
374
+
375
+
// Build viewer states if needed
376
+
const viewerDid = sessionDid;
377
+
const { mutes } = await storage.getListMutes(viewerDid, 1000, undefined);
378
+
const viewerMutes = new Set(mutes.map((m) => m.listUri));
379
+
380
+
const { blocks } = await storage.getListBlocks(viewerDid, 1000, undefined);
381
+
const viewerBlocks = new Set(blocks.map((b) => b.listUri));
382
+
383
+
// Build listsWithMembership response
384
+
const listsWithMembership = userLists.map((list) => {
385
+
const listItemCount = countMap.get(list.uri) || 0;
386
+
387
+
// Build viewer state
388
+
let viewer: any = undefined;
389
+
const isMuted = viewerMutes.has(list.uri);
390
+
const isBlocked = viewerBlocks.has(list.uri);
391
+
392
+
if (isMuted || isBlocked) {
393
+
viewer = {
394
+
muted: isMuted || undefined,
395
+
blocked: isBlocked ? list.uri : undefined,
396
+
};
397
+
}
398
+
399
+
// Build full listView
400
+
const listView = {
401
+
uri: list.uri,
402
+
cid: list.cid,
403
+
creator: creatorView,
404
+
name: list.name,
405
+
purpose: list.purpose,
406
+
description: list.description || undefined,
407
+
avatar: list.avatarUrl || undefined,
408
+
listItemCount,
409
+
indexedAt: list.indexedAt.toISOString(),
410
+
...(viewer && { viewer }),
411
+
};
412
+
413
+
// Check if actor is a member of this list
414
+
const listItems = membershipMap.get(list.uri) || [];
415
+
const memberItem = listItems.find((item) => item.subjectDid === actorDid);
416
+
417
+
// Build response object
418
+
const response: {
419
+
list: typeof listView;
420
+
listItem?: { uri: string; subject: any };
421
+
} = {
422
+
list: listView,
423
+
};
424
+
425
+
// Include listItem if actor is a member
426
+
if (memberItem && actorProfile) {
427
+
response.listItem = {
428
+
uri: memberItem.uri,
429
+
subject: actorProfile,
430
+
};
431
+
}
432
+
433
+
return response;
434
+
});
128
435
129
436
res.json({
130
-
cursor: undefined,
131
-
lists: lists.map((l) => ({
132
-
uri: l.uri,
133
-
cid: l.cid,
134
-
name: l.name,
135
-
purpose: l.purpose,
136
-
})),
437
+
cursor: nextCursor,
438
+
listsWithMembership,
137
439
});
138
440
} catch (error) {
139
441
handleError(res, error, 'getListsWithMembership');
···
150
452
const userDid = await requireAuthDid(req, res);
151
453
if (!userDid) return;
152
454
153
-
const { mutes, cursor } = await storage.getListMutes(
455
+
// Get muted list URIs with pagination
456
+
const { mutes, cursor: nextCursor } = await storage.getListMutes(
154
457
userDid,
155
458
params.limit,
156
459
params.cursor
157
460
);
158
461
462
+
if (mutes.length === 0) {
463
+
res.json({
464
+
cursor: nextCursor,
465
+
lists: [],
466
+
});
467
+
return;
468
+
}
469
+
470
+
// Batch fetch all muted lists
471
+
const listUris = mutes.map((m) => m.listUri);
472
+
const lists = await Promise.all(
473
+
listUris.map((uri) => storage.getList(uri))
474
+
);
475
+
476
+
// Filter out nulls (lists that no longer exist)
477
+
const existingLists = lists.filter((list): list is NonNullable<typeof list> => list !== null);
478
+
479
+
if (existingLists.length === 0) {
480
+
res.json({
481
+
cursor: nextCursor,
482
+
lists: [],
483
+
});
484
+
return;
485
+
}
486
+
487
+
// Get unique creator DIDs
488
+
const creatorDids = [...new Set(existingLists.map((list) => list.creatorDid))];
489
+
490
+
// Batch fetch all creator profiles
491
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
492
+
const creatorMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
493
+
494
+
// Batch fetch list item counts
495
+
const listItemCounts = await Promise.all(
496
+
existingLists.map(async (list) => {
497
+
const items = await storage.getListItems(list.uri, 10000);
498
+
return { uri: list.uri, count: items.length };
499
+
})
500
+
);
501
+
const countMap = new Map(listItemCounts.map((c) => [c.uri, c.count]));
502
+
503
+
// Build full listView objects
504
+
const listViews = existingLists
505
+
.map((list) => {
506
+
const creator = creatorMap.get(list.creatorDid);
507
+
if (!creator) return null;
508
+
509
+
const listItemCount = countMap.get(list.uri) || 0;
510
+
511
+
// All these lists are muted by the viewer (by definition)
512
+
const viewer = {
513
+
muted: true,
514
+
};
515
+
516
+
return {
517
+
uri: list.uri,
518
+
cid: list.cid,
519
+
creator,
520
+
name: list.name,
521
+
purpose: list.purpose,
522
+
description: list.description || undefined,
523
+
avatar: list.avatarUrl || undefined,
524
+
listItemCount,
525
+
indexedAt: list.indexedAt.toISOString(),
526
+
viewer,
527
+
};
528
+
})
529
+
.filter((list): list is NonNullable<typeof list> => list !== null);
530
+
159
531
res.json({
160
-
cursor,
161
-
lists: await Promise.all(
162
-
mutes.map(async (listMute) => {
163
-
const list = await storage.getList(listMute.listUri);
164
-
return list
165
-
? {
166
-
uri: list.uri,
167
-
name: list.name,
168
-
purpose: list.purpose,
169
-
}
170
-
: null;
171
-
})
172
-
),
532
+
cursor: nextCursor,
533
+
lists: listViews,
173
534
});
174
535
} catch (error) {
175
536
handleError(res, error, 'getListMutes');
···
189
550
const userDid = await requireAuthDid(req, res);
190
551
if (!userDid) return;
191
552
192
-
const { blocks, cursor } = await storage.getListBlocks(
553
+
// Get blocked list URIs with pagination
554
+
const { blocks, cursor: nextCursor } = await storage.getListBlocks(
193
555
userDid,
194
556
params.limit,
195
557
params.cursor
196
558
);
197
559
560
+
if (blocks.length === 0) {
561
+
res.json({
562
+
cursor: nextCursor,
563
+
lists: [],
564
+
});
565
+
return;
566
+
}
567
+
568
+
// Batch fetch all blocked lists
569
+
const listUris = blocks.map((b) => b.listUri);
570
+
const lists = await Promise.all(
571
+
listUris.map((uri) => storage.getList(uri))
572
+
);
573
+
574
+
// Filter out nulls (lists that no longer exist)
575
+
const existingLists = lists.filter((list): list is NonNullable<typeof list> => list !== null);
576
+
577
+
if (existingLists.length === 0) {
578
+
res.json({
579
+
cursor: nextCursor,
580
+
lists: [],
581
+
});
582
+
return;
583
+
}
584
+
585
+
// Get unique creator DIDs
586
+
const creatorDids = [...new Set(existingLists.map((list) => list.creatorDid))];
587
+
588
+
// Batch fetch all creator profiles
589
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
590
+
const creatorMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
591
+
592
+
// Batch fetch list item counts
593
+
const listItemCounts = await Promise.all(
594
+
existingLists.map(async (list) => {
595
+
const items = await storage.getListItems(list.uri, 10000);
596
+
return { uri: list.uri, count: items.length };
597
+
})
598
+
);
599
+
const countMap = new Map(listItemCounts.map((c) => [c.uri, c.count]));
600
+
601
+
// Build full listView objects
602
+
const listViews = existingLists
603
+
.map((list) => {
604
+
const creator = creatorMap.get(list.creatorDid);
605
+
if (!creator) return null;
606
+
607
+
const listItemCount = countMap.get(list.uri) || 0;
608
+
609
+
// All these lists are blocked by the viewer (by definition)
610
+
const viewer = {
611
+
blocked: list.uri,
612
+
};
613
+
614
+
return {
615
+
uri: list.uri,
616
+
cid: list.cid,
617
+
creator,
618
+
name: list.name,
619
+
purpose: list.purpose,
620
+
description: list.description || undefined,
621
+
avatar: list.avatarUrl || undefined,
622
+
listItemCount,
623
+
indexedAt: list.indexedAt.toISOString(),
624
+
viewer,
625
+
};
626
+
})
627
+
.filter((list): list is NonNullable<typeof list> => list !== null);
628
+
198
629
res.json({
199
-
cursor,
200
-
lists: await Promise.all(
201
-
blocks.map(async (listBlock) => {
202
-
const list = await storage.getList(listBlock.listUri);
203
-
return list
204
-
? {
205
-
uri: list.uri,
206
-
name: list.name,
207
-
purpose: list.purpose,
208
-
}
209
-
: null;
210
-
})
211
-
),
630
+
cursor: nextCursor,
631
+
lists: listViews,
212
632
});
213
633
} catch (error) {
214
634
handleError(res, error, 'getListBlocks');
+204
-135
server/services/xrpc/services/moderation-service.ts
+204
-135
server/services/xrpc/services/moderation-service.ts
···
7
7
import { storage } from '../../../storage';
8
8
import { requireAuthDid, getAuthenticatedDid } from '../utils/auth-helpers';
9
9
import { handleError } from '../utils/error-handler';
10
-
import { resolveActor } from '../utils/resolvers';
10
+
import { resolveActor, getUserPdsEndpoint } from '../utils/resolvers';
11
11
import { maybeAvatar } from '../utils/serializers';
12
12
import {
13
13
getBlocksSchema,
···
19
19
queryLabelsSchema,
20
20
createReportSchema,
21
21
} from '../schemas';
22
+
import { xrpcApi } from '../../xrpc-api';
22
23
23
24
/**
24
25
* Get blocked actors
···
35
36
params.limit,
36
37
params.cursor
37
38
);
39
+
40
+
if (blocks.length === 0) {
41
+
return res.json({
42
+
cursor,
43
+
blocks: [],
44
+
});
45
+
}
46
+
38
47
const blockedDids = blocks.map((b) => b.blockedDid);
39
-
const blockedUsers = await storage.getUsers(blockedDids);
40
-
const userMap = new Map(blockedUsers.map((u) => [u.did, u]));
48
+
49
+
// Use _getProfiles helper to build complete profileView objects
50
+
const profiles = await (xrpcApi as any)._getProfiles(blockedDids, req);
51
+
52
+
// Create a map of DID -> profile for quick lookup
53
+
const profileMap = new Map(profiles.map((p: any) => [p.did, p]));
54
+
55
+
// Build blocks array with full profileView objects and viewer state
56
+
const blocksWithProfiles = blocks
57
+
.map((b) => {
58
+
const profile = profileMap.get(b.blockedDid);
59
+
if (!profile) return null;
60
+
61
+
// Ensure viewer.blocking is set correctly
62
+
return {
63
+
...profile,
64
+
viewer: {
65
+
...profile.viewer,
66
+
blocking: b.uri, // Override with the specific block URI
67
+
},
68
+
};
69
+
})
70
+
.filter(Boolean);
41
71
42
72
res.json({
43
73
cursor,
44
-
blocks: blocks
45
-
.map((b) => {
46
-
const user = userMap.get(b.blockedDid);
47
-
if (!user) return null;
48
-
return {
49
-
did: user.did,
50
-
handle: user.handle,
51
-
displayName: user.displayName || user.handle,
52
-
...maybeAvatar(user.avatarUrl, user.did, req),
53
-
viewer: {
54
-
blocking: b.uri,
55
-
muted: false,
56
-
},
57
-
};
58
-
})
59
-
.filter(Boolean),
74
+
blocks: blocksWithProfiles,
60
75
});
61
76
} catch (error) {
62
77
handleError(res, error, 'getBlocks');
···
66
81
/**
67
82
* Get muted actors
68
83
* GET /xrpc/app.bsky.graph.getMutes
84
+
*
85
+
* NOTE: Per ATProto architecture, mutes are private user preferences
86
+
* that belong on the PDS, NOT the AppView. Unlike blocks (which are public
87
+
* records), mutes are private metadata affecting content filtering.
88
+
*
89
+
* Returns error directing client to fetch from PDS directly.
69
90
*/
70
91
export async function getMutes(req: Request, res: Response): Promise<void> {
71
92
try {
72
-
const params = getMutesSchema.parse(req.query);
73
93
const userDid = await requireAuthDid(req, res);
74
94
if (!userDid) return;
75
95
76
-
const { mutes, cursor } = await storage.getMutes(
77
-
userDid,
78
-
params.limit,
79
-
params.cursor
80
-
);
81
-
const mutedDids = mutes.map((m) => m.mutedDid);
82
-
const mutedUsers = await storage.getUsers(mutedDids);
83
-
const userMap = new Map(mutedUsers.map((u) => [u.did, u]));
96
+
// Get user's PDS endpoint to include in error message
97
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
84
98
85
-
res.json({
86
-
cursor,
87
-
mutes: mutes
88
-
.map((m) => {
89
-
const user = userMap.get(m.mutedDid);
90
-
if (!user) return null;
91
-
return {
92
-
did: user.did,
93
-
handle: user.handle,
94
-
displayName: user.displayName || user.handle,
95
-
...maybeAvatar(user.avatarUrl, user.did, req),
96
-
viewer: {
97
-
muted: true,
98
-
},
99
-
};
100
-
})
101
-
.filter(Boolean),
99
+
res.status(501).json({
100
+
error: 'NotImplemented',
101
+
message: 'Mutes must be fetched directly from your PDS, not through the AppView. ' +
102
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
103
+
'Unlike blocks (which are public records), mutes are private metadata. ' +
104
+
(pdsEndpoint
105
+
? `Please fetch from: ${pdsEndpoint}/xrpc/app.bsky.graph.getMutes`
106
+
: 'Please fetch from your PDS using your PDS token.'),
107
+
pdsEndpoint: pdsEndpoint || undefined,
102
108
});
103
109
} catch (error) {
104
110
handleError(res, error, 'getMutes');
···
108
114
/**
109
115
* Mute an actor
110
116
* POST /xrpc/app.bsky.graph.muteActor
117
+
*
118
+
* NOTE: Per ATProto architecture, mutes are private user preferences
119
+
* that belong on the PDS, NOT the AppView.
120
+
*
121
+
* Returns error directing client to create mute on PDS directly.
111
122
*/
112
123
export async function muteActor(req: Request, res: Response): Promise<void> {
113
124
try {
114
-
const params = muteActorSchema.parse(req.body);
115
125
const userDid = await requireAuthDid(req, res);
116
126
if (!userDid) return;
117
127
118
-
const mutedDid = await resolveActor(res, params.actor);
119
-
if (!mutedDid) return;
128
+
// Get user's PDS endpoint to include in error message
129
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
120
130
121
-
await storage.createMute({
122
-
uri: `at://${userDid}/app.bsky.graph.mute/${Date.now()}`,
123
-
muterDid: userDid,
124
-
mutedDid,
125
-
createdAt: new Date(),
131
+
res.status(501).json({
132
+
error: 'NotImplemented',
133
+
message: 'Mutes must be created directly on your PDS, not through the AppView. ' +
134
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
135
+
(pdsEndpoint
136
+
? `Please create mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.muteActor`
137
+
: 'Please create mute on your PDS using your PDS token.'),
138
+
pdsEndpoint: pdsEndpoint || undefined,
126
139
});
127
-
128
-
res.json({ success: true });
129
140
} catch (error) {
130
141
handleError(res, error, 'muteActor');
131
142
}
···
134
145
/**
135
146
* Unmute an actor
136
147
* POST /xrpc/app.bsky.graph.unmuteActor
148
+
*
149
+
* NOTE: Per ATProto architecture, mutes are private user preferences
150
+
* that belong on the PDS, NOT the AppView.
151
+
*
152
+
* Returns error directing client to delete mute on PDS directly.
137
153
*/
138
154
export async function unmuteActor(req: Request, res: Response): Promise<void> {
139
155
try {
140
-
const params = muteActorSchema.parse(req.body);
141
156
const userDid = await requireAuthDid(req, res);
142
157
if (!userDid) return;
143
158
144
-
const mutedDid = await resolveActor(res, params.actor);
145
-
if (!mutedDid) return;
159
+
// Get user's PDS endpoint to include in error message
160
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
146
161
147
-
const { mutes } = await storage.getMutes(userDid, 1000);
148
-
const mute = mutes.find((m) => m.mutedDid === mutedDid);
149
-
150
-
if (mute) {
151
-
await storage.deleteMute(mute.uri);
152
-
}
153
-
154
-
res.json({ success: true });
162
+
res.status(501).json({
163
+
error: 'NotImplemented',
164
+
message: 'Mutes must be removed directly on your PDS, not through the AppView. ' +
165
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
166
+
(pdsEndpoint
167
+
? `Please remove mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.unmuteActor`
168
+
: 'Please remove mute on your PDS using your PDS token.'),
169
+
pdsEndpoint: pdsEndpoint || undefined,
170
+
});
155
171
} catch (error) {
156
172
handleError(res, error, 'unmuteActor');
157
173
}
···
160
176
/**
161
177
* Mute a list
162
178
* POST /xrpc/app.bsky.graph.muteActorList
179
+
*
180
+
* NOTE: Per ATProto architecture, list mutes are private user preferences
181
+
* that belong on the PDS, NOT the AppView.
182
+
*
183
+
* Returns error directing client to create list mute on PDS directly.
163
184
*/
164
185
export async function muteActorList(
165
186
req: Request,
166
187
res: Response
167
188
): Promise<void> {
168
189
try {
169
-
const params = muteActorListSchema.parse(req.body);
170
190
const userDid = await requireAuthDid(req, res);
171
191
if (!userDid) return;
172
192
173
-
// Verify list exists
174
-
const list = await storage.getList(params.list);
175
-
if (!list) {
176
-
res.status(404).json({ error: 'List not found' });
177
-
return;
178
-
}
193
+
// Get user's PDS endpoint to include in error message
194
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
179
195
180
-
await storage.createListMute({
181
-
uri: `at://${userDid}/app.bsky.graph.listMute/${Date.now()}`,
182
-
muterDid: userDid,
183
-
listUri: params.list,
184
-
createdAt: new Date(),
196
+
res.status(501).json({
197
+
error: 'NotImplemented',
198
+
message: 'List mutes must be created directly on your PDS, not through the AppView. ' +
199
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
200
+
(pdsEndpoint
201
+
? `Please create list mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.muteActorList`
202
+
: 'Please create list mute on your PDS using your PDS token.'),
203
+
pdsEndpoint: pdsEndpoint || undefined,
185
204
});
186
-
187
-
res.json({ success: true });
188
205
} catch (error) {
189
206
handleError(res, error, 'muteActorList');
190
207
}
···
193
210
/**
194
211
* Unmute a list
195
212
* POST /xrpc/app.bsky.graph.unmuteActorList
213
+
*
214
+
* NOTE: Per ATProto architecture, list mutes are private user preferences
215
+
* that belong on the PDS, NOT the AppView.
216
+
*
217
+
* Returns error directing client to delete list mute on PDS directly.
196
218
*/
197
219
export async function unmuteActorList(
198
220
req: Request,
199
221
res: Response
200
222
): Promise<void> {
201
223
try {
202
-
const params = unmuteActorListSchema.parse(req.body);
203
224
const userDid = await requireAuthDid(req, res);
204
225
if (!userDid) return;
205
226
206
-
const { mutes } = await storage.getListMutes(userDid, 1000);
207
-
const mute = mutes.find((m) => m.listUri === params.list);
227
+
// Get user's PDS endpoint to include in error message
228
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
208
229
209
-
if (mute) {
210
-
await storage.deleteListMute(mute.uri);
211
-
}
212
-
213
-
res.json({ success: true });
230
+
res.status(501).json({
231
+
error: 'NotImplemented',
232
+
message: 'List mutes must be removed directly on your PDS, not through the AppView. ' +
233
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
234
+
(pdsEndpoint
235
+
? `Please remove list mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.unmuteActorList`
236
+
: 'Please remove list mute on your PDS using your PDS token.'),
237
+
pdsEndpoint: pdsEndpoint || undefined,
238
+
});
214
239
} catch (error) {
215
240
handleError(res, error, 'unmuteActorList');
216
241
}
···
219
244
/**
220
245
* Mute a thread
221
246
* POST /xrpc/app.bsky.graph.muteThread
247
+
*
248
+
* NOTE: Per ATProto architecture, thread mutes are private user preferences
249
+
* that belong on the PDS, NOT the AppView.
250
+
*
251
+
* Returns error directing client to create thread mute on PDS directly.
222
252
*/
223
253
export async function muteThread(req: Request, res: Response): Promise<void> {
224
254
try {
225
-
const params = muteThreadSchema.parse(req.body);
226
255
const userDid = await requireAuthDid(req, res);
227
256
if (!userDid) return;
228
257
229
-
// Verify thread root post exists
230
-
const rootPost = await storage.getPost(params.root);
231
-
if (!rootPost) {
232
-
res.status(404).json({ error: 'Thread root post not found' });
233
-
return;
234
-
}
258
+
// Get user's PDS endpoint to include in error message
259
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
235
260
236
-
// Create thread mute
237
-
await storage.createThreadMute({
238
-
uri: `at://${userDid}/app.bsky.graph.threadMute/${Date.now()}`,
239
-
muterDid: userDid,
240
-
threadRootUri: params.root,
241
-
createdAt: new Date(),
261
+
res.status(501).json({
262
+
error: 'NotImplemented',
263
+
message: 'Thread mutes must be created directly on your PDS, not through the AppView. ' +
264
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
265
+
(pdsEndpoint
266
+
? `Please create thread mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.muteThread`
267
+
: 'Please create thread mute on your PDS using your PDS token.'),
268
+
pdsEndpoint: pdsEndpoint || undefined,
242
269
});
243
-
244
-
res.json({ success: true });
245
270
} catch (error) {
246
271
handleError(res, error, 'muteThread');
247
272
}
···
250
275
/**
251
276
* Unmute a thread
252
277
* POST /xrpc/app.bsky.graph.unmuteThread
278
+
*
279
+
* NOTE: Per ATProto architecture, thread mutes are private user preferences
280
+
* that belong on the PDS, NOT the AppView.
281
+
*
282
+
* Returns error directing client to remove thread mute on PDS directly.
253
283
*/
254
284
export async function unmuteThread(req: Request, res: Response): Promise<void> {
255
285
try {
256
-
const body = muteThreadSchema.parse(req.body);
257
286
const userDid = await requireAuthDid(req, res);
258
287
if (!userDid) return;
259
288
260
-
const { mutes } = await storage.getThreadMutes(userDid, 1000);
261
-
const existing = mutes.find((m) => m.threadRootUri === body.root);
289
+
// Get user's PDS endpoint to include in error message
290
+
const pdsEndpoint = await getUserPdsEndpoint(userDid);
262
291
263
-
if (existing) {
264
-
await storage.deleteThreadMute(existing.uri);
265
-
}
266
-
267
-
res.json({ success: true });
292
+
res.status(501).json({
293
+
error: 'NotImplemented',
294
+
message: 'Thread mutes must be removed directly on your PDS, not through the AppView. ' +
295
+
'Per ATProto architecture, mutes are private user preferences stored on the PDS. ' +
296
+
(pdsEndpoint
297
+
? `Please remove thread mute at: ${pdsEndpoint}/xrpc/app.bsky.graph.unmuteThread`
298
+
: 'Please remove thread mute on your PDS using your PDS token.'),
299
+
pdsEndpoint: pdsEndpoint || undefined,
300
+
});
268
301
} catch (error) {
269
302
handleError(res, error, 'unmuteThread');
270
303
}
···
279
312
const params = queryLabelsSchema.parse(req.query);
280
313
const subjects = params.uriPatterns ?? [];
281
314
315
+
// Validate wildcard usage
282
316
if (subjects.some((u) => u.includes('*'))) {
283
317
res.status(400).json({
284
318
error: 'InvalidRequest',
···
287
321
return;
288
322
}
289
323
290
-
const sources = params.sources ?? [];
291
-
if (sources.length === 0) {
292
-
res.status(400).json({
293
-
error: 'InvalidRequest',
294
-
message: 'source dids are required',
295
-
});
296
-
return;
297
-
}
324
+
// Sources are optional - if not provided, return labels from all sources
325
+
const sources = params.sources;
298
326
299
-
const labels = await storage.getLabelsForSubjects(subjects);
300
-
const filtered = labels.filter((l) => sources.includes(l.src));
327
+
// Use the proper storage.queryLabels method which handles filtering in DB
328
+
const labels = await storage.queryLabels({
329
+
subjects: subjects.length > 0 ? subjects : undefined,
330
+
sources: sources && sources.length > 0 ? sources : undefined,
331
+
limit: params.limit,
332
+
});
301
333
302
-
res.json({ cursor: undefined, labels: filtered });
334
+
// Note: Cursor-based pagination not yet implemented in storage layer
335
+
// AT Protocol spec allows cursor to be undefined if pagination not supported
336
+
res.json({ cursor: undefined, labels });
303
337
} catch (error) {
304
338
handleError(res, error, 'queryLabels');
305
339
}
···
312
346
export async function createReport(req: Request, res: Response): Promise<void> {
313
347
try {
314
348
const params = createReportSchema.parse(req.body);
315
-
const reporterDid =
316
-
(await getAuthenticatedDid(req)) ||
317
-
(req as any).user?.did ||
318
-
'did:unknown:anonymous';
349
+
350
+
// Require authentication - reports must be from known users
351
+
const reporterDid = await requireAuthDid(req, res);
352
+
if (!reporterDid) return;
353
+
354
+
// Determine subject and subjectType from the subject object
355
+
let subject: string;
356
+
let subjectType: 'post' | 'account' | 'message';
357
+
358
+
if (params.subject.uri) {
359
+
subject = params.subject.uri;
360
+
// Determine type from URI
361
+
if (params.subject.uri.includes('app.bsky.feed.post')) {
362
+
subjectType = 'post';
363
+
} else if (params.subject.uri.includes('chat.bsky.convo.message')) {
364
+
subjectType = 'message';
365
+
} else {
366
+
// Default to post for other URIs
367
+
subjectType = 'post';
368
+
}
369
+
} else if (params.subject.did) {
370
+
subject = params.subject.did;
371
+
subjectType = 'account';
372
+
} else {
373
+
res.status(400).json({
374
+
error: 'InvalidRequest',
375
+
message: 'subject must contain either uri or did',
376
+
});
377
+
return;
378
+
}
319
379
320
380
const report = await storage.createModerationReport({
321
381
reporterDid,
322
-
reasonType: params.reasonType,
382
+
reportType: params.reasonType, // Note: DB field is 'reportType' not 'reasonType'
323
383
reason: params.reason || null,
324
-
subject:
325
-
params.subject.uri ||
326
-
params.subject.did ||
327
-
params.subject.cid ||
328
-
'unknown',
384
+
subject,
385
+
subjectType,
386
+
status: 'pending', // Use correct default status
329
387
createdAt: new Date(),
330
-
status: 'open',
331
-
} as any);
388
+
});
332
389
333
-
res.json({ id: report.id, success: true });
390
+
res.json({
391
+
id: report.id,
392
+
reasonType: report.reportType,
393
+
reason: report.reason,
394
+
subject: {
395
+
$type: params.subject.$type,
396
+
uri: params.subject.uri,
397
+
did: params.subject.did,
398
+
cid: params.subject.cid,
399
+
},
400
+
reportedBy: reporterDid,
401
+
createdAt: report.createdAt.toISOString(),
402
+
});
334
403
} catch (error) {
335
404
handleError(res, error, 'createReport');
336
405
}
+351
-127
server/services/xrpc/services/notification-service.ts
+351
-127
server/services/xrpc/services/notification-service.ts
···
10
10
import { transformBlobToCdnUrl } from '../utils/serializers';
11
11
import {
12
12
listNotificationsSchema,
13
+
getUnreadCountSchema,
13
14
updateSeenSchema,
14
15
getNotificationPreferencesSchema,
15
16
putNotificationPreferencesSchema,
···
51
52
const notificationsList = await storage.getNotifications(
52
53
userDid,
53
54
params.limit,
54
-
params.cursor
55
+
params.cursor,
56
+
params.seenAt ? new Date(params.seenAt) : undefined
55
57
);
56
58
console.log(
57
59
`[listNotifications] Found ${notificationsList.length} notifications`
···
62
64
.map((n) => (n as { reasonSubject?: string }).reasonSubject)
63
65
.filter((uri): uri is string => !!uri);
64
66
67
+
// Batch fetch all posts at once (not one by one)
68
+
const postsMap = new Map<string, any>();
65
69
if (postUris.length > 0) {
66
-
// Check which posts exist
67
70
const existingPosts = await storage.getPosts(postUris);
68
-
const existingUris = new Set(existingPosts.map((p) => p.uri));
69
-
const missingUris = postUris.filter((uri) => !existingUris.has(uri));
71
+
existingPosts.forEach((post) => postsMap.set(post.uri, post));
70
72
73
+
const missingUris = postUris.filter((uri) => !postsMap.has(uri));
71
74
if (missingUris.length > 0) {
72
75
console.log(
73
76
`[listNotifications] ${missingUris.length} notification posts not in database (will be backfilled on login)`
···
84
87
// Author profiles should be available from firehose events
85
88
const authors = await storage.getUsers(authorDids);
86
89
const authorMap = new Map(authors.map((a) => [a.did, a]));
90
+
91
+
// Get viewer relationships with all authors
92
+
const relationships = await storage.getRelationships(userDid, authorDids);
87
93
88
94
const items = await Promise.all(
89
95
notificationsList.map(async (n) => {
···
122
128
};
123
129
124
130
if (reasonSubject) {
125
-
try {
126
-
// For post-related notifications, check if the post still exists
127
-
if (
128
-
notification.reason === 'like' ||
129
-
notification.reason === 'repost' ||
130
-
notification.reason === 'reply' ||
131
-
notification.reason === 'quote'
132
-
) {
133
-
const post = await storage.getPost(reasonSubject);
134
-
if (!post) {
135
-
// Post was deleted, filter out this notification
136
-
return null;
137
-
}
138
-
const postData = post as {
139
-
text: string;
140
-
createdAt: Date;
141
-
embed?: unknown;
142
-
facets?: unknown;
143
-
};
144
-
record = {
145
-
$type: 'app.bsky.feed.post',
146
-
text: postData.text,
147
-
createdAt: postData.createdAt.toISOString(),
148
-
};
149
-
if (postData.embed)
150
-
(record as { embed?: unknown }).embed = postData.embed;
151
-
if (postData.facets)
152
-
(record as { facets?: unknown }).facets = postData.facets;
131
+
// For post-related notifications, check if the post still exists
132
+
if (
133
+
notification.reason === 'like' ||
134
+
notification.reason === 'repost' ||
135
+
notification.reason === 'reply' ||
136
+
notification.reason === 'quote'
137
+
) {
138
+
const post = postsMap.get(reasonSubject);
139
+
if (!post) {
140
+
// Post was deleted or not found, filter out this notification
141
+
return null;
153
142
}
154
-
} catch (error) {
155
-
console.warn(
156
-
'[NOTIFICATIONS] Failed to fetch record for subject:',
157
-
{ reasonSubject },
158
-
error
159
-
);
160
-
// If we can't fetch the record, filter out this notification
161
-
return null;
143
+
const postData = post as {
144
+
text: string;
145
+
createdAt: Date;
146
+
embed?: unknown;
147
+
facets?: unknown;
148
+
};
149
+
record = {
150
+
$type: 'app.bsky.feed.post',
151
+
text: postData.text,
152
+
createdAt: postData.createdAt.toISOString(),
153
+
};
154
+
if (postData.embed)
155
+
(record as { embed?: unknown }).embed = postData.embed;
156
+
if (postData.facets)
157
+
(record as { facets?: unknown }).facets = postData.facets;
162
158
}
163
159
} else {
164
160
// For notifications without a reasonSubject (like follows), create a fallback
···
191
187
createdAt?: Date;
192
188
};
193
189
190
+
// Get actual viewer state for this author
191
+
const viewerState = relationships.get(authorData.did);
192
+
const viewer: {
193
+
muted: boolean;
194
+
blockedBy: boolean;
195
+
blocking?: string;
196
+
following?: string;
197
+
followedBy?: string;
198
+
} = {
199
+
muted: viewerState ? !!viewerState.muting : false,
200
+
blockedBy: viewerState?.blockedBy || false,
201
+
};
202
+
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
203
+
if (viewerState?.following) viewer.following = viewerState.following;
204
+
if (viewerState?.followedBy) viewer.followedBy = viewerState.followedBy;
205
+
194
206
const view = {
195
207
$type: 'app.bsky.notification.listNotifications#notification',
196
208
uri: notificationUri,
···
209
221
displayName: authorData.displayName ?? authorData.handle,
210
222
pronouns: authorData.pronouns,
211
223
...maybeAvatar(authorData.avatarUrl, authorData.did, req),
212
-
associated: {
213
-
$type: 'app.bsky.actor.defs#profileAssociated',
214
-
lists: 0,
215
-
feedgens: 0,
216
-
starterPacks: 0,
217
-
labeler: false,
218
-
chat: undefined,
219
-
activitySubscription: undefined,
220
-
},
221
-
viewer: {
222
-
$type: 'app.bsky.actor.defs#viewerState',
223
-
muted: false,
224
-
mutedByList: undefined,
225
-
blockedBy: false,
226
-
blocking: undefined,
227
-
blockingByList: undefined,
228
-
following: undefined,
229
-
followedBy: undefined,
230
-
knownFollowers: undefined,
231
-
activitySubscription: undefined,
232
-
},
233
-
labels: [],
234
-
createdAt: authorData.createdAt?.toISOString(),
235
-
verification: undefined,
236
-
status: undefined,
224
+
viewer,
237
225
},
238
226
};
239
227
return view;
···
274
262
res: Response
275
263
): Promise<void> {
276
264
try {
265
+
const params = getUnreadCountSchema.parse(req.query);
277
266
const userDid = await requireAuthDid(req, res);
278
267
if (!userDid) return;
279
-
const count = await storage.getUnreadNotificationCount(userDid);
280
-
res.json({ count });
268
+
269
+
// If seenAt is provided, count only notifications after that time
270
+
let count: number;
271
+
if (params.seenAt) {
272
+
const seenAtDate = new Date(params.seenAt);
273
+
const allNotifications = await storage.getNotifications(
274
+
userDid,
275
+
1000, // High limit to get all recent
276
+
undefined,
277
+
undefined // No seenAt filter here
278
+
);
279
+
// Count unread notifications that occurred after seenAt
280
+
count = allNotifications.filter(
281
+
(n) => !n.isRead && n.indexedAt > seenAtDate
282
+
).length;
283
+
} else {
284
+
// No seenAt filter - just count all unread
285
+
count = await storage.getUnreadNotificationCount(userDid);
286
+
}
287
+
288
+
// Get user's last seenAt from preferences
289
+
const prefs = await storage.getUserPreferences(userDid);
290
+
const lastSeenAt = (prefs as { lastNotificationSeenAt?: Date })
291
+
?.lastNotificationSeenAt;
292
+
293
+
res.json({
294
+
count,
295
+
...(lastSeenAt && { seenAt: lastSeenAt.toISOString() }),
296
+
});
281
297
} catch (error) {
282
298
handleError(res, error, 'getUnreadCount');
283
299
}
···
292
308
const params = updateSeenSchema.parse(req.body);
293
309
const userDid = await requireAuthDid(req, res);
294
310
if (!userDid) return;
295
-
await storage.markNotificationsAsRead(
296
-
userDid,
297
-
params.seenAt ? new Date(params.seenAt) : undefined
298
-
);
299
-
res.json({ success: true });
311
+
312
+
const seenAtDate = new Date(params.seenAt);
313
+
314
+
// Mark notifications as read up to the seenAt timestamp
315
+
await storage.markNotificationsAsRead(userDid, seenAtDate);
316
+
317
+
// Update user's lastNotificationSeenAt preference for cross-device sync
318
+
const prefs = await storage.getUserPreferences(userDid);
319
+
if (prefs) {
320
+
await storage.updateUserPreferences(userDid, {
321
+
lastNotificationSeenAt: seenAtDate,
322
+
} as any);
323
+
} else {
324
+
// Create preferences if they don't exist
325
+
await storage.createUserPreferences({
326
+
userDid,
327
+
lastNotificationSeenAt: seenAtDate,
328
+
} as any);
329
+
}
330
+
331
+
// AT Protocol spec: return empty object on success
332
+
res.json({});
300
333
} catch (error) {
301
334
handleError(res, error, 'updateSeen');
302
335
}
···
305
338
/**
306
339
* Get notification preferences
307
340
* GET /xrpc/app.bsky.notification.getPreferences
341
+
*
342
+
* NOTE: Unlike app.bsky.actor.getPreferences (which retrieves general account preferences
343
+
* from the PDS), notification preferences are stored on the AppView because they control
344
+
* how THIS AppView delivers notifications to the user.
345
+
*
346
+
* Architectural rationale:
347
+
* - Notification preferences are service-level settings specific to each AppView instance
348
+
* - These settings control how the AppView processes and filters notifications
349
+
* - The AppView needs direct access to these preferences to deliver notifications
350
+
* - These are NOT portable user preferences that should travel between services
351
+
*
352
+
* Per ATProto architecture, this pattern is acknowledged as "not ideal" but is the
353
+
* current design for notification delivery services. The app.bsky.notification.*
354
+
* namespace is intentionally distinct from app.bsky.actor.* to reflect this difference.
308
355
*/
309
356
export async function getNotificationPreferences(
310
357
req: Request,
···
332
379
/**
333
380
* Update notification preferences
334
381
* POST /xrpc/app.bsky.notification.putPreferences
382
+
*
383
+
* NOTE: Unlike app.bsky.actor.putPreferences (which stores general account preferences
384
+
* on the PDS), notification preferences are stored on the AppView because they control
385
+
* how THIS AppView delivers notifications to the user.
386
+
*
387
+
* Architectural rationale:
388
+
* - Notification preferences are service-level settings specific to each AppView instance
389
+
* - These settings control how the AppView processes and filters notifications
390
+
* - The AppView needs direct access to these preferences to deliver notifications
391
+
* - These are NOT portable user preferences that should travel between services
392
+
*
393
+
* Per ATProto architecture, this pattern is acknowledged as "not ideal" but is the
394
+
* current design for notification delivery services. The app.bsky.notification.*
395
+
* namespace is intentionally distinct from app.bsky.actor.* to reflect this difference.
335
396
*/
336
397
export async function putNotificationPreferences(
337
398
req: Request,
···
376
437
/**
377
438
* Update notification preferences (V2)
378
439
* POST /xrpc/app.bsky.notification.putPreferencesV2
440
+
*
441
+
* ATProto-compliant notification preferences supporting all 13 notification categories
442
+
*
443
+
* NOTE: Unlike app.bsky.actor.putPreferences (which stores general account preferences
444
+
* on the PDS), notification preferences are stored on the AppView because they control
445
+
* how THIS AppView delivers notifications to the user.
446
+
*
447
+
* Architectural rationale:
448
+
* - Notification preferences are service-level settings specific to each AppView instance
449
+
* - These settings control how the AppView processes and filters notifications
450
+
* - The AppView needs direct access to these preferences to deliver notifications
451
+
* - These are NOT portable user preferences that should travel between services
452
+
*
453
+
* Per ATProto architecture, this pattern is acknowledged as "not ideal" but is the
454
+
* current design for notification delivery services. The app.bsky.notification.*
455
+
* namespace is intentionally distinct from app.bsky.actor.* to reflect this difference.
379
456
*/
380
457
export async function putNotificationPreferencesV2(
381
458
req: Request,
···
385
462
const params = putNotificationPreferencesV2Schema.parse(req.body);
386
463
const userDid = await requireAuthDid(req, res);
387
464
if (!userDid) return;
388
-
let prefs = await storage.getUserPreferences(userDid);
389
-
if (!prefs) {
390
-
prefs = await storage.createUserPreferences({
465
+
466
+
// Get existing preferences or create with defaults
467
+
let userPrefs = await storage.getUserPreferences(userDid);
468
+
if (!userPrefs) {
469
+
userPrefs = await storage.createUserPreferences({
391
470
userDid,
392
-
notificationPriority: !!params.priority,
393
-
} as {
394
-
userDid: string;
395
-
notificationPriority: boolean;
396
-
});
397
-
} else {
398
-
prefs = await storage.updateUserPreferences(userDid, {
399
-
notificationPriority:
400
-
params.priority ??
401
-
(prefs as { notificationPriority: boolean }).notificationPriority,
402
-
});
471
+
} as any);
403
472
}
473
+
474
+
// Get current notification preferences V2 (or use defaults)
475
+
const currentPrefs = (userPrefs as any).notificationPreferencesV2 || {
476
+
chat: { include: 'accepted', push: true },
477
+
follow: { list: true, push: true, include: 'all' },
478
+
like: { list: true, push: false, include: 'follows' },
479
+
mention: { list: true, push: true, include: 'all' },
480
+
reply: { list: true, push: true, include: 'all' },
481
+
repost: { list: true, push: false, include: 'follows' },
482
+
quote: { list: true, push: true, include: 'all' },
483
+
likeViaRepost: { list: false, push: false, include: 'all' },
484
+
repostViaRepost: { list: false, push: false, include: 'all' },
485
+
starterpackJoined: { list: true, push: false },
486
+
subscribedPost: { list: true, push: true },
487
+
unverified: { list: true, push: false },
488
+
verified: { list: true, push: true },
489
+
};
490
+
491
+
// Merge new preferences with existing (partial update)
492
+
const updatedPrefs = {
493
+
chat: params.chat ?? currentPrefs.chat,
494
+
follow: params.follow ?? currentPrefs.follow,
495
+
like: params.like ?? currentPrefs.like,
496
+
mention: params.mention ?? currentPrefs.mention,
497
+
reply: params.reply ?? currentPrefs.reply,
498
+
repost: params.repost ?? currentPrefs.repost,
499
+
quote: params.quote ?? currentPrefs.quote,
500
+
likeViaRepost: params.likeViaRepost ?? currentPrefs.likeViaRepost,
501
+
repostViaRepost: params.repostViaRepost ?? currentPrefs.repostViaRepost,
502
+
starterpackJoined:
503
+
params.starterpackJoined ?? currentPrefs.starterpackJoined,
504
+
subscribedPost: params.subscribedPost ?? currentPrefs.subscribedPost,
505
+
unverified: params.unverified ?? currentPrefs.unverified,
506
+
verified: params.verified ?? currentPrefs.verified,
507
+
};
508
+
509
+
// Update preferences in database
510
+
await storage.updateUserPreferences(userDid, {
511
+
notificationPreferencesV2: updatedPrefs,
512
+
} as any);
513
+
514
+
// Return preferences in ATProto format (object, not array)
404
515
res.json({
405
-
preferences: [
406
-
{
407
-
$type: 'app.bsky.notification.defs#preferences',
408
-
priority:
409
-
(prefs as { notificationPriority?: boolean })
410
-
?.notificationPriority ?? false,
411
-
},
412
-
],
516
+
preferences: {
517
+
$type: 'app.bsky.notification.defs#preferences',
518
+
...updatedPrefs,
519
+
},
413
520
});
414
521
} catch (error) {
415
522
handleError(res, error, 'putNotificationPreferencesV2');
···
417
524
}
418
525
419
526
/**
527
+
* Get notification preferences (V2)
528
+
* GET /xrpc/app.bsky.notification.getPreferencesV2
529
+
*
530
+
* Returns full notification preferences for all 13 categories
531
+
*
532
+
* NOTE: Unlike app.bsky.actor.getPreferences (which retrieves general account preferences
533
+
* from the PDS), notification preferences are stored on the AppView because they control
534
+
* how THIS AppView delivers notifications to the user.
535
+
*
536
+
* Architectural rationale:
537
+
* - Notification preferences are service-level settings specific to each AppView instance
538
+
* - These settings control how the AppView processes and filters notifications
539
+
* - The AppView needs direct access to these preferences to deliver notifications
540
+
* - These are NOT portable user preferences that should travel between services
541
+
*
542
+
* Per ATProto architecture, this pattern is acknowledged as "not ideal" but is the
543
+
* current design for notification delivery services. The app.bsky.notification.*
544
+
* namespace is intentionally distinct from app.bsky.actor.* to reflect this difference.
545
+
*/
546
+
export async function getNotificationPreferencesV2(
547
+
req: Request,
548
+
res: Response
549
+
): Promise<void> {
550
+
try {
551
+
const userDid = await requireAuthDid(req, res);
552
+
if (!userDid) return;
553
+
554
+
const userPrefs = await storage.getUserPreferences(userDid);
555
+
556
+
// Get notification preferences V2 (or use defaults)
557
+
const prefs = (userPrefs as any)?.notificationPreferencesV2 || {
558
+
chat: { include: 'accepted', push: true },
559
+
follow: { list: true, push: true, include: 'all' },
560
+
like: { list: true, push: false, include: 'follows' },
561
+
mention: { list: true, push: true, include: 'all' },
562
+
reply: { list: true, push: true, include: 'all' },
563
+
repost: { list: true, push: false, include: 'follows' },
564
+
quote: { list: true, push: true, include: 'all' },
565
+
likeViaRepost: { list: false, push: false, include: 'all' },
566
+
repostViaRepost: { list: false, push: false, include: 'all' },
567
+
starterpackJoined: { list: true, push: false },
568
+
subscribedPost: { list: true, push: true },
569
+
unverified: { list: true, push: false },
570
+
verified: { list: true, push: true },
571
+
};
572
+
573
+
res.json({
574
+
preferences: {
575
+
$type: 'app.bsky.notification.defs#preferences',
576
+
...prefs,
577
+
},
578
+
});
579
+
} catch (error) {
580
+
handleError(res, error, 'getNotificationPreferencesV2');
581
+
}
582
+
}
583
+
584
+
/**
420
585
* List activity subscriptions
421
586
* GET /xrpc/app.bsky.notification.listActivitySubscriptions
587
+
*
588
+
* Returns profile views of accounts the user has subscribed to for activity notifications
589
+
* Per ATProto spec: "Enumerate all accounts to which the requesting account is subscribed to receive notifications for."
422
590
*/
423
591
export async function listActivitySubscriptions(
424
592
req: Request,
425
593
res: Response
426
594
): Promise<void> {
427
595
try {
428
-
listActivitySubscriptionsSchema.parse(req.query);
596
+
const params = listActivitySubscriptionsSchema.parse(req.query);
429
597
const userDid = await requireAuthDid(req, res);
430
598
if (!userDid) return;
431
-
const subs = await storage.getUserPushSubscriptions(userDid);
599
+
600
+
// Get activity subscriptions (accounts user is subscribed to)
601
+
const result = await storage.getActivitySubscriptions(
602
+
userDid,
603
+
params.limit,
604
+
params.cursor
605
+
);
606
+
607
+
// Extract subject DIDs (accounts user is subscribed to)
608
+
const subjectDids = result.subscriptions.map((sub) => sub.subjectDid);
609
+
610
+
if (subjectDids.length === 0) {
611
+
res.json({
612
+
subscriptions: [],
613
+
cursor: result.cursor,
614
+
});
615
+
return;
616
+
}
617
+
618
+
// Get full profile views for subscribed accounts
619
+
const { xrpcApi } = await import('../../xrpc-api');
620
+
const profiles = await (xrpcApi as any)._getProfiles(subjectDids, req);
621
+
432
622
res.json({
433
-
subscriptions: (
434
-
subs as {
435
-
id: string;
436
-
platform: string;
437
-
appId?: string;
438
-
createdAt: Date;
439
-
updatedAt: Date;
440
-
}[]
441
-
).map((s) => ({
442
-
id: s.id,
443
-
platform: s.platform,
444
-
appId: s.appId,
445
-
createdAt: s.createdAt.toISOString(),
446
-
updatedAt: s.updatedAt.toISOString(),
447
-
})),
623
+
subscriptions: profiles,
624
+
cursor: result.cursor,
448
625
});
449
626
} catch (error) {
450
627
handleError(res, error, 'listActivitySubscriptions');
···
454
631
/**
455
632
* Update activity subscription
456
633
* POST /xrpc/app.bsky.notification.putActivitySubscription
634
+
*
635
+
* Creates or updates an activity subscription for a specific account
636
+
* Per ATProto spec: "Puts an activity subscription entry. The key should be omitted for creation and provided for updates."
457
637
*/
458
638
export async function putActivitySubscription(
459
639
req: Request,
···
463
643
const body = putActivitySubscriptionSchema.parse(req.body);
464
644
const userDid = await requireAuthDid(req, res);
465
645
if (!userDid) return;
466
-
// Upsert a synthetic web subscription for parity
467
-
await storage.createPushSubscription({
468
-
userDid,
469
-
platform: 'web',
470
-
token: `activity-${userDid}`,
471
-
endpoint: undefined,
472
-
keys: undefined,
473
-
appId: body.subject || undefined,
474
-
} as {
475
-
userDid: string;
476
-
platform: string;
477
-
token: string;
478
-
endpoint?: string;
479
-
keys?: string;
480
-
appId?: string;
646
+
647
+
// Validate that subject is a valid DID
648
+
if (!body.subject || !body.subject.startsWith('did:')) {
649
+
res.status(400).json({
650
+
error: 'InvalidRequest',
651
+
message: 'Subject must be a valid DID',
652
+
});
653
+
return;
654
+
}
655
+
656
+
// Check if subject user exists
657
+
const subjectUser = await storage.getUser(body.subject);
658
+
if (!subjectUser) {
659
+
res.status(404).json({
660
+
error: 'NotFound',
661
+
message: 'Subject account not found',
662
+
});
663
+
return;
664
+
}
665
+
666
+
// Create AT URI for the activity subscription
667
+
// Format: at://{subscriberDid}/app.bsky.notification.activitySubscription/{rkey}
668
+
// Use subjectDid as rkey for uniqueness
669
+
const rkey = body.subject.replace(/[^a-zA-Z0-9]/g, '-');
670
+
const uri = `at://${userDid}/app.bsky.notification.activitySubscription/${rkey}`;
671
+
672
+
// Generate a CID (in production, this would be calculated from the record)
673
+
const cid = `bafyrei${Buffer.from(`${uri}-${Date.now()}`).toString('base64url').slice(0, 44)}`;
674
+
675
+
// Check if subscription already exists
676
+
const existing = await storage.getActivitySubscription(uri);
677
+
678
+
if (existing) {
679
+
// Update existing subscription
680
+
// Note: For now, we don't have an update method, so we'll delete and recreate
681
+
await storage.deleteActivitySubscription(uri);
682
+
}
683
+
684
+
// Create/update the activity subscription
685
+
const subscription = await storage.createActivitySubscription({
686
+
uri,
687
+
cid,
688
+
subscriberDid: userDid,
689
+
subjectDid: body.subject,
690
+
priority: body.activitySubscription.post || body.activitySubscription.reply,
691
+
createdAt: new Date(),
692
+
});
693
+
694
+
// Get profile view for the subject
695
+
const { xrpcApi } = await import('../../xrpc-api');
696
+
const profiles = await (xrpcApi as any)._getProfiles([body.subject], req);
697
+
698
+
res.json({
699
+
subject: body.subject,
700
+
activitySubscription: {
701
+
post: body.activitySubscription.post,
702
+
reply: body.activitySubscription.reply,
703
+
},
704
+
// Return profile view for convenience
705
+
profile: profiles[0] || null,
481
706
});
482
-
res.json({ success: true });
483
707
} catch (error) {
484
708
handleError(res, error, 'putActivitySubscription');
485
709
}
+378
-40
server/services/xrpc/services/post-interaction-service.ts
+378
-40
server/services/xrpc/services/post-interaction-service.ts
···
7
7
import { storage } from '../../../storage';
8
8
import { handleError } from '../utils/error-handler';
9
9
import { maybeAvatar } from '../utils/serializers';
10
-
import { getAuthenticatedDid } from '../utils/auth-helpers';
10
+
import { getAuthenticatedDid, requireAuthDid } from '../utils/auth-helpers';
11
11
import {
12
12
getPostsSchema,
13
13
getLikesSchema,
···
69
69
params.limit,
70
70
params.cursor
71
71
);
72
+
73
+
if (likes.length === 0) {
74
+
return res.json({
75
+
uri: params.uri,
76
+
cid: params.cid,
77
+
cursor,
78
+
likes: [],
79
+
});
80
+
}
81
+
72
82
const userDids = likes.map((like) => like.userDid);
73
-
const users = await storage.getUsers(userDids);
83
+
84
+
// Batch fetch all required data
85
+
const [
86
+
users,
87
+
relationships,
88
+
listMutes,
89
+
listBlocks,
90
+
allLabels,
91
+
listCounts,
92
+
feedgenCounts,
93
+
starterPackCounts,
94
+
labelerStatuses,
95
+
] = await Promise.all([
96
+
storage.getUsers(userDids),
97
+
viewerDid
98
+
? storage.getRelationships(viewerDid, userDids)
99
+
: Promise.resolve(new Map()),
100
+
viewerDid
101
+
? storage.getListMutesForUsers(viewerDid, userDids)
102
+
: Promise.resolve(new Map()),
103
+
viewerDid
104
+
? storage.getListBlocksForUsers(viewerDid, userDids)
105
+
: Promise.resolve(new Map()),
106
+
storage.getLabelsForSubjects(userDids),
107
+
storage.getUsersListCounts(userDids),
108
+
storage.getUsersFeedGeneratorCounts(userDids),
109
+
Promise.all(
110
+
userDids.map(async (did) => {
111
+
const packs = await storage.getStarterPacksByCreator(did);
112
+
return { did, count: packs.starterPacks.length };
113
+
})
114
+
),
115
+
Promise.all(
116
+
userDids.map(async (did) => {
117
+
const labelers = await storage.getLabelerServicesByCreator(did);
118
+
return { did, isLabeler: labelers.length > 0 };
119
+
})
120
+
),
121
+
]);
122
+
74
123
const userMap = new Map(users.map((u) => [u.did, u]));
124
+
const starterPackCountMap = new Map(
125
+
starterPackCounts.map((sp) => [sp.did, sp.count])
126
+
);
127
+
const labelerStatusMap = new Map(
128
+
labelerStatuses.map((ls) => [ls.did, ls.isLabeler])
129
+
);
75
130
76
-
const relationships = viewerDid
77
-
? await storage.getRelationships(viewerDid, userDids)
78
-
: new Map();
131
+
// Fetch list data for mutes/blocks
132
+
const listUris = new Set<string>();
133
+
listMutes.forEach((mute) => listUris.add(mute.listUri));
134
+
listBlocks.forEach((block) => listUris.add(block.listUri));
135
+
136
+
const listData = new Map<string, any>();
137
+
if (listUris.size > 0) {
138
+
const lists = await Promise.all(
139
+
Array.from(listUris).map((uri) => storage.getList(uri))
140
+
);
141
+
lists.forEach((list, index) => {
142
+
if (list) {
143
+
listData.set(Array.from(listUris)[index], list);
144
+
}
145
+
});
146
+
}
147
+
148
+
// Group labels by subject
149
+
const labelsBySubject = new Map<string, any[]>();
150
+
allLabels.forEach((label) => {
151
+
if (!labelsBySubject.has(label.subject)) {
152
+
labelsBySubject.set(label.subject, []);
153
+
}
154
+
labelsBySubject.get(label.subject)!.push(label);
155
+
});
79
156
80
157
res.json({
81
158
uri: params.uri,
82
159
cid: params.cid,
83
-
cursor: cursor,
160
+
cursor,
84
161
likes: likes
85
162
.map((like) => {
86
163
const user = userMap.get(like.userDid);
···
89
166
const viewerState = viewerDid
90
167
? relationships.get(like.userDid)
91
168
: null;
92
-
const viewer: any = {
93
-
muted: viewerState ? !!viewerState.muting : false,
94
-
blockedBy: viewerState?.blockedBy || false,
169
+
const mutingList = viewerDid ? listMutes.get(like.userDid) : null;
170
+
const blockingList = viewerDid ? listBlocks.get(like.userDid) : null;
171
+
172
+
// Build viewer state
173
+
const viewer: any = {};
174
+
if (viewerDid) {
175
+
viewer.muted = !!viewerState?.muting || !!mutingList;
176
+
if (mutingList) {
177
+
const list = listData.get(mutingList.listUri);
178
+
if (list) {
179
+
viewer.mutedByList = {
180
+
$type: 'app.bsky.graph.defs#listViewBasic',
181
+
uri: list.uri,
182
+
name: list.name,
183
+
purpose: list.purpose,
184
+
};
185
+
}
186
+
}
187
+
viewer.blockedBy = viewerState?.blockedBy || false;
188
+
if (blockingList) {
189
+
const list = listData.get(blockingList.listUri);
190
+
if (list) {
191
+
viewer.blocking = blockingList.uri;
192
+
viewer.blockingByList = {
193
+
$type: 'app.bsky.graph.defs#listViewBasic',
194
+
uri: list.uri,
195
+
name: list.name,
196
+
purpose: list.purpose,
197
+
};
198
+
}
199
+
} else if (viewerState?.blocking) {
200
+
viewer.blocking = viewerState.blocking;
201
+
}
202
+
if (viewerState?.following) viewer.following = viewerState.following;
203
+
if (viewerState?.followedBy)
204
+
viewer.followedBy = viewerState.followedBy;
205
+
}
206
+
207
+
// Build full profileView
208
+
const profileView: any = {
209
+
$type: 'app.bsky.actor.defs#profileView',
210
+
did: user.did,
211
+
handle: user.handle,
212
+
displayName: user.displayName || user.handle,
95
213
};
96
-
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
97
-
if (viewerState?.following) viewer.following = viewerState.following;
98
-
if (viewerState?.followedBy)
99
-
viewer.followedBy = viewerState.followedBy;
214
+
215
+
// Add optional fields
216
+
if (user.description) {
217
+
profileView.description = user.description;
218
+
}
219
+
220
+
const avatar = maybeAvatar(user.avatarUrl, user.did, req);
221
+
if (avatar.avatar) {
222
+
profileView.avatar = avatar.avatar;
223
+
}
224
+
225
+
// Add associated counts
226
+
profileView.associated = {
227
+
$type: 'app.bsky.actor.defs#profileAssociated',
228
+
lists: listCounts.get(like.userDid) || 0,
229
+
feedgens: feedgenCounts.get(like.userDid) || 0,
230
+
starterPacks: starterPackCountMap.get(like.userDid) || 0,
231
+
labeler: labelerStatusMap.get(like.userDid) || false,
232
+
};
233
+
234
+
// Add indexedAt
235
+
if (user.indexedAt) {
236
+
profileView.indexedAt = user.indexedAt.toISOString();
237
+
}
238
+
239
+
// Add createdAt
240
+
if (user.createdAt) {
241
+
profileView.createdAt = user.createdAt.toISOString();
242
+
}
243
+
244
+
// Add viewer state
245
+
if (Object.keys(viewer).length > 0) {
246
+
profileView.viewer = viewer;
247
+
}
248
+
249
+
// Add labels
250
+
const labels = labelsBySubject.get(like.userDid) || [];
251
+
if (labels.length > 0) {
252
+
profileView.labels = labels.map((l: any) => ({
253
+
src: l.src,
254
+
uri: l.uri,
255
+
val: l.val,
256
+
neg: l.neg,
257
+
cts: l.createdAt.toISOString(),
258
+
}));
259
+
}
100
260
101
261
return {
102
-
actor: {
103
-
did: user.did,
104
-
handle: user.handle,
105
-
displayName: user.displayName || user.handle,
106
-
...maybeAvatar(user.avatarUrl, user.did, req),
107
-
viewer,
108
-
},
262
+
indexedAt: like.indexedAt.toISOString(),
109
263
createdAt: like.createdAt.toISOString(),
110
-
indexedAt: like.indexedAt.toISOString(),
264
+
actor: profileView,
111
265
};
112
266
})
113
267
.filter(Boolean),
···
134
288
params.limit,
135
289
params.cursor
136
290
);
291
+
292
+
if (reposts.length === 0) {
293
+
return res.json({
294
+
uri: params.uri,
295
+
cid: params.cid,
296
+
cursor,
297
+
repostedBy: [],
298
+
});
299
+
}
300
+
137
301
const userDids = reposts.map((repost) => repost.userDid);
138
-
const users = await storage.getUsers(userDids);
302
+
303
+
// Batch fetch all required data
304
+
const [
305
+
users,
306
+
relationships,
307
+
listMutes,
308
+
listBlocks,
309
+
allLabels,
310
+
listCounts,
311
+
feedgenCounts,
312
+
starterPackCounts,
313
+
labelerStatuses,
314
+
] = await Promise.all([
315
+
storage.getUsers(userDids),
316
+
viewerDid
317
+
? storage.getRelationships(viewerDid, userDids)
318
+
: Promise.resolve(new Map()),
319
+
viewerDid
320
+
? storage.getListMutesForUsers(viewerDid, userDids)
321
+
: Promise.resolve(new Map()),
322
+
viewerDid
323
+
? storage.getListBlocksForUsers(viewerDid, userDids)
324
+
: Promise.resolve(new Map()),
325
+
storage.getLabelsForSubjects(userDids),
326
+
storage.getUsersListCounts(userDids),
327
+
storage.getUsersFeedGeneratorCounts(userDids),
328
+
Promise.all(
329
+
userDids.map(async (did) => {
330
+
const packs = await storage.getStarterPacksByCreator(did);
331
+
return { did, count: packs.starterPacks.length };
332
+
})
333
+
),
334
+
Promise.all(
335
+
userDids.map(async (did) => {
336
+
const labelers = await storage.getLabelerServicesByCreator(did);
337
+
return { did, isLabeler: labelers.length > 0 };
338
+
})
339
+
),
340
+
]);
341
+
139
342
const userMap = new Map(users.map((u) => [u.did, u]));
343
+
const starterPackCountMap = new Map(
344
+
starterPackCounts.map((sp) => [sp.did, sp.count])
345
+
);
346
+
const labelerStatusMap = new Map(
347
+
labelerStatuses.map((ls) => [ls.did, ls.isLabeler])
348
+
);
140
349
141
-
const relationships = viewerDid
142
-
? await storage.getRelationships(viewerDid, userDids)
143
-
: new Map();
350
+
// Fetch list data for mutes/blocks
351
+
const listUris = new Set<string>();
352
+
listMutes.forEach((mute) => listUris.add(mute.listUri));
353
+
listBlocks.forEach((block) => listUris.add(block.listUri));
354
+
355
+
const listData = new Map<string, any>();
356
+
if (listUris.size > 0) {
357
+
const lists = await Promise.all(
358
+
Array.from(listUris).map((uri) => storage.getList(uri))
359
+
);
360
+
lists.forEach((list, index) => {
361
+
if (list) {
362
+
listData.set(Array.from(listUris)[index], list);
363
+
}
364
+
});
365
+
}
366
+
367
+
// Group labels by subject
368
+
const labelsBySubject = new Map<string, any[]>();
369
+
allLabels.forEach((label) => {
370
+
if (!labelsBySubject.has(label.subject)) {
371
+
labelsBySubject.set(label.subject, []);
372
+
}
373
+
labelsBySubject.get(label.subject)!.push(label);
374
+
});
144
375
145
376
res.json({
146
377
uri: params.uri,
147
378
cid: params.cid,
148
-
cursor: cursor,
379
+
cursor,
149
380
repostedBy: reposts
150
381
.map((repost) => {
151
382
const user = userMap.get(repost.userDid);
···
154
385
const viewerState = viewerDid
155
386
? relationships.get(repost.userDid)
156
387
: null;
157
-
const viewer: any = {
158
-
muted: viewerState ? !!viewerState.muting : false,
159
-
blockedBy: viewerState?.blockedBy || false,
160
-
};
161
-
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
162
-
if (viewerState?.following) viewer.following = viewerState.following;
163
-
if (viewerState?.followedBy)
164
-
viewer.followedBy = viewerState.followedBy;
388
+
const mutingList = viewerDid ? listMutes.get(repost.userDid) : null;
389
+
const blockingList = viewerDid ? listBlocks.get(repost.userDid) : null;
390
+
391
+
// Build viewer state
392
+
const viewer: any = {};
393
+
if (viewerDid) {
394
+
viewer.muted = !!viewerState?.muting || !!mutingList;
395
+
if (mutingList) {
396
+
const list = listData.get(mutingList.listUri);
397
+
if (list) {
398
+
viewer.mutedByList = {
399
+
$type: 'app.bsky.graph.defs#listViewBasic',
400
+
uri: list.uri,
401
+
name: list.name,
402
+
purpose: list.purpose,
403
+
};
404
+
}
405
+
}
406
+
viewer.blockedBy = viewerState?.blockedBy || false;
407
+
if (blockingList) {
408
+
const list = listData.get(blockingList.listUri);
409
+
if (list) {
410
+
viewer.blocking = blockingList.uri;
411
+
viewer.blockingByList = {
412
+
$type: 'app.bsky.graph.defs#listViewBasic',
413
+
uri: list.uri,
414
+
name: list.name,
415
+
purpose: list.purpose,
416
+
};
417
+
}
418
+
} else if (viewerState?.blocking) {
419
+
viewer.blocking = viewerState.blocking;
420
+
}
421
+
if (viewerState?.following) viewer.following = viewerState.following;
422
+
if (viewerState?.followedBy)
423
+
viewer.followedBy = viewerState.followedBy;
424
+
}
165
425
166
-
return {
426
+
// Build full profileView
427
+
const profileView: any = {
428
+
$type: 'app.bsky.actor.defs#profileView',
167
429
did: user.did,
168
430
handle: user.handle,
169
431
displayName: user.displayName || user.handle,
170
-
...maybeAvatar(user.avatarUrl, user.did, req),
171
-
viewer,
172
-
indexedAt: repost.indexedAt.toISOString(),
173
432
};
433
+
434
+
// Add optional fields
435
+
if (user.description) {
436
+
profileView.description = user.description;
437
+
}
438
+
439
+
const avatar = maybeAvatar(user.avatarUrl, user.did, req);
440
+
if (avatar.avatar) {
441
+
profileView.avatar = avatar.avatar;
442
+
}
443
+
444
+
// Add associated counts
445
+
profileView.associated = {
446
+
$type: 'app.bsky.actor.defs#profileAssociated',
447
+
lists: listCounts.get(repost.userDid) || 0,
448
+
feedgens: feedgenCounts.get(repost.userDid) || 0,
449
+
starterPacks: starterPackCountMap.get(repost.userDid) || 0,
450
+
labeler: labelerStatusMap.get(repost.userDid) || false,
451
+
};
452
+
453
+
// Add indexedAt (profile indexed time, not repost time)
454
+
if (user.indexedAt) {
455
+
profileView.indexedAt = user.indexedAt.toISOString();
456
+
}
457
+
458
+
// Add createdAt
459
+
if (user.createdAt) {
460
+
profileView.createdAt = user.createdAt.toISOString();
461
+
}
462
+
463
+
// Add viewer state
464
+
if (Object.keys(viewer).length > 0) {
465
+
profileView.viewer = viewer;
466
+
}
467
+
468
+
// Add labels
469
+
const labels = labelsBySubject.get(repost.userDid) || [];
470
+
if (labels.length > 0) {
471
+
profileView.labels = labels.map((l: any) => ({
472
+
src: l.src,
473
+
uri: l.uri,
474
+
val: l.val,
475
+
neg: l.neg,
476
+
cts: l.createdAt.toISOString(),
477
+
}));
478
+
}
479
+
480
+
return profileView;
174
481
})
175
482
.filter(Boolean),
176
483
});
···
212
519
/**
213
520
* Get posts liked by an actor
214
521
* GET /xrpc/app.bsky.feed.getActorLikes
522
+
*
523
+
* IMPORTANT: ATProto spec requires authentication and actor must be the requesting account
215
524
*/
216
525
export async function getActorLikes(
217
526
req: Request,
···
219
528
): Promise<void> {
220
529
try {
221
530
const params = getActorLikesSchema.parse(req.query);
222
-
const viewerDid = await getAuthenticatedDid(req);
531
+
532
+
// Require authentication (per ATProto spec)
533
+
const viewerDid = await requireAuthDid(req, res);
534
+
if (!viewerDid) return;
223
535
536
+
// Resolve actor to DID
224
537
let actorDid = params.actor;
225
538
if (!params.actor.startsWith('did:')) {
226
539
const user = await storage.getUserByHandle(params.actor);
···
230
543
actorDid = user.did;
231
544
}
232
545
546
+
// Check for block relationships
547
+
const relationship = await storage.getRelationship(viewerDid, actorDid);
548
+
if (relationship) {
549
+
if (relationship.blocking) {
550
+
return res.status(400).json({
551
+
error: 'BlockedActor',
552
+
message: 'Requesting user has blocked the target actor',
553
+
});
554
+
}
555
+
if (relationship.blockedBy) {
556
+
return res.status(400).json({
557
+
error: 'BlockedByActor',
558
+
message: 'Target actor has blocked the requesting user',
559
+
});
560
+
}
561
+
}
562
+
563
+
// Authorization check: actor must be the requesting account
564
+
if (actorDid !== viewerDid) {
565
+
return res.status(403).json({
566
+
error: 'Forbidden',
567
+
message: 'Actor must be the requesting account',
568
+
});
569
+
}
570
+
233
571
console.log(
234
572
`[getActorLikes] Fetching likes for ${actorDid}, cursor: ${params.cursor}, limit: ${params.limit}`
235
573
);
···
269
607
270
608
const serialized = await (xrpcApi as any).serializePosts(
271
609
postsWithLikes.map(({ post }) => post),
272
-
viewerDid || undefined,
610
+
viewerDid,
273
611
req
274
612
);
275
613
+14
-8
server/services/xrpc/services/preferences-service.ts
+14
-8
server/services/xrpc/services/preferences-service.ts
···
29
29
const userDid = await requireAuthDid(req, res);
30
30
if (!userDid) return;
31
31
32
-
console.log(
33
-
`[PREFERENCES] GET request for ${userDid} - directing to PDS`
34
-
);
32
+
// Use debug-level logging to reduce log volume
33
+
if (process.env.DEBUG_LOGGING === 'true') {
34
+
console.log(
35
+
`[PREFERENCES] GET request for ${userDid} - directing to PDS`
36
+
);
37
+
}
35
38
36
39
// Get user's PDS endpoint to include in error message
37
40
const pdsEndpoint = await getUserPdsEndpoint(userDid);
38
41
39
-
return res.status(501).json({
42
+
res.status(501).json({
40
43
error: 'NotImplemented',
41
44
message: 'Preferences must be fetched directly from your PDS, not through the AppView. ' +
42
45
'Per ATProto architecture, preferences are private user data stored on the PDS. ' +
···
65
68
const userDid = await requireAuthDid(req, res);
66
69
if (!userDid) return;
67
70
68
-
console.log(
69
-
`[PREFERENCES] PUT request for ${userDid} - directing to PDS`
70
-
);
71
+
// Use debug-level logging to reduce log volume
72
+
if (process.env.DEBUG_LOGGING === 'true') {
73
+
console.log(
74
+
`[PREFERENCES] PUT request for ${userDid} - directing to PDS`
75
+
);
76
+
}
71
77
72
78
// Get user's PDS endpoint to include in error message
73
79
const pdsEndpoint = await getUserPdsEndpoint(userDid);
74
80
75
-
return res.status(501).json({
81
+
res.status(501).json({
76
82
error: 'NotImplemented',
77
83
message: 'Preferences must be updated directly on your PDS, not through the AppView. ' +
78
84
'Per ATProto architecture, preferences are private user data stored on the PDS. ' +
+33
-8
server/services/xrpc/services/push-notification-service.ts
+33
-8
server/services/xrpc/services/push-notification-service.ts
···
19
19
const userDid = await requireAuthDid(req, res);
20
20
if (!userDid) return;
21
21
22
+
// Validate serviceDid matches this AppView's DID (if configured)
23
+
// For now, we accept any serviceDid as this AppView handles push for all users
24
+
// In production, you might want to validate: params.serviceDid === process.env.SERVICE_DID
25
+
22
26
// Create or update push subscription
23
-
const subscription = await storage.createPushSubscription({
27
+
await storage.createPushSubscription({
24
28
userDid,
25
29
platform: params.platform,
26
30
token: params.token,
27
31
appId: params.appId,
32
+
endpoint: params.endpoint,
33
+
keys: params.keys ? JSON.stringify(params.keys) : undefined,
28
34
} as {
29
35
userDid: string;
30
36
platform: string;
31
37
token: string;
32
38
appId?: string;
39
+
endpoint?: string;
40
+
keys?: string;
33
41
});
34
42
35
-
res.json({
36
-
id: (subscription as { id: string }).id,
37
-
platform: (subscription as { platform: string }).platform,
38
-
createdAt: (subscription as { createdAt: Date }).createdAt.toISOString(),
39
-
});
43
+
// AT Protocol spec: return empty object on success
44
+
res.json({});
40
45
} catch (error) {
41
46
handleError(res, error, 'registerPush');
42
47
}
···
54
59
const params = unregisterPushSchema.parse(req.body);
55
60
const userDid = await requireAuthDid(req, res);
56
61
if (!userDid) return;
57
-
await storage.deletePushSubscriptionByToken(params.token);
58
-
res.json({ success: true });
62
+
63
+
// Validate serviceDid matches this AppView's DID (if configured)
64
+
const serviceDid = process.env.SERVICE_DID;
65
+
if (serviceDid && params.serviceDid !== serviceDid) {
66
+
res.status(400).json({
67
+
error: 'InvalidRequest',
68
+
message: 'serviceDid does not match this service',
69
+
});
70
+
return;
71
+
}
72
+
73
+
// Delete push subscription with full validation
74
+
// Ensures user can only unregister their own devices
75
+
await storage.deletePushSubscriptionByDetails(
76
+
userDid,
77
+
params.token,
78
+
params.platform,
79
+
params.appId
80
+
);
81
+
82
+
// AT Protocol spec: return empty object on success
83
+
res.json({});
59
84
} catch (error) {
60
85
handleError(res, error, 'unregisterPush');
61
86
}
+128
-17
server/services/xrpc/services/search-service.ts
+128
-17
server/services/xrpc/services/search-service.ts
···
43
43
export async function searchPosts(req: Request, res: Response): Promise<void> {
44
44
try {
45
45
const params = searchPostsSchema.parse(req.query);
46
+
47
+
// Validate query is not empty/whitespace only
48
+
if (!params.q.trim()) {
49
+
res.status(400).json({
50
+
error: 'InvalidRequest',
51
+
message: 'query string cannot be empty',
52
+
});
53
+
return;
54
+
}
55
+
46
56
const viewerDid = await getAuthenticatedDid(req);
47
57
48
58
const { posts, cursor } = await searchService.searchPosts(
49
59
params.q,
50
-
params.limit,
51
-
params.cursor,
60
+
{
61
+
limit: params.limit,
62
+
cursor: params.cursor,
63
+
sort: params.sort || 'top',
64
+
since: params.since,
65
+
until: params.until,
66
+
mentions: params.mentions,
67
+
author: params.author,
68
+
lang: params.lang,
69
+
domain: params.domain,
70
+
url: params.url,
71
+
tag: params.tag,
72
+
},
52
73
viewerDid || undefined
53
74
);
54
75
···
69
90
const params = searchActorsSchema.parse(req.query);
70
91
const term = (params.q || params.term)!;
71
92
93
+
// Validate query is not empty/whitespace only
94
+
if (!term.trim()) {
95
+
res.status(400).json({
96
+
error: 'InvalidRequest',
97
+
message: 'query string cannot be empty',
98
+
});
99
+
return;
100
+
}
101
+
102
+
const viewerDid = await getAuthenticatedDid(req);
103
+
72
104
const { actors, cursor } = await searchService.searchActors(
73
105
term,
74
106
params.limit,
···
81
113
const users: UserModel[] = await storage.getUsers(dids);
82
114
const userMap = new Map(users.map((u) => [u.did, u]));
83
115
84
-
const results = actorResults
85
-
.map((a) => {
86
-
const u = userMap.get(a.did);
87
-
if (!u) return null;
116
+
// Get viewer relationships if authenticated
117
+
const relationships = viewerDid
118
+
? await storage.getRelationships(viewerDid, dids)
119
+
: new Map();
120
+
121
+
const results = actorResults.map((a) => {
122
+
const u = userMap.get(a.did);
123
+
124
+
// If user profile not found, create minimal profile with DID
125
+
if (!u) {
88
126
return {
89
-
did: u.did,
90
-
handle: u.handle,
91
-
displayName: u.displayName,
92
-
...maybeAvatar(u.avatarUrl, u.did, req),
127
+
$type: 'app.bsky.actor.defs#profileView',
128
+
did: a.did,
129
+
handle: a.did, // Use DID as fallback
130
+
displayName: a.did,
131
+
viewer: {
132
+
muted: false,
133
+
blockedBy: false,
134
+
},
93
135
};
94
-
})
95
-
.filter(Boolean);
136
+
}
137
+
138
+
const viewerState = viewerDid ? relationships.get(u.did) : null;
139
+
const viewer: {
140
+
muted: boolean;
141
+
blockedBy: boolean;
142
+
blocking?: string;
143
+
following?: string;
144
+
followedBy?: string;
145
+
} = {
146
+
muted: viewerState ? !!viewerState.muting : false,
147
+
blockedBy: viewerState?.blockedBy || false,
148
+
};
149
+
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
150
+
if (viewerState?.following) viewer.following = viewerState.following;
151
+
if (viewerState?.followedBy) viewer.followedBy = viewerState.followedBy;
152
+
153
+
return {
154
+
$type: 'app.bsky.actor.defs#profileView',
155
+
did: u.did,
156
+
handle: u.handle,
157
+
displayName: u.displayName,
158
+
description: u.description,
159
+
...maybeAvatar(u.avatarUrl, u.did, req),
160
+
indexedAt: u.indexedAt?.toISOString(),
161
+
viewer,
162
+
};
163
+
});
96
164
97
165
res.json({ actors: results, cursor });
98
166
} catch (error) {
···
110
178
): Promise<void> {
111
179
try {
112
180
const params = searchActorsTypeaheadSchema.parse(req.query);
113
-
const results = await searchService.searchActorsTypeahead(
114
-
(params.q || params.term)!,
115
-
params.limit
116
-
);
181
+
const term = (params.q || params.term)!;
182
+
183
+
// Validate query is not empty/whitespace only
184
+
if (!term.trim()) {
185
+
res.status(400).json({
186
+
error: 'InvalidRequest',
187
+
message: 'query string cannot be empty',
188
+
});
189
+
return;
190
+
}
117
191
118
-
res.json({ actors: results });
192
+
const viewerDid = await getAuthenticatedDid(req);
193
+
194
+
const results = await searchService.searchActorsTypeahead(term, params.limit);
195
+
196
+
// Get viewer relationships if authenticated
197
+
const dids = results.map((r) => r.did);
198
+
const relationships = viewerDid
199
+
? await storage.getRelationships(viewerDid, dids)
200
+
: new Map();
201
+
202
+
// Transform to proper profileViewBasic
203
+
const actors = results.map((actor) => {
204
+
const viewerState = viewerDid ? relationships.get(actor.did) : null;
205
+
const viewer: {
206
+
muted: boolean;
207
+
blockedBy: boolean;
208
+
blocking?: string;
209
+
following?: string;
210
+
followedBy?: string;
211
+
} = {
212
+
muted: viewerState ? !!viewerState.muting : false,
213
+
blockedBy: viewerState?.blockedBy || false,
214
+
};
215
+
if (viewerState?.blocking) viewer.blocking = viewerState.blocking;
216
+
if (viewerState?.following) viewer.following = viewerState.following;
217
+
if (viewerState?.followedBy) viewer.followedBy = viewerState.followedBy;
218
+
219
+
return {
220
+
$type: 'app.bsky.actor.defs#profileViewBasic',
221
+
did: actor.did,
222
+
handle: actor.handle,
223
+
displayName: actor.displayName,
224
+
...maybeAvatar(actor.avatarUrl, actor.did, req),
225
+
viewer,
226
+
};
227
+
});
228
+
229
+
res.json({ actors });
119
230
} catch (error) {
120
231
handleError(res, error, 'searchActorsTypeahead');
121
232
}
+397
-211
server/services/xrpc/services/starter-pack-service.ts
+397
-211
server/services/xrpc/services/starter-pack-service.ts
···
6
6
import type { Request, Response } from 'express';
7
7
import { storage } from '../../../storage';
8
8
import { handleError } from '../utils/error-handler';
9
-
import { resolveActor } from '../utils/resolvers';
9
+
import { resolveActor, requireAuthDid } from '../utils/resolvers';
10
10
import { transformBlobToCdnUrl } from '../utils/serializers';
11
11
import {
12
12
getStarterPackSchema,
13
13
getStarterPacksSchema,
14
14
getActorStarterPacksSchema,
15
15
getStarterPacksWithMembershipSchema,
16
+
getOnboardingSuggestedStarterPacksSchema,
16
17
} from '../schemas';
18
+
import { xrpcApi } from '../../xrpc-api';
17
19
18
20
/**
19
21
* Get a single starter pack by URI
···
43
45
indexedAt: Date;
44
46
};
45
47
46
-
// Creator profile should be available from firehose events
47
-
const creator = await storage.getUser(packData.creatorDid);
48
+
// Use _getProfiles for complete creator profileViewBasic
49
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(
50
+
[packData.creatorDid],
51
+
req
52
+
);
48
53
49
-
if (!creator || !(creator as { handle?: string }).handle) {
54
+
if (creatorProfiles.length === 0) {
50
55
return res.status(500).json({
51
56
error: 'Starter pack creator profile not available',
52
57
message: 'Unable to load creator information',
53
58
});
54
59
}
55
60
56
-
const creatorData = creator as {
57
-
handle: string;
58
-
displayName?: string;
59
-
avatarUrl?: string;
60
-
did: string;
61
-
};
62
-
63
-
let list = null;
64
-
if (packData.listUri) {
65
-
list = await storage.getList(packData.listUri);
66
-
}
67
-
68
-
const creatorView: {
69
-
did: string;
70
-
handle: string;
71
-
displayName?: string;
72
-
avatar?: string;
73
-
} = {
74
-
did: packData.creatorDid,
75
-
handle: creatorData.handle,
76
-
};
77
-
if (creatorData.displayName)
78
-
creatorView.displayName = creatorData.displayName;
79
-
if (creatorData.avatarUrl) {
80
-
const avatarUrl = transformBlobToCdnUrl(
81
-
creatorData.avatarUrl,
82
-
creatorData.did,
83
-
'avatar',
84
-
req
85
-
);
86
-
if (
87
-
avatarUrl &&
88
-
typeof avatarUrl === 'string' &&
89
-
avatarUrl.trim() !== ''
90
-
) {
91
-
creatorView.avatar = avatarUrl;
92
-
}
93
-
}
94
-
95
-
const record: {
96
-
name: string;
97
-
list?: string;
98
-
feeds?: unknown[];
99
-
createdAt: string;
100
-
description?: string;
101
-
} = {
102
-
name: packData.name,
103
-
list: packData.listUri,
104
-
feeds: packData.feeds,
105
-
createdAt: packData.createdAt.toISOString(),
106
-
};
107
-
if (packData.description) record.description = packData.description;
108
-
109
-
const starterPackView: {
110
-
uri: string;
111
-
cid: string;
112
-
record: typeof record;
113
-
creator: typeof creatorView;
114
-
indexedAt: string;
115
-
list?: { uri: string; cid: string; name: string; purpose: string };
116
-
} = {
61
+
// Build starter pack view
62
+
const starterPackView: any = {
117
63
uri: packData.uri,
118
64
cid: packData.cid,
119
-
record,
120
-
creator: creatorView,
65
+
record: {
66
+
name: packData.name,
67
+
list: packData.listUri,
68
+
feeds: packData.feeds,
69
+
createdAt: packData.createdAt.toISOString(),
70
+
...(packData.description && { description: packData.description }),
71
+
},
72
+
creator: creatorProfiles[0], // Full profileViewBasic
121
73
indexedAt: packData.indexedAt.toISOString(),
122
74
};
123
75
124
-
if (list) {
125
-
const listData = list as {
126
-
uri: string;
127
-
cid: string;
128
-
name: string;
129
-
purpose: string;
130
-
};
131
-
starterPackView.list = {
132
-
uri: listData.uri,
133
-
cid: listData.cid,
134
-
name: listData.name,
135
-
purpose: listData.purpose,
136
-
};
76
+
// Add optional list info if exists
77
+
if (packData.listUri) {
78
+
const list = await storage.getList(packData.listUri);
79
+
if (list) {
80
+
starterPackView.list = {
81
+
uri: list.uri,
82
+
cid: list.cid,
83
+
name: list.name,
84
+
purpose: list.purpose,
85
+
};
86
+
}
137
87
}
138
88
139
89
res.json({ starterPack: starterPackView });
···
153
103
try {
154
104
const params = getStarterPacksSchema.parse(req.query);
155
105
156
-
const packs = await storage.getStarterPacks(params.uris);
157
-
158
-
// Creator profiles should be available from firehose events
159
-
const views = await Promise.all(
160
-
(
161
-
packs as {
162
-
creatorDid: string;
163
-
listUri?: string;
164
-
name: string;
165
-
description?: string;
166
-
feeds?: unknown[];
167
-
uri: string;
168
-
cid: string;
169
-
createdAt: Date;
170
-
indexedAt: Date;
171
-
}[]
172
-
).map(async (pack) => {
173
-
const creator = await storage.getUser(pack.creatorDid);
106
+
const packs = await storage.getStarterPacks(params.uris) as {
107
+
creatorDid: string;
108
+
listUri?: string;
109
+
name: string;
110
+
description?: string;
111
+
feeds?: unknown[];
112
+
uri: string;
113
+
cid: string;
114
+
createdAt: Date;
115
+
indexedAt: Date;
116
+
}[];
174
117
175
-
// Skip packs from creators without valid handles
176
-
if (!creator || !(creator as { handle?: string }).handle) {
177
-
console.warn(
178
-
`[XRPC] Skipping starter pack ${pack.uri} - creator ${pack.creatorDid} has no handle`
179
-
);
180
-
return null;
181
-
}
118
+
if (packs.length === 0) {
119
+
return res.json({ starterPacks: [] });
120
+
}
182
121
183
-
const creatorData = creator as {
184
-
handle: string;
185
-
displayName?: string;
186
-
avatarUrl?: string;
187
-
did: string;
188
-
};
122
+
// Batch fetch all creator profiles
123
+
const creatorDids = [...new Set(packs.map(p => p.creatorDid))];
124
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
189
125
190
-
let list = null;
191
-
if (pack.listUri) {
192
-
list = await storage.getList(pack.listUri);
193
-
}
126
+
// Create map for quick lookup
127
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
194
128
195
-
const creatorView: {
196
-
did: string;
197
-
handle: string;
198
-
displayName?: string;
199
-
avatar?: string;
200
-
} = {
201
-
did: pack.creatorDid,
202
-
handle: creatorData.handle,
203
-
};
204
-
if (creatorData.displayName)
205
-
creatorView.displayName = creatorData.displayName;
206
-
if (creatorData.avatarUrl) {
207
-
const avatarUri = transformBlobToCdnUrl(
208
-
creatorData.avatarUrl,
209
-
creatorData.did,
210
-
'avatar',
211
-
req
129
+
// Build views with complete creator profiles
130
+
const views = await Promise.all(
131
+
packs.map(async (pack) => {
132
+
const creatorProfile = profileMap.get(pack.creatorDid);
133
+
if (!creatorProfile) {
134
+
console.warn(
135
+
`[XRPC] Skipping starter pack ${pack.uri} - creator ${pack.creatorDid} profile not found`
212
136
);
213
-
if (
214
-
avatarUri &&
215
-
typeof avatarUri === 'string' &&
216
-
avatarUri.trim() !== ''
217
-
) {
218
-
creatorView.avatar = avatarUri;
219
-
}
137
+
return null;
220
138
}
221
139
222
-
const record: {
223
-
name: string;
224
-
list?: string;
225
-
feeds?: unknown[];
226
-
createdAt: string;
227
-
description?: string;
228
-
} = {
229
-
name: pack.name,
230
-
list: pack.listUri,
231
-
feeds: pack.feeds,
232
-
createdAt: pack.createdAt.toISOString(),
233
-
};
234
-
if (pack.description) record.description = pack.description;
235
-
236
-
const view: {
237
-
uri: string;
238
-
cid: string;
239
-
record: typeof record;
240
-
creator: typeof creatorView;
241
-
indexedAt: string;
242
-
list?: { uri: string; cid: string; name: string; purpose: string };
243
-
} = {
140
+
const view: any = {
244
141
uri: pack.uri,
245
142
cid: pack.cid,
246
-
record,
247
-
creator: creatorView,
143
+
record: {
144
+
name: pack.name,
145
+
list: pack.listUri,
146
+
feeds: pack.feeds,
147
+
createdAt: pack.createdAt.toISOString(),
148
+
...(pack.description && { description: pack.description }),
149
+
},
150
+
creator: creatorProfile, // Full profileViewBasic
248
151
indexedAt: pack.indexedAt.toISOString(),
249
152
};
250
153
251
-
if (list) {
252
-
const listData = list as {
253
-
uri: string;
254
-
cid: string;
255
-
name: string;
256
-
purpose: string;
257
-
};
258
-
view.list = {
259
-
uri: listData.uri,
260
-
cid: listData.cid,
261
-
name: listData.name,
262
-
purpose: listData.purpose,
263
-
};
154
+
// Add optional list info if exists
155
+
if (pack.listUri) {
156
+
const list = await storage.getList(pack.listUri);
157
+
if (list) {
158
+
view.list = {
159
+
uri: list.uri,
160
+
cid: list.cid,
161
+
name: list.name,
162
+
purpose: list.purpose,
163
+
};
164
+
}
264
165
}
265
166
266
167
return view;
267
168
})
268
169
);
269
170
270
-
// Filter out null entries (packs from creators without valid handles)
271
-
const validViews = views.filter((view) => view !== null);
171
+
const validViews = views.filter(Boolean);
272
172
273
173
res.json({ starterPacks: validViews });
274
174
} catch (error) {
···
288
188
const params = getActorStarterPacksSchema.parse(req.query);
289
189
const did = await resolveActor(res, params.actor);
290
190
if (!did) return;
291
-
const { starterPacks, cursor } = await storage.getStarterPacksByCreator(
191
+
192
+
const { starterPacks, cursor: nextCursor } = await storage.getStarterPacksByCreator(
292
193
did,
293
194
params.limit,
294
195
params.cursor
295
196
);
296
-
res.json({
297
-
cursor,
298
-
starterPacks: (
197
+
198
+
if (starterPacks.length === 0) {
199
+
res.json({
200
+
cursor: nextCursor,
201
+
starterPacks: [],
202
+
});
203
+
return;
204
+
}
205
+
206
+
// Use _getProfiles for complete creator profileViewBasic (all packs have same creator)
207
+
const creatorProfiles = await (xrpcApi as any)._getProfiles([did], req);
208
+
209
+
if (creatorProfiles.length === 0) {
210
+
res.status(500).json({
211
+
error: 'InternalServerError',
212
+
message: 'Creator profile not available',
213
+
});
214
+
return;
215
+
}
216
+
217
+
const creatorView = creatorProfiles[0];
218
+
219
+
// Get all starter pack URIs for batch label fetching
220
+
const packUris = starterPacks.map((p: any) => p.uri);
221
+
222
+
// Batch fetch labels for all starter packs
223
+
const allLabels = await storage.getLabelsForSubjects(packUris);
224
+
const labelsMap = new Map<string, typeof allLabels>();
225
+
226
+
allLabels.forEach((label) => {
227
+
const existing = labelsMap.get(label.subject) || [];
228
+
existing.push(label);
229
+
labelsMap.set(label.subject, existing);
230
+
});
231
+
232
+
// Build starterPackViewBasic objects
233
+
const starterPackViews = await Promise.all(
234
+
(
299
235
starterPacks as {
300
236
uri: string;
301
237
cid: string;
302
238
name: string;
239
+
description?: string;
303
240
listUri?: string;
304
241
feeds?: unknown[];
305
242
createdAt: Date;
243
+
indexedAt: Date;
306
244
}[]
307
-
).map((p) => ({
308
-
uri: p.uri,
309
-
cid: p.cid,
310
-
record: {
311
-
name: p.name,
312
-
list: p.listUri,
313
-
feeds: p.feeds,
314
-
createdAt: p.createdAt.toISOString(),
315
-
},
316
-
})),
317
-
feeds: [],
245
+
).map(async (pack) => {
246
+
const record: {
247
+
name: string;
248
+
list?: string;
249
+
feeds?: unknown[];
250
+
createdAt: string;
251
+
description?: string;
252
+
} = {
253
+
name: pack.name,
254
+
list: pack.listUri,
255
+
feeds: pack.feeds,
256
+
createdAt: pack.createdAt.toISOString(),
257
+
};
258
+
259
+
if (pack.description) {
260
+
record.description = pack.description;
261
+
}
262
+
263
+
// Calculate listItemCount if list exists
264
+
let listItemCount: number | undefined = undefined;
265
+
if (pack.listUri) {
266
+
const items = await storage.getListItems(pack.listUri, 10000);
267
+
listItemCount = items.length;
268
+
}
269
+
270
+
// Get labels for this pack
271
+
const packLabels = labelsMap.get(pack.uri);
272
+
const labels = packLabels?.map((label) => ({
273
+
src: label.src,
274
+
uri: label.uri,
275
+
val: label.val,
276
+
cts: label.createdAt.toISOString(),
277
+
...(label.neg && { neg: true }),
278
+
}));
279
+
280
+
return {
281
+
uri: pack.uri,
282
+
cid: pack.cid,
283
+
record,
284
+
creator: creatorView,
285
+
indexedAt: pack.indexedAt.toISOString(),
286
+
...(listItemCount !== undefined && { listItemCount }),
287
+
...(labels && labels.length > 0 && { labels }),
288
+
};
289
+
})
290
+
);
291
+
292
+
res.json({
293
+
cursor: nextCursor,
294
+
starterPacks: starterPackViews,
318
295
});
319
296
} catch (error) {
320
297
handleError(res, error, 'getActorStarterPacks');
···
324
301
/**
325
302
* Get starter packs with membership info
326
303
* GET /xrpc/app.bsky.graph.getStarterPacksWithMembership
304
+
*
305
+
* Returns starter packs created by the authenticated user, with membership info
306
+
* about the specified actor in each pack's associated list.
327
307
*/
328
308
export async function getStarterPacksWithMembership(
329
309
req: Request,
···
331
311
): Promise<void> {
332
312
try {
333
313
const params = getStarterPacksWithMembershipSchema.parse(req.query);
334
-
const did = params.actor ? await resolveActor(res, params.actor) : null;
335
-
const { starterPacks, cursor } = did
336
-
? await storage.getStarterPacksByCreator(did, params.limit, params.cursor)
337
-
: await storage.listStarterPacks(params.limit, params.cursor);
314
+
315
+
// Requires authentication - starter packs are created by session user
316
+
const sessionDid = await requireAuthDid(req, res);
317
+
if (!sessionDid) return;
318
+
319
+
// Resolve the actor to check for membership
320
+
const actorDid = await resolveActor(res, params.actor);
321
+
if (!actorDid) return;
322
+
323
+
// Get starter packs created by authenticated user
324
+
const { starterPacks, cursor: nextCursor } = await storage.getStarterPacksByCreator(
325
+
sessionDid,
326
+
params.limit,
327
+
params.cursor
328
+
);
329
+
330
+
if (starterPacks.length === 0) {
331
+
res.json({
332
+
cursor: nextCursor,
333
+
starterPacksWithMembership: [],
334
+
});
335
+
return;
336
+
}
337
+
338
+
// Use _getProfiles for both creator and actor profiles
339
+
const profiles = await (xrpcApi as any)._getProfiles([sessionDid, actorDid], req);
340
+
341
+
if (profiles.length === 0) {
342
+
res.status(500).json({
343
+
error: 'InternalServerError',
344
+
message: 'Profiles not available',
345
+
});
346
+
return;
347
+
}
348
+
349
+
const profileMap = new Map(profiles.map((p: any) => [p.did, p]));
350
+
const creatorView = profileMap.get(sessionDid);
351
+
const actorProfile = profileMap.get(actorDid);
352
+
353
+
if (!creatorView) {
354
+
res.status(500).json({
355
+
error: 'InternalServerError',
356
+
message: 'Creator profile not available',
357
+
});
358
+
return;
359
+
}
360
+
361
+
// Get all starter pack URIs for batch label fetching
362
+
const packUris = starterPacks.map((p: any) => p.uri);
363
+
364
+
// Batch fetch labels for all starter packs
365
+
const allLabels = await storage.getLabelsForSubjects(packUris);
366
+
const labelsMap = new Map<string, typeof allLabels>();
367
+
368
+
allLabels.forEach((label) => {
369
+
const existing = labelsMap.get(label.subject) || [];
370
+
existing.push(label);
371
+
labelsMap.set(label.subject, existing);
372
+
});
373
+
374
+
// Build starterPacksWithMembership response
375
+
const starterPacksWithMembershipData = await Promise.all(
376
+
(
377
+
starterPacks as {
378
+
uri: string;
379
+
cid: string;
380
+
name: string;
381
+
description?: string;
382
+
listUri?: string;
383
+
feeds?: unknown[];
384
+
createdAt: Date;
385
+
indexedAt: Date;
386
+
}[]
387
+
).map(async (pack) => {
388
+
const record: {
389
+
name: string;
390
+
list?: string;
391
+
feeds?: unknown[];
392
+
createdAt: string;
393
+
description?: string;
394
+
} = {
395
+
name: pack.name,
396
+
list: pack.listUri,
397
+
feeds: pack.feeds,
398
+
createdAt: pack.createdAt.toISOString(),
399
+
};
400
+
401
+
if (pack.description) {
402
+
record.description = pack.description;
403
+
}
404
+
405
+
// Calculate listItemCount if list exists
406
+
let listItemCount: number | undefined = undefined;
407
+
let memberItem = null;
408
+
409
+
if (pack.listUri) {
410
+
const listItems = await storage.getListItems(pack.listUri, 10000);
411
+
listItemCount = listItems.length;
412
+
413
+
// Check if actor is a member of this pack's list
414
+
memberItem = listItems.find((item) => item.subjectDid === actorDid);
415
+
}
416
+
417
+
// Get labels for this pack
418
+
const packLabels = labelsMap.get(pack.uri);
419
+
const labels = packLabels?.map((label) => ({
420
+
src: label.src,
421
+
uri: label.uri,
422
+
val: label.val,
423
+
cts: label.createdAt.toISOString(),
424
+
...(label.neg && { neg: true }),
425
+
}));
426
+
427
+
// Build full starterPackViewBasic
428
+
const starterPackView = {
429
+
uri: pack.uri,
430
+
cid: pack.cid,
431
+
record,
432
+
creator: creatorView,
433
+
indexedAt: pack.indexedAt.toISOString(),
434
+
...(listItemCount !== undefined && { listItemCount }),
435
+
...(labels && labels.length > 0 && { labels }),
436
+
};
437
+
438
+
// Build response object
439
+
const response: {
440
+
starterPack: typeof starterPackView;
441
+
listItem?: { uri: string; subject: any };
442
+
} = {
443
+
starterPack: starterPackView,
444
+
};
445
+
446
+
// Include listItem if actor is a member of the pack's list
447
+
if (memberItem && actorProfile) {
448
+
response.listItem = {
449
+
uri: memberItem.uri,
450
+
subject: actorProfile,
451
+
};
452
+
}
453
+
454
+
return response;
455
+
})
456
+
);
457
+
338
458
res.json({
339
-
cursor,
340
-
starterPacks: (starterPacks as { uri: string; cid: string }[]).map(
341
-
(p) => ({ uri: p.uri, cid: p.cid })
342
-
),
459
+
cursor: nextCursor,
460
+
starterPacksWithMembership: starterPacksWithMembershipData,
343
461
});
344
462
} catch (error) {
345
463
handleError(res, error, 'getStarterPacksWithMembership');
···
349
467
/**
350
468
* Get suggested starter packs for onboarding
351
469
* GET /xrpc/app.bsky.unspecced.getOnboardingSuggestedStarterPacks
470
+
*
471
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
472
+
* Returns a list of suggested starter packs for new user onboarding with complete starterPackView objects.
352
473
*/
353
474
export async function getOnboardingSuggestedStarterPacks(
354
475
req: Request,
355
476
res: Response
356
477
): Promise<void> {
357
478
try {
479
+
const params = getOnboardingSuggestedStarterPacksSchema.parse(req.query);
480
+
358
481
// Return recent starter packs as onboarding suggestions
359
-
const { starterPacks } = await storage.listStarterPacks(10);
360
-
res.json({
361
-
starterPacks: (
362
-
starterPacks as { uri: string; cid: string; createdAt: Date }[]
363
-
).map((p) => ({
364
-
uri: p.uri,
365
-
cid: p.cid,
366
-
createdAt: p.createdAt.toISOString(),
367
-
})),
368
-
});
482
+
const { starterPacks } = (await storage.listStarterPacks(params.limit)) as {
483
+
starterPacks: {
484
+
uri: string;
485
+
cid: string;
486
+
creatorDid: string;
487
+
listUri?: string;
488
+
name: string;
489
+
description?: string;
490
+
feeds?: unknown[];
491
+
createdAt: Date;
492
+
indexedAt: Date;
493
+
}[];
494
+
};
495
+
496
+
if (starterPacks.length === 0) {
497
+
return res.json({ starterPacks: [] });
498
+
}
499
+
500
+
// Batch fetch all creator profiles
501
+
const creatorDids = [...new Set(starterPacks.map((p) => p.creatorDid))];
502
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(
503
+
creatorDids,
504
+
req
505
+
);
506
+
507
+
// Create map for quick lookup
508
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
509
+
510
+
// Build views with complete creator profiles
511
+
const views = await Promise.all(
512
+
starterPacks.map(async (pack) => {
513
+
const creatorProfile = profileMap.get(pack.creatorDid);
514
+
if (!creatorProfile) {
515
+
console.warn(
516
+
`[XRPC] Skipping starter pack ${pack.uri} - creator ${pack.creatorDid} profile not found`
517
+
);
518
+
return null;
519
+
}
520
+
521
+
const view: any = {
522
+
uri: pack.uri,
523
+
cid: pack.cid,
524
+
record: {
525
+
name: pack.name,
526
+
list: pack.listUri,
527
+
feeds: pack.feeds,
528
+
createdAt: pack.createdAt.toISOString(),
529
+
...(pack.description && { description: pack.description }),
530
+
},
531
+
creator: creatorProfile, // Full profileViewBasic
532
+
indexedAt: pack.indexedAt.toISOString(),
533
+
};
534
+
535
+
// Add optional list info if exists
536
+
if (pack.listUri) {
537
+
const list = await storage.getList(pack.listUri);
538
+
if (list) {
539
+
view.list = {
540
+
uri: list.uri,
541
+
cid: list.cid,
542
+
name: list.name,
543
+
purpose: list.purpose,
544
+
};
545
+
}
546
+
}
547
+
548
+
return view;
549
+
})
550
+
);
551
+
552
+
const validViews = views.filter(Boolean);
553
+
554
+
res.json({ starterPacks: validViews });
369
555
} catch (error) {
370
556
handleError(res, error, 'getOnboardingSuggestedStarterPacks');
371
557
}
+164
-72
server/services/xrpc/services/timeline-service.ts
+164
-72
server/services/xrpc/services/timeline-service.ts
···
362
362
// Get feed generator info
363
363
const feedGen = await storage.getFeedGenerator(params.feed);
364
364
if (!feedGen) {
365
-
res.status(404).json({ error: 'Feed generator not found' });
365
+
res.status(404).json({
366
+
error: 'UnknownFeed',
367
+
message: 'Feed generator not found'
368
+
});
366
369
return;
367
370
}
368
371
···
371
374
);
372
375
373
376
// Call external feed generator service to get skeleton
374
-
// Then hydrate with full post data from our database
375
377
const { feed: hydratedFeed, cursor } = await feedGeneratorClient.getFeed(
376
378
feedGen.did,
377
379
{
···
388
390
`[XRPC] Hydrated ${hydratedFeed.length} posts from feed generator`
389
391
);
390
392
391
-
// Build post views with author information
392
-
const feed = await Promise.all(
393
-
hydratedFeed.map(async ({ post, reason }) => {
394
-
const author = await storage.getUser(post.authorDid);
393
+
if (hydratedFeed.length === 0) {
394
+
return res.json({ feed: [], cursor });
395
+
}
395
396
396
-
// Skip posts from authors without valid handles
397
-
if (!author || !author.handle) {
398
-
console.warn(
399
-
`[XRPC] Skipping post ${post.uri} - author ${post.authorDid} has no handle`
400
-
);
401
-
return null;
402
-
}
397
+
// Extract post URIs for batch fetching
398
+
const postUris = hydratedFeed.map(({ post }) => post.uri);
399
+
const posts = await storage.getPosts(postUris);
403
400
404
-
const postView: any = {
405
-
uri: post.uri,
406
-
cid: post.cid,
407
-
author: {
408
-
$type: 'app.bsky.actor.defs#profileViewBasic',
409
-
did: post.authorDid,
410
-
handle: author.handle,
411
-
displayName: author.displayName ?? author.handle,
412
-
pronouns: author?.pronouns,
413
-
...maybeAvatar(author?.avatarUrl, author?.did, req),
414
-
associated: {
415
-
$type: 'app.bsky.actor.defs#profileAssociated',
416
-
lists: 0,
417
-
feedgens: 0,
418
-
starterPacks: 0,
419
-
labeler: false,
420
-
chat: undefined,
421
-
activitySubscription: undefined,
422
-
},
423
-
viewer: undefined,
424
-
labels: [],
425
-
createdAt: author?.createdAt?.toISOString(),
426
-
verification: undefined,
427
-
status: undefined,
428
-
},
429
-
record: {
430
-
text: post.text,
431
-
createdAt: post.createdAt.toISOString(),
432
-
},
433
-
replyCount: 0,
434
-
repostCount: 0,
435
-
likeCount: 0,
436
-
indexedAt: post.indexedAt.toISOString(),
437
-
};
401
+
// Get viewer DID for proper serialization
402
+
const viewerDid = await getAuthenticatedDid(req);
438
403
439
-
const feedView: any = { post: postView };
404
+
// Use existing serializePosts infrastructure for complete post objects
405
+
const serializedPosts = await (xrpcApi as any).serializePosts(
406
+
posts,
407
+
viewerDid || undefined,
408
+
req
409
+
);
440
410
441
-
// Include reason if present (e.g., repost context)
442
-
if (reason) {
443
-
feedView.reason = reason;
444
-
}
411
+
// Create map for quick lookup
412
+
const postsByUri = new Map(serializedPosts.map((p: any) => [p.uri, p]));
413
+
414
+
// Build feed with reasons and optional feedContext/reqId
415
+
const feed = hydratedFeed
416
+
.map(({ post, reason, feedContext, reqId }) => {
417
+
const serializedPost = postsByUri.get(post.uri);
418
+
if (!serializedPost) return null;
419
+
420
+
const feedView: any = { post: serializedPost };
421
+
if (reason) feedView.reason = reason;
422
+
if (feedContext) feedView.feedContext = feedContext;
423
+
if (reqId) feedView.reqId = reqId;
445
424
446
425
return feedView;
447
426
})
448
-
);
427
+
.filter(Boolean);
449
428
450
-
// Filter out null entries (posts from authors without handles)
451
-
const validFeed = feed.filter((item) => item !== null);
452
-
453
-
res.json({ feed: validFeed, cursor });
429
+
res.json({ feed, cursor });
454
430
} catch (error) {
455
-
// If feed generator is unavailable, provide a helpful error
456
431
handleError(res, error, 'getFeed');
457
432
}
458
433
}
···
460
435
/**
461
436
* Get post thread (V2 - unspecced)
462
437
* GET /xrpc/app.bsky.unspecced.getPostThreadV2
438
+
*
439
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
440
+
* Per the official lexicon: "this endpoint is under development and WILL change without notice."
441
+
*
442
+
* Returns a flat array of thread items with depth indicators for building threaded UIs.
443
+
* - depth 0: anchor post
444
+
* - depth < 0: parent posts (ancestors)
445
+
* - depth > 0: reply posts (descendants)
463
446
*/
464
447
export async function getPostThreadV2(
465
448
req: Request,
···
467
450
): Promise<void> {
468
451
try {
469
452
const params = getPostThreadV2Schema.parse(req.query);
470
-
const posts = await storage.getPostThread(params.anchor);
471
453
const viewerDid = await getAuthenticatedDid(req);
472
454
455
+
// Get the anchor post
456
+
const anchorPost = await storage.getPost(params.anchor);
457
+
if (!anchorPost) {
458
+
return res.status(404).json({ error: 'Post not found' });
459
+
}
460
+
461
+
const threadItems: Array<{ post: any; depth: number }> = [];
462
+
const depthLimit = Math.min(params.below || 6, 20);
463
+
const includeAbove = params.above !== false;
464
+
const branchingFactor = Math.min(params.branchingFactor || 10, 100);
465
+
const sort = params.sort || 'oldest';
466
+
467
+
// 1. Collect parent chain if above=true (depth will be negative)
468
+
if (includeAbove && anchorPost.parentUri) {
469
+
let currentUri = anchorPost.parentUri;
470
+
let depth = -1;
471
+
const parentChain: Array<{ post: any; depth: number }> = [];
472
+
473
+
while (currentUri && depth >= -20) {
474
+
const parent = await storage.getPost(currentUri);
475
+
if (!parent) break;
476
+
477
+
parentChain.unshift({ post: parent, depth });
478
+
currentUri = parent.parentUri || null;
479
+
depth--;
480
+
}
481
+
482
+
threadItems.push(...parentChain);
483
+
}
484
+
485
+
// 2. Add anchor post at depth 0
486
+
threadItems.push({ post: anchorPost, depth: 0 });
487
+
488
+
// 3. Collect replies below anchor (depth will be positive)
489
+
if (depthLimit > 0) {
490
+
// Get all posts in the thread
491
+
const rootUri = anchorPost.rootUri || anchorPost.uri;
492
+
const allThreadPosts = await storage.db
493
+
.select()
494
+
.from(storage.schema.posts)
495
+
.where(storage.sql.eq(storage.schema.posts.rootUri, rootUri));
496
+
497
+
// Build parent-to-children map
498
+
const replyMap = new Map<string, any[]>();
499
+
for (const post of allThreadPosts) {
500
+
if (post.parentUri) {
501
+
const siblings = replyMap.get(post.parentUri) || [];
502
+
siblings.push(post);
503
+
replyMap.set(post.parentUri, siblings);
504
+
}
505
+
}
506
+
507
+
// Apply sorting to reply arrays
508
+
const sortReplies = (replies: any[]) => {
509
+
if (sort === 'newest') {
510
+
return replies.sort(
511
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
512
+
);
513
+
} else if (sort === 'top') {
514
+
// TODO: Implement top sorting with engagement metrics
515
+
return replies.sort(
516
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
517
+
);
518
+
} else {
519
+
// oldest (default)
520
+
return replies.sort(
521
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
522
+
);
523
+
}
524
+
};
525
+
526
+
// Traverse reply tree with depth limits
527
+
const collectReplies = (postUri: string, currentDepth: number) => {
528
+
if (currentDepth >= depthLimit) return;
529
+
530
+
const children = replyMap.get(postUri) || [];
531
+
const sortedChildren = sortReplies([...children]);
532
+
const limitedChildren = sortedChildren.slice(0, branchingFactor);
533
+
534
+
for (const child of limitedChildren) {
535
+
threadItems.push({ post: child, depth: currentDepth + 1 });
536
+
collectReplies(child.uri, currentDepth + 1);
537
+
}
538
+
};
539
+
540
+
collectReplies(anchorPost.uri, 0);
541
+
}
542
+
543
+
// 4. Serialize all posts
544
+
const allPosts = threadItems.map((item) => item.post);
473
545
const serialized = await (xrpcApi as any).serializePosts(
474
-
posts,
546
+
allPosts,
475
547
viewerDid || undefined,
476
548
req
477
549
);
478
550
479
-
res.json({
480
-
hasOtherReplies: false,
481
-
thread: serialized.length
482
-
? {
551
+
const postsByUri = new Map(serialized.map((p: any) => [p.uri, p]));
552
+
553
+
// 5. Build response items with depth
554
+
const items = threadItems
555
+
.map((item) => {
556
+
const serializedPost = postsByUri.get(item.post.uri);
557
+
if (!serializedPost) return null;
558
+
559
+
return {
560
+
uri: item.post.uri,
561
+
depth: item.depth,
562
+
value: {
483
563
$type: 'app.bsky.unspecced.defs#threadItemPost',
484
-
post: serialized[0],
485
-
}
486
-
: null,
487
-
threadgate: null,
488
-
});
564
+
post: serializedPost,
565
+
},
566
+
hasOtherReplies: false, // TODO: Implement pagination detection
567
+
};
568
+
})
569
+
.filter(Boolean);
570
+
571
+
res.json({ items });
489
572
} catch (error) {
490
573
handleError(res, error, 'getPostThreadV2');
491
574
}
492
575
}
493
576
494
577
/**
495
-
* Get other thread replies (V2 - unspecced stub)
578
+
* Get other thread replies (V2 - unspecced)
496
579
* GET /xrpc/app.bsky.unspecced.getPostThreadOtherV2
580
+
*
581
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
582
+
* Per the official lexicon: "this endpoint is under development and WILL change without notice."
583
+
*
584
+
* Returns additional replies that may be hidden by threadgate restrictions or pagination.
585
+
* Currently returns an empty array as pagination is not yet implemented.
497
586
*/
498
587
export async function getPostThreadOtherV2(
499
588
req: Request,
500
589
res: Response
501
590
): Promise<void> {
502
591
try {
503
-
getPostThreadOtherV2Schema.parse(req.query);
504
-
res.json({ hasOtherReplies: false, items: [] });
592
+
const params = getPostThreadOtherV2Schema.parse(req.query);
593
+
594
+
// TODO: Implement actual functionality for paginated/hidden replies
595
+
// For now, return empty array indicating no additional replies
596
+
res.json({ items: [] });
505
597
} catch (error) {
506
598
handleError(res, error, 'getPostThreadOtherV2');
507
599
}
+148
-25
server/services/xrpc/services/unspecced-service.ts
+148
-25
server/services/xrpc/services/unspecced-service.ts
···
6
6
import type { Request, Response } from 'express';
7
7
import { storage } from '../../../storage';
8
8
import { handleError } from '../utils/error-handler';
9
-
import { maybeAvatar } from '../utils/serializers';
10
-
import { z } from 'zod';
11
-
12
-
const unspeccedNoParamsSchema = z.object({
13
-
// No required params
14
-
});
9
+
import { getTrendsSchema, unspeccedNoParamsSchema } from '../schemas';
10
+
import { xrpcApi } from '../../xrpc-api';
15
11
16
12
/**
17
13
* Get tagged suggestions (unspecced)
18
14
* GET /xrpc/app.bsky.unspecced.getTaggedSuggestions
15
+
*
16
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
17
+
* Returns categorized suggestions for feeds and users with tags.
18
+
*
19
+
* Response format per spec:
20
+
* - tag: Category identifier (e.g., "popular", "tech", "news")
21
+
* - subjectType: "actor" or "feed"
22
+
* - subject: AT-URI of the suggested resource
19
23
*/
20
24
export async function getTaggedSuggestions(
21
25
req: Request,
···
24
28
try {
25
29
unspeccedNoParamsSchema.parse(req.query);
26
30
27
-
// Return recent users as generic suggestions
28
-
const users = await storage.getSuggestedUsers(undefined, 25);
31
+
const suggestions: Array<{
32
+
tag: string;
33
+
subjectType: 'actor' | 'feed';
34
+
subject: string;
35
+
}> = [];
36
+
37
+
// Get suggested users and tag them
38
+
const { users } = await storage.getSuggestedUsers(undefined, 10);
39
+
for (const user of users as { did: string }[]) {
40
+
suggestions.push({
41
+
tag: 'suggested-users',
42
+
subjectType: 'actor',
43
+
subject: user.did, // Using DID as subject (can be used to fetch full profile)
44
+
});
45
+
}
46
+
47
+
// Get suggested feeds and tag them
48
+
const { generators } = (await storage.getSuggestedFeeds(10)) as {
49
+
generators: { uri: string }[];
50
+
};
51
+
for (const generator of generators) {
52
+
suggestions.push({
53
+
tag: 'suggested-feeds',
54
+
subjectType: 'feed',
55
+
subject: generator.uri, // AT-URI of the feed generator
56
+
});
57
+
}
58
+
59
+
// TODO: Implement more sophisticated tagging logic
60
+
// - Categorize by topic (tech, news, sports, etc.)
61
+
// - Use trending/popular tags
62
+
// - Personalize based on user interests
63
+
// For now, we use generic "suggested-users" and "suggested-feeds" tags
29
64
30
-
res.json({
31
-
suggestions: users.map((u) => ({
32
-
did: u.did,
33
-
handle: u.handle,
34
-
displayName: u.displayName,
35
-
...maybeAvatar(u.avatarUrl, u.did, req),
36
-
})),
37
-
});
65
+
res.json({ suggestions });
38
66
} catch (error) {
39
67
handleError(res, error, 'getTaggedSuggestions');
40
68
}
···
63
91
}
64
92
65
93
/**
66
-
* Get trends (unspecced stub)
94
+
* Get trends (unspecced)
67
95
* GET /xrpc/app.bsky.unspecced.getTrends
96
+
*
97
+
* IMPORTANT: This endpoint is experimental and marked as "unspecced" in the ATProto specification.
98
+
* Returns trending topics with engagement metrics and associated user profiles.
99
+
*
100
+
* Response format per spec (trendView):
101
+
* - topic: Trend topic/hashtag
102
+
* - displayName: Display name for the trend
103
+
* - link: Link to the trend
104
+
* - startedAt: When the trend started
105
+
* - postCount: Number of posts in this trend
106
+
* - actors: Array of profileViewBasic objects
107
+
* - status: Optional "hot" indicator
108
+
* - category: Optional category
68
109
*/
69
110
export async function getTrends(req: Request, res: Response): Promise<void> {
70
111
try {
71
-
unspeccedNoParamsSchema.parse(req.query);
72
-
res.json({ trends: [{ topic: '#bluesky', count: 0 }] });
112
+
const params = getTrendsSchema.parse(req.query);
113
+
114
+
// TODO: Implement real trending logic based on:
115
+
// - Recent post counts by hashtag/topic
116
+
// - Velocity of engagement (likes, reposts, replies)
117
+
// - Time-based trending windows
118
+
// - Geographic/network-wide trends
119
+
120
+
// For now, return placeholder trends with proper structure
121
+
const placeholderTrends = [
122
+
{
123
+
topic: '#bluesky',
124
+
displayName: 'Bluesky',
125
+
category: 'social-media',
126
+
},
127
+
{
128
+
topic: '#atproto',
129
+
displayName: 'AT Protocol',
130
+
category: 'technology',
131
+
},
132
+
];
133
+
134
+
const trends = await Promise.all(
135
+
placeholderTrends.slice(0, params.limit).map(async (trend) => {
136
+
// Get some users to associate with the trend (placeholder)
137
+
const { users } = await storage.getSuggestedUsers(undefined, 3);
138
+
const userDids = (users as { did: string }[]).map((u) => u.did);
139
+
140
+
// Hydrate user profiles
141
+
const actors =
142
+
userDids.length > 0
143
+
? await (xrpcApi as any)._getProfiles(userDids, req)
144
+
: [];
145
+
146
+
return {
147
+
topic: trend.topic,
148
+
displayName: trend.displayName,
149
+
link: `https://bsky.app/search?q=${encodeURIComponent(trend.topic)}`,
150
+
startedAt: new Date(Date.now() - 3600000).toISOString(), // Started 1 hour ago
151
+
postCount: Math.floor(Math.random() * 1000) + 100, // Placeholder count
152
+
actors: actors.slice(0, 3), // Include up to 3 actors
153
+
status: 'hot',
154
+
category: trend.category,
155
+
};
156
+
})
157
+
);
158
+
159
+
res.json({ trends });
73
160
} catch (error) {
74
161
handleError(res, error, 'getTrends');
75
162
}
···
121
208
}
122
209
123
210
/**
124
-
* Get age assurance state (stub)
125
-
* GET /xrpc/com.atproto.identity.getAgeAssuranceState
211
+
* Get age assurance state
212
+
* GET /xrpc/app.bsky.unspecced.getAgeAssuranceState
213
+
*
214
+
* IMPORTANT: This AppView will NEVER provide age assurance/verification services.
215
+
*
216
+
* Age verification is a sensitive legal and privacy matter that requires:
217
+
* - Compliance with varying international age verification laws (COPPA, GDPR, etc.)
218
+
* - Secure handling of personal identification documents
219
+
* - Legal liability and regulatory oversight
220
+
* - Infrastructure for identity verification
221
+
*
222
+
* This is an explicit architectural decision that age assurance is NOT and will NEVER
223
+
* be provided by this AppView under any circumstances. Users must handle age verification
224
+
* through their PDS or other appropriate identity services.
126
225
*/
127
226
export async function getAgeAssuranceState(
128
227
req: Request,
129
228
res: Response
130
229
): Promise<void> {
131
230
try {
132
-
res.json({ state: 'unknown' });
231
+
res.status(501).json({
232
+
error: 'NotImplemented',
233
+
message: 'This AppView does not and will never provide age assurance services. ' +
234
+
'Age verification is a sensitive legal matter requiring compliance with international laws, ' +
235
+
'secure handling of personal identification, and regulatory oversight. ' +
236
+
'Users must handle age verification through their PDS or appropriate identity services.',
237
+
});
133
238
} catch (error) {
134
239
handleError(res, error, 'getAgeAssuranceState');
135
240
}
136
241
}
137
242
138
243
/**
139
-
* Initialize age assurance (stub)
140
-
* POST /xrpc/com.atproto.identity.initAgeAssurance
244
+
* Initialize age assurance
245
+
* POST /xrpc/app.bsky.unspecced.initAgeAssurance
246
+
*
247
+
* IMPORTANT: This AppView will NEVER provide age assurance/verification services.
248
+
*
249
+
* Age verification is a sensitive legal and privacy matter that requires:
250
+
* - Compliance with varying international age verification laws (COPPA, GDPR, etc.)
251
+
* - Secure handling of personal identification documents
252
+
* - Legal liability and regulatory oversight
253
+
* - Infrastructure for identity verification
254
+
*
255
+
* This is an explicit architectural decision that age assurance is NOT and will NEVER
256
+
* be provided by this AppView under any circumstances. Users must handle age verification
257
+
* through their PDS or other appropriate identity services.
141
258
*/
142
259
export async function initAgeAssurance(
143
260
req: Request,
144
261
res: Response
145
262
): Promise<void> {
146
263
try {
147
-
res.json({ ok: true });
264
+
res.status(501).json({
265
+
error: 'NotImplemented',
266
+
message: 'This AppView does not and will never provide age assurance services. ' +
267
+
'Age verification is a sensitive legal matter requiring compliance with international laws, ' +
268
+
'secure handling of personal identification, and regulatory oversight. ' +
269
+
'Users must handle age verification through their PDS or appropriate identity services.',
270
+
});
148
271
} catch (error) {
149
272
handleError(res, error, 'initAgeAssurance');
150
273
}
+87
-134
server/services/xrpc/services/utility-service.ts
+87
-134
server/services/xrpc/services/utility-service.ts
···
13
13
getJobStatusSchema,
14
14
sendInteractionsSchema,
15
15
} from '../schemas/utility-schemas';
16
+
import { xrpcApi } from '../../xrpc-api';
16
17
17
18
/**
18
19
* Get labeler services for given DIDs
···
31
32
);
32
33
33
34
// Flatten array of arrays
34
-
const services = allServices.flat();
35
+
const services = allServices.flat() as {
36
+
uri: string;
37
+
cid: string;
38
+
creatorDid: string;
39
+
likeCount: number;
40
+
indexedAt: Date;
41
+
policies?: unknown;
42
+
}[];
35
43
36
-
const views = await Promise.all(
37
-
services.map(async (service) => {
38
-
const creator = await storage.getUser(
39
-
(service as { creatorDid: string }).creatorDid
40
-
);
44
+
if (services.length === 0) {
45
+
return res.json({ views: [] });
46
+
}
41
47
42
-
// Skip services from creators without valid handles
43
-
if (!creator || !creator.handle) {
48
+
// Batch fetch all creator profiles
49
+
const creatorDids = [...new Set(services.map(s => s.creatorDid))];
50
+
const creatorProfiles = await (xrpcApi as any)._getProfiles(creatorDids, req);
51
+
52
+
// Create map for quick lookup
53
+
const profileMap = new Map(creatorProfiles.map((p: any) => [p.did, p]));
54
+
55
+
// Batch fetch labels for all services
56
+
const serviceUris = services.map(s => s.uri);
57
+
const allLabels = await storage.getLabelsForSubjects(serviceUris);
58
+
59
+
// Create labels map
60
+
const labelsMap = new Map<string, typeof allLabels>();
61
+
allLabels.forEach((label) => {
62
+
const existing = labelsMap.get(label.subject) || [];
63
+
existing.push(label);
64
+
labelsMap.set(label.subject, existing);
65
+
});
66
+
67
+
// Build views
68
+
const views = services
69
+
.map((service) => {
70
+
const creatorProfile = profileMap.get(service.creatorDid);
71
+
if (!creatorProfile) {
44
72
console.warn(
45
-
`[XRPC] Skipping labeler service ${(service as { uri: string }).uri} - creator ${(service as { creatorDid: string }).creatorDid} has no handle`
73
+
`[XRPC] Skipping labeler service ${service.uri} - creator ${service.creatorDid} profile not found`
46
74
);
47
75
return null;
48
76
}
49
77
50
-
const creatorView: {
51
-
did: string;
52
-
handle: string;
53
-
displayName?: string;
54
-
avatar?: string;
55
-
} = {
56
-
did: (service as { creatorDid: string }).creatorDid,
57
-
handle: creator.handle,
78
+
const view: any = {
79
+
uri: service.uri,
80
+
cid: service.cid,
81
+
creator: creatorProfile, // Full profileView
82
+
likeCount: service.likeCount || 0,
83
+
indexedAt: service.indexedAt.toISOString(),
58
84
};
59
85
60
-
if (creator?.displayName) creatorView.displayName = creator.displayName;
61
-
if (creator?.avatarUrl) {
62
-
const avatarUri = transformBlobToCdnUrl(
63
-
creator.avatarUrl,
64
-
creator.did,
65
-
'avatar',
66
-
req
67
-
);
68
-
if (
69
-
avatarUri &&
70
-
typeof avatarUri === 'string' &&
71
-
avatarUri.trim() !== ''
72
-
) {
73
-
creatorView.avatar = avatarUri;
74
-
}
86
+
// Add policies (required for detailed view, optional for basic view)
87
+
if (params.detailed && service.policies) {
88
+
view.policies = service.policies;
75
89
}
76
90
77
-
const view: {
78
-
uri: string;
79
-
cid: string;
80
-
creator: typeof creatorView;
81
-
likeCount: number;
82
-
indexedAt: string;
83
-
policies?: unknown;
84
-
labels?: unknown[];
85
-
} = {
86
-
uri: (service as { uri: string }).uri,
87
-
cid: (service as { cid: string }).cid,
88
-
creator: creatorView,
89
-
likeCount: (service as { likeCount: number }).likeCount,
90
-
indexedAt: (service as { indexedAt: Date }).indexedAt.toISOString(),
91
-
};
92
-
93
-
// Add policies
94
-
if ((service as { policies?: unknown }).policies) {
95
-
view.policies = (service as { policies: unknown }).policies;
96
-
}
97
-
98
-
// Get labels applied to this labeler service
99
-
const labels = await storage.getLabelsForSubject(
100
-
(service as { uri: string }).uri
101
-
);
102
-
if (labels.length > 0) {
103
-
view.labels = labels.map((label) => {
104
-
const labelView: {
105
-
src: string;
106
-
uri: string;
107
-
val: string;
108
-
cts: string;
109
-
neg?: boolean;
110
-
} = {
111
-
src: (label as { src: string }).src,
112
-
uri: (label as { subject: string }).subject,
113
-
val: (label as { val: string }).val,
114
-
cts: (label as { createdAt: Date }).createdAt.toISOString(),
115
-
};
116
-
if ((label as { neg?: boolean }).neg) labelView.neg = true;
117
-
return labelView;
118
-
});
91
+
// Add labels
92
+
const serviceLabels = labelsMap.get(service.uri);
93
+
if (serviceLabels && serviceLabels.length > 0) {
94
+
view.labels = serviceLabels.map((label) => ({
95
+
src: label.src,
96
+
uri: label.subject,
97
+
val: label.val,
98
+
cts: label.createdAt.toISOString(),
99
+
...(label.neg && { neg: true }),
100
+
}));
119
101
}
120
102
121
103
return view;
122
104
})
123
-
);
124
-
125
-
// Filter out null entries (services from creators without valid handles)
126
-
const validViews = views.filter((view) => view !== null);
105
+
.filter(Boolean);
127
106
128
-
res.json({ views: validViews });
107
+
res.json({ views });
129
108
} catch (error) {
130
109
handleError(res, error, 'getServices');
131
110
}
···
134
113
/**
135
114
* Get video job status
136
115
* GET /xrpc/app.bsky.video.getJobStatus
116
+
*
117
+
* NOTE: This endpoint is for video processing services, not AppView.
118
+
* Per ATProto architecture, video processing is handled by dedicated video services
119
+
* (e.g., video.bsky.app) that manage video transcoding, storage, and job tracking.
120
+
*
121
+
* AppView aggregates public data but does not process videos or maintain video job state.
122
+
* Users should interact with video services directly or via PDS proxy using service auth.
137
123
*/
138
124
export async function getJobStatus(req: Request, res: Response): Promise<void> {
139
125
try {
140
-
const params = getJobStatusSchema.parse(req.query);
141
-
142
-
// Get video job
143
-
const job = await storage.getVideoJob(params.jobId);
144
-
145
-
if (!job) {
146
-
res.status(404).json({ error: 'Job not found' });
147
-
return;
148
-
}
149
-
150
-
// Build response
151
-
const response: {
152
-
jobId: string;
153
-
did: string;
154
-
state: string;
155
-
progress: number;
156
-
blob?: unknown;
157
-
error?: string;
158
-
} = {
159
-
jobId: (job as { jobId: string }).jobId,
160
-
did: (job as { userDid: string }).userDid,
161
-
state: (job as { state: string }).state,
162
-
progress: (job as { progress: number }).progress,
163
-
};
164
-
165
-
// Add optional fields
166
-
if ((job as { blobRef?: unknown }).blobRef) {
167
-
response.blob = (job as { blobRef: unknown }).blobRef;
168
-
}
126
+
getJobStatusSchema.parse(req.query);
169
127
170
-
if ((job as { error?: string }).error) {
171
-
response.error = (job as { error: string }).error;
172
-
}
173
-
174
-
res.json({ jobStatus: response });
128
+
res.status(501).json({
129
+
error: 'NotImplemented',
130
+
message: 'This endpoint is for video processing services, not AppView. ' +
131
+
'Video processing (upload, transcoding, job tracking) is handled by dedicated video services. ' +
132
+
'Please use a video service endpoint (e.g., video.bsky.app) directly or via PDS proxy with service auth.',
133
+
});
175
134
} catch (error) {
176
135
handleError(res, error, 'getJobStatus');
177
136
}
···
180
139
/**
181
140
* Get video upload limits
182
141
* GET /xrpc/app.bsky.video.getUploadLimits
142
+
*
143
+
* NOTE: This endpoint is for video processing services, not AppView.
144
+
* Per ATProto architecture, video upload quotas and limits are managed by dedicated
145
+
* video services (e.g., video.bsky.app) that handle video processing and storage.
146
+
*
147
+
* AppView aggregates public data but does not manage user-specific video upload quotas.
148
+
* Users should check upload limits via video service endpoint or via PDS proxy with service auth.
183
149
*/
184
150
export async function getUploadLimits(
185
151
req: Request,
···
189
155
const userDid = await requireAuthDid(req, res);
190
156
if (!userDid) return;
191
157
192
-
const DAILY_VIDEO_LIMIT = Number(process.env.VIDEO_DAILY_LIMIT || 10);
193
-
const DAILY_BYTES_LIMIT = Number(
194
-
process.env.VIDEO_DAILY_BYTES || 100 * 1024 * 1024
195
-
);
196
-
197
-
const todayJobs = await storage.getUserVideoJobs(userDid, 1000);
198
-
const today = new Date();
199
-
today.setHours(0, 0, 0, 0);
200
-
const usedVideos = todayJobs.filter(
201
-
(j) => (j as { createdAt: Date }).createdAt >= today
202
-
).length;
203
-
const canUpload = usedVideos < DAILY_VIDEO_LIMIT;
204
-
205
-
res.json({
206
-
canUpload,
207
-
remainingDailyVideos: Math.max(0, DAILY_VIDEO_LIMIT - usedVideos),
208
-
remainingDailyBytes: DAILY_BYTES_LIMIT,
209
-
message: canUpload ? undefined : 'Daily upload limit reached',
210
-
error: undefined,
158
+
res.status(501).json({
159
+
error: 'NotImplemented',
160
+
message: 'This endpoint is for video processing services, not AppView. ' +
161
+
'Video upload limits are managed by dedicated video services. ' +
162
+
'Please use a video service endpoint (e.g., video.bsky.app) directly or via PDS proxy with service auth.',
211
163
});
212
164
} catch (error) {
213
165
handleError(res, error, 'getUploadLimits');
···
229
181
230
182
// Record basic metrics; future: persist interactions for ranking signals
231
183
const { metricsService } = await import('../../metrics');
232
-
for (const _ of (body as { interactions: unknown[] }).interactions) {
184
+
for (const _ of body.interactions) {
233
185
metricsService.recordApiRequest();
234
186
}
235
187
236
-
res.json({ success: true });
188
+
// AT Protocol spec: return empty object
189
+
res.json({});
237
190
} catch (error) {
238
191
handleError(res, error, 'sendInteractions');
239
192
}
+56
server/services/xrpc/utils/cache.ts
+56
server/services/xrpc/utils/cache.ts
···
13
13
timestamp: number;
14
14
}
15
15
16
+
interface PdsEndpointCache {
17
+
endpoint: string;
18
+
timestamp: number;
19
+
}
20
+
16
21
export class CacheManager {
17
22
// Preferences cache: DID -> { preferences: unknown[], timestamp: number }
18
23
private preferencesCache = new Map<string, PreferencesCache>();
···
22
27
private handleResolutionCache = new Map<string, HandleResolutionCache>();
23
28
private readonly HANDLE_RESOLUTION_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
24
29
30
+
// PDS endpoint cache: DID -> { endpoint: string, timestamp: number }
31
+
private pdsEndpointCache = new Map<string, PdsEndpointCache>();
32
+
private readonly PDS_ENDPOINT_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
33
+
25
34
constructor() {
26
35
// Clear expired cache entries every minute
27
36
setInterval(() => {
28
37
this.cleanExpiredPreferencesCache();
29
38
this.cleanExpiredHandleResolutionCache();
39
+
this.cleanExpiredPdsEndpointCache();
30
40
}, 60 * 1000);
31
41
}
32
42
···
132
142
133
143
expiredHandles.forEach((handle) => {
134
144
this.handleResolutionCache.delete(handle);
145
+
});
146
+
}
147
+
148
+
/**
149
+
* Get PDS endpoint from cache
150
+
*/
151
+
getPdsEndpoint(userDid: string): string | null {
152
+
const cached = this.pdsEndpointCache.get(userDid);
153
+
if (cached && !this.isPdsEndpointCacheExpired(cached)) {
154
+
return cached.endpoint;
155
+
}
156
+
return null;
157
+
}
158
+
159
+
/**
160
+
* Cache PDS endpoint for a DID
161
+
*/
162
+
cachePdsEndpoint(userDid: string, endpoint: string): void {
163
+
this.pdsEndpointCache.set(userDid, {
164
+
endpoint,
165
+
timestamp: Date.now(),
166
+
});
167
+
}
168
+
169
+
/**
170
+
* Check if PDS endpoint cache entry is expired
171
+
*/
172
+
private isPdsEndpointCacheExpired(cached: PdsEndpointCache): boolean {
173
+
return Date.now() - cached.timestamp > this.PDS_ENDPOINT_CACHE_TTL;
174
+
}
175
+
176
+
/**
177
+
* Clean expired entries from PDS endpoint cache
178
+
*/
179
+
private cleanExpiredPdsEndpointCache(): void {
180
+
const now = Date.now();
181
+
const expiredDids: string[] = [];
182
+
183
+
this.pdsEndpointCache.forEach((cached, did) => {
184
+
if (now - cached.timestamp > this.PDS_ENDPOINT_CACHE_TTL) {
185
+
expiredDids.push(did);
186
+
}
187
+
});
188
+
189
+
expiredDids.forEach((did) => {
190
+
this.pdsEndpointCache.delete(did);
135
191
});
136
192
}
137
193
}
+204
-32
server/services/xrpc/utils/resolvers.ts
+204
-32
server/services/xrpc/utils/resolvers.ts
···
29
29
}
30
30
31
31
/**
32
+
* Known PDS providers and their endpoints
33
+
* Used as fallback when DID document resolution fails
34
+
*/
35
+
const KNOWN_PDS_PROVIDERS: Record<string, string> = {
36
+
'bsky.social': 'https://bsky.social',
37
+
'bsky.app': 'https://bsky.social',
38
+
'staging.bsky.dev': 'https://staging.bsky.dev',
39
+
};
40
+
41
+
/**
42
+
* Attempt to discover PDS endpoint via handle resolution
43
+
* Uses the ATProto handle resolution mechanism (DNS TXT record or HTTPS well-known)
44
+
*/
45
+
async function discoverPdsViaHandle(handle: string): Promise<string | null> {
46
+
try {
47
+
// Try HTTPS well-known endpoint for handle verification
48
+
// This may also contain PDS information in some implementations
49
+
const wellKnownUrl = `https://${handle}/.well-known/atproto-did`;
50
+
const response = await fetch(wellKnownUrl, {
51
+
method: 'GET',
52
+
headers: { 'User-Agent': 'PublicAppView/1.0' },
53
+
signal: AbortSignal.timeout(5000), // 5 second timeout
54
+
});
55
+
56
+
if (response.ok) {
57
+
const didFromHandle = (await response.text()).trim();
58
+
if (didFromHandle.startsWith('did:')) {
59
+
// We found the DID, but we need to resolve it to get the PDS
60
+
// This creates a recursive resolution attempt, which is intentional
61
+
console.log(
62
+
`[PDS_DISCOVERY] Handle ${handle} resolved to DID via well-known: ${didFromHandle}`
63
+
);
64
+
// Don't recurse here - just return null and let caller handle it
65
+
return null;
66
+
}
67
+
}
68
+
} catch (error) {
69
+
// Well-known resolution failed, which is expected for most handles
70
+
console.log(
71
+
`[PDS_DISCOVERY] Well-known resolution failed for handle ${handle} (this is normal)`
72
+
);
73
+
}
74
+
75
+
return null;
76
+
}
77
+
78
+
/**
79
+
* Extract domain from handle and validate it
80
+
* Returns null if handle format is invalid
81
+
*/
82
+
function extractDomainFromHandle(handle: string): string | null {
83
+
if (!handle || typeof handle !== 'string') {
84
+
return null;
85
+
}
86
+
87
+
// Handle must be a valid domain format
88
+
// Examples: alice.bsky.social, bob.example.com, charlie.co.uk
89
+
const parts = handle.toLowerCase().split('.');
90
+
91
+
if (parts.length < 2) {
92
+
return null; // Invalid handle format
93
+
}
94
+
95
+
// Check for known multi-part TLDs (e.g., .co.uk, .com.au)
96
+
const knownMultiPartTlds = [
97
+
'co.uk',
98
+
'com.au',
99
+
'co.nz',
100
+
'co.za',
101
+
'com.br',
102
+
'co.jp',
103
+
'ac.uk',
104
+
'gov.uk',
105
+
];
106
+
107
+
// Try to extract domain intelligently
108
+
if (parts.length >= 3) {
109
+
const lastTwoParts = parts.slice(-2).join('.');
110
+
if (knownMultiPartTlds.includes(lastTwoParts)) {
111
+
// Handle multi-part TLD: take last 3 parts (subdomain.domain.co.uk -> domain.co.uk)
112
+
if (parts.length >= 3) {
113
+
return parts.slice(-3).join('.');
114
+
}
115
+
}
116
+
}
117
+
118
+
// Default: take last 2 parts (subdomain.domain.com -> domain.com)
119
+
return parts.slice(-2).join('.');
120
+
}
121
+
122
+
/**
32
123
* Get PDS endpoint for a user DID
124
+
* Enhanced with improved fallback logic and better error handling
33
125
*/
34
126
export async function getUserPdsEndpoint(
35
127
userDid: string
36
128
): Promise<string | null> {
37
129
try {
38
-
// Resolve DID document to find PDS endpoint
130
+
// Step 0: Check cache first
131
+
const cachedEndpoint = cacheManager.getPdsEndpoint(userDid);
132
+
if (cachedEndpoint) {
133
+
console.log(
134
+
`[PDS_DISCOVERY] ✓ Cache hit for ${userDid}: ${cachedEndpoint}`
135
+
);
136
+
return cachedEndpoint;
137
+
}
138
+
139
+
// Step 1: Resolve DID document to find PDS endpoint (primary method)
39
140
const didDoc = await resolveDidDocument(userDid);
40
-
if (!didDoc) return null;
41
141
42
-
// Look for PDS endpoint in service endpoints
43
-
const services = (didDoc as { service?: unknown[] }).service || [];
44
-
const pdsService = services.find((service: unknown) => {
45
-
const svc = service as { type?: string; id?: string };
46
-
return (
47
-
svc.type === 'AtprotoPersonalDataServer' || svc.id === '#atproto_pds'
142
+
if (didDoc) {
143
+
// Look for PDS endpoint in service endpoints
144
+
const services = (didDoc as { service?: unknown[] }).service || [];
145
+
const pdsService = services.find((service: unknown) => {
146
+
const svc = service as { type?: string; id?: string };
147
+
return (
148
+
svc.type === 'AtprotoPersonalDataServer' || svc.id === '#atproto_pds'
149
+
);
150
+
});
151
+
152
+
if (
153
+
pdsService &&
154
+
(pdsService as { serviceEndpoint?: string }).serviceEndpoint
155
+
) {
156
+
const endpoint = (pdsService as { serviceEndpoint: string })
157
+
.serviceEndpoint;
158
+
159
+
// SECURITY: Validate PDS endpoint to prevent SSRF attacks
160
+
// Malicious DID documents could point to internal services
161
+
if (!isUrlSafeToFetch(endpoint)) {
162
+
console.error(
163
+
`[PDS_DISCOVERY] SECURITY: Blocked unsafe PDS endpoint for ${userDid}: ${endpoint}`
164
+
);
165
+
// Don't return null immediately - try fallback methods
166
+
} else {
167
+
console.log(
168
+
`[PDS_DISCOVERY] ✓ Resolved PDS from DID document: ${endpoint}`
169
+
);
170
+
// Cache the successfully resolved endpoint
171
+
cacheManager.cachePdsEndpoint(userDid, endpoint);
172
+
return endpoint;
173
+
}
174
+
}
175
+
}
176
+
177
+
// Step 2: Fallback to handle-based resolution
178
+
console.log(
179
+
`[PDS_DISCOVERY] DID document resolution failed or missing PDS service, trying handle-based fallback for ${userDid}`
180
+
);
181
+
182
+
const user = await storage.getUser(userDid);
183
+
if (!user?.handle) {
184
+
console.warn(
185
+
`[PDS_DISCOVERY] No handle found for ${userDid}, cannot use fallback`
48
186
);
49
-
});
187
+
return null;
188
+
}
50
189
51
-
if (
52
-
pdsService &&
53
-
(pdsService as { serviceEndpoint?: string }).serviceEndpoint
54
-
) {
55
-
const endpoint = (pdsService as { serviceEndpoint: string })
56
-
.serviceEndpoint;
190
+
const handle = user.handle.toLowerCase();
191
+
console.log(`[PDS_DISCOVERY] Found handle: ${handle}`);
57
192
58
-
// SECURITY: Validate PDS endpoint to prevent SSRF attacks
59
-
// Malicious DID documents could point to internal services
60
-
if (!isUrlSafeToFetch(endpoint)) {
61
-
console.error(
62
-
`[XRPC] SECURITY: Blocked unsafe PDS endpoint for ${userDid}: ${endpoint}`
193
+
// Step 2a: Check known PDS providers (fast path)
194
+
for (const [domain, pdsEndpoint] of Object.entries(KNOWN_PDS_PROVIDERS)) {
195
+
if (handle.endsWith(`.${domain}`) || handle === domain) {
196
+
console.log(
197
+
`[PDS_DISCOVERY] ✓ Matched known provider: ${domain} -> ${pdsEndpoint}`
63
198
);
64
-
return null;
199
+
// Cache the successfully resolved endpoint
200
+
cacheManager.cachePdsEndpoint(userDid, pdsEndpoint);
201
+
return pdsEndpoint;
65
202
}
203
+
}
66
204
67
-
return endpoint;
205
+
// Step 2b: Try handle-based discovery (ATProto well-known)
206
+
const discoveredPds = await discoverPdsViaHandle(handle);
207
+
if (discoveredPds) {
208
+
// Validate before returning
209
+
if (isUrlSafeToFetch(discoveredPds)) {
210
+
console.log(
211
+
`[PDS_DISCOVERY] ✓ Discovered PDS via handle: ${discoveredPds}`
212
+
);
213
+
// Cache the successfully resolved endpoint
214
+
cacheManager.cachePdsEndpoint(userDid, discoveredPds);
215
+
return discoveredPds;
216
+
} else {
217
+
console.warn(
218
+
`[PDS_DISCOVERY] SECURITY: Blocked unsafe discovered PDS: ${discoveredPds}`
219
+
);
220
+
}
68
221
}
69
222
70
-
// Fallback: try to construct PDS URL from handle if available
71
-
const user = await storage.getUser(userDid);
72
-
if (user?.handle) {
73
-
// For now, assume bsky.social PDS for handles ending in .bsky.social
74
-
if (user.handle.endsWith('.bsky.social')) {
75
-
return 'https://bsky.social';
223
+
// Step 2c: Extract domain from handle and construct PDS URL
224
+
const domain = extractDomainFromHandle(handle);
225
+
if (domain) {
226
+
const constructedPds = `https://${domain}`;
227
+
228
+
// Validate the constructed URL
229
+
if (isUrlSafeToFetch(constructedPds)) {
230
+
console.log(
231
+
`[PDS_DISCOVERY] ⚠ Using constructed PDS URL from handle domain (may be incorrect): ${constructedPds}`
232
+
);
233
+
console.log(
234
+
`[PDS_DISCOVERY] ⚠ This is a heuristic fallback - the PDS may not be at this domain`
235
+
);
236
+
// Cache the constructed endpoint with shorter TTL (since it's less reliable)
237
+
// Note: The cache uses a fixed TTL, but this could be enhanced to use different TTLs
238
+
cacheManager.cachePdsEndpoint(userDid, constructedPds);
239
+
return constructedPds;
240
+
} else {
241
+
console.warn(
242
+
`[PDS_DISCOVERY] SECURITY: Blocked unsafe constructed PDS: ${constructedPds}`
243
+
);
76
244
}
77
-
// For other handles, try to construct PDS URL
78
-
// This is a simplified approach - in production you'd need more sophisticated PDS discovery
79
-
return `https://${user.handle.split('.').slice(-2).join('.')}`;
80
245
}
81
246
247
+
// Step 3: All methods failed
248
+
console.error(
249
+
`[PDS_DISCOVERY] ✗ Failed to resolve PDS endpoint for ${userDid} (handle: ${handle})`
250
+
);
251
+
console.error(
252
+
`[PDS_DISCOVERY] Recommendation: Ensure DID document is properly configured with PDS service endpoint`
253
+
);
82
254
return null;
83
255
} catch (error) {
84
256
console.error(
85
-
`[PREFERENCES] Error resolving PDS endpoint for ${userDid}:`,
257
+
`[PDS_DISCOVERY] Error resolving PDS endpoint for ${userDid}:`,
86
258
error
87
259
);
88
260
return null;
+180
-23
server/storage.ts
+180
-23
server/storage.ts
···
123
123
createUser(user: InsertUser): Promise<User>;
124
124
updateUser(did: string, data: Partial<InsertUser>): Promise<User | undefined>;
125
125
upsertUserHandle(did: string, handle: string): Promise<void>;
126
-
getSuggestedUsers(viewerDid?: string, limit?: number): Promise<User[]>;
126
+
getSuggestedUsers(
127
+
viewerDid?: string,
128
+
limit?: number,
129
+
cursor?: string
130
+
): Promise<{ users: User[]; cursor?: string }>;
127
131
getUserFollowerCount(did: string): Promise<number>;
128
132
getUsersFollowerCounts(dids: string[]): Promise<Map<string, number>>;
129
133
getUserFollowingCount(did: string): Promise<number>;
···
326
330
// Thread mute operations
327
331
createThreadMute(threadMute: InsertThreadMute): Promise<ThreadMute>;
328
332
deleteThreadMute(uri: string): Promise<void>;
333
+
deleteThreadMuteByRoot(
334
+
muterDid: string,
335
+
threadRootUri: string
336
+
): Promise<void>;
329
337
getThreadMutes(
330
338
muterDid: string,
331
339
limit?: number,
···
482
490
getNotifications(
483
491
recipientDid: string,
484
492
limit?: number,
485
-
cursor?: string
493
+
cursor?: string,
494
+
seenAt?: Date
486
495
): Promise<Notification[]>;
487
496
getUnreadNotificationCount(recipientDid: string): Promise<number>;
488
497
markNotificationsAsRead(recipientDid: string, seenAt?: Date): Promise<void>;
···
492
501
deleteList(uri: string): Promise<void>;
493
502
getList(uri: string): Promise<List | undefined>;
494
503
getUserLists(creatorDid: string, limit?: number): Promise<List[]>;
504
+
getUserListsWithPagination(
505
+
creatorDid: string,
506
+
limit?: number,
507
+
cursor?: string,
508
+
purposes?: string[]
509
+
): Promise<{ lists: List[]; cursor?: string }>;
495
510
496
511
// List item operations
497
512
createListItem(item: InsertListItem): Promise<ListItem>;
498
513
deleteListItem(uri: string): Promise<void>;
499
514
getListItems(listUri: string, limit?: number): Promise<ListItem[]>;
515
+
getListItemsWithPagination(
516
+
listUri: string,
517
+
limit?: number,
518
+
cursor?: string
519
+
): Promise<{ items: ListItem[]; cursor?: string }>;
500
520
getListFeed(
501
521
listUri: string,
502
522
limit?: number,
···
565
585
): Promise<PushSubscription>;
566
586
deletePushSubscription(id: number): Promise<void>;
567
587
deletePushSubscriptionByToken(token: string): Promise<void>;
588
+
deletePushSubscriptionByDetails(
589
+
userDid: string,
590
+
token: string,
591
+
platform: string,
592
+
appId: string
593
+
): Promise<void>;
568
594
getUserPushSubscriptions(userDid: string): Promise<PushSubscription[]>;
569
595
getPushSubscription(id: number): Promise<PushSubscription | undefined>;
570
596
updatePushSubscription(
···
766
792
return await this.db.select().from(users).where(inArray(users.did, dids));
767
793
}
768
794
769
-
async getSuggestedUsers(viewerDid?: string, limit = 25): Promise<User[]> {
795
+
async getSuggestedUsers(
796
+
viewerDid?: string,
797
+
limit = 25,
798
+
cursor?: string
799
+
): Promise<{ users: User[]; cursor?: string }> {
800
+
const conditions: SQL<unknown>[] = [];
801
+
770
802
if (viewerDid) {
771
803
const followedDids = await db
772
804
.select({ did: follows.followingDid })
···
775
807
776
808
const followedDidList = followedDids.map((f) => f.did);
777
809
810
+
conditions.push(sql`${users.did} != ${viewerDid}`);
811
+
778
812
if (followedDidList.length > 0) {
779
-
return await db
780
-
.select()
781
-
.from(users)
782
-
.where(
783
-
and(
784
-
sql`${users.did} != ${viewerDid}`,
785
-
sql`${users.did} NOT IN (${sql.join(
786
-
followedDidList.map((did) => sql`${did}`),
787
-
sql`, `
788
-
)})`
789
-
)
790
-
)
791
-
.orderBy(desc(users.createdAt))
792
-
.limit(limit);
813
+
conditions.push(
814
+
sql`${users.did} NOT IN (${sql.join(
815
+
followedDidList.map((did) => sql`${did}`),
816
+
sql`, `
817
+
)})`
818
+
);
793
819
}
794
820
}
795
821
796
-
return await db
822
+
// Add cursor condition for pagination
823
+
if (cursor) {
824
+
conditions.push(sql`${users.createdAt} < ${new Date(cursor)}`);
825
+
}
826
+
827
+
const query = db
797
828
.select()
798
829
.from(users)
799
830
.orderBy(desc(users.createdAt))
800
-
.limit(limit);
831
+
.limit(limit + 1);
832
+
833
+
const results =
834
+
conditions.length > 0
835
+
? await query.where(and(...conditions))
836
+
: await query;
837
+
838
+
const hasMore = results.length > limit;
839
+
const userResults = hasMore ? results.slice(0, limit) : results;
840
+
const nextCursor = hasMore
841
+
? userResults[userResults.length - 1]?.createdAt.toISOString()
842
+
: undefined;
843
+
844
+
return {
845
+
users: userResults,
846
+
cursor: nextCursor,
847
+
};
801
848
}
802
849
803
850
async getUserFollowerCount(did: string): Promise<number> {
···
2012
2059
await this.db.delete(threadMutes).where(eq(threadMutes.uri, uri));
2013
2060
}
2014
2061
2062
+
async deleteThreadMuteByRoot(
2063
+
muterDid: string,
2064
+
threadRootUri: string
2065
+
): Promise<void> {
2066
+
await this.db
2067
+
.delete(threadMutes)
2068
+
.where(
2069
+
and(
2070
+
eq(threadMutes.muterDid, muterDid),
2071
+
eq(threadMutes.threadRootUri, threadRootUri)
2072
+
)
2073
+
);
2074
+
}
2075
+
2015
2076
async getThreadMutes(
2016
2077
muterDid: string,
2017
2078
limit = 100,
···
2991
3052
async getNotifications(
2992
3053
recipientDid: string,
2993
3054
limit = 50,
2994
-
cursor?: string
3055
+
cursor?: string,
3056
+
seenAt?: Date
2995
3057
): Promise<Notification[]> {
2996
3058
const conditions = [eq(notifications.recipientDid, recipientDid)];
2997
3059
···
2999
3061
conditions.push(sql`${notifications.indexedAt} < ${new Date(cursor)}`);
3000
3062
}
3001
3063
3064
+
// Filter by seenAt if provided (only return notifications before this time)
3065
+
if (seenAt) {
3066
+
conditions.push(sql`${notifications.indexedAt} <= ${seenAt}`);
3067
+
}
3068
+
3002
3069
return await db
3003
3070
.select()
3004
3071
.from(notifications)
···
3062
3129
.limit(limit);
3063
3130
}
3064
3131
3132
+
async getUserListsWithPagination(
3133
+
creatorDid: string,
3134
+
limit = 50,
3135
+
cursor?: string,
3136
+
purposes?: string[]
3137
+
): Promise<{ lists: List[]; cursor?: string }> {
3138
+
const conditions = [eq(lists.creatorDid, creatorDid)];
3139
+
3140
+
if (cursor) {
3141
+
conditions.push(sql`${lists.indexedAt} < ${new Date(cursor)}`);
3142
+
}
3143
+
3144
+
if (purposes && purposes.length > 0) {
3145
+
conditions.push(inArray(lists.purpose, purposes));
3146
+
}
3147
+
3148
+
const results = await this.db
3149
+
.select()
3150
+
.from(lists)
3151
+
.where(and(...conditions))
3152
+
.orderBy(desc(lists.indexedAt))
3153
+
.limit(limit + 1);
3154
+
3155
+
const hasMore = results.length > limit;
3156
+
const listResults = hasMore ? results.slice(0, limit) : results;
3157
+
const nextCursor = hasMore ? listResults[listResults.length - 1]?.indexedAt.toISOString() : undefined;
3158
+
3159
+
return {
3160
+
lists: listResults,
3161
+
cursor: nextCursor,
3162
+
};
3163
+
}
3164
+
3065
3165
async createListItem(item: InsertListItem): Promise<ListItem> {
3066
3166
const [newItem] = await this.db
3067
3167
.insert(listItems)
···
3084
3184
.limit(limit);
3085
3185
}
3086
3186
3187
+
async getListItemsWithPagination(
3188
+
listUri: string,
3189
+
limit = 50,
3190
+
cursor?: string
3191
+
): Promise<{ items: ListItem[]; cursor?: string }> {
3192
+
const conditions = [eq(listItems.listUri, listUri)];
3193
+
3194
+
if (cursor) {
3195
+
conditions.push(sql`${listItems.indexedAt} < ${new Date(cursor)}`);
3196
+
}
3197
+
3198
+
const items = await this.db
3199
+
.select()
3200
+
.from(listItems)
3201
+
.where(and(...conditions))
3202
+
.orderBy(desc(listItems.indexedAt))
3203
+
.limit(limit + 1);
3204
+
3205
+
const hasMore = items.length > limit;
3206
+
const resultItems = hasMore ? items.slice(0, limit) : items;
3207
+
const nextCursor = hasMore ? resultItems[resultItems.length - 1]?.indexedAt.toISOString() : undefined;
3208
+
3209
+
return {
3210
+
items: resultItems,
3211
+
cursor: nextCursor,
3212
+
};
3213
+
}
3214
+
3087
3215
async getListFeed(
3088
3216
listUri: string,
3089
3217
limit = 50,
3090
3218
cursor?: string
3091
3219
): Promise<Post[]> {
3092
-
const items = await this.getListItems(listUri, 500);
3220
+
// Fetch all list members (no hardcoded limit)
3221
+
// For very large lists, consider adding pagination or caching
3222
+
const items = await this.db
3223
+
.select()
3224
+
.from(listItems)
3225
+
.where(eq(listItems.listUri, listUri))
3226
+
.orderBy(desc(listItems.indexedAt));
3227
+
3093
3228
const memberDids = items.map((item) => item.subjectDid);
3094
3229
3095
3230
if (memberDids.length === 0) {
···
3102
3237
conditions.push(sql`${posts.indexedAt} < ${new Date(cursor)}`);
3103
3238
}
3104
3239
3105
-
return await this.db
3240
+
// Fetch limit + 1 to determine if more results exist
3241
+
const results = await this.db
3106
3242
.select()
3107
3243
.from(posts)
3108
3244
.where(and(...conditions))
3109
3245
.orderBy(desc(posts.indexedAt))
3110
-
.limit(limit);
3246
+
.limit(limit + 1);
3247
+
3248
+
// Return only requested limit (endpoint will use length to determine cursor)
3249
+
return results.slice(0, limit);
3111
3250
}
3112
3251
3113
3252
// Feed generator operations
···
3467
3606
await this.db
3468
3607
.delete(pushSubscriptions)
3469
3608
.where(eq(pushSubscriptions.token, token));
3609
+
}
3610
+
3611
+
async deletePushSubscriptionByDetails(
3612
+
userDid: string,
3613
+
token: string,
3614
+
platform: string,
3615
+
appId: string
3616
+
): Promise<void> {
3617
+
await this.db
3618
+
.delete(pushSubscriptions)
3619
+
.where(
3620
+
and(
3621
+
eq(pushSubscriptions.userDid, userDid),
3622
+
eq(pushSubscriptions.token, token),
3623
+
eq(pushSubscriptions.platform, platform),
3624
+
eq(pushSubscriptions.appId, appId)
3625
+
)
3626
+
);
3470
3627
}
3471
3628
3472
3629
async getUserPushSubscriptions(userDid: string): Promise<PushSubscription[]> {