Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/database/seed-marketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { seedDefaultTemplates } from '../features/events/server/seed-templates.s
import { EntranceKind } from '../features/territories/model/entrance-kind.type'
import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-kind.type'
import { TerritoryKind } from '../features/territories/model/territory-kind.type'
import { syncBuiltInRoleAssignments } from '../shared/domain/built-in-roles.server'
import { seedBuiltInRoles } from '../shared/domain/setup.server'
import { Permission } from '../shared/types/permission'
import { PublisherType } from '../shared/types/publisher-type'
import { stripDiacritics } from '../shared/utils/strip-diacritics'
Expand Down Expand Up @@ -625,6 +627,11 @@ async function main() {

console.log(` ✓ Congregation "${congregation.name}" (id=${congId})`)

// Ensure built-in identity roles (`member`, `publisher`, `brother`, …) exist
// for this congregation even when the marketing seed runs on a fresh DB
// without the regular seed having run first.
await seedBuiltInRoles(prisma, congId)

// ── Event templates ───────────────────────────────────────────────────
await seedDefaultTemplates(prisma, congId, 'fr')

Expand Down Expand Up @@ -676,6 +683,11 @@ async function main() {
},
})

// Populate MemberRoleAssignment rows from the member's flags. The regular
// aggregate does this via syncBuiltInRoleAssignments; the direct
// prisma.member.create above bypasses it, so call it explicitly here.
await syncBuiltInRoleAssignments(prisma, member.id, congId, null)

const account = await prisma.userAccount.upsert({
where: { email },
update: {},
Expand Down
17 changes: 17 additions & 0 deletions app/shared/domain/built-in-roles.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ describe('syncBuiltInRoleAssignments', () => {
)
})

it('scopes the built-in role lookup to the congregation', async () => {
// Guards against cross-tenant assignments when the caller bypasses RLS
// (e.g. seed scripts running as the DB owner).
const db = makeDb({
member: { ...BASE, isPublisher: true },
builtInRoles: [{ id: 14, key: 'member' }],
existingAssignments: [],
})

await syncBuiltInRoleAssignments(db as never, 42, 7, 99)

expect(db.role.findMany).toHaveBeenCalledWith({
where: { isBuiltIn: true, congregationId: 7 },
select: { id: true, key: true },
})
})

it('removes stale assignments and audits the diff', async () => {
const db = makeDb({
// Sister publisher only (no elder, since female cannot be elder)
Expand Down
6 changes: 5 additions & 1 deletion app/shared/domain/built-in-roles.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,12 @@ export async function syncBuiltInRoleAssignments(
})
if (!member) return

// Scope by congregationId explicitly. Under RLS-scoped callers this is a
// no-op (rows are already filtered), but callers that bypass RLS — e.g. the
// seed scripts running as the DB owner — would otherwise match every
// congregation's built-in roles and write cross-tenant assignments.
const builtInRoles = await db.role.findMany({
where: { isBuiltIn: true },
where: { isBuiltIn: true, congregationId },
select: { id: true, key: true },
})

Expand Down
14 changes: 14 additions & 0 deletions scripts/check-aggregate-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ describe('analyzeSource — rule 1: writes on aggregate models', () => {
}
})

it('flags prisma.member.create outside an aggregate/allowlist file', () => {
const source = 'const m = await prisma.member.create({ data: {} })'
const v = analyzeSource('app/features/publishers/server/thing.server.ts', source)
expect(v).toHaveLength(1)
expect(v[0].rule).toBe('write-outside-aggregate')
})

it('allows the write inside an app/database seed script', () => {
const source = 'const m = await prisma.member.create({ data: {} })'
for (const path of ['app/database/seed.ts', 'app/database/seed-marketing.ts']) {
expect(analyzeSource(path, source)).toHaveLength(0)
}
})

it('does not flag writes on non-aggregate models', () => {
const source = 'await db.role.update({ where, data })'
const v = analyzeSource('app/features/settings/server/roles.server.ts', source)
Expand Down
14 changes: 10 additions & 4 deletions scripts/check-aggregate-boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// CQRS-lite boundary check. Enforces two rules from
// docs/development/architecture-conventions.md#cqrs-lite:
//
// 1. Writes to aggregate-owned models (`db.member.*` / `db.attribution.*`
// 1. Writes to aggregate-owned models (`db.member.*` / `prisma.member.*` /
// `db.attribution.*` / `prisma.attribution.*` with
// create/update/updateMany/delete/deleteMany/upsert) are allowed only
// inside *.aggregate.ts files or on the allowlist (import-*.server.ts
// orchestrators, tests).
// orchestrators, tests, and app/database/** seed scripts).
//
// 2. UI-shaped reads inside *.aggregate.ts files (`findMany`, `count`,
// `aggregate`) are forbidden, unless the enclosing function is prefixed
Expand All @@ -27,13 +28,17 @@ const AGGREGATE_MODELS = ['member', 'attribution'] as const
const WRITE_METHODS = ['create', 'update', 'updateMany', 'delete', 'deleteMany', 'upsert'] as const
const UI_READ_METHODS = ['findMany', 'count', 'aggregate'] as const

const WRITE_RE = new RegExp(`\\bdb\\.(${AGGREGATE_MODELS.join('|')})\\.(${WRITE_METHODS.join('|')})\\b`)
const WRITE_RE = new RegExp(`\\b(?:db|prisma)\\.(${AGGREGATE_MODELS.join('|')})\\.(${WRITE_METHODS.join('|')})\\b`)
const READ_RE = new RegExp(`\\bdb\\.\\w+\\.(${UI_READ_METHODS.join('|')})\\b`)
const FUNCTION_DECL_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/
const ALLOW_COMMENT_RE = /\/\/\s*aggregate-boundaries-allow\b/
const TEST_FILE_RE = /\.(?:test|integration\.test|spec)\.tsx?$/
const TEST_INFRA_RE = /^app\/tests\//
const IMPORT_ORCHESTRATOR_RE = /\/import-[a-z-]+\.server\.ts$/
// Seed scripts run before any aggregate is wired (no CongregationInfo / services)
// and legitimately write via the raw client. This exemption used to live only
// in prose in docs/development/architecture-conventions.md — enforce it here.
const DB_SEED_RE = /^app\/database\//
const TS_EXTENSION_RE = /\.tsx?$/

function isCommentLine(line: string): boolean {
Expand Down Expand Up @@ -61,7 +66,8 @@ export function analyzeSource(relPath: string, source: string): Violation[] {
relPath.endsWith('.aggregate.ts') ||
IMPORT_ORCHESTRATOR_RE.test(relPath) ||
TEST_FILE_RE.test(relPath) ||
TEST_INFRA_RE.test(relPath)
TEST_INFRA_RE.test(relPath) ||
DB_SEED_RE.test(relPath)
const isAggregate = relPath.endsWith('.aggregate.ts')

let currentFunction = ''
Expand Down