commits
- Bind errors in all bare catch blocks; add isProgrammingError re-throw
in export JSON parse and parseBody catch paths
- Split uploaded.text() and JSON.parse into separate try blocks for
distinct error messages and log entries
- Add logger.error to extractAppviewError catch and parseBody catch
- Add 100 KB file size guard before reading uploaded file
- Slugify colorScheme in export filename to guard against unexpected values
- Fix route registration comment: 4-segment path is distinct from 3-segment
/:rkey — registration order does not matter
- Rewrite cssOverrides drop comment to focus on portability and CSS bleed
- Update FCIS annotation to reference project one-file-per-route-group convention
- Add safety comment on fontUrls cast (isHttpsUrl verifies typeof === "string")
- Add tests: non-404 AppView error → 500, fontUrls non-array, AppView POST
network failure; change mockFetch.mock.calls[N] to .at(-1)! with URL assertion
Adds GET /admin/themes/:rkey/export (JSON attachment download, excludes
cssOverrides) and POST /admin/themes/import (file upload with per-field
validation, strips unknown tokens, drops cssOverrides, delegates to
existing POST /api/admin/themes). Export and import buttons added to the
theme list page. 26 new tests covering auth, validation, and happy paths.
Adds a cookie-based color scheme toggle button to the site header (both
desktop and mobile navs). Clicking it flips the atbb-color-scheme cookie
between light/dark and reloads the page so the server re-renders with the
correct preset tokens resolved by ATB-53's theme middleware.
- NavContent now accepts colorScheme and renders a toggle button with a
contextual aria-label ("Switch to dark mode" / "Switch to light mode")
- toggleColorScheme() vanilla JS sets cookie (path=/, max-age=1yr,
SameSite=Lax) and calls location.reload()
- .color-scheme-toggle CSS class follows neobrutal button aesthetics
- 5 new tests cover button presence, aria-label for both modes, dual-nav
rendering, onclick wiring, and cookie attribute correctness
* feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61)
Redesigns themeRef to use optional CID (live vs pinned refs), ships 5 complete
preset token sets, and adds release pipeline + escape-hatch scripts.
Lexicon:
- themePolicy#themeRef: replace strongRef wrapper with flat { uri, cid? }
uri-only = live ref (auto-updates); uri+cid = pinned ref (version-locked)
DB:
- theme_policy_available_themes.theme_cid: DROP NOT NULL (migration 0014)
Presets:
- Add clean-light.json, clean-dark.json, classic-bb.json (3 new presets)
- All 5 presets bundled as hardcoded fallback + deployment pipeline source
AppView:
- indexer: update themeRef field access (.theme.uri -> .uri, .theme.cid -> .cid)
- admin PUT /api/admin/theme-policy: remove CID-autofill/DB-lookup block;
pass CID through when provided, omit when absent; flat PDS record write
- theme-resolution: cid optional in ThemePolicyResponse type; split warning
for "URI not in availableThemes" vs "absent CID on live ref"
Scripts:
- publish-presets.ts: idempotent release pipeline script; skips unchanged
presets by comparing sorted token JSON; preserves createdAt on updates
- bootstrap-local-presets.ts: escape-hatch for zero-external-deps installs;
writes presets to forum's own PDS, rewrites themePolicy to local URIs
Docs:
- theming-plan.md: document canonical-presets design, live/pinned ref model,
deployment pipeline, local escape hatch, updated resolution waterfall
- ATB-61 Linear issue updated with new scope and acceptance criteria
* fix(atb-61): address PR review feedback on preset theme implementation
- Fix rkey regex to allow hyphens (/^[a-z0-9-]+$/i) — all 5 preset rkeys
contain hyphens (neobrutal-light, clean-dark, etc.) and were silently
falling back to the hardcoded theme
- Add live-ref resolveTheme test verifying CID check is skipped when
expectedCid is null (canonical atbb.space presets ship without CID)
- Add ThemePolicy indexer tests verifying flat .uri field access and
null themeCid for live refs
- Fix bare catch in publish-presets.ts to only swallow 404 (record not
found) and rethrow all other errors
- Add isRecordCurrent() helper including name/colorScheme in change
detection, not just tokens
- Replace non-null assertions on .find() in admin.ts with explicit guards
- Log existing themePolicy before overwriting in bootstrap-local-presets.ts
- Update Bruno Update Theme Policy docs: remove stale CID-lookup comment
* test(css-sanitizer): prove @IMPORT and EXPRESSION() case-insensitive handling
The sanitizer uses .toLowerCase() before comparing atrule/function names, so
uppercase obfuscation variants are already caught. These two tests document
that assumption explicitly so future changes can't silently break it.
* refactor(cli): move theme preset scripts into atbb CLI as theme subcommands
Moves `publish-presets.ts` and `bootstrap-local-presets.ts` from
`apps/appview/scripts/` into the `@atbb/cli` package as
`atbb theme publish-canonical` and `atbb theme bootstrap-local`.
Both commands sit alongside the existing init/category/board commands
and reuse the CLI's ForumAgent auth infrastructure. The appview npm
scripts that wrapped the old scripts are removed.
* feat(web+appview): CSS sanitization for theme cssOverrides (ATB-62)
Add @atbb/css-sanitizer workspace package (css-tree v2 AST-based) that
strips dangerous CSS constructs — @import, external url(), @font-face
with external src, expression(), -moz-binding, behavior, data: URIs —
while preserving safe structural overrides.
- appview: sanitize cssOverrides at write time (POST + PUT /api/admin/themes)
and log any stripped constructs as structured warnings
- web: replace inline stub sanitizeCss with the real package; enable the
CSS overrides textarea in the theme editor (was disabled pending ATB-62)
* fix(css-sanitizer): address PR review security and quality issues
Critical:
- Strip </style> sequences from generated output to prevent HTML parser
breakout when CSS is injected via dangerouslySetInnerHTML (XSS regression)
- Fail closed on css-tree onParseError: Raw nodes from error recovery bypass
walker checks, so discard entire output when any parse error occurs
- Wrap sanitizeCssOverrides calls in dedicated try-catch in POST and PUT
theme handlers (separate from PDS write block per CLAUDE.md granularity rule)
- Add try-catch around sanitizeCss calls in BaseLayout with empty fallback
so a css-tree bug doesn't 500 every page for all users
Security:
- Sanitize cssOverrides in POST /api/admin/themes/:rkey/duplicate so
pre-sanitization records don't propagate dangerous CSS via duplication
- Move warning push after list.remove() so audit log only says "Stripped X"
when the node was actually removed (not before the null-check)
- Fix onParseError type signature: (error: SyntaxError) => void
Quality:
- Replace JSON.stringify(warnings) with warnings in structured logger calls
- Update Bruno Create Theme.bru: remove stale ATB-62 placeholder text
- Add integration tests: dangerous CSS stripped in POST and PUT theme handlers
- Fix duplicate test expectation: sanitizer now runs on duplication (compact form)
- Fix </style> test: split into fail-closed test and string-literal stripping test
* docs: add design doc for ATB-59 admin theme token editor
Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.
* docs: add implementation plan for ATB-59 theme token editor
Covers extract-to-admin-themes.tsx, TDD for GET /admin/themes/:rkey,
preview endpoint, save, and reset-to-preset handlers.
* docs: add design doc for ATB-53 theme resolution and server-side token injection
* docs: add implementation plan for ATB-53 theme resolution and server-side token injection
* feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)
* feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)
* feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)
* test(web): add missing resolveTheme branch test for malformed theme URI (ATB-53)
* fix(web): re-throw programming errors in resolveTheme catch block (ATB-53)
* feat(web): add createThemeMiddleware Hono middleware (ATB-53)
* feat(web): register createThemeMiddleware on webRoutes (ATB-53)
* feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta (ATB-53)
Update BaseLayout to take a required resolvedTheme prop that drives dynamic :root CSS token injection, font URL rendering, and optional cssOverrides. Remove hardcoded neobrutal-light preset import and static ROOT_CSS constant. Add Accept-CH meta tag for color scheme client hint. Update all route factories to read theme from context (falling back to FALLBACK_THEME when middleware is absent, e.g. in tests).
* fix(web): sanitize cssOverrides before injection, add null branch tests (ATB-53)
* feat(web): type auth and mod route factories with WebAppEnv (ATB-53)
* docs: move ATB-53 plan docs to complete/
* docs(bruno): update GET /api/themes/:rkey to document cid field (ATB-53)
* feat(web): thread resolvedTheme through admin-themes route factory (ATB-53)
* fix(web): address PR review — sanitize tokens, split try blocks, add logs, rkey validation (ATB-53)
- Change `import { WebAppEnv }` to `import type` in routes/index.ts (type-only import)
- Freeze FALLBACK_THEME and its fontUrls array to prevent mutation across callers
- Split single giant try block in resolveTheme into 6 focused blocks (policy fetch, policy parse, URI/rkey extraction, theme fetch, theme parse, CID check) with per-operation error messages
- Add rkey validation against /^[a-z0-9]+$/i before using in fetch URL (path traversal prevention)
- Log warning when theme URI is absent from availableThemes (CID check bypassed)
- Log warn with status+url on non-ok policy/theme responses instead of silent fallback
- SyntaxError from Response.json() is now caught as a data error and not re-thrown
- Fix detectColorScheme cookie regex to use (?:^|;\s*) prefix anchor (prevents x-atbb-color-scheme=dark from matching)
- Wrap :root token block in sanitizeCss() in base.tsx
- Filter fontUrls to https:// only before rendering link tags in base.tsx
- Add try-catch error boundary in createThemeMiddleware so unexpected throws use FALLBACK_THEME
- Add tests: invalid JSON in policy/theme responses, CID bypass warning, invalid rkey, cookie regex prefix fix, middleware error boundary, non-https font URL filtering
* docs: add design doc for ATB-59 admin theme token editor
Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.
* docs: add implementation plan for ATB-59 theme token editor
Covers extract-to-admin-themes.tsx, TDD for GET /admin/themes/:rkey,
preview endpoint, save, and reset-to-preset handlers.
* refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)
* refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)
* test(web): add failing tests for GET /admin/themes/:rkey (ATB-59)
* test(web): improve admin-themes test quality for GET /admin/themes/:rkey
- Extract MANAGE_THEMES constant to reduce repetition
- Rename setupAuth → setupAuthenticatedSession to match admin.test.tsx pattern
- Remove unnecessary fetch mock from unauthenticated test
- Strengthen CSS overrides assertion to require co-location via regex
- Add colorScheme and second token assertions to happy-path test
- Restore strict "Access Denied" assertion on 403 test
- Add ATB-62 reference to CSS overrides test description
* feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)
* fix(web): block } in sanitizeTokenValue to prevent CSS block-escape injection (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/preview (ATB-59 TDD red)
* test(web): strengthen preview tests — add fallback test, fix semicolon sanitization assertion (ATB-59)
* test(web): fix preview test quality — align auth fixture, strengthen } assertion, clarify description (ATB-59)
* test(web): add 403 test for preview POST — manageThemes permission gate (ATB-59)
* feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)
Adds the live-preview fragment endpoint used by the theme editor's HTMX
integration. Sanitizes token values via sanitizeTokenValue() before
rendering ThemePreviewContent, dropping any value containing '<', ';',
or '}' to prevent CSS injection.
* fix(web): tighten sanitization assertions to --name: format, restore var(--color-bg) in preview template (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/save (ATB-59)
* feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)
* fix(web): sanitize token values on save + add PUT body forwarding test (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/reset-to-preset (ATB-59)
* test(web): strengthen reset-to-preset 400 assertion (ATB-59)
* feat(web): POST /admin/themes/:rkey/reset-to-preset (ATB-59)
* fix(web): address code review issues — ATB-59
- Fix GET /admin/themes/:rkey to call public /api/themes/:rkey instead
of nonexistent /api/admin/themes/:rkey; remove unused cookie variable
- Validate name before AppView PUT in save handler; redirect with error
if empty (prevents wasteful round-trip and unclear AppView message)
- Replace c.json() with redirect-on-error in reset-to-preset handler so
browser form POSTs show friendly error pages instead of raw JSON
- Add network failure test for GET /admin/themes/:rkey (500 unavailable)
- Add empty-name validation test for save handler
- Move ATB-59 plan docs to docs/plans/complete/
* docs: move ATB-59 plan docs to complete/
Break the 675-line monolithic helpers file into three focused modules:
- helpers/serialize.ts — serialization functions and DB row type aliases
- helpers/validate.ts — input validation and parameter parsing
- helpers/queries.ts — database query helpers (bans, mod status, etc.)
helpers.ts becomes a barrel re-export, so zero consumer changes needed.
This reduces merge conflicts since team members working on admin routes
won't collide with changes to serialization or query helpers.
Also fix admin modlog route: replace drizzle-orm aliased self-joins
(which generate invalid SQL for SQLite) with a batch handle lookup.
This fixes 9 pre-existing test failures in the modlog endpoint.
https://claude.ai/code/session_0119eQacx3ejToSd9c6QEc98
Co-authored-by: Claude <noreply@anthropic.com>
* feat(appview): add GET /api/admin/themes — unfiltered theme list for admin UI (ATB-58)
* fix(appview): add cleanDatabase, isTruncated, and Bruno collection for GET /api/admin/themes (ATB-58)
* feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58)
* fix(appview): use != null guards for optional fields and add cssOverrides/fontUrls test in duplicate (ATB-58)
* feat(web): add canManageThemes permission check and Themes card on admin landing (ATB-58)
* test(web): add negative assertions to admin landing page permission tests
Add missing negative assertions to ensure single-permission tests verify
that unrelated cards are not shown. The Themes card test now asserts that
members/structure/modlog links are absent; the manageCategories, moderatePosts,
banUsers, and lockTopics tests now assert that the themes link is absent.
* test(web): complete themes card assertions across all admin landing tests
Add missing `href="/admin/themes"` assertions to three tests:
- wildcard (*) permission test: assert themes card IS shown
- manageMembers-only test: assert themes card is NOT shown
- manageMembers + moderatePosts combo test: assert themes card is NOT shown
* feat(web): implement GET /admin/themes page — theme cards, policy form, create form (ATB-58)
* fix(web): rename _THEME_PRESETS and log non-404 policy fetch errors (ATB-58)
* feat(web): POST /admin/themes — create theme from preset and redirect (ATB-58)
* feat(web): POST /admin/themes/:rkey/duplicate — proxy duplicate to AppView (ATB-58)
* feat(web): POST /admin/themes/:rkey/delete — proxy delete to AppView with 409 handling (ATB-58)
* feat(web): POST /admin/theme-policy — update theme policy with availability and defaults (ATB-58)
* fix(appview): PUT /theme-policy accepts availableThemes without cid — looks up from DB (ATB-58)
* fix(web): add auth/permission/network tests and 409-specific delete handling (ATB-58)
Add missing unauthenticated, 403, and network-error tests to all four POST
theme routes. Separate the 409 branch in POST /admin/themes/:rkey/delete to
return a web-layer-owned human-friendly message. Strengthen the availableThemes
assertion in the theme-policy success test to verify exact payload shape.
* fix(atb-58): address PR review — CID validation, SyntaxError handling, Bruno seq
- Return 400 (not 200 with cid:"") when availableThemes contains uri-only entries
not found in the themes DB — empty string is not a valid AT Proto strongRef CID
- Wrap Response.json() calls in GET /admin/themes in inner try-catch so upstream
non-JSON responses are caught as parse errors rather than re-thrown as programming
errors via isProgrammingError(SyntaxError)
- Fix Bruno seq conflict: Duplicate Theme seq 4→5, List Themes seq 5→6
* fix(atb-58): block cid:\"\" as invalid strongRef; add DB failure test for needsLookup
- Introduce isMissingCid predicate (typeof !== string || === "") applied to all
three sites: needsLookup check, unresolvedUris filter, resolvedThemes map.
Explicit cid:"" bypassed the previous typeof-only guard and would have been
written verbatim to the PDS as an invalid strongRef CID.
- Add test: cid:"" entry returns 400 (same as absent cid not found in DB)
- Add test: DB select failure during needsLookup returns 500
* docs: add design doc for ATB-57 theme write API endpoints
* docs: add implementation plan for ATB-57 theme write API endpoints
* feat(appview): add manageThemes permission to Admin role (ATB-57)
* feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)
* test(appview): add 401/403/PDS-failure tests for POST /api/admin/themes (ATB-57)
* feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)
* feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)
* test(appview): assert 409 error body in dark-theme default check (ATB-57)
* test(appview): verify deleteRecord called with exact theme args (ATB-57)
* feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)
* test(appview): add theme-policy update path and updatedAt assertions (ATB-57)
* refactor(appview): strengthen theme-policy type guards and test assertions (ATB-57)
* docs(bruno): add Admin Themes collection for ATB-57 write endpoints
* docs: mark ATB-57 plan docs complete, move to docs/plans/complete/
* fix(appview): add 503 ForumAgent-not-authenticated tests; fix Bruno error code docs (ATB-57)
* docs: add ATB-55 theme read API design doc
Records approved design for themes table, theme_policies table,
theme_policy_available_themes join table, firehose indexer configs,
and GET /api/themes + GET /api/themes/:rkey + GET /api/theme-policy endpoints.
* docs: add ATB-55 theme API implementation plan
* feat(db): add themes, theme_policies, theme_policy_available_themes tables
Generate Postgres (0013) and SQLite (0001) migrations for the three new
theme tables. Build @atbb/db to verify schema compiles correctly.
* feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)
* feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)
* docs(bruno): add Themes API collection (ATB-55)
* docs: ATB-52 CSS token extraction design doc
* docs: ATB-52 implementation plan
* test(web): add failing preset completeness tests (ATB-52)
* test(web): improve preset test descriptions (ATB-52)
* feat(web): add neobrutal-light and neobrutal-dark JSON presets with font-size-xs token (ATB-52)
* feat(web): switch base layout to JSON preset import, remove TS preset (ATB-52)
* fix(web): replace all hardcoded CSS values with design tokens in mod and structure UI (ATB-52)
Define two new AT Proto record types for the theming system:
- space.atbb.forum.theme (tid key) — design tokens, color scheme, CSS overrides, font URLs
- space.atbb.forum.themePolicy (literal:self) — available themes, light/dark defaults, user choice toggle
Uses knownValues for colorScheme extensibility and strongRef wrapped in themeRef named def for CID integrity.
README was missing 3 of 5 packages (atproto, cli, logger).
Deployment guide had SESSION_TTL_DAYS default wrong (30 vs actual 7)
and was missing LOG_LEVEL, SEED_DEFAULT_ROLES, DEFAULT_MEMBER_ROLE
from the optional env vars table. Production env example was missing
FORUM_HANDLE and FORUM_PASSWORD variables.
* feat(web): admin mod action log page — /admin/modlog (ATB-48)
* docs: ATB-48 modlog UI implementation plan and completion notes
* fix(web): wrap modlogRes.json() in try-catch for non-JSON AppView responses (ATB-48)
A proxy returning HTML with HTTP 200 would cause Response.json() to throw
SyntaxError, which isProgrammingError() re-throws, producing an unhandled crash
instead of a 500 error page. Wrap with the same pattern used in the members
and role-assignment handlers.
Step-by-step TDD plan for GET /api/admin/modlog — requireAnyPermission
middleware, Drizzle alias double join, route handler, and Bruno collection.
Design for GET /api/admin/modlog — paginated mod action audit log with
double users join for moderator and subject handles, and requireAnyPermission
middleware for OR-based permission checks.
* docs: ATB-46 mod action log endpoint design
Design for GET /api/admin/modlog — paginated mod action audit log with
double users join for moderator and subject handles, and requireAnyPermission
middleware for OR-based permission checks.
* docs: ATB-46 mod action log implementation plan
Step-by-step TDD plan for GET /api/admin/modlog — requireAnyPermission
middleware, Drizzle alias double join, route handler, and Bruno collection.
* feat(appview): add requireAnyPermission middleware (ATB-46)
* test(appview): failing tests for GET /api/admin/modlog (ATB-46)
* feat(appview): GET /api/admin/modlog with double users leftJoin (ATB-46)
* docs(bruno): GET /api/admin/modlog collection (ATB-46)
* docs: move ATB-46 plan docs to complete/
* fix(appview): scope modlog queries to forumDid (ATB-46)
* feat(appview): add uri field to serializeCategory (ATB-47)
* test(web): add failing tests for GET /admin/structure (ATB-47)
* feat(web): add GET /admin/structure page with category/board listing (ATB-47)
* fix(web): use lowercase method="post" on structure page forms (ATB-47)
* test(web): add failing tests for category proxy routes (ATB-47)
* feat(web): add category proxy routes for structure management (ATB-47)
* test(web): add failing tests for board proxy routes (ATB-47)
* feat(web): add board proxy routes for structure management (ATB-47)
* feat(web): add CSS for admin structure management page (ATB-47)
* test(web): add missing network error tests for edit proxy routes (ATB-47)
* fix(web): log errors when boards fetch fails per-category in structure page (ATB-47)
* docs: add completed implementation plan for ATB-47 admin structure UI
* fix(web): validate sort order and fix board delete 409 test (ATB-47)
- parseSortOrder now returns null for negative/non-integer values and
redirects with "Sort order must be a non-negative integer." error;
0 remains the default for empty/missing sort order fields
- Use Number() + Number.isInteger() instead of parseInt() to reject
floats like "1.5" that parseInt would silently truncate
- Add validation tests for negative sort order across all 4 create/edit
handlers (create category, edit category, create board, edit board)
- Fix board delete 409 test mock to use the real AppView error message
("Cannot delete board with posts. Remove all posts first.") and assert
the message appears in the redirect URL, matching category delete test
* test(appview): add failing tests for POST /api/admin/boards (ATB-45)
* test(appview): add ForumAgent not authenticated test for POST /api/admin/boards (ATB-45)
* feat(appview): POST /api/admin/boards create endpoint (ATB-45)
* test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)
* test(appview): add error body assertion to PUT boards malformed JSON test (ATB-45)
* feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)
* test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)
* test(appview): improve DELETE /api/admin/boards/:id test coverage (ATB-45)
* feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)
Pre-flight refuses with 409 if any posts reference the board (via posts.boardId).
Also fixes test error messages to use "Database connection lost" (matching isDatabaseError keywords) for consistent 503 classification.
* docs(bruno): add board management API collection (ATB-45)
* fix(appview): close postgres connection after each admin test to prevent pool exhaustion (ATB-45)
Each createTestContext() call opens a new postgres.js connection pool. With 93
tests in admin.test.ts, the old pools were never closed, exhausting PostgreSQL's
max_connections limit. Fix by calling $client.end() in cleanup() for Postgres.
* docs(plans): move ATB-44 and ATB-45 plan docs to complete/
* test(appview): add missing DB error 503 tests for board endpoints (ATB-45)
- POST /api/admin/boards: add "returns 503 when category lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when board lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when category CID lookup query fails"
(call-count pattern: first select passes, second throws)
Replace loose pattern checks (toMatch, toContain) with an exact toBe()
assertion using the seeded role's full AT URI. Also assert toHaveLength(1)
so the test fails if extra roles appear unexpectedly.
Add a startsWith("did:") guard in the POST /admin/members/:did/role handler
before the upstream fetch call. Malformed path parameters now return an inline
MemberRow error fragment without hitting the AppView. Covered by a new test.
* feat(appview): POST /api/admin/categories create endpoint (ATB-44)
* feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)
* test(appview): add malformed JSON test for PUT /api/admin/categories/:id (ATB-44)
* test(appview): add failing tests for DELETE /api/admin/categories/:id (ATB-44)
* feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)
* docs(bruno): add category management API collection (ATB-44)
* fix(appview): use handleRouteError after consolidation refactor (ATB-44)
PR #74 consolidated handleReadError, handleWriteError, and
handleSecurityCheckError into a single handleRouteError. Update the
new category management handlers added in this branch to use the
consolidated name.
* fix(appview): address category endpoint review feedback (ATB-44)
- Tighten sortOrder validation: Number.isInteger() && >= 0 instead of
typeof === "number" (rejects floats, negatives, NaN, Infinity per lexicon
constraint integer, minimum: 0)
- Add 503 "ForumAgent not authenticated" tests for POST, PUT, DELETE
- Add 503 database failure tests for PUT and DELETE category lookup
- Add 403 permission tests for POST, PUT, DELETE
* fix(appview): address final review feedback on category endpoints (ATB-44)
- Fix PUT data loss: putRecord is a full AT Protocol record replacement,
not a patch. Fall back to existing category.description and
category.sortOrder when not provided in request body.
- Add test verifying existing description/sortOrder are preserved on
partial updates (regression test for the data loss bug).
- Add test for DELETE board-count preflight query failure path (503),
using a call-count mock so category lookup succeeds while the second
select throws.
* fix(web): show reply count and last-reply date on board topic listing
The topic listing on board pages always showed "0 replies" (hardcoded)
and used the topic's own createdAt for the date instead of the most
recent reply's timestamp.
- Add getReplyStats() helper: single GROUP BY query computing COUNT() and
MAX(createdAt) per rootPostId for a batch of topic IDs in one round-trip
- Enrich GET /api/boards/:id/topics response with replyCount and lastReplyAt
per topic; fail-open so a stats query failure degrades gracefully to 0/null
- Update TopicResponse interface and TopicRow in boards.tsx to consume the
new fields; date now reflects lastReplyAt ?? createdAt
- Add 5 integration tests covering zero replies, non-banned count, MAX date
accuracy, banned-reply exclusion, and per-topic independence
* fix(web): address code review issues from PR #75
- Add fail-open error path test: mocks getReplyStats DB failure and
asserts 200 response with replyCount 0 / lastReplyAt null / logger.error called
- Fix UI attribution: when lastReplyAt is set, show "last reply X ago"
instead of raw date next to "by {author}" — disambiguates whose action
the timestamp refers to
- Update Bruno collection: add replyCount and lastReplyAt to docs example
and assert blocks for GET /api/boards/:id/topics
- Rebase: merge conflict in boards.ts imports resolved (handleReadError →
handleRouteError from PR #74 refactor)
* fix(web): update setupSuccessfulFetch type to include replyCount and lastReplyAt
The helper's topics array element type was not updated alongside makeTopicsResponse,
causing tsc to reject the new test cases with TS2353. Vitest strips types via esbuild
so it passed locally; tsc in CI caught the mismatch.
handleReadError, handleWriteError, and handleSecurityCheckError had
byte-for-byte identical implementations. Replaced with a single
handleRouteError function, eliminating ~250 lines of duplicated code
across the error handler and its tests. Updated all 10 call sites.
https://claude.ai/code/session_018SH9pay3PqGo9JDdAv3iRj
Co-authored-by: Claude <noreply@anthropic.com>
* feat(web): add canManageRoles session helper (ATB-43)
* feat(appview): include uri in GET /api/admin/roles response (ATB-43)
Add rkey and did fields to the roles DB query, then construct the AT URI
(at://<did>/space.atbb.forum.role/<rkey>) in the response map so the
admin members page dropdown can submit a valid roleUri.
* style(web): add admin member table CSS classes (ATB-43)
* feat(web): add GET /admin/members page and POST proxy route (ATB-43)
* fix(web): add manageRoles permission gate to POST proxy route (ATB-43)
* docs: mark ATB-42 and ATB-43 complete in project plan
* docs: add ATB-43 implementation plan
* fix(web): address PR review feedback on admin members page (ATB-43)
* fix(web): use canManageRoles(auth) instead of hardcoded false in rolesJson error path
* docs: ATB-42 admin panel landing page implementation plan
* feat(web): add hasAnyAdminPermission() helper to session.ts (ATB-42)
* test(web): add hasAnyAdminPermission tests + tighten JSDoc (ATB-42)
* feat(web): add GET /admin landing page with permission-gated nav cards (ATB-42)
* refactor(web): move canManageMembers/canManageCategories/canViewModLog to session.ts (ATB-42)
* test(web): add admin landing page route tests (ATB-42)
* test(web): add missing structure-absent assertions for banUsers/lockTopics (ATB-42)
* style(web): add admin nav grid CSS (ATB-42)
* docs: add admin panel UI preview screenshot (ATB-42)
* fix(web): address minor code review feedback on ATB-42 admin panel
- Use var(--font-size-xl, 2rem) for admin card icon (CSS token consistency)
- Add banUsers and lockTopics test cases for canViewModLog helper
- Move plan doc to docs/plans/complete/
Step-by-step plan: add axe-core + jsdom deps, create consolidated test
file with jsdom env pragma, one happy-path WCAG AA test per page route.
Captures the approved design for adding axe-core + jsdom automated
accessibility tests to apps/web — single consolidated test file,
per-file jsdom environment pragma, one happy-path test per page route.
* docs: ATB-34 axe-core a11y testing design
Captures the approved design for adding axe-core + jsdom automated
accessibility tests to apps/web — single consolidated test file,
per-file jsdom environment pragma, one happy-path test per page route.
* docs: ATB-34 axe-core a11y implementation plan
Step-by-step plan: add axe-core + jsdom deps, create consolidated test
file with jsdom env pragma, one happy-path WCAG AA test per page route.
* chore(web): add axe-core, jsdom, vitest as explicit devDependencies (ATB-34)
* test(web): scaffold a11y test file with jsdom environment and module mocks (ATB-34)
* test(web): add WCAG AA accessibility tests for all page routes (ATB-34)
* test(web): suppress document.write deprecation with explanatory comment (ATB-34)
* test(web): use @ts-ignore to suppress deprecated document.write diagnostic (ATB-34)
* docs: move ATB-34 plan docs to complete
* test(web): address PR review feedback on a11y tests (ATB-34)
- Fix DOMParser comment to explain axe isPageContext() mechanism accurately
- Add DOM replacement guard after document.write() to catch silent no-ops
- Wrap axe.run() in try/catch with routeLabel for infrastructure error context
- Add routeLabel param to checkA11y; update all 6 call sites
- Reset canLockTopics/canModeratePosts/canBanUsers in beforeEach
- Add afterEach DOM cleanup via documentElement.innerHTML
- Fix path.startsWith('/topics/1') to exact match '/topics/1?offset=0&limit=25'
- Add form presence guard in new-topic test to catch silent auth fallback
- Update design doc to document DOMParser divergence and its reason
* test(web): strengthen DOM write guard to check html[lang] (ATB-34)
afterEach now removes lang from <html> so the guard in checkA11y can use
html[lang] as proof that document.write() actually executed, rather than
just checking that the <html> element exists (which it always does after
afterEach resets innerHTML without touching attributes).
Adds missing \$type fields to forum and board ref objects written to the PDS,
consistent with the replyRef fix in PR #61. Without \$type, the AT Protocol
runtime guards Post.isForumRef()/isBoardRef() return false, which would silently
break any future indexer refactor that adopts typed guards over optional chaining.
Also adds Post.isForumRef()/isBoardRef() assertions to the corresponding tests,
following the same contract-based pattern established for replyRef.
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
* fix(appview): include role strongRef when upgrading bootstrap membership
upgradeBootstrapMembership was writing a PDS record without a role field.
When the firehose re-indexed the event, membershipConfig.toUpdateValues()
set roleUri = record.role?.role.uri ?? null, overwriting the Owner's
roleUri with null and stripping their permissions on first login.
Fix: look up the existing roleUri in the roles table (parsing the AT URI
to extract did/rkey), then include it as a strongRef in the PDS record —
same pattern used by writeMembershipRecord for new members.
Closes ATB-37
* fix(appview): address review feedback on ATB-37 bootstrap upgrade
- Pass ctx.logger to parseAtUri so malformed roleUris emit structured
warnings at parse time
- Add explicit error log when parseAtUri returns null (data integrity
signal — a stored roleUri that can't be parsed is unexpected)
- Upgrade warn → error in DB failure catch: a failed role lookup during
bootstrap upgrade has the same consequence as the role-not-found path
(firehose will null out roleUri), so both warrant error level
- Add DB failure test for the role lookup catch block
- Add malformed roleUri test (parseAtUri returns null path)
- Add result.created assertions to tests 1 and 2
- Add DB state assertions to all three new bootstrap upgrade tests
Covers member management, forum structure CRUD (categories + boards),
and mod action audit log. Approach A: separate pages per section at
/admin/*.
All structure mutations follow PDS-first pattern (ForumAgent → firehose
→ DB) consistent with the rest of the AppView, not the bootstrap CLI's
dual-write shortcut.
- README: update packages/db to mention SQLite/LibSQL support alongside PostgreSQL
- README: add space.atbb.forum.board to the lexicon table (added by ATB-23)
- Deployment guide: fix stale plan doc path (moved to docs/plans/complete/)
- Deployment guide: update DATABASE_URL to document both PostgreSQL and SQLite formats
- Deployment guide: add SQLite overview paragraph to Section 4 Database Setup
- Deployment guide: update migration verification table list from 7 to 12 tables
- Deployment guide: bump version to 1.2, update date to 2026-02-26
* docs: reorganize completed plans and sync atproto-forum-plan
- Move all 45 completed plan docs from docs/plans/ to docs/plans/complete/
to distinguish finished work from active/upcoming plans
- Mark SQLite support as complete in the Future Roadmap section
- Add show-handles-in-posts as a completed Phase 4 item
- Update docs/plans/ path references to docs/plans/complete/
* docs: trim CLAUDE.md to gotchas-only, add CONTRIBUTING.md
CLAUDE.md's stated purpose is "common mistakes and confusion points."
Several sections had grown into general style guides and process docs
that don't serve that purpose:
- Remove generic testing workflow, quality standards, coverage expectations
→ moved to CONTRIBUTING.md
- Remove defensive programming boilerplate, global error handler template,
error testing examples → discoverable from existing code
- Remove security test coverage requirements and security checklist
→ moved to CONTRIBUTING.md
- Remove Bruno file template and testing workflow → moved to CONTRIBUTING.md
Bug fixes in CLAUDE.md:
- Fix stale `role.permissions.includes()` example → role_permissions join table
- Add `forum.board` to lexicon record ownership list (added by ATB-23)
- Add docs/plans/complete/ convention
CONTRIBUTING.md is a new contributor-facing document covering testing
workflow, error handling patterns (with examples), security checklist,
and Bruno template — content humans read before their first PR.
* docs: consolidate Bruno collections into top-level bruno/
apps/appview/bruno/ was a leftover from when the admin role endpoints
were first written. The top-level bruno/ collection was created later
but never picked up the three files that already existed:
- Admin/Assign Role.bru
- Admin/List Members.bru
- Admin/List Roles.bru
Move all three into bruno/AppView API/Admin/ alongside the backfill
endpoints. Remove the now-redundant apps/appview/bruno/bruno.json and
environments/local.bru.
Add missing environment variables (target_user_did, role_rkey) to both
local.bru and dev.bru so Assign Role.bru resolves correctly.
* feat: constrain oauth scopes
* docs: document oauth scopes and sync test mock
Add inline comment explaining each scope token's purpose and the %23
encoding requirement. Update auth test mock to reflect the new scopes.
* feat(db): add @libsql/client dependency for SQLite support
* feat(db): add SQLite schema file
* feat(db): add role_permissions table to Postgres schema (permissions column still present)
* feat(db): URL-based driver detection in createDb (postgres vs SQLite)
* feat(appview): add dialect-specific Drizzle configs and update db scripts
* feat(db): migration 0011 — add role_permissions table
* feat(appview): add migrate-permissions data migration script
Copies permissions from roles.permissions[] into the role_permissions
join table before the column is dropped in migration 0012.
* feat(db): migration 0012 — drop permissions column from roles (data already in role_permissions)
* feat(db): add SQLite migrations (single clean initial migration)
* feat(appview): update checkPermission and getUserRole to use role_permissions table
* feat(appview): update indexer to store role permissions in role_permissions table
- Remove permissions field from roleConfig.toInsertValues and toUpdateValues
(the permissions text[] column no longer exists on the roles table)
- Add optional afterUpsert hook to CollectionConfig for post-row child writes
- Implement roleConfig.afterUpsert to delete+insert into role_permissions
within the same transaction, using .returning({ id }) to get the row id
- Update genericCreate and genericUpdate to call afterUpsert when defined
- Rewrite indexer-roles test assertions to query role_permissions table
- Remove permissions field from direct db.insert(roles) test setup calls
* feat(appview): update admin routes to return permissions from role_permissions table
- GET /api/admin/roles: removed roles.permissions from select, enriches each
role with permissions fetched from role_permissions join table via Promise.all
- GET /api/admin/members/me: removed roles.permissions from join select, adds
roleId to select, then fetches permissions separately from role_permissions
- Updated all test files to remove permissions field from db.insert(roles).values()
calls, replacing with separate db.insert(rolePermissions).values() calls after
capturing the returned role id via .returning({ id: roles.id })
- Fixed admin-backfill.test.ts mock count: checkPermission now makes 3 DB
selects (membership + role + role_permissions) instead of 2
- Updated seed-roles.ts CLI step to insert permissions into role_permissions
table separately instead of the removed permissions column
* feat(appview): update test context to support SQLite via createDb factory
- Replace hardcoded postgres.js/drizzle-orm/postgres-js with createDb() from @atbb/db,
which auto-detects the URL prefix (postgres:// vs file:) and selects the right driver
- Add runSqliteMigrations() to @atbb/db so migrations run via the same drizzle-orm
instance used by createDb(), avoiding cross-package module boundary issues
- For SQLite in-memory mode, upgrade file::memory: to file::memory:?cache=shared so
that @libsql/client's transaction() handoff (which sets #db=null and lazily reconnects)
reattaches to the same shared in-memory database rather than creating an empty new one
- For SQLite cleanDatabase(): delete all rows without WHERE clauses (isolated in-memory DB)
- For Postgres cleanDatabase(): retain existing DID-based patterns
- Remove sql.end() from cleanup() — createDb owns the connection lifecycle
- Fix boards.test.ts and categories.test.ts "returns 503 on database error" tests to use
vi.spyOn() instead of relying on sql.end() to break the connection
- Replace count(*)::int (Postgres-specific cast) with Drizzle's count() helper in admin.ts
so the backfill/:id endpoint works on both Postgres and SQLite
* feat: add docker-compose.sqlite.yml for SQLite deployments
* feat(nix): add database.type option to NixOS module (postgresql | sqlite)
* feat(nix): include SQLite migrations and dialect configs in Nix package output
* feat(devenv): make postgres optional via mkDefault, document SQLite alternative
* refactor(appview): embed data migration into 0012 postgres migration
Copy permissions array into role_permissions join table inside the same
migration that drops the column. ON CONFLICT DO NOTHING keeps the script
idempotent for DBs that already ran migrate-permissions.ts manually.
* fix(appview): guard against out-of-order UPDATE in indexer; populate role_permissions in mod tests
- indexer.ts: add `if (!updated) return` before afterUpsert call so an
out-of-order firehose UPDATE that matches zero rows (CREATE not yet
received) doesn't crash with TypeError on `updated.id`
- mod.test.ts: add rolePermissions inserts for all 4 role setups so
test DB state accurately reflects the permissions each role claims
to have (ban admin: banUsers; lock/unlock mod: lockTopics)
* fix(appview): replace fabricated editPosts permission with real lockTopics in admin test
space.atbb.permission.editPosts is not defined in the lexicon or any
default role. Swap it for space.atbb.permission.lockTopics so the
GET /api/admin/members/me test uses a real permission value and would
catch a regression that silently dropped a real permission from a role.
Audit of plan doc against Linear issues and codebase state as of 2026-02-24.
Completed work added:
- ATB-25: separate bannedByMod column from deleted (Phase 3 bug fix, PR #56)
- ATB-35: strip title from reply records at index time (Phase 3 bug fix, PR #55)
- ATB-26: neobrutal design system, shared components, route stubs (Phase 4, PR #39)
- ATB-33: server-side offset/limit pagination for GET /api/topics/:id (Phase 4, PR #57)
- Fix ATB-30/31 attribution: compose forms are ATB-31, login/logout is ATB-30
Key Risks section:
- Mark PDS write path resolved (OAuth 2.1 + PKCE, ATB-14)
- Mark record deletion resolved (tombstone handling + bannedByMod split, ATB-25)
New Known Issues / Active Backlog section:
- ATB-39 (High): upgradeBootstrapMembership writes PDS record without role field
- ATB-38 (High): seedDefaultRoles partial failure should fail fast
- ATB-41 (Medium): missing $type on forumRef/boardRef in PDS writes
- ATB-34 (Low): axe-core WCAG AA automated tests
- Notes ATB-39/40 are duplicates of ATB-37/38
Future Roadmap:
- Add SQLite support (design approved, docs/plans/2026-02-24-sqlite-support-design.md)
- Update user self-deletion note: deleted_by_user column already in schema (ATB-25)
Designs dual-dialect database support (PostgreSQL + SQLite) with:
- URL-prefix detection in createDb factory
- role_permissions join table replacing permissions text[] array
- Two-phase Postgres migration with data migration script
- NixOS module and devenv changes for optional Postgres service
- Operator upgrade instructions with safety warnings
* docs: add design for storing user handles at login time
* feat: persist user handle to DB during OAuth login so posts show handles
* fix: address PR review comments on handle persistence
Critical:
- Add isProgrammingError guard to upsert catch so TypeErrors are not swallowed
- Add logger.warn assertion to upsert failure test (per CLAUDE.md logging test requirement)
Important:
- Fix upsert to use COALESCE so a null getProfile result never overwrites a good existing handle
- Add warn log when persisting null handle so operators can detect suspended/migrating accounts
- Add test: getProfile returns undefined → null handle written, login still succeeds
- Add test: existing handle preserved when getProfile returns undefined
- Align log severity — upsert failure now uses warn (consistent with membership failure)
- Fix misleading vi.clearAllMocks() comment; fresh ctx is what makes the spy safe to abandon
- Update design doc snippet to match implementation (use extracted handle variable + COALESCE)
Suggestion:
- Add test: TypeError from upsert is re-thrown and causes 500, not silent redirect
- Hardcode getProfile mock return value instead of deriving from DID string split
* fix(appview): add $type to reply ref so indexer resolves rootPostId/parentPostId
Post.isReplyRef() uses AT Protocol's is$typed() runtime guard which requires
$type: "space.atbb.post#replyRef" to be present. Without it the guard returned
false, leaving rootPostId/parentPostId null in the database and breaking reply
threading. Also adds an assertion to the happy-path test that verifies the
record written to the PDS includes the correct $type on the reply ref.
* fix(appview): address PR review feedback on reply ref $type fix
- Use Post.isReplyRef() in route tests instead of $type string literal,
so the actual indexer contract is tested (a typo in the string would
still break isReplyRef() but pass a literal comparison)
- Add isReplyRef() assertion to nested-reply test (creates reply to reply)
- Add regex URI assertions for stronger AT-URI shape validation
- Add indexer happy-path test: correctly-typed reply ref resolves
rootPostId/parentPostId to non-null values
- Upgrade logger.warn → logger.error for missing $type (data corruption,
not a warning — post is silently unreachable in thread navigation)
- Add errorId field to missing $type log entry for operator filtering
- Split outer try block in POST /api/posts: DB lookup and PDS write now
have separate catch blocks with accurate error messages (per CLAUDE.md
Try Block Granularity pattern)
* style(web): make board cards full-width on all screen sizes
Remove responsive grid overrides so boards stack in a single column
at every viewport, freeing up horizontal space for future additions
like topic counts and latest-topic metadata.
* fix(docker,nix): add missing @atbb/logger package to build configs
The @atbb/logger package was added to the workspace but Dockerfile and
nix/package.nix were not updated to include it, causing the Docker build
to fail with missing @opentelemetry/* and hono modules.
Dockerfile: copy packages/logger/package.json in both builder and runtime
stages so pnpm installs its dependencies, and copy logger/dist in the
runtime stage so workspace symlinks resolve at runtime.
nix/package.nix: add packages/logger to the installPhase loop so the
built dist/ and node_modules/ are included in the Nix output derivation.
* set pnpmDeps hash to empty string for updates
* set pnpmDeps hash to the proper hash
Remove responsive grid overrides so boards stack in a single column
at every viewport, freeing up horizontal space for future additions
like topic counts and latest-topic metadata.
* fix(appview): assign default Member role when creating membership on first login
Membership PDS records were written without a role reference, causing the
firehose to index roleUri as null. The permission middleware fails closed on
null roleUri, so all newly-registered users got 403 on every post attempt.
Now looks up the seeded "Member" role and includes it as a strongRef in the
membership record at creation time. Falls back gracefully (no role field) if
the Member role is not yet in the DB.
* fix(appview): address code review feedback on default-member-role fix
- Log ctx.logger.error when Member role not found in DB (operator alert)
- Wrap role lookup in try-catch; log ctx.logger.warn and proceed without
role on transient DB errors, so membership is still created
- Add orderBy(asc(roles.indexedAt)) to make role selection deterministic
when duplicate Member roles exist
- Rename test DIDs to use did:plc:test-* prefix per established cleanup patterns
- Add test asserting logger.error fires when Member role is absent
- Add test asserting membership is created without role on DB error
* fix(appview): re-throw programming errors from role lookup catch block
Per CLAUDE.md error handling standards, TypeError/ReferenceError/SyntaxError
indicate code bugs and must not be silently swallowed. Adds re-throw guard
before the warn log so transient DB errors are handled gracefully while
programming errors surface during development.
Adds test verifying TypeError propagates rather than being caught.
* test(appview): add failing pagination tests for server-side topic pagination (ATB-33)
Add describe.sequential block to topics.test.ts covering GET /api/topics/:id
server-side pagination behavior that does not exist yet. Tests verify total,
offset, limit fields in response, default/explicit pagination, offset/limit
clamping, and empty result sets. All 8 tests fail as expected — implementation
pending.
* test(appview): clarify total semantics in pagination test name (ATB-33)
* feat(appview): add offset/limit pagination to GET /api/topics/:id (ATB-33)
- Parse offset/limit query params (default 25, max 100)
- Run COUNT + paginated SELECT in parallel (matching boards pattern)
- Return total, offset, limit in response alongside paginated replies
- Removes 1000-reply defensive limit in favour of server-controlled pagination
* test(appview): document approximate total semantic and add companion test (ATB-33)
* feat(web): use server-side pagination for topic replies (ATB-33)
- Pass offset/limit to AppView instead of slicing locally
- HTMX partial: forwards ?offset=N&limit=25 to AppView
- Full page: requests ?offset=0&limit=(offset+25) for bookmark support
- Removes TODO(ATB-33) comment
* fix(web): remove duplicate total property in makeTopicResponse test helper (ATB-33)
* chore(web): add clarifying comments for pagination edge cases (ATB-33)
* fix(web): remove invalid JSX comment between attributes (ATB-33)
* docs(bruno): update Get Topic collection with pagination params (ATB-33)
* fix(appview,web): address code review feedback on ATB-33 pagination
Critical fixes:
- Split single try-catch into two: topic query and reply query now have
distinct error messages ("Failed to retrieve topic" vs "Failed to
retrieve replies for topic") per CLAUDE.md try-block granularity rule
- HTMX partial error now returns a retry fragment instead of silently
replacing the Load More button with empty content
- Fix hasMore infinite loop: use `replies.length >= limit` (page-fullness
heuristic) instead of `nextOffset < total`; total is pre-filter and can
cause an infinite loop when in-memory filters zero out all SQL results
- Raise AppView limit cap from 100 to 250 so bookmark displayLimit
(offset + REPLIES_PER_PAGE) no longer gets silently clamped for deep links
- Fix Bruno docs: total is filtered for bannedByMod=false at SQL level,
not "unfiltered"; update description to match inline code comment
Important fixes:
- Remove total from ReplyFragment props (no longer used after hasMore fix)
- Change `total === 0` guard to `initialReplies.length === 0` so EmptyState
renders when all page-1 replies are filtered in-memory; update message to
"No replies to show."
- Add test: bannedByMod=true directly reduces total (proves COUNT query
applies the SQL-level filter)
- Add test: non-numeric offset/limit params default to 0/25
- Strengthen clamps limit=0 test to assert replies are returned, not just
metadata; rename limit cap test to reflect new max of 250
- Add AppView URL assertions to bookmark and HTMX partial web tests
- Update HTMX error test to assert retry fragment content
* fix(atb-33): clean up HTMX retry element and stale pagination comment
- Replace <p>/<button> retry fragment with bare <button> so hx-swap="outerHTML"
replaces the entire error element on retry success (no orphan text node)
- Update stale comment: topics cap is 250 (not 100 like boards) to support bookmarks
* fix(indexer): strip title from reply records at index time (ATB-35)
When a space.atbb.post record carries both a reply ref and a title field,
the indexer now coerces title to null for both INSERT and UPDATE paths.
This enforces the lexicon invariant ("title is omitted for replies") at
the data-ingestion boundary rather than relying on the API or UI to ignore
the field.
ATB-36 is a duplicate of this issue and has been marked accordingly.
* fix(indexer): address PR review feedback (ATB-35)
- Add try-catch to getPostIdByUri, matching the structured error logging
pattern used by all other helper methods
- Warn when reply ref is present but missing $type (rootPostId/parentPostId
will be null; operators now have an observable signal)
- Reaction stub handlers: info → warn (events are being permanently
discarded, not successfully processed)
- Tests: extract createTrackingDb helper to eliminate inline mock duplication
- Tests: add topic-starter title-preserved assertions (right branch of ternary)
- Tests: assert full inserted shape on reply create (rootPostId/parentPostId
null, rootUri/parentUri populated) to document the $type-less behavior
* fix(appview): separate ban enforcement from user-initiated deletes (ATB-25)
- Add bannedByMod column to posts: applyBan/liftBan now use this column
exclusively, so lifting a ban can never resurrect user-deleted content
- Add deletedByUser column and tombstone user-initiated deletes: when a
post delete arrives from the firehose, the row is kept (for FK stability)
but text is replaced with '[user deleted this post]' and deletedByUser
is set to true — personal content is gone, thread structure is preserved
- Remove shared deleted column; all API filters now use bannedByMod=false
- Migrations: 0009 adds banned_by_mod, 0010 drops deleted / adds deleted_by_user
* test: fix schema column list test and strengthen tombstone assertion
- schema.test.ts: update "has expected columns for the unified post model"
to check for bannedByMod/deletedByUser instead of deleted (was missed in
the original commit, causing CI to fail)
- indexer.test.ts: replace weak toHaveBeenCalled() guard with exact
payload assertion per code review — verifies text and deletedByUser are
set, and that bannedByMod/deleted are never touched
* docs: correct genericDelete comment — posts always tombstone, no fallback
- Bind errors in all bare catch blocks; add isProgrammingError re-throw
in export JSON parse and parseBody catch paths
- Split uploaded.text() and JSON.parse into separate try blocks for
distinct error messages and log entries
- Add logger.error to extractAppviewError catch and parseBody catch
- Add 100 KB file size guard before reading uploaded file
- Slugify colorScheme in export filename to guard against unexpected values
- Fix route registration comment: 4-segment path is distinct from 3-segment
/:rkey — registration order does not matter
- Rewrite cssOverrides drop comment to focus on portability and CSS bleed
- Update FCIS annotation to reference project one-file-per-route-group convention
- Add safety comment on fontUrls cast (isHttpsUrl verifies typeof === "string")
- Add tests: non-404 AppView error → 500, fontUrls non-array, AppView POST
network failure; change mockFetch.mock.calls[N] to .at(-1)! with URL assertion
Adds GET /admin/themes/:rkey/export (JSON attachment download, excludes
cssOverrides) and POST /admin/themes/import (file upload with per-field
validation, strips unknown tokens, drops cssOverrides, delegates to
existing POST /api/admin/themes). Export and import buttons added to the
theme list page. 26 new tests covering auth, validation, and happy paths.
Adds a cookie-based color scheme toggle button to the site header (both
desktop and mobile navs). Clicking it flips the atbb-color-scheme cookie
between light/dark and reloads the page so the server re-renders with the
correct preset tokens resolved by ATB-53's theme middleware.
- NavContent now accepts colorScheme and renders a toggle button with a
contextual aria-label ("Switch to dark mode" / "Switch to light mode")
- toggleColorScheme() vanilla JS sets cookie (path=/, max-age=1yr,
SameSite=Lax) and calls location.reload()
- .color-scheme-toggle CSS class follows neobrutal button aesthetics
- 5 new tests cover button presence, aria-label for both modes, dual-nav
rendering, onclick wiring, and cookie attribute correctness
* feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61)
Redesigns themeRef to use optional CID (live vs pinned refs), ships 5 complete
preset token sets, and adds release pipeline + escape-hatch scripts.
Lexicon:
- themePolicy#themeRef: replace strongRef wrapper with flat { uri, cid? }
uri-only = live ref (auto-updates); uri+cid = pinned ref (version-locked)
DB:
- theme_policy_available_themes.theme_cid: DROP NOT NULL (migration 0014)
Presets:
- Add clean-light.json, clean-dark.json, classic-bb.json (3 new presets)
- All 5 presets bundled as hardcoded fallback + deployment pipeline source
AppView:
- indexer: update themeRef field access (.theme.uri -> .uri, .theme.cid -> .cid)
- admin PUT /api/admin/theme-policy: remove CID-autofill/DB-lookup block;
pass CID through when provided, omit when absent; flat PDS record write
- theme-resolution: cid optional in ThemePolicyResponse type; split warning
for "URI not in availableThemes" vs "absent CID on live ref"
Scripts:
- publish-presets.ts: idempotent release pipeline script; skips unchanged
presets by comparing sorted token JSON; preserves createdAt on updates
- bootstrap-local-presets.ts: escape-hatch for zero-external-deps installs;
writes presets to forum's own PDS, rewrites themePolicy to local URIs
Docs:
- theming-plan.md: document canonical-presets design, live/pinned ref model,
deployment pipeline, local escape hatch, updated resolution waterfall
- ATB-61 Linear issue updated with new scope and acceptance criteria
* fix(atb-61): address PR review feedback on preset theme implementation
- Fix rkey regex to allow hyphens (/^[a-z0-9-]+$/i) — all 5 preset rkeys
contain hyphens (neobrutal-light, clean-dark, etc.) and were silently
falling back to the hardcoded theme
- Add live-ref resolveTheme test verifying CID check is skipped when
expectedCid is null (canonical atbb.space presets ship without CID)
- Add ThemePolicy indexer tests verifying flat .uri field access and
null themeCid for live refs
- Fix bare catch in publish-presets.ts to only swallow 404 (record not
found) and rethrow all other errors
- Add isRecordCurrent() helper including name/colorScheme in change
detection, not just tokens
- Replace non-null assertions on .find() in admin.ts with explicit guards
- Log existing themePolicy before overwriting in bootstrap-local-presets.ts
- Update Bruno Update Theme Policy docs: remove stale CID-lookup comment
* test(css-sanitizer): prove @IMPORT and EXPRESSION() case-insensitive handling
The sanitizer uses .toLowerCase() before comparing atrule/function names, so
uppercase obfuscation variants are already caught. These two tests document
that assumption explicitly so future changes can't silently break it.
* refactor(cli): move theme preset scripts into atbb CLI as theme subcommands
Moves `publish-presets.ts` and `bootstrap-local-presets.ts` from
`apps/appview/scripts/` into the `@atbb/cli` package as
`atbb theme publish-canonical` and `atbb theme bootstrap-local`.
Both commands sit alongside the existing init/category/board commands
and reuse the CLI's ForumAgent auth infrastructure. The appview npm
scripts that wrapped the old scripts are removed.
* feat(web+appview): CSS sanitization for theme cssOverrides (ATB-62)
Add @atbb/css-sanitizer workspace package (css-tree v2 AST-based) that
strips dangerous CSS constructs — @import, external url(), @font-face
with external src, expression(), -moz-binding, behavior, data: URIs —
while preserving safe structural overrides.
- appview: sanitize cssOverrides at write time (POST + PUT /api/admin/themes)
and log any stripped constructs as structured warnings
- web: replace inline stub sanitizeCss with the real package; enable the
CSS overrides textarea in the theme editor (was disabled pending ATB-62)
* fix(css-sanitizer): address PR review security and quality issues
Critical:
- Strip </style> sequences from generated output to prevent HTML parser
breakout when CSS is injected via dangerouslySetInnerHTML (XSS regression)
- Fail closed on css-tree onParseError: Raw nodes from error recovery bypass
walker checks, so discard entire output when any parse error occurs
- Wrap sanitizeCssOverrides calls in dedicated try-catch in POST and PUT
theme handlers (separate from PDS write block per CLAUDE.md granularity rule)
- Add try-catch around sanitizeCss calls in BaseLayout with empty fallback
so a css-tree bug doesn't 500 every page for all users
Security:
- Sanitize cssOverrides in POST /api/admin/themes/:rkey/duplicate so
pre-sanitization records don't propagate dangerous CSS via duplication
- Move warning push after list.remove() so audit log only says "Stripped X"
when the node was actually removed (not before the null-check)
- Fix onParseError type signature: (error: SyntaxError) => void
Quality:
- Replace JSON.stringify(warnings) with warnings in structured logger calls
- Update Bruno Create Theme.bru: remove stale ATB-62 placeholder text
- Add integration tests: dangerous CSS stripped in POST and PUT theme handlers
- Fix duplicate test expectation: sanitizer now runs on duplication (compact form)
- Fix </style> test: split into fail-closed test and string-literal stripping test
* docs: add design doc for ATB-59 admin theme token editor
Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.
* docs: add implementation plan for ATB-59 theme token editor
Covers extract-to-admin-themes.tsx, TDD for GET /admin/themes/:rkey,
preview endpoint, save, and reset-to-preset handlers.
* docs: add design doc for ATB-53 theme resolution and server-side token injection
* docs: add implementation plan for ATB-53 theme resolution and server-side token injection
* feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)
* feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)
* feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)
* test(web): add missing resolveTheme branch test for malformed theme URI (ATB-53)
* fix(web): re-throw programming errors in resolveTheme catch block (ATB-53)
* feat(web): add createThemeMiddleware Hono middleware (ATB-53)
* feat(web): register createThemeMiddleware on webRoutes (ATB-53)
* feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta (ATB-53)
Update BaseLayout to take a required resolvedTheme prop that drives dynamic :root CSS token injection, font URL rendering, and optional cssOverrides. Remove hardcoded neobrutal-light preset import and static ROOT_CSS constant. Add Accept-CH meta tag for color scheme client hint. Update all route factories to read theme from context (falling back to FALLBACK_THEME when middleware is absent, e.g. in tests).
* fix(web): sanitize cssOverrides before injection, add null branch tests (ATB-53)
* feat(web): type auth and mod route factories with WebAppEnv (ATB-53)
* docs: move ATB-53 plan docs to complete/
* docs(bruno): update GET /api/themes/:rkey to document cid field (ATB-53)
* feat(web): thread resolvedTheme through admin-themes route factory (ATB-53)
* fix(web): address PR review — sanitize tokens, split try blocks, add logs, rkey validation (ATB-53)
- Change `import { WebAppEnv }` to `import type` in routes/index.ts (type-only import)
- Freeze FALLBACK_THEME and its fontUrls array to prevent mutation across callers
- Split single giant try block in resolveTheme into 6 focused blocks (policy fetch, policy parse, URI/rkey extraction, theme fetch, theme parse, CID check) with per-operation error messages
- Add rkey validation against /^[a-z0-9]+$/i before using in fetch URL (path traversal prevention)
- Log warning when theme URI is absent from availableThemes (CID check bypassed)
- Log warn with status+url on non-ok policy/theme responses instead of silent fallback
- SyntaxError from Response.json() is now caught as a data error and not re-thrown
- Fix detectColorScheme cookie regex to use (?:^|;\s*) prefix anchor (prevents x-atbb-color-scheme=dark from matching)
- Wrap :root token block in sanitizeCss() in base.tsx
- Filter fontUrls to https:// only before rendering link tags in base.tsx
- Add try-catch error boundary in createThemeMiddleware so unexpected throws use FALLBACK_THEME
- Add tests: invalid JSON in policy/theme responses, CID bypass warning, invalid rkey, cookie regex prefix fix, middleware error boundary, non-https font URL filtering
* docs: add design doc for ATB-59 admin theme token editor
Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.
* docs: add implementation plan for ATB-59 theme token editor
Covers extract-to-admin-themes.tsx, TDD for GET /admin/themes/:rkey,
preview endpoint, save, and reset-to-preset handlers.
* refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)
* refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)
* test(web): add failing tests for GET /admin/themes/:rkey (ATB-59)
* test(web): improve admin-themes test quality for GET /admin/themes/:rkey
- Extract MANAGE_THEMES constant to reduce repetition
- Rename setupAuth → setupAuthenticatedSession to match admin.test.tsx pattern
- Remove unnecessary fetch mock from unauthenticated test
- Strengthen CSS overrides assertion to require co-location via regex
- Add colorScheme and second token assertions to happy-path test
- Restore strict "Access Denied" assertion on 403 test
- Add ATB-62 reference to CSS overrides test description
* feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)
* fix(web): block } in sanitizeTokenValue to prevent CSS block-escape injection (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/preview (ATB-59 TDD red)
* test(web): strengthen preview tests — add fallback test, fix semicolon sanitization assertion (ATB-59)
* test(web): fix preview test quality — align auth fixture, strengthen } assertion, clarify description (ATB-59)
* test(web): add 403 test for preview POST — manageThemes permission gate (ATB-59)
* feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)
Adds the live-preview fragment endpoint used by the theme editor's HTMX
integration. Sanitizes token values via sanitizeTokenValue() before
rendering ThemePreviewContent, dropping any value containing '<', ';',
or '}' to prevent CSS injection.
* fix(web): tighten sanitization assertions to --name: format, restore var(--color-bg) in preview template (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/save (ATB-59)
* feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)
* fix(web): sanitize token values on save + add PUT body forwarding test (ATB-59)
* test(web): write failing tests for POST /admin/themes/:rkey/reset-to-preset (ATB-59)
* test(web): strengthen reset-to-preset 400 assertion (ATB-59)
* feat(web): POST /admin/themes/:rkey/reset-to-preset (ATB-59)
* fix(web): address code review issues — ATB-59
- Fix GET /admin/themes/:rkey to call public /api/themes/:rkey instead
of nonexistent /api/admin/themes/:rkey; remove unused cookie variable
- Validate name before AppView PUT in save handler; redirect with error
if empty (prevents wasteful round-trip and unclear AppView message)
- Replace c.json() with redirect-on-error in reset-to-preset handler so
browser form POSTs show friendly error pages instead of raw JSON
- Add network failure test for GET /admin/themes/:rkey (500 unavailable)
- Add empty-name validation test for save handler
- Move ATB-59 plan docs to docs/plans/complete/
* docs: move ATB-59 plan docs to complete/
Break the 675-line monolithic helpers file into three focused modules:
- helpers/serialize.ts — serialization functions and DB row type aliases
- helpers/validate.ts — input validation and parameter parsing
- helpers/queries.ts — database query helpers (bans, mod status, etc.)
helpers.ts becomes a barrel re-export, so zero consumer changes needed.
This reduces merge conflicts since team members working on admin routes
won't collide with changes to serialization or query helpers.
Also fix admin modlog route: replace drizzle-orm aliased self-joins
(which generate invalid SQL for SQLite) with a batch handle lookup.
This fixes 9 pre-existing test failures in the modlog endpoint.
https://claude.ai/code/session_0119eQacx3ejToSd9c6QEc98
Co-authored-by: Claude <noreply@anthropic.com>
* feat(appview): add GET /api/admin/themes — unfiltered theme list for admin UI (ATB-58)
* fix(appview): add cleanDatabase, isTruncated, and Bruno collection for GET /api/admin/themes (ATB-58)
* feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58)
* fix(appview): use != null guards for optional fields and add cssOverrides/fontUrls test in duplicate (ATB-58)
* feat(web): add canManageThemes permission check and Themes card on admin landing (ATB-58)
* test(web): add negative assertions to admin landing page permission tests
Add missing negative assertions to ensure single-permission tests verify
that unrelated cards are not shown. The Themes card test now asserts that
members/structure/modlog links are absent; the manageCategories, moderatePosts,
banUsers, and lockTopics tests now assert that the themes link is absent.
* test(web): complete themes card assertions across all admin landing tests
Add missing `href="/admin/themes"` assertions to three tests:
- wildcard (*) permission test: assert themes card IS shown
- manageMembers-only test: assert themes card is NOT shown
- manageMembers + moderatePosts combo test: assert themes card is NOT shown
* feat(web): implement GET /admin/themes page — theme cards, policy form, create form (ATB-58)
* fix(web): rename _THEME_PRESETS and log non-404 policy fetch errors (ATB-58)
* feat(web): POST /admin/themes — create theme from preset and redirect (ATB-58)
* feat(web): POST /admin/themes/:rkey/duplicate — proxy duplicate to AppView (ATB-58)
* feat(web): POST /admin/themes/:rkey/delete — proxy delete to AppView with 409 handling (ATB-58)
* feat(web): POST /admin/theme-policy — update theme policy with availability and defaults (ATB-58)
* fix(appview): PUT /theme-policy accepts availableThemes without cid — looks up from DB (ATB-58)
* fix(web): add auth/permission/network tests and 409-specific delete handling (ATB-58)
Add missing unauthenticated, 403, and network-error tests to all four POST
theme routes. Separate the 409 branch in POST /admin/themes/:rkey/delete to
return a web-layer-owned human-friendly message. Strengthen the availableThemes
assertion in the theme-policy success test to verify exact payload shape.
* fix(atb-58): address PR review — CID validation, SyntaxError handling, Bruno seq
- Return 400 (not 200 with cid:"") when availableThemes contains uri-only entries
not found in the themes DB — empty string is not a valid AT Proto strongRef CID
- Wrap Response.json() calls in GET /admin/themes in inner try-catch so upstream
non-JSON responses are caught as parse errors rather than re-thrown as programming
errors via isProgrammingError(SyntaxError)
- Fix Bruno seq conflict: Duplicate Theme seq 4→5, List Themes seq 5→6
* fix(atb-58): block cid:\"\" as invalid strongRef; add DB failure test for needsLookup
- Introduce isMissingCid predicate (typeof !== string || === "") applied to all
three sites: needsLookup check, unresolvedUris filter, resolvedThemes map.
Explicit cid:"" bypassed the previous typeof-only guard and would have been
written verbatim to the PDS as an invalid strongRef CID.
- Add test: cid:"" entry returns 400 (same as absent cid not found in DB)
- Add test: DB select failure during needsLookup returns 500
* docs: add design doc for ATB-57 theme write API endpoints
* docs: add implementation plan for ATB-57 theme write API endpoints
* feat(appview): add manageThemes permission to Admin role (ATB-57)
* feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)
* test(appview): add 401/403/PDS-failure tests for POST /api/admin/themes (ATB-57)
* feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)
* feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)
* test(appview): assert 409 error body in dark-theme default check (ATB-57)
* test(appview): verify deleteRecord called with exact theme args (ATB-57)
* feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)
* test(appview): add theme-policy update path and updatedAt assertions (ATB-57)
* refactor(appview): strengthen theme-policy type guards and test assertions (ATB-57)
* docs(bruno): add Admin Themes collection for ATB-57 write endpoints
* docs: mark ATB-57 plan docs complete, move to docs/plans/complete/
* fix(appview): add 503 ForumAgent-not-authenticated tests; fix Bruno error code docs (ATB-57)
* docs: add ATB-55 theme read API design doc
Records approved design for themes table, theme_policies table,
theme_policy_available_themes join table, firehose indexer configs,
and GET /api/themes + GET /api/themes/:rkey + GET /api/theme-policy endpoints.
* docs: add ATB-55 theme API implementation plan
* feat(db): add themes, theme_policies, theme_policy_available_themes tables
Generate Postgres (0013) and SQLite (0001) migrations for the three new
theme tables. Build @atbb/db to verify schema compiles correctly.
* feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)
* feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)
* docs(bruno): add Themes API collection (ATB-55)
* docs: ATB-52 CSS token extraction design doc
* docs: ATB-52 implementation plan
* test(web): add failing preset completeness tests (ATB-52)
* test(web): improve preset test descriptions (ATB-52)
* feat(web): add neobrutal-light and neobrutal-dark JSON presets with font-size-xs token (ATB-52)
* feat(web): switch base layout to JSON preset import, remove TS preset (ATB-52)
* fix(web): replace all hardcoded CSS values with design tokens in mod and structure UI (ATB-52)
Define two new AT Proto record types for the theming system:
- space.atbb.forum.theme (tid key) — design tokens, color scheme, CSS overrides, font URLs
- space.atbb.forum.themePolicy (literal:self) — available themes, light/dark defaults, user choice toggle
Uses knownValues for colorScheme extensibility and strongRef wrapped in themeRef named def for CID integrity.
README was missing 3 of 5 packages (atproto, cli, logger).
Deployment guide had SESSION_TTL_DAYS default wrong (30 vs actual 7)
and was missing LOG_LEVEL, SEED_DEFAULT_ROLES, DEFAULT_MEMBER_ROLE
from the optional env vars table. Production env example was missing
FORUM_HANDLE and FORUM_PASSWORD variables.
* feat(web): admin mod action log page — /admin/modlog (ATB-48)
* docs: ATB-48 modlog UI implementation plan and completion notes
* fix(web): wrap modlogRes.json() in try-catch for non-JSON AppView responses (ATB-48)
A proxy returning HTML with HTTP 200 would cause Response.json() to throw
SyntaxError, which isProgrammingError() re-throws, producing an unhandled crash
instead of a 500 error page. Wrap with the same pattern used in the members
and role-assignment handlers.
* docs: ATB-46 mod action log endpoint design
Design for GET /api/admin/modlog — paginated mod action audit log with
double users join for moderator and subject handles, and requireAnyPermission
middleware for OR-based permission checks.
* docs: ATB-46 mod action log implementation plan
Step-by-step TDD plan for GET /api/admin/modlog — requireAnyPermission
middleware, Drizzle alias double join, route handler, and Bruno collection.
* feat(appview): add requireAnyPermission middleware (ATB-46)
* test(appview): failing tests for GET /api/admin/modlog (ATB-46)
* feat(appview): GET /api/admin/modlog with double users leftJoin (ATB-46)
* docs(bruno): GET /api/admin/modlog collection (ATB-46)
* docs: move ATB-46 plan docs to complete/
* fix(appview): scope modlog queries to forumDid (ATB-46)
* feat(appview): add uri field to serializeCategory (ATB-47)
* test(web): add failing tests for GET /admin/structure (ATB-47)
* feat(web): add GET /admin/structure page with category/board listing (ATB-47)
* fix(web): use lowercase method="post" on structure page forms (ATB-47)
* test(web): add failing tests for category proxy routes (ATB-47)
* feat(web): add category proxy routes for structure management (ATB-47)
* test(web): add failing tests for board proxy routes (ATB-47)
* feat(web): add board proxy routes for structure management (ATB-47)
* feat(web): add CSS for admin structure management page (ATB-47)
* test(web): add missing network error tests for edit proxy routes (ATB-47)
* fix(web): log errors when boards fetch fails per-category in structure page (ATB-47)
* docs: add completed implementation plan for ATB-47 admin structure UI
* fix(web): validate sort order and fix board delete 409 test (ATB-47)
- parseSortOrder now returns null for negative/non-integer values and
redirects with "Sort order must be a non-negative integer." error;
0 remains the default for empty/missing sort order fields
- Use Number() + Number.isInteger() instead of parseInt() to reject
floats like "1.5" that parseInt would silently truncate
- Add validation tests for negative sort order across all 4 create/edit
handlers (create category, edit category, create board, edit board)
- Fix board delete 409 test mock to use the real AppView error message
("Cannot delete board with posts. Remove all posts first.") and assert
the message appears in the redirect URL, matching category delete test
* test(appview): add failing tests for POST /api/admin/boards (ATB-45)
* test(appview): add ForumAgent not authenticated test for POST /api/admin/boards (ATB-45)
* feat(appview): POST /api/admin/boards create endpoint (ATB-45)
* test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)
* test(appview): add error body assertion to PUT boards malformed JSON test (ATB-45)
* feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)
* test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)
* test(appview): improve DELETE /api/admin/boards/:id test coverage (ATB-45)
* feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)
Pre-flight refuses with 409 if any posts reference the board (via posts.boardId).
Also fixes test error messages to use "Database connection lost" (matching isDatabaseError keywords) for consistent 503 classification.
* docs(bruno): add board management API collection (ATB-45)
* fix(appview): close postgres connection after each admin test to prevent pool exhaustion (ATB-45)
Each createTestContext() call opens a new postgres.js connection pool. With 93
tests in admin.test.ts, the old pools were never closed, exhausting PostgreSQL's
max_connections limit. Fix by calling $client.end() in cleanup() for Postgres.
* docs(plans): move ATB-44 and ATB-45 plan docs to complete/
* test(appview): add missing DB error 503 tests for board endpoints (ATB-45)
- POST /api/admin/boards: add "returns 503 when category lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when board lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when category CID lookup query fails"
(call-count pattern: first select passes, second throws)
* feat(appview): POST /api/admin/categories create endpoint (ATB-44)
* feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)
* test(appview): add malformed JSON test for PUT /api/admin/categories/:id (ATB-44)
* test(appview): add failing tests for DELETE /api/admin/categories/:id (ATB-44)
* feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)
* docs(bruno): add category management API collection (ATB-44)
* fix(appview): use handleRouteError after consolidation refactor (ATB-44)
PR #74 consolidated handleReadError, handleWriteError, and
handleSecurityCheckError into a single handleRouteError. Update the
new category management handlers added in this branch to use the
consolidated name.
* fix(appview): address category endpoint review feedback (ATB-44)
- Tighten sortOrder validation: Number.isInteger() && >= 0 instead of
typeof === "number" (rejects floats, negatives, NaN, Infinity per lexicon
constraint integer, minimum: 0)
- Add 503 "ForumAgent not authenticated" tests for POST, PUT, DELETE
- Add 503 database failure tests for PUT and DELETE category lookup
- Add 403 permission tests for POST, PUT, DELETE
* fix(appview): address final review feedback on category endpoints (ATB-44)
- Fix PUT data loss: putRecord is a full AT Protocol record replacement,
not a patch. Fall back to existing category.description and
category.sortOrder when not provided in request body.
- Add test verifying existing description/sortOrder are preserved on
partial updates (regression test for the data loss bug).
- Add test for DELETE board-count preflight query failure path (503),
using a call-count mock so category lookup succeeds while the second
select throws.
* fix(web): show reply count and last-reply date on board topic listing
The topic listing on board pages always showed "0 replies" (hardcoded)
and used the topic's own createdAt for the date instead of the most
recent reply's timestamp.
- Add getReplyStats() helper: single GROUP BY query computing COUNT() and
MAX(createdAt) per rootPostId for a batch of topic IDs in one round-trip
- Enrich GET /api/boards/:id/topics response with replyCount and lastReplyAt
per topic; fail-open so a stats query failure degrades gracefully to 0/null
- Update TopicResponse interface and TopicRow in boards.tsx to consume the
new fields; date now reflects lastReplyAt ?? createdAt
- Add 5 integration tests covering zero replies, non-banned count, MAX date
accuracy, banned-reply exclusion, and per-topic independence
* fix(web): address code review issues from PR #75
- Add fail-open error path test: mocks getReplyStats DB failure and
asserts 200 response with replyCount 0 / lastReplyAt null / logger.error called
- Fix UI attribution: when lastReplyAt is set, show "last reply X ago"
instead of raw date next to "by {author}" — disambiguates whose action
the timestamp refers to
- Update Bruno collection: add replyCount and lastReplyAt to docs example
and assert blocks for GET /api/boards/:id/topics
- Rebase: merge conflict in boards.ts imports resolved (handleReadError →
handleRouteError from PR #74 refactor)
* fix(web): update setupSuccessfulFetch type to include replyCount and lastReplyAt
The helper's topics array element type was not updated alongside makeTopicsResponse,
causing tsc to reject the new test cases with TS2353. Vitest strips types via esbuild
so it passed locally; tsc in CI caught the mismatch.
handleReadError, handleWriteError, and handleSecurityCheckError had
byte-for-byte identical implementations. Replaced with a single
handleRouteError function, eliminating ~250 lines of duplicated code
across the error handler and its tests. Updated all 10 call sites.
https://claude.ai/code/session_018SH9pay3PqGo9JDdAv3iRj
Co-authored-by: Claude <noreply@anthropic.com>
* feat(web): add canManageRoles session helper (ATB-43)
* feat(appview): include uri in GET /api/admin/roles response (ATB-43)
Add rkey and did fields to the roles DB query, then construct the AT URI
(at://<did>/space.atbb.forum.role/<rkey>) in the response map so the
admin members page dropdown can submit a valid roleUri.
* style(web): add admin member table CSS classes (ATB-43)
* feat(web): add GET /admin/members page and POST proxy route (ATB-43)
* fix(web): add manageRoles permission gate to POST proxy route (ATB-43)
* docs: mark ATB-42 and ATB-43 complete in project plan
* docs: add ATB-43 implementation plan
* fix(web): address PR review feedback on admin members page (ATB-43)
* fix(web): use canManageRoles(auth) instead of hardcoded false in rolesJson error path
* docs: ATB-42 admin panel landing page implementation plan
* feat(web): add hasAnyAdminPermission() helper to session.ts (ATB-42)
* test(web): add hasAnyAdminPermission tests + tighten JSDoc (ATB-42)
* feat(web): add GET /admin landing page with permission-gated nav cards (ATB-42)
* refactor(web): move canManageMembers/canManageCategories/canViewModLog to session.ts (ATB-42)
* test(web): add admin landing page route tests (ATB-42)
* test(web): add missing structure-absent assertions for banUsers/lockTopics (ATB-42)
* style(web): add admin nav grid CSS (ATB-42)
* docs: add admin panel UI preview screenshot (ATB-42)
* fix(web): address minor code review feedback on ATB-42 admin panel
- Use var(--font-size-xl, 2rem) for admin card icon (CSS token consistency)
- Add banUsers and lockTopics test cases for canViewModLog helper
- Move plan doc to docs/plans/complete/
* docs: ATB-34 axe-core a11y testing design
Captures the approved design for adding axe-core + jsdom automated
accessibility tests to apps/web — single consolidated test file,
per-file jsdom environment pragma, one happy-path test per page route.
* docs: ATB-34 axe-core a11y implementation plan
Step-by-step plan: add axe-core + jsdom deps, create consolidated test
file with jsdom env pragma, one happy-path WCAG AA test per page route.
* chore(web): add axe-core, jsdom, vitest as explicit devDependencies (ATB-34)
* test(web): scaffold a11y test file with jsdom environment and module mocks (ATB-34)
* test(web): add WCAG AA accessibility tests for all page routes (ATB-34)
* test(web): suppress document.write deprecation with explanatory comment (ATB-34)
* test(web): use @ts-ignore to suppress deprecated document.write diagnostic (ATB-34)
* docs: move ATB-34 plan docs to complete
* test(web): address PR review feedback on a11y tests (ATB-34)
- Fix DOMParser comment to explain axe isPageContext() mechanism accurately
- Add DOM replacement guard after document.write() to catch silent no-ops
- Wrap axe.run() in try/catch with routeLabel for infrastructure error context
- Add routeLabel param to checkA11y; update all 6 call sites
- Reset canLockTopics/canModeratePosts/canBanUsers in beforeEach
- Add afterEach DOM cleanup via documentElement.innerHTML
- Fix path.startsWith('/topics/1') to exact match '/topics/1?offset=0&limit=25'
- Add form presence guard in new-topic test to catch silent auth fallback
- Update design doc to document DOMParser divergence and its reason
* test(web): strengthen DOM write guard to check html[lang] (ATB-34)
afterEach now removes lang from <html> so the guard in checkA11y can use
html[lang] as proof that document.write() actually executed, rather than
just checking that the <html> element exists (which it always does after
afterEach resets innerHTML without touching attributes).
Adds missing \$type fields to forum and board ref objects written to the PDS,
consistent with the replyRef fix in PR #61. Without \$type, the AT Protocol
runtime guards Post.isForumRef()/isBoardRef() return false, which would silently
break any future indexer refactor that adopts typed guards over optional chaining.
Also adds Post.isForumRef()/isBoardRef() assertions to the corresponding tests,
following the same contract-based pattern established for replyRef.
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
* fix(appview): include role strongRef when upgrading bootstrap membership
upgradeBootstrapMembership was writing a PDS record without a role field.
When the firehose re-indexed the event, membershipConfig.toUpdateValues()
set roleUri = record.role?.role.uri ?? null, overwriting the Owner's
roleUri with null and stripping their permissions on first login.
Fix: look up the existing roleUri in the roles table (parsing the AT URI
to extract did/rkey), then include it as a strongRef in the PDS record —
same pattern used by writeMembershipRecord for new members.
Closes ATB-37
* fix(appview): address review feedback on ATB-37 bootstrap upgrade
- Pass ctx.logger to parseAtUri so malformed roleUris emit structured
warnings at parse time
- Add explicit error log when parseAtUri returns null (data integrity
signal — a stored roleUri that can't be parsed is unexpected)
- Upgrade warn → error in DB failure catch: a failed role lookup during
bootstrap upgrade has the same consequence as the role-not-found path
(firehose will null out roleUri), so both warrant error level
- Add DB failure test for the role lookup catch block
- Add malformed roleUri test (parseAtUri returns null path)
- Add result.created assertions to tests 1 and 2
- Add DB state assertions to all three new bootstrap upgrade tests
Covers member management, forum structure CRUD (categories + boards),
and mod action audit log. Approach A: separate pages per section at
/admin/*.
All structure mutations follow PDS-first pattern (ForumAgent → firehose
→ DB) consistent with the rest of the AppView, not the bootstrap CLI's
dual-write shortcut.
- README: update packages/db to mention SQLite/LibSQL support alongside PostgreSQL
- README: add space.atbb.forum.board to the lexicon table (added by ATB-23)
- Deployment guide: fix stale plan doc path (moved to docs/plans/complete/)
- Deployment guide: update DATABASE_URL to document both PostgreSQL and SQLite formats
- Deployment guide: add SQLite overview paragraph to Section 4 Database Setup
- Deployment guide: update migration verification table list from 7 to 12 tables
- Deployment guide: bump version to 1.2, update date to 2026-02-26
* docs: reorganize completed plans and sync atproto-forum-plan
- Move all 45 completed plan docs from docs/plans/ to docs/plans/complete/
to distinguish finished work from active/upcoming plans
- Mark SQLite support as complete in the Future Roadmap section
- Add show-handles-in-posts as a completed Phase 4 item
- Update docs/plans/ path references to docs/plans/complete/
* docs: trim CLAUDE.md to gotchas-only, add CONTRIBUTING.md
CLAUDE.md's stated purpose is "common mistakes and confusion points."
Several sections had grown into general style guides and process docs
that don't serve that purpose:
- Remove generic testing workflow, quality standards, coverage expectations
→ moved to CONTRIBUTING.md
- Remove defensive programming boilerplate, global error handler template,
error testing examples → discoverable from existing code
- Remove security test coverage requirements and security checklist
→ moved to CONTRIBUTING.md
- Remove Bruno file template and testing workflow → moved to CONTRIBUTING.md
Bug fixes in CLAUDE.md:
- Fix stale `role.permissions.includes()` example → role_permissions join table
- Add `forum.board` to lexicon record ownership list (added by ATB-23)
- Add docs/plans/complete/ convention
CONTRIBUTING.md is a new contributor-facing document covering testing
workflow, error handling patterns (with examples), security checklist,
and Bruno template — content humans read before their first PR.
* docs: consolidate Bruno collections into top-level bruno/
apps/appview/bruno/ was a leftover from when the admin role endpoints
were first written. The top-level bruno/ collection was created later
but never picked up the three files that already existed:
- Admin/Assign Role.bru
- Admin/List Members.bru
- Admin/List Roles.bru
Move all three into bruno/AppView API/Admin/ alongside the backfill
endpoints. Remove the now-redundant apps/appview/bruno/bruno.json and
environments/local.bru.
Add missing environment variables (target_user_did, role_rkey) to both
local.bru and dev.bru so Assign Role.bru resolves correctly.
* feat(db): add @libsql/client dependency for SQLite support
* feat(db): add SQLite schema file
* feat(db): add role_permissions table to Postgres schema (permissions column still present)
* feat(db): URL-based driver detection in createDb (postgres vs SQLite)
* feat(appview): add dialect-specific Drizzle configs and update db scripts
* feat(db): migration 0011 — add role_permissions table
* feat(appview): add migrate-permissions data migration script
Copies permissions from roles.permissions[] into the role_permissions
join table before the column is dropped in migration 0012.
* feat(db): migration 0012 — drop permissions column from roles (data already in role_permissions)
* feat(db): add SQLite migrations (single clean initial migration)
* feat(appview): update checkPermission and getUserRole to use role_permissions table
* feat(appview): update indexer to store role permissions in role_permissions table
- Remove permissions field from roleConfig.toInsertValues and toUpdateValues
(the permissions text[] column no longer exists on the roles table)
- Add optional afterUpsert hook to CollectionConfig for post-row child writes
- Implement roleConfig.afterUpsert to delete+insert into role_permissions
within the same transaction, using .returning({ id }) to get the row id
- Update genericCreate and genericUpdate to call afterUpsert when defined
- Rewrite indexer-roles test assertions to query role_permissions table
- Remove permissions field from direct db.insert(roles) test setup calls
* feat(appview): update admin routes to return permissions from role_permissions table
- GET /api/admin/roles: removed roles.permissions from select, enriches each
role with permissions fetched from role_permissions join table via Promise.all
- GET /api/admin/members/me: removed roles.permissions from join select, adds
roleId to select, then fetches permissions separately from role_permissions
- Updated all test files to remove permissions field from db.insert(roles).values()
calls, replacing with separate db.insert(rolePermissions).values() calls after
capturing the returned role id via .returning({ id: roles.id })
- Fixed admin-backfill.test.ts mock count: checkPermission now makes 3 DB
selects (membership + role + role_permissions) instead of 2
- Updated seed-roles.ts CLI step to insert permissions into role_permissions
table separately instead of the removed permissions column
* feat(appview): update test context to support SQLite via createDb factory
- Replace hardcoded postgres.js/drizzle-orm/postgres-js with createDb() from @atbb/db,
which auto-detects the URL prefix (postgres:// vs file:) and selects the right driver
- Add runSqliteMigrations() to @atbb/db so migrations run via the same drizzle-orm
instance used by createDb(), avoiding cross-package module boundary issues
- For SQLite in-memory mode, upgrade file::memory: to file::memory:?cache=shared so
that @libsql/client's transaction() handoff (which sets #db=null and lazily reconnects)
reattaches to the same shared in-memory database rather than creating an empty new one
- For SQLite cleanDatabase(): delete all rows without WHERE clauses (isolated in-memory DB)
- For Postgres cleanDatabase(): retain existing DID-based patterns
- Remove sql.end() from cleanup() — createDb owns the connection lifecycle
- Fix boards.test.ts and categories.test.ts "returns 503 on database error" tests to use
vi.spyOn() instead of relying on sql.end() to break the connection
- Replace count(*)::int (Postgres-specific cast) with Drizzle's count() helper in admin.ts
so the backfill/:id endpoint works on both Postgres and SQLite
* feat: add docker-compose.sqlite.yml for SQLite deployments
* feat(nix): add database.type option to NixOS module (postgresql | sqlite)
* feat(nix): include SQLite migrations and dialect configs in Nix package output
* feat(devenv): make postgres optional via mkDefault, document SQLite alternative
* refactor(appview): embed data migration into 0012 postgres migration
Copy permissions array into role_permissions join table inside the same
migration that drops the column. ON CONFLICT DO NOTHING keeps the script
idempotent for DBs that already ran migrate-permissions.ts manually.
* fix(appview): guard against out-of-order UPDATE in indexer; populate role_permissions in mod tests
- indexer.ts: add `if (!updated) return` before afterUpsert call so an
out-of-order firehose UPDATE that matches zero rows (CREATE not yet
received) doesn't crash with TypeError on `updated.id`
- mod.test.ts: add rolePermissions inserts for all 4 role setups so
test DB state accurately reflects the permissions each role claims
to have (ban admin: banUsers; lock/unlock mod: lockTopics)
* fix(appview): replace fabricated editPosts permission with real lockTopics in admin test
space.atbb.permission.editPosts is not defined in the lexicon or any
default role. Swap it for space.atbb.permission.lockTopics so the
GET /api/admin/members/me test uses a real permission value and would
catch a regression that silently dropped a real permission from a role.
Audit of plan doc against Linear issues and codebase state as of 2026-02-24.
Completed work added:
- ATB-25: separate bannedByMod column from deleted (Phase 3 bug fix, PR #56)
- ATB-35: strip title from reply records at index time (Phase 3 bug fix, PR #55)
- ATB-26: neobrutal design system, shared components, route stubs (Phase 4, PR #39)
- ATB-33: server-side offset/limit pagination for GET /api/topics/:id (Phase 4, PR #57)
- Fix ATB-30/31 attribution: compose forms are ATB-31, login/logout is ATB-30
Key Risks section:
- Mark PDS write path resolved (OAuth 2.1 + PKCE, ATB-14)
- Mark record deletion resolved (tombstone handling + bannedByMod split, ATB-25)
New Known Issues / Active Backlog section:
- ATB-39 (High): upgradeBootstrapMembership writes PDS record without role field
- ATB-38 (High): seedDefaultRoles partial failure should fail fast
- ATB-41 (Medium): missing $type on forumRef/boardRef in PDS writes
- ATB-34 (Low): axe-core WCAG AA automated tests
- Notes ATB-39/40 are duplicates of ATB-37/38
Future Roadmap:
- Add SQLite support (design approved, docs/plans/2026-02-24-sqlite-support-design.md)
- Update user self-deletion note: deleted_by_user column already in schema (ATB-25)
Designs dual-dialect database support (PostgreSQL + SQLite) with:
- URL-prefix detection in createDb factory
- role_permissions join table replacing permissions text[] array
- Two-phase Postgres migration with data migration script
- NixOS module and devenv changes for optional Postgres service
- Operator upgrade instructions with safety warnings
* docs: add design for storing user handles at login time
* feat: persist user handle to DB during OAuth login so posts show handles
* fix: address PR review comments on handle persistence
Critical:
- Add isProgrammingError guard to upsert catch so TypeErrors are not swallowed
- Add logger.warn assertion to upsert failure test (per CLAUDE.md logging test requirement)
Important:
- Fix upsert to use COALESCE so a null getProfile result never overwrites a good existing handle
- Add warn log when persisting null handle so operators can detect suspended/migrating accounts
- Add test: getProfile returns undefined → null handle written, login still succeeds
- Add test: existing handle preserved when getProfile returns undefined
- Align log severity — upsert failure now uses warn (consistent with membership failure)
- Fix misleading vi.clearAllMocks() comment; fresh ctx is what makes the spy safe to abandon
- Update design doc snippet to match implementation (use extracted handle variable + COALESCE)
Suggestion:
- Add test: TypeError from upsert is re-thrown and causes 500, not silent redirect
- Hardcode getProfile mock return value instead of deriving from DID string split
* fix(appview): add $type to reply ref so indexer resolves rootPostId/parentPostId
Post.isReplyRef() uses AT Protocol's is$typed() runtime guard which requires
$type: "space.atbb.post#replyRef" to be present. Without it the guard returned
false, leaving rootPostId/parentPostId null in the database and breaking reply
threading. Also adds an assertion to the happy-path test that verifies the
record written to the PDS includes the correct $type on the reply ref.
* fix(appview): address PR review feedback on reply ref $type fix
- Use Post.isReplyRef() in route tests instead of $type string literal,
so the actual indexer contract is tested (a typo in the string would
still break isReplyRef() but pass a literal comparison)
- Add isReplyRef() assertion to nested-reply test (creates reply to reply)
- Add regex URI assertions for stronger AT-URI shape validation
- Add indexer happy-path test: correctly-typed reply ref resolves
rootPostId/parentPostId to non-null values
- Upgrade logger.warn → logger.error for missing $type (data corruption,
not a warning — post is silently unreachable in thread navigation)
- Add errorId field to missing $type log entry for operator filtering
- Split outer try block in POST /api/posts: DB lookup and PDS write now
have separate catch blocks with accurate error messages (per CLAUDE.md
Try Block Granularity pattern)
* style(web): make board cards full-width on all screen sizes
Remove responsive grid overrides so boards stack in a single column
at every viewport, freeing up horizontal space for future additions
like topic counts and latest-topic metadata.
* fix(docker,nix): add missing @atbb/logger package to build configs
The @atbb/logger package was added to the workspace but Dockerfile and
nix/package.nix were not updated to include it, causing the Docker build
to fail with missing @opentelemetry/* and hono modules.
Dockerfile: copy packages/logger/package.json in both builder and runtime
stages so pnpm installs its dependencies, and copy logger/dist in the
runtime stage so workspace symlinks resolve at runtime.
nix/package.nix: add packages/logger to the installPhase loop so the
built dist/ and node_modules/ are included in the Nix output derivation.
* set pnpmDeps hash to empty string for updates
* set pnpmDeps hash to the proper hash
* fix(appview): assign default Member role when creating membership on first login
Membership PDS records were written without a role reference, causing the
firehose to index roleUri as null. The permission middleware fails closed on
null roleUri, so all newly-registered users got 403 on every post attempt.
Now looks up the seeded "Member" role and includes it as a strongRef in the
membership record at creation time. Falls back gracefully (no role field) if
the Member role is not yet in the DB.
* fix(appview): address code review feedback on default-member-role fix
- Log ctx.logger.error when Member role not found in DB (operator alert)
- Wrap role lookup in try-catch; log ctx.logger.warn and proceed without
role on transient DB errors, so membership is still created
- Add orderBy(asc(roles.indexedAt)) to make role selection deterministic
when duplicate Member roles exist
- Rename test DIDs to use did:plc:test-* prefix per established cleanup patterns
- Add test asserting logger.error fires when Member role is absent
- Add test asserting membership is created without role on DB error
* fix(appview): re-throw programming errors from role lookup catch block
Per CLAUDE.md error handling standards, TypeError/ReferenceError/SyntaxError
indicate code bugs and must not be silently swallowed. Adds re-throw guard
before the warn log so transient DB errors are handled gracefully while
programming errors surface during development.
Adds test verifying TypeError propagates rather than being caught.
* test(appview): add failing pagination tests for server-side topic pagination (ATB-33)
Add describe.sequential block to topics.test.ts covering GET /api/topics/:id
server-side pagination behavior that does not exist yet. Tests verify total,
offset, limit fields in response, default/explicit pagination, offset/limit
clamping, and empty result sets. All 8 tests fail as expected — implementation
pending.
* test(appview): clarify total semantics in pagination test name (ATB-33)
* feat(appview): add offset/limit pagination to GET /api/topics/:id (ATB-33)
- Parse offset/limit query params (default 25, max 100)
- Run COUNT + paginated SELECT in parallel (matching boards pattern)
- Return total, offset, limit in response alongside paginated replies
- Removes 1000-reply defensive limit in favour of server-controlled pagination
* test(appview): document approximate total semantic and add companion test (ATB-33)
* feat(web): use server-side pagination for topic replies (ATB-33)
- Pass offset/limit to AppView instead of slicing locally
- HTMX partial: forwards ?offset=N&limit=25 to AppView
- Full page: requests ?offset=0&limit=(offset+25) for bookmark support
- Removes TODO(ATB-33) comment
* fix(web): remove duplicate total property in makeTopicResponse test helper (ATB-33)
* chore(web): add clarifying comments for pagination edge cases (ATB-33)
* fix(web): remove invalid JSX comment between attributes (ATB-33)
* docs(bruno): update Get Topic collection with pagination params (ATB-33)
* fix(appview,web): address code review feedback on ATB-33 pagination
Critical fixes:
- Split single try-catch into two: topic query and reply query now have
distinct error messages ("Failed to retrieve topic" vs "Failed to
retrieve replies for topic") per CLAUDE.md try-block granularity rule
- HTMX partial error now returns a retry fragment instead of silently
replacing the Load More button with empty content
- Fix hasMore infinite loop: use `replies.length >= limit` (page-fullness
heuristic) instead of `nextOffset < total`; total is pre-filter and can
cause an infinite loop when in-memory filters zero out all SQL results
- Raise AppView limit cap from 100 to 250 so bookmark displayLimit
(offset + REPLIES_PER_PAGE) no longer gets silently clamped for deep links
- Fix Bruno docs: total is filtered for bannedByMod=false at SQL level,
not "unfiltered"; update description to match inline code comment
Important fixes:
- Remove total from ReplyFragment props (no longer used after hasMore fix)
- Change `total === 0` guard to `initialReplies.length === 0` so EmptyState
renders when all page-1 replies are filtered in-memory; update message to
"No replies to show."
- Add test: bannedByMod=true directly reduces total (proves COUNT query
applies the SQL-level filter)
- Add test: non-numeric offset/limit params default to 0/25
- Strengthen clamps limit=0 test to assert replies are returned, not just
metadata; rename limit cap test to reflect new max of 250
- Add AppView URL assertions to bookmark and HTMX partial web tests
- Update HTMX error test to assert retry fragment content
* fix(atb-33): clean up HTMX retry element and stale pagination comment
- Replace <p>/<button> retry fragment with bare <button> so hx-swap="outerHTML"
replaces the entire error element on retry success (no orphan text node)
- Update stale comment: topics cap is 250 (not 100 like boards) to support bookmarks
* fix(indexer): strip title from reply records at index time (ATB-35)
When a space.atbb.post record carries both a reply ref and a title field,
the indexer now coerces title to null for both INSERT and UPDATE paths.
This enforces the lexicon invariant ("title is omitted for replies") at
the data-ingestion boundary rather than relying on the API or UI to ignore
the field.
ATB-36 is a duplicate of this issue and has been marked accordingly.
* fix(indexer): address PR review feedback (ATB-35)
- Add try-catch to getPostIdByUri, matching the structured error logging
pattern used by all other helper methods
- Warn when reply ref is present but missing $type (rootPostId/parentPostId
will be null; operators now have an observable signal)
- Reaction stub handlers: info → warn (events are being permanently
discarded, not successfully processed)
- Tests: extract createTrackingDb helper to eliminate inline mock duplication
- Tests: add topic-starter title-preserved assertions (right branch of ternary)
- Tests: assert full inserted shape on reply create (rootPostId/parentPostId
null, rootUri/parentUri populated) to document the $type-less behavior
* fix(appview): separate ban enforcement from user-initiated deletes (ATB-25)
- Add bannedByMod column to posts: applyBan/liftBan now use this column
exclusively, so lifting a ban can never resurrect user-deleted content
- Add deletedByUser column and tombstone user-initiated deletes: when a
post delete arrives from the firehose, the row is kept (for FK stability)
but text is replaced with '[user deleted this post]' and deletedByUser
is set to true — personal content is gone, thread structure is preserved
- Remove shared deleted column; all API filters now use bannedByMod=false
- Migrations: 0009 adds banned_by_mod, 0010 drops deleted / adds deleted_by_user
* test: fix schema column list test and strengthen tombstone assertion
- schema.test.ts: update "has expected columns for the unified post model"
to check for bannedByMod/deletedByUser instead of deleted (was missed in
the original commit, causing CI to fail)
- indexer.test.ts: replace weak toHaveBeenCalled() guard with exact
payload assertion per code review — verifies text and deletedByUser are
set, and that bannedByMod/deleted are never touched
* docs: correct genericDelete comment — posts always tombstone, no fallback