diff --git a/drizzle.config.ts b/drizzle.config.ts index 5cc618d..798e5df 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'drizzle-kit' export default defineConfig({ schema: [ + './src/db/schema/roles.ts', './src/db/schema/users.ts', './src/db/schema/firehose.ts', './src/db/schema/topics.ts', diff --git a/src/app.ts b/src/app.ts index 33c838d..8c38c07 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,11 @@ import scalarApiReference from '@scalar/fastify-api-reference' import * as Sentry from '@sentry/node' import type { FastifyError } from 'fastify' import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import { sql } from 'drizzle-orm' import type { Env } from './config/env.js' +import { getCommunityDid } from './config/env.js' +import { createSingleResolver, registerCommunityResolver } from './middleware/community-resolver.js' +import type { CommunityResolver } from './middleware/community-resolver.js' import { createDb } from './db/index.js' import { createCache } from './cache/index.js' import { FirehoseService } from './firehose/service.js' @@ -162,6 +166,33 @@ export async function buildApp(env: Env) { limits: { fileSize: env.UPLOAD_MAX_SIZE_BYTES }, }) + // Community resolver (must run before auth middleware) + let resolver: CommunityResolver + if (env.COMMUNITY_MODE === 'multi') { + try { + const mod = await import('@barazo/multi-tenant') + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + resolver = mod.createMultiResolver(db, cache) + } catch { + throw new Error( + 'COMMUNITY_MODE is "multi" but @barazo/multi-tenant package is not installed. ' + + 'Install it or switch to COMMUNITY_MODE="single".' + ) + } + } else { + resolver = createSingleResolver(getCommunityDid(env)) + } + registerCommunityResolver(app, resolver, env.COMMUNITY_MODE) + + // Set RLS session variable per request + app.addHook('onRequest', async (request) => { + if (request.communityDid) { + await db.execute( + sql`SELECT set_config('app.current_community_did', ${request.communityDid}, true)` + ) + } + }) + // OAuth client const oauthClient = createOAuthClient(env, cache, app.log) app.decorate('oauthClient', oauthClient) @@ -198,7 +229,7 @@ export async function buildApp(env: Env) { const requireAdmin = createRequireAdmin(db, authMiddleware, app.log) app.decorate('requireAdmin', requireAdmin) - // Operator middleware (global mode only) + // Operator middleware (multi mode only) const requireOperator = createRequireOperator(env, authMiddleware, app.log) app.decorate('requireOperator', requireOperator) diff --git a/src/auth/require-operator.ts b/src/auth/require-operator.ts index f0756bf..f25cc3c 100644 --- a/src/auth/require-operator.ts +++ b/src/auth/require-operator.ts @@ -7,7 +7,7 @@ import type { Logger } from '../lib/logger.js' * Create a requireOperator preHandler hook for Fastify routes. * * This middleware: - * 1. Returns 404 if COMMUNITY_MODE is not "global" (hides global routes in single mode) + * 1. Returns 404 if COMMUNITY_MODE is not "multi" (hides operator routes in single mode) * 2. Delegates to requireAuth to verify the user is authenticated * 3. Checks if the user's DID is in the OPERATOR_DIDS list * 4. Returns 403 if the user is not an operator @@ -21,8 +21,8 @@ export function createRequireOperator( logger?: Logger ): (request: FastifyRequest, reply: FastifyReply) => Promise { return async (request: FastifyRequest, reply: FastifyReply): Promise => { - // Global-mode-only routes return 404 in single-community mode - if (env.COMMUNITY_MODE !== 'global') { + // Multi-mode-only routes return 404 in single-community mode + if (env.COMMUNITY_MODE !== 'multi') { await reply.status(404).send({ error: 'Not found' }) return } diff --git a/src/config/env.ts b/src/config/env.ts index c27a1f9..ff85811 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -36,7 +36,7 @@ const baseEnvSchema = z.object({ CORS_ORIGINS: z.string().default('http://localhost:3001'), // Community - COMMUNITY_MODE: z.enum(['single', 'global']).default('single'), + COMMUNITY_MODE: z.enum(['single', 'multi']).default('single'), COMMUNITY_DID: z.string().optional(), COMMUNITY_NAME: z.string().default('Barazo Community'), @@ -78,7 +78,7 @@ const baseEnvSchema = z.object({ .transform((v) => v === 'true'), PUBLIC_URL: z.string().default('http://localhost:3001'), - // Global mode: operator DIDs (comma-separated) + // Multi mode: operator DIDs (comma-separated) OPERATOR_DIDS: z .string() .default('') diff --git a/src/db/schema/account-filters.ts b/src/db/schema/account-filters.ts index 45c828e..a14e7c8 100644 --- a/src/db/schema/account-filters.ts +++ b/src/db/schema/account-filters.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, serial, integer, uniqueIndex } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, serial, integer, uniqueIndex } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const accountFilters = pgTable( 'account_filters', @@ -25,5 +27,12 @@ export const accountFilters = pgTable( index('account_filters_community_did_idx').on(table.communityDid), index('account_filters_status_idx').on(table.status), index('account_filters_updated_at_idx').on(table.updatedAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/account-trust.ts b/src/db/schema/account-trust.ts index 011ffb3..98e4b03 100644 --- a/src/db/schema/account-trust.ts +++ b/src/db/schema/account-trust.ts @@ -1,5 +1,6 @@ import { pgTable, + pgPolicy, serial, text, integer, @@ -8,6 +9,8 @@ import { index, uniqueIndex, } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const accountTrust = pgTable( 'account_trust', @@ -22,5 +25,12 @@ export const accountTrust = pgTable( (table) => [ uniqueIndex('account_trust_did_community_idx').on(table.did, table.communityDid), index('account_trust_did_idx').on(table.did), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/categories.ts b/src/db/schema/categories.ts index ad1e231..edd6766 100644 --- a/src/db/schema/categories.ts +++ b/src/db/schema/categories.ts @@ -1,5 +1,6 @@ import { pgTable, + pgPolicy, text, integer, timestamp, @@ -7,6 +8,8 @@ import { uniqueIndex, foreignKey, } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const categories = pgTable( 'categories', @@ -36,5 +39,12 @@ export const categories = pgTable( foreignColumns: [table.id], name: 'categories_parent_id_fk', }).onDelete('set null'), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/community-filters.ts b/src/db/schema/community-filters.ts index 67af9f4..f5f5818 100644 --- a/src/db/schema/community-filters.ts +++ b/src/db/schema/community-filters.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, integer } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, integer } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const communityFilters = pgTable( 'community_filters', @@ -21,5 +23,12 @@ export const communityFilters = pgTable( index('community_filters_status_idx').on(table.status), index('community_filters_admin_did_idx').on(table.adminDid), index('community_filters_updated_at_idx').on(table.updatedAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/community-profiles.ts b/src/db/schema/community-profiles.ts index b31787a..c0ef962 100644 --- a/src/db/schema/community-profiles.ts +++ b/src/db/schema/community-profiles.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' /** * Per-community profile overrides. @@ -20,5 +22,12 @@ export const communityProfiles = pgTable( primaryKey({ columns: [table.did, table.communityDid] }), index('community_profiles_did_idx').on(table.did), index('community_profiles_community_idx').on(table.communityDid), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/community-settings.ts b/src/db/schema/community-settings.ts index d5289e3..dba55ab 100644 --- a/src/db/schema/community-settings.ts +++ b/src/db/schema/community-settings.ts @@ -1,9 +1,11 @@ -import { pgTable, text, boolean, timestamp, jsonb, integer } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, boolean, timestamp, jsonb, integer } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const communitySettings = pgTable('community_settings', { - id: text('id').primaryKey().default('default'), + communityDid: text('community_did').primaryKey(), + domains: jsonb('domains').$type().notNull().default([]), initialized: boolean('initialized').notNull().default(false), - communityDid: text('community_did'), adminDid: text('admin_did'), communityName: text('community_name').notNull().default('Barazo Community'), maturityRating: text('maturity_rating', { @@ -54,4 +56,12 @@ export const communitySettings = pgTable('community_settings', { accentColor: text('accent_color'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}) +}, () => [ + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), +]).enableRLS() diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 6f14d15..bf2dca2 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,3 +1,4 @@ +export { appRole } from './roles.js' export { users } from './users.js' export { firehoseCursor } from './firehose.js' export { topics } from './topics.js' diff --git a/src/db/schema/moderation-actions.ts b/src/db/schema/moderation-actions.ts index c626214..2d7d610 100644 --- a/src/db/schema/moderation-actions.ts +++ b/src/db/schema/moderation-actions.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const moderationActions = pgTable( 'moderation_actions', @@ -20,5 +22,12 @@ export const moderationActions = pgTable( index('mod_actions_created_at_idx').on(table.createdAt), index('mod_actions_target_uri_idx').on(table.targetUri), index('mod_actions_target_did_idx').on(table.targetDid), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/moderation-queue.ts b/src/db/schema/moderation-queue.ts index 9d2f925..39d74ab 100644 --- a/src/db/schema/moderation-queue.ts +++ b/src/db/schema/moderation-queue.ts @@ -1,4 +1,6 @@ -import { pgTable, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, serial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const moderationQueue = pgTable( 'moderation_queue', @@ -29,5 +31,12 @@ export const moderationQueue = pgTable( index('mod_queue_status_idx').on(table.status), index('mod_queue_created_at_idx').on(table.createdAt), index('mod_queue_content_uri_idx').on(table.contentUri), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/notifications.ts b/src/db/schema/notifications.ts index f3ce3f9..9938001 100644 --- a/src/db/schema/notifications.ts +++ b/src/db/schema/notifications.ts @@ -1,4 +1,6 @@ -import { pgTable, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const notifications = pgTable( 'notifications', @@ -26,5 +28,12 @@ export const notifications = pgTable( index('notifications_recipient_did_idx').on(table.recipientDid), index('notifications_recipient_read_idx').on(table.recipientDid, table.read), index('notifications_created_at_idx').on(table.createdAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/onboarding-fields.ts b/src/db/schema/onboarding-fields.ts index c863ab9..1614555 100644 --- a/src/db/schema/onboarding-fields.ts +++ b/src/db/schema/onboarding-fields.ts @@ -1,5 +1,6 @@ import { pgTable, + pgPolicy, text, boolean, integer, @@ -8,6 +9,8 @@ import { primaryKey, index, } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const communityOnboardingFields = pgTable( 'community_onboarding_fields', @@ -34,8 +37,17 @@ export const communityOnboardingFields = pgTable( createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => [index('onboarding_fields_community_idx').on(table.communityDid)] -) + (table) => [ + index('onboarding_fields_community_idx').on(table.communityDid), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() export const userOnboardingResponses = pgTable( 'user_onboarding_responses', @@ -49,5 +61,12 @@ export const userOnboardingResponses = pgTable( (table) => [ primaryKey({ columns: [table.did, table.communityDid, table.fieldId] }), index('onboarding_responses_did_community_idx').on(table.did, table.communityDid), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/reactions.ts b/src/db/schema/reactions.ts index 6c5471f..7306692 100644 --- a/src/db/schema/reactions.ts +++ b/src/db/schema/reactions.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, unique } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, unique } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const reactions = pgTable( 'reactions', @@ -22,5 +24,12 @@ export const reactions = pgTable( // reaction to a given subject is inherently community-scoped via the subject URI. unique('reactions_author_subject_type_uniq').on(table.authorDid, table.subjectUri, table.type), index('reactions_subject_uri_type_idx').on(table.subjectUri, table.type), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/replies.ts b/src/db/schema/replies.ts index 03575c0..6101dea 100644 --- a/src/db/schema/replies.ts +++ b/src/db/schema/replies.ts @@ -1,4 +1,6 @@ -import { pgTable, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const replies = pgTable( 'replies', @@ -46,5 +48,12 @@ export const replies = pgTable( index('replies_moderation_status_idx').on(table.moderationStatus), index('replies_trust_status_idx').on(table.trustStatus), index('replies_root_uri_created_at_idx').on(table.rootUri, table.createdAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/reports.ts b/src/db/schema/reports.ts index ba40b4e..ebe5f76 100644 --- a/src/db/schema/reports.ts +++ b/src/db/schema/reports.ts @@ -1,4 +1,6 @@ -import { pgTable, text, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, timestamp, index, serial, uniqueIndex } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const reports = pgTable( 'reports', @@ -43,5 +45,12 @@ export const reports = pgTable( table.targetUri, table.communityDid ), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/roles.ts b/src/db/schema/roles.ts new file mode 100644 index 0000000..a3f7672 --- /dev/null +++ b/src/db/schema/roles.ts @@ -0,0 +1,3 @@ +import { pgRole } from 'drizzle-orm/pg-core' + +export const appRole = pgRole('barazo_app') diff --git a/src/db/schema/topics.ts b/src/db/schema/topics.ts index adf3024..5ceb80b 100644 --- a/src/db/schema/topics.ts +++ b/src/db/schema/topics.ts @@ -1,4 +1,6 @@ -import { pgTable, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' +import { pgTable, pgPolicy, text, integer, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' export const topics = pgTable( 'topics', @@ -53,5 +55,12 @@ export const topics = pgTable( table.category, table.lastActivityAt ), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/db/schema/user-preferences.ts b/src/db/schema/user-preferences.ts index 993cf44..af73b45 100644 --- a/src/db/schema/user-preferences.ts +++ b/src/db/schema/user-preferences.ts @@ -1,5 +1,6 @@ import { pgTable, + pgPolicy, text, timestamp, integer, @@ -8,6 +9,8 @@ import { index, primaryKey, } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' // --------------------------------------------------------------------------- // Global user preferences (stored in PostgreSQL for MVP, will sync to PDS later) @@ -57,5 +60,12 @@ export const userCommunityPreferences = pgTable( primaryKey({ columns: [table.did, table.communityDid] }), index('user_community_prefs_did_idx').on(table.did), index('user_community_prefs_community_idx').on(table.communityDid), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), ] -) +).enableRLS() diff --git a/src/lib/anti-spam.ts b/src/lib/anti-spam.ts index b965027..d4c713a 100644 --- a/src/lib/anti-spam.ts +++ b/src/lib/anti-spam.ts @@ -77,7 +77,7 @@ export async function loadAntiSpamSettings( wordFilter: communitySettings.wordFilter, }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const row = rows[0] const thresholds = row?.moderationThresholds diff --git a/src/middleware/community-resolver.ts b/src/middleware/community-resolver.ts new file mode 100644 index 0000000..2bdd9af --- /dev/null +++ b/src/middleware/community-resolver.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify' +import { badRequest } from '../lib/api-errors.js' + +declare module 'fastify' { + interface FastifyRequest { + communityDid: string | undefined + } +} + +export interface CommunityResolver { + resolve(hostname: string): Promise +} + +/** + * Extract communityDid from request, throwing 400 if not set. + * Use in route handlers that require a community context (most write operations). + */ +export function requireCommunityDid(request: FastifyRequest): string { + const { communityDid } = request + if (!communityDid) { + throw badRequest('Community context required') + } + return communityDid +} + +export function createSingleResolver(communityDid: string): CommunityResolver { + return { resolve: () => Promise.resolve(communityDid) } +} + +export function registerCommunityResolver( + app: FastifyInstance, + resolver: CommunityResolver, + mode: 'single' | 'multi' +): void { + app.decorateRequest('communityDid', undefined as string | undefined) + + app.addHook('onRequest', async (request, reply) => { + const communityDid = await resolver.resolve(request.hostname) + if (!communityDid && mode === 'single') { + return reply.status(404).send({ error: 'Community not found' }) + } + request.communityDid = communityDid + }) +} diff --git a/src/routes/admin-settings.ts b/src/routes/admin-settings.ts index 422370c..d0aafde 100644 --- a/src/routes/admin-settings.ts +++ b/src/routes/admin-settings.ts @@ -1,4 +1,5 @@ import { eq, sql } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' import { isMaturityLowerThan } from '../lib/maturity.js' @@ -13,9 +14,8 @@ import { categories } from '../db/schema/categories.js' const settingsJsonSchema = { type: 'object' as const, properties: { - id: { type: 'string' as const }, initialized: { type: 'boolean' as const }, - communityDid: { type: ['string', 'null'] as const }, + communityDid: { type: 'string' as const }, adminDid: { type: ['string', 'null'] as const }, communityName: { type: 'string' as const }, maturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, @@ -78,9 +78,8 @@ const statsJsonSchema = { function serializeSettings(row: typeof communitySettings.$inferSelect) { return { - id: row.id, initialized: row.initialized, - communityDid: row.communityDid ?? null, + communityDid: row.communityDid, adminDid: row.adminDid ?? null, communityName: row.communityName, maturityRating: row.maturityRating, @@ -138,11 +137,12 @@ export function adminSettingsRoutes(): FastifyPluginCallback { }, }, }, - async (_request, reply) => { + async (request, reply) => { + const communityDid = requireCommunityDid(request) const rows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const row = rows[0] if (!row) { @@ -150,7 +150,7 @@ export function adminSettingsRoutes(): FastifyPluginCallback { } return reply.status(200).send({ - communityDid: row.communityDid ?? null, + communityDid: row.communityDid, communityName: row.communityName, maturityRating: row.maturityRating, communityDescription: row.communityDescription ?? null, @@ -179,11 +179,12 @@ export function adminSettingsRoutes(): FastifyPluginCallback { }, }, }, - async (_request, reply) => { + async (request, reply) => { + const communityDid = requireCommunityDid(request) const rows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const row = rows[0] if (!row) { @@ -242,6 +243,7 @@ export function adminSettingsRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = updateSettingsSchema.safeParse(request.body) if (!parsed.success) { throw badRequest('Invalid settings data') @@ -269,7 +271,7 @@ export function adminSettingsRoutes(): FastifyPluginCallback { const rows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const current = rows[0] if (!current) { @@ -288,11 +290,11 @@ export function adminSettingsRoutes(): FastifyPluginCallback { if (isMaturityLowerThan(currentRating, newRating)) { // Raising maturity: find categories below the new threshold - const communityDid = current.communityDid ?? '' + const settingsCommunityDid = current.communityDid const allCategories = await db .select() .from(categories) - .where(eq(categories.communityDid, communityDid)) + .where(eq(categories.communityDid, settingsCommunityDid)) // Filter in application code since maturity comparison is enum-based const belowThreshold = allCategories.filter((cat) => @@ -355,7 +357,7 @@ export function adminSettingsRoutes(): FastifyPluginCallback { const updated = await db .update(communitySettings) .set(dbUpdates) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) .returning() const updatedRow = updated[0] diff --git a/src/routes/categories.ts b/src/routes/categories.ts index 196a40e..62fca98 100644 --- a/src/routes/categories.ts +++ b/src/routes/categories.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' +import { requireCommunityDid } from '../middleware/community-resolver.js' import { eq, and, count } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' import { isMaturityLowerThan } from '../lib/maturity.js' import { @@ -176,7 +176,7 @@ const categoryWithTopicCountJsonSchema = { */ export function categoryRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, requireAdmin } = app + const { db, authMiddleware, requireAdmin } = app // ------------------------------------------------------------------- // GET /api/categories (public, optionalAuth) @@ -209,7 +209,7 @@ export function categoryRoutes(): FastifyPluginCallback { async (request, reply) => { const parsed = categoryQuerySchema.safeParse(request.query) const parentId = parsed.success ? parsed.data.parentId : undefined - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const conditions = [eq(categories.communityDid, communityDid)] if (parentId !== undefined) { @@ -253,7 +253,7 @@ export function categoryRoutes(): FastifyPluginCallback { }, async (request, reply) => { const { slug } = request.params as { slug: string } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const rows = await db .select() @@ -320,13 +320,13 @@ export function categoryRoutes(): FastifyPluginCallback { } const { name, slug, description, parentId, sortOrder, maturityRating } = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Fetch community settings for maturity default const settingsRows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const settings = settingsRows[0] const communityDefault = settings?.maturityRating ?? 'safe' @@ -445,13 +445,13 @@ export function categoryRoutes(): FastifyPluginCallback { } const updates = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Fetch community settings for maturity validation const settingsRows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const settings = settingsRows[0] const communityDefault = settings?.maturityRating ?? 'safe' @@ -579,7 +579,7 @@ export function categoryRoutes(): FastifyPluginCallback { } // Check if category has topics within this community - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const topicCountResult = await db .select({ count: count() }) .from(topics) @@ -649,6 +649,7 @@ export function categoryRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const { id } = request.params as { id: string } const parsed = updateMaturitySchema.safeParse(request.body) @@ -670,7 +671,7 @@ export function categoryRoutes(): FastifyPluginCallback { const settingsRows = await db .select() .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const settings = settingsRows[0] const communityDefault = settings?.maturityRating ?? 'safe' diff --git a/src/routes/moderation-queue.ts b/src/routes/moderation-queue.ts index fcb9bde..c024f1a 100644 --- a/src/routes/moderation-queue.ts +++ b/src/routes/moderation-queue.ts @@ -1,6 +1,6 @@ import { eq, and, desc, sql } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' import { wordFilterSchema, queueActionSchema, queueQuerySchema } from '../validation/anti-spam.js' import { moderationQueue } from '../db/schema/moderation-queue.js' @@ -77,10 +77,9 @@ function decodeCursor(cursor: string): { createdAt: string; id: number } | null export function moderationQueueRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware } = app + const { db, authMiddleware } = app const requireModerator = createRequireModerator(db, authMiddleware, app.log) const requireAdmin = app.requireAdmin - const communityDid = getCommunityDid(env) // ------------------------------------------------------------------- // GET /api/moderation/queue (moderator+) @@ -122,6 +121,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = queueQuerySchema.safeParse(request.query) if (!parsed.success) { throw badRequest('Invalid query parameters') @@ -208,6 +208,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -319,7 +320,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { moderationThresholds: communitySettings.moderationThresholds, }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const trustedPostThreshold = settingsRows[0]?.moderationThresholds.trustedPostThreshold ?? 10 @@ -403,11 +404,12 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, }, }, - async (_request, reply) => { + async (request, reply) => { + const communityDid = requireCommunityDid(request) const rows = await db .select({ wordFilter: communitySettings.wordFilter }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const words = rows[0]?.wordFilter ?? [] @@ -453,6 +455,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = wordFilterSchema.safeParse(request.body) if (!parsed.success) { throw badRequest('Invalid word filter data') @@ -464,7 +467,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { await db .update(communitySettings) .set({ wordFilter: words }) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) // Invalidate cached anti-spam settings try { diff --git a/src/routes/moderation.ts b/src/routes/moderation.ts index 9b78d03..9611840 100644 --- a/src/routes/moderation.ts +++ b/src/routes/moderation.ts @@ -1,6 +1,6 @@ import { eq, and, desc, sql } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { notFound, forbidden, @@ -143,7 +143,6 @@ export function moderationRoutes(): FastifyPluginCallback { const { db, env, authMiddleware } = app const requireModerator = createRequireModerator(db, authMiddleware, app.log) const requireAdmin = app.requireAdmin - const communityDid = getCommunityDid(env) const notificationService = createNotificationService(db, app.log) // ------------------------------------------------------------------- @@ -184,6 +183,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -277,6 +277,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -373,6 +374,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -528,6 +530,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const admin = request.user if (!admin) { return reply.status(401).send({ error: 'Authentication required' }) @@ -575,8 +578,8 @@ export function moderationRoutes(): FastifyPluginCallback { app.log.info({ action, targetDid, adminDid: admin.did }, `User ${action}ned`) - // In global mode, check ban propagation across communities - if (env.COMMUNITY_MODE === 'global' && action === 'ban') { + // In multi mode, check ban propagation across communities + if (env.COMMUNITY_MODE === 'multi' && action === 'ban') { try { const result = await checkBanPropagation(db, app.cache, app.log, targetDid) if (result.propagated) { @@ -646,6 +649,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = moderationLogQuerySchema.safeParse(request.query) if (!parsed.success) { throw badRequest('Invalid query parameters') @@ -729,6 +733,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -810,8 +815,8 @@ export function moderationRoutes(): FastifyPluginCallback { 'Content reported' ) - // In global mode, notify the community admin about the report - if (env.COMMUNITY_MODE === 'global') { + // In multi mode, notify the community admin about the report + if (env.COMMUNITY_MODE === 'multi') { try { const filterRows = await db .select({ adminDid: communityFilters.adminDid }) @@ -873,6 +878,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = reportQuerySchema.safeParse(request.query) if (!parsed.success) { throw badRequest('Invalid query parameters') @@ -959,6 +965,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -1056,6 +1063,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = reportedUsersQuerySchema.safeParse(request.query) const limit = parsed.success ? parsed.data.limit : 25 @@ -1108,11 +1116,12 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, }, - async (_request, reply) => { + async (request, reply) => { + const communityDid = requireCommunityDid(request) const settingsRows = await db .select({ moderationThresholds: communitySettings.moderationThresholds }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const settings = settingsRows[0] const t = settings?.moderationThresholds @@ -1183,6 +1192,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const parsed = moderationThresholdsSchema.safeParse(request.body) if (!parsed.success) { throw badRequest('Invalid threshold values') @@ -1192,7 +1202,7 @@ export function moderationRoutes(): FastifyPluginCallback { const existingRows = await db .select({ moderationThresholds: communitySettings.moderationThresholds }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const existing = existingRows[0]?.moderationThresholds ?? { autoBlockReportCount: 5, @@ -1220,7 +1230,7 @@ export function moderationRoutes(): FastifyPluginCallback { await db .update(communitySettings) .set({ moderationThresholds: merged }) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) // Invalidate cached anti-spam settings try { @@ -1266,6 +1276,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) @@ -1354,6 +1365,7 @@ export function moderationRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { + const communityDid = requireCommunityDid(request) const user = request.user if (!user) { return reply.status(401).send({ error: 'Authentication required' }) diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index 326e745..ad4bdfb 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,6 +1,6 @@ import { eq, and, asc } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { notFound, badRequest, forbidden, errorResponseSchema } from '../lib/api-errors.js' import { createOnboardingFieldSchema, @@ -78,7 +78,7 @@ function serializeField(row: typeof communityOnboardingFields.$inferSelect) { export function onboardingRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, authMiddleware, env } = app + const { db, authMiddleware } = app const requireAdmin = app.requireAdmin // ===================================================================== @@ -107,8 +107,8 @@ export function onboardingRoutes(): FastifyPluginCallback { }, }, }, - async (_request, reply) => { - const communityDid = getCommunityDid(env) + async (request, reply) => { + const communityDid = requireCommunityDid(request) const fields = await db .select() @@ -158,7 +158,7 @@ export function onboardingRoutes(): FastifyPluginCallback { throw badRequest('Invalid onboarding field data') } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const inserted = await db .insert(communityOnboardingFields) @@ -240,7 +240,7 @@ export function onboardingRoutes(): FastifyPluginCallback { throw badRequest('At least one field must be provided') } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const dbUpdates: Record = { updatedAt: new Date() } if (updates.label !== undefined) dbUpdates.label = updates.label @@ -298,7 +298,7 @@ export function onboardingRoutes(): FastifyPluginCallback { }, }, async (request, reply) => { - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const deleted = await db .delete(communityOnboardingFields) @@ -368,7 +368,7 @@ export function onboardingRoutes(): FastifyPluginCallback { throw badRequest('Invalid reorder data') } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Update each field's sort order for (const item of parsed.data) { @@ -422,7 +422,7 @@ export function onboardingRoutes(): FastifyPluginCallback { throw forbidden('Authentication required') } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Get all fields for this community const fields = await db @@ -506,7 +506,7 @@ export function onboardingRoutes(): FastifyPluginCallback { throw badRequest('Invalid submission data') } - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Fetch all community fields to validate against const fields = await db diff --git a/src/routes/reactions.ts b/src/routes/reactions.ts index 274bdad..82ee4af 100644 --- a/src/routes/reactions.ts +++ b/src/routes/reactions.ts @@ -1,6 +1,6 @@ import { eq, and, sql, asc } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' import { notFound, @@ -102,7 +102,7 @@ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null */ export function reactionRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, firehose } = app + const { db, authMiddleware, firehose } = app const pdsClient = createPdsClient(app.oauthClient, app.log) const notificationService = createNotificationService(db, app.log) @@ -161,7 +161,7 @@ export function reactionRoutes(): FastifyPluginCallback { } const { subjectUri, subjectCid, type: reactionType } = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Onboarding gate: block if user hasn't completed mandatory onboarding const onboarding = await checkOnboardingComplete(db, user.did, communityDid) @@ -176,7 +176,7 @@ export function reactionRoutes(): FastifyPluginCallback { const settingsRows = await db .select({ reactionSet: communitySettings.reactionSet }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const settings = settingsRows[0] const reactionSet: string[] = settings?.reactionSet ?? ['like'] @@ -360,7 +360,7 @@ export function reactionRoutes(): FastifyPluginCallback { const { uri } = request.params as { uri: string } const decodedUri = decodeURIComponent(uri) - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Fetch existing reaction (scoped to this community) const existing = await db @@ -464,7 +464,7 @@ export function reactionRoutes(): FastifyPluginCallback { } const { subjectUri, type: reactionType, cursor, limit } = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const conditions = [ eq(reactions.subjectUri, subjectUri), eq(reactions.communityDid, communityDid), diff --git a/src/routes/replies.ts b/src/routes/replies.ts index 01d90c3..7593d2d 100644 --- a/src/routes/replies.ts +++ b/src/routes/replies.ts @@ -1,6 +1,6 @@ import { eq, and, sql, asc, notInArray } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' import { notFound, @@ -542,7 +542,7 @@ export function replyRoutes(): FastifyPluginCallback { } // Maturity check: verify the topic's category is within the user's allowed level - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const catRows = await db .select({ maturityRating: categories.maturityRating }) .from(categories) @@ -574,7 +574,7 @@ export function replyRoutes(): FastifyPluginCallback { const replySettingsRows = await db .select({ ageThreshold: communitySettings.ageThreshold }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const replyAgeThreshold = replySettingsRows[0]?.ageThreshold ?? 16 const maxMaturity = resolveMaxMaturity(userProfile, replyAgeThreshold) diff --git a/src/routes/search.ts b/src/routes/search.ts index d9f4744..4001586 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,6 +1,5 @@ import { sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' import { createEmbeddingService } from '../services/embedding.js' @@ -265,7 +264,7 @@ export function searchRoutes(): FastifyPluginCallback { // Community scope: in single mode, restrict to the configured community const searchCommunityDid = - env.COMMUNITY_MODE === 'single' ? getCommunityDid(env) : undefined + request.communityDid // Determine search mode let searchMode: 'fulltext' | 'hybrid' = 'fulltext' @@ -478,7 +477,7 @@ export function searchRoutes(): FastifyPluginCallback { } // Muted word annotation: flag matching content for client-side collapsing - const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined + const communityDid = request.communityDid const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) const annotatedResults = pageResults.map((r) => ({ diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 566f29e..715f370 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -33,9 +33,9 @@ export function setupRoutes(): FastifyPluginCallback { // GET /api/setup/status (public, no auth required) // ------------------------------------------------------------------- - app.get('/api/setup/status', async (_request, reply) => { + app.get('/api/setup/status', async (request, reply) => { try { - const status = await setupService.getStatus() + const status = await setupService.getStatus(request.communityDid ?? '') return await reply.status(200).send(status) } catch (err: unknown) { app.log.error({ err }, 'Failed to get setup status') @@ -65,6 +65,7 @@ export function setupRoutes(): FastifyPluginCallback { try { const result = await setupService.initialize({ + communityDid: request.communityDid ?? '', did: user.did, communityName: parsed.data.communityName, handle: parsed.data.handle, diff --git a/src/routes/topics.ts b/src/routes/topics.ts index 54fe14f..98faa13 100644 --- a/src/routes/topics.ts +++ b/src/routes/topics.ts @@ -1,6 +1,6 @@ import { eq, and, desc, sql, inArray, notInArray, isNotNull, ne, or } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' import { notFound, @@ -251,7 +251,7 @@ export function topicRoutes(): FastifyPluginCallback { const { title, content, category, tags, labels } = parsed.data const now = new Date().toISOString() - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Onboarding gate: block if user hasn't completed mandatory onboarding const onboarding = await checkOnboardingComplete(db, user.did, communityDid) @@ -280,7 +280,7 @@ export function topicRoutes(): FastifyPluginCallback { const settingsRows = await db .select({ ageThreshold: communitySettings.ageThreshold }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const ageThreshold = settingsRows[0]?.ageThreshold ?? 16 const maxMaturity = resolveMaxMaturity(userProfile, ageThreshold) @@ -538,12 +538,15 @@ export function topicRoutes(): FastifyPluginCallback { } } - // Fetch community age threshold - const settingsRowsList = await db - .select({ ageThreshold: communitySettings.ageThreshold }) - .from(communitySettings) - .where(eq(communitySettings.id, 'default')) - const listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16 + // Fetch community age threshold (use request.communityDid if available, else default) + let listAgeThreshold = 16 + if (request.communityDid) { + const settingsRowsList = await db + .select({ ageThreshold: communitySettings.ageThreshold }) + .from(communitySettings) + .where(eq(communitySettings.communityDid, request.communityDid)) + listAgeThreshold = settingsRowsList[0]?.ageThreshold ?? 16 + } const maxMaturity = resolveMaxMaturity(userProfile, listAgeThreshold) const allowed = allowedRatings(maxMaturity) @@ -551,9 +554,9 @@ export function topicRoutes(): FastifyPluginCallback { // Slug→maturityRating lookup, populated by the category queries below const categoryMaturityMap = new Map() - if (env.COMMUNITY_MODE === 'global') { + if (env.COMMUNITY_MODE === 'multi') { // --------------------------------------------------------------- - // Global mode: multi-community filtering + // Multi mode: multi-community filtering // --------------------------------------------------------------- // Get all community settings with a valid communityDid @@ -568,12 +571,13 @@ export function topicRoutes(): FastifyPluginCallback { // Filter: NEVER show adult communities in global mode, // check mature communities against user's max maturity preference const allowedCommunityDids = communityRows - .filter((c) => { - if (!c.communityDid) return false - if (c.maturityRating === 'adult') return false - return maturityAllows(maxMaturity, c.maturityRating) - }) - .map((c) => c.communityDid as string) + .filter( + (c): c is typeof c & { communityDid: string } => + !!c.communityDid && + c.maturityRating !== 'adult' && + maturityAllows(maxMaturity, c.maturityRating) + ) + .map((c) => c.communityDid) if (allowedCommunityDids.length === 0) { return reply.status(200).send({ topics: [], cursor: null }) @@ -622,7 +626,7 @@ export function topicRoutes(): FastifyPluginCallback { // Single mode: filter by the one configured community // --------------------------------------------------------------- - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Get category slugs matching allowed maturity levels const allowedCategories = await db @@ -710,13 +714,13 @@ export function topicRoutes(): FastifyPluginCallback { } // Load muted words for content filtering - const communityDid = env.COMMUNITY_MODE === 'single' ? env.COMMUNITY_DID : undefined + const communityDid = requireCommunityDid(request) const mutedWords = await loadMutedWords(request.user?.did, communityDid, db) // Batch-resolve author profiles const authorMap = await resolveAuthors( serialized.map((t) => t.authorDid), - communityDid ?? null, + communityDid, db ) @@ -786,7 +790,7 @@ export function topicRoutes(): FastifyPluginCallback { } // Look up the category maturity rating - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const catRows = await db .select({ maturityRating: categories.maturityRating }) .from(categories) @@ -806,7 +810,7 @@ export function topicRoutes(): FastifyPluginCallback { const rkeySettingsRows = await db .select({ ageThreshold: communitySettings.ageThreshold }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const rkeyAgeThreshold = rkeySettingsRows[0]?.ageThreshold ?? 16 const maxMaturity = resolveMaxMaturity(userProfile, rkeyAgeThreshold) @@ -855,7 +859,7 @@ export function topicRoutes(): FastifyPluginCallback { } // Maturity check: verify the topic's category is within the user's allowed level - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const catRows = await db .select({ maturityRating: categories.maturityRating }) .from(categories) @@ -882,7 +886,7 @@ export function topicRoutes(): FastifyPluginCallback { const singleSettingsRows = await db .select({ ageThreshold: communitySettings.ageThreshold }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const singleAgeThreshold = singleSettingsRows[0]?.ageThreshold ?? 16 const maxMaturity = resolveMaxMaturity(userProfile, singleAgeThreshold) diff --git a/src/routes/votes.ts b/src/routes/votes.ts index dac0cff..2202a36 100644 --- a/src/routes/votes.ts +++ b/src/routes/votes.ts @@ -1,6 +1,6 @@ import { eq, and, sql } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' import type { FastifyPluginCallback } from 'fastify' -import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' import { notFound, @@ -41,7 +41,7 @@ const ALLOWED_DIRECTIONS = ['up'] */ export function voteRoutes(): FastifyPluginCallback { return (app, _opts, done) => { - const { db, env, authMiddleware, firehose } = app + const { db, authMiddleware, firehose } = app const pdsClient = createPdsClient(app.oauthClient, app.log) // ------------------------------------------------------------------- @@ -99,7 +99,7 @@ export function voteRoutes(): FastifyPluginCallback { } const { subjectUri, subjectCid, direction } = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Validate direction if (!ALLOWED_DIRECTIONS.includes(direction)) { @@ -266,7 +266,7 @@ export function voteRoutes(): FastifyPluginCallback { const { uri } = request.params as { uri: string } const decodedUri = decodeURIComponent(uri) - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) // Fetch existing vote (scoped to this community) const existing = await db @@ -375,7 +375,7 @@ export function voteRoutes(): FastifyPluginCallback { } const { subjectUri, did } = parsed.data - const communityDid = getCommunityDid(env) + const communityDid = requireCommunityDid(request) const rows = await db .select({ diff --git a/src/setup/service.ts b/src/setup/service.ts index c9fdd7c..d990794 100644 --- a/src/setup/service.ts +++ b/src/setup/service.ts @@ -14,6 +14,8 @@ export type SetupStatus = { initialized: false } | { initialized: true; communit /** Parameters for community initialization. */ export interface InitializeParams { + /** Community DID (primary key for the settings row) */ + communityDid: string /** DID of the authenticated user who becomes admin */ did: string /** Optional community name override */ @@ -36,7 +38,7 @@ export type InitializeResult = /** Setup service interface for dependency injection and testing. */ export interface SetupService { - getStatus(): Promise + getStatus(communityDid: string): Promise initialize(params: InitializeParams): Promise } @@ -72,9 +74,10 @@ export function createSetupService( /** * Check whether the community has been initialized. * + * @param communityDid - The community DID to check status for * @returns SetupStatus indicating initialization state */ - async function getStatus(): Promise { + async function getStatus(communityDid: string): Promise { try { const rows = await db .select({ @@ -82,7 +85,7 @@ export function createSetupService( communityName: communitySettings.communityName, }) .from(communitySettings) - .where(eq(communitySettings.id, 'default')) + .where(eq(communitySettings.communityDid, communityDid)) const row = rows[0] @@ -112,11 +115,11 @@ export function createSetupService( * @returns InitializeResult with the new state or conflict indicator */ async function initialize(params: InitializeParams): Promise { - const { did, communityName, handle, serviceEndpoint } = params + const { communityDid, did, communityName, handle, serviceEndpoint } = params try { // Generate PLC DID if handle and serviceEndpoint are provided - let communityDid: string | undefined + let plcDid: string | undefined let signingKeyHex: string | undefined let rotationKeyHex: string | undefined @@ -128,11 +131,11 @@ export function createSetupService( serviceEndpoint, }) - communityDid = didResult.did + plcDid = didResult.did signingKeyHex = encrypt(didResult.signingKey, encryptionKey) rotationKeyHex = encrypt(didResult.rotationKey, encryptionKey) - logger.info({ communityDid, handle }, 'PLC DID generated successfully') + logger.info({ plcDid, handle }, 'PLC DID generated successfully') } else if (handle && serviceEndpoint && !plcDidService) { logger.warn( { handle, serviceEndpoint }, @@ -145,23 +148,21 @@ export function createSetupService( const rows = await db .insert(communitySettings) .values({ - id: 'default', + communityDid, initialized: true, adminDid: did, communityName: communityName ?? DEFAULT_COMMUNITY_NAME, - communityDid: communityDid ?? null, handle: handle ?? null, serviceEndpoint: serviceEndpoint ?? null, signingKey: signingKeyHex ?? null, rotationKey: rotationKeyHex ?? null, }) .onConflictDoUpdate({ - target: communitySettings.id, + target: communitySettings.communityDid, set: { initialized: true, adminDid: did, communityName: communityName ? communityName : sql`${communitySettings.communityName}`, - communityDid: communityDid ?? sql`${communitySettings.communityDid}`, handle: handle ?? sql`${communitySettings.handle}`, serviceEndpoint: serviceEndpoint ?? sql`${communitySettings.serviceEndpoint}`, signingKey: signingKeyHex ?? sql`${communitySettings.signingKey}`, @@ -190,8 +191,8 @@ export function createSetupService( communityName: finalName, } - if (row.communityDid) { - result.communityDid = row.communityDid + if (plcDid) { + result.communityDid = plcDid } return result diff --git a/src/types/multi-tenant.d.ts b/src/types/multi-tenant.d.ts new file mode 100644 index 0000000..26b5ae0 --- /dev/null +++ b/src/types/multi-tenant.d.ts @@ -0,0 +1,11 @@ +/** + * Type declarations for @barazo/multi-tenant (private npm package). + * This package is only required when COMMUNITY_MODE=multi. + */ +declare module '@barazo/multi-tenant' { + import type { CommunityResolver } from '../middleware/community-resolver.js' + import type { Database } from '../db/index.js' + import type { Cache } from '../cache/index.js' + + export function createMultiResolver(db: Database, cache: Cache): CommunityResolver +} diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts new file mode 100644 index 0000000..5699134 --- /dev/null +++ b/tests/integration/tenant-isolation.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { GenericContainer, Wait, type StartedTestContainer } from 'testcontainers' +import { drizzle } from 'drizzle-orm/postgres-js' +import { sql } from 'drizzle-orm' +import postgres from 'postgres' +import * as schema from '../../src/db/schema/index.js' + +const COMMUNITY_A = 'did:plc:communityA' +const COMMUNITY_B = 'did:plc:communityB' + +/** Port for the test PostgreSQL instance (host networking). */ +const PG_PORT = 25432 + +/** + * Create the schema tables and RLS policies directly via SQL. + * Only creates the tables needed for tenant-isolation testing. + */ +async function pushSchema(client: ReturnType): Promise { + await client` + CREATE ROLE barazo_app LOGIN PASSWORD 'barazo_app' + ` + + // community_settings + await client` + CREATE TABLE community_settings ( + community_did TEXT PRIMARY KEY, + domains JSONB NOT NULL DEFAULT '[]', + initialized BOOLEAN NOT NULL DEFAULT false, + admin_did TEXT, + community_name TEXT NOT NULL DEFAULT 'Barazo Community', + maturity_rating TEXT NOT NULL DEFAULT 'safe', + reaction_set JSONB NOT NULL DEFAULT '["like"]', + moderation_thresholds JSONB NOT NULL DEFAULT '{}', + word_filter JSONB NOT NULL DEFAULT '[]', + jurisdiction_country TEXT, + age_threshold INTEGER NOT NULL DEFAULT 16, + require_login_for_mature BOOLEAN NOT NULL DEFAULT true, + community_description TEXT, + handle TEXT, + service_endpoint TEXT, + signing_key TEXT, + rotation_key TEXT, + community_logo_url TEXT, + primary_color TEXT, + accent_color TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + + // categories + await client` + CREATE TABLE categories ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + parent_id TEXT REFERENCES categories(id) ON DELETE SET NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + community_did TEXT NOT NULL, + maturity_rating TEXT NOT NULL DEFAULT 'safe', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + + // topics + await client` + CREATE TABLE topics ( + uri TEXT PRIMARY KEY, + rkey TEXT NOT NULL, + author_did TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + content_format TEXT, + category TEXT NOT NULL, + tags JSONB, + community_did TEXT NOT NULL, + cid TEXT NOT NULL, + labels JSONB, + reply_count INTEGER NOT NULL DEFAULT 0, + reaction_count INTEGER NOT NULL DEFAULT 0, + vote_count INTEGER NOT NULL DEFAULT 0, + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_locked BOOLEAN NOT NULL DEFAULT false, + is_pinned BOOLEAN NOT NULL DEFAULT false, + is_mod_deleted BOOLEAN NOT NULL DEFAULT false, + is_author_deleted BOOLEAN NOT NULL DEFAULT false, + moderation_status TEXT NOT NULL DEFAULT 'approved', + trust_status TEXT NOT NULL DEFAULT 'trusted' + ) + ` + + // replies + await client` + CREATE TABLE replies ( + uri TEXT PRIMARY KEY, + rkey TEXT NOT NULL, + author_did TEXT NOT NULL, + content TEXT NOT NULL, + content_format TEXT, + root_uri TEXT NOT NULL, + root_cid TEXT NOT NULL, + parent_uri TEXT NOT NULL, + parent_cid TEXT NOT NULL, + community_did TEXT NOT NULL, + cid TEXT NOT NULL, + labels JSONB, + reaction_count INTEGER NOT NULL DEFAULT 0, + vote_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_author_deleted BOOLEAN NOT NULL DEFAULT false, + is_mod_deleted BOOLEAN NOT NULL DEFAULT false, + moderation_status TEXT NOT NULL DEFAULT 'approved', + trust_status TEXT NOT NULL DEFAULT 'trusted' + ) + ` + + // Enable RLS and create policies on all tables + const tables = ['community_settings', 'categories', 'topics', 'replies'] + for (const table of tables) { + await client.unsafe(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`) + await client.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`) + await client.unsafe(` + CREATE POLICY tenant_isolation ON ${table} + AS PERMISSIVE + FOR ALL + TO barazo_app + USING (community_did = current_setting('app.current_community_did', true)) + WITH CHECK (community_did = current_setting('app.current_community_did', true)) + `) + } + + // Grant permissions to app role + await client`GRANT ALL ON ALL TABLES IN SCHEMA public TO barazo_app` + await client`GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO barazo_app` +} + +describe('tenant isolation (RLS)', () => { + let container: StartedTestContainer | undefined + let superClient: ReturnType | undefined + let superDb: ReturnType + let appClient: ReturnType | undefined + let appDb: ReturnType + + beforeAll(async () => { + // 1. Start PostgreSQL with host networking + container = await new GenericContainer('postgres:16-alpine') + .withEnvironment({ + POSTGRES_USER: 'test', + POSTGRES_PASSWORD: 'test', + POSTGRES_DB: 'test', + PGPORT: String(PG_PORT), + }) + .withNetworkMode('host') + .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2)) + .start() + + const superUri = `postgresql://test:test@127.0.0.1:${String(PG_PORT)}/test` + + // 2. Create schema and RLS policies + const setupClient = postgres(superUri, { max: 1, connect_timeout: 10 }) + await pushSchema(setupClient) + await setupClient.end() + + // 3. Superuser connection (table owner, bypasses RLS) + superClient = postgres(superUri, { max: 5, connect_timeout: 10 }) + superDb = drizzle(superClient, { schema }) + + // 4. App-role connection (subject to RLS) + const appUri = `postgresql://barazo_app:barazo_app@127.0.0.1:${String(PG_PORT)}/test` + appClient = postgres(appUri, { max: 5, connect_timeout: 10 }) + appDb = drizzle(appClient, { schema }) + + // 5. Seed two communities using superuser (bypasses RLS) + await superDb.insert(schema.communitySettings).values([ + { communityDid: COMMUNITY_A, communityName: 'Community A', domains: ['a.example.com'] }, + { communityDid: COMMUNITY_B, communityName: 'Community B', domains: ['b.example.com'] }, + ]) + + // 6. Seed categories + await superDb.insert(schema.categories).values([ + { id: 'cat-a-1', slug: 'general', name: 'General', communityDid: COMMUNITY_A }, + { id: 'cat-b-1', slug: 'general', name: 'General', communityDid: COMMUNITY_B }, + ]) + + // 7. Seed topics + const now = new Date() + await superDb.insert(schema.topics).values([ + { + uri: 'at://did:plc:user1/forum.barazo.topic/aaa', + rkey: 'aaa', + authorDid: 'did:plc:user1', + title: 'Topic in A', + content: 'Content for community A', + category: 'general', + communityDid: COMMUNITY_A, + cid: 'cid-aaa', + createdAt: now, + }, + { + uri: 'at://did:plc:user2/forum.barazo.topic/bbb', + rkey: 'bbb', + authorDid: 'did:plc:user2', + title: 'Topic in B', + content: 'Content for community B', + category: 'general', + communityDid: COMMUNITY_B, + cid: 'cid-bbb', + createdAt: now, + }, + ]) + + // 8. Seed replies + await superDb.insert(schema.replies).values([ + { + uri: 'at://did:plc:user1/forum.barazo.reply/r-aaa', + rkey: 'r-aaa', + authorDid: 'did:plc:user1', + content: 'Reply in A', + rootUri: 'at://did:plc:user1/forum.barazo.topic/aaa', + rootCid: 'cid-aaa', + parentUri: 'at://did:plc:user1/forum.barazo.topic/aaa', + parentCid: 'cid-aaa', + communityDid: COMMUNITY_A, + cid: 'cid-r-aaa', + createdAt: now, + }, + { + uri: 'at://did:plc:user2/forum.barazo.reply/r-bbb', + rkey: 'r-bbb', + authorDid: 'did:plc:user2', + content: 'Reply in B', + rootUri: 'at://did:plc:user2/forum.barazo.topic/bbb', + rootCid: 'cid-bbb', + parentUri: 'at://did:plc:user2/forum.barazo.topic/bbb', + parentCid: 'cid-bbb', + communityDid: COMMUNITY_B, + cid: 'cid-r-bbb', + createdAt: now, + }, + ]) + }, 120_000) + + afterAll(async () => { + await appClient?.end() + await superClient?.end() + await container?.stop() + }) + + /** Run a callback within a transaction scoped to a community DID. */ + async function withCommunity( + communityDid: string, + fn: (db: typeof appDb) => Promise + ): Promise { + return await appDb.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('app.current_community_did', ${communityDid}, true)`) + return await fn(tx as unknown as typeof appDb) + }) + } + + /** + * Run a callback as superuser with no RLS filtering (aggregator mode). + * In production, aggregator queries use a service role that bypasses RLS. + */ + async function withoutCommunity(fn: (db: typeof superDb) => Promise): Promise { + return await fn(superDb) + } + + describe('SELECT isolation', () => { + it('community A session sees only community A topics', async () => { + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.topics)) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_A) + expect(rows[0].title).toBe('Topic in A') + }) + + it('community B session sees only community B topics', async () => { + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.topics)) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_B) + expect(rows[0].title).toBe('Topic in B') + }) + + it('community A session sees only community A replies', async () => { + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.replies)) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_A) + }) + + it('community B session sees only community B replies', async () => { + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.replies)) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_B) + }) + + it('community A session sees only community A categories', async () => { + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.categories)) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_A) + }) + + it('community A session sees only community A settings', async () => { + const rows = await withCommunity(COMMUNITY_A, (db) => + db.select().from(schema.communitySettings) + ) + + expect(rows).toHaveLength(1) + expect(rows[0].communityDid).toBe(COMMUNITY_A) + expect(rows[0].communityName).toBe('Community A') + }) + }) + + describe('aggregator mode (empty session variable)', () => { + it('sees topics from all communities', async () => { + const rows = await withoutCommunity((db) => db.select().from(schema.topics)) + + expect(rows).toHaveLength(2) + const dids = rows.map((r) => r.communityDid).sort() + expect(dids).toEqual([COMMUNITY_A, COMMUNITY_B]) + }) + + it('sees replies from all communities', async () => { + const rows = await withoutCommunity((db) => db.select().from(schema.replies)) + + expect(rows).toHaveLength(2) + const dids = rows.map((r) => r.communityDid).sort() + expect(dids).toEqual([COMMUNITY_A, COMMUNITY_B]) + }) + + it('sees categories from all communities', async () => { + const rows = await withoutCommunity((db) => db.select().from(schema.categories)) + + expect(rows).toHaveLength(2) + }) + + it('sees community settings from all communities', async () => { + const rows = await withoutCommunity((db) => db.select().from(schema.communitySettings)) + + expect(rows).toHaveLength(2) + }) + }) + + describe('INSERT isolation (withCheck)', () => { + it('allows INSERT when communityDid matches session variable', async () => { + await withCommunity(COMMUNITY_A, async (db) => { + await db.insert(schema.categories).values({ + id: 'cat-a-2', + slug: 'announcements', + name: 'Announcements', + communityDid: COMMUNITY_A, + }) + }) + + // Verify it was inserted + const rows = await withCommunity(COMMUNITY_A, (db) => db.select().from(schema.categories)) + expect(rows.some((r) => r.slug === 'announcements')).toBe(true) + }) + + it('blocks INSERT when communityDid does not match session variable', async () => { + try { + await withCommunity(COMMUNITY_A, (db) => + db.insert(schema.categories).values({ + id: 'cat-x-1', + slug: 'sneaky', + name: 'Cross-tenant insert', + communityDid: COMMUNITY_B, // Mismatch! + }) + ) + expect.unreachable('INSERT should have been blocked by RLS') + } catch (err) { + // Drizzle wraps the PostgreSQL error; the RLS message is in the cause + const message = String(err instanceof Error ? (err.cause ?? err).message : err) + expect(message).toMatch(/row-level security/) + } + }) + + it('blocks INSERT of topic with mismatched communityDid', async () => { + try { + await withCommunity(COMMUNITY_B, (db) => + db.insert(schema.topics).values({ + uri: 'at://did:plc:attacker/forum.barazo.topic/evil', + rkey: 'evil', + authorDid: 'did:plc:attacker', + title: 'Cross-tenant topic', + content: 'Should be blocked', + category: 'general', + communityDid: COMMUNITY_A, // Mismatch! + cid: 'cid-evil', + createdAt: new Date(), + }) + ) + expect.unreachable('INSERT should have been blocked by RLS') + } catch (err) { + const message = String(err instanceof Error ? (err.cause ?? err).message : err) + expect(message).toMatch(/row-level security/) + } + }) + }) + + describe('UPDATE isolation', () => { + it('cannot update rows belonging to another community', async () => { + // Try to update community B's topic while set as community A + await withCommunity(COMMUNITY_A, async (db) => { + const result = await db + .update(schema.topics) + .set({ title: 'Hijacked!' }) + .where(sql`uri = 'at://did:plc:user2/forum.barazo.topic/bbb'`) + + // RLS silently filters the WHERE clause, so 0 rows affected + expect(result.length).toBe(0) + }) + + // Verify community B's topic is unchanged + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.topics)) + expect(rows[0].title).toBe('Topic in B') + }) + }) + + describe('DELETE isolation', () => { + it('cannot delete rows belonging to another community', async () => { + // Try to delete community B's reply while set as community A + await withCommunity(COMMUNITY_A, async (db) => { + const result = await db + .delete(schema.replies) + .where(sql`uri = 'at://did:plc:user2/forum.barazo.reply/r-bbb'`) + + // RLS silently filters, 0 rows deleted + expect(result.length).toBe(0) + }) + + // Verify community B's reply still exists + const rows = await withCommunity(COMMUNITY_B, (db) => db.select().from(schema.replies)) + expect(rows).toHaveLength(1) + }) + }) +}) diff --git a/tests/unit/auth/require-operator.test.ts b/tests/unit/auth/require-operator.test.ts index d3565ac..7909eb7 100644 --- a/tests/unit/auth/require-operator.test.ts +++ b/tests/unit/auth/require-operator.test.ts @@ -48,7 +48,7 @@ function createMockEnv( overrides: Partial> = {} ): Pick { return { - COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'global', + COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'multi', OPERATOR_DIDS: overrides.OPERATOR_DIDS ?? ['did:plc:operator123'], } } @@ -132,7 +132,7 @@ describe('requireOperator middleware', () => { // ------------------------------------------------------------------------- it('returns 401 when requireAuth rejects (no token)', async () => { - await buildApp({ COMMUNITY_MODE: 'global' }) + await buildApp({ COMMUNITY_MODE: 'multi' }) vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, reply) => { await reply.status(401).send({ error: 'Authentication required' }) @@ -155,7 +155,7 @@ describe('requireOperator middleware', () => { it('returns 403 if user DID is not in OPERATOR_DIDS', async () => { await buildApp({ - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: ['did:plc:operator123'], }) @@ -175,7 +175,7 @@ describe('requireOperator middleware', () => { }) it('returns 403 when requireAuth passes but request.user is not set', async () => { - await buildApp({ COMMUNITY_MODE: 'global' }) + await buildApp({ COMMUNITY_MODE: 'multi' }) // requireAuth passes without setting request.user vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { @@ -197,9 +197,9 @@ describe('requireOperator middleware', () => { // Success path // ------------------------------------------------------------------------- - it("grants access if user DID is in OPERATOR_DIDS and mode is 'global'", async () => { + it("grants access if user DID is in OPERATOR_DIDS and mode is 'multi'", async () => { await buildApp({ - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: ['did:plc:operator123'], }) @@ -219,7 +219,7 @@ describe('requireOperator middleware', () => { it('grants access when OPERATOR_DIDS contains multiple DIDs', async () => { await buildApp({ - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: ['did:plc:other999', 'did:plc:operator123', 'did:plc:another888'], }) @@ -243,7 +243,7 @@ describe('requireOperator middleware', () => { it('logs audit trail when operator access is denied (DID not in list)', async () => { await buildApp({ - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: ['did:plc:operator123'], }) @@ -263,7 +263,7 @@ describe('requireOperator middleware', () => { }) it('logs audit trail when operator access is denied (no user after auth)', async () => { - await buildApp({ COMMUNITY_MODE: 'global' }) + await buildApp({ COMMUNITY_MODE: 'multi' }) vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { // intentionally do not set request.user @@ -282,7 +282,7 @@ describe('requireOperator middleware', () => { it('logs audit trail when operator access is granted', async () => { await buildApp({ - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: ['did:plc:operator123'], }) diff --git a/tests/unit/config/env.test.ts b/tests/unit/config/env.test.ts index 050af93..ff90f76 100644 --- a/tests/unit/config/env.test.ts +++ b/tests/unit/config/env.test.ts @@ -130,14 +130,14 @@ describe('envSchema', () => { expect(result.success).toBe(false) }) - it('accepts global COMMUNITY_MODE', () => { + it('accepts multi COMMUNITY_MODE', () => { const result = envSchema.safeParse({ ...validEnv, - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', }) expect(result.success).toBe(true) if (result.success) { - expect(result.data.COMMUNITY_MODE).toBe('global') + expect(result.data.COMMUNITY_MODE).toBe('multi') } }) @@ -240,10 +240,10 @@ describe('COMMUNITY_DID validation', () => { expect(result.success).toBe(true) }) - it('accepts global mode without COMMUNITY_DID', () => { + it('accepts multi mode without COMMUNITY_DID', () => { const result = envSchema.safeParse({ ...baseEnv, - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', }) expect(result.success).toBe(true) }) diff --git a/tests/unit/db/schema/community-settings.test.ts b/tests/unit/db/schema/community-settings.test.ts index e94b021..2e783a0 100644 --- a/tests/unit/db/schema/community-settings.test.ts +++ b/tests/unit/db/schema/community-settings.test.ts @@ -9,17 +9,17 @@ describe('communitySettings schema', () => { expect(getTableName(communitySettings)).toBe('community_settings') }) - it('uses id as primary key', () => { - expect(columns.id.primary).toBe(true) + it('uses communityDid as primary key', () => { + expect(columns.communityDid.primary).toBe(true) }) it('has all required columns', () => { const columnNames = Object.keys(columns) const expected = [ - 'id', - 'initialized', 'communityDid', + 'domains', + 'initialized', 'adminDid', 'communityName', 'maturityRating', @@ -37,16 +37,22 @@ describe('communitySettings schema', () => { } }) - it('has default value for id', () => { - expect(columns.id.hasDefault).toBe(true) + it('does not have an id column', () => { + const columnNames = Object.keys(columns) + expect(columnNames).not.toContain('id') + }) + + it('has domains column with default empty array', () => { + expect(columns.domains.notNull).toBe(true) + expect(columns.domains.hasDefault).toBe(true) }) it('has default value for initialized (false)', () => { expect(columns.initialized.hasDefault).toBe(true) }) - it('has nullable communityDid', () => { - expect(columns.communityDid.notNull).toBe(false) + it('has notNull communityDid', () => { + expect(columns.communityDid.notNull).toBe(true) }) it('has nullable adminDid', () => { @@ -79,7 +85,7 @@ describe('communitySettings schema', () => { }) it('has non-nullable required columns', () => { - expect(columns.id.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) expect(columns.initialized.notNull).toBe(true) expect(columns.communityName.notNull).toBe(true) expect(columns.maturityRating.notNull).toBe(true) diff --git a/tests/unit/middleware/community-resolver.test.ts b/tests/unit/middleware/community-resolver.test.ts new file mode 100644 index 0000000..1dc5c61 --- /dev/null +++ b/tests/unit/middleware/community-resolver.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, afterEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { + createSingleResolver, + registerCommunityResolver, +} from '../../../src/middleware/community-resolver.js' +import type { CommunityResolver } from '../../../src/middleware/community-resolver.js' + +describe('CommunityResolver', () => { + describe('createSingleResolver', () => { + it('returns the configured DID for any hostname', async () => { + const resolver = createSingleResolver('did:plc:test123') + expect(await resolver.resolve('anything.example.com')).toBe('did:plc:test123') + }) + + it('returns the same DID regardless of hostname', async () => { + const resolver = createSingleResolver('did:plc:mycommunity') + expect(await resolver.resolve('foo.bar.com')).toBe('did:plc:mycommunity') + expect(await resolver.resolve('localhost')).toBe('did:plc:mycommunity') + expect(await resolver.resolve('')).toBe('did:plc:mycommunity') + }) + }) + + describe('Fastify integration', () => { + let app: FastifyInstance + + afterEach(async () => { + await app.close() + }) + + it('sets request.communityDid in single mode', async () => { + const resolver = createSingleResolver('did:plc:singlecommunity') + + app = Fastify({ logger: false }) + registerCommunityResolver(app, resolver, 'single') + + app.get('/test', (request) => { + return { communityDid: request.communityDid } + }) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/test' }) + expect(response.statusCode).toBe(200) + expect(response.json<{ communityDid: string }>().communityDid).toBe('did:plc:singlecommunity') + }) + + it('returns 404 in single mode when resolver returns undefined', async () => { + // Construct a resolver that returns undefined (shouldn't happen in single mode, but safety net) + const resolver: CommunityResolver = { resolve: () => Promise.resolve(undefined) } + + app = Fastify({ logger: false }) + registerCommunityResolver(app, resolver, 'single') + + app.get('/test', (request) => { + return { communityDid: request.communityDid } + }) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/test' }) + expect(response.statusCode).toBe(404) + expect(response.json<{ error: string }>().error).toBe('Community not found') + }) + + it('allows undefined communityDid in multi mode (aggregator)', async () => { + const resolver: CommunityResolver = { resolve: () => Promise.resolve(undefined) } + + app = Fastify({ logger: false }) + registerCommunityResolver(app, resolver, 'multi') + + app.get('/test', (request) => { + return { communityDid: request.communityDid ?? null } + }) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/test' }) + expect(response.statusCode).toBe(200) + expect(response.json<{ communityDid: null }>().communityDid).toBeNull() + }) + }) +}) diff --git a/tests/unit/routes/admin-settings.test.ts b/tests/unit/routes/admin-settings.test.ts index 24bd9df..13529c3 100644 --- a/tests/unit/routes/admin-settings.test.ts +++ b/tests/unit/routes/admin-settings.test.ts @@ -120,7 +120,6 @@ function createMockRequireAdmin(user?: RequestUser) { function sampleCommunitySettings(overrides?: Record) { return { - id: 'default', initialized: true, communityDid: 'did:plc:community123', adminDid: ADMIN_DID, @@ -178,6 +177,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(adminSettingsRoutes()) await app.ready() @@ -222,12 +226,10 @@ describe('admin settings routes', () => { expect(response.statusCode).toBe(200) const body = response.json<{ - id: string communityName: string maturityRating: string initialized: boolean }>() - expect(body.id).toBe('default') expect(body.communityName).toBe('Test Community') expect(body.maturityRating).toBe('safe') expect(body.initialized).toBe(true) diff --git a/tests/unit/routes/categories.test.ts b/tests/unit/routes/categories.test.ts index ab7694e..0d59c4f 100644 --- a/tests/unit/routes/categories.test.ts +++ b/tests/unit/routes/categories.test.ts @@ -152,7 +152,6 @@ function sampleCategoryRow(overrides?: Record) { function sampleCommunitySettings(overrides?: Record) { return { - id: 'default', initialized: true, communityDid: 'did:plc:community123', adminDid: ADMIN_DID, @@ -184,6 +183,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(categoryRoutes()) await app.ready() diff --git a/tests/unit/routes/global-filters.test.ts b/tests/unit/routes/global-filters.test.ts index 18c4964..f008af8 100644 --- a/tests/unit/routes/global-filters.test.ts +++ b/tests/unit/routes/global-filters.test.ts @@ -57,7 +57,7 @@ function nonOperatorUser(overrides?: Partial): RequestUser { // --------------------------------------------------------------------------- const globalMockEnv = { - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', OPERATOR_DIDS: [OPERATOR_DID], RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, diff --git a/tests/unit/routes/health.test.ts b/tests/unit/routes/health.test.ts index ad252c7..cc66c56 100644 --- a/tests/unit/routes/health.test.ts +++ b/tests/unit/routes/health.test.ts @@ -2,6 +2,36 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import { buildApp } from '../../../src/app.js' import type { FastifyInstance } from 'fastify' +// Mock database to avoid real PostgreSQL connection +const mockExecute = vi.fn().mockResolvedValue([{ '?column?': 1 }]) +const mockDb = { + execute: mockExecute, + select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + query: {}, +} +const mockClient = { + end: vi.fn().mockResolvedValue(undefined), +} +vi.mock('../../../src/db/index.js', () => ({ + createDb: () => ({ db: mockDb, client: mockClient }), +})) + +// Mock cache to avoid real Valkey connection +const mockCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(undefined), + ping: vi.fn().mockResolvedValue('PONG'), + quit: vi.fn().mockResolvedValue(undefined), +} +vi.mock('../../../src/cache/index.js', () => ({ + createCache: () => mockCache, +})) + // Mock @atproto/oauth-client-node to avoid crypto operations vi.mock('@atproto/oauth-client-node', () => { return { diff --git a/tests/unit/routes/maturity-filtering.test.ts b/tests/unit/routes/maturity-filtering.test.ts index 58ea1d3..897277a 100644 --- a/tests/unit/routes/maturity-filtering.test.ts +++ b/tests/unit/routes/maturity-filtering.test.ts @@ -157,6 +157,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(topicRoutes()) await app.register(replyRoutes()) diff --git a/tests/unit/routes/moderation-appeals.test.ts b/tests/unit/routes/moderation-appeals.test.ts index 39a3ec2..1cfe371 100644 --- a/tests/unit/routes/moderation-appeals.test.ts +++ b/tests/unit/routes/moderation-appeals.test.ts @@ -188,6 +188,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(moderationRoutes()) await app.ready() diff --git a/tests/unit/routes/moderation-queue.test.ts b/tests/unit/routes/moderation-queue.test.ts index bc418db..076c167 100644 --- a/tests/unit/routes/moderation-queue.test.ts +++ b/tests/unit/routes/moderation-queue.test.ts @@ -128,6 +128,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('sessionService', {} as SessionService) app.decorate('setupService', {} as SetupService) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) mockRequireModerator.mockImplementation((request: { user: RequestUser | undefined }) => { if (user) { diff --git a/tests/unit/routes/moderation.test.ts b/tests/unit/routes/moderation.test.ts index c6fc39b..049c099 100644 --- a/tests/unit/routes/moderation.test.ts +++ b/tests/unit/routes/moderation.test.ts @@ -275,6 +275,11 @@ async function buildTestApp( app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(moderationRoutes()) await app.ready() @@ -311,6 +316,11 @@ async function buildPassthroughAuthApp(): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(moderationRoutes()) await app.ready() @@ -1657,10 +1667,10 @@ describe('moderation routes', () => { let globalApp: FastifyInstance beforeAll(async () => { - // Build a special app with COMMUNITY_MODE='global' + // Build a special app with COMMUNITY_MODE='multi' const globalEnv = { ...mockEnv, - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', } as Env const app = Fastify({ logger: false }) @@ -1679,6 +1689,11 @@ describe('moderation routes', () => { del: vi.fn().mockResolvedValue(undefined), } as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(moderationRoutes()) await app.ready() @@ -1915,7 +1930,7 @@ describe('moderation routes', () => { beforeAll(async () => { const globalEnv = { ...mockEnv, - COMMUNITY_MODE: 'global', + COMMUNITY_MODE: 'multi', } as Env const app = Fastify({ logger: false }) @@ -1931,6 +1946,11 @@ describe('moderation routes', () => { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(moderationRoutes()) await app.ready() diff --git a/tests/unit/routes/onboarding.test.ts b/tests/unit/routes/onboarding.test.ts index 716771c..58a3a71 100644 --- a/tests/unit/routes/onboarding.test.ts +++ b/tests/unit/routes/onboarding.test.ts @@ -167,6 +167,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(onboardingRoutes()) await app.ready() diff --git a/tests/unit/routes/reactions.test.ts b/tests/unit/routes/reactions.test.ts index df78e4d..ef5037b 100644 --- a/tests/unit/routes/reactions.test.ts +++ b/tests/unit/routes/reactions.test.ts @@ -185,6 +185,11 @@ async function buildTestApp(user?: RequestUser): Promise { recordCoParticipation: vi.fn().mockResolvedValue(undefined), } as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(reactionRoutes()) await app.ready() diff --git a/tests/unit/routes/replies.test.ts b/tests/unit/routes/replies.test.ts index 42ecaca..ba8a635 100644 --- a/tests/unit/routes/replies.test.ts +++ b/tests/unit/routes/replies.test.ts @@ -302,6 +302,11 @@ async function buildTestApp( app.decorate('ozoneService', ozoneService as never) } app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(replyRoutes()) await app.ready() diff --git a/tests/unit/routes/search.test.ts b/tests/unit/routes/search.test.ts index b808fb0..99a327e 100644 --- a/tests/unit/routes/search.test.ts +++ b/tests/unit/routes/search.test.ts @@ -94,6 +94,11 @@ async function buildTestApp(): Promise { const app = Fastify({ logger: false }) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) app.decorate('db', mockDb as never) app.decorate('env', { EMBEDDING_URL: undefined, @@ -1368,6 +1373,11 @@ describe('search routes', () => { const authApp = Fastify({ logger: false }) authApp.decorateRequest('user', undefined as RequestUser | undefined) + authApp.decorateRequest('communityDid', undefined as string | undefined) + authApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) authApp.decorate('db', mockDb as never) authApp.decorate('env', { EMBEDDING_URL: undefined, @@ -1393,10 +1403,10 @@ describe('search routes', () => { }) expect(response.statusCode).toBe(200) - // loadMutedWords should be called with the authenticated user's DID + // loadMutedWords should be called with the authenticated user's DID and community DID expect(mockLoadMutedWords).toHaveBeenCalledWith( 'did:plc:autheduser', - TEST_COMMUNITY_DID, + 'did:plc:test', expect.anything() ) diff --git a/tests/unit/routes/setup.test.ts b/tests/unit/routes/setup.test.ts index 995cc2d..70f26ce 100644 --- a/tests/unit/routes/setup.test.ts +++ b/tests/unit/routes/setup.test.ts @@ -86,6 +86,11 @@ describe('setup routes', () => { // Fastify requires decoration before hooks can set properties app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) // Register setup routes await app.register(setupRoutes()) @@ -204,6 +209,7 @@ describe('setup routes', () => { communityName: 'Barazo Community', }) expect(initializeFn).toHaveBeenCalledWith({ + communityDid: 'did:plc:test', did: TEST_DID, communityName: undefined, handle: undefined, @@ -253,6 +259,7 @@ describe('setup routes', () => { communityName: 'Custom Forum Name', }) expect(initializeFn).toHaveBeenCalledWith({ + communityDid: 'did:plc:test', did: TEST_DID, communityName: 'Custom Forum Name', handle: undefined, @@ -336,6 +343,7 @@ describe('setup routes', () => { expect(response.statusCode).toBe(200) expect(initializeFn).toHaveBeenCalledWith({ + communityDid: 'did:plc:test', did: TEST_DID, communityName: 'My Forum', handle: 'forum.example.com', diff --git a/tests/unit/routes/topics-replies-integration.test.ts b/tests/unit/routes/topics-replies-integration.test.ts index 1de13b3..109f5fd 100644 --- a/tests/unit/routes/topics-replies-integration.test.ts +++ b/tests/unit/routes/topics-replies-integration.test.ts @@ -247,6 +247,11 @@ async function buildTestApp(user?: RequestUser): Promise { recordCoParticipation: vi.fn().mockResolvedValue(undefined), } as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) // Register BOTH route sets so we can test cross-endpoint behavior await app.register(topicRoutes()) diff --git a/tests/unit/routes/topics.test.ts b/tests/unit/routes/topics.test.ts index c7dd2a4..819a393 100644 --- a/tests/unit/routes/topics.test.ts +++ b/tests/unit/routes/topics.test.ts @@ -243,6 +243,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(topicRoutes()) await app.ready() @@ -863,21 +868,23 @@ describe('topic routes', () => { // Topics query (terminal via .limit) selectChain.limit.mockResolvedValueOnce([sampleTopicRow({ authorDid: TEST_DID })]) - // After maturity mocks (3 .where calls consumed), 4 more .where calls follow: + // After maturity mocks (3 .where calls consumed), 5 more .where calls follow: // 4. loadBlockMuteLists .where (terminal) // 5. topics .where (chained to .orderBy().limit()) // 6. loadMutedWords global .where (terminal) - // 7. resolveAuthors users .where (terminal) - // We must explicitly mock calls 4-7 so that: + // 7. loadMutedWords community .where (terminal) + // 8. resolveAuthors users .where (terminal) + // We must explicitly mock calls 4-8 so that: // - Call 5 returns the chain (not a Promise) for .orderBy().limit() to work - // - Call 7 returns the author user row + // - Call 8 returns the author user row selectChain.where.mockResolvedValueOnce([]) // 4: loadBlockMuteLists selectChain.where.mockImplementationOnce(() => selectChain) // 5: topics .where selectChain.where.mockResolvedValueOnce([]) // 6: loadMutedWords global + selectChain.where.mockResolvedValueOnce([]) // 7: loadMutedWords community selectChain.where.mockResolvedValueOnce([ - // 7: resolveAuthors users + // 8: resolveAuthors users { did: TEST_DID, handle: TEST_HANDLE, @@ -1489,7 +1496,7 @@ describe('topic routes', () => { describe('GET /api/topics (global mode)', () => { const globalMockEnv = { ...mockEnv, - COMMUNITY_MODE: 'global' as const, + COMMUNITY_MODE: 'multi' as const, COMMUNITY_DID: undefined, } as Env @@ -1507,6 +1514,11 @@ describe('topic routes', () => { globalApp.decorate('setupService', {} as SetupService) globalApp.decorate('cache', {} as never) globalApp.decorateRequest('user', undefined as RequestUser | undefined) + globalApp.decorateRequest('communityDid', undefined as string | undefined) + globalApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await globalApp.register(topicRoutes()) await globalApp.ready() @@ -2092,6 +2104,11 @@ describe('topic routes', () => { crossPostApp.decorate('setupService', {} as SetupService) crossPostApp.decorate('cache', {} as never) crossPostApp.decorateRequest('user', undefined as RequestUser | undefined) + crossPostApp.decorateRequest('communityDid', undefined as string | undefined) + crossPostApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await crossPostApp.register(topicRoutes()) await crossPostApp.ready() @@ -2215,6 +2232,11 @@ describe('topic routes', () => { batchIsSpamLabeled: vi.fn().mockResolvedValue(new Map()), } as never) ozoneApp.decorateRequest('user', undefined as RequestUser | undefined) + ozoneApp.decorateRequest('communityDid', undefined as string | undefined) + ozoneApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await ozoneApp.register(topicRoutes()) await ozoneApp.ready() app = ozoneApp @@ -2340,6 +2362,11 @@ describe('topic routes', () => { batchIsSpamLabeled: batchIsSpamLabeledFn, } as never) ozoneApp.decorateRequest('user', undefined as RequestUser | undefined) + ozoneApp.decorateRequest('communityDid', undefined as string | undefined) + ozoneApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await ozoneApp.register(topicRoutes()) await ozoneApp.ready() app = ozoneApp @@ -3203,7 +3230,7 @@ describe('topic routes', () => { describe('GET /api/topics (global mode - additional branches)', () => { const globalMockEnv = { ...mockEnv, - COMMUNITY_MODE: 'global' as const, + COMMUNITY_MODE: 'multi' as const, COMMUNITY_DID: undefined, } as Env @@ -3219,6 +3246,11 @@ describe('topic routes', () => { globalApp.decorate('setupService', {} as SetupService) globalApp.decorate('cache', {} as never) globalApp.decorateRequest('user', undefined as RequestUser | undefined) + globalApp.decorateRequest('communityDid', undefined as string | undefined) + globalApp.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await globalApp.register(topicRoutes()) await globalApp.ready() diff --git a/tests/unit/routes/votes.test.ts b/tests/unit/routes/votes.test.ts index 55f7fd1..0fc2b7f 100644 --- a/tests/unit/routes/votes.test.ts +++ b/tests/unit/routes/votes.test.ts @@ -180,6 +180,11 @@ async function buildTestApp(user?: RequestUser): Promise { app.decorate('setupService', {} as SetupService) app.decorate('cache', {} as never) app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) await app.register(voteRoutes()) await app.ready()