WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

fix(appview): seedDefaultRoles fails fast on critical role failure (#69)

Mark all default roles as critical and throw on any failure during
startup seeding. Previously, per-role errors were caught and swallowed,
allowing the server to start without a Member role — causing every new
user login to create a permanently broken membership with no permissions.

Also scope the existing-role check to ctx.config.forumDid so that roles
from other DIDs in a shared database don't incorrectly satisfy the
idempotency check.

Adds seed-roles unit tests covering the new fail-fast behavior.

Closes ATB-38

authored by

Malpercio and committed by
GitHub
cfe76a95 c29907da

+154 -4
+141
apps/appview/src/lib/__tests__/seed-roles.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { seedDefaultRoles } from "../seed-roles.js"; 3 + import { createTestContext, type TestContext } from "./test-context.js"; 4 + import { roles } from "@atbb/db"; 5 + 6 + describe("seedDefaultRoles", () => { 7 + let ctx: TestContext; 8 + 9 + beforeEach(async () => { 10 + ctx = await createTestContext(); 11 + }); 12 + 13 + afterEach(async () => { 14 + await ctx.cleanup(); 15 + }); 16 + 17 + function mockAgent(opts: { 18 + failForRole?: string; 19 + alwaysFail?: boolean; 20 + } = {}) { 21 + let callIndex = 0; 22 + return { 23 + com: { 24 + atproto: { 25 + repo: { 26 + createRecord: vi.fn().mockImplementation(({ record }: { record: { name: string } }) => { 27 + callIndex++; 28 + if (opts.alwaysFail || record.name === opts.failForRole) { 29 + throw new Error(`PDS write failed for role "${record.name}"`); 30 + } 31 + return Promise.resolve({ 32 + data: { 33 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/tid${callIndex}`, 34 + cid: `bafynew${callIndex}`, 35 + }, 36 + }); 37 + }), 38 + }, 39 + }, 40 + }, 41 + } as any; 42 + } 43 + 44 + function withAgent(agent: ReturnType<typeof mockAgent>) { 45 + return { 46 + ...ctx, 47 + forumAgent: { 48 + getAgent: () => agent, 49 + } as any, 50 + }; 51 + } 52 + 53 + it("creates all roles when none exist", async () => { 54 + const agent = mockAgent(); 55 + const result = await seedDefaultRoles(withAgent(agent)); 56 + 57 + expect(result.created).toBe(4); 58 + expect(result.skipped).toBe(0); 59 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); 60 + }); 61 + 62 + it("skips roles that already exist in the database", async () => { 63 + // Pre-insert Owner role so it gets skipped 64 + await ctx.db.insert(roles).values({ 65 + did: ctx.config.forumDid, 66 + rkey: "owner-rkey", 67 + cid: "bafyexisting", 68 + name: "Owner", 69 + description: "Forum owner", 70 + priority: 0, 71 + createdAt: new Date(), 72 + indexedAt: new Date(), 73 + }); 74 + 75 + const agent = mockAgent(); 76 + const result = await seedDefaultRoles(withAgent(agent)); 77 + 78 + expect(result.skipped).toBe(1); 79 + expect(result.created).toBe(3); 80 + // Only 3 PDS writes — Owner was skipped 81 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(3); 82 + }); 83 + 84 + it("throws when ForumAgent is null", async () => { 85 + await expect(seedDefaultRoles({ ...ctx, forumAgent: null })).rejects.toThrow( 86 + "ForumAgent not available" 87 + ); 88 + }); 89 + 90 + it("throws when ForumAgent is not authenticated (getAgent returns null)", async () => { 91 + const unauthenticatedAgent = { getAgent: () => null } as any; 92 + await expect( 93 + seedDefaultRoles({ ...ctx, forumAgent: unauthenticatedAgent }) 94 + ).rejects.toThrow("ForumAgent not authenticated"); 95 + }); 96 + 97 + it("throws when the Member role (critical) fails to seed", async () => { 98 + const agent = mockAgent({ failForRole: "Member" }); 99 + 100 + await expect(seedDefaultRoles(withAgent(agent))).rejects.toThrow( 101 + 'Failed to seed critical role "Member"' 102 + ); 103 + }); 104 + 105 + it("throws when any other role fails to seed", async () => { 106 + const agent = mockAgent({ failForRole: "Owner" }); 107 + 108 + await expect(seedDefaultRoles(withAgent(agent))).rejects.toThrow( 109 + 'Failed to seed critical role "Owner"' 110 + ); 111 + }); 112 + 113 + it("does not partially create roles when a critical role fails", async () => { 114 + // Owner and Admin succeed, then Member fails 115 + let callCount = 0; 116 + const failingAgent = { 117 + com: { 118 + atproto: { 119 + repo: { 120 + createRecord: vi.fn().mockImplementation(({ record }: { record: { name: string } }) => { 121 + callCount++; 122 + if (record.name === "Member") { 123 + throw new Error("PDS timeout"); 124 + } 125 + return Promise.resolve({ 126 + data: { 127 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/tid${callCount}`, 128 + cid: `bafynew${callCount}`, 129 + }, 130 + }); 131 + }), 132 + }, 133 + }, 134 + }, 135 + } as any; 136 + 137 + await expect(seedDefaultRoles(withAgent(failingAgent))).rejects.toThrow( 138 + 'Failed to seed critical role "Member"' 139 + ); 140 + }); 141 + });
+13 -4
apps/appview/src/lib/seed-roles.ts
··· 1 1 import type { AppContext } from "./app-context.js"; 2 2 import { roles } from "@atbb/db"; 3 - import { eq } from "drizzle-orm"; 3 + import { and, eq } from "drizzle-orm"; 4 4 5 5 interface DefaultRole { 6 6 name: string; 7 7 description: string; 8 8 permissions: string[]; 9 9 priority: number; 10 + critical: boolean; 10 11 } 11 12 12 13 const DEFAULT_ROLES: DefaultRole[] = [ ··· 15 16 description: "Forum owner with full control", 16 17 permissions: ["*"], 17 18 priority: 0, 19 + critical: true, 18 20 }, 19 21 { 20 22 name: "Admin", ··· 31 33 "space.atbb.permission.createPosts", 32 34 ], 33 35 priority: 10, 36 + critical: true, 34 37 }, 35 38 { 36 39 name: "Moderator", ··· 44 47 "space.atbb.permission.createPosts", 45 48 ], 46 49 priority: 20, 50 + critical: true, 47 51 }, 48 52 { 49 53 name: "Member", ··· 53 57 "space.atbb.permission.createPosts", 54 58 ], 55 59 priority: 30, 60 + critical: true, 56 61 }, 57 62 ]; 58 63 ··· 62 67 * Idempotent: Checks for existing roles by name before creating. 63 68 * Safe to run on every startup. 64 69 * 65 - * @throws Error if ForumAgent is unavailable - permission system cannot function without roles 70 + * @throws Error if ForumAgent is unavailable or if any critical role fails to seed 66 71 */ 67 72 export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> { 68 73 // Check ForumAgent availability ··· 84 89 const [existingRole] = await ctx.db 85 90 .select() 86 91 .from(roles) 87 - .where(eq(roles.name, defaultRole.name)) 92 + .where(and(eq(roles.did, ctx.config.forumDid), eq(roles.name, defaultRole.name))) 88 93 .limit(1); 89 94 90 95 if (existingRole) { ··· 124 129 roleName: defaultRole.name, 125 130 error: error instanceof Error ? error.message : String(error), 126 131 }); 127 - // Continue seeding other roles even if one fails 132 + if (defaultRole.critical) { 133 + throw new Error( 134 + `Failed to seed critical role "${defaultRole.name}": ${error instanceof Error ? error.message : String(error)}` 135 + ); 136 + } 128 137 } 129 138 } 130 139