diff --git a/drizzle/0031_atproto_labels.sql b/drizzle/0031_atproto_labels.sql new file mode 100644 index 0000000..7892c1e --- /dev/null +++ b/drizzle/0031_atproto_labels.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "atproto_labels" jsonb DEFAULT '[]'::jsonb NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index aa68642..5644908 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1771876800000, "tag": "0030_atproto_profile_stats", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1772265600000, + "tag": "0031_atproto_labels", + "breakpoints": true } ] } diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index ea07e44..0f42841 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, boolean, integer, index } from 'drizzle-orm/pg-core' +import { pgTable, text, timestamp, boolean, integer, index, jsonb } from 'drizzle-orm/pg-core' import { sql } from 'drizzle-orm' export const users = pgTable( @@ -29,6 +29,11 @@ export const users = pgTable( followsCount: integer('follows_count').notNull().default(0), atprotoPostsCount: integer('atproto_posts_count').notNull().default(0), hasBlueskyProfile: boolean('has_bluesky_profile').notNull().default(false), + /** AT Protocol labels from the Bluesky AppView (self-applied and moderator-applied). */ + atprotoLabels: jsonb('atproto_labels') + .$type>() + .notNull() + .default([]), }, (table) => [ index('users_role_elevated_idx') diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index b6d5ed8..739ab6a 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -61,6 +61,17 @@ const profileJsonSchema = { votesReceived: { type: 'number' as const }, }, }, + labels: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + val: { type: 'string' as const }, + src: { type: 'string' as const }, + isSelfLabel: { type: 'boolean' as const }, + }, + }, + }, }, } @@ -375,6 +386,13 @@ export function profileRoutes(): FastifyPluginCallback { resolved = resolveProfile(sourceProfile, override) } + // Map stored labels to response format with isSelfLabel flag + const labels = user.atprotoLabels.map((l) => ({ + val: l.val, + src: l.src, + isSelfLabel: l.src === user.did, + })) + const globalActivity = { topicCount, replyCount, @@ -398,6 +416,7 @@ export function profileRoutes(): FastifyPluginCallback { hasBlueskyProfile: user.hasBlueskyProfile, communityCount, activity: scopedActivity ?? globalActivity, + labels, } if (communityDid && communityCount >= 2) { diff --git a/src/services/profile-sync.ts b/src/services/profile-sync.ts index 7d92609..a6dac1c 100644 --- a/src/services/profile-sync.ts +++ b/src/services/profile-sync.ts @@ -19,6 +19,7 @@ export interface ProfileData { followsCount: number atprotoPostsCount: number hasBlueskyProfile: boolean + labels: Array<{ val: string; src: string; neg: boolean; cts: string }> } export interface ProfileSyncService { @@ -35,6 +36,7 @@ const NULL_PROFILE: ProfileData = { followsCount: 0, atprotoPostsCount: 0, hasBlueskyProfile: false, + labels: [], } // --------------------------------------------------------------------------- @@ -47,6 +49,7 @@ const BSKY_PUBLIC_API = 'https://public.api.bsky.app' interface AgentLike { getProfile(params: { actor: string }): Promise<{ data: { + did?: string displayName?: string avatar?: string banner?: string @@ -54,6 +57,7 @@ interface AgentLike { followersCount?: number followsCount?: number postsCount?: number + labels?: Array<{ val: string; src: string; uri: string; neg?: boolean; cts: string }> } }> } @@ -95,6 +99,11 @@ export function createProfileSyncService( try { const agent = agentFactory.createAgent() const response = await agent.getProfile({ actor: did }) + const rawLabels = response.data.labels ?? [] + const labels = rawLabels + .filter((l) => !l.neg) + .map((l) => ({ val: l.val, src: l.src, neg: false as const, cts: l.cts })) + const sanitizedName = stripControlCharacters(response.data.displayName ?? '') profileData = { displayName: sanitizedName || null, @@ -105,6 +114,7 @@ export function createProfileSyncService( followsCount: response.data.followsCount ?? 0, atprotoPostsCount: response.data.postsCount ?? 0, hasBlueskyProfile: true, + labels, } } catch (err: unknown) { logger.debug({ did, err }, 'profile sync failed: could not fetch profile from public API') @@ -124,6 +134,7 @@ export function createProfileSyncService( followsCount: profileData.followsCount, atprotoPostsCount: profileData.atprotoPostsCount, hasBlueskyProfile: profileData.hasBlueskyProfile, + atprotoLabels: profileData.labels, lastActiveAt: new Date(), }) .where(eq(users.did, did)) diff --git a/tests/unit/routes/profiles.test.ts b/tests/unit/routes/profiles.test.ts index 5a34fc2..24e2167 100644 --- a/tests/unit/routes/profiles.test.ts +++ b/tests/unit/routes/profiles.test.ts @@ -67,6 +67,7 @@ function sampleUserRow(overrides?: Record) { followsCount: 0, atprotoPostsCount: 0, hasBlueskyProfile: false, + atprotoLabels: [], ...overrides, } } @@ -569,6 +570,67 @@ describe('profile routes', () => { expect(body.lastActiveAt).toBe(TEST_NOW) }) + it('includes labels with isSelfLabel flag in response', async () => { + const labelsData = [ + { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, + { + val: '!warn', + src: 'did:plc:ozone-mod-service', + neg: false, + cts: '2026-01-20T12:00:00.000Z', + }, + ] + + selectChain.where.mockResolvedValueOnce([sampleUserRow({ atprotoLabels: labelsData })]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + labels: Array<{ val: string; src: string; isSelfLabel: boolean }> + }>() + expect(body.labels).toStrictEqual([ + { val: 'adult-content', src: TEST_DID, isSelfLabel: true }, + { val: '!warn', src: 'did:plc:ozone-mod-service', isSelfLabel: false }, + ]) + }) + + it('returns empty labels array when user has no labels', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow({ atprotoLabels: [] })]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ labels: unknown[] }>() + expect(body.labels).toStrictEqual([]) + }) + // ----------------------------------------------------------------------- // Community-scoped activity // ----------------------------------------------------------------------- diff --git a/tests/unit/services/profile-sync.test.ts b/tests/unit/services/profile-sync.test.ts index 50bdece..f9dde66 100644 --- a/tests/unit/services/profile-sync.test.ts +++ b/tests/unit/services/profile-sync.test.ts @@ -61,6 +61,29 @@ const MOCK_PROFILE_RESPONSE = { followersCount: 150, followsCount: 75, postsCount: 230, + labels: [ + { + src: TEST_DID, + uri: `at://${TEST_DID}`, + val: 'adult-content', + neg: false, + cts: '2026-01-15T10:00:00.000Z', + }, + { + src: 'did:plc:ozone-mod-service', + uri: `at://${TEST_DID}`, + val: '!warn', + neg: false, + cts: '2026-01-20T12:00:00.000Z', + }, + { + src: TEST_DID, + uri: `at://${TEST_DID}`, + val: 'old-label', + neg: true, + cts: '2026-01-25T08:00:00.000Z', + }, + ], }, } @@ -112,6 +135,15 @@ describe('ProfileSyncService', () => { followsCount: 75, atprotoPostsCount: 230, hasBlueskyProfile: true, + labels: [ + { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, + { + val: '!warn', + src: 'did:plc:ozone-mod-service', + neg: false, + cts: '2026-01-20T12:00:00.000Z', + }, + ], }) }) @@ -127,6 +159,32 @@ describe('ProfileSyncService', () => { expect(mockDb._mocks.updateFn).toHaveBeenCalled() }) + // ------------------------------------------------------------------------- + // Label capture + // ------------------------------------------------------------------------- + + it('returns labels from profile, filtering out negated labels', async () => { + const result = await service.syncProfile(TEST_DID) + + expect(result.labels).toStrictEqual([ + { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, + { + val: '!warn', + src: 'did:plc:ozone-mod-service', + neg: false, + cts: '2026-01-20T12:00:00.000Z', + }, + ]) + }) + + it('returns empty labels array when profile has no labels', async () => { + mockGetProfile.mockResolvedValue(MOCK_MINIMAL_PROFILE_RESPONSE) + + const result = await service.syncProfile(TEST_DID) + + expect(result.labels).toStrictEqual([]) + }) + // ------------------------------------------------------------------------- // No profile fields (minimal profile) // ------------------------------------------------------------------------- @@ -145,6 +203,7 @@ describe('ProfileSyncService', () => { followsCount: 0, atprotoPostsCount: 0, hasBlueskyProfile: true, + labels: [], }) }) @@ -166,6 +225,7 @@ describe('ProfileSyncService', () => { followsCount: 0, atprotoPostsCount: 0, hasBlueskyProfile: false, + labels: [], }) }) @@ -209,6 +269,15 @@ describe('ProfileSyncService', () => { followsCount: 75, atprotoPostsCount: 230, hasBlueskyProfile: true, + labels: [ + { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, + { + val: '!warn', + src: 'did:plc:ozone-mod-service', + neg: false, + cts: '2026-01-20T12:00:00.000Z', + }, + ], }) })