Barazo AppView backend barazo.forum

feat: multi-tenant community resolver with RLS isolation (#82)

* refactor(config): rename COMMUNITY_MODE 'global' to 'multi'

Aligns with multi-tenant design: 'single' for self-hosters, 'multi'
for Barazo SaaS with hostname-based community resolution.

* refactor(schema): make communityDid the primary key for community_settings

Remove singleton id column, add domains JSONB column for custom domain
configuration. communityDid is now the primary key, enabling per-community
settings in multi-tenant mode.

* feat(middleware): add CommunityResolver with hostname-based resolution

Introduces CommunityResolver interface with single-mode implementation.
Fastify onRequest hook resolves community from hostname, sets
request.communityDid, and configures RLS session variable.

* refactor(routes): replace getCommunityDid(env) with request.communityDid

Community DID is now resolved per-request by the CommunityResolver
middleware instead of read from process-level env config. Removes
getCommunityDid import from all 9 route files.

* refactor(queries): replace WHERE id='default' with communityDid lookups

All community_settings queries now use communityDid as the lookup key.
Adds requireCommunityDid() helper for type-safe extraction from request.
Updates setup service to accept communityDid parameter. Removes id column
references from serializers and JSON schemas.

* feat(schema): add RLS tenant isolation policies to all community-scoped tables

Enables Row-Level Security on 17 tables across 15 schema files using
pgPolicy with current_setting('app.current_community_did'). Creates
barazo_app database role for policy enforcement. Tables without
communityDid (users, firehose, trust graph) are excluded.

* test: update all tests for multi-tenant schema changes

Fix 400 test failures across 13 test files by adding communityDid
request decoration, replacing 'global' with 'multi', updating
setup service call signatures, and adjusting mock query chains.

* test(rls): add tenant isolation integration tests

- Add tenant-isolation.test.ts using testcontainers with host networking
to verify PostgreSQL RLS policies across two communities
- Tests cover SELECT/INSERT/UPDATE/DELETE isolation, aggregator mode,
and withCheck enforcement blocking cross-tenant writes
- Fix lint errors: require-await, unnecessary type assertions, template
literal types, unsafe assignment
- Fix Fastify onRequest hooks in 15 test files to use done() callback
pattern instead of async (prevents request hanging)
- Add roles.ts to drizzle.config.ts schema list

authored by

Guido X Jansen and committed by
GitHub
31566cc7 3ac07a67

+1125 -184
+1
drizzle.config.ts
··· 2 2 3 3 export default defineConfig({ 4 4 schema: [ 5 + './src/db/schema/roles.ts', 5 6 './src/db/schema/users.ts', 6 7 './src/db/schema/firehose.ts', 7 8 './src/db/schema/topics.ts',
+32 -1
src/app.ts
··· 9 9 import * as Sentry from '@sentry/node' 10 10 import type { FastifyError } from 'fastify' 11 11 import type { NodeOAuthClient } from '@atproto/oauth-client-node' 12 + import { sql } from 'drizzle-orm' 12 13 import type { Env } from './config/env.js' 14 + import { getCommunityDid } from './config/env.js' 15 + import { createSingleResolver, registerCommunityResolver } from './middleware/community-resolver.js' 16 + import type { CommunityResolver } from './middleware/community-resolver.js' 13 17 import { createDb } from './db/index.js' 14 18 import { createCache } from './cache/index.js' 15 19 import { FirehoseService } from './firehose/service.js' ··· 162 166 limits: { fileSize: env.UPLOAD_MAX_SIZE_BYTES }, 163 167 }) 164 168 169 + // Community resolver (must run before auth middleware) 170 + let resolver: CommunityResolver 171 + if (env.COMMUNITY_MODE === 'multi') { 172 + try { 173 + const mod = await import('@barazo/multi-tenant') 174 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 175 + resolver = mod.createMultiResolver(db, cache) 176 + } catch { 177 + throw new Error( 178 + 'COMMUNITY_MODE is "multi" but @barazo/multi-tenant package is not installed. ' + 179 + 'Install it or switch to COMMUNITY_MODE="single".' 180 + ) 181 + } 182 + } else { 183 + resolver = createSingleResolver(getCommunityDid(env)) 184 + } 185 + registerCommunityResolver(app, resolver, env.COMMUNITY_MODE) 186 + 187 + // Set RLS session variable per request 188 + app.addHook('onRequest', async (request) => { 189 + if (request.communityDid) { 190 + await db.execute( 191 + sql`SELECT set_config('app.current_community_did', ${request.communityDid}, true)` 192 + ) 193 + } 194 + }) 195 + 165 196 // OAuth client 166 197 const oauthClient = createOAuthClient(env, cache, app.log) 167 198 app.decorate('oauthClient', oauthClient) ··· 198 229 const requireAdmin = createRequireAdmin(db, authMiddleware, app.log) 199 230 app.decorate('requireAdmin', requireAdmin) 200 231 201 - // Operator middleware (global mode only) 232 + // Operator middleware (multi mode only) 202 233 const requireOperator = createRequireOperator(env, authMiddleware, app.log) 203 234 app.decorate('requireOperator', requireOperator) 204 235
+3 -3
src/auth/require-operator.ts
··· 7 7 * Create a requireOperator preHandler hook for Fastify routes. 8 8 * 9 9 * This middleware: 10 - * 1. Returns 404 if COMMUNITY_MODE is not "global" (hides global routes in single mode) 10 + * 1. Returns 404 if COMMUNITY_MODE is not "multi" (hides operator routes in single mode) 11 11 * 2. Delegates to requireAuth to verify the user is authenticated 12 12 * 3. Checks if the user's DID is in the OPERATOR_DIDS list 13 13 * 4. Returns 403 if the user is not an operator ··· 21 21 logger?: Logger 22 22 ): (request: FastifyRequest, reply: FastifyReply) => Promise<void> { 23 23 return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => { 24 - // Global-mode-only routes return 404 in single-community mode 25 - if (env.COMMUNITY_MODE !== 'global') { 24 + // Multi-mode-only routes return 404 in single-community mode 25 + if (env.COMMUNITY_MODE !== 'multi') { 26 26 await reply.status(404).send({ error: 'Not found' }) 27 27 return 28 28 }
+2 -2
src/config/env.ts
··· 36 36 CORS_ORIGINS: z.string().default('http://localhost:3001'), 37 37 38 38 // Community 39 - COMMUNITY_MODE: z.enum(['single', 'global']).default('single'), 39 + COMMUNITY_MODE: z.enum(['single', 'multi']).default('single'), 40 40 COMMUNITY_DID: z.string().optional(), 41 41 COMMUNITY_NAME: z.string().default('Barazo Community'), 42 42 ··· 78 78 .transform((v) => v === 'true'), 79 79 PUBLIC_URL: z.string().default('http://localhost:3001'), 80 80 81 - // Global mode: operator DIDs (comma-separated) 81 + // Multi mode: operator DIDs (comma-separated) 82 82 OPERATOR_DIDS: z 83 83 .string() 84 84 .default('')
+11 -2
src/db/schema/account-filters.ts
··· 1 - import { pgTable, text, timestamp, index, serial, integer, uniqueIndex } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, serial, integer, uniqueIndex } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const accountFilters = pgTable( 4 6 'account_filters', ··· 25 27 index('account_filters_community_did_idx').on(table.communityDid), 26 28 index('account_filters_status_idx').on(table.status), 27 29 index('account_filters_updated_at_idx').on(table.updatedAt), 30 + pgPolicy('tenant_isolation', { 31 + as: 'permissive', 32 + to: appRole, 33 + for: 'all', 34 + using: sql`community_did = current_setting('app.current_community_did', true)`, 35 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 36 + }), 28 37 ] 29 - ) 38 + ).enableRLS()
+11 -1
src/db/schema/account-trust.ts
··· 1 1 import { 2 2 pgTable, 3 + pgPolicy, 3 4 serial, 4 5 text, 5 6 integer, ··· 8 9 index, 9 10 uniqueIndex, 10 11 } from 'drizzle-orm/pg-core' 12 + import { sql } from 'drizzle-orm' 13 + import { appRole } from './roles.js' 11 14 12 15 export const accountTrust = pgTable( 13 16 'account_trust', ··· 22 25 (table) => [ 23 26 uniqueIndex('account_trust_did_community_idx').on(table.did, table.communityDid), 24 27 index('account_trust_did_idx').on(table.did), 28 + pgPolicy('tenant_isolation', { 29 + as: 'permissive', 30 + to: appRole, 31 + for: 'all', 32 + using: sql`community_did = current_setting('app.current_community_did', true)`, 33 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 34 + }), 25 35 ] 26 - ) 36 + ).enableRLS()
+11 -1
src/db/schema/categories.ts
··· 1 1 import { 2 2 pgTable, 3 + pgPolicy, 3 4 text, 4 5 integer, 5 6 timestamp, ··· 7 8 uniqueIndex, 8 9 foreignKey, 9 10 } from 'drizzle-orm/pg-core' 11 + import { sql } from 'drizzle-orm' 12 + import { appRole } from './roles.js' 10 13 11 14 export const categories = pgTable( 12 15 'categories', ··· 36 39 foreignColumns: [table.id], 37 40 name: 'categories_parent_id_fk', 38 41 }).onDelete('set null'), 42 + pgPolicy('tenant_isolation', { 43 + as: 'permissive', 44 + to: appRole, 45 + for: 'all', 46 + using: sql`community_did = current_setting('app.current_community_did', true)`, 47 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 48 + }), 39 49 ] 40 - ) 50 + ).enableRLS()
+11 -2
src/db/schema/community-filters.ts
··· 1 - import { pgTable, text, timestamp, index, integer } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, integer } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const communityFilters = pgTable( 4 6 'community_filters', ··· 21 23 index('community_filters_status_idx').on(table.status), 22 24 index('community_filters_admin_did_idx').on(table.adminDid), 23 25 index('community_filters_updated_at_idx').on(table.updatedAt), 26 + pgPolicy('tenant_isolation', { 27 + as: 'permissive', 28 + to: appRole, 29 + for: 'all', 30 + using: sql`community_did = current_setting('app.current_community_did', true)`, 31 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 32 + }), 24 33 ] 25 - ) 34 + ).enableRLS()
+11 -2
src/db/schema/community-profiles.ts
··· 1 - import { pgTable, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 /** 4 6 * Per-community profile overrides. ··· 20 22 primaryKey({ columns: [table.did, table.communityDid] }), 21 23 index('community_profiles_did_idx').on(table.did), 22 24 index('community_profiles_community_idx').on(table.communityDid), 25 + pgPolicy('tenant_isolation', { 26 + as: 'permissive', 27 + to: appRole, 28 + for: 'all', 29 + using: sql`community_did = current_setting('app.current_community_did', true)`, 30 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 31 + }), 23 32 ] 24 - ) 33 + ).enableRLS()
+14 -4
src/db/schema/community-settings.ts
··· 1 - import { pgTable, text, boolean, timestamp, jsonb, integer } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, boolean, timestamp, jsonb, integer } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const communitySettings = pgTable('community_settings', { 4 - id: text('id').primaryKey().default('default'), 6 + communityDid: text('community_did').primaryKey(), 7 + domains: jsonb('domains').$type<string[]>().notNull().default([]), 5 8 initialized: boolean('initialized').notNull().default(false), 6 - communityDid: text('community_did'), 7 9 adminDid: text('admin_did'), 8 10 communityName: text('community_name').notNull().default('Barazo Community'), 9 11 maturityRating: text('maturity_rating', { ··· 54 56 accentColor: text('accent_color'), 55 57 createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), 56 58 updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), 57 - }) 59 + }, () => [ 60 + pgPolicy('tenant_isolation', { 61 + as: 'permissive', 62 + to: appRole, 63 + for: 'all', 64 + using: sql`community_did = current_setting('app.current_community_did', true)`, 65 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 66 + }), 67 + ]).enableRLS()
+1
src/db/schema/index.ts
··· 1 + export { appRole } from './roles.js' 1 2 export { users } from './users.js' 2 3 export { firehoseCursor } from './firehose.js' 3 4 export { topics } from './topics.js'
+11 -2
src/db/schema/moderation-actions.ts
··· 1 - import { pgTable, text, timestamp, index, serial } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, serial } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const moderationActions = pgTable( 4 6 'moderation_actions', ··· 20 22 index('mod_actions_created_at_idx').on(table.createdAt), 21 23 index('mod_actions_target_uri_idx').on(table.targetUri), 22 24 index('mod_actions_target_did_idx').on(table.targetDid), 25 + pgPolicy('tenant_isolation', { 26 + as: 'permissive', 27 + to: appRole, 28 + for: 'all', 29 + using: sql`community_did = current_setting('app.current_community_did', true)`, 30 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 31 + }), 23 32 ] 24 - ) 33 + ).enableRLS()
+11 -2
src/db/schema/moderation-queue.ts
··· 1 - import { pgTable, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const moderationQueue = pgTable( 4 6 'moderation_queue', ··· 29 31 index('mod_queue_status_idx').on(table.status), 30 32 index('mod_queue_created_at_idx').on(table.createdAt), 31 33 index('mod_queue_content_uri_idx').on(table.contentUri), 34 + pgPolicy('tenant_isolation', { 35 + as: 'permissive', 36 + to: appRole, 37 + for: 'all', 38 + using: sql`community_did = current_setting('app.current_community_did', true)`, 39 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 40 + }), 32 41 ] 33 - ) 42 + ).enableRLS()
+11 -2
src/db/schema/notifications.ts
··· 1 - import { pgTable, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const notifications = pgTable( 4 6 'notifications', ··· 26 28 index('notifications_recipient_did_idx').on(table.recipientDid), 27 29 index('notifications_recipient_read_idx').on(table.recipientDid, table.read), 28 30 index('notifications_created_at_idx').on(table.createdAt), 31 + pgPolicy('tenant_isolation', { 32 + as: 'permissive', 33 + to: appRole, 34 + for: 'all', 35 + using: sql`community_did = current_setting('app.current_community_did', true)`, 36 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 37 + }), 29 38 ] 30 - ) 39 + ).enableRLS()
+22 -3
src/db/schema/onboarding-fields.ts
··· 1 1 import { 2 2 pgTable, 3 + pgPolicy, 3 4 text, 4 5 boolean, 5 6 integer, ··· 8 9 primaryKey, 9 10 index, 10 11 } from 'drizzle-orm/pg-core' 12 + import { sql } from 'drizzle-orm' 13 + import { appRole } from './roles.js' 11 14 12 15 export const communityOnboardingFields = pgTable( 13 16 'community_onboarding_fields', ··· 34 37 createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), 35 38 updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), 36 39 }, 37 - (table) => [index('onboarding_fields_community_idx').on(table.communityDid)] 38 - ) 40 + (table) => [ 41 + index('onboarding_fields_community_idx').on(table.communityDid), 42 + pgPolicy('tenant_isolation', { 43 + as: 'permissive', 44 + to: appRole, 45 + for: 'all', 46 + using: sql`community_did = current_setting('app.current_community_did', true)`, 47 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 48 + }), 49 + ] 50 + ).enableRLS() 39 51 40 52 export const userOnboardingResponses = pgTable( 41 53 'user_onboarding_responses', ··· 49 61 (table) => [ 50 62 primaryKey({ columns: [table.did, table.communityDid, table.fieldId] }), 51 63 index('onboarding_responses_did_community_idx').on(table.did, table.communityDid), 64 + pgPolicy('tenant_isolation', { 65 + as: 'permissive', 66 + to: appRole, 67 + for: 'all', 68 + using: sql`community_did = current_setting('app.current_community_did', true)`, 69 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 70 + }), 52 71 ] 53 - ) 72 + ).enableRLS()
+11 -2
src/db/schema/reactions.ts
··· 1 - import { pgTable, text, timestamp, index, unique } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, unique } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const reactions = pgTable( 4 6 'reactions', ··· 22 24 // reaction to a given subject is inherently community-scoped via the subject URI. 23 25 unique('reactions_author_subject_type_uniq').on(table.authorDid, table.subjectUri, table.type), 24 26 index('reactions_subject_uri_type_idx').on(table.subjectUri, table.type), 27 + pgPolicy('tenant_isolation', { 28 + as: 'permissive', 29 + to: appRole, 30 + for: 'all', 31 + using: sql`community_did = current_setting('app.current_community_did', true)`, 32 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 33 + }), 25 34 ] 26 - ) 35 + ).enableRLS()
+11 -2
src/db/schema/replies.ts
··· 1 - import { pgTable, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const replies = pgTable( 4 6 'replies', ··· 46 48 index('replies_moderation_status_idx').on(table.moderationStatus), 47 49 index('replies_trust_status_idx').on(table.trustStatus), 48 50 index('replies_root_uri_created_at_idx').on(table.rootUri, table.createdAt), 51 + pgPolicy('tenant_isolation', { 52 + as: 'permissive', 53 + to: appRole, 54 + for: 'all', 55 + using: sql`community_did = current_setting('app.current_community_did', true)`, 56 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 57 + }), 49 58 ] 50 - ) 59 + ).enableRLS()
+11 -2
src/db/schema/reports.ts
··· 1 - import { pgTable, text, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const reports = pgTable( 4 6 'reports', ··· 43 45 table.targetUri, 44 46 table.communityDid 45 47 ), 48 + pgPolicy('tenant_isolation', { 49 + as: 'permissive', 50 + to: appRole, 51 + for: 'all', 52 + using: sql`community_did = current_setting('app.current_community_did', true)`, 53 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 54 + }), 46 55 ] 47 - ) 56 + ).enableRLS()
+3
src/db/schema/roles.ts
··· 1 + import { pgRole } from 'drizzle-orm/pg-core' 2 + 3 + export const appRole = pgRole('barazo_app')
+11 -2
src/db/schema/topics.ts
··· 1 - import { pgTable, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' 1 + import { pgTable, pgPolicy, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' 2 + import { sql } from 'drizzle-orm' 3 + import { appRole } from './roles.js' 2 4 3 5 export const topics = pgTable( 4 6 'topics', ··· 53 55 table.category, 54 56 table.lastActivityAt 55 57 ), 58 + pgPolicy('tenant_isolation', { 59 + as: 'permissive', 60 + to: appRole, 61 + for: 'all', 62 + using: sql`community_did = current_setting('app.current_community_did', true)`, 63 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 64 + }), 56 65 ] 57 - ) 66 + ).enableRLS()
+11 -1
src/db/schema/user-preferences.ts
··· 1 1 import { 2 2 pgTable, 3 + pgPolicy, 3 4 text, 4 5 timestamp, 5 6 integer, ··· 8 9 index, 9 10 primaryKey, 10 11 } from 'drizzle-orm/pg-core' 12 + import { sql } from 'drizzle-orm' 13 + import { appRole } from './roles.js' 11 14 12 15 // --------------------------------------------------------------------------- 13 16 // Global user preferences (stored in PostgreSQL for MVP, will sync to PDS later) ··· 57 60 primaryKey({ columns: [table.did, table.communityDid] }), 58 61 index('user_community_prefs_did_idx').on(table.did), 59 62 index('user_community_prefs_community_idx').on(table.communityDid), 63 + pgPolicy('tenant_isolation', { 64 + as: 'permissive', 65 + to: appRole, 66 + for: 'all', 67 + using: sql`community_did = current_setting('app.current_community_did', true)`, 68 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 69 + }), 60 70 ] 61 - ) 71 + ).enableRLS()
+1 -1
src/lib/anti-spam.ts
··· 77 77 wordFilter: communitySettings.wordFilter, 78 78 }) 79 79 .from(communitySettings) 80 - .where(eq(communitySettings.id, 'default')) 80 + .where(eq(communitySettings.communityDid, communityDid)) 81 81 82 82 const row = rows[0] 83 83 const thresholds = row?.moderationThresholds
+44
src/middleware/community-resolver.ts
··· 1 + import type { FastifyInstance, FastifyRequest } from 'fastify' 2 + import { badRequest } from '../lib/api-errors.js' 3 + 4 + declare module 'fastify' { 5 + interface FastifyRequest { 6 + communityDid: string | undefined 7 + } 8 + } 9 + 10 + export interface CommunityResolver { 11 + resolve(hostname: string): Promise<string | undefined> 12 + } 13 + 14 + /** 15 + * Extract communityDid from request, throwing 400 if not set. 16 + * Use in route handlers that require a community context (most write operations). 17 + */ 18 + export function requireCommunityDid(request: FastifyRequest): string { 19 + const { communityDid } = request 20 + if (!communityDid) { 21 + throw badRequest('Community context required') 22 + } 23 + return communityDid 24 + } 25 + 26 + export function createSingleResolver(communityDid: string): CommunityResolver { 27 + return { resolve: () => Promise.resolve(communityDid) } 28 + } 29 + 30 + export function registerCommunityResolver( 31 + app: FastifyInstance, 32 + resolver: CommunityResolver, 33 + mode: 'single' | 'multi' 34 + ): void { 35 + app.decorateRequest('communityDid', undefined as string | undefined) 36 + 37 + app.addHook('onRequest', async (request, reply) => { 38 + const communityDid = await resolver.resolve(request.hostname) 39 + if (!communityDid && mode === 'single') { 40 + return reply.status(404).send({ error: 'Community not found' }) 41 + } 42 + request.communityDid = communityDid 43 + }) 44 + }
+15 -13
src/routes/admin-settings.ts
··· 1 1 import { eq, sql } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 4 import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 5 import { isMaturityLowerThan } from '../lib/maturity.js' ··· 13 14 const settingsJsonSchema = { 14 15 type: 'object' as const, 15 16 properties: { 16 - id: { type: 'string' as const }, 17 17 initialized: { type: 'boolean' as const }, 18 - communityDid: { type: ['string', 'null'] as const }, 18 + communityDid: { type: 'string' as const }, 19 19 adminDid: { type: ['string', 'null'] as const }, 20 20 communityName: { type: 'string' as const }, 21 21 maturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, ··· 78 78 79 79 function serializeSettings(row: typeof communitySettings.$inferSelect) { 80 80 return { 81 - id: row.id, 82 81 initialized: row.initialized, 83 - communityDid: row.communityDid ?? null, 82 + communityDid: row.communityDid, 84 83 adminDid: row.adminDid ?? null, 85 84 communityName: row.communityName, 86 85 maturityRating: row.maturityRating, ··· 138 137 }, 139 138 }, 140 139 }, 141 - async (_request, reply) => { 140 + async (request, reply) => { 141 + const communityDid = requireCommunityDid(request) 142 142 const rows = await db 143 143 .select() 144 144 .from(communitySettings) 145 - .where(eq(communitySettings.id, 'default')) 145 + .where(eq(communitySettings.communityDid, communityDid)) 146 146 147 147 const row = rows[0] 148 148 if (!row) { ··· 150 150 } 151 151 152 152 return reply.status(200).send({ 153 - communityDid: row.communityDid ?? null, 153 + communityDid: row.communityDid, 154 154 communityName: row.communityName, 155 155 maturityRating: row.maturityRating, 156 156 communityDescription: row.communityDescription ?? null, ··· 179 179 }, 180 180 }, 181 181 }, 182 - async (_request, reply) => { 182 + async (request, reply) => { 183 + const communityDid = requireCommunityDid(request) 183 184 const rows = await db 184 185 .select() 185 186 .from(communitySettings) 186 - .where(eq(communitySettings.id, 'default')) 187 + .where(eq(communitySettings.communityDid, communityDid)) 187 188 188 189 const row = rows[0] 189 190 if (!row) { ··· 242 243 }, 243 244 }, 244 245 async (request, reply) => { 246 + const communityDid = requireCommunityDid(request) 245 247 const parsed = updateSettingsSchema.safeParse(request.body) 246 248 if (!parsed.success) { 247 249 throw badRequest('Invalid settings data') ··· 269 271 const rows = await db 270 272 .select() 271 273 .from(communitySettings) 272 - .where(eq(communitySettings.id, 'default')) 274 + .where(eq(communitySettings.communityDid, communityDid)) 273 275 274 276 const current = rows[0] 275 277 if (!current) { ··· 288 290 289 291 if (isMaturityLowerThan(currentRating, newRating)) { 290 292 // Raising maturity: find categories below the new threshold 291 - const communityDid = current.communityDid ?? '' 293 + const settingsCommunityDid = current.communityDid 292 294 const allCategories = await db 293 295 .select() 294 296 .from(categories) 295 - .where(eq(categories.communityDid, communityDid)) 297 + .where(eq(categories.communityDid, settingsCommunityDid)) 296 298 297 299 // Filter in application code since maturity comparison is enum-based 298 300 const belowThreshold = allCategories.filter((cat) => ··· 355 357 const updated = await db 356 358 .update(communitySettings) 357 359 .set(dbUpdates) 358 - .where(eq(communitySettings.id, 'default')) 360 + .where(eq(communitySettings.communityDid, communityDid)) 359 361 .returning() 360 362 361 363 const updatedRow = updated[0]
+11 -10
src/routes/categories.ts
··· 1 1 import { randomUUID } from 'node:crypto' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import { eq, and, count } from 'drizzle-orm' 3 4 import type { FastifyPluginCallback } from 'fastify' 4 - import { getCommunityDid } from '../config/env.js' 5 5 import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 6 6 import { isMaturityLowerThan } from '../lib/maturity.js' 7 7 import { ··· 176 176 */ 177 177 export function categoryRoutes(): FastifyPluginCallback { 178 178 return (app, _opts, done) => { 179 - const { db, env, authMiddleware, requireAdmin } = app 179 + const { db, authMiddleware, requireAdmin } = app 180 180 181 181 // ------------------------------------------------------------------- 182 182 // GET /api/categories (public, optionalAuth) ··· 209 209 async (request, reply) => { 210 210 const parsed = categoryQuerySchema.safeParse(request.query) 211 211 const parentId = parsed.success ? parsed.data.parentId : undefined 212 - const communityDid = getCommunityDid(env) 212 + const communityDid = requireCommunityDid(request) 213 213 214 214 const conditions = [eq(categories.communityDid, communityDid)] 215 215 if (parentId !== undefined) { ··· 253 253 }, 254 254 async (request, reply) => { 255 255 const { slug } = request.params as { slug: string } 256 - const communityDid = getCommunityDid(env) 256 + const communityDid = requireCommunityDid(request) 257 257 258 258 const rows = await db 259 259 .select() ··· 320 320 } 321 321 322 322 const { name, slug, description, parentId, sortOrder, maturityRating } = parsed.data 323 - const communityDid = getCommunityDid(env) 323 + const communityDid = requireCommunityDid(request) 324 324 325 325 // Fetch community settings for maturity default 326 326 const settingsRows = await db 327 327 .select() 328 328 .from(communitySettings) 329 - .where(eq(communitySettings.id, 'default')) 329 + .where(eq(communitySettings.communityDid, communityDid)) 330 330 331 331 const settings = settingsRows[0] 332 332 const communityDefault = settings?.maturityRating ?? 'safe' ··· 445 445 } 446 446 447 447 const updates = parsed.data 448 - const communityDid = getCommunityDid(env) 448 + const communityDid = requireCommunityDid(request) 449 449 450 450 // Fetch community settings for maturity validation 451 451 const settingsRows = await db 452 452 .select() 453 453 .from(communitySettings) 454 - .where(eq(communitySettings.id, 'default')) 454 + .where(eq(communitySettings.communityDid, communityDid)) 455 455 456 456 const settings = settingsRows[0] 457 457 const communityDefault = settings?.maturityRating ?? 'safe' ··· 579 579 } 580 580 581 581 // Check if category has topics within this community 582 - const communityDid = getCommunityDid(env) 582 + const communityDid = requireCommunityDid(request) 583 583 const topicCountResult = await db 584 584 .select({ count: count() }) 585 585 .from(topics) ··· 649 649 }, 650 650 }, 651 651 async (request, reply) => { 652 + const communityDid = requireCommunityDid(request) 652 653 const { id } = request.params as { id: string } 653 654 654 655 const parsed = updateMaturitySchema.safeParse(request.body) ··· 670 671 const settingsRows = await db 671 672 .select() 672 673 .from(communitySettings) 673 - .where(eq(communitySettings.id, 'default')) 674 + .where(eq(communitySettings.communityDid, communityDid)) 674 675 675 676 const settings = settingsRows[0] 676 677 const communityDefault = settings?.maturityRating ?? 'safe'
+10 -7
src/routes/moderation-queue.ts
··· 1 1 import { eq, and, desc, sql } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 5 5 import { wordFilterSchema, queueActionSchema, queueQuerySchema } from '../validation/anti-spam.js' 6 6 import { moderationQueue } from '../db/schema/moderation-queue.js' ··· 77 77 78 78 export function moderationQueueRoutes(): FastifyPluginCallback { 79 79 return (app, _opts, done) => { 80 - const { db, env, authMiddleware } = app 80 + const { db, authMiddleware } = app 81 81 const requireModerator = createRequireModerator(db, authMiddleware, app.log) 82 82 const requireAdmin = app.requireAdmin 83 - const communityDid = getCommunityDid(env) 84 83 85 84 // ------------------------------------------------------------------- 86 85 // GET /api/moderation/queue (moderator+) ··· 122 121 }, 123 122 }, 124 123 async (request, reply) => { 124 + const communityDid = requireCommunityDid(request) 125 125 const parsed = queueQuerySchema.safeParse(request.query) 126 126 if (!parsed.success) { 127 127 throw badRequest('Invalid query parameters') ··· 208 208 }, 209 209 }, 210 210 async (request, reply) => { 211 + const communityDid = requireCommunityDid(request) 211 212 const user = request.user 212 213 if (!user) { 213 214 return reply.status(401).send({ error: 'Authentication required' }) ··· 319 320 moderationThresholds: communitySettings.moderationThresholds, 320 321 }) 321 322 .from(communitySettings) 322 - .where(eq(communitySettings.id, 'default')) 323 + .where(eq(communitySettings.communityDid, communityDid)) 323 324 const trustedPostThreshold = 324 325 settingsRows[0]?.moderationThresholds.trustedPostThreshold ?? 10 325 326 ··· 403 404 }, 404 405 }, 405 406 }, 406 - async (_request, reply) => { 407 + async (request, reply) => { 408 + const communityDid = requireCommunityDid(request) 407 409 const rows = await db 408 410 .select({ wordFilter: communitySettings.wordFilter }) 409 411 .from(communitySettings) 410 - .where(eq(communitySettings.id, 'default')) 412 + .where(eq(communitySettings.communityDid, communityDid)) 411 413 412 414 const words = rows[0]?.wordFilter ?? [] 413 415 ··· 453 455 }, 454 456 }, 455 457 async (request, reply) => { 458 + const communityDid = requireCommunityDid(request) 456 459 const parsed = wordFilterSchema.safeParse(request.body) 457 460 if (!parsed.success) { 458 461 throw badRequest('Invalid word filter data') ··· 464 467 await db 465 468 .update(communitySettings) 466 469 .set({ wordFilter: words }) 467 - .where(eq(communitySettings.id, 'default')) 470 + .where(eq(communitySettings.communityDid, communityDid)) 468 471 469 472 // Invalidate cached anti-spam settings 470 473 try {
+22 -10
src/routes/moderation.ts
··· 1 1 import { eq, and, desc, sql } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { 5 5 notFound, 6 6 forbidden, ··· 143 143 const { db, env, authMiddleware } = app 144 144 const requireModerator = createRequireModerator(db, authMiddleware, app.log) 145 145 const requireAdmin = app.requireAdmin 146 - const communityDid = getCommunityDid(env) 147 146 const notificationService = createNotificationService(db, app.log) 148 147 149 148 // ------------------------------------------------------------------- ··· 184 183 }, 185 184 }, 186 185 async (request, reply) => { 186 + const communityDid = requireCommunityDid(request) 187 187 const user = request.user 188 188 if (!user) { 189 189 return reply.status(401).send({ error: 'Authentication required' }) ··· 277 277 }, 278 278 }, 279 279 async (request, reply) => { 280 + const communityDid = requireCommunityDid(request) 280 281 const user = request.user 281 282 if (!user) { 282 283 return reply.status(401).send({ error: 'Authentication required' }) ··· 373 374 }, 374 375 }, 375 376 async (request, reply) => { 377 + const communityDid = requireCommunityDid(request) 376 378 const user = request.user 377 379 if (!user) { 378 380 return reply.status(401).send({ error: 'Authentication required' }) ··· 528 530 }, 529 531 }, 530 532 async (request, reply) => { 533 + const communityDid = requireCommunityDid(request) 531 534 const admin = request.user 532 535 if (!admin) { 533 536 return reply.status(401).send({ error: 'Authentication required' }) ··· 575 578 576 579 app.log.info({ action, targetDid, adminDid: admin.did }, `User ${action}ned`) 577 580 578 - // In global mode, check ban propagation across communities 579 - if (env.COMMUNITY_MODE === 'global' && action === 'ban') { 581 + // In multi mode, check ban propagation across communities 582 + if (env.COMMUNITY_MODE === 'multi' && action === 'ban') { 580 583 try { 581 584 const result = await checkBanPropagation(db, app.cache, app.log, targetDid) 582 585 if (result.propagated) { ··· 646 649 }, 647 650 }, 648 651 async (request, reply) => { 652 + const communityDid = requireCommunityDid(request) 649 653 const parsed = moderationLogQuerySchema.safeParse(request.query) 650 654 if (!parsed.success) { 651 655 throw badRequest('Invalid query parameters') ··· 729 733 }, 730 734 }, 731 735 async (request, reply) => { 736 + const communityDid = requireCommunityDid(request) 732 737 const user = request.user 733 738 if (!user) { 734 739 return reply.status(401).send({ error: 'Authentication required' }) ··· 810 815 'Content reported' 811 816 ) 812 817 813 - // In global mode, notify the community admin about the report 814 - if (env.COMMUNITY_MODE === 'global') { 818 + // In multi mode, notify the community admin about the report 819 + if (env.COMMUNITY_MODE === 'multi') { 815 820 try { 816 821 const filterRows = await db 817 822 .select({ adminDid: communityFilters.adminDid }) ··· 873 878 }, 874 879 }, 875 880 async (request, reply) => { 881 + const communityDid = requireCommunityDid(request) 876 882 const parsed = reportQuerySchema.safeParse(request.query) 877 883 if (!parsed.success) { 878 884 throw badRequest('Invalid query parameters') ··· 959 965 }, 960 966 }, 961 967 async (request, reply) => { 968 + const communityDid = requireCommunityDid(request) 962 969 const user = request.user 963 970 if (!user) { 964 971 return reply.status(401).send({ error: 'Authentication required' }) ··· 1056 1063 }, 1057 1064 }, 1058 1065 async (request, reply) => { 1066 + const communityDid = requireCommunityDid(request) 1059 1067 const parsed = reportedUsersQuerySchema.safeParse(request.query) 1060 1068 const limit = parsed.success ? parsed.data.limit : 25 1061 1069 ··· 1108 1116 }, 1109 1117 }, 1110 1118 }, 1111 - async (_request, reply) => { 1119 + async (request, reply) => { 1120 + const communityDid = requireCommunityDid(request) 1112 1121 const settingsRows = await db 1113 1122 .select({ moderationThresholds: communitySettings.moderationThresholds }) 1114 1123 .from(communitySettings) 1115 - .where(eq(communitySettings.id, 'default')) 1124 + .where(eq(communitySettings.communityDid, communityDid)) 1116 1125 1117 1126 const settings = settingsRows[0] 1118 1127 const t = settings?.moderationThresholds ··· 1183 1192 }, 1184 1193 }, 1185 1194 async (request, reply) => { 1195 + const communityDid = requireCommunityDid(request) 1186 1196 const parsed = moderationThresholdsSchema.safeParse(request.body) 1187 1197 if (!parsed.success) { 1188 1198 throw badRequest('Invalid threshold values') ··· 1192 1202 const existingRows = await db 1193 1203 .select({ moderationThresholds: communitySettings.moderationThresholds }) 1194 1204 .from(communitySettings) 1195 - .where(eq(communitySettings.id, 'default')) 1205 + .where(eq(communitySettings.communityDid, communityDid)) 1196 1206 1197 1207 const existing = existingRows[0]?.moderationThresholds ?? { 1198 1208 autoBlockReportCount: 5, ··· 1220 1230 await db 1221 1231 .update(communitySettings) 1222 1232 .set({ moderationThresholds: merged }) 1223 - .where(eq(communitySettings.id, 'default')) 1233 + .where(eq(communitySettings.communityDid, communityDid)) 1224 1234 1225 1235 // Invalidate cached anti-spam settings 1226 1236 try { ··· 1266 1276 }, 1267 1277 }, 1268 1278 async (request, reply) => { 1279 + const communityDid = requireCommunityDid(request) 1269 1280 const user = request.user 1270 1281 if (!user) { 1271 1282 return reply.status(401).send({ error: 'Authentication required' }) ··· 1354 1365 }, 1355 1366 }, 1356 1367 async (request, reply) => { 1368 + const communityDid = requireCommunityDid(request) 1357 1369 const user = request.user 1358 1370 if (!user) { 1359 1371 return reply.status(401).send({ error: 'Authentication required' })
+10 -10
src/routes/onboarding.ts
··· 1 1 import { eq, and, asc } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { notFound, badRequest, forbidden, errorResponseSchema } from '../lib/api-errors.js' 5 5 import { 6 6 createOnboardingFieldSchema, ··· 78 78 79 79 export function onboardingRoutes(): FastifyPluginCallback { 80 80 return (app, _opts, done) => { 81 - const { db, authMiddleware, env } = app 81 + const { db, authMiddleware } = app 82 82 const requireAdmin = app.requireAdmin 83 83 84 84 // ===================================================================== ··· 107 107 }, 108 108 }, 109 109 }, 110 - async (_request, reply) => { 111 - const communityDid = getCommunityDid(env) 110 + async (request, reply) => { 111 + const communityDid = requireCommunityDid(request) 112 112 113 113 const fields = await db 114 114 .select() ··· 158 158 throw badRequest('Invalid onboarding field data') 159 159 } 160 160 161 - const communityDid = getCommunityDid(env) 161 + const communityDid = requireCommunityDid(request) 162 162 163 163 const inserted = await db 164 164 .insert(communityOnboardingFields) ··· 240 240 throw badRequest('At least one field must be provided') 241 241 } 242 242 243 - const communityDid = getCommunityDid(env) 243 + const communityDid = requireCommunityDid(request) 244 244 245 245 const dbUpdates: Record<string, unknown> = { updatedAt: new Date() } 246 246 if (updates.label !== undefined) dbUpdates.label = updates.label ··· 298 298 }, 299 299 }, 300 300 async (request, reply) => { 301 - const communityDid = getCommunityDid(env) 301 + const communityDid = requireCommunityDid(request) 302 302 303 303 const deleted = await db 304 304 .delete(communityOnboardingFields) ··· 368 368 throw badRequest('Invalid reorder data') 369 369 } 370 370 371 - const communityDid = getCommunityDid(env) 371 + const communityDid = requireCommunityDid(request) 372 372 373 373 // Update each field's sort order 374 374 for (const item of parsed.data) { ··· 422 422 throw forbidden('Authentication required') 423 423 } 424 424 425 - const communityDid = getCommunityDid(env) 425 + const communityDid = requireCommunityDid(request) 426 426 427 427 // Get all fields for this community 428 428 const fields = await db ··· 506 506 throw badRequest('Invalid submission data') 507 507 } 508 508 509 - const communityDid = getCommunityDid(env) 509 + const communityDid = requireCommunityDid(request) 510 510 511 511 // Fetch all community fields to validate against 512 512 const fields = await db
+6 -6
src/routes/reactions.ts
··· 1 1 import { eq, and, sql, asc } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 5 import { 6 6 notFound, ··· 102 102 */ 103 103 export function reactionRoutes(): FastifyPluginCallback { 104 104 return (app, _opts, done) => { 105 - const { db, env, authMiddleware, firehose } = app 105 + const { db, authMiddleware, firehose } = app 106 106 const pdsClient = createPdsClient(app.oauthClient, app.log) 107 107 const notificationService = createNotificationService(db, app.log) 108 108 ··· 161 161 } 162 162 163 163 const { subjectUri, subjectCid, type: reactionType } = parsed.data 164 - const communityDid = getCommunityDid(env) 164 + const communityDid = requireCommunityDid(request) 165 165 166 166 // Onboarding gate: block if user hasn't completed mandatory onboarding 167 167 const onboarding = await checkOnboardingComplete(db, user.did, communityDid) ··· 176 176 const settingsRows = await db 177 177 .select({ reactionSet: communitySettings.reactionSet }) 178 178 .from(communitySettings) 179 - .where(eq(communitySettings.id, 'default')) 179 + .where(eq(communitySettings.communityDid, communityDid)) 180 180 181 181 const settings = settingsRows[0] 182 182 const reactionSet: string[] = settings?.reactionSet ?? ['like'] ··· 360 360 361 361 const { uri } = request.params as { uri: string } 362 362 const decodedUri = decodeURIComponent(uri) 363 - const communityDid = getCommunityDid(env) 363 + const communityDid = requireCommunityDid(request) 364 364 365 365 // Fetch existing reaction (scoped to this community) 366 366 const existing = await db ··· 464 464 } 465 465 466 466 const { subjectUri, type: reactionType, cursor, limit } = parsed.data 467 - const communityDid = getCommunityDid(env) 467 + const communityDid = requireCommunityDid(request) 468 468 const conditions = [ 469 469 eq(reactions.subjectUri, subjectUri), 470 470 eq(reactions.communityDid, communityDid),
+3 -3
src/routes/replies.ts
··· 1 1 import { eq, and, sql, asc, notInArray } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 5 import { 6 6 notFound, ··· 542 542 } 543 543 544 544 // Maturity check: verify the topic's category is within the user's allowed level 545 - const communityDid = getCommunityDid(env) 545 + const communityDid = requireCommunityDid(request) 546 546 const catRows = await db 547 547 .select({ maturityRating: categories.maturityRating }) 548 548 .from(categories) ··· 574 574 const replySettingsRows = await db 575 575 .select({ ageThreshold: communitySettings.ageThreshold }) 576 576 .from(communitySettings) 577 - .where(eq(communitySettings.id, 'default')) 577 + .where(eq(communitySettings.communityDid, communityDid)) 578 578 const replyAgeThreshold = replySettingsRows[0]?.ageThreshold ?? 16 579 579 580 580 const maxMaturity = resolveMaxMaturity(userProfile, replyAgeThreshold)
+2 -3
src/routes/search.ts
··· 1 1 import { sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 3 import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 5 4 import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' 6 5 import { createEmbeddingService } from '../services/embedding.js' ··· 265 264 266 265 // Community scope: in single mode, restrict to the configured community 267 266 const searchCommunityDid = 268 - env.COMMUNITY_MODE === 'single' ? getCommunityDid(env) : undefined 267 + request.communityDid 269 268 270 269 // Determine search mode 271 270 let searchMode: 'fulltext' | 'hybrid' = 'fulltext' ··· 478 477 } 479 478 480 479 // Muted word annotation: flag matching content for client-side collapsing 481 - const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined 480 + const communityDid = request.communityDid 482 481 const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) 483 482 484 483 const annotatedResults = pageResults.map((r) => ({
+3 -2
src/routes/setup.ts
··· 33 33 // GET /api/setup/status (public, no auth required) 34 34 // ------------------------------------------------------------------- 35 35 36 - app.get('/api/setup/status', async (_request, reply) => { 36 + app.get('/api/setup/status', async (request, reply) => { 37 37 try { 38 - const status = await setupService.getStatus() 38 + const status = await setupService.getStatus(request.communityDid ?? '') 39 39 return await reply.status(200).send(status) 40 40 } catch (err: unknown) { 41 41 app.log.error({ err }, 'Failed to get setup status') ··· 65 65 66 66 try { 67 67 const result = await setupService.initialize({ 68 + communityDid: request.communityDid ?? '', 68 69 did: user.did, 69 70 communityName: parsed.data.communityName, 70 71 handle: parsed.data.handle,
+28 -24
src/routes/topics.ts
··· 1 1 import { eq, and, desc, sql, inArray, notInArray, isNotNull, ne, or } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 5 import { 6 6 notFound, ··· 251 251 252 252 const { title, content, category, tags, labels } = parsed.data 253 253 const now = new Date().toISOString() 254 - const communityDid = getCommunityDid(env) 254 + const communityDid = requireCommunityDid(request) 255 255 256 256 // Onboarding gate: block if user hasn't completed mandatory onboarding 257 257 const onboarding = await checkOnboardingComplete(db, user.did, communityDid) ··· 280 280 const settingsRows = await db 281 281 .select({ ageThreshold: communitySettings.ageThreshold }) 282 282 .from(communitySettings) 283 - .where(eq(communitySettings.id, 'default')) 283 + .where(eq(communitySettings.communityDid, communityDid)) 284 284 const ageThreshold = settingsRows[0]?.ageThreshold ?? 16 285 285 286 286 const maxMaturity = resolveMaxMaturity(userProfile, ageThreshold) ··· 538 538 } 539 539 } 540 540 541 - // Fetch community age threshold 542 - const settingsRowsList = await db 543 - .select({ ageThreshold: communitySettings.ageThreshold }) 544 - .from(communitySettings) 545 - .where(eq(communitySettings.id, 'default')) 546 - const listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16 541 + // Fetch community age threshold (use request.communityDid if available, else default) 542 + let listAgeThreshold = 16 543 + if (request.communityDid) { 544 + const settingsRowsList = await db 545 + .select({ ageThreshold: communitySettings.ageThreshold }) 546 + .from(communitySettings) 547 + .where(eq(communitySettings.communityDid, request.communityDid)) 548 + listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16 549 + } 547 550 548 551 const maxMaturity = resolveMaxMaturity(userProfile, listAgeThreshold) 549 552 const allowed = allowedRatings(maxMaturity) ··· 551 554 // Slug→maturityRating lookup, populated by the category queries below 552 555 const categoryMaturityMap = new Map<string, string>() 553 556 554 - if (env.COMMUNITY_MODE === 'global') { 557 + if (env.COMMUNITY_MODE === 'multi') { 555 558 // --------------------------------------------------------------- 556 - // Global mode: multi-community filtering 559 + // Multi mode: multi-community filtering 557 560 // --------------------------------------------------------------- 558 561 559 562 // Get all community settings with a valid communityDid ··· 568 571 // Filter: NEVER show adult communities in global mode, 569 572 // check mature communities against user's max maturity preference 570 573 const allowedCommunityDids = communityRows 571 - .filter((c) => { 572 - if (!c.communityDid) return false 573 - if (c.maturityRating === 'adult') return false 574 - return maturityAllows(maxMaturity, c.maturityRating) 575 - }) 576 - .map((c) => c.communityDid as string) 574 + .filter( 575 + (c): c is typeof c & { communityDid: string } => 576 + !!c.communityDid && 577 + c.maturityRating !== 'adult' && 578 + maturityAllows(maxMaturity, c.maturityRating) 579 + ) 580 + .map((c) => c.communityDid) 577 581 578 582 if (allowedCommunityDids.length === 0) { 579 583 return reply.status(200).send({ topics: [], cursor: null }) ··· 622 626 // Single mode: filter by the one configured community 623 627 // --------------------------------------------------------------- 624 628 625 - const communityDid = getCommunityDid(env) 629 + const communityDid = requireCommunityDid(request) 626 630 627 631 // Get category slugs matching allowed maturity levels 628 632 const allowedCategories = await db ··· 710 714 } 711 715 712 716 // Load muted words for content filtering 713 - const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined 717 + const communityDid = requireCommunityDid(request) 714 718 const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) 715 719 716 720 // Batch-resolve author profiles 717 721 const authorMap = await resolveAuthors( 718 722 serialized.map((t) => t.authorDid), 719 - communityDid ?? null, 723 + communityDid, 720 724 db 721 725 ) 722 726 ··· 786 790 } 787 791 788 792 // Look up the category maturity rating 789 - const communityDid = getCommunityDid(env) 793 + const communityDid = requireCommunityDid(request) 790 794 const catRows = await db 791 795 .select({ maturityRating: categories.maturityRating }) 792 796 .from(categories) ··· 806 810 const rkeySettingsRows = await db 807 811 .select({ ageThreshold: communitySettings.ageThreshold }) 808 812 .from(communitySettings) 809 - .where(eq(communitySettings.id, 'default')) 813 + .where(eq(communitySettings.communityDid, communityDid)) 810 814 const rkeyAgeThreshold = rkeySettingsRows[0]?.ageThreshold ?? 16 811 815 812 816 const maxMaturity = resolveMaxMaturity(userProfile, rkeyAgeThreshold) ··· 855 859 } 856 860 857 861 // Maturity check: verify the topic's category is within the user's allowed level 858 - const communityDid = getCommunityDid(env) 862 + const communityDid = requireCommunityDid(request) 859 863 const catRows = await db 860 864 .select({ maturityRating: categories.maturityRating }) 861 865 .from(categories) ··· 882 886 const singleSettingsRows = await db 883 887 .select({ ageThreshold: communitySettings.ageThreshold }) 884 888 .from(communitySettings) 885 - .where(eq(communitySettings.id, 'default')) 889 + .where(eq(communitySettings.communityDid, communityDid)) 886 890 const singleAgeThreshold = singleSettingsRows[0]?.ageThreshold ?? 16 887 891 888 892 const maxMaturity = resolveMaxMaturity(userProfile, singleAgeThreshold)
+5 -5
src/routes/votes.ts
··· 1 1 import { eq, and, sql } from 'drizzle-orm' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 2 3 import type { FastifyPluginCallback } from 'fastify' 3 - import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 5 import { 6 6 notFound, ··· 41 41 */ 42 42 export function voteRoutes(): FastifyPluginCallback { 43 43 return (app, _opts, done) => { 44 - const { db, env, authMiddleware, firehose } = app 44 + const { db, authMiddleware, firehose } = app 45 45 const pdsClient = createPdsClient(app.oauthClient, app.log) 46 46 47 47 // ------------------------------------------------------------------- ··· 99 99 } 100 100 101 101 const { subjectUri, subjectCid, direction } = parsed.data 102 - const communityDid = getCommunityDid(env) 102 + const communityDid = requireCommunityDid(request) 103 103 104 104 // Validate direction 105 105 if (!ALLOWED_DIRECTIONS.includes(direction)) { ··· 266 266 267 267 const { uri } = request.params as { uri: string } 268 268 const decodedUri = decodeURIComponent(uri) 269 - const communityDid = getCommunityDid(env) 269 + const communityDid = requireCommunityDid(request) 270 270 271 271 // Fetch existing vote (scoped to this community) 272 272 const existing = await db ··· 375 375 } 376 376 377 377 const { subjectUri, did } = parsed.data 378 - const communityDid = getCommunityDid(env) 378 + const communityDid = requireCommunityDid(request) 379 379 380 380 const rows = await db 381 381 .select({
+14 -13
src/setup/service.ts
··· 14 14 15 15 /** Parameters for community initialization. */ 16 16 export interface InitializeParams { 17 + /** Community DID (primary key for the settings row) */ 18 + communityDid: string 17 19 /** DID of the authenticated user who becomes admin */ 18 20 did: string 19 21 /** Optional community name override */ ··· 36 38 37 39 /** Setup service interface for dependency injection and testing. */ 38 40 export interface SetupService { 39 - getStatus(): Promise<SetupStatus> 41 + getStatus(communityDid: string): Promise<SetupStatus> 40 42 initialize(params: InitializeParams): Promise<InitializeResult> 41 43 } 42 44 ··· 72 74 /** 73 75 * Check whether the community has been initialized. 74 76 * 77 + * @param communityDid - The community DID to check status for 75 78 * @returns SetupStatus indicating initialization state 76 79 */ 77 - async function getStatus(): Promise<SetupStatus> { 80 + async function getStatus(communityDid: string): Promise<SetupStatus> { 78 81 try { 79 82 const rows = await db 80 83 .select({ ··· 82 85 communityName: communitySettings.communityName, 83 86 }) 84 87 .from(communitySettings) 85 - .where(eq(communitySettings.id, 'default')) 88 + .where(eq(communitySettings.communityDid, communityDid)) 86 89 87 90 const row = rows[0] 88 91 ··· 112 115 * @returns InitializeResult with the new state or conflict indicator 113 116 */ 114 117 async function initialize(params: InitializeParams): Promise<InitializeResult> { 115 - const { did, communityName, handle, serviceEndpoint } = params 118 + const { communityDid, did, communityName, handle, serviceEndpoint } = params 116 119 117 120 try { 118 121 // Generate PLC DID if handle and serviceEndpoint are provided 119 - let communityDid: string | undefined 122 + let plcDid: string | undefined 120 123 let signingKeyHex: string | undefined 121 124 let rotationKeyHex: string | undefined 122 125 ··· 128 131 serviceEndpoint, 129 132 }) 130 133 131 - communityDid = didResult.did 134 + plcDid = didResult.did 132 135 signingKeyHex = encrypt(didResult.signingKey, encryptionKey) 133 136 rotationKeyHex = encrypt(didResult.rotationKey, encryptionKey) 134 137 135 - logger.info({ communityDid, handle }, 'PLC DID generated successfully') 138 + logger.info({ plcDid, handle }, 'PLC DID generated successfully') 136 139 } else if (handle && serviceEndpoint && !plcDidService) { 137 140 logger.warn( 138 141 { handle, serviceEndpoint }, ··· 145 148 const rows = await db 146 149 .insert(communitySettings) 147 150 .values({ 148 - id: 'default', 151 + communityDid, 149 152 initialized: true, 150 153 adminDid: did, 151 154 communityName: communityName ?? DEFAULT_COMMUNITY_NAME, 152 - communityDid: communityDid ?? null, 153 155 handle: handle ?? null, 154 156 serviceEndpoint: serviceEndpoint ?? null, 155 157 signingKey: signingKeyHex ?? null, 156 158 rotationKey: rotationKeyHex ?? null, 157 159 }) 158 160 .onConflictDoUpdate({ 159 - target: communitySettings.id, 161 + target: communitySettings.communityDid, 160 162 set: { 161 163 initialized: true, 162 164 adminDid: did, 163 165 communityName: communityName ? communityName : sql`${communitySettings.communityName}`, 164 - communityDid: communityDid ?? sql`${communitySettings.communityDid}`, 165 166 handle: handle ?? sql`${communitySettings.handle}`, 166 167 serviceEndpoint: serviceEndpoint ?? sql`${communitySettings.serviceEndpoint}`, 167 168 signingKey: signingKeyHex ?? sql`${communitySettings.signingKey}`, ··· 190 191 communityName: finalName, 191 192 } 192 193 193 - if (row.communityDid) { 194 - result.communityDid = row.communityDid 194 + if (plcDid) { 195 + result.communityDid = plcDid 195 196 } 196 197 197 198 return result
+11
src/types/multi-tenant.d.ts
··· 1 + /** 2 + * Type declarations for @barazo/multi-tenant (private npm package). 3 + * This package is only required when COMMUNITY_MODE=multi. 4 + */ 5 + declare module '@barazo/multi-tenant' { 6 + import type { CommunityResolver } from '../middleware/community-resolver.js' 7 + import type { Database } from '../db/index.js' 8 + import type { Cache } from '../cache/index.js' 9 + 10 + export function createMultiResolver(db: Database, cache: Cache): CommunityResolver 11 + }
+445
tests/integration/tenant-isolation.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll } from 'vitest' 2 + import { GenericContainer, Wait, type StartedTestContainer } from 'testcontainers' 3 + import { drizzle } from 'drizzle-orm/postgres-js' 4 + import { sql } from 'drizzle-orm' 5 + import postgres from 'postgres' 6 + import * as schema from '../../src/db/schema/index.js' 7 + 8 + const COMMUNITY_A = 'did:plc:communityA' 9 + const COMMUNITY_B = 'did:plc:communityB' 10 + 11 + /** Port for the test PostgreSQL instance (host networking). */ 12 + const PG_PORT = 25432 13 + 14 + /** 15 + * Create the schema tables and RLS policies directly via SQL. 16 + * Only creates the tables needed for tenant-isolation testing. 17 + */ 18 + async function pushSchema(client: ReturnType<typeof postgres>): Promise<void> { 19 + await client` 20 + CREATE ROLE barazo_app LOGIN PASSWORD 'barazo_app' 21 + ` 22 + 23 + // community_settings 24 + await client` 25 + CREATE TABLE community_settings ( 26 + community_did TEXT PRIMARY KEY, 27 + domains JSONB NOT NULL DEFAULT '[]', 28 + initialized BOOLEAN NOT NULL DEFAULT false, 29 + admin_did TEXT, 30 + community_name TEXT NOT NULL DEFAULT 'Barazo Community', 31 + maturity_rating TEXT NOT NULL DEFAULT 'safe', 32 + reaction_set JSONB NOT NULL DEFAULT '["like"]', 33 + moderation_thresholds JSONB NOT NULL DEFAULT '{}', 34 + word_filter JSONB NOT NULL DEFAULT '[]', 35 + jurisdiction_country TEXT, 36 + age_threshold INTEGER NOT NULL DEFAULT 16, 37 + require_login_for_mature BOOLEAN NOT NULL DEFAULT true, 38 + community_description TEXT, 39 + handle TEXT, 40 + service_endpoint TEXT, 41 + signing_key TEXT, 42 + rotation_key TEXT, 43 + community_logo_url TEXT, 44 + primary_color TEXT, 45 + accent_color TEXT, 46 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 47 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 48 + ) 49 + ` 50 + 51 + // categories 52 + await client` 53 + CREATE TABLE categories ( 54 + id TEXT PRIMARY KEY, 55 + slug TEXT NOT NULL, 56 + name TEXT NOT NULL, 57 + description TEXT, 58 + parent_id TEXT REFERENCES categories(id) ON DELETE SET NULL, 59 + sort_order INTEGER NOT NULL DEFAULT 0, 60 + community_did TEXT NOT NULL, 61 + maturity_rating TEXT NOT NULL DEFAULT 'safe', 62 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 63 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 64 + ) 65 + ` 66 + 67 + // topics 68 + await client` 69 + CREATE TABLE topics ( 70 + uri TEXT PRIMARY KEY, 71 + rkey TEXT NOT NULL, 72 + author_did TEXT NOT NULL, 73 + title TEXT NOT NULL, 74 + content TEXT NOT NULL, 75 + content_format TEXT, 76 + category TEXT NOT NULL, 77 + tags JSONB, 78 + community_did TEXT NOT NULL, 79 + cid TEXT NOT NULL, 80 + labels JSONB, 81 + reply_count INTEGER NOT NULL DEFAULT 0, 82 + reaction_count INTEGER NOT NULL DEFAULT 0, 83 + vote_count INTEGER NOT NULL DEFAULT 0, 84 + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(), 85 + created_at TIMESTAMPTZ NOT NULL, 86 + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), 87 + is_locked BOOLEAN NOT NULL DEFAULT false, 88 + is_pinned BOOLEAN NOT NULL DEFAULT false, 89 + is_mod_deleted BOOLEAN NOT NULL DEFAULT false, 90 + is_author_deleted BOOLEAN NOT NULL DEFAULT false, 91 + moderation_status TEXT NOT NULL DEFAULT 'approved', 92 + trust_status TEXT NOT NULL DEFAULT 'trusted' 93 + ) 94 + ` 95 + 96 + // replies 97 + await client` 98 + CREATE TABLE replies ( 99 + uri TEXT PRIMARY KEY, 100 + rkey TEXT NOT NULL, 101 + author_did TEXT NOT NULL, 102 + content TEXT NOT NULL, 103 + content_format TEXT, 104 + root_uri TEXT NOT NULL, 105 + root_cid TEXT NOT NULL, 106 + parent_uri TEXT NOT NULL, 107 + parent_cid TEXT NOT NULL, 108 + community_did TEXT NOT NULL, 109 + cid TEXT NOT NULL, 110 + labels JSONB, 111 + reaction_count INTEGER NOT NULL DEFAULT 0, 112 + vote_count INTEGER NOT NULL DEFAULT 0, 113 + created_at TIMESTAMPTZ NOT NULL, 114 + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), 115 + is_author_deleted BOOLEAN NOT NULL DEFAULT false, 116 + is_mod_deleted BOOLEAN NOT NULL DEFAULT false, 117 + moderation_status TEXT NOT NULL DEFAULT 'approved', 118 + trust_status TEXT NOT NULL DEFAULT 'trusted' 119 + ) 120 + ` 121 + 122 + // Enable RLS and create policies on all tables 123 + const tables = ['community_settings', 'categories', 'topics', 'replies'] 124 + for (const table of tables) { 125 + await client.unsafe(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`) 126 + await client.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`) 127 + await client.unsafe(` 128 + CREATE POLICY tenant_isolation ON ${table} 129 + AS PERMISSIVE 130 + FOR ALL 131 + TO barazo_app 132 + USING (community_did = current_setting('app.current_community_did', true)) 133 + WITH CHECK (community_did = current_setting('app.current_community_did', true)) 134 + `) 135 + } 136 + 137 + // Grant permissions to app role 138 + await client`GRANT ALL ON ALL TABLES IN SCHEMA public TO barazo_app` 139 + await client`GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO barazo_app` 140 + } 141 + 142 + describe('tenant isolation (RLS)', () => { 143 + let container: StartedTestContainer | undefined 144 + let superClient: ReturnType<typeof postgres> | undefined 145 + let superDb: ReturnType<typeof drizzle> 146 + let appClient: ReturnType<typeof postgres> | undefined 147 + let appDb: ReturnType<typeof drizzle> 148 + 149 + beforeAll(async () => { 150 + // 1. Start PostgreSQL with host networking 151 + container = await new GenericContainer('postgres:16-alpine') 152 + .withEnvironment({ 153 + POSTGRES_USER: 'test', 154 + POSTGRES_PASSWORD: 'test', 155 + POSTGRES_DB: 'test', 156 + PGPORT: String(PG_PORT), 157 + }) 158 + .withNetworkMode('host') 159 + .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2)) 160 + .start() 161 + 162 + const superUri = `postgresql://test:test@127.0.0.1:${String(PG_PORT)}/test` 163 + 164 + // 2. Create schema and RLS policies 165 + const setupClient = postgres(superUri, { max: 1, connect_timeout: 10 }) 166 + await pushSchema(setupClient) 167 + await setupClient.end() 168 + 169 + // 3. Superuser connection (table owner, bypasses RLS) 170 + superClient = postgres(superUri, { max: 5, connect_timeout: 10 }) 171 + superDb = drizzle(superClient, { schema }) 172 + 173 + // 4. App-role connection (subject to RLS) 174 + const appUri = `postgresql://barazo_app:barazo_app@127.0.0.1:${String(PG_PORT)}/test` 175 + appClient = postgres(appUri, { max: 5, connect_timeout: 10 }) 176 + appDb = drizzle(appClient, { schema }) 177 + 178 + // 5. Seed two communities using superuser (bypasses RLS) 179 + await superDb.insert(schema.communitySettings).values([ 180 + { communityDid: COMMUNITY_A, communityName: 'Community A', domains: ['a.example.com'] }, 181 + { communityDid: COMMUNITY_B, communityName: 'Community B', domains: ['b.example.com'] }, 182 + ]) 183 + 184 + // 6. Seed categories 185 + await superDb.insert(schema.categories).values([ 186 + { id: 'cat-a-1', slug: 'general', name: 'General', communityDid: COMMUNITY_A }, 187 + { id: 'cat-b-1', slug: 'general', name: 'General', communityDid: COMMUNITY_B }, 188 + ]) 189 + 190 + // 7. Seed topics 191 + const now = new Date() 192 + await superDb.insert(schema.topics).values([ 193 + { 194 + uri: 'at://did:plc:user1/forum.barazo.topic/aaa', 195 + rkey: 'aaa', 196 + authorDid: 'did:plc:user1', 197 + title: 'Topic in A', 198 + content: 'Content for community A', 199 + category: 'general', 200 + communityDid: COMMUNITY_A, 201 + cid: 'cid-aaa', 202 + createdAt: now, 203 + }, 204 + { 205 + uri: 'at://did:plc:user2/forum.barazo.topic/bbb', 206 + rkey: 'bbb', 207 + authorDid: 'did:plc:user2', 208 + title: 'Topic in B', 209 + content: 'Content for community B', 210 + category: 'general', 211 + communityDid: COMMUNITY_B, 212 + cid: 'cid-bbb', 213 + createdAt: now, 214 + }, 215 + ]) 216 + 217 + // 8. Seed replies 218 + await superDb.insert(schema.replies).values([ 219 + { 220 + uri: 'at://did:plc:user1/forum.barazo.reply/r-aaa', 221 + rkey: 'r-aaa', 222 + authorDid: 'did:plc:user1', 223 + content: 'Reply in A', 224 + rootUri: 'at://did:plc:user1/forum.barazo.topic/aaa', 225 + rootCid: 'cid-aaa', 226 + parentUri: 'at://did:plc:user1/forum.barazo.topic/aaa', 227 + parentCid: 'cid-aaa', 228 + communityDid: COMMUNITY_A, 229 + cid: 'cid-r-aaa', 230 + createdAt: now, 231 + }, 232 + { 233 + uri: 'at://did:plc:user2/forum.barazo.reply/r-bbb', 234 + rkey: 'r-bbb', 235 + authorDid: 'did:plc:user2', 236 + content: 'Reply in B', 237 + rootUri: 'at://did:plc:user2/forum.barazo.topic/bbb', 238 + rootCid: 'cid-bbb', 239 + parentUri: 'at://did:plc:user2/forum.barazo.topic/bbb', 240 + parentCid: 'cid-bbb', 241 + communityDid: COMMUNITY_B, 242 + cid: 'cid-r-bbb', 243 + createdAt: now, 244 + }, 245 + ]) 246 + }, 120_000) 247 + 248 + afterAll(async () => { 249 + await appClient?.end() 250 + await superClient?.end() 251 + await container?.stop() 252 + }) 253 + 254 + /** Run a callback within a transaction scoped to a community DID. */ 255 + async function withCommunity<T>( 256 + communityDid: string, 257 + fn: (db: typeof appDb) => Promise<T> 258 + ): Promise<T> { 259 + return await appDb.transaction(async (tx) => { 260 + await tx.execute(sql`SELECT set_config('app.current_community_did', ${communityDid}, true)`) 261 + return await fn(tx as unknown as typeof appDb) 262 + }) 263 + } 264 + 265 + /** 266 + * Run a callback as superuser with no RLS filtering (aggregator mode). 267 + * In production, aggregator queries use a service role that bypasses RLS. 268 + */ 269 + async function withoutCommunity<T>(fn: (db: typeof superDb) => Promise<T>): Promise<T> { 270 + return await fn(superDb) 271 + } 272 + 273 + describe('SELECT isolation', () => { 274 + it('community A session sees only community A topics', async () => { 275 + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.topics)) 276 + 277 + expect(rows).toHaveLength(1) 278 + expect(rows[0].communityDid).toBe(COMMUNITY_A) 279 + expect(rows[0].title).toBe('Topic in A') 280 + }) 281 + 282 + it('community B session sees only community B topics', async () => { 283 + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.topics)) 284 + 285 + expect(rows).toHaveLength(1) 286 + expect(rows[0].communityDid).toBe(COMMUNITY_B) 287 + expect(rows[0].title).toBe('Topic in B') 288 + }) 289 + 290 + it('community A session sees only community A replies', async () => { 291 + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.replies)) 292 + 293 + expect(rows).toHaveLength(1) 294 + expect(rows[0].communityDid).toBe(COMMUNITY_A) 295 + }) 296 + 297 + it('community B session sees only community B replies', async () => { 298 + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.replies)) 299 + 300 + expect(rows).toHaveLength(1) 301 + expect(rows[0].communityDid).toBe(COMMUNITY_B) 302 + }) 303 + 304 + it('community A session sees only community A categories', async () => { 305 + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.categories)) 306 + 307 + expect(rows).toHaveLength(1) 308 + expect(rows[0].communityDid).toBe(COMMUNITY_A) 309 + }) 310 + 311 + it('community A session sees only community A settings', async () => { 312 + const rows = await withCommunity(COMMUNITY_A, (db) => 313 + db.select().from(schema.communitySettings) 314 + ) 315 + 316 + expect(rows).toHaveLength(1) 317 + expect(rows[0].communityDid).toBe(COMMUNITY_A) 318 + expect(rows[0].communityName).toBe('Community A') 319 + }) 320 + }) 321 + 322 + describe('aggregator mode (empty session variable)', () => { 323 + it('sees topics from all communities', async () => { 324 + const rows = await withoutCommunity((db) => db.select().from(schema.topics)) 325 + 326 + expect(rows).toHaveLength(2) 327 + const dids = rows.map((r) => r.communityDid).sort() 328 + expect(dids).toEqual([COMMUNITY_A, COMMUNITY_B]) 329 + }) 330 + 331 + it('sees replies from all communities', async () => { 332 + const rows = await withoutCommunity((db) => db.select().from(schema.replies)) 333 + 334 + expect(rows).toHaveLength(2) 335 + const dids = rows.map((r) => r.communityDid).sort() 336 + expect(dids).toEqual([COMMUNITY_A, COMMUNITY_B]) 337 + }) 338 + 339 + it('sees categories from all communities', async () => { 340 + const rows = await withoutCommunity((db) => db.select().from(schema.categories)) 341 + 342 + expect(rows).toHaveLength(2) 343 + }) 344 + 345 + it('sees community settings from all communities', async () => { 346 + const rows = await withoutCommunity((db) => db.select().from(schema.communitySettings)) 347 + 348 + expect(rows).toHaveLength(2) 349 + }) 350 + }) 351 + 352 + describe('INSERT isolation (withCheck)', () => { 353 + it('allows INSERT when communityDid matches session variable', async () => { 354 + await withCommunity(COMMUNITY_A, async (db) => { 355 + await db.insert(schema.categories).values({ 356 + id: 'cat-a-2', 357 + slug: 'announcements', 358 + name: 'Announcements', 359 + communityDid: COMMUNITY_A, 360 + }) 361 + }) 362 + 363 + // Verify it was inserted 364 + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.categories)) 365 + expect(rows.some((r) => r.slug === 'announcements')).toBe(true) 366 + }) 367 + 368 + it('blocks INSERT when communityDid does not match session variable', async () => { 369 + try { 370 + await withCommunity(COMMUNITY_A, (db) => 371 + db.insert(schema.categories).values({ 372 + id: 'cat-x-1', 373 + slug: 'sneaky', 374 + name: 'Cross-tenant insert', 375 + communityDid: COMMUNITY_B, // Mismatch! 376 + }) 377 + ) 378 + expect.unreachable('INSERT should have been blocked by RLS') 379 + } catch (err) { 380 + // Drizzle wraps the PostgreSQL error; the RLS message is in the cause 381 + const message = String(err instanceof Error ? (err.cause ?? err).message : err) 382 + expect(message).toMatch(/row-level security/) 383 + } 384 + }) 385 + 386 + it('blocks INSERT of topic with mismatched communityDid', async () => { 387 + try { 388 + await withCommunity(COMMUNITY_B, (db) => 389 + db.insert(schema.topics).values({ 390 + uri: 'at://did:plc:attacker/forum.barazo.topic/evil', 391 + rkey: 'evil', 392 + authorDid: 'did:plc:attacker', 393 + title: 'Cross-tenant topic', 394 + content: 'Should be blocked', 395 + category: 'general', 396 + communityDid: COMMUNITY_A, // Mismatch! 397 + cid: 'cid-evil', 398 + createdAt: new Date(), 399 + }) 400 + ) 401 + expect.unreachable('INSERT should have been blocked by RLS') 402 + } catch (err) { 403 + const message = String(err instanceof Error ? (err.cause ?? err).message : err) 404 + expect(message).toMatch(/row-level security/) 405 + } 406 + }) 407 + }) 408 + 409 + describe('UPDATE isolation', () => { 410 + it('cannot update rows belonging to another community', async () => { 411 + // Try to update community B's topic while set as community A 412 + await withCommunity(COMMUNITY_A, async (db) => { 413 + const result = await db 414 + .update(schema.topics) 415 + .set({ title: 'Hijacked!' }) 416 + .where(sql`uri = 'at://did:plc:user2/forum.barazo.topic/bbb'`) 417 + 418 + // RLS silently filters the WHERE clause, so 0 rows affected 419 + expect(result.length).toBe(0) 420 + }) 421 + 422 + // Verify community B's topic is unchanged 423 + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.topics)) 424 + expect(rows[0].title).toBe('Topic in B') 425 + }) 426 + }) 427 + 428 + describe('DELETE isolation', () => { 429 + it('cannot delete rows belonging to another community', async () => { 430 + // Try to delete community B's reply while set as community A 431 + await withCommunity(COMMUNITY_A, async (db) => { 432 + const result = await db 433 + .delete(schema.replies) 434 + .where(sql`uri = 'at://did:plc:user2/forum.barazo.reply/r-bbb'`) 435 + 436 + // RLS silently filters, 0 rows deleted 437 + expect(result.length).toBe(0) 438 + }) 439 + 440 + // Verify community B's reply still exists 441 + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.replies)) 442 + expect(rows).toHaveLength(1) 443 + }) 444 + }) 445 + })
+10 -10
tests/unit/auth/require-operator.test.ts
··· 48 48 overrides: Partial<Pick<Env, 'COMMUNITY_MODE' | 'OPERATOR_DIDS'>> = {} 49 49 ): Pick<Env, 'COMMUNITY_MODE' | 'OPERATOR_DIDS'> { 50 50 return { 51 - COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'global', 51 + COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'multi', 52 52 OPERATOR_DIDS: overrides.OPERATOR_DIDS ?? ['did:plc:operator123'], 53 53 } 54 54 } ··· 132 132 // ------------------------------------------------------------------------- 133 133 134 134 it('returns 401 when requireAuth rejects (no token)', async () => { 135 - await buildApp({ COMMUNITY_MODE: 'global' }) 135 + await buildApp({ COMMUNITY_MODE: 'multi' }) 136 136 137 137 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, reply) => { 138 138 await reply.status(401).send({ error: 'Authentication required' }) ··· 155 155 156 156 it('returns 403 if user DID is not in OPERATOR_DIDS', async () => { 157 157 await buildApp({ 158 - COMMUNITY_MODE: 'global', 158 + COMMUNITY_MODE: 'multi', 159 159 OPERATOR_DIDS: ['did:plc:operator123'], 160 160 }) 161 161 ··· 175 175 }) 176 176 177 177 it('returns 403 when requireAuth passes but request.user is not set', async () => { 178 - await buildApp({ COMMUNITY_MODE: 'global' }) 178 + await buildApp({ COMMUNITY_MODE: 'multi' }) 179 179 180 180 // requireAuth passes without setting request.user 181 181 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { ··· 197 197 // Success path 198 198 // ------------------------------------------------------------------------- 199 199 200 - it("grants access if user DID is in OPERATOR_DIDS and mode is 'global'", async () => { 200 + it("grants access if user DID is in OPERATOR_DIDS and mode is 'multi'", async () => { 201 201 await buildApp({ 202 - COMMUNITY_MODE: 'global', 202 + COMMUNITY_MODE: 'multi', 203 203 OPERATOR_DIDS: ['did:plc:operator123'], 204 204 }) 205 205 ··· 219 219 220 220 it('grants access when OPERATOR_DIDS contains multiple DIDs', async () => { 221 221 await buildApp({ 222 - COMMUNITY_MODE: 'global', 222 + COMMUNITY_MODE: 'multi', 223 223 OPERATOR_DIDS: ['did:plc:other999', 'did:plc:operator123', 'did:plc:another888'], 224 224 }) 225 225 ··· 243 243 244 244 it('logs audit trail when operator access is denied (DID not in list)', async () => { 245 245 await buildApp({ 246 - COMMUNITY_MODE: 'global', 246 + COMMUNITY_MODE: 'multi', 247 247 OPERATOR_DIDS: ['did:plc:operator123'], 248 248 }) 249 249 ··· 263 263 }) 264 264 265 265 it('logs audit trail when operator access is denied (no user after auth)', async () => { 266 - await buildApp({ COMMUNITY_MODE: 'global' }) 266 + await buildApp({ COMMUNITY_MODE: 'multi' }) 267 267 268 268 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { 269 269 // intentionally do not set request.user ··· 282 282 283 283 it('logs audit trail when operator access is granted', async () => { 284 284 await buildApp({ 285 - COMMUNITY_MODE: 'global', 285 + COMMUNITY_MODE: 'multi', 286 286 OPERATOR_DIDS: ['did:plc:operator123'], 287 287 }) 288 288
+5 -5
tests/unit/config/env.test.ts
··· 130 130 expect(result.success).toBe(false) 131 131 }) 132 132 133 - it('accepts global COMMUNITY_MODE', () => { 133 + it('accepts multi COMMUNITY_MODE', () => { 134 134 const result = envSchema.safeParse({ 135 135 ...validEnv, 136 - COMMUNITY_MODE: 'global', 136 + COMMUNITY_MODE: 'multi', 137 137 }) 138 138 expect(result.success).toBe(true) 139 139 if (result.success) { 140 - expect(result.data.COMMUNITY_MODE).toBe('global') 140 + expect(result.data.COMMUNITY_MODE).toBe('multi') 141 141 } 142 142 }) 143 143 ··· 240 240 expect(result.success).toBe(true) 241 241 }) 242 242 243 - it('accepts global mode without COMMUNITY_DID', () => { 243 + it('accepts multi mode without COMMUNITY_DID', () => { 244 244 const result = envSchema.safeParse({ 245 245 ...baseEnv, 246 - COMMUNITY_MODE: 'global', 246 + COMMUNITY_MODE: 'multi', 247 247 }) 248 248 expect(result.success).toBe(true) 249 249 })
+15 -9
tests/unit/db/schema/community-settings.test.ts
··· 9 9 expect(getTableName(communitySettings)).toBe('community_settings') 10 10 }) 11 11 12 - it('uses id as primary key', () => { 13 - expect(columns.id.primary).toBe(true) 12 + it('uses communityDid as primary key', () => { 13 + expect(columns.communityDid.primary).toBe(true) 14 14 }) 15 15 16 16 it('has all required columns', () => { 17 17 const columnNames = Object.keys(columns) 18 18 19 19 const expected = [ 20 - 'id', 21 - 'initialized', 22 20 'communityDid', 21 + 'domains', 22 + 'initialized', 23 23 'adminDid', 24 24 'communityName', 25 25 'maturityRating', ··· 37 37 } 38 38 }) 39 39 40 - it('has default value for id', () => { 41 - expect(columns.id.hasDefault).toBe(true) 40 + it('does not have an id column', () => { 41 + const columnNames = Object.keys(columns) 42 + expect(columnNames).not.toContain('id') 43 + }) 44 + 45 + it('has domains column with default empty array', () => { 46 + expect(columns.domains.notNull).toBe(true) 47 + expect(columns.domains.hasDefault).toBe(true) 42 48 }) 43 49 44 50 it('has default value for initialized (false)', () => { 45 51 expect(columns.initialized.hasDefault).toBe(true) 46 52 }) 47 53 48 - it('has nullable communityDid', () => { 49 - expect(columns.communityDid.notNull).toBe(false) 54 + it('has notNull communityDid', () => { 55 + expect(columns.communityDid.notNull).toBe(true) 50 56 }) 51 57 52 58 it('has nullable adminDid', () => { ··· 79 85 }) 80 86 81 87 it('has non-nullable required columns', () => { 82 - expect(columns.id.notNull).toBe(true) 88 + expect(columns.communityDid.notNull).toBe(true) 83 89 expect(columns.initialized.notNull).toBe(true) 84 90 expect(columns.communityName.notNull).toBe(true) 85 91 expect(columns.maturityRating.notNull).toBe(true)
+81
tests/unit/middleware/community-resolver.test.ts
··· 1 + import { describe, it, expect, afterEach } from 'vitest' 2 + import Fastify from 'fastify' 3 + import type { FastifyInstance } from 'fastify' 4 + import { 5 + createSingleResolver, 6 + registerCommunityResolver, 7 + } from '../../../src/middleware/community-resolver.js' 8 + import type { CommunityResolver } from '../../../src/middleware/community-resolver.js' 9 + 10 + describe('CommunityResolver', () => { 11 + describe('createSingleResolver', () => { 12 + it('returns the configured DID for any hostname', async () => { 13 + const resolver = createSingleResolver('did:plc:test123') 14 + expect(await resolver.resolve('anything.example.com')).toBe('did:plc:test123') 15 + }) 16 + 17 + it('returns the same DID regardless of hostname', async () => { 18 + const resolver = createSingleResolver('did:plc:mycommunity') 19 + expect(await resolver.resolve('foo.bar.com')).toBe('did:plc:mycommunity') 20 + expect(await resolver.resolve('localhost')).toBe('did:plc:mycommunity') 21 + expect(await resolver.resolve('')).toBe('did:plc:mycommunity') 22 + }) 23 + }) 24 + 25 + describe('Fastify integration', () => { 26 + let app: FastifyInstance 27 + 28 + afterEach(async () => { 29 + await app.close() 30 + }) 31 + 32 + it('sets request.communityDid in single mode', async () => { 33 + const resolver = createSingleResolver('did:plc:singlecommunity') 34 + 35 + app = Fastify({ logger: false }) 36 + registerCommunityResolver(app, resolver, 'single') 37 + 38 + app.get('/test', (request) => { 39 + return { communityDid: request.communityDid } 40 + }) 41 + await app.ready() 42 + 43 + const response = await app.inject({ method: 'GET', url: '/test' }) 44 + expect(response.statusCode).toBe(200) 45 + expect(response.json<{ communityDid: string }>().communityDid).toBe('did:plc:singlecommunity') 46 + }) 47 + 48 + it('returns 404 in single mode when resolver returns undefined', async () => { 49 + // Construct a resolver that returns undefined (shouldn't happen in single mode, but safety net) 50 + const resolver: CommunityResolver = { resolve: () => Promise.resolve(undefined) } 51 + 52 + app = Fastify({ logger: false }) 53 + registerCommunityResolver(app, resolver, 'single') 54 + 55 + app.get('/test', (request) => { 56 + return { communityDid: request.communityDid } 57 + }) 58 + await app.ready() 59 + 60 + const response = await app.inject({ method: 'GET', url: '/test' }) 61 + expect(response.statusCode).toBe(404) 62 + expect(response.json<{ error: string }>().error).toBe('Community not found') 63 + }) 64 + 65 + it('allows undefined communityDid in multi mode (aggregator)', async () => { 66 + const resolver: CommunityResolver = { resolve: () => Promise.resolve(undefined) } 67 + 68 + app = Fastify({ logger: false }) 69 + registerCommunityResolver(app, resolver, 'multi') 70 + 71 + app.get('/test', (request) => { 72 + return { communityDid: request.communityDid ?? null } 73 + }) 74 + await app.ready() 75 + 76 + const response = await app.inject({ method: 'GET', url: '/test' }) 77 + expect(response.statusCode).toBe(200) 78 + expect(response.json<{ communityDid: null }>().communityDid).toBeNull() 79 + }) 80 + }) 81 + })
+5 -3
tests/unit/routes/admin-settings.test.ts
··· 120 120 121 121 function sampleCommunitySettings(overrides?: Record<string, unknown>) { 122 122 return { 123 - id: 'default', 124 123 initialized: true, 125 124 communityDid: 'did:plc:community123', 126 125 adminDid: ADMIN_DID, ··· 178 177 app.decorate('setupService', {} as SetupService) 179 178 app.decorate('cache', {} as never) 180 179 app.decorateRequest('user', undefined as RequestUser | undefined) 180 + app.decorateRequest('communityDid', undefined as string | undefined) 181 + app.addHook('onRequest', (request, _reply, done) => { 182 + request.communityDid = 'did:plc:test' 183 + done() 184 + }) 181 185 182 186 await app.register(adminSettingsRoutes()) 183 187 await app.ready() ··· 222 226 223 227 expect(response.statusCode).toBe(200) 224 228 const body = response.json<{ 225 - id: string 226 229 communityName: string 227 230 maturityRating: string 228 231 initialized: boolean 229 232 }>() 230 - expect(body.id).toBe('default') 231 233 expect(body.communityName).toBe('Test Community') 232 234 expect(body.maturityRating).toBe('safe') 233 235 expect(body.initialized).toBe(true)
+5 -1
tests/unit/routes/categories.test.ts
··· 152 152 153 153 function sampleCommunitySettings(overrides?: Record<string, unknown>) { 154 154 return { 155 - id: 'default', 156 155 initialized: true, 157 156 communityDid: 'did:plc:community123', 158 157 adminDid: ADMIN_DID, ··· 184 183 app.decorate('setupService', {} as SetupService) 185 184 app.decorate('cache', {} as never) 186 185 app.decorateRequest('user', undefined as RequestUser | undefined) 186 + app.decorateRequest('communityDid', undefined as string | undefined) 187 + app.addHook('onRequest', (request, _reply, done) => { 188 + request.communityDid = 'did:plc:test' 189 + done() 190 + }) 187 191 188 192 await app.register(categoryRoutes()) 189 193 await app.ready()
+1 -1
tests/unit/routes/global-filters.test.ts
··· 57 57 // --------------------------------------------------------------------------- 58 58 59 59 const globalMockEnv = { 60 - COMMUNITY_MODE: 'global', 60 + COMMUNITY_MODE: 'multi', 61 61 OPERATOR_DIDS: [OPERATOR_DID], 62 62 RATE_LIMIT_WRITE: 10, 63 63 RATE_LIMIT_READ_ANON: 100,
+30
tests/unit/routes/health.test.ts
··· 2 2 import { buildApp } from '../../../src/app.js' 3 3 import type { FastifyInstance } from 'fastify' 4 4 5 + // Mock database to avoid real PostgreSQL connection 6 + const mockExecute = vi.fn().mockResolvedValue([{ '?column?': 1 }]) 7 + const mockDb = { 8 + execute: mockExecute, 9 + select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), 10 + insert: vi.fn(), 11 + update: vi.fn(), 12 + delete: vi.fn(), 13 + transaction: vi.fn(), 14 + query: {}, 15 + } 16 + const mockClient = { 17 + end: vi.fn().mockResolvedValue(undefined), 18 + } 19 + vi.mock('../../../src/db/index.js', () => ({ 20 + createDb: () => ({ db: mockDb, client: mockClient }), 21 + })) 22 + 23 + // Mock cache to avoid real Valkey connection 24 + const mockCache = { 25 + get: vi.fn().mockResolvedValue(null), 26 + set: vi.fn().mockResolvedValue(undefined), 27 + del: vi.fn().mockResolvedValue(undefined), 28 + ping: vi.fn().mockResolvedValue('PONG'), 29 + quit: vi.fn().mockResolvedValue(undefined), 30 + } 31 + vi.mock('../../../src/cache/index.js', () => ({ 32 + createCache: () => mockCache, 33 + })) 34 + 5 35 // Mock @atproto/oauth-client-node to avoid crypto operations 6 36 vi.mock('@atproto/oauth-client-node', () => { 7 37 return {
+5
tests/unit/routes/maturity-filtering.test.ts
··· 157 157 app.decorate('setupService', {} as SetupService) 158 158 app.decorate('cache', {} as never) 159 159 app.decorateRequest('user', undefined as RequestUser | undefined) 160 + app.decorateRequest('communityDid', undefined as string | undefined) 161 + app.addHook('onRequest', (request, _reply, done) => { 162 + request.communityDid = 'did:plc:test' 163 + done() 164 + }) 160 165 161 166 await app.register(topicRoutes()) 162 167 await app.register(replyRoutes())
+5
tests/unit/routes/moderation-appeals.test.ts
··· 188 188 app.decorate('setupService', {} as SetupService) 189 189 app.decorate('cache', {} as never) 190 190 app.decorateRequest('user', undefined as RequestUser | undefined) 191 + app.decorateRequest('communityDid', undefined as string | undefined) 192 + app.addHook('onRequest', (request, _reply, done) => { 193 + request.communityDid = 'did:plc:test' 194 + done() 195 + }) 191 196 192 197 await app.register(moderationRoutes()) 193 198 await app.ready()
+5
tests/unit/routes/moderation-queue.test.ts
··· 128 128 app.decorate('sessionService', {} as SessionService) 129 129 app.decorate('setupService', {} as SetupService) 130 130 app.decorateRequest('user', undefined as RequestUser | undefined) 131 + app.decorateRequest('communityDid', undefined as string | undefined) 132 + app.addHook('onRequest', (request, _reply, done) => { 133 + request.communityDid = 'did:plc:test' 134 + done() 135 + }) 131 136 132 137 mockRequireModerator.mockImplementation((request: { user: RequestUser | undefined }) => { 133 138 if (user) {
+23 -3
tests/unit/routes/moderation.test.ts
··· 275 275 app.decorate('setupService', {} as SetupService) 276 276 app.decorate('cache', {} as never) 277 277 app.decorateRequest('user', undefined as RequestUser | undefined) 278 + app.decorateRequest('communityDid', undefined as string | undefined) 279 + app.addHook('onRequest', (request, _reply, done) => { 280 + request.communityDid = 'did:plc:test' 281 + done() 282 + }) 278 283 279 284 await app.register(moderationRoutes()) 280 285 await app.ready() ··· 311 316 app.decorate('setupService', {} as SetupService) 312 317 app.decorate('cache', {} as never) 313 318 app.decorateRequest('user', undefined as RequestUser | undefined) 319 + app.decorateRequest('communityDid', undefined as string | undefined) 320 + app.addHook('onRequest', (request, _reply, done) => { 321 + request.communityDid = 'did:plc:test' 322 + done() 323 + }) 314 324 315 325 await app.register(moderationRoutes()) 316 326 await app.ready() ··· 1657 1667 let globalApp: FastifyInstance 1658 1668 1659 1669 beforeAll(async () => { 1660 - // Build a special app with COMMUNITY_MODE='global' 1670 + // Build a special app with COMMUNITY_MODE='multi' 1661 1671 const globalEnv = { 1662 1672 ...mockEnv, 1663 - COMMUNITY_MODE: 'global', 1673 + COMMUNITY_MODE: 'multi', 1664 1674 } as Env 1665 1675 1666 1676 const app = Fastify({ logger: false }) ··· 1679 1689 del: vi.fn().mockResolvedValue(undefined), 1680 1690 } as never) 1681 1691 app.decorateRequest('user', undefined as RequestUser | undefined) 1692 + app.decorateRequest('communityDid', undefined as string | undefined) 1693 + app.addHook('onRequest', (request, _reply, done) => { 1694 + request.communityDid = 'did:plc:test' 1695 + done() 1696 + }) 1682 1697 1683 1698 await app.register(moderationRoutes()) 1684 1699 await app.ready() ··· 1915 1930 beforeAll(async () => { 1916 1931 const globalEnv = { 1917 1932 ...mockEnv, 1918 - COMMUNITY_MODE: 'global', 1933 + COMMUNITY_MODE: 'multi', 1919 1934 } as Env 1920 1935 1921 1936 const app = Fastify({ logger: false }) ··· 1931 1946 app.decorate('setupService', {} as SetupService) 1932 1947 app.decorate('cache', {} as never) 1933 1948 app.decorateRequest('user', undefined as RequestUser | undefined) 1949 + app.decorateRequest('communityDid', undefined as string | undefined) 1950 + app.addHook('onRequest', (request, _reply, done) => { 1951 + request.communityDid = 'did:plc:test' 1952 + done() 1953 + }) 1934 1954 1935 1955 await app.register(moderationRoutes()) 1936 1956 await app.ready()
+5
tests/unit/routes/onboarding.test.ts
··· 167 167 app.decorate('setupService', {} as SetupService) 168 168 app.decorate('cache', {} as never) 169 169 app.decorateRequest('user', undefined as RequestUser | undefined) 170 + app.decorateRequest('communityDid', undefined as string | undefined) 171 + app.addHook('onRequest', (request, _reply, done) => { 172 + request.communityDid = 'did:plc:test' 173 + done() 174 + }) 170 175 171 176 await app.register(onboardingRoutes()) 172 177 await app.ready()
+5
tests/unit/routes/reactions.test.ts
··· 185 185 recordCoParticipation: vi.fn().mockResolvedValue(undefined), 186 186 } as never) 187 187 app.decorateRequest('user', undefined as RequestUser | undefined) 188 + app.decorateRequest('communityDid', undefined as string | undefined) 189 + app.addHook('onRequest', (request, _reply, done) => { 190 + request.communityDid = 'did:plc:test' 191 + done() 192 + }) 188 193 189 194 await app.register(reactionRoutes()) 190 195 await app.ready()
+5
tests/unit/routes/replies.test.ts
··· 302 302 app.decorate('ozoneService', ozoneService as never) 303 303 } 304 304 app.decorateRequest('user', undefined as RequestUser | undefined) 305 + app.decorateRequest('communityDid', undefined as string | undefined) 306 + app.addHook('onRequest', (request, _reply, done) => { 307 + request.communityDid = 'did:plc:test' 308 + done() 309 + }) 305 310 306 311 await app.register(replyRoutes()) 307 312 await app.ready()
+12 -2
tests/unit/routes/search.test.ts
··· 94 94 const app = Fastify({ logger: false }) 95 95 96 96 app.decorateRequest('user', undefined as RequestUser | undefined) 97 + app.decorateRequest('communityDid', undefined as string | undefined) 98 + app.addHook('onRequest', (request, _reply, done) => { 99 + request.communityDid = 'did:plc:test' 100 + done() 101 + }) 97 102 app.decorate('db', mockDb as never) 98 103 app.decorate('env', { 99 104 EMBEDDING_URL: undefined, ··· 1368 1373 const authApp = Fastify({ logger: false }) 1369 1374 1370 1375 authApp.decorateRequest('user', undefined as RequestUser | undefined) 1376 + authApp.decorateRequest('communityDid', undefined as string | undefined) 1377 + authApp.addHook('onRequest', (request, _reply, done) => { 1378 + request.communityDid = 'did:plc:test' 1379 + done() 1380 + }) 1371 1381 authApp.decorate('db', mockDb as never) 1372 1382 authApp.decorate('env', { 1373 1383 EMBEDDING_URL: undefined, ··· 1393 1403 }) 1394 1404 1395 1405 expect(response.statusCode).toBe(200) 1396 - // loadMutedWords should be called with the authenticated user's DID 1406 + // loadMutedWords should be called with the authenticated user's DID and community DID 1397 1407 expect(mockLoadMutedWords).toHaveBeenCalledWith( 1398 1408 'did:plc:autheduser', 1399 - TEST_COMMUNITY_DID, 1409 + 'did:plc:test', 1400 1410 expect.anything() 1401 1411 ) 1402 1412
+8
tests/unit/routes/setup.test.ts
··· 86 86 87 87 // Fastify requires decoration before hooks can set properties 88 88 app.decorateRequest('user', undefined as RequestUser | undefined) 89 + app.decorateRequest('communityDid', undefined as string | undefined) 90 + app.addHook('onRequest', (request, _reply, done) => { 91 + request.communityDid = 'did:plc:test' 92 + done() 93 + }) 89 94 90 95 // Register setup routes 91 96 await app.register(setupRoutes()) ··· 204 209 communityName: 'Barazo Community', 205 210 }) 206 211 expect(initializeFn).toHaveBeenCalledWith({ 212 + communityDid: 'did:plc:test', 207 213 did: TEST_DID, 208 214 communityName: undefined, 209 215 handle: undefined, ··· 253 259 communityName: 'Custom Forum Name', 254 260 }) 255 261 expect(initializeFn).toHaveBeenCalledWith({ 262 + communityDid: 'did:plc:test', 256 263 did: TEST_DID, 257 264 communityName: 'Custom Forum Name', 258 265 handle: undefined, ··· 336 343 337 344 expect(response.statusCode).toBe(200) 338 345 expect(initializeFn).toHaveBeenCalledWith({ 346 + communityDid: 'did:plc:test', 339 347 did: TEST_DID, 340 348 communityName: 'My Forum', 341 349 handle: 'forum.example.com',
+5
tests/unit/routes/topics-replies-integration.test.ts
··· 247 247 recordCoParticipation: vi.fn().mockResolvedValue(undefined), 248 248 } as never) 249 249 app.decorateRequest('user', undefined as RequestUser | undefined) 250 + app.decorateRequest('communityDid', undefined as string | undefined) 251 + app.addHook('onRequest', (request, _reply, done) => { 252 + request.communityDid = 'did:plc:test' 253 + done() 254 + }) 250 255 251 256 // Register BOTH route sets so we can test cross-endpoint behavior 252 257 await app.register(topicRoutes())
+39 -7
tests/unit/routes/topics.test.ts
··· 243 243 app.decorate('setupService', {} as SetupService) 244 244 app.decorate('cache', {} as never) 245 245 app.decorateRequest('user', undefined as RequestUser | undefined) 246 + app.decorateRequest('communityDid', undefined as string | undefined) 247 + app.addHook('onRequest', (request, _reply, done) => { 248 + request.communityDid = 'did:plc:test' 249 + done() 250 + }) 246 251 247 252 await app.register(topicRoutes()) 248 253 await app.ready() ··· 863 868 // Topics query (terminal via .limit) 864 869 selectChain.limit.mockResolvedValueOnce([sampleTopicRow({ authorDid: TEST_DID })]) 865 870 866 - // After maturity mocks (3 .where calls consumed), 4 more .where calls follow: 871 + // After maturity mocks (3 .where calls consumed), 5 more .where calls follow: 867 872 // 4. loadBlockMuteLists .where (terminal) 868 873 // 5. topics .where (chained to .orderBy().limit()) 869 874 // 6. loadMutedWords global .where (terminal) 870 - // 7. resolveAuthors users .where (terminal) 871 - // We must explicitly mock calls 4-7 so that: 875 + // 7. loadMutedWords community .where (terminal) 876 + // 8. resolveAuthors users .where (terminal) 877 + // We must explicitly mock calls 4-8 so that: 872 878 // - Call 5 returns the chain (not a Promise) for .orderBy().limit() to work 873 - // - Call 7 returns the author user row 879 + // - Call 8 returns the author user row 874 880 875 881 selectChain.where.mockResolvedValueOnce([]) // 4: loadBlockMuteLists 876 882 877 883 selectChain.where.mockImplementationOnce(() => selectChain) // 5: topics .where 878 884 selectChain.where.mockResolvedValueOnce([]) // 6: loadMutedWords global 885 + selectChain.where.mockResolvedValueOnce([]) // 7: loadMutedWords community 879 886 selectChain.where.mockResolvedValueOnce([ 880 - // 7: resolveAuthors users 887 + // 8: resolveAuthors users 881 888 { 882 889 did: TEST_DID, 883 890 handle: TEST_HANDLE, ··· 1489 1496 describe('GET /api/topics (global mode)', () => { 1490 1497 const globalMockEnv = { 1491 1498 ...mockEnv, 1492 - COMMUNITY_MODE: 'global' as const, 1499 + COMMUNITY_MODE: 'multi' as const, 1493 1500 COMMUNITY_DID: undefined, 1494 1501 } as Env 1495 1502 ··· 1507 1514 globalApp.decorate('setupService', {} as SetupService) 1508 1515 globalApp.decorate('cache', {} as never) 1509 1516 globalApp.decorateRequest('user', undefined as RequestUser | undefined) 1517 + globalApp.decorateRequest('communityDid', undefined as string | undefined) 1518 + globalApp.addHook('onRequest', (request, _reply, done) => { 1519 + request.communityDid = 'did:plc:test' 1520 + done() 1521 + }) 1510 1522 1511 1523 await globalApp.register(topicRoutes()) 1512 1524 await globalApp.ready() ··· 2092 2104 crossPostApp.decorate('setupService', {} as SetupService) 2093 2105 crossPostApp.decorate('cache', {} as never) 2094 2106 crossPostApp.decorateRequest('user', undefined as RequestUser | undefined) 2107 + crossPostApp.decorateRequest('communityDid', undefined as string | undefined) 2108 + crossPostApp.addHook('onRequest', (request, _reply, done) => { 2109 + request.communityDid = 'did:plc:test' 2110 + done() 2111 + }) 2095 2112 await crossPostApp.register(topicRoutes()) 2096 2113 await crossPostApp.ready() 2097 2114 ··· 2215 2232 batchIsSpamLabeled: vi.fn().mockResolvedValue(new Map()), 2216 2233 } as never) 2217 2234 ozoneApp.decorateRequest('user', undefined as RequestUser | undefined) 2235 + ozoneApp.decorateRequest('communityDid', undefined as string | undefined) 2236 + ozoneApp.addHook('onRequest', (request, _reply, done) => { 2237 + request.communityDid = 'did:plc:test' 2238 + done() 2239 + }) 2218 2240 await ozoneApp.register(topicRoutes()) 2219 2241 await ozoneApp.ready() 2220 2242 app = ozoneApp ··· 2340 2362 batchIsSpamLabeled: batchIsSpamLabeledFn, 2341 2363 } as never) 2342 2364 ozoneApp.decorateRequest('user', undefined as RequestUser | undefined) 2365 + ozoneApp.decorateRequest('communityDid', undefined as string | undefined) 2366 + ozoneApp.addHook('onRequest', (request, _reply, done) => { 2367 + request.communityDid = 'did:plc:test' 2368 + done() 2369 + }) 2343 2370 await ozoneApp.register(topicRoutes()) 2344 2371 await ozoneApp.ready() 2345 2372 app = ozoneApp ··· 3203 3230 describe('GET /api/topics (global mode - additional branches)', () => { 3204 3231 const globalMockEnv = { 3205 3232 ...mockEnv, 3206 - COMMUNITY_MODE: 'global' as const, 3233 + COMMUNITY_MODE: 'multi' as const, 3207 3234 COMMUNITY_DID: undefined, 3208 3235 } as Env 3209 3236 ··· 3219 3246 globalApp.decorate('setupService', {} as SetupService) 3220 3247 globalApp.decorate('cache', {} as never) 3221 3248 globalApp.decorateRequest('user', undefined as RequestUser | undefined) 3249 + globalApp.decorateRequest('communityDid', undefined as string | undefined) 3250 + globalApp.addHook('onRequest', (request, _reply, done) => { 3251 + request.communityDid = 'did:plc:test' 3252 + done() 3253 + }) 3222 3254 3223 3255 await globalApp.register(topicRoutes()) 3224 3256 await globalApp.ready()
+5
tests/unit/routes/votes.test.ts
··· 180 180 app.decorate('setupService', {} as SetupService) 181 181 app.decorate('cache', {} as never) 182 182 app.decorateRequest('user', undefined as RequestUser | undefined) 183 + app.decorateRequest('communityDid', undefined as string | undefined) 184 + app.addHook('onRequest', (request, _reply, done) => { 185 + request.communityDid = 'did:plc:test' 186 + done() 187 + }) 183 188 184 189 await app.register(voteRoutes()) 185 190 await app.ready()