@takosjp/yurucommu-core 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,479 @@
1
+ import { Hono } from "hono";
2
+ import { and, desc, eq, isNull, sql } from "drizzle-orm";
3
+ import {
4
+ activities,
5
+ communities,
6
+ communityMembers,
7
+ objectRecipients,
8
+ objects,
9
+ } from "../../../db/index.ts";
10
+ import type { Env, Variables } from "../../types.ts";
11
+ import { formatUsername, generateId } from "../../federation-helpers.ts";
12
+ import { feedCursorWhere } from "../../lib/feed-cursor.ts";
13
+ import { communityRequiresMembership } from "../../lib/community-visibility.ts";
14
+ import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
15
+ import {
16
+ deleteObjectCascade,
17
+ purgeMediaBlobs,
18
+ } from "../posts/delete-cascade.ts";
19
+ import {
20
+ batchLoadActorInfo,
21
+ communityWhere,
22
+ fetchCommunityId,
23
+ managerRoles,
24
+ memberWhere,
25
+ resolveCommunityApId,
26
+ } from "./membership-shared.ts";
27
+
28
+ const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
29
+ const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
30
+
31
+ // D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
32
+ // driver, not the shared `Database` union; reach it through a narrow cast.
33
+ type Batchable = {
34
+ batch(statements: readonly unknown[]): Promise<unknown>;
35
+ };
36
+
37
+ const messagesRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
38
+
39
+ /**
40
+ * Enforce post policy against the actor's membership and role.
41
+ * Returns an error message string if denied, or null if allowed.
42
+ *
43
+ * This is a WRITE policy and must only gate POST/PATCH. Read access is
44
+ * governed separately by `checkReadAccess` (membership / `visibility`), so
45
+ * that e.g. a `post_policy: "mods"` community does not lock ordinary members
46
+ * out of reading channels they are entitled to see.
47
+ */
48
+ function checkPostPolicy(
49
+ policy: string,
50
+ visibility: string,
51
+ membership: { role: string } | null,
52
+ ): string | null {
53
+ const role = membership?.role;
54
+ const isManager = role != null && managerRoles.has(role);
55
+
56
+ // A non-public community requires membership to WRITE regardless of policy:
57
+ // read is membership-gated (checkReadAccess), so a private community with
58
+ // post_policy="anyone" must not let a non-member who cannot read it post.
59
+ if (communityRequiresMembership(visibility) && !membership)
60
+ return "Not a community member";
61
+ if (policy !== "anyone" && !membership) return "Not a community member";
62
+ if (policy === "mods" && !isManager) return "Moderator role required";
63
+ if (policy === "owners" && role !== "owner") return "Owner role required";
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Authorize READING community messages based on `visibility` and membership,
69
+ * independent of who may post. Public communities are readable by anyone;
70
+ * private communities require membership. Returns an error message string if
71
+ * denied, or null if allowed.
72
+ */
73
+ function checkReadAccess(
74
+ visibility: string,
75
+ membership: { role: string } | null,
76
+ ): string | null {
77
+ if (!communityRequiresMembership(visibility)) return null;
78
+ if (!membership) return "Not a community member";
79
+ return null;
80
+ }
81
+
82
+ // GET /api/communities/:name/messages - Get chat messages
83
+ messagesRouter.get("/:identifier/messages", async (c) => {
84
+ const actor = c.get("actor");
85
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
86
+
87
+ const identifier = c.req.param("identifier")!;
88
+ const db = c.get("db");
89
+ const baseUrl = c.env.APP_URL;
90
+ const apId = resolveCommunityApId(baseUrl, identifier);
91
+ const rawLimit = parseInt(c.req.query("limit") || "50", 10);
92
+ const limit = Number.isFinite(rawLimit)
93
+ ? Math.min(Math.max(rawLimit, 1), MAX_COMMUNITY_MESSAGES_LIMIT)
94
+ : 50;
95
+ const before = c.req.query("before");
96
+
97
+ const community = await db
98
+ .select()
99
+ .from(communities)
100
+ .where(communityWhere(apId, identifier))
101
+ .get();
102
+ if (!community) {
103
+ return c.json({ error: "Community not found" }, 404);
104
+ }
105
+
106
+ const membership = await db
107
+ .select()
108
+ .from(communityMembers)
109
+ .where(memberWhere(community.apId, actor.ap_id))
110
+ .get();
111
+
112
+ // Read access is governed by community visibility + membership, NOT by the
113
+ // post policy (which only controls who may write).
114
+ const readError = checkReadAccess(
115
+ community.visibility || "public",
116
+ membership ?? null,
117
+ );
118
+ if (readError) {
119
+ return c.json({ error: readError }, 403);
120
+ }
121
+
122
+ // Query objects addressed to this community (via object_recipients) with a
123
+ // single INNER JOIN, NOT a two-step "load every recipient id then IN (...)".
124
+ // The old shape materialized the community's ENTIRE chat history into an
125
+ // `objectApIds` array and an `inArray` bound-parameter list every request:
126
+ // memory O(all messages) and — past SQLite's bound-variable ceiling — a hard
127
+ // error on a busy channel. The join filters on the indexed `recipient_ap_id`
128
+ // and pages with LIMIT, so the work is bounded by the page size.
129
+ //
130
+ // The group-chat reader must return CHAT messages only, not community feed
131
+ // posts. Feed posts are stored with `communityApId` set (and are surfaced by
132
+ // the community-scoped feed), whereas chat messages are addressed purely via
133
+ // object_recipients and leave `communityApId` NULL. Filtering on
134
+ // `communityApId IS NULL` keeps the chat object-set disjoint from the feed
135
+ // object-set, matching the unread count in GET /dm/contacts.
136
+ const whereConditions = [
137
+ eq(objectRecipients.recipientApId, community.apId),
138
+ eq(objectRecipients.type, "audience"),
139
+ eq(objects.type, "Note"),
140
+ isNull(objects.communityApId),
141
+ ];
142
+ // Composite (published, apId) cursor so same-millisecond messages aren't
143
+ // skipped on a load-older boundary (see lib/feed-cursor.ts).
144
+ const chatCursor = feedCursorWhere(objects.published, objects.apId, before);
145
+ if (chatCursor) whereConditions.push(chatCursor);
146
+
147
+ // Fetch one extra to detect whether an OLDER page exists (powers the thread's
148
+ // "load older" affordance; `before` is the oldest shown message's cursor).
149
+ const fetched = await db
150
+ .select({
151
+ apId: objects.apId,
152
+ attributedTo: objects.attributedTo,
153
+ content: objects.content,
154
+ published: objects.published,
155
+ })
156
+ .from(objectRecipients)
157
+ .innerJoin(objects, eq(objectRecipients.objectApId, objects.apId))
158
+ .where(and(...whereConditions))
159
+ .orderBy(desc(objects.published), desc(objects.apId))
160
+ .limit(limit + 1);
161
+ const hasMore = fetched.length > limit;
162
+ const messages = hasMore ? fetched.slice(0, limit) : fetched;
163
+
164
+ const senderApIds = [...new Set(messages.map((msg) => msg.attributedTo))];
165
+ const actorInfoMap = await batchLoadActorInfo(db, senderApIds);
166
+
167
+ const result = messages.reverse().map((msg) => {
168
+ const senderInfo = actorInfoMap.get(msg.attributedTo);
169
+ return {
170
+ id: msg.apId,
171
+ sender: {
172
+ ap_id: msg.attributedTo,
173
+ username: formatUsername(msg.attributedTo),
174
+ preferred_username: senderInfo?.preferredUsername || null,
175
+ name: senderInfo?.name || null,
176
+ icon_url: senderInfo?.iconUrl || null,
177
+ },
178
+ content: msg.content,
179
+ created_at: msg.published,
180
+ };
181
+ });
182
+
183
+ return c.json({ messages: result, has_more: hasMore });
184
+ });
185
+
186
+ // POST /api/communities/:name/messages - Send a chat message
187
+ // Rate-limited as a publish-like write (per-actor), not under the general bucket.
188
+ messagesRouter.post(
189
+ "/:identifier/messages",
190
+ rateLimit(RateLimitConfigs.communityMessage),
191
+ async (c) => {
192
+ const actor = c.get("actor");
193
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
194
+
195
+ const identifier = c.req.param("identifier")!;
196
+ const db = c.get("db");
197
+ const baseUrl = c.env.APP_URL;
198
+ const apId = resolveCommunityApId(baseUrl, identifier);
199
+ const body = await c.req.json<{ content: string }>();
200
+
201
+ // Guard non-string content before .trim() (else TypeError → 500).
202
+ if (typeof body.content !== "string") {
203
+ return c.json({ error: "Message content is required" }, 400);
204
+ }
205
+ const content = body.content.trim();
206
+ if (!content) {
207
+ return c.json({ error: "Message content is required" }, 400);
208
+ }
209
+ if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
210
+ return c.json(
211
+ {
212
+ error: `Message too long (max ${MAX_COMMUNITY_MESSAGE_LENGTH} chars)`,
213
+ },
214
+ 400,
215
+ );
216
+ }
217
+
218
+ const community = await db
219
+ .select()
220
+ .from(communities)
221
+ .where(communityWhere(apId, identifier))
222
+ .get();
223
+ if (!community) {
224
+ return c.json({ error: "Community not found" }, 404);
225
+ }
226
+
227
+ const membership = await db
228
+ .select()
229
+ .from(communityMembers)
230
+ .where(memberWhere(community.apId, actor.ap_id))
231
+ .get();
232
+
233
+ const policyError = checkPostPolicy(
234
+ community.postPolicy || "members",
235
+ community.visibility || "public",
236
+ membership ?? null,
237
+ );
238
+ if (policyError) {
239
+ return c.json(
240
+ {
241
+ error:
242
+ policyError === "Not a community member"
243
+ ? "Not a member"
244
+ : policyError,
245
+ },
246
+ 403,
247
+ );
248
+ }
249
+
250
+ const objectId = generateId();
251
+ const objectApId = `${baseUrl}/ap/objects/${objectId}`;
252
+ const now = new Date().toISOString();
253
+
254
+ const toJson = JSON.stringify([community.apId]);
255
+ const audienceJson = JSON.stringify([community.apId]);
256
+
257
+ const activityId = generateId();
258
+ const activityApIdVal = `${baseUrl}/ap/activities/${activityId}`;
259
+
260
+ // Persist the chat message atomically: the Note, its community-audience
261
+ // recipient row (which the GET-messages reader joins on), the Create
262
+ // activity, and the community's lastMessageAt. D1 has no interactive
263
+ // transactions; batch() is atomic, so a mid-write failure can no longer
264
+ // leave an orphan Note with no recipient row. The recipient row is a plain
265
+ // Drizzle insert now that recipient_ap_id no longer has a (wrong) FK to
266
+ // actors — a community apId is a valid recipient (migration 0010).
267
+ await (db as unknown as Batchable).batch([
268
+ db.insert(objects).values({
269
+ apId: objectApId,
270
+ type: "Note",
271
+ attributedTo: actor.ap_id,
272
+ content,
273
+ toJson,
274
+ audienceJson,
275
+ visibility: "unlisted",
276
+ published: now,
277
+ isLocal: 1,
278
+ }),
279
+ db.insert(objectRecipients).values({
280
+ objectApId,
281
+ recipientApId: community.apId,
282
+ type: "audience",
283
+ createdAt: now,
284
+ }),
285
+ db.insert(activities).values({
286
+ apId: activityApIdVal,
287
+ type: "Create",
288
+ actorApId: actor.ap_id,
289
+ objectApId,
290
+ rawJson: JSON.stringify({ to: JSON.parse(toJson) }),
291
+ }),
292
+ db
293
+ .update(communities)
294
+ .set({ lastMessageAt: now })
295
+ .where(eq(communities.apId, community.apId)),
296
+ ]);
297
+
298
+ return c.json(
299
+ {
300
+ message: {
301
+ id: objectApId,
302
+ sender: {
303
+ ap_id: actor.ap_id,
304
+ username: formatUsername(actor.ap_id),
305
+ preferred_username: actor.preferred_username,
306
+ name: actor.name,
307
+ icon_url: actor.icon_url,
308
+ },
309
+ content,
310
+ created_at: now,
311
+ },
312
+ },
313
+ 201,
314
+ );
315
+ },
316
+ );
317
+
318
+ // PATCH /api/communities/:identifier/messages/:messageId - Edit a message
319
+ messagesRouter.patch("/:identifier/messages/:messageId", async (c) => {
320
+ const actor = c.get("actor");
321
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
322
+
323
+ const identifier = c.req.param("identifier")!;
324
+ const messageId = decodeURIComponent(c.req.param("messageId")!);
325
+ const db = c.get("db");
326
+
327
+ const { community } = await fetchCommunityId(c, identifier);
328
+ if (!community) {
329
+ return c.json({ error: "Community not found" }, 404);
330
+ }
331
+
332
+ const body = await c.req.json<{ content: string }>();
333
+ // Guard non-string content before .trim() (else TypeError → 500).
334
+ if (typeof body.content !== "string") {
335
+ return c.json({ error: "Message content is required" }, 400);
336
+ }
337
+ const content = body.content.trim();
338
+ if (!content) {
339
+ return c.json({ error: "Message content is required" }, 400);
340
+ }
341
+ if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
342
+ return c.json(
343
+ {
344
+ error: `Message too long (max ${MAX_COMMUNITY_MESSAGE_LENGTH} chars)`,
345
+ },
346
+ 400,
347
+ );
348
+ }
349
+
350
+ // Check message exists and belongs to community (using raw SQL since ObjectRecipient FK expects Actor)
351
+ const recipientRows = await db.all<{ object_ap_id: string }>(sql`
352
+ SELECT object_ap_id FROM object_recipients
353
+ WHERE object_ap_id = ${messageId} AND recipient_ap_id = ${community.apId} AND type = 'audience'
354
+ LIMIT 1
355
+ `);
356
+ if (recipientRows.length === 0) {
357
+ return c.json({ error: "Message not found" }, 404);
358
+ }
359
+
360
+ const message = await db
361
+ .select({
362
+ apId: objects.apId,
363
+ attributedTo: objects.attributedTo,
364
+ })
365
+ .from(objects)
366
+ .where(eq(objects.apId, messageId))
367
+ .get();
368
+ if (!message) {
369
+ return c.json({ error: "Message not found" }, 404);
370
+ }
371
+
372
+ // Membership / read gate: a kicked or never-member actor must NOT mutate chat
373
+ // content. The author check below is not sufficient — a removed author keeps
374
+ // their message id + authorship and could otherwise keep rewriting a private
375
+ // community's history. Mirrors the GET reader (checkReadAccess) + POST gate.
376
+ const communityRow = await db
377
+ .select({ visibility: communities.visibility })
378
+ .from(communities)
379
+ .where(eq(communities.apId, community.apId))
380
+ .get();
381
+ const membership = await db
382
+ .select({ role: communityMembers.role })
383
+ .from(communityMembers)
384
+ .where(memberWhere(community.apId, actor.ap_id))
385
+ .get();
386
+ const readError = checkReadAccess(
387
+ communityRow?.visibility || "public",
388
+ membership ?? null,
389
+ );
390
+ if (readError) return c.json({ error: readError }, 403);
391
+
392
+ if (message.attributedTo !== actor.ap_id) {
393
+ return c.json({ error: "Only the author can edit this message" }, 403);
394
+ }
395
+
396
+ await db
397
+ .update(objects)
398
+ .set({ content, updated: new Date().toISOString() })
399
+ .where(eq(objects.apId, messageId));
400
+
401
+ return c.json({ success: true });
402
+ });
403
+
404
+ // DELETE /api/communities/:identifier/messages/:messageId - Delete a message
405
+ messagesRouter.delete("/:identifier/messages/:messageId", async (c) => {
406
+ const actor = c.get("actor");
407
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
408
+
409
+ const identifier = c.req.param("identifier")!;
410
+ const messageId = decodeURIComponent(c.req.param("messageId")!);
411
+ const db = c.get("db");
412
+
413
+ const { community } = await fetchCommunityId(c, identifier);
414
+ if (!community) {
415
+ return c.json({ error: "Community not found" }, 404);
416
+ }
417
+
418
+ // Check message exists and belongs to community
419
+ const recipientsForDelete = await db.all<{ object_ap_id: string }>(sql`
420
+ SELECT object_ap_id FROM object_recipients
421
+ WHERE object_ap_id = ${messageId} AND recipient_ap_id = ${community.apId} AND type = 'audience'
422
+ LIMIT 1
423
+ `);
424
+ if (recipientsForDelete.length === 0) {
425
+ return c.json({ error: "Message not found" }, 404);
426
+ }
427
+
428
+ const message = await db
429
+ .select({
430
+ apId: objects.apId,
431
+ attributedTo: objects.attributedTo,
432
+ })
433
+ .from(objects)
434
+ .where(eq(objects.apId, messageId))
435
+ .get();
436
+ if (!message) {
437
+ return c.json({ error: "Message not found" }, 404);
438
+ }
439
+
440
+ // Check permission: author can delete, or moderator/owner can delete any
441
+ const membership = await db
442
+ .select()
443
+ .from(communityMembers)
444
+ .where(memberWhere(community.apId, actor.ap_id))
445
+ .get();
446
+
447
+ // Membership / read gate first: a kicked or never-member author must not keep
448
+ // deleting a private community's history. (A manager is always a member.)
449
+ const communityRow = await db
450
+ .select({ visibility: communities.visibility })
451
+ .from(communities)
452
+ .where(eq(communities.apId, community.apId))
453
+ .get();
454
+ const readError = checkReadAccess(
455
+ communityRow?.visibility || "public",
456
+ membership ?? null,
457
+ );
458
+ if (readError) return c.json({ error: readError }, 403);
459
+
460
+ const isAuthor = message.attributedTo === actor.ap_id;
461
+ const isManager = membership && managerRoles.has(membership.role);
462
+
463
+ if (!isAuthor && !isManager) {
464
+ return c.json({ error: "Permission denied" }, 403);
465
+ }
466
+
467
+ // Route through the shared object cascade so a chat message's interaction rows
468
+ // (likes/announces/bookmarks/object_recipients/story_*) and any attached R2
469
+ // blob + media_uploads row are reaped deterministically — the runtime's
470
+ // ON DELETE CASCADE is not relied upon (self-host libsql may run with FK
471
+ // enforcement off). Then drop the object row and purge blobs last.
472
+ const mediaKeys = await deleteObjectCascade(db, messageId, c.env.MEDIA);
473
+ await db.delete(objects).where(eq(objects.apId, messageId));
474
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
475
+
476
+ return c.json({ success: true });
477
+ });
478
+
479
+ export default messagesRouter;