WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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:

  1. User makes POST request to /api/topics or /api/posts
  2. requireAuth middleware validates session, attaches authenticated user to context
  3. Endpoint validates request body (text length, IDs, etc.)
  4. For replies: Query database to verify parent and root posts exist and belong to same thread
  5. Construct AT Protocol record structure (different for topics vs replies)
  6. Call user.agent.com.atproto.repo.putRecord() to write to user's PDS
  7. Return {uri, cid, rkey} immediately (201 Created)
  8. Separately: Firehose subscriber picks up the record asynchronously (1-5 seconds later)
  9. Indexer writes to AppView database
  10. 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-web generates 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:

  1. Extract authenticated user: const user = c.get('user') (requireAuth middleware guarantees this exists)

  2. Validate text:

    • Must be non-empty (trim whitespace first)
    • Max length: 300 graphemes (per lexicon space.atbb.post)
    • Use new UnicodeString(text).graphemeLength from @atproto/api
    • Return 400 with clear message if invalid
  3. Resolve forum URI:

    • If forumUri provided, use it
    • Otherwise, default to at://{FORUM_DID}/space.atbb.forum.forum/self
    • Load FORUM_DID from environment/config
  4. 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)
  5. Generate TID: const rkey = TID.nextStr() from @atproto/common-web

  6. 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
  }
});
  1. 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:

  1. Extract authenticated user: const user = c.get('user')

  2. Validate text: Same as topics (non-empty, max 300 graphemes)

  3. Parse and validate IDs:

    • Parse rootPostId and parentPostId using parseBigIntParam()
    • Return 400 if either is invalid format
  4. 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
    • Return 404 with specific error if validation fails ("Parent post not found", "Parent does not belong to this thread")
  5. 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
  6. Generate TID: const rkey = TID.nextStr()

  7. 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()
  }
});
  1. 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:

  1. Client errors (4xx):

    • 400 Bad Request: Invalid text (empty, too long), invalid ID format
    • 401 Unauthorized: Session expired or missing (handled by requireAuth)
    • 404 Not Found: Forum doesn't exist, parent/root post not found, parent not in same thread
  2. Server errors (5xx):

    • 500 Internal Server Error: Unexpected database errors, unexpected PDS errors
    • 503 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):

  1. Validation helpers:

    • Test grapheme counting with ASCII, emoji, multi-byte characters
    • Test parseBigIntParam() with valid/invalid inputs
    • Test forum URI defaulting logic
  2. Request validation:

    • Empty text → 400
    • Text with 301 graphemes → 400
    • Text with 300 graphemes → passes
    • Invalid parent ID format → 400
  3. 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):

  1. 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
  2. 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:

  1. Authenticate via OAuth (use web UI or Postman)
  2. POST /api/topics → get back URI
  3. Wait 2-3 seconds for firehose indexing
  4. GET /api/topics (list) → verify topic appears
  5. POST /api/posts (reply to topic) → get back URI
  6. Wait 2-3 seconds
  7. 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 - Use requireAuth from ATB-14
  • packages/lexicon/lexicons/space/atbb/post.yaml - Source of truth for record structure

Dependencies:

  • @atproto/api - Already in dependencies (provides UnicodeString, Agent)
  • @atproto/common-web - Already in dependencies (provides TID)

Open Questions#

None. Design is approved and ready for implementation.

References#