This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Anchor AppView is a location-based social feed generator built on AT Protocol. The system uses a PDS-only architecture where all checkin data is read directly from users' Personal Data Servers, with minimal local storage used only for OAuth session management.
Critical architectural constraint: This system does NOT store checkin data locally. All checkins are:
com.atproto.repo.createRecordcom.atproto.repo.getRecord and
com.atproto.repo.listRecordsException for Likes and Comments: While checkins remain PDS-only, likes and comments use a hybrid architecture with local indexing for performance:
checkin_interactions and checkin_counts tables) tracks
interactions for efficient discovery and countingLocal database storage:
iron_session_storage: OAuth session managementcheckin_interactions: Index of likes/comments for efficient queriescheckin_counts: Aggregated counts per checkin for performancemain.tsx (deployed to Val Town with
// @val-town anchordashboard comment)https://dropanchor.app (configurable via ANCHOR_BASE_URL)OAuth Authentication (backend/routes/oauth.ts, backend/oauth/):
jsr:@tijs/atproto-oauth@2.4.0anchor-app://auth-callbackCheckin API (backend/api/checkins.ts):
createRecord with immediate PDS writesFeed API (backend/api/anchor-api.ts):
Likes and Comments API (backend/api/likes.ts, backend/api/comments.ts):
createRecord in user's PDSmakeRequest() for all PDS communication/api/checkins/:did/:rkey/likes and
/api/checkins/:did/:rkey/commentsDatabase Layer (backend/database/):
sqlite-proxy adapterbackend/database/schema.ts (OAuth sessions + interaction indexes)backend/database/migrations.ts# Run all tests (unit + integration) deno task test # or ./scripts/test.sh # Run only unit tests deno task test:unit # Run only integration tests deno task test:integration # Watch mode for TDD deno task test:watch
# Format, lint, type check, and test deno task quality # Quality check without type checking (faster) deno task quality-no-check # Individual checks deno fmt # Format code deno lint # Lint code deno check --allow-import # Type check
# Deploy to Val Town (runs quality checks first) deno task deploy # Manual deployment vt push
CRITICAL: Always use sqlite2, not the deprecated sqlite module.
// ✅ CORRECT
import { sqlite } from "https://esm.town/v/std/sqlite2";
const result = await sqlite.execute({
sql: "SELECT * FROM users WHERE id = ?",
args: [userId],
});
// ❌ WRONG - old deprecated module
import { sqlite } from "https://esm.town/v/std/sqlite";
await sqlite.execute("SELECT * FROM users", [userId]);
This project uses Drizzle ORM with the sqlite-proxy adapter to wrap Val Town's
sqlite2:
// See backend/database/db.ts for the adapter implementation
export const db = drizzle(
async (sql, params) => {
const result = await sqlite.execute({ sql, args: params || [] });
return { rows: result.rows };
},
{ schema },
);
When adding new tables:
backend/database/schema.ts using Drizzle syntaxbackend/database/migrations.tsinitializeTables() in main.tsxNever hardcode secrets:
const secret = Deno.env.get("COOKIE_SECRET");
const baseUrl = Deno.env.get("ANCHOR_BASE_URL") || "https://dropanchor.app";
Key environment variables (stored in .env locally, Val Town secrets in prod):
COOKIE_SECRET - Iron Session encryption keyANCHOR_BASE_URL - Public URL (defaults to https://dropanchor.app)HANDLE - AT Protocol handle for lexicon publishing (e.g., tijs.org)APP_PASSWORD - App password for lexicon publishingBUNNY_STORAGE_ZONE - Bunny CDN storage zone nameBUNNY_STORAGE_KEY - Bunny CDN API keyBUNNY_STORAGE_REGION - Bunny CDN region (e.g., storage.bunnycdn.com)BUNNY_CDN_URL - CDN base URL (e.g., https://cdn.dropanchor.app)https://esm.sh for npm packagesjsr: for JSR packages (Hono, atproto-oauth)https://esm.town/v/std/ for Val Town utilitiesThe OAuth system uses a custom package that handles:
import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth@2.4.0";
const oauth = createATProtoOAuth({
baseUrl: BASE_URL,
cookieSecret: COOKIE_SECRET,
mobileScheme: "anchor-app://auth-callback",
sessionTtl: 60 * 60 * 24 * 30, // 30 days
storage, // DrizzleStorage instance
});
// Export for use in other modules
export const oauthRoutes = oauth.routes; // Hono routes
export const sessions = oauth.sessions; // Session management API
OAuth sessions provide automatic token refresh and DPoP handling:
const oauthSession = await sessions.getOAuthSession(did);
if (!oauthSession) {
return { error: "No session" };
}
// makeRequest handles token refresh and DPoP automatically
const response = await oauthSession.makeRequest(
"POST",
`${oauthSession.pdsUrl}/xrpc/com.atproto.repo.createRecord`,
{
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repo: did, collection: "...", record: {...} })
}
);
Never manually construct Authorization headers - always use
oauthSession.makeRequest().
Checkin record (app.dropanchor.checkin):
{
$type: "app.dropanchor.checkin",
text: string,
createdAt: string, // ISO8601
addressRef: StrongRef, // Reference to address record
coordinates: { latitude: number, longitude: number },
category?: string,
categoryGroup?: string,
categoryIcon?: string
}
Address record (community.lexicon.location.address):
{
$type: "community.lexicon.location.address",
name?: string,
street?: string,
locality?: string,
region?: string,
country?: string,
postalCode?: string
}
Like record (app.dropanchor.like):
{
$type: "app.dropanchor.like",
createdAt: string, // ISO8601
checkinRef: StrongRef // Reference to the liked checkin
}
Comment record (app.dropanchor.comment):
{
$type: "app.dropanchor.comment",
text: string, // Max 1000 characters
createdAt: string, // ISO8601
checkinRef: StrongRef // Reference to the commented checkin
}
Checkins reference addresses via StrongRefs (CID + URI):
addressRef: {
uri: "at://did:plc:abc123/community.lexicon.location.address/3k2...",
cid: "bafyreicv3pecq6fuua22xcoguxep76otivb33nlaofzl76fpagczo5t5jm"
}
This ensures data integrity via content-addressing.
POST /api/checkins Create checkin
GET /api/checkins/:did Get all checkins for user
GET /api/checkins/:did/:rkey Get specific checkin
DELETE /api/checkins/:did/:rkey Delete checkin
GET /api/checkins/:did/:rkey/likes Get likes for checkin
POST /api/checkins/:did/:rkey/likes Like a checkin (requires auth)
DELETE /api/checkins/:did/:rkey/likes Unlike a checkin (requires auth)
GET /api/checkins/:did/:rkey/comments Get comments for checkin
POST /api/checkins/:did/:rkey/comments Comment on checkin (requires auth)
DELETE /api/checkins/:did/:rkey/comments Delete comment (requires auth)
GET /api/nearby?lat=52.0&lng=4.3&radius=5&limit=50 Spatial query
GET /api/user?did=did:plc:abc123&limit=50 User's checkins
GET /api/following?user=did:plc:abc123&limit=50 Following feed
GET /login Initiate OAuth (web)
GET /oauth/callback OAuth redirect handler
POST /api/checkins Create checkin (requires auth)
GET /api/auth/session Session validation
POST /api/auth/logout Session cleanup
GET /api/stats System health metrics
GET /api/places/nearby OpenStreetMap POI search via Overpass
GET /api/places/categories Category system for mobile apps
The project has comprehensive test coverage with two categories:
Unit tests (tests/unit/):
Integration tests (tests/integration/):
Key testing patterns:
assertAlmostEquals for floating-point coordinate calculationsmain.tsx:app.get("/api/newfeature", async (c) => {
return await anchorApiHandler(c.req.raw);
});
Handle in backend/api/anchor-api.ts or create new handler file
Add integration test in tests/integration/api.test.ts
OAuth is configured in backend/routes/oauth.ts:
const oauth = createATProtoOAuth({
baseUrl: BASE_URL, // Public base URL
cookieSecret: COOKIE_SECRET, // Session encryption
mobileScheme: "anchor-app://auth-callback",
sessionTtl: 60 * 60 * 24 * 30, // 30 days
storage, // Drizzle storage adapter
});
Never modify the package's internal logic - all customization via config.
Always use OAuth sessions for PDS requests:
// ✅ CORRECT - automatic token refresh and DPoP
const oauthSession = await sessions.getOAuthSession(did);
const response = await oauthSession.makeRequest(
"POST",
`${oauthSession.pdsUrl}/xrpc/com.atproto.repo.createRecord`,
{ headers: {...}, body: JSON.stringify({...}) }
);
// ❌ WRONG - manual token handling breaks DPoP and refresh logic
const response = await fetch(`${pdsUrl}/xrpc/...`, {
headers: { "Authorization": `Bearer ${accessToken}` }
});
The system supports iOS app integration via WebView OAuth flow:
https://dropanchor.app/login?handle=user.bsky.socialanchor-app://auth-callback with session dataanchor-app://auth-callback?
access_token=...
&refresh_token=...
&did=...
&handle=...
&session_id=...
&pds_url=...
&avatar=...
&display_name=...
anchor-app URL scheme in Info.plistAuthorization: Bearer {session_id}# Run debug script to inspect OAuth sessions deno run --allow-net scripts/debug-oauth-sessions.ts
Or check via API endpoint:
GET https://dropanchor.app/api/debug/oauth-sessions
All PDS requests go through OAuth session's makeRequest(). Enable logging:
console.log("PDS request:", {
method,
url,
pdsUrl: oauthSession.pdsUrl,
did: oauthSession.did,
});
If deployment fails:
vt whoamideno task qualityDO NOT create local database tables for checkin data. The PDS-only architecture is intentional:
If you need to cache data, use Val Town blob storage with TTL, not SQLite.
Always follow AT Protocol patterns:
com.atproto.repo.* XRPC methods for record operations$type fields in all recordsZ suffixDeno.env.get()Lexicons are published as com.atproto.lexicon.schema records on hamster.farm
(tijs.org's PDS), following the official AT Protocol spec.
Published lexicons:
app.dropanchor.checkinapp.dropanchor.likeapp.dropanchor.commentResolution chain:
app.dropanchor.checkindropanchor.app_lexicon.dropanchor.app → did:plc:aq7owa5y7ndc2hzjz37wy7mahttps://hamster.farmRepublishing lexicons (after modifying lexicons/ files):
source .env deno run --allow-net --allow-read --allow-env scripts/publish-lexicons.ts
Verification:
~/go/bin/glot status lexicons/ dig TXT _lexicon.dropanchor.app +short
See docs/lexicon-publishing.md for full details.
Static images are hosted on Bunny CDN at cdn.dropanchor.app:
https://cdn.dropanchor.app/images/anchor-logo.pnghttps://cdn.dropanchor.app/images/seagull-looking.pnghttps://cdn.dropanchor.app/images/seagull-chest.pngUploading new assets:
source .env curl -X PUT "https://${BUNNY_STORAGE_REGION}/${BUNNY_STORAGE_ZONE}/images/filename.png" \ -H "AccessKey: ${BUNNY_STORAGE_KEY}" \ -H "Content-Type: image/png" \ --data-binary @/path/to/file.png
Originally designed as a traditional AppView with background ingestion and local database caching. Now migrated to PDS-only architecture where:
checkins_v1, address_cache_v1, etc. removed)Comments and variable names may reference old architecture - these are safe to update.
deno task deploy