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