Write-Path API Endpoints Design (ATB-12)#
Date: 2026-02-09 Issue: ATB-12 Status: Design Approved
Overview#
Build write-path API endpoints that proxy post creation to users' PDS servers. Users authenticate via OAuth (ATB-14). The AppView writes records to their personal data servers. The firehose subscriber indexes records asynchronously.
Architecture & Request Flow#
Overall Flow:
- User makes POST request to
/api/topicsor/api/posts requireAuthmiddleware validates session, attaches authenticateduserto context- Endpoint validates request body (text length, IDs, etc.)
- For replies: Query database to verify parent and root posts exist and belong to same thread
- Construct AT Protocol record structure (different for topics vs replies)
- Call
user.agent.com.atproto.repo.putRecord()to write to user's PDS - Return
{uri, cid, rkey}immediately (201 Created) - Separately: Firehose subscriber picks up the record asynchronously (1-5 seconds later)
- Indexer writes to AppView database
- Record becomes visible in GET endpoints
Key Architectural Points:
- Write endpoints are thin proxies—they validate and forward to PDS
- These endpoints never write directly to the database (the indexer handles that)
- OAuth Agent from ATB-14 handles DPoP automatically
@atproto/common-webgenerates TIDs (already in dependencies)- Forum URI defaults to singleton
at://{FORUM_DID}/space.atbb.forum.forum/self - Reply validation queries the database for parent and root URIs and CIDs
Data Flow Separation:
- Write path: User → AppView API → User's PDS → Firehose → Indexer → AppView DB
- Read path: User → AppView API → AppView DB (already implemented in ATB-11)
Design Decision: Fire-and-Forget
- Endpoints return immediately after PDS write succeeds
- No optimistic database writes (MVP keeps it simple)
- Posts appear in API responses after a 1-5 second delay
- Optimistic writes can be added later if UX demands it
POST /api/topics - Create Thread Starter#
Endpoint: POST /api/topics
Authentication: Required via requireAuth middleware
Request Body:
{
text: string; // Required: post content
forumUri?: string; // Optional: defaults to singleton forum
}
Implementation Steps:
-
Extract authenticated user:
const user = c.get('user')(requireAuthmiddleware guarantees this exists) -
Validate text:
- Must be non-empty (trim whitespace first)
- Max length: 300 graphemes (per lexicon
space.atbb.post) - Use
new UnicodeString(text).graphemeLengthfrom@atproto/api - Return 400 with clear message if invalid
-
Resolve forum URI:
- If
forumUriprovided, use it - Otherwise, default to
at://{FORUM_DID}/space.atbb.forum.forum/self - Load
FORUM_DIDfrom environment/config
- If
-
Query forum to get CID:
- Look up forum in database to get its current CID
- Return 404 if forum doesn't exist (prevents writing to non-existent forums)
-
Generate TID:
const rkey = TID.nextStr()from@atproto/common-web -
Write to PDS:
const result = await user.agent.com.atproto.repo.putRecord({
repo: user.did,
collection: "space.atbb.post",
rkey,
record: {
$type: "space.atbb.post",
text: requestBody.text,
forum: {
forum: { uri: forumUri, cid: forumCid }
},
createdAt: new Date().toISOString()
// NO reply field - this is a topic starter
}
});
- Return success:
return c.json({
uri: result.uri,
cid: result.cid,
rkey
}, 201);
Success Response (201):
{
"uri": "at://did:plc:abc123/space.atbb.post/3lbk7foobar",
"cid": "bafyreiexample",
"rkey": "3lbk7foobar"
}
POST /api/posts - Create Reply#
Endpoint: POST /api/posts
Authentication: Required via requireAuth middleware
Request Body:
{
text: string; // Required: post content
rootPostId: string; // Required: thread starter ID (bigint as string)
parentPostId: string; // Required: direct parent ID (bigint as string)
}
Implementation Steps:
-
Extract authenticated user:
const user = c.get('user') -
Validate text: Same as topics (non-empty, max 300 graphemes)
-
Parse and validate IDs:
- Parse
rootPostIdandparentPostIdusingparseBigIntParam() - Return 400 if either is invalid format
- Parse
-
Query and validate parent/root posts:
- Fetch both posts in a single query (or two parallel queries)
- Check that both exist and are not deleted
- Check that parent belongs to the same thread:
- If parent IS the root:
parent.rootPostId === null && parent.id === rootId - If parent is a reply:
parent.rootPostId === rootId
- If parent IS the root:
- Return 404 with specific error if validation fails ("Parent post not found", "Parent does not belong to this thread")
-
Extract URIs and CIDs:
- Construct root URI:
at://${root.did}/space.atbb.post/${root.rkey} - Construct parent URI:
at://${parent.did}/space.atbb.post/${parent.rkey} - Get CIDs from database:
root.cid,parent.cid - Get forum URI from root post:
root.forumUri
- Construct root URI:
-
Generate TID:
const rkey = TID.nextStr() -
Write to PDS:
const result = await user.agent.com.atproto.repo.putRecord({
repo: user.did,
collection: "space.atbb.post",
rkey,
record: {
$type: "space.atbb.post",
text: requestBody.text,
forum: {
forum: { uri: root.forumUri, cid: root.cid } // Inherit from root
},
reply: {
root: { uri: rootUri, cid: root.cid },
parent: { uri: parentUri, cid: parent.cid }
},
createdAt: new Date().toISOString()
}
});
- Return success: Same 201 response with
{uri, cid, rkey}
Success Response (201):
{
"uri": "at://did:plc:xyz789/space.atbb.post/3lbk8bazqux",
"cid": "bafyreiexample2",
"rkey": "3lbk8bazqux"
}
Error Handling#
Error Categories and HTTP Status Codes:
-
Client errors (4xx):
400 Bad Request: Invalid text (empty, too long), invalid ID format401 Unauthorized: Session expired or missing (handled byrequireAuth)404 Not Found: Forum doesn't exist, parent/root post not found, parent not in same thread
-
Server errors (5xx):
500 Internal Server Error: Unexpected database errors, unexpected PDS errors503 Service Unavailable: PDS unreachable (network timeout, PDS down)
Error Handling Pattern:
try {
// Database query for parent validation
const [parent] = await ctx.db.select()...;
if (!parent) {
return c.json({ error: "Parent post not found" }, 404);
}
// PDS write
const result = await user.agent.com.atproto.repo.putRecord(...);
return c.json({ uri: result.uri, cid: result.cid, rkey }, 201);
} catch (error) {
console.error("Failed to create post", {
operation: "POST /api/posts",
userId: user.did,
parentId: parentPostId,
error: error instanceof Error ? error.message : String(error),
});
// Distinguish network errors from unexpected errors
if (error instanceof Error && error.message.includes("fetch failed")) {
return c.json(
{ error: "Unable to reach your PDS. Please try again later." },
503
);
}
return c.json(
{ error: "Failed to create post. Please try again later." },
500
);
}
Edge Cases:
- Long text near grapheme limit (test with emoji)
- Replying to a reply (3-level nesting)
- Concurrent requests (TID uniqueness is guaranteed by
TID.nextStr()) - PDS returns error (invalid record, quota exceeded) - treat as 500
- No retry logic: If PDS write fails, return error immediately. User can retry manually.
Testing Strategy#
Unit Tests (Vitest):
-
Validation helpers:
- Test grapheme counting with ASCII, emoji, multi-byte characters
- Test
parseBigIntParam()with valid/invalid inputs - Test forum URI defaulting logic
-
Request validation:
- Empty text → 400
- Text with 301 graphemes → 400
- Text with 300 graphemes → passes
- Invalid parent ID format → 400
-
Parent validation logic:
- Parent doesn't exist → 404
- Parent is deleted → 404
- Parent belongs to different thread → 404
- Parent is the root (replying directly to topic) → passes
- Parent is a reply in same thread → passes
Integration Tests (requires test PDS):
-
POST /api/topics:
- Create topic as authenticated user → 201 with
{uri, cid, rkey} - Create topic without auth → 401
- Create topic with empty text → 400
- Create topic with non-existent forum → 404
- Create topic as authenticated user → 201 with
-
POST /api/posts:
- Create reply to topic → 201
- Create reply to reply → 201
- Reply to non-existent parent → 404
- Reply with parent from different thread → 404
Manual Verification Flow:
- Authenticate via OAuth (use web UI or Postman)
- POST /api/topics → get back URI
- Wait 2-3 seconds for firehose indexing
- GET /api/topics (list) → verify topic appears
- POST /api/posts (reply to topic) → get back URI
- Wait 2-3 seconds
- GET /api/topics/:id → verify reply appears in thread
Mock Strategy:
- Mock
user.agent.com.atproto.repo.putRecord()in unit tests (return fake URI/CID) - Use real database with test data for integration tests
- Don't mock the database in integration tests (test real queries)
Implementation Files#
Files to modify:
apps/appview/src/routes/topics.ts- Add POST handler (currently returns 501)apps/appview/src/routes/posts.ts- Implement POST handler (currently returns 501)apps/appview/src/routes/helpers.ts- Add validation helpers (grapheme counting, post lookup)
Files to reference:
apps/appview/src/middleware/auth.ts- UserequireAuthfrom ATB-14packages/lexicon/lexicons/space/atbb/post.yaml- Source of truth for record structure
Dependencies:
@atproto/api- Already in dependencies (providesUnicodeString,Agent)@atproto/common-web- Already in dependencies (providesTID)
Open Questions#
None. Design is approved and ready for implementation.
References#
- Linear issue: https://linear.app/atbb/issue/ATB-12
- AT Proto repository spec: https://atproto.com/specs/repository
- ATB-14 (OAuth): PR #14 (merged)
- ATB-11 (Read endpoints): PR #13 (merged)