AppView in a box as a Vite plugin thing hatk.dev

feat: add user report system with admin review queue

Users can submit reports via dev.hatk.createReport XRPC endpoint,
selecting from the app's defined labels with optional free-text reason.
Reports are stored in a new _reports table (dialect-aware for SQLite/DuckDB).

Admins see a Reports tab in the admin UI to review, apply labels, or
dismiss reports. Open report count appears on the Overview dashboard.

Also fixes the label scaffold template to use defineLabel from $hatk,
and adds the createReport lexicon to hatk new scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+917 -7
+519
docs/plans/2026-03-22-reports-design.md
··· 1 + # User Reports Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Let authenticated users report accounts/records by selecting a label, and give admins a queue to review and act on those reports. 6 + 7 + **Architecture:** New `_reports` SQLite table + db helper functions, a `dev.hatk.createReport` XRPC endpoint registered as a core handler, three admin REST endpoints in server.ts, and a new "Reports" tab in admin.html. 8 + 9 + **Tech Stack:** TypeScript, SQLite (better-sqlite3), vanilla HTML/CSS/JS admin UI 10 + 11 + --- 12 + 13 + ### Task 1: Create the `_reports` table in database init 14 + 15 + **Files:** 16 + - Modify: `packages/hatk/src/database/db.ts:67-127` (inside `initDatabase`, after the `_preferences` table creation) 17 + 18 + **Step 1: Add the table creation SQL** 19 + 20 + After the `_preferences` table block (line ~123) and before the OAuth DDL line, add: 21 + 22 + ```typescript 23 + // Reports table (user-submitted moderation reports) 24 + await run(`CREATE TABLE IF NOT EXISTS _reports ( 25 + id INTEGER PRIMARY KEY AUTOINCREMENT, 26 + subject_uri TEXT NOT NULL, 27 + subject_did TEXT NOT NULL, 28 + label TEXT NOT NULL, 29 + reason TEXT, 30 + reported_by TEXT NOT NULL, 31 + status TEXT NOT NULL DEFAULT 'open', 32 + resolved_by TEXT, 33 + resolved_at TEXT, 34 + created_at TEXT NOT NULL 35 + )`) 36 + await run(`CREATE INDEX IF NOT EXISTS idx_reports_status ON _reports(status)`) 37 + await run(`CREATE INDEX IF NOT EXISTS idx_reports_subject_uri ON _reports(subject_uri)`) 38 + ``` 39 + 40 + **Step 2: Verify the app boots** 41 + 42 + Run: `cd packages/hatk && npx vite build` 43 + Expected: Build succeeds 44 + 45 + --- 46 + 47 + ### Task 2: Add report database helper functions 48 + 49 + **Files:** 50 + - Modify: `packages/hatk/src/database/db.ts` (add new exported functions at the end, before the final closing brace or after the last export) 51 + 52 + **Step 1: Add the helper functions** 53 + 54 + Add these exports to `db.ts`: 55 + 56 + ```typescript 57 + export async function insertReport(report: { 58 + subjectUri: string 59 + subjectDid: string 60 + label: string 61 + reason?: string 62 + reportedBy: string 63 + }): Promise<{ id: number }> { 64 + const createdAt = new Date().toISOString() 65 + await run( 66 + `INSERT INTO _reports (subject_uri, subject_did, label, reason, reported_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)`, 67 + [report.subjectUri, report.subjectDid, report.label, report.reason || null, report.reportedBy, createdAt], 68 + ) 69 + const rows = await all<{ id: number }>(`SELECT last_insert_rowid() as id`) 70 + return { id: rows[0].id } 71 + } 72 + 73 + export async function queryReports(opts: { 74 + status?: string 75 + label?: string 76 + limit?: number 77 + offset?: number 78 + }): Promise<{ reports: any[]; total: number }> { 79 + const conditions: string[] = [] 80 + const params: unknown[] = [] 81 + let idx = 1 82 + 83 + if (opts.status) { 84 + conditions.push(`r.status = $${idx++}`) 85 + params.push(opts.status) 86 + } 87 + if (opts.label) { 88 + conditions.push(`r.label = $${idx++}`) 89 + params.push(opts.label) 90 + } 91 + 92 + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' 93 + const limit = opts.limit || 50 94 + const offset = opts.offset || 0 95 + 96 + const countRows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM _reports r ${where}`, params) 97 + const total = Number(countRows[0]?.count || 0) 98 + 99 + const rows = await all( 100 + `SELECT r.*, rp.handle as reported_by_handle FROM _reports r LEFT JOIN _repos rp ON r.reported_by = rp.did ${where} ORDER BY r.created_at DESC LIMIT $${idx++} OFFSET $${idx++}`, 101 + [...params, limit, offset], 102 + ) 103 + 104 + return { reports: rows, total } 105 + } 106 + 107 + export async function resolveReport( 108 + id: number, 109 + action: 'resolved' | 'dismissed', 110 + resolvedBy: string, 111 + ): Promise<{ subjectUri: string; label: string } | null> { 112 + const rows = await all<{ subject_uri: string; label: string; status: string }>( 113 + `SELECT subject_uri, label, status FROM _reports WHERE id = $1`, 114 + [id], 115 + ) 116 + if (!rows.length) return null 117 + if (rows[0].status !== 'open') return null 118 + 119 + await run(`UPDATE _reports SET status = $1, resolved_by = $2, resolved_at = $3 WHERE id = $4`, [ 120 + action, 121 + resolvedBy, 122 + new Date().toISOString(), 123 + id, 124 + ]) 125 + return { subjectUri: rows[0].subject_uri, label: rows[0].label } 126 + } 127 + 128 + export async function getOpenReportCount(): Promise<number> { 129 + const rows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM _reports WHERE status = 'open'`) 130 + return Number(rows[0]?.count || 0) 131 + } 132 + ``` 133 + 134 + **Step 2: Verify build** 135 + 136 + Run: `cd packages/hatk && npx vite build` 137 + Expected: Build succeeds 138 + 139 + --- 140 + 141 + ### Task 3: Register `dev.hatk.createReport` XRPC handler 142 + 143 + **Files:** 144 + - Modify: `packages/hatk/src/server.ts:27` (add imports) 145 + - Modify: `packages/hatk/src/server.ts:167-203` (inside the `if (oauth)` block, add the new handler) 146 + 147 + **Step 1: Add imports** 148 + 149 + Add `insertReport` to the existing import from `'./database/db.ts'` (line 6-27): 150 + 151 + ```typescript 152 + import { 153 + // ... existing imports ... 154 + insertReport, 155 + } from './database/db.ts' 156 + ``` 157 + 158 + **Step 2: Add the handler** 159 + 160 + Inside the `if (oauth) {` block (after the existing `dev.hatk.uploadBlob` handler, around line 202), add: 161 + 162 + ```typescript 163 + registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => { 164 + if (!viewer) throw new InvalidRequestError('Authentication required') 165 + const body = input as { subject?: any; label?: string; reason?: string } 166 + if (!body.subject) throw new InvalidRequestError('Missing subject') 167 + if (!body.label || typeof body.label !== 'string') throw new InvalidRequestError('Missing or invalid label') 168 + 169 + // Validate label exists in definitions 170 + const defs = getLabelDefinitions() 171 + if (!defs.some((d) => d.identifier === body.label)) { 172 + throw new InvalidRequestError(`Unknown label: ${body.label}`) 173 + } 174 + 175 + // Validate reason length 176 + if (body.reason && body.reason.length > 2000) { 177 + throw new InvalidRequestError('Reason must be 2000 characters or less') 178 + } 179 + 180 + // Determine subject URI and DID 181 + let subjectUri: string 182 + let subjectDid: string 183 + if (body.subject.uri) { 184 + // Record report: { uri, cid } 185 + subjectUri = body.subject.uri 186 + // Extract DID from at:// URI 187 + const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/) 188 + if (!match) throw new InvalidRequestError('Invalid subject URI') 189 + subjectDid = match[1] 190 + } else if (body.subject.did) { 191 + // Account report: { did } 192 + subjectUri = `at://${body.subject.did}` 193 + subjectDid = body.subject.did 194 + } else { 195 + throw new InvalidRequestError('Subject must have uri or did') 196 + } 197 + 198 + const result = await insertReport({ 199 + subjectUri, 200 + subjectDid, 201 + label: body.label, 202 + reason: body.reason, 203 + reportedBy: viewer.did, 204 + }) 205 + 206 + return { 207 + id: result.id, 208 + subject: body.subject, 209 + label: body.label, 210 + reason: body.reason || null, 211 + reportedBy: viewer.did, 212 + createdAt: new Date().toISOString(), 213 + } 214 + }) 215 + ``` 216 + 217 + **Step 3: Verify build** 218 + 219 + Run: `cd packages/hatk && npx vite build` 220 + Expected: Build succeeds 221 + 222 + --- 223 + 224 + ### Task 4: Add admin report endpoints 225 + 226 + **Files:** 227 + - Modify: `packages/hatk/src/server.ts` (add imports + admin routes) 228 + 229 + **Step 1: Add imports** 230 + 231 + Add `queryReports`, `resolveReport`, `getOpenReportCount` to the import from `'./database/db.ts'`: 232 + 233 + ```typescript 234 + import { 235 + // ... existing imports ... 236 + queryReports, 237 + resolveReport, 238 + getOpenReportCount, 239 + } from './database/db.ts' 240 + ``` 241 + 242 + **Step 2: Add the GET /admin/reports endpoint** 243 + 244 + After the existing `GET /admin/repos` handler (around line 670), add: 245 + 246 + ```typescript 247 + // GET /admin/reports — list reports 248 + if (url.pathname === '/admin/reports' && request.method === 'GET') { 249 + const denied = requireAdmin(viewer, acceptEncoding) 250 + if (denied) return denied 251 + const status = url.searchParams.get('status') || 'open' 252 + const label = url.searchParams.get('label') || undefined 253 + const limit = parseInt(url.searchParams.get('limit') || '50') 254 + const offset = parseInt(url.searchParams.get('offset') || '0') 255 + const result = await queryReports({ status, label, limit, offset }) 256 + return withCors(json(result, 200, acceptEncoding)) 257 + } 258 + ``` 259 + 260 + **Step 3: Add the POST /admin/reports/resolve endpoint** 261 + 262 + Right after the GET handler above: 263 + 264 + ```typescript 265 + // POST /admin/reports/resolve — resolve or dismiss a report 266 + if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') { 267 + const denied = requireAdmin(viewer, acceptEncoding) 268 + if (denied) return denied 269 + const { id, action } = JSON.parse(await request.text()) 270 + if (!id || !action) return withCors(jsonError(400, 'Missing id or action', acceptEncoding)) 271 + if (action !== 'resolve' && action !== 'dismiss') 272 + return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding)) 273 + 274 + const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer!.did) 275 + if (!report) return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding)) 276 + 277 + // If resolving, apply the label 278 + if (action === 'resolve') { 279 + await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]) 280 + } 281 + return withCors(json({ ok: true }, 200, acceptEncoding)) 282 + } 283 + ``` 284 + 285 + **Step 4: Add openReports to the GET /admin/info response** 286 + 287 + In the existing `/admin/info` handler (around line 619-636), modify the return to include the open report count. Change: 288 + 289 + ```typescript 290 + return withCors( 291 + json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding), 292 + ) 293 + ``` 294 + 295 + to: 296 + 297 + ```typescript 298 + const openReports = await getOpenReportCount() 299 + return withCors( 300 + json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts, openReports }, 200, acceptEncoding), 301 + ) 302 + ``` 303 + 304 + **Step 5: Verify build** 305 + 306 + Run: `cd packages/hatk && npx vite build` 307 + Expected: Build succeeds 308 + 309 + --- 310 + 311 + ### Task 5: Add Reports tab to admin UI 312 + 313 + **Files:** 314 + - Modify: `packages/hatk/public/admin.html` 315 + 316 + **Step 1: Add the Reports tab button to desktop nav** 317 + 318 + Find the `<nav class="tabs">` element (around line 1202) and add the Reports button: 319 + 320 + ```html 321 + <nav class="tabs"> 322 + <button class="tab active" data-tab="overview">Overview</button> 323 + <button class="tab" data-tab="repos">Repos</button> 324 + <button class="tab" data-tab="content">Content</button> 325 + <button class="tab" data-tab="reports">Reports</button> 326 + </nav> 327 + ``` 328 + 329 + **Step 2: Add the Reports button to mobile bottom nav** 330 + 331 + Find the `<div class="bottom-nav-track">` element (around line 1284) and add: 332 + 333 + ```html 334 + <button class="bnav-btn" data-tab="reports">Reports</button> 335 + ``` 336 + 337 + **Step 3: Add the Reports tab panel** 338 + 339 + After the content tab panel closing `</div>` (before `<!-- Bottom nav (mobile) -->`), add: 340 + 341 + ```html 342 + <!-- Reports --> 343 + <div class="tab-panel" id="panel-reports"> 344 + <div class="search-bar"> 345 + <select class="search-input" id="reports-status" style="max-width: 200px"> 346 + <option value="open">Open</option> 347 + <option value="resolved">Resolved</option> 348 + <option value="dismissed">Dismissed</option> 349 + </select> 350 + <select class="search-input" id="reports-label-filter" style="max-width: 200px"> 351 + <option value="">All labels</option> 352 + </select> 353 + </div> 354 + <div id="reports-results"></div> 355 + </div> 356 + ``` 357 + 358 + **Step 4: Add the tab activation case** 359 + 360 + In the `activateTab` function (around line 1462), add after `if (tab === 'content') loadContent()`: 361 + 362 + ```javascript 363 + if (tab === 'reports') loadReports() 364 + ``` 365 + 366 + **Step 5: Add the loadReports function and rendering** 367 + 368 + Before the closing `</script>` tag, add: 369 + 370 + ```javascript 371 + // ── Reports ── 372 + 373 + const reportsPage = { limit: 50, offset: 0 } 374 + 375 + function populateReportsLabelFilter() { 376 + const select = document.getElementById('reports-label-filter') 377 + select.innerHTML = '<option value="">All labels</option>' + 378 + labelDefinitions.map(d => `<option value="${d.identifier}">${d.identifier}</option>`).join('') 379 + } 380 + 381 + document.getElementById('reports-status').addEventListener('change', () => { 382 + reportsPage.offset = 0 383 + loadReports() 384 + }) 385 + document.getElementById('reports-label-filter').addEventListener('change', () => { 386 + reportsPage.offset = 0 387 + loadReports() 388 + }) 389 + 390 + async function loadReports() { 391 + populateReportsLabelFilter() 392 + const status = document.getElementById('reports-status').value 393 + const label = document.getElementById('reports-label-filter').value 394 + const container = document.getElementById('reports-results') 395 + container.innerHTML = '<div class="loading">Loading</div>' 396 + try { 397 + let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}` 398 + if (label) url += `&label=${encodeURIComponent(label)}` 399 + const result = await api(url) 400 + renderReports(result.reports || [], result.total) 401 + } catch (e) { 402 + container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>` 403 + } 404 + } 405 + 406 + function renderReports(reports, total) { 407 + const container = document.getElementById('reports-results') 408 + if (!reports.length) { 409 + container.innerHTML = '<div class="empty-state">No reports found</div>' 410 + return 411 + } 412 + 413 + const showPagination = total != null && total > reportsPage.limit 414 + const paginationHtml = showPagination ? ` 415 + <div class="pagination"> 416 + <span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span> 417 + <div class="pagination-buttons"> 418 + <button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button> 419 + <button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button> 420 + </div> 421 + </div> 422 + ` : '' 423 + 424 + const countLabel = total != null 425 + ? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}` 426 + : `${reports.length} result${reports.length !== 1 ? 's' : ''}` 427 + 428 + const isOpen = document.getElementById('reports-status').value === 'open' 429 + 430 + container.innerHTML = ` 431 + <div class="card"> 432 + <div class="result-count">${countLabel}</div> 433 + ${reports.map(r => { 434 + const reporterDisplay = r.reported_by_handle ? `@${escapeHtml(r.reported_by_handle)}` : escapeHtml(r.reported_by) 435 + const date = new Date(r.created_at).toLocaleString() 436 + return `<div class="record-card"> 437 + <div class="record-header"> 438 + <div class="record-meta"> 439 + <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div> 440 + <div class="record-summary"> 441 + <span class="label-tag">${escapeHtml(r.label)}</span> 442 + reported by ${reporterDisplay} &middot; ${date} 443 + </div> 444 + ${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''} 445 + ${!isOpen ? `<div class="record-summary" style="margin-top:0.25rem;opacity:0.6">${escapeHtml(r.status)} ${r.resolved_by ? `by ${escapeHtml(r.resolved_by)}` : ''}</div>` : ''} 446 + </div> 447 + ${isOpen ? `<div class="record-actions"> 448 + <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="resolve" style="background:var(--accent);color:white">Apply Label</button> 449 + <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button> 450 + </div>` : ''} 451 + </div> 452 + </div>` 453 + }).join('')} 454 + ${paginationHtml} 455 + </div> 456 + ` 457 + 458 + container.querySelectorAll('[data-reports-page="prev"]').forEach(b => { 459 + b.addEventListener('click', () => { reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit); loadReports() }) 460 + }) 461 + container.querySelectorAll('[data-reports-page="next"]').forEach(b => { 462 + b.addEventListener('click', () => { reportsPage.offset += reportsPage.limit; loadReports() }) 463 + }) 464 + 465 + container.querySelectorAll('[data-action="resolve-report"]').forEach(btn => { 466 + btn.addEventListener('click', async () => { 467 + const action = btn.dataset.resolve 468 + try { 469 + await api('/admin/reports/resolve', { 470 + method: 'POST', 471 + headers: { 'Content-Type': 'application/json' }, 472 + body: JSON.stringify({ id: parseInt(btn.dataset.id), action }), 473 + }) 474 + toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success') 475 + loadReports() 476 + } catch (e) { 477 + toast(e.message, 'error') 478 + } 479 + }) 480 + }) 481 + } 482 + ``` 483 + 484 + **Step 6: Add report badge to overview** 485 + 486 + In the `loadOverview` function, after the existing status cards are rendered, add the open reports count. Find where `info` is used in `loadOverview` and add after the status cards rendering: 487 + 488 + In the overview section, the open reports count will already be available from the `/admin/info` response as `info.openReports`. Add a card showing the count. Find the status cards rendering and append: 489 + 490 + ```javascript 491 + if (info.openReports > 0) { 492 + statusCards.innerHTML += `<div class="stat-card" style="cursor:pointer" onclick="activateTab('reports')"><div class="stat-value">${info.openReports}</div><div class="stat-label">Open Reports</div></div>` 493 + } 494 + ``` 495 + 496 + **Step 7: Verify build** 497 + 498 + Run: `cd packages/hatk && npx vite build` 499 + Expected: Build succeeds 500 + 501 + --- 502 + 503 + ### Task 6: Final verification 504 + 505 + **Step 1: Verify the full build** 506 + 507 + Run: `cd packages/hatk && npx vite build` 508 + Expected: Build succeeds with no errors 509 + 510 + **Step 2: Commit** 511 + 512 + ```bash 513 + git add packages/hatk/src/database/db.ts packages/hatk/src/server.ts packages/hatk/public/admin.html 514 + git commit -m "feat: add user report system with admin review queue 515 + 516 + Users can submit reports via dev.hatk.createReport XRPC endpoint, 517 + selecting from the app's defined labels. Admins see a Reports tab 518 + in the admin UI to review, apply labels, or dismiss reports." 519 + ```
+133
packages/hatk/public/admin.html
··· 1203 1203 <button class="tab active" data-tab="overview">Overview</button> 1204 1204 <button class="tab" data-tab="repos">Repos</button> 1205 1205 <button class="tab" data-tab="content">Content</button> 1206 + <button class="tab" data-tab="reports">Reports</button> 1206 1207 </nav> 1207 1208 1208 1209 <!-- Overview --> ··· 1277 1278 <div class="loading">Loading</div> 1278 1279 </div> 1279 1280 </div> 1281 + 1282 + <!-- Reports --> 1283 + <div class="tab-panel" id="panel-reports"> 1284 + <div class="search-bar"> 1285 + <select class="search-input" id="reports-status" style="max-width: 200px"> 1286 + <option value="open">Open</option> 1287 + <option value="resolved">Resolved</option> 1288 + <option value="dismissed">Dismissed</option> 1289 + </select> 1290 + <select class="search-input" id="reports-label-filter" style="max-width: 200px"> 1291 + <option value="">All labels</option> 1292 + </select> 1293 + </div> 1294 + <div id="reports-results"></div> 1295 + </div> 1280 1296 </div> 1281 1297 1282 1298 <!-- Bottom nav (mobile) --> ··· 1285 1301 <button class="bnav-btn active" data-tab="overview">Overview</button> 1286 1302 <button class="bnav-btn" data-tab="repos">Repos</button> 1287 1303 <button class="bnav-btn" data-tab="content">Content</button> 1304 + <button class="bnav-btn" data-tab="reports">Reports</button> 1288 1305 </div> 1289 1306 </div> 1290 1307 </div> ··· 1468 1485 if (tab === 'overview') loadOverview() 1469 1486 if (tab === 'repos') loadRepos() 1470 1487 if (tab === 'content') loadContent() 1488 + if (tab === 'reports') loadReports() 1471 1489 if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' }) 1472 1490 } 1473 1491 ··· 1494 1512 <div class="stat-card"><div class="stat-label">Pending</div><div class="stat-value yellow">${fmt(repoStatuses.pending)}</div></div> 1495 1513 <div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${fmt(repoStatuses.failed)}</div></div> 1496 1514 <div class="stat-card"><div class="stat-label">Taken Down</div><div class="stat-value red">${fmt(repoStatuses.takendown)}</div></div> 1515 + ${info.openReports > 0 ? `<div class="stat-card" style="cursor:pointer" onclick="activateTab('reports')"><div class="stat-label">Open Reports</div><div class="stat-value yellow">${fmt(info.openReports)}</div></div>` : ''} 1497 1516 ` 1498 1517 1499 1518 const collectionCards = document.getElementById('collection-cards') ··· 2101 2120 toast(`Applied: ${val}`, 'success') 2102 2121 sel.value = '' 2103 2122 loadContent() 2123 + } catch (e) { 2124 + toast(e.message, 'error') 2125 + } 2126 + }) 2127 + }) 2128 + } 2129 + 2130 + // ── Reports ── 2131 + 2132 + const reportsPage = { limit: 50, offset: 0 } 2133 + 2134 + function populateReportsLabelFilter() { 2135 + const select = document.getElementById('reports-label-filter') 2136 + const current = select.value 2137 + select.innerHTML = '<option value="">All labels</option>' + 2138 + labelDefinitions.map(d => `<option value="${d.identifier}">${d.identifier}</option>`).join('') 2139 + select.value = current 2140 + } 2141 + 2142 + document.getElementById('reports-status').addEventListener('change', () => { 2143 + reportsPage.offset = 0 2144 + loadReports() 2145 + }) 2146 + document.getElementById('reports-label-filter').addEventListener('change', () => { 2147 + reportsPage.offset = 0 2148 + loadReports() 2149 + }) 2150 + 2151 + async function loadReports() { 2152 + populateReportsLabelFilter() 2153 + const status = document.getElementById('reports-status').value 2154 + const label = document.getElementById('reports-label-filter').value 2155 + const container = document.getElementById('reports-results') 2156 + container.innerHTML = '<div class="loading">Loading</div>' 2157 + try { 2158 + let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}` 2159 + if (label) url += `&label=${encodeURIComponent(label)}` 2160 + const result = await api(url) 2161 + renderReports(result.reports || [], result.total) 2162 + } catch (e) { 2163 + container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>` 2164 + } 2165 + } 2166 + 2167 + function renderReports(reports, total) { 2168 + const container = document.getElementById('reports-results') 2169 + if (!reports.length) { 2170 + container.innerHTML = '<div class="empty-state">No reports found</div>' 2171 + return 2172 + } 2173 + 2174 + const showPagination = total != null && total > reportsPage.limit 2175 + const paginationHtml = showPagination ? ` 2176 + <div class="pagination"> 2177 + <span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span> 2178 + <div class="pagination-buttons"> 2179 + <button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button> 2180 + <button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button> 2181 + </div> 2182 + </div> 2183 + ` : '' 2184 + 2185 + const countLabel = total != null 2186 + ? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}` 2187 + : `${reports.length} result${reports.length !== 1 ? 's' : ''}` 2188 + 2189 + const isOpen = document.getElementById('reports-status').value === 'open' 2190 + 2191 + container.innerHTML = ` 2192 + <div class="card"> 2193 + <div class="result-count">${countLabel}</div> 2194 + ${reports.map(r => { 2195 + const reporterDisplay = r.reported_by_handle ? `@${escapeHtml(r.reported_by_handle)}` : escapeHtml(r.reported_by) 2196 + const date = new Date(r.created_at).toLocaleString() 2197 + return `<div class="record-card"> 2198 + <div class="record-header"> 2199 + <div class="record-meta"> 2200 + <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div> 2201 + <div class="record-summary"> 2202 + <span class="label-tag">${escapeHtml(r.label)}</span> 2203 + reported by ${reporterDisplay} &middot; ${date} 2204 + </div> 2205 + ${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''} 2206 + ${!isOpen ? `<div class="record-summary" style="margin-top:0.25rem;opacity:0.6">${escapeHtml(r.status)}${r.resolved_by ? ` by ${escapeHtml(r.resolved_by)}` : ''}</div>` : ''} 2207 + </div> 2208 + ${isOpen ? `<div class="record-actions"> 2209 + <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="resolve" style="background:var(--accent);color:white">Apply Label</button> 2210 + <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button> 2211 + </div>` : ''} 2212 + </div> 2213 + </div>` 2214 + }).join('')} 2215 + ${paginationHtml} 2216 + </div> 2217 + ` 2218 + 2219 + container.querySelectorAll('[data-reports-page="prev"]').forEach(b => { 2220 + b.addEventListener('click', () => { reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit); loadReports() }) 2221 + }) 2222 + container.querySelectorAll('[data-reports-page="next"]').forEach(b => { 2223 + b.addEventListener('click', () => { reportsPage.offset += reportsPage.limit; loadReports() }) 2224 + }) 2225 + 2226 + container.querySelectorAll('[data-action="resolve-report"]').forEach(btn => { 2227 + btn.addEventListener('click', async () => { 2228 + const action = btn.dataset.resolve 2229 + try { 2230 + await api('/admin/reports/resolve', { 2231 + method: 'POST', 2232 + headers: { 'Content-Type': 'application/json' }, 2233 + body: JSON.stringify({ id: parseInt(btn.dataset.id), action }), 2234 + }) 2235 + toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success') 2236 + loadReports() 2104 2237 } catch (e) { 2105 2238 toast(e.message, 'error') 2106 2239 }
+64
packages/hatk/src/cli.ts
··· 795 795 ) 796 796 797 797 writeFileSync( 798 + join(coreLexDir, 'createReport.json'), 799 + JSON.stringify( 800 + { 801 + lexicon: 1, 802 + id: 'dev.hatk.createReport', 803 + defs: { 804 + main: { 805 + type: 'procedure', 806 + description: 'Report an account or record for moderation review.', 807 + input: { 808 + encoding: 'application/json', 809 + schema: { 810 + type: 'object', 811 + required: ['subject', 'label'], 812 + properties: { 813 + subject: { 814 + type: 'union', 815 + description: 'The account or record being reported.', 816 + refs: ['#repoRef', '#strongRef'], 817 + }, 818 + label: { type: 'string', description: 'Label identifier for the report reason.' }, 819 + reason: { type: 'string', maxLength: 2000, description: 'Optional free-text explanation.' }, 820 + }, 821 + }, 822 + }, 823 + output: { 824 + encoding: 'application/json', 825 + schema: { 826 + type: 'object', 827 + required: ['id', 'subject', 'label', 'reportedBy', 'createdAt'], 828 + properties: { 829 + id: { type: 'integer' }, 830 + subject: { type: 'unknown' }, 831 + label: { type: 'string' }, 832 + reason: { type: 'string' }, 833 + reportedBy: { type: 'string', format: 'did' }, 834 + createdAt: { type: 'string', format: 'datetime' }, 835 + }, 836 + }, 837 + }, 838 + }, 839 + repoRef: { 840 + type: 'object', 841 + required: ['did'], 842 + properties: { 843 + did: { type: 'string', format: 'did' }, 844 + }, 845 + }, 846 + strongRef: { 847 + type: 'object', 848 + required: ['uri', 'cid'], 849 + properties: { 850 + uri: { type: 'string', format: 'at-uri' }, 851 + cid: { type: 'string', format: 'cid' }, 852 + }, 853 + }, 854 + }, 855 + }, 856 + null, 857 + 2, 858 + ) + '\n', 859 + ) 860 + 861 + writeFileSync( 798 862 join(dir, 'seeds', 'seed.ts'), 799 863 loadTemplate('seed.tpl', ''), 800 864 )
+114 -2
packages/hatk/src/database/db.ts
··· 122 122 PRIMARY KEY (did, key) 123 123 )`) 124 124 125 + // Reports table (user-submitted moderation reports) 126 + if (dialect.supportsSequences) { 127 + await run(`CREATE SEQUENCE IF NOT EXISTS _reports_seq START 1`) 128 + await run(`CREATE TABLE IF NOT EXISTS _reports ( 129 + id INTEGER PRIMARY KEY DEFAULT nextval('_reports_seq'), 130 + subject_uri TEXT NOT NULL, 131 + subject_did TEXT NOT NULL, 132 + label TEXT NOT NULL, 133 + reason TEXT, 134 + reported_by TEXT NOT NULL, 135 + status TEXT NOT NULL DEFAULT 'open', 136 + resolved_by TEXT, 137 + resolved_at ${dialect.timestampType}, 138 + created_at ${dialect.timestampType} NOT NULL 139 + )`) 140 + } else { 141 + await run(`CREATE TABLE IF NOT EXISTS _reports ( 142 + id INTEGER PRIMARY KEY AUTOINCREMENT, 143 + subject_uri TEXT NOT NULL, 144 + subject_did TEXT NOT NULL, 145 + label TEXT NOT NULL, 146 + reason TEXT, 147 + reported_by TEXT NOT NULL, 148 + status TEXT NOT NULL DEFAULT 'open', 149 + resolved_by TEXT, 150 + resolved_at TEXT, 151 + created_at TEXT NOT NULL 152 + )`) 153 + } 154 + await run(`CREATE INDEX IF NOT EXISTS idx_reports_status ON _reports(status)`) 155 + await run(`CREATE INDEX IF NOT EXISTS idx_reports_subject_uri ON _reports(subject_uri)`) 156 + 125 157 // OAuth tables 126 158 await port.executeMultiple(OAUTH_DDL) 127 159 } ··· 765 797 src: row.src, 766 798 uri: row.uri, 767 799 val: row.val, 768 - neg: row.neg, 800 + neg: !!row.neg, 769 801 cts: normalizeValue(row.cts), 770 - exp: row.exp ? String(row.exp) : null, 802 + ...(row.exp ? { exp: String(row.exp) } : {}), 771 803 }) 772 804 } 773 805 return result ··· 1681 1713 const rows = await all<{ did: string }>(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids) 1682 1714 return new Set(rows.map((r) => r.did)) 1683 1715 } 1716 + 1717 + export async function insertReport(report: { 1718 + subjectUri: string 1719 + subjectDid: string 1720 + label: string 1721 + reason?: string 1722 + reportedBy: string 1723 + }): Promise<{ id: number }> { 1724 + const createdAt = new Date().toISOString() 1725 + const rows = await all<{ id: number }>( 1726 + `INSERT INTO _reports (subject_uri, subject_did, label, reason, reported_by, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, 1727 + [report.subjectUri, report.subjectDid, report.label, report.reason || null, report.reportedBy, createdAt], 1728 + ) 1729 + return { id: rows[0].id } 1730 + } 1731 + 1732 + export async function queryReports(opts: { 1733 + status?: string 1734 + label?: string 1735 + limit?: number 1736 + offset?: number 1737 + }): Promise<{ reports: any[]; total: number }> { 1738 + const conditions: string[] = [] 1739 + const params: unknown[] = [] 1740 + let idx = 1 1741 + 1742 + if (opts.status) { 1743 + conditions.push(`r.status = $${idx++}`) 1744 + params.push(opts.status) 1745 + } 1746 + if (opts.label) { 1747 + conditions.push(`r.label = $${idx++}`) 1748 + params.push(opts.label) 1749 + } 1750 + 1751 + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' 1752 + const limit = opts.limit || 50 1753 + const offset = opts.offset || 0 1754 + 1755 + const countRows = await all<{ count: number }>( 1756 + `SELECT ${dialect.countAsInteger} as count FROM _reports r ${where}`, 1757 + params, 1758 + ) 1759 + const total = Number(countRows[0]?.count || 0) 1760 + 1761 + const rows = await all( 1762 + `SELECT r.*, rp.handle as reported_by_handle FROM _reports r LEFT JOIN _repos rp ON r.reported_by = rp.did ${where} ORDER BY r.created_at DESC LIMIT $${idx++} OFFSET $${idx++}`, 1763 + [...params, limit, offset], 1764 + ) 1765 + 1766 + return { reports: rows, total } 1767 + } 1768 + 1769 + export async function resolveReport( 1770 + id: number, 1771 + action: 'resolved' | 'dismissed', 1772 + resolvedBy: string, 1773 + ): Promise<{ subjectUri: string; label: string } | null> { 1774 + const rows = await all<{ subject_uri: string; label: string; status: string }>( 1775 + `SELECT subject_uri, label, status FROM _reports WHERE id = $1`, 1776 + [id], 1777 + ) 1778 + if (!rows.length) return null 1779 + if (rows[0].status !== 'open') return null 1780 + 1781 + await run(`UPDATE _reports SET status = $1, resolved_by = $2, resolved_at = $3 WHERE id = $4`, [ 1782 + action, 1783 + resolvedBy, 1784 + new Date().toISOString(), 1785 + id, 1786 + ]) 1787 + return { subjectUri: rows[0].subject_uri, label: rows[0].label } 1788 + } 1789 + 1790 + export async function getOpenReportCount(): Promise<number> { 1791 + const rows = await all<{ count: number }>( 1792 + `SELECT ${dialect.countAsInteger} as count FROM _reports WHERE status = 'open'`, 1793 + ) 1794 + return Number(rows[0]?.count || 0) 1795 + }
+83 -1
packages/hatk/src/server.ts
··· 24 24 getRepoHandle, 25 25 getPreferences, 26 26 putPreference, 27 + insertReport, 28 + queryReports, 29 + resolveReport, 30 + getOpenReportCount, 27 31 } from './database/db.ts' 28 32 import { executeFeed, listFeeds } from './feeds.ts' 29 33 import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from './xrpc.ts' ··· 199 203 registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => { 200 204 if (!viewer) throw new InvalidRequestError('Authentication required') 201 205 return pdsUploadBlob(oauth, viewer, input as any, 'application/octet-stream') 206 + }) 207 + 208 + registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => { 209 + if (!viewer) throw new InvalidRequestError('Authentication required') 210 + const body = input as { subject?: any; label?: string; reason?: string } 211 + if (!body.subject) throw new InvalidRequestError('Missing subject') 212 + if (!body.label || typeof body.label !== 'string') throw new InvalidRequestError('Missing or invalid label') 213 + 214 + const defs = getLabelDefinitions() 215 + if (!defs.some((d) => d.identifier === body.label)) { 216 + throw new InvalidRequestError(`Unknown label: ${body.label}`) 217 + } 218 + 219 + if (body.reason && body.reason.length > 2000) { 220 + throw new InvalidRequestError('Reason must be 2000 characters or less') 221 + } 222 + 223 + let subjectUri: string 224 + let subjectDid: string 225 + if (body.subject.uri) { 226 + subjectUri = body.subject.uri 227 + const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/) 228 + if (!match) throw new InvalidRequestError('Invalid subject URI') 229 + subjectDid = match[1] 230 + } else if (body.subject.did) { 231 + subjectUri = `at://${body.subject.did}` 232 + subjectDid = body.subject.did 233 + } else { 234 + throw new InvalidRequestError('Subject must have uri or did') 235 + } 236 + 237 + const result = await insertReport({ 238 + subjectUri, 239 + subjectDid, 240 + label: body.label, 241 + reason: body.reason, 242 + reportedBy: viewer.did, 243 + }) 244 + 245 + return { 246 + id: result.id, 247 + subject: body.subject, 248 + label: body.label, 249 + reason: body.reason || null, 250 + reportedBy: viewer.did, 251 + createdAt: new Date().toISOString(), 252 + } 202 253 }) 203 254 } 204 255 } ··· 630 681 heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`, 631 682 external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`, 632 683 } 684 + const openReports = await getOpenReportCount() 633 685 return withCors( 634 - json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding), 686 + json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts, openReports }, 200, acceptEncoding), 635 687 ) 688 + } 689 + 690 + // GET /admin/reports — list reports 691 + if (url.pathname === '/admin/reports' && request.method === 'GET') { 692 + const denied = requireAdmin(viewer, acceptEncoding) 693 + if (denied) return denied 694 + const status = url.searchParams.get('status') || 'open' 695 + const label = url.searchParams.get('label') || undefined 696 + const limit = parseInt(url.searchParams.get('limit') || '50') 697 + const offset = parseInt(url.searchParams.get('offset') || '0') 698 + const result = await queryReports({ status, label, limit, offset }) 699 + return withCors(json(result, 200, acceptEncoding)) 700 + } 701 + 702 + // POST /admin/reports/resolve — resolve or dismiss a report 703 + if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') { 704 + const denied = requireAdmin(viewer, acceptEncoding) 705 + if (denied) return denied 706 + const { id, action } = JSON.parse(await request.text()) 707 + if (!id || !action) return withCors(jsonError(400, 'Missing id or action', acceptEncoding)) 708 + if (action !== 'resolve' && action !== 'dismiss') 709 + return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding)) 710 + 711 + const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer!.did) 712 + if (!report) return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding)) 713 + 714 + if (action === 'resolve') { 715 + await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]) 716 + } 717 + return withCors(json({ ok: true }, 200, acceptEncoding)) 636 718 } 637 719 638 720 // GET /admin/info/:did — repo status info
+4 -4
packages/hatk/src/templates/label.tpl
··· 1 - import type { LabelRuleContext } from '@hatk/hatk/labels' 1 + import { defineLabel } from '$hatk' 2 2 3 - export default { 3 + export default defineLabel({ 4 4 definition: { 5 5 identifier: '{{name}}', 6 6 severity: 'inform', ··· 8 8 defaultSetting: 'warn', 9 9 locales: [{ lang: 'en', name: '{{Name}}', description: 'Description here' }], 10 10 }, 11 - async evaluate(ctx: LabelRuleContext) { 11 + async evaluate(ctx) { 12 12 // Return array of label identifiers to apply, or empty array 13 13 return [] 14 14 }, 15 - } 15 + })