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
4
fork

Configure Feed

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

Write-Path API Endpoints Implementation Plan (ATB-12)#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build POST /api/topics and POST /api/posts endpoints that write records to users' PDS servers via OAuth-authenticated agents.

Architecture: Thin proxy endpoints that validate input, query database for parent/root validation (replies only), construct AT Protocol records, write to user's PDS via agent.com.atproto.repo.putRecord(), and return immediately. Firehose indexer picks up records asynchronously.

Tech Stack: Hono, Drizzle ORM, @atproto/api (UnicodeString, Agent), @atproto/common-web (TID), OAuth middleware from ATB-14


Task 1: Add Grapheme Validation Helper#

Files:

  • Modify: apps/appview/src/routes/helpers.ts:56 (add at end)

Step 1: Write failing test for grapheme validation

Create: apps/appview/src/routes/__tests__/helpers.test.ts

import { describe, it, expect } from "vitest";
import { validatePostText } from "../helpers.js";

describe("validatePostText", () => {
  it("accepts text with 300 graphemes", () => {
    const text = "a".repeat(300);
    const result = validatePostText(text);
    expect(result.valid).toBe(true);
    expect(result.trimmed).toBe(text);
  });

  it("rejects text with 301 graphemes", () => {
    const text = "a".repeat(301);
    const result = validatePostText(text);
    expect(result.valid).toBe(false);
    expect(result.error).toBe("Text must be 300 characters or less");
  });

  it("rejects empty text after trimming", () => {
    const result = validatePostText("   ");
    expect(result.valid).toBe(false);
    expect(result.error).toBe("Text cannot be empty");
  });

  it("trims whitespace before validation", () => {
    const result = validatePostText("  hello  ");
    expect(result.valid).toBe(true);
    expect(result.trimmed).toBe("hello");
  });

  it("handles emoji as single graphemes", () => {
    // 5 emoji = 5 graphemes (not 10+ code points)
    const text = "👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦";
    const result = validatePostText(text);
    expect(result.valid).toBe(true);
  });

  it("counts emoji + text correctly", () => {
    // Should count correctly: emoji as 1 grapheme each
    const text = "👋 Hello world!"; // 1 + 1 (space) + 12 = 14 graphemes
    const result = validatePostText(text);
    expect(result.valid).toBe(true);
  });
});

Step 2: Run test to verify it fails

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: FAIL with "validatePostText is not exported"

Step 3: Implement grapheme validation helper

Modify: apps/appview/src/routes/helpers.ts:56 (add at end)

import { UnicodeString } from "@atproto/api";

/**
 * Validate post text according to lexicon constraints.
 * - Max 300 graphemes (user-perceived characters)
 * - Non-empty after trimming whitespace
 */
export function validatePostText(text: string): {
  valid: boolean;
  trimmed?: string;
  error?: string;
} {
  const trimmed = text.trim();

  if (trimmed.length === 0) {
    return { valid: false, error: "Text cannot be empty" };
  }

  const graphemeLength = new UnicodeString(trimmed).graphemeLength;
  if (graphemeLength > 300) {
    return {
      valid: false,
      error: "Text must be 300 characters or less",
    };
  }

  return { valid: true, trimmed };
}

Step 4: Run test to verify it passes

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: PASS (6 tests)

Step 5: Commit

git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts
git commit -m "feat(appview): add grapheme validation helper for post text"

Task 2: Add Forum Lookup Helper#

Files:

  • Modify: apps/appview/src/routes/helpers.ts:82 (add at end)

Step 1: Write failing test for forum lookup

Modify: apps/appview/src/routes/__tests__/helpers.test.ts (add at end)

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getForumByUri } from "../helpers.js";
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";

describe("getForumByUri", () => {
  let ctx: TestContext;

  beforeEach(async () => {
    ctx = await createTestContext();
  });

  afterEach(async () => {
    await ctx.cleanup();
  });

  it("returns forum when it exists", async () => {
    // Test context creates a forum with rkey='self'
    const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;

    const forum = await getForumByUri(ctx.db, forumUri);

    expect(forum).toBeDefined();
    expect(forum?.rkey).toBe("self");
    expect(forum?.did).toBe(ctx.config.forumDid);
  });

  it("returns null when forum does not exist", async () => {
    const forumUri = `at://did:plc:nonexistent/space.atbb.forum.forum/self`;

    const forum = await getForumByUri(ctx.db, forumUri);

    expect(forum).toBeNull();
  });
});

