tangled
alpha
login
or
join now
malpercio.dev
/
atbb
5
fork
atom
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
atom
overview
issues
pulls
pipelines
docs: add detailed implementation plan for ATB-12
malpercio.dev
4 weeks ago
b793a00e
79554f45
+1205
1 changed file
expand all
collapse all
unified
split
docs
plans
2026-02-09-write-endpoints-implementation.md
+1205
docs/plans/2026-02-09-write-endpoints-implementation.md
···
1
1
+
# Write-Path API Endpoints Implementation Plan (ATB-12)
2
2
+
3
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
4
+
5
5
+
**Goal:** Build POST /api/topics and POST /api/posts endpoints that write records to users' PDS servers via OAuth-authenticated agents.
6
6
+
7
7
+
**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.
8
8
+
9
9
+
**Tech Stack:** Hono, Drizzle ORM, @atproto/api (UnicodeString, Agent), @atproto/common-web (TID), OAuth middleware from ATB-14
10
10
+
11
11
+
---
12
12
+
13
13
+
## Task 1: Add Grapheme Validation Helper
14
14
+
15
15
+
**Files:**
16
16
+
- Modify: `apps/appview/src/routes/helpers.ts:56` (add at end)
17
17
+
18
18
+
**Step 1: Write failing test for grapheme validation**
19
19
+
20
20
+
Create: `apps/appview/src/routes/__tests__/helpers.test.ts`
21
21
+
22
22
+
```typescript
23
23
+
import { describe, it, expect } from "vitest";
24
24
+
import { validatePostText } from "../helpers.js";
25
25
+
26
26
+
describe("validatePostText", () => {
27
27
+
it("accepts text with 300 graphemes", () => {
28
28
+
const text = "a".repeat(300);
29
29
+
const result = validatePostText(text);
30
30
+
expect(result.valid).toBe(true);
31
31
+
expect(result.trimmed).toBe(text);
32
32
+
});
33
33
+
34
34
+
it("rejects text with 301 graphemes", () => {
35
35
+
const text = "a".repeat(301);
36
36
+
const result = validatePostText(text);
37
37
+
expect(result.valid).toBe(false);
38
38
+
expect(result.error).toBe("Text must be 300 characters or less");
39
39
+
});
40
40
+
41
41
+
it("rejects empty text after trimming", () => {
42
42
+
const result = validatePostText(" ");
43
43
+
expect(result.valid).toBe(false);
44
44
+
expect(result.error).toBe("Text cannot be empty");
45
45
+
});
46
46
+
47
47
+
it("trims whitespace before validation", () => {
48
48
+
const result = validatePostText(" hello ");
49
49
+
expect(result.valid).toBe(true);
50
50
+
expect(result.trimmed).toBe("hello");
51
51
+
});
52
52
+
53
53
+
it("handles emoji as single graphemes", () => {
54
54
+
// 5 emoji = 5 graphemes (not 10+ code points)
55
55
+
const text = "👨👩👧👦👨👩👧👦👨👩👧👦👨👩👧👦👨👩👧👦";
56
56
+
const result = validatePostText(text);
57
57
+
expect(result.valid).toBe(true);
58
58
+
});
59
59
+
60
60
+
it("counts emoji + text correctly", () => {
61
61
+
// Should count correctly: emoji as 1 grapheme each
62
62
+
const text = "👋 Hello world!"; // 1 + 1 (space) + 12 = 14 graphemes
63
63
+
const result = validatePostText(text);
64
64
+
expect(result.valid).toBe(true);
65
65
+
});
66
66
+
});
67
67
+
```
68
68
+
69
69
+
**Step 2: Run test to verify it fails**
70
70
+
71
71
+
Run:
72
72
+
```bash
73
73
+
cd apps/appview
74
74
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
75
75
+
```
76
76
+
77
77
+
Expected: FAIL with "validatePostText is not exported"
78
78
+
79
79
+
**Step 3: Implement grapheme validation helper**
80
80
+
81
81
+
Modify: `apps/appview/src/routes/helpers.ts:56` (add at end)
82
82
+
83
83
+
```typescript
84
84
+
import { UnicodeString } from "@atproto/api";
85
85
+
86
86
+
/**
87
87
+
* Validate post text according to lexicon constraints.
88
88
+
* - Max 300 graphemes (user-perceived characters)
89
89
+
* - Non-empty after trimming whitespace
90
90
+
*/
91
91
+
export function validatePostText(text: string): {
92
92
+
valid: boolean;
93
93
+
trimmed?: string;
94
94
+
error?: string;
95
95
+
} {
96
96
+
const trimmed = text.trim();
97
97
+
98
98
+
if (trimmed.length === 0) {
99
99
+
return { valid: false, error: "Text cannot be empty" };
100
100
+
}
101
101
+
102
102
+
const graphemeLength = new UnicodeString(trimmed).graphemeLength;
103
103
+
if (graphemeLength > 300) {
104
104
+
return {
105
105
+
valid: false,
106
106
+
error: "Text must be 300 characters or less",
107
107
+
};
108
108
+
}
109
109
+
110
110
+
return { valid: true, trimmed };
111
111
+
}
112
112
+
```
113
113
+
114
114
+
**Step 4: Run test to verify it passes**
115
115
+
116
116
+
Run:
117
117
+
```bash
118
118
+
cd apps/appview
119
119
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
120
120
+
```
121
121
+
122
122
+
Expected: PASS (6 tests)
123
123
+
124
124
+
**Step 5: Commit**
125
125
+
126
126
+
```bash
127
127
+
git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts
128
128
+
git commit -m "feat(appview): add grapheme validation helper for post text"
129
129
+
```
130
130
+
131
131
+
---
132
132
+
133
133
+
## Task 2: Add Forum Lookup Helper
134
134
+
135
135
+
**Files:**
136
136
+
- Modify: `apps/appview/src/routes/helpers.ts:82` (add at end)
137
137
+
138
138
+
**Step 1: Write failing test for forum lookup**
139
139
+
140
140
+
Modify: `apps/appview/src/routes/__tests__/helpers.test.ts` (add at end)
141
141
+
142
142
+
```typescript
143
143
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
144
144
+
import { getForumByUri } from "../helpers.js";
145
145
+
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
146
146
+
147
147
+
describe("getForumByUri", () => {
148
148
+
let ctx: TestContext;
149
149
+
150
150
+
beforeEach(async () => {
151
151
+
ctx = await createTestContext();
152
152
+
});
153
153
+
154
154
+
afterEach(async () => {
155
155
+
await ctx.cleanup();
156
156
+
});
157
157
+
158
158
+
it("returns forum when it exists", async () => {
159
159
+
// Test context creates a forum with rkey='self'
160
160
+
const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
161
161
+
162
162
+
const forum = await getForumByUri(ctx.db, forumUri);
163
163
+
164
164
+
expect(forum).toBeDefined();
165
165
+
expect(forum?.rkey).toBe("self");
166
166
+
expect(forum?.did).toBe(ctx.config.forumDid);
167
167
+
});
168
168
+
169
169
+
it("returns null when forum does not exist", async () => {
170
170
+
const forumUri = `at://did:plc:nonexistent/space.atbb.forum.forum/self`;
171
171
+
172
172
+
const forum = await getForumByUri(ctx.db, forumUri);
173
173
+
174
174
+
expect(forum).toBeNull();
175
175
+
});
176
176
+
});
177
177
+
```
178
178
+
179
179
+
**Step 2: Run test to verify it fails**
180
180
+
181
181
+
Run:
182
182
+
```bash
183
183
+
cd apps/appview
184
184
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
185
185
+
```
186
186
+
187
187
+
Expected: FAIL with "getForumByUri is not exported" or "createTestContext is not exported"
188
188
+
189
189
+
**Step 3: Create test context helper (if needed)**
190
190
+
191
191
+
Create: `apps/appview/src/lib/__tests__/test-context.ts`
192
192
+
193
193
+
```typescript
194
194
+
import { drizzle } from "drizzle-orm/postgres-js";
195
195
+
import postgres from "postgres";
196
196
+
import * as schema from "@atbb/db";
197
197
+
import type { AppConfig } from "../config.js";
198
198
+
199
199
+
export interface TestContext {
200
200
+
db: ReturnType<typeof drizzle>;
201
201
+
config: AppConfig;
202
202
+
cleanup: () => Promise<void>;
203
203
+
}
204
204
+
205
205
+
/**
206
206
+
* Create test context with in-memory database and sample data.
207
207
+
* Call cleanup() after tests to close connection.
208
208
+
*/
209
209
+
export async function createTestContext(): Promise<TestContext> {
210
210
+
const config: AppConfig = {
211
211
+
port: 3000,
212
212
+
forumDid: "did:plc:test-forum",
213
213
+
pdsUrl: "https://test.pds",
214
214
+
databaseUrl: process.env.TEST_DATABASE_URL ?? "",
215
215
+
jetstreamUrl: "wss://test.jetstream",
216
216
+
oauthPublicUrl: "http://localhost:3000",
217
217
+
sessionSecret: "test-secret-at-least-32-characters-long",
218
218
+
sessionTtlDays: 7,
219
219
+
};
220
220
+
221
221
+
const client = postgres(config.databaseUrl);
222
222
+
const db = drizzle(client, { schema });
223
223
+
224
224
+
// Insert test forum
225
225
+
await db.insert(schema.forums).values({
226
226
+
did: config.forumDid,
227
227
+
rkey: "self",
228
228
+
cid: "bafytest",
229
229
+
name: "Test Forum",
230
230
+
description: "A test forum",
231
231
+
indexedAt: new Date(),
232
232
+
});
233
233
+
234
234
+
return {
235
235
+
db,
236
236
+
config,
237
237
+
cleanup: async () => {
238
238
+
await client.end();
239
239
+
},
240
240
+
};
241
241
+
}
242
242
+
```
243
243
+
244
244
+
**Step 4: Implement forum lookup helper**
245
245
+
246
246
+
Modify: `apps/appview/src/routes/helpers.ts:82` (add at end)
247
247
+
248
248
+
```typescript
249
249
+
import { forums } from "@atbb/db";
250
250
+
import { eq } from "drizzle-orm";
251
251
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
252
252
+
253
253
+
/**
254
254
+
* Look up forum by AT-URI.
255
255
+
* Returns null if forum doesn't exist.
256
256
+
*
257
257
+
* @param db Database instance
258
258
+
* @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self"
259
259
+
*/
260
260
+
export async function getForumByUri(
261
261
+
db: PostgresJsDatabase,
262
262
+
uri: string
263
263
+
): Promise<{ did: string; rkey: string; cid: string } | null> {
264
264
+
// Parse AT-URI: at://did/collection/rkey
265
265
+
const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/);
266
266
+
if (!match) {
267
267
+
return null;
268
268
+
}
269
269
+
270
270
+
const [, did, rkey] = match;
271
271
+
272
272
+
const [forum] = await db
273
273
+
.select({
274
274
+
did: forums.did,
275
275
+
rkey: forums.rkey,
276
276
+
cid: forums.cid,
277
277
+
})
278
278
+
.from(forums)
279
279
+
.where(eq(forums.did, did))
280
280
+
.where(eq(forums.rkey, rkey))
281
281
+
.limit(1);
282
282
+
283
283
+
return forum ?? null;
284
284
+
}
285
285
+
```
286
286
+
287
287
+
**Step 5: Run test to verify it passes**
288
288
+
289
289
+
Run:
290
290
+
```bash
291
291
+
cd apps/appview
292
292
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
293
293
+
```
294
294
+
295
295
+
Expected: PASS (8 tests)
296
296
+
297
297
+
**Step 6: Commit**
298
298
+
299
299
+
```bash
300
300
+
git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts apps/appview/src/lib/__tests__/test-context.ts
301
301
+
git commit -m "feat(appview): add forum lookup helper and test context"
302
302
+
```
303
303
+
304
304
+
---
305
305
+
306
306
+
## Task 3: Implement POST /api/topics Endpoint
307
307
+
308
308
+
**Files:**
309
309
+
- Modify: `apps/appview/src/routes/topics.ts:92-96` (replace stub)
310
310
+
311
311
+
**Step 1: Write failing integration test**
312
312
+
313
313
+
Create: `apps/appview/src/routes/__tests__/topics.test.ts`
314
314
+
315
315
+
```typescript
316
316
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
317
317
+
import { Hono } from "hono";
318
318
+
import { createTopicsRoutes } from "../topics.js";
319
319
+
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
320
320
+
import { requireAuth } from "../../middleware/auth.js";
321
321
+
import type { Variables } from "../../types.js";
322
322
+
323
323
+
describe("POST /api/topics", () => {
324
324
+
let ctx: TestContext;
325
325
+
let app: Hono<{ Variables: Variables }>;
326
326
+
327
327
+
beforeEach(async () => {
328
328
+
ctx = await createTestContext();
329
329
+
330
330
+
// Mock requireAuth middleware for testing
331
331
+
const mockAuth = vi.fn(async (c, next) => {
332
332
+
// Mock authenticated user with agent
333
333
+
c.set("user", {
334
334
+
did: "did:plc:test-user",
335
335
+
handle: "testuser.test",
336
336
+
pdsUrl: "https://test.pds",
337
337
+
agent: {
338
338
+
com: {
339
339
+
atproto: {
340
340
+
repo: {
341
341
+
putRecord: vi.fn(async () => ({
342
342
+
uri: "at://did:plc:test-user/space.atbb.post/3lbk7test",
343
343
+
cid: "bafytest",
344
344
+
})),
345
345
+
},
346
346
+
},
347
347
+
},
348
348
+
},
349
349
+
});
350
350
+
await next();
351
351
+
});
352
352
+
353
353
+
app = new Hono<{ Variables: Variables }>();
354
354
+
app.route("/api/topics", createTopicsRoutes(ctx).use("/*", mockAuth));
355
355
+
});
356
356
+
357
357
+
afterEach(async () => {
358
358
+
await ctx.cleanup();
359
359
+
});
360
360
+
361
361
+
it("creates topic with valid text", async () => {
362
362
+
const res = await app.request("/api/topics", {
363
363
+
method: "POST",
364
364
+
headers: { "Content-Type": "application/json" },
365
365
+
body: JSON.stringify({ text: "Hello, atBB!" }),
366
366
+
});
367
367
+
368
368
+
expect(res.status).toBe(201);
369
369
+
const data = await res.json();
370
370
+
expect(data.uri).toMatch(/^at:\/\/did:plc:test-user\/space\.atbb\.post\/3/);
371
371
+
expect(data.cid).toBeTruthy();
372
372
+
expect(data.rkey).toBeTruthy();
373
373
+
});
374
374
+
375
375
+
it("returns 400 for empty text", async () => {
376
376
+
const res = await app.request("/api/topics", {
377
377
+
method: "POST",
378
378
+
headers: { "Content-Type": "application/json" },
379
379
+
body: JSON.stringify({ text: " " }),
380
380
+
});
381
381
+
382
382
+
expect(res.status).toBe(400);
383
383
+
const data = await res.json();
384
384
+
expect(data.error).toContain("empty");
385
385
+
});
386
386
+
387
387
+
it("returns 400 for text exceeding 300 graphemes", async () => {
388
388
+
const res = await app.request("/api/topics", {
389
389
+
method: "POST",
390
390
+
headers: { "Content-Type": "application/json" },
391
391
+
body: JSON.stringify({ text: "a".repeat(301) }),
392
392
+
});
393
393
+
394
394
+
expect(res.status).toBe(400);
395
395
+
const data = await res.json();
396
396
+
expect(data.error).toContain("300 characters");
397
397
+
});
398
398
+
399
399
+
it("uses default forum URI when not provided", async () => {
400
400
+
const res = await app.request("/api/topics", {
401
401
+
method: "POST",
402
402
+
headers: { "Content-Type": "application/json" },
403
403
+
body: JSON.stringify({ text: "Test topic" }),
404
404
+
});
405
405
+
406
406
+
expect(res.status).toBe(201);
407
407
+
// Verify putRecord was called with correct forum ref
408
408
+
const user = app.get("user");
409
409
+
const putRecord = user.agent.com.atproto.repo.putRecord;
410
410
+
expect(putRecord).toHaveBeenCalledWith(
411
411
+
expect.objectContaining({
412
412
+
record: expect.objectContaining({
413
413
+
forum: expect.objectContaining({
414
414
+
forum: expect.objectContaining({
415
415
+
uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
416
416
+
}),
417
417
+
}),
418
418
+
}),
419
419
+
})
420
420
+
);
421
421
+
});
422
422
+
423
423
+
it("returns 404 when custom forum does not exist", async () => {
424
424
+
const res = await app.request("/api/topics", {
425
425
+
method: "POST",
426
426
+
headers: { "Content-Type": "application/json" },
427
427
+
body: JSON.stringify({
428
428
+
text: "Test",
429
429
+
forumUri: "at://did:plc:nonexistent/space.atbb.forum.forum/self",
430
430
+
}),
431
431
+
});
432
432
+
433
433
+
expect(res.status).toBe(404);
434
434
+
const data = await res.json();
435
435
+
expect(data.error).toContain("Forum not found");
436
436
+
});
437
437
+
});
438
438
+
```
439
439
+
440
440
+
**Step 2: Run test to verify it fails**
441
441
+
442
442
+
Run:
443
443
+
```bash
444
444
+
cd apps/appview
445
445
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts
446
446
+
```
447
447
+
448
448
+
Expected: FAIL with "not implemented" response
449
449
+
450
450
+
**Step 3: Implement POST /api/topics endpoint**
451
451
+
452
452
+
Modify: `apps/appview/src/routes/topics.ts:92-96`
453
453
+
454
454
+
Replace the stub:
455
455
+
```typescript
456
456
+
.post("/", (c) => {
457
457
+
// Phase 2: create space.atbb.post record with forumRef but no reply ref
458
458
+
// This requires authentication and PDS write operations
459
459
+
return c.json({ error: "not implemented" }, 501);
460
460
+
});
461
461
+
```
462
462
+
463
463
+
With the implementation:
464
464
+
```typescript
465
465
+
.post("/", requireAuth(ctx), async (c) => {
466
466
+
const user = c.get("user");
467
467
+
468
468
+
// Parse and validate request body
469
469
+
const body = await c.req.json();
470
470
+
const { text, forumUri: customForumUri } = body;
471
471
+
472
472
+
// Validate text
473
473
+
const validation = validatePostText(text);
474
474
+
if (!validation.valid) {
475
475
+
return c.json({ error: validation.error }, 400);
476
476
+
}
477
477
+
478
478
+
try {
479
479
+
// Resolve forum URI (default to singleton forum)
480
480
+
const forumUri =
481
481
+
customForumUri ??
482
482
+
`at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
483
483
+
484
484
+
// Look up forum to get CID
485
485
+
const forum = await getForumByUri(ctx.db, forumUri);
486
486
+
if (!forum) {
487
487
+
return c.json({ error: "Forum not found" }, 404);
488
488
+
}
489
489
+
490
490
+
// Generate TID for rkey
491
491
+
const rkey = TID.nextStr();
492
492
+
493
493
+
// Write to user's PDS
494
494
+
const result = await user.agent.com.atproto.repo.putRecord({
495
495
+
repo: user.did,
496
496
+
collection: "space.atbb.post",
497
497
+
rkey,
498
498
+
record: {
499
499
+
$type: "space.atbb.post",
500
500
+
text: validation.trimmed!,
501
501
+
forum: {
502
502
+
forum: { uri: forumUri, cid: forum.cid },
503
503
+
},
504
504
+
createdAt: new Date().toISOString(),
505
505
+
},
506
506
+
});
507
507
+
508
508
+
return c.json(
509
509
+
{
510
510
+
uri: result.uri,
511
511
+
cid: result.cid,
512
512
+
rkey,
513
513
+
},
514
514
+
201
515
515
+
);
516
516
+
} catch (error) {
517
517
+
console.error("Failed to create topic", {
518
518
+
operation: "POST /api/topics",
519
519
+
userId: user.did,
520
520
+
error: error instanceof Error ? error.message : String(error),
521
521
+
});
522
522
+
523
523
+
// Distinguish PDS errors from unexpected errors
524
524
+
if (error instanceof Error && error.message.includes("fetch failed")) {
525
525
+
return c.json(
526
526
+
{
527
527
+
error: "Unable to reach your PDS. Please try again later.",
528
528
+
},
529
529
+
503
530
530
+
);
531
531
+
}
532
532
+
533
533
+
return c.json(
534
534
+
{
535
535
+
error: "Failed to create topic. Please try again later.",
536
536
+
},
537
537
+
500
538
538
+
);
539
539
+
}
540
540
+
});
541
541
+
```
542
542
+
543
543
+
Add imports at top of file:
544
544
+
```typescript
545
545
+
import { TID } from "@atproto/common-web";
546
546
+
import { requireAuth } from "../middleware/auth.js";
547
547
+
import { validatePostText, getForumByUri } from "./helpers.js";
548
548
+
```
549
549
+
550
550
+
**Step 4: Run test to verify it passes**
551
551
+
552
552
+
Run:
553
553
+
```bash
554
554
+
cd apps/appview
555
555
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts
556
556
+
```
557
557
+
558
558
+
Expected: PASS (5 tests)
559
559
+
560
560
+
**Step 5: Commit**
561
561
+
562
562
+
```bash
563
563
+
git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts
564
564
+
git commit -m "feat(appview): implement POST /api/topics endpoint"
565
565
+
```
566
566
+
567
567
+
---
568
568
+
569
569
+
## Task 4: Add Post Lookup and Validation Helpers
570
570
+
571
571
+
**Files:**
572
572
+
- Modify: `apps/appview/src/routes/helpers.ts:125` (add at end)
573
573
+
574
574
+
**Step 1: Write failing test for post lookup**
575
575
+
576
576
+
Modify: `apps/appview/src/routes/__tests__/helpers.test.ts` (add at end)
577
577
+
578
578
+
```typescript
579
579
+
import { getPostsByIds, validateReplyParent } from "../helpers.js";
580
580
+
import { posts, users } from "@atbb/db";
581
581
+
582
582
+
describe("getPostsByIds", () => {
583
583
+
let ctx: TestContext;
584
584
+
585
585
+
beforeEach(async () => {
586
586
+
ctx = await createTestContext();
587
587
+
588
588
+
// Insert test user
589
589
+
await ctx.db.insert(users).values({
590
590
+
did: "did:plc:test-user",
591
591
+
handle: "testuser.test",
592
592
+
indexedAt: new Date(),
593
593
+
});
594
594
+
595
595
+
// Insert test posts
596
596
+
await ctx.db.insert(posts).values([
597
597
+
{
598
598
+
did: "did:plc:test-user",
599
599
+
rkey: "3lbk7topic",
600
600
+
cid: "bafytopic",
601
601
+
text: "Topic post",
602
602
+
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
603
603
+
createdAt: new Date(),
604
604
+
indexedAt: new Date(),
605
605
+
deleted: false,
606
606
+
},
607
607
+
{
608
608
+
did: "did:plc:test-user",
609
609
+
rkey: "3lbk8reply",
610
610
+
cid: "bafyreply",
611
611
+
text: "Reply post",
612
612
+
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
613
613
+
rootPostId: 1n, // Assuming topic has id=1
614
614
+
parentPostId: 1n,
615
615
+
rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
616
616
+
parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
617
617
+
createdAt: new Date(),
618
618
+
indexedAt: new Date(),
619
619
+
deleted: false,
620
620
+
},
621
621
+
]);
622
622
+
});
623
623
+
624
624
+
afterEach(async () => {
625
625
+
await ctx.cleanup();
626
626
+
});
627
627
+
628
628
+
it("returns posts when they exist", async () => {
629
629
+
const result = await getPostsByIds(ctx.db, [1n, 2n]);
630
630
+
631
631
+
expect(result.size).toBe(2);
632
632
+
expect(result.get(1n)?.rkey).toBe("3lbk7topic");
633
633
+
expect(result.get(2n)?.rkey).toBe("3lbk8reply");
634
634
+
});
635
635
+
636
636
+
it("excludes deleted posts", async () => {
637
637
+
// Mark topic as deleted
638
638
+
await ctx.db
639
639
+
.update(posts)
640
640
+
.set({ deleted: true })
641
641
+
.where(eq(posts.id, 1n));
642
642
+
643
643
+
const result = await getPostsByIds(ctx.db, [1n, 2n]);
644
644
+
645
645
+
expect(result.size).toBe(1);
646
646
+
expect(result.has(1n)).toBe(false);
647
647
+
expect(result.has(2n)).toBe(true);
648
648
+
});
649
649
+
650
650
+
it("returns empty map for non-existent IDs", async () => {
651
651
+
const result = await getPostsByIds(ctx.db, [999n]);
652
652
+
653
653
+
expect(result.size).toBe(0);
654
654
+
});
655
655
+
});
656
656
+
657
657
+
describe("validateReplyParent", () => {
658
658
+
it("accepts when parent IS the root", () => {
659
659
+
const root = { id: 1n, rootPostId: null };
660
660
+
const parent = { id: 1n, rootPostId: null };
661
661
+
662
662
+
const result = validateReplyParent(root, parent, 1n);
663
663
+
664
664
+
expect(result.valid).toBe(true);
665
665
+
});
666
666
+
667
667
+
it("accepts when parent is a reply in same thread", () => {
668
668
+
const root = { id: 1n, rootPostId: null };
669
669
+
const parent = { id: 2n, rootPostId: 1n };
670
670
+
671
671
+
const result = validateReplyParent(root, parent, 1n);
672
672
+
673
673
+
expect(result.valid).toBe(true);
674
674
+
});
675
675
+
676
676
+
it("rejects when parent belongs to different thread", () => {
677
677
+
const root = { id: 1n, rootPostId: null };
678
678
+
const parent = { id: 2n, rootPostId: 99n }; // Different root
679
679
+
680
680
+
const result = validateReplyParent(root, parent, 1n);
681
681
+
682
682
+
expect(result.valid).toBe(false);
683
683
+
expect(result.error).toContain("different thread");
684
684
+
});
685
685
+
686
686
+
it("rejects when parent is a root but not THE root", () => {
687
687
+
const root = { id: 1n, rootPostId: null };
688
688
+
const parent = { id: 2n, rootPostId: null }; // Also a root, but different
689
689
+
690
690
+
const result = validateReplyParent(root, parent, 1n);
691
691
+
692
692
+
expect(result.valid).toBe(false);
693
693
+
expect(result.error).toContain("different thread");
694
694
+
});
695
695
+
});
696
696
+
```
697
697
+
698
698
+
**Step 2: Run test to verify it fails**
699
699
+
700
700
+
Run:
701
701
+
```bash
702
702
+
cd apps/appview
703
703
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
704
704
+
```
705
705
+
706
706
+
Expected: FAIL with "getPostsByIds is not exported"
707
707
+
708
708
+
**Step 3: Implement post lookup and validation helpers**
709
709
+
710
710
+
Modify: `apps/appview/src/routes/helpers.ts:125` (add at end)
711
711
+
712
712
+
```typescript
713
713
+
import { posts } from "@atbb/db";
714
714
+
import { inArray, and, eq } from "drizzle-orm";
715
715
+
716
716
+
export type PostRow = typeof posts.$inferSelect;
717
717
+
718
718
+
/**
719
719
+
* Look up multiple posts by ID in a single query.
720
720
+
* Excludes deleted posts.
721
721
+
* Returns a Map for O(1) lookup.
722
722
+
*/
723
723
+
export async function getPostsByIds(
724
724
+
db: PostgresJsDatabase,
725
725
+
ids: bigint[]
726
726
+
): Promise<Map<bigint, PostRow>> {
727
727
+
if (ids.length === 0) {
728
728
+
return new Map();
729
729
+
}
730
730
+
731
731
+
const results = await db
732
732
+
.select()
733
733
+
.from(posts)
734
734
+
.where(and(inArray(posts.id, ids), eq(posts.deleted, false)));
735
735
+
736
736
+
return new Map(results.map((post) => [post.id, post]));
737
737
+
}
738
738
+
739
739
+
/**
740
740
+
* Validate that a parent post belongs to the same thread as the root.
741
741
+
*
742
742
+
* Rules:
743
743
+
* - Parent can BE the root (replying directly to topic)
744
744
+
* - Parent can be a reply in the same thread (parent.rootPostId === rootId)
745
745
+
* - Parent cannot belong to a different thread
746
746
+
*/
747
747
+
export function validateReplyParent(
748
748
+
root: { id: bigint; rootPostId: bigint | null },
749
749
+
parent: { id: bigint; rootPostId: bigint | null },
750
750
+
rootId: bigint
751
751
+
): { valid: boolean; error?: string } {
752
752
+
// Parent IS the root (replying to topic)
753
753
+
if (parent.id === rootId && parent.rootPostId === null) {
754
754
+
return { valid: true };
755
755
+
}
756
756
+
757
757
+
// Parent is a reply in the same thread
758
758
+
if (parent.rootPostId === rootId) {
759
759
+
return { valid: true };
760
760
+
}
761
761
+
762
762
+
// Parent belongs to a different thread
763
763
+
return {
764
764
+
valid: false,
765
765
+
error: "Parent post does not belong to this thread",
766
766
+
};
767
767
+
}
768
768
+
```
769
769
+
770
770
+
**Step 4: Run test to verify it passes**
771
771
+
772
772
+
Run:
773
773
+
```bash
774
774
+
cd apps/appview
775
775
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts
776
776
+
```
777
777
+
778
778
+
Expected: PASS (14 tests)
779
779
+
780
780
+
**Step 5: Commit**
781
781
+
782
782
+
```bash
783
783
+
git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts
784
784
+
git commit -m "feat(appview): add post lookup and reply validation helpers"
785
785
+
```
786
786
+
787
787
+
---
788
788
+
789
789
+
## Task 5: Implement POST /api/posts Endpoint
790
790
+
791
791
+
**Files:**
792
792
+
- Modify: `apps/appview/src/routes/posts.ts:1-6` (replace entire file)
793
793
+
794
794
+
**Step 1: Write failing integration test**
795
795
+
796
796
+
Create: `apps/appview/src/routes/__tests__/posts.test.ts`
797
797
+
798
798
+
```typescript
799
799
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
800
800
+
import { Hono } from "hono";
801
801
+
import { createPostsRoutes } from "../posts.js";
802
802
+
import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
803
803
+
import type { Variables } from "../../types.js";
804
804
+
import { posts, users } from "@atbb/db";
805
805
+
806
806
+
describe("POST /api/posts", () => {
807
807
+
let ctx: TestContext;
808
808
+
let app: Hono<{ Variables: Variables }>;
809
809
+
810
810
+
beforeEach(async () => {
811
811
+
ctx = await createTestContext();
812
812
+
813
813
+
// Insert test user
814
814
+
await ctx.db.insert(users).values({
815
815
+
did: "did:plc:test-user",
816
816
+
handle: "testuser.test",
817
817
+
indexedAt: new Date(),
818
818
+
});
819
819
+
820
820
+
// Insert topic (root post)
821
821
+
await ctx.db.insert(posts).values({
822
822
+
did: "did:plc:test-user",
823
823
+
rkey: "3lbk7topic",
824
824
+
cid: "bafytopic",
825
825
+
text: "Topic post",
826
826
+
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
827
827
+
createdAt: new Date(),
828
828
+
indexedAt: new Date(),
829
829
+
deleted: false,
830
830
+
});
831
831
+
832
832
+
// Insert reply
833
833
+
await ctx.db.insert(posts).values({
834
834
+
did: "did:plc:test-user",
835
835
+
rkey: "3lbk8reply",
836
836
+
cid: "bafyreply",
837
837
+
text: "Reply post",
838
838
+
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
839
839
+
rootPostId: 1n,
840
840
+
parentPostId: 1n,
841
841
+
rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
842
842
+
parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
843
843
+
createdAt: new Date(),
844
844
+
indexedAt: new Date(),
845
845
+
deleted: false,
846
846
+
});
847
847
+
848
848
+
// Mock auth middleware
849
849
+
const mockAuth = vi.fn(async (c, next) => {
850
850
+
c.set("user", {
851
851
+
did: "did:plc:test-user",
852
852
+
handle: "testuser.test",
853
853
+
pdsUrl: "https://test.pds",
854
854
+
agent: {
855
855
+
com: {
856
856
+
atproto: {
857
857
+
repo: {
858
858
+
putRecord: vi.fn(async () => ({
859
859
+
uri: "at://did:plc:test-user/space.atbb.post/3lbk9test",
860
860
+
cid: "bafytest",
861
861
+
})),
862
862
+
},
863
863
+
},
864
864
+
},
865
865
+
},
866
866
+
});
867
867
+
await next();
868
868
+
});
869
869
+
870
870
+
app = new Hono<{ Variables: Variables }>();
871
871
+
app.route("/api/posts", createPostsRoutes(ctx).use("/*", mockAuth));
872
872
+
});
873
873
+
874
874
+
afterEach(async () => {
875
875
+
await ctx.cleanup();
876
876
+
});
877
877
+
878
878
+
it("creates reply to topic", async () => {
879
879
+
const res = await app.request("/api/posts", {
880
880
+
method: "POST",
881
881
+
headers: { "Content-Type": "application/json" },
882
882
+
body: JSON.stringify({
883
883
+
text: "My reply",
884
884
+
rootPostId: "1",
885
885
+
parentPostId: "1",
886
886
+
}),
887
887
+
});
888
888
+
889
889
+
expect(res.status).toBe(201);
890
890
+
const data = await res.json();
891
891
+
expect(data.uri).toBeTruthy();
892
892
+
expect(data.cid).toBeTruthy();
893
893
+
expect(data.rkey).toBeTruthy();
894
894
+
});
895
895
+
896
896
+
it("creates reply to reply", async () => {
897
897
+
const res = await app.request("/api/posts", {
898
898
+
method: "POST",
899
899
+
headers: { "Content-Type": "application/json" },
900
900
+
body: JSON.stringify({
901
901
+
text: "Nested reply",
902
902
+
rootPostId: "1",
903
903
+
parentPostId: "2", // Reply to the reply
904
904
+
}),
905
905
+
});
906
906
+
907
907
+
expect(res.status).toBe(201);
908
908
+
});
909
909
+
910
910
+
it("returns 400 for invalid parent ID format", async () => {
911
911
+
const res = await app.request("/api/posts", {
912
912
+
method: "POST",
913
913
+
headers: { "Content-Type": "application/json" },
914
914
+
body: JSON.stringify({
915
915
+
text: "Test",
916
916
+
rootPostId: "not-a-number",
917
917
+
parentPostId: "1",
918
918
+
}),
919
919
+
});
920
920
+
921
921
+
expect(res.status).toBe(400);
922
922
+
const data = await res.json();
923
923
+
expect(data.error).toContain("Invalid");
924
924
+
});
925
925
+
926
926
+
it("returns 404 when root post does not exist", async () => {
927
927
+
const res = await app.request("/api/posts", {
928
928
+
method: "POST",
929
929
+
headers: { "Content-Type": "application/json" },
930
930
+
body: JSON.stringify({
931
931
+
text: "Test",
932
932
+
rootPostId: "999",
933
933
+
parentPostId: "999",
934
934
+
}),
935
935
+
});
936
936
+
937
937
+
expect(res.status).toBe(404);
938
938
+
const data = await res.json();
939
939
+
expect(data.error).toContain("not found");
940
940
+
});
941
941
+
942
942
+
it("returns 404 when parent post does not exist", async () => {
943
943
+
const res = await app.request("/api/posts", {
944
944
+
method: "POST",
945
945
+
headers: { "Content-Type": "application/json" },
946
946
+
body: JSON.stringify({
947
947
+
text: "Test",
948
948
+
rootPostId: "1",
949
949
+
parentPostId: "999",
950
950
+
}),
951
951
+
});
952
952
+
953
953
+
expect(res.status).toBe(404);
954
954
+
const data = await res.json();
955
955
+
expect(data.error).toContain("not found");
956
956
+
});
957
957
+
958
958
+
it("returns 400 when parent belongs to different thread", async () => {
959
959
+
// Insert a different topic
960
960
+
await ctx.db.insert(posts).values({
961
961
+
did: "did:plc:test-user",
962
962
+
rkey: "3lbkaother",
963
963
+
cid: "bafyother",
964
964
+
text: "Other topic",
965
965
+
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
966
966
+
createdAt: new Date(),
967
967
+
indexedAt: new Date(),
968
968
+
deleted: false,
969
969
+
});
970
970
+
971
971
+
const res = await app.request("/api/posts", {
972
972
+
method: "POST",
973
973
+
headers: { "Content-Type": "application/json" },
974
974
+
body: JSON.stringify({
975
975
+
text: "Test",
976
976
+
rootPostId: "1",
977
977
+
parentPostId: "3", // Different thread
978
978
+
}),
979
979
+
});
980
980
+
981
981
+
expect(res.status).toBe(400);
982
982
+
const data = await res.json();
983
983
+
expect(data.error).toContain("thread");
984
984
+
});
985
985
+
});
986
986
+
```
987
987
+
988
988
+
**Step 2: Run test to verify it fails**
989
989
+
990
990
+
Run:
991
991
+
```bash
992
992
+
cd apps/appview
993
993
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts
994
994
+
```
995
995
+
996
996
+
Expected: FAIL with "not implemented"
997
997
+
998
998
+
**Step 3: Implement POST /api/posts endpoint**
999
999
+
1000
1000
+
Modify: `apps/appview/src/routes/posts.ts` (replace entire file)
1001
1001
+
1002
1002
+
```typescript
1003
1003
+
import { Hono } from "hono";
1004
1004
+
import { TID } from "@atproto/common-web";
1005
1005
+
import type { AppContext } from "../lib/app-context.js";
1006
1006
+
import { requireAuth } from "../middleware/auth.js";
1007
1007
+
import {
1008
1008
+
validatePostText,
1009
1009
+
parseBigIntParam,
1010
1010
+
getPostsByIds,
1011
1011
+
validateReplyParent,
1012
1012
+
} from "./helpers.js";
1013
1013
+
1014
1014
+
export function createPostsRoutes(ctx: AppContext) {
1015
1015
+
return new Hono().post("/", requireAuth(ctx), async (c) => {
1016
1016
+
const user = c.get("user");
1017
1017
+
1018
1018
+
// Parse and validate request body
1019
1019
+
const body = await c.req.json();
1020
1020
+
const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body;
1021
1021
+
1022
1022
+
// Validate text
1023
1023
+
const validation = validatePostText(text);
1024
1024
+
if (!validation.valid) {
1025
1025
+
return c.json({ error: validation.error }, 400);
1026
1026
+
}
1027
1027
+
1028
1028
+
// Parse IDs
1029
1029
+
const rootId = parseBigIntParam(rootIdStr);
1030
1030
+
const parentId = parseBigIntParam(parentIdStr);
1031
1031
+
1032
1032
+
if (rootId === null || parentId === null) {
1033
1033
+
return c.json(
1034
1034
+
{
1035
1035
+
error: "Invalid post ID format. IDs must be numeric strings.",
1036
1036
+
},
1037
1037
+
400
1038
1038
+
);
1039
1039
+
}
1040
1040
+
1041
1041
+
try {
1042
1042
+
// Look up root and parent posts
1043
1043
+
const postsMap = await getPostsByIds(ctx.db, [rootId, parentId]);
1044
1044
+
1045
1045
+
const root = postsMap.get(rootId);
1046
1046
+
const parent = postsMap.get(parentId);
1047
1047
+
1048
1048
+
if (!root) {
1049
1049
+
return c.json({ error: "Root post not found" }, 404);
1050
1050
+
}
1051
1051
+
1052
1052
+
if (!parent) {
1053
1053
+
return c.json({ error: "Parent post not found" }, 404);
1054
1054
+
}
1055
1055
+
1056
1056
+
// Validate parent belongs to same thread
1057
1057
+
const parentValidation = validateReplyParent(root, parent, rootId);
1058
1058
+
if (!parentValidation.valid) {
1059
1059
+
return c.json({ error: parentValidation.error }, 400);
1060
1060
+
}
1061
1061
+
1062
1062
+
// Construct AT-URIs
1063
1063
+
const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`;
1064
1064
+
const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`;
1065
1065
+
1066
1066
+
// Generate TID for rkey
1067
1067
+
const rkey = TID.nextStr();
1068
1068
+
1069
1069
+
// Write to user's PDS
1070
1070
+
const result = await user.agent.com.atproto.repo.putRecord({
1071
1071
+
repo: user.did,
1072
1072
+
collection: "space.atbb.post",
1073
1073
+
rkey,
1074
1074
+
record: {
1075
1075
+
$type: "space.atbb.post",
1076
1076
+
text: validation.trimmed!,
1077
1077
+
forum: {
1078
1078
+
forum: { uri: root.forumUri!, cid: root.cid },
1079
1079
+
},
1080
1080
+
reply: {
1081
1081
+
root: { uri: rootUri, cid: root.cid },
1082
1082
+
parent: { uri: parentUri, cid: parent.cid },
1083
1083
+
},
1084
1084
+
createdAt: new Date().toISOString(),
1085
1085
+
},
1086
1086
+
});
1087
1087
+
1088
1088
+
return c.json(
1089
1089
+
{
1090
1090
+
uri: result.uri,
1091
1091
+
cid: result.cid,
1092
1092
+
rkey,
1093
1093
+
},
1094
1094
+
201
1095
1095
+
);
1096
1096
+
} catch (error) {
1097
1097
+
console.error("Failed to create post", {
1098
1098
+
operation: "POST /api/posts",
1099
1099
+
userId: user.did,
1100
1100
+
rootId: rootIdStr,
1101
1101
+
parentId: parentIdStr,
1102
1102
+
error: error instanceof Error ? error.message : String(error),
1103
1103
+
});
1104
1104
+
1105
1105
+
// Distinguish PDS errors from unexpected errors
1106
1106
+
if (error instanceof Error && error.message.includes("fetch failed")) {
1107
1107
+
return c.json(
1108
1108
+
{
1109
1109
+
error: "Unable to reach your PDS. Please try again later.",
1110
1110
+
},
1111
1111
+
503
1112
1112
+
);
1113
1113
+
}
1114
1114
+
1115
1115
+
return c.json(
1116
1116
+
{
1117
1117
+
error: "Failed to create post. Please try again later.",
1118
1118
+
},
1119
1119
+
500
1120
1120
+
);
1121
1121
+
}
1122
1122
+
});
1123
1123
+
}
1124
1124
+
```
1125
1125
+
1126
1126
+
**Step 4: Update posts route registration**
1127
1127
+
1128
1128
+
Modify: `apps/appview/src/index.ts` (find where `postsRoutes` is registered)
1129
1129
+
1130
1130
+
Replace:
1131
1131
+
```typescript
1132
1132
+
import { postsRoutes } from "./routes/posts.js";
1133
1133
+
app.route("/api/posts", postsRoutes);
1134
1134
+
```
1135
1135
+
1136
1136
+
With:
1137
1137
+
```typescript
1138
1138
+
import { createPostsRoutes } from "./routes/posts.js";
1139
1139
+
app.route("/api/posts", createPostsRoutes(appContext));
1140
1140
+
```
1141
1141
+
1142
1142
+
**Step 5: Run test to verify it passes**
1143
1143
+
1144
1144
+
Run:
1145
1145
+
```bash
1146
1146
+
cd apps/appview
1147
1147
+
PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts
1148
1148
+
```
1149
1149
+
1150
1150
+
Expected: PASS (6 tests)
1151
1151
+
1152
1152
+
**Step 6: Commit**
1153
1153
+
1154
1154
+
```bash
1155
1155
+
git add apps/appview/src/routes/posts.ts apps/appview/src/routes/__tests__/posts.test.ts apps/appview/src/index.ts
1156
1156
+
git commit -m "feat(appview): implement POST /api/posts endpoint for replies"
1157
1157
+
```
1158
1158
+
1159
1159
+
---
1160
1160
+
1161
1161
+
## Task 6: Update Documentation and Linear
1162
1162
+
1163
1163
+
**Files:**
1164
1164
+
- Modify: `docs/atproto-forum-plan.md` (mark ATB-12 complete)
1165
1165
+
1166
1166
+
**Step 1: Mark ATB-12 complete in plan document**
1167
1167
+
1168
1168
+
Modify: `docs/atproto-forum-plan.md`
1169
1169
+
1170
1170
+
Find the ATB-12 checkbox and update it:
1171
1171
+
```markdown
1172
1172
+
- [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`)
1173
1173
+
```
1174
1174
+
1175
1175
+
**Step 2: Commit documentation update**
1176
1176
+
1177
1177
+
```bash
1178
1178
+
git add docs/atproto-forum-plan.md
1179
1179
+
git commit -m "docs: mark ATB-12 complete in project plan"
1180
1180
+
```
1181
1181
+
1182
1182
+
**Step 3: Update Linear issue**
1183
1183
+
1184
1184
+
Update ATB-12 status to Done and add completion comment with implementation summary and file references.
1185
1185
+
1186
1186
+
---
1187
1187
+
1188
1188
+
## Summary
1189
1189
+
1190
1190
+
**Total Tasks:** 6
1191
1191
+
**Estimated Time:** 2-3 hours
1192
1192
+
**Test Coverage:** Unit tests for helpers, integration tests for endpoints
1193
1193
+
**Dependencies:** @atproto/api (UnicodeString), @atproto/common-web (TID), requireAuth middleware from ATB-14
1194
1194
+
1195
1195
+
**Key Implementation Points:**
1196
1196
+
- Grapheme validation ensures proper Unicode handling (emoji count correctly)
1197
1197
+
- Fire-and-forget design: return immediately after PDS write
1198
1198
+
- No optimistic DB writes (indexer handles that asynchronously)
1199
1199
+
- Comprehensive error handling with structured logging
1200
1200
+
- Thread validation prevents replies to wrong topics
1201
1201
+
1202
1202
+
**Testing Strategy:**
1203
1203
+
- Mock `user.agent.com.atproto.repo.putRecord()` for fast tests
1204
1204
+
- Use real database with test fixtures for integration tests
1205
1205
+
- Verify error cases: empty text, text too long, invalid IDs, missing posts, wrong thread