.claude/skills/v2-route-audit/SKILL.md
v2-route-audit
npx skillsauth add timelessco/recollect v2-route-auditInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Post-implementation audit for v2 API route observability. Runs 10 checks, reports a checklist, and offers to fix gaps.
For v2 route authoring rules (handler composition, error handling, schemas, response patterns), see .claude/rules/api-v2.md.
Read references/patterns.md for canonical examples and field naming conventions.
| User says | Action |
|-----------|--------|
| "audit this route" / "check wide events" / "ctx.fields audit" | Phases 1-3 (audit + report) |
| "fix wide events" / "enrich this route" / "add missing fields" | Phases 1-4 (audit + fix) |
| "what fields should I add" | Show Route Type Guidance table |
| "PII check" / "is this logging PII" | Run Check 5 only |
| "audit all routes" / "batch audit" | Run all checks on every src/app/api/v2/**/route.ts |
Glob src/app/api/v2/**/route.ts and ask which routewithAuth | withPublic | withRawBody | withSecret (grep the import)GET | POST | PATCH | PUT | DELETE (grep the export)after(async)queue_name or processImageQueue)Run all 10 checks against the route file. For each, determine PASS/FAIL/WARN/N/A.
getServerContext importgrep 'import.*getServerContext.*from.*server-context' <file>
Compare line numbers: first ctx.fields.*_id or ctx.fields.user_id assignment vs first
supabase.from( / supabase.rpc( / fetch( / createApiClient( call.
Entity ID fields: user_id, bookmark_id, category_id, tag_id, shared_category_id,
queue_name, msg_id, url (when it's the subject of the operation).
Look for ctx.fields.* assignments that reflect results: booleans (*_completed, *_deleted,
*_updated, *_sent, *_failed, found), counts (*_count, processed_count,
bookmarks_returned), or detail fields (content_type, provider).
ctx.fields assignmentsCount distinct ctx.fields.FIELDNAME assignments (not counting duplicates in different branches).
ctx.fieldsScan for exact patterns (avoid false positives on has_email, username_length):
ctx.fields.email = (not has_email)ctx.fields.username = (not username_updated or username_length)ctx.fields.recipient_emailctx.fields.collaboration_emailctx.fields.passwordctx.fields.token = (not has_token)ctx.fields.access_tokenctx.fields.refresh_tokenctx.fields.api_key = (not has_api_key)PII replacements:
| Raw PII | Safe alternative |
|---------|------------------|
| email | has_email = Boolean(email) |
| username | username_length = username.length |
| recipient_email | recipient_count = emailList.length |
| collaboration_email | has_collaboration_email = Boolean(addr) |
| api_key | has_api_key = Boolean(apiKey) |
console.* callsgrep 'console\.\(log\|warn\|error\)(' <file>
after() blocksgrep 'Sentry' <file>
logger.warn pattern in after() catcheslogger.warn callsIf the route has logger.warn( calls (typically in after() catch blocks):
Check error field uses: error instanceof Error ? error.message : String(error)
Check message format: "[route-name] after() ..." with route identifier prefix
PASS: all logger.warn calls follow the pattern
FAIL: logger.warn with raw error object or missing error formatting
N/A: no logger.warn calls in the file
Scan all catch blocks that rethrow (contain throw in the catch body):
catch block must capture the error: catch (error) not bare catch {}RecollectApiError thrown inside a catch block must include cause: errorcause can be omitted when there is no caught error (business logic: not found, forbidden, validation)grep 'catch {' <file> # bare catch — check if it rethrows
grep 'catch (' <file> # good — captures error variable
Then for each catch (error) block that contains throw, check the RecollectApiError includes cause::
catch {} that rethrows as RecollectApiError, or any rethrowing RecollectApiError inside a catch that omits causeExceptions (PASS):
catch {} that returns a default value without rethrowing (e.g., return null, return false) — intentional swallow, no cause to propagate. Oxfmt strips unused _error variables, so bare catch {} is the correct pattern herectx.fields observability (e.g., image re-upload fallbacks) — error details logged to wide event fields instead of causethrow new Error() in route or helpersgrep 'throw new Error(' <file>
In v2 routes and their helpers, all throws should use RecollectApiError so they're caught by the inner factory layer (Axiom warn) rather than the outer layer (Axiom error + Sentry).
Exceptions (PASS despite throw new Error):
Inside vet() callbacks -- vet catches and the route wraps in RecollectApiError
Inside inner try-catch that re-throws with { cause: error } -- error chaining through to an outer RecollectApiError
Inside after() catch blocks -- these use logger.warn directly
PASS: no raw throw new Error() or all instances are in exception patterns
FAIL: raw throw new Error() that would reach the outer factory catch as an unknown error
Present findings as a table:
## Wide Event Audit: {ROUTE}
Route type: {type} ({METHOD}) | Factory: {factory} | after(): {yes/no}
| # | Check | Status | Detail |
|---|-------|--------|--------|
| 1 | getServerContext import | PASS | Line 8 |
| 2 | Entity IDs before operations | PASS | user_id at L24, first DB op at L38 |
| 3 | Outcome flags after operations | PASS | deleted=true at L45 |
| 4 | Minimum 2 ctx.fields | PASS | 4 fields |
| 5 | No PII in ctx.fields | PASS | |
| 6 | No console.* calls | PASS | |
| 7 | No Sentry in after() | N/A | No after() blocks |
| 8 | Error format in logger.warn | N/A | No logger.warn calls |
| 9 | Error cause propagation | PASS | 2 catch blocks, all pass cause |
| 10 | No raw throw new Error() | PASS | |
Score: 8/8 passed, 2 N/A
If all checks pass: done. If any FAIL: proceed to Phase 4.
For each FAIL, show the specific code to add with line numbers. Apply only after user confirms.
Fix templates:
Missing import:
import { getServerContext } from "@/lib/api-helpers/server-context";
Missing entity IDs (add right after destructuring input/auth, before first DB call):
const ctx = getServerContext();
if (ctx?.fields) {
ctx.fields.user_id = userId;
// add relevant entity IDs for this route
}
Missing outcome flags (add after the main DB operation's success path):
if (ctx?.fields) {
ctx.fields.operation_completed = true;
}
PII violation: Replace raw field with boolean signal (see Check 5 table).
Console calls: Delete or convert to ctx.fields assignment.
Sentry in after(): Replace with:
logger.warn("[route-name] after() enrichment failed", {
bookmark_id: id,
user_id: userId,
error: error instanceof Error ? error.message : String(error),
});
Add import { logger } from "@/lib/api-helpers/axiom"; if not present.
Bare catch (Check 9): Change catch { to catch (error) and add cause: error to the RecollectApiError.
Raw throw new Error (Check 10): Convert to RecollectApiError:
// Before
throw new Error(`Failed to X: ${error.message}`);
// After
throw new RecollectApiError("service_unavailable", {
cause: error,
message: "Failed to X",
operation: "operation_name",
});
Expected fields by route type -- use as a checklist when adding fields:
| Route Type | Entity IDs (before) | Outcome Flags (after) |
|------------|--------------------|-----------------------|
| GET read | user_id | count/result fields (*_count, bookmarks_returned, found) |
| POST create | user_id, input context (url, category_id) | *_id (after insert), has_* booleans |
| PATCH update | user_id, *_id (target entity) | *_updated = true |
| DELETE | user_id, *_id (target entity) | deleted = true |
| File upload | user_id, file_type, file_name, category_id | bookmark_id (after insert), has_og_image |
| Queue worker | queue_name, msg_id, bookmark_id, url | processed_count, enrichment_*, *_failed |
| Invite/share | user_id, category_id | *_sent, *_accepted, role_updated |
Complex routes with branching or multi-step pipelines should have MORE fields (one per decision point or failure mode). Simple single-operation routes need fewer.
tools
release
development
recollect-mutation-hook-refactoring
databases
recollect-caller-migration
tools
prod-hotfix