Step 2: Run test to verify it fails

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: FAIL with "getForumByUri is not exported" or "createTestContext is not exported"

Step 3: Create test context helper (if needed)

Create: apps/appview/src/lib/__tests__/test-context.ts

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@atbb/db";
import type { AppConfig } from "../config.js";

export interface TestContext {
  db: ReturnType<typeof drizzle>;
  config: AppConfig;
  cleanup: () => Promise<void>;
}

/**
 * Create test context with in-memory database and sample data.
 * Call cleanup() after tests to close connection.
 */
export async function createTestContext(): Promise<TestContext> {
  const config: AppConfig = {
    port: 3000,
    forumDid: "did:plc:test-forum",
    pdsUrl: "https://test.pds",
    databaseUrl: process.env.TEST_DATABASE_URL ?? "",
    jetstreamUrl: "wss://test.jetstream",
    oauthPublicUrl: "http://localhost:3000",
    sessionSecret: "test-secret-at-least-32-characters-long",
    sessionTtlDays: 7,
  };

  const client = postgres(config.databaseUrl);
  const db = drizzle(client, { schema });

  // Insert test forum
  await db.insert(schema.forums).values({
    did: config.forumDid,
    rkey: "self",
    cid: "bafytest",
    name: "Test Forum",
    description: "A test forum",
    indexedAt: new Date(),
  });

  return {
    db,
    config,
    cleanup: async () => {
      await client.end();
    },
  };
}

Step 4: Implement forum lookup helper

Modify: apps/appview/src/routes/helpers.ts:82 (add at end)

import { forums } from "@atbb/db";
import { eq } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";

/**
 * Look up forum by AT-URI.
 * Returns null if forum doesn't exist.
 *
 * @param db Database instance
 * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self"
 */
export async function getForumByUri(
  db: PostgresJsDatabase,
  uri: string
): Promise<{ did: string; rkey: string; cid: string } | null> {
  // Parse AT-URI: at://did/collection/rkey
  const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/);
  if (!match) {
    return null;
  }

  const [, did, rkey] = match;

  const [forum] = await db
    .select({
      did: forums.did,
      rkey: forums.rkey,
      cid: forums.cid,
    })
    .from(forums)
    .where(eq(forums.did, did))
    .where(eq(forums.rkey, rkey))
    .limit(1);

  return forum ?? null;
}

Step 5: Run test to verify it passes

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: PASS (8 tests)

Step 6: Commit

git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts apps/appview/src/lib/__tests__/test-context.ts
git commit -m "feat(appview): add forum lookup helper and test context"

Task 3: Implement POST /api/topics Endpoint#

Files:

  • Modify: apps/appview/src/routes/topics.ts:92-96 (replace stub)

Step 1: Write failing integration test

Create: apps/appview/src/routes/__tests__/topics.test.ts

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Hono } from "hono";
import { createTopicsRoutes } from "../topics.js";
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
import { requireAuth } from "../../middleware/auth.js";
import type { Variables } from "../../types.js";

describe("POST /api/topics", () => {
  let ctx: TestContext;
  let app: Hono<{ Variables: Variables }>;

  beforeEach(async () => {
    ctx = await createTestContext();

    // Mock requireAuth middleware for testing
    const mockAuth = vi.fn(async (c, next) => {
      // Mock authenticated user with agent
      c.set("user", {
        did: "did:plc:test-user",
        handle: "testuser.test",
        pdsUrl: "https://test.pds",
        agent: {
          com: {
            atproto: {
              repo: {
                putRecord: vi.fn(async () => ({
                  uri: "at://did:plc:test-user/space.atbb.post/3lbk7test",
                  cid: "bafytest",
                })),
              },
            },
          },
        },
      });
      await next();
    });

    app = new Hono<{ Variables: Variables }>();
    app.route("/api/topics", createTopicsRoutes(ctx).use("/*", mockAuth));
  });

  afterEach(async () => {
    await ctx.cleanup();
  });

  it("creates topic with valid text", async () => {
    const res = await app.request("/api/topics", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "Hello, atBB!" }),
    });

    expect(res.status).toBe(201);
    const data = await res.json();
    expect(data.uri).toMatch(/^at:\/\/did:plc:test-user\/space\.atbb\.post\/3/);
    expect(data.cid).toBeTruthy();
    expect(data.rkey).toBeTruthy();
  });

  it("returns 400 for empty text", async () => {
    const res = await app.request("/api/topics", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "   " }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("empty");
  });

  it("returns 400 for text exceeding 300 graphemes", async () => {
    const res = await app.request("/api/topics", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "a".repeat(301) }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("300 characters");
  });

  it("uses default forum URI when not provided", async () => {
    const res = await app.request("/api/topics", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "Test topic" }),
    });

    expect(res.status).toBe(201);
    // Verify putRecord was called with correct forum ref
    const user = app.get("user");
    const putRecord = user.agent.com.atproto.repo.putRecord;
    expect(putRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        record: expect.objectContaining({
          forum: expect.objectContaining({
            forum: expect.objectContaining({
              uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
            }),
          }),
        }),
      })
    );
  });

  it("returns 404 when custom forum does not exist", async () => {
    const res = await app.request("/api/topics", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Test",
        forumUri: "at://did:plc:nonexistent/space.atbb.forum.forum/self",
      }),
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("Forum not found");
  });
});

Step 2: Run test to verify it fails

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts

Expected: FAIL with "not implemented" response

Step 3: Implement POST /api/topics endpoint

Modify: apps/appview/src/routes/topics.ts:92-96

Replace the stub:

    .post("/", (c) => {
      // Phase 2: create space.atbb.post record with forumRef but no reply ref
      // This requires authentication and PDS write operations
      return c.json({ error: "not implemented" }, 501);
    });

With the implementation:

    .post("/", requireAuth(ctx), async (c) => {
      const user = c.get("user");

      // Parse and validate request body
      const body = await c.req.json();
      const { text, forumUri: customForumUri } = body;

      // Validate text
      const validation = validatePostText(text);
      if (!validation.valid) {
        return c.json({ error: validation.error }, 400);
      }

      try {
        // Resolve forum URI (default to singleton forum)
        const forumUri =
          customForumUri ??
          `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;

        // Look up forum to get CID
        const forum = await getForumByUri(ctx.db, forumUri);
        if (!forum) {
          return c.json({ error: "Forum not found" }, 404);
        }

        // Generate TID for rkey
        const rkey = TID.nextStr();

        // Write to user's PDS
        const result = await user.agent.com.atproto.repo.putRecord({
          repo: user.did,
          collection: "space.atbb.post",
          rkey,
          record: {
            $type: "space.atbb.post",
            text: validation.trimmed!,
            forum: {
              forum: { uri: forumUri, cid: forum.cid },
            },
            createdAt: new Date().toISOString(),
          },
        });

        return c.json(
          {
            uri: result.uri,
            cid: result.cid,
            rkey,
          },
          201
        );
      } catch (error) {
        console.error("Failed to create topic", {
          operation: "POST /api/topics",
          userId: user.did,
          error: error instanceof Error ? error.message : String(error),
        });

        // Distinguish PDS 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 topic. Please try again later.",
          },
          500
        );
      }
    });

Add imports at top of file:

import { TID } from "@atproto/common-web";
import { requireAuth } from "../middleware/auth.js";
import { validatePostText, getForumByUri } from "./helpers.js";

Step 4: Run test to verify it passes

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts

Expected: PASS (5 tests)

Step 5: Commit

git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts
git commit -m "feat(appview): implement POST /api/topics endpoint"

Task 4: Add Post Lookup and Validation Helpers#

Files:

  • Modify: apps/appview/src/routes/helpers.ts:125 (add at end)

Step 1: Write failing test for post lookup

Modify: apps/appview/src/routes/__tests__/helpers.test.ts (add at end)

import { getPostsByIds, validateReplyParent } from "../helpers.js";
import { posts, users } from "@atbb/db";

describe("getPostsByIds", () => {
  let ctx: TestContext;

  beforeEach(async () => {
    ctx = await createTestContext();

    // Insert test user
    await ctx.db.insert(users).values({
      did: "did:plc:test-user",
      handle: "testuser.test",
      indexedAt: new Date(),
    });

    // Insert test posts
    await ctx.db.insert(posts).values([
      {
        did: "did:plc:test-user",
        rkey: "3lbk7topic",
        cid: "bafytopic",
        text: "Topic post",
        forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
        createdAt: new Date(),
        indexedAt: new Date(),
        deleted: false,
      },
      {
        did: "did:plc:test-user",
        rkey: "3lbk8reply",
        cid: "bafyreply",
        text: "Reply post",
        forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
        rootPostId: 1n, // Assuming topic has id=1
        parentPostId: 1n,
        rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
        parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
        createdAt: new Date(),
        indexedAt: new Date(),
        deleted: false,
      },
    ]);
  });

  afterEach(async () => {
    await ctx.cleanup();
  });

  it("returns posts when they exist", async () => {
    const result = await getPostsByIds(ctx.db, [1n, 2n]);

    expect(result.size).toBe(2);
    expect(result.get(1n)?.rkey).toBe("3lbk7topic");
    expect(result.get(2n)?.rkey).toBe("3lbk8reply");
  });

  it("excludes deleted posts", async () => {
    // Mark topic as deleted
    await ctx.db
      .update(posts)
      .set({ deleted: true })
      .where(eq(posts.id, 1n));

    const result = await getPostsByIds(ctx.db, [1n, 2n]);

    expect(result.size).toBe(1);
    expect(result.has(1n)).toBe(false);
    expect(result.has(2n)).toBe(true);
  });

  it("returns empty map for non-existent IDs", async () => {
    const result = await getPostsByIds(ctx.db, [999n]);

    expect(result.size).toBe(0);
  });
});

describe("validateReplyParent", () => {
  it("accepts when parent IS the root", () => {
    const root = { id: 1n, rootPostId: null };
    const parent = { id: 1n, rootPostId: null };

    const result = validateReplyParent(root, parent, 1n);

    expect(result.valid).toBe(true);
  });

  it("accepts when parent is a reply in same thread", () => {
    const root = { id: 1n, rootPostId: null };
    const parent = { id: 2n, rootPostId: 1n };

    const result = validateReplyParent(root, parent, 1n);

    expect(result.valid).toBe(true);
  });

  it("rejects when parent belongs to different thread", () => {
    const root = { id: 1n, rootPostId: null };
    const parent = { id: 2n, rootPostId: 99n }; // Different root

    const result = validateReplyParent(root, parent, 1n);

    expect(result.valid).toBe(false);
    expect(result.error).toContain("different thread");
  });

  it("rejects when parent is a root but not THE root", () => {
    const root = { id: 1n, rootPostId: null };
    const parent = { id: 2n, rootPostId: null }; // Also a root, but different

    const result = validateReplyParent(root, parent, 1n);

    expect(result.valid).toBe(false);
    expect(result.error).toContain("different thread");
  });
});

Step 2: Run test to verify it fails

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: FAIL with "getPostsByIds is not exported"

Step 3: Implement post lookup and validation helpers

Modify: apps/appview/src/routes/helpers.ts:125 (add at end)

import { posts } from "@atbb/db";
import { inArray, and, eq } from "drizzle-orm";

export type PostRow = typeof posts.$inferSelect;

/**
 * Look up multiple posts by ID in a single query.
 * Excludes deleted posts.
 * Returns a Map for O(1) lookup.
 */
export async function getPostsByIds(
  db: PostgresJsDatabase,
  ids: bigint[]
): Promise<Map<bigint, PostRow>> {
  if (ids.length === 0) {
    return new Map();
  }

  const results = await db
    .select()
    .from(posts)
    .where(and(inArray(posts.id, ids), eq(posts.deleted, false)));

  return new Map(results.map((post) => [post.id, post]));
}

/**
 * Validate that a parent post belongs to the same thread as the root.
 *
 * Rules:
 * - Parent can BE the root (replying directly to topic)
 * - Parent can be a reply in the same thread (parent.rootPostId === rootId)
 * - Parent cannot belong to a different thread
 */
export function validateReplyParent(
  root: { id: bigint; rootPostId: bigint | null },
  parent: { id: bigint; rootPostId: bigint | null },
  rootId: bigint
): { valid: boolean; error?: string } {
  // Parent IS the root (replying to topic)
  if (parent.id === rootId && parent.rootPostId === null) {
    return { valid: true };
  }

  // Parent is a reply in the same thread
  if (parent.rootPostId === rootId) {
    return { valid: true };
  }

  // Parent belongs to a different thread
  return {
    valid: false,
    error: "Parent post does not belong to this thread",
  };
}

Step 4: Run test to verify it passes

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts

Expected: PASS (14 tests)

Step 5: Commit

git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts
git commit -m "feat(appview): add post lookup and reply validation helpers"

Task 5: Implement POST /api/posts Endpoint#

Files:

  • Modify: apps/appview/src/routes/posts.ts:1-6 (replace entire file)

Step 1: Write failing integration test

Create: apps/appview/src/routes/__tests__/posts.test.ts

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Hono } from "hono";
import { createPostsRoutes } from "../posts.js";
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
import type { Variables } from "../../types.js";
import { posts, users } from "@atbb/db";

describe("POST /api/posts", () => {
  let ctx: TestContext;
  let app: Hono<{ Variables: Variables }>;

  beforeEach(async () => {
    ctx = await createTestContext();

    // Insert test user
    await ctx.db.insert(users).values({
      did: "did:plc:test-user",
      handle: "testuser.test",
      indexedAt: new Date(),
    });

    // Insert topic (root post)
    await ctx.db.insert(posts).values({
      did: "did:plc:test-user",
      rkey: "3lbk7topic",
      cid: "bafytopic",
      text: "Topic post",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      createdAt: new Date(),
      indexedAt: new Date(),
      deleted: false,
    });

    // Insert reply
    await ctx.db.insert(posts).values({
      did: "did:plc:test-user",
      rkey: "3lbk8reply",
      cid: "bafyreply",
      text: "Reply post",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      rootPostId: 1n,
      parentPostId: 1n,
      rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
      parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
      createdAt: new Date(),
      indexedAt: new Date(),
      deleted: false,
    });

    // Mock auth middleware
    const mockAuth = vi.fn(async (c, next) => {
      c.set("user", {
        did: "did:plc:test-user",
        handle: "testuser.test",
        pdsUrl: "https://test.pds",
        agent: {
          com: {
            atproto: {
              repo: {
                putRecord: vi.fn(async () => ({
                  uri: "at://did:plc:test-user/space.atbb.post/3lbk9test",
                  cid: "bafytest",
                })),
              },
            },
          },
        },
      });
      await next();
    });

    app = new Hono<{ Variables: Variables }>();
    app.route("/api/posts", createPostsRoutes(ctx).use("/*", mockAuth));
  });

  afterEach(async () => {
    await ctx.cleanup();
  });

  it("creates reply to topic", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "My reply",
        rootPostId: "1",
        parentPostId: "1",
      }),
    });

    expect(res.status).toBe(201);
    const data = await res.json();
    expect(data.uri).toBeTruthy();
    expect(data.cid).toBeTruthy();
    expect(data.rkey).toBeTruthy();
  });

  it("creates reply to reply", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Nested reply",
        rootPostId: "1",
        parentPostId: "2", // Reply to the reply
      }),
    });

    expect(res.status).toBe(201);
  });

  it("returns 400 for invalid parent ID format", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Test",
        rootPostId: "not-a-number",
        parentPostId: "1",
      }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("Invalid");
  });

  it("returns 404 when root post does not exist", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Test",
        rootPostId: "999",
        parentPostId: "999",
      }),
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("not found");
  });

  it("returns 404 when parent post does not exist", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Test",
        rootPostId: "1",
        parentPostId: "999",
      }),
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("not found");
  });

  it("returns 400 when parent belongs to different thread", async () => {
    // Insert a different topic
    await ctx.db.insert(posts).values({
      did: "did:plc:test-user",
      rkey: "3lbkaother",
      cid: "bafyother",
      text: "Other topic",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      createdAt: new Date(),
      indexedAt: new Date(),
      deleted: false,
    });

    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: "Test",
        rootPostId: "1",
        parentPostId: "3", // Different thread
      }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("thread");
  });
});

Step 2: Run test to verify it fails

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts

Expected: FAIL with "not implemented"

Step 3: Implement POST /api/posts endpoint

Modify: apps/appview/src/routes/posts.ts (replace entire file)

import { Hono } from "hono";
import { TID } from "@atproto/common-web";
import type { AppContext } from "../lib/app-context.js";
import { requireAuth } from "../middleware/auth.js";
import {
  validatePostText,
  parseBigIntParam,
  getPostsByIds,
  validateReplyParent,
} from "./helpers.js";

export function createPostsRoutes(ctx: AppContext) {
  return new Hono().post("/", requireAuth(ctx), async (c) => {
    const user = c.get("user");

    // Parse and validate request body
    const body = await c.req.json();
    const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body;

    // Validate text
    const validation = validatePostText(text);
    if (!validation.valid) {
      return c.json({ error: validation.error }, 400);
    }

    // Parse IDs
    const rootId = parseBigIntParam(rootIdStr);
    const parentId = parseBigIntParam(parentIdStr);

    if (rootId === null || parentId === null) {
      return c.json(
        {
          error: "Invalid post ID format. IDs must be numeric strings.",
        },
        400
      );
    }

    try {
      // Look up root and parent posts
      const postsMap = await getPostsByIds(ctx.db, [rootId, parentId]);

      const root = postsMap.get(rootId);
      const parent = postsMap.get(parentId);

      if (!root) {
        return c.json({ error: "Root post not found" }, 404);
      }

      if (!parent) {
        return c.json({ error: "Parent post not found" }, 404);
      }

      // Validate parent belongs to same thread
      const parentValidation = validateReplyParent(root, parent, rootId);
      if (!parentValidation.valid) {
        return c.json({ error: parentValidation.error }, 400);
      }

      // Construct AT-URIs
      const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`;
      const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`;

      // Generate TID for rkey
      const rkey = TID.nextStr();

      // Write to user's PDS
      const result = await user.agent.com.atproto.repo.putRecord({
        repo: user.did,
        collection: "space.atbb.post",
        rkey,
        record: {
          $type: "space.atbb.post",
          text: validation.trimmed!,
          forum: {
            forum: { uri: root.forumUri!, cid: root.cid },
          },
          reply: {
            root: { uri: rootUri, cid: root.cid },
            parent: { uri: parentUri, cid: parent.cid },
          },
          createdAt: new Date().toISOString(),
        },
      });

      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,
        rootId: rootIdStr,
        parentId: parentIdStr,
        error: error instanceof Error ? error.message : String(error),
      });

      // Distinguish PDS 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
      );
    }
  });
}

Step 4: Update posts route registration

Modify: apps/appview/src/index.ts (find where postsRoutes is registered)

Replace:

import { postsRoutes } from "./routes/posts.js";
app.route("/api/posts", postsRoutes);

With:

import { createPostsRoutes } from "./routes/posts.js";
app.route("/api/posts", createPostsRoutes(appContext));

Step 5: Run test to verify it passes

Run:

cd apps/appview
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts

Expected: PASS (6 tests)

Step 6: Commit

git add apps/appview/src/routes/posts.ts apps/appview/src/routes/__tests__/posts.test.ts apps/appview/src/index.ts
git commit -m "feat(appview): implement POST /api/posts endpoint for replies"

Task 6: Update Documentation and Linear#

Files:

  • Modify: docs/atproto-forum-plan.md (mark ATB-12 complete)

Step 1: Mark ATB-12 complete in plan document

Modify: docs/atproto-forum-plan.md

Find the ATB-12 checkbox and update it:

- [x] **ATB-12:** Implement write-path API endpoints (POST /api/topics, POST /api/posts) ✅ **DONE** - Thin proxy endpoints with OAuth auth, grapheme validation, reply thread validation. Tests passing. (See `apps/appview/src/routes/topics.ts:92-145`, `apps/appview/src/routes/posts.ts`)

Step 2: Commit documentation update

git add docs/atproto-forum-plan.md
git commit -m "docs: mark ATB-12 complete in project plan"

Step 3: Update Linear issue

Update ATB-12 status to Done and add completion comment with implementation summary and file references.


Summary#

Total Tasks: 6 Estimated Time: 2-3 hours Test Coverage: Unit tests for helpers, integration tests for endpoints Dependencies: @atproto/api (UnicodeString), @atproto/common-web (TID), requireAuth middleware from ATB-14

Key Implementation Points:

  • Grapheme validation ensures proper Unicode handling (emoji count correctly)
  • Fire-and-forget design: return immediately after PDS write
  • No optimistic DB writes (indexer handles that asynchronously)
  • Comprehensive error handling with structured logging
  • Thread validation prevents replies to wrong topics

Testing Strategy:

  • Mock user.agent.com.atproto.repo.putRecord() for fast tests
  • Use real database with test fixtures for integration tests
  • Verify error cases: empty text, text too long, invalid IDs, missing posts, wrong thread