@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,1311 @@
1
+ import { Hono } from "hono";
2
+ import { deleteCookie } from "hono/cookie";
3
+ import {
4
+ and,
5
+ asc,
6
+ count,
7
+ desc,
8
+ eq,
9
+ inArray,
10
+ isNotNull,
11
+ isNull,
12
+ lt,
13
+ ne,
14
+ or,
15
+ sql,
16
+ } from "drizzle-orm";
17
+ import type { BatchItem } from "drizzle-orm/batch";
18
+ import {
19
+ activities,
20
+ actorCache,
21
+ actors,
22
+ blocks,
23
+ communities,
24
+ deliveryQueue,
25
+ follows,
26
+ inbox,
27
+ mediaUploads,
28
+ mutes,
29
+ notDeleted,
30
+ nowIso,
31
+ objects,
32
+ } from "../../db/index.ts";
33
+ import type { Database } from "../../db/index.ts";
34
+ import type { Env, Variables } from "../types.ts";
35
+ import {
36
+ activityApId,
37
+ formatUsername,
38
+ generateId,
39
+ isSafeRemoteUrl,
40
+ parseLimit,
41
+ parseOffset,
42
+ safeJsonParse,
43
+ } from "../federation-helpers.ts";
44
+ import { enqueueFanoutToFollowers } from "../lib/delivery/queue.ts";
45
+ import {
46
+ destinationDeclaresAlias,
47
+ resolveMoveTarget,
48
+ } from "../lib/account-migration.ts";
49
+ import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
50
+ import { severFollowEdge } from "./activitypub/handlers/inbox-interaction-handlers.ts";
51
+ import { teardownActor } from "./account-teardown.ts";
52
+ import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
53
+ import {
54
+ actorExists,
55
+ createRelation,
56
+ deleteRelation,
57
+ isValidHttpUrl,
58
+ isValidProfileImageUrl,
59
+ listFollowRelation,
60
+ listRelation,
61
+ loadActorInfoMap,
62
+ loadPostInteractions,
63
+ MAX_ACTOR_POSTS_LIMIT,
64
+ MAX_PROFILE_NAME_LENGTH,
65
+ MAX_PROFILE_SUMMARY_LENGTH,
66
+ MAX_PROFILE_URL_LENGTH,
67
+ requireActor,
68
+ resolveActorApId,
69
+ } from "./actors-helpers.ts";
70
+ import { reapReplacedMediaUrl } from "./posts/delete-cascade.ts";
71
+ import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
72
+ import { encodeFeedCursor, feedCursorWhere } from "../lib/feed-cursor.ts";
73
+ import { chunkForInClause } from "../lib/chunk.ts";
74
+ import { logger } from "../lib/logger.ts";
75
+
76
+ const log = logger.child({ component: "actors" });
77
+
78
+ // Mastodon-parity profile metadata limits. Mastodon caps profile fields at 4
79
+ // rows with bounded name/value lengths; we mirror that to keep the served
80
+ // actor document and federated Update(Person) bounded.
81
+ const MAX_PROFILE_FIELDS = 4;
82
+ const MAX_PROFILE_FIELD_NAME_LENGTH = 255;
83
+ const MAX_PROFILE_FIELD_VALUE_LENGTH = 255;
84
+ // Bound declared aliases (alsoKnownAs) so the actor document stays bounded.
85
+ const MAX_ALSO_KNOWN_AS = 10;
86
+ // Personal-portability export caps so a single archive request cannot OOM.
87
+ const MAX_EXPORT_POSTS = 5000;
88
+ const MAX_EXPORT_RELATIONS = 10000;
89
+ const MAX_EXPORT_MEDIA = 10000;
90
+
91
+ type ProfileField = { name: string; value: string };
92
+
93
+ /**
94
+ * Sanitize a structured profile fields payload into a bounded array of
95
+ * { name, value } rows. Non-string / empty rows are dropped; the result is
96
+ * capped at MAX_PROFILE_FIELDS with trimmed, length-bounded values.
97
+ */
98
+ function sanitizeProfileFields(input: unknown): ProfileField[] {
99
+ if (!Array.isArray(input)) return [];
100
+ const out: ProfileField[] = [];
101
+ for (const row of input) {
102
+ if (out.length >= MAX_PROFILE_FIELDS) break;
103
+ if (!row || typeof row !== "object") continue;
104
+ const name = (row as { name?: unknown }).name;
105
+ const value = (row as { value?: unknown }).value;
106
+ if (typeof name !== "string" || typeof value !== "string") continue;
107
+ const trimmedName = name.trim().slice(0, MAX_PROFILE_FIELD_NAME_LENGTH);
108
+ const trimmedValue = value.trim().slice(0, MAX_PROFILE_FIELD_VALUE_LENGTH);
109
+ if (trimmedName.length === 0 && trimmedValue.length === 0) continue;
110
+ out.push({ name: trimmedName, value: trimmedValue });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Render stored profile fields as PropertyValue attachments for the federated
117
+ * Person object (mirrors the served actor document in routes/activitypub.ts).
118
+ */
119
+ function fieldsToAttachments(
120
+ fields: ProfileField[],
121
+ ): Array<{ type: "PropertyValue"; name: string; value: string }> {
122
+ return fields.map((f) => ({
123
+ type: "PropertyValue",
124
+ name: f.name,
125
+ value: f.value,
126
+ }));
127
+ }
128
+
129
+ // Tombstone reaper horizon. A deleted account's row is kept as a tombstone so
130
+ // the queued Delete(actor) deliver_endpoint jobs can sign with the actor's
131
+ // private key when they drain. Once those jobs have drained AND enough time has
132
+ // passed that no further delivery attempts are possible, the tombstone (and its
133
+ // signing material) can be hard-deleted. The horizon is comfortably past the
134
+ // delivery backoff series (~4.3h total) so a still-retrying Delete is never
135
+ // reaped out from under the signer; the no-pending-jobs check below is the
136
+ // primary guard and this is a belt-and-braces lower bound.
137
+ const TOMBSTONE_REAP_AFTER_MS = 24 * 60 * 60 * 1000;
138
+
139
+ /**
140
+ * Hard-delete tombstoned local actors whose federation Delete has drained.
141
+ *
142
+ * A tombstone is only reaped when (a) its `deletedAt` is older than
143
+ * TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal (pending / processing
144
+ * / failed / retry_wait) delivery_queue rows for any of its Delete activities —
145
+ * i.e. nothing still needs the private key to sign a retry. The preserved Delete activity
146
+ * rows are removed alongside the actor so they do not accumulate forever.
147
+ *
148
+ * Returns the number of tombstones hard-deleted.
149
+ */
150
+ export async function reapDrainedTombstones(db: Database): Promise<number> {
151
+ const cutoff = new Date(Date.now() - TOMBSTONE_REAP_AFTER_MS).toISOString();
152
+
153
+ const candidates = await db
154
+ .select({ apId: actors.apId })
155
+ .from(actors)
156
+ .where(
157
+ and(sql`${actors.deletedAt} IS NOT NULL`, lt(actors.deletedAt, cutoff)),
158
+ )
159
+ .limit(100);
160
+ if (candidates.length === 0) return 0;
161
+
162
+ let reaped = 0;
163
+ for (const { apId } of candidates) {
164
+ // Delete activities this actor authored (preserved through teardown for
165
+ // the delivery signer). Outbound by construction.
166
+ const deleteActivities = await db
167
+ .select({ apId: activities.apId })
168
+ .from(activities)
169
+ .where(
170
+ and(eq(activities.actorApId, apId), eq(activities.type, "Delete")),
171
+ );
172
+ const deleteActivityIds = deleteActivities.map((a) => a.apId);
173
+
174
+ if (deleteActivityIds.length > 0) {
175
+ // Any non-terminal delivery job for those Delete activities means the
176
+ // signer may still need this actor's key — skip reaping for now. Chunked:
177
+ // a prolific deleted actor can have >100 Delete activities, which would
178
+ // blow D1's 100-bound-param cap and 500 this fire-and-forget reap, leaking
179
+ // the tombstone (and its signing key) forever.
180
+ let hasPendingDelivery = false;
181
+ for (const chunk of chunkForInClause(deleteActivityIds)) {
182
+ const pending = await db
183
+ .select({ id: deliveryQueue.id })
184
+ .from(deliveryQueue)
185
+ .where(
186
+ and(
187
+ inArray(deliveryQueue.activityApId, chunk),
188
+ // Non-terminal delivery states (anything other than the terminal
189
+ // "delivered" / "dead_letter"). A "retry_wait" row is a Delete
190
+ // between attempts and will be re-sent, so reaping the tombstone
191
+ // (and its signing key) while one exists would strand the retry
192
+ // unsigned. queue-delivery.ts writes: pending / processing /
193
+ // failed / retry_wait / delivered / dead_letter.
194
+ inArray(deliveryQueue.status, [
195
+ "pending",
196
+ "processing",
197
+ "failed",
198
+ "retry_wait",
199
+ ]),
200
+ ),
201
+ )
202
+ .limit(1)
203
+ .get();
204
+ if (pending) {
205
+ hasPendingDelivery = true;
206
+ break;
207
+ }
208
+ }
209
+ if (hasPendingDelivery) continue;
210
+ }
211
+
212
+ // Drained: remove the terminal delivery_queue rows, the preserved Delete
213
+ // activities, then the tombstone row itself (chunked for D1's param cap).
214
+ if (deleteActivityIds.length > 0) {
215
+ for (const chunk of chunkForInClause(deleteActivityIds)) {
216
+ await db
217
+ .delete(deliveryQueue)
218
+ .where(inArray(deliveryQueue.activityApId, chunk));
219
+ await db.delete(activities).where(inArray(activities.apId, chunk));
220
+ }
221
+ }
222
+ await db.delete(actors).where(eq(actors.apId, apId));
223
+ reaped += 1;
224
+ }
225
+
226
+ return reaped;
227
+ }
228
+
229
+ /**
230
+ * Cancel the stranded outbound Delete(actor) for a tombstone that is about to be
231
+ * REVIVED (a freed handle re-registered onto the same deterministic apId).
232
+ *
233
+ * A tombstone keeps its OLD signing key precisely so the queued Delete(actor)
234
+ * delivery jobs can sign with it at send time (read live from the actor row).
235
+ * Re-registration rotates that row to a FRESH key + identity, which would make
236
+ * any still-pending Delete job sign with the wrong key (invalid signature) or
237
+ * target a now-live actor. So before reviving we cancel the stranded Delete:
238
+ * remove its non-terminal (pending / processing / failed / retry_wait)
239
+ * delivery_queue rows AND the preserved Delete activity rows, so re-registration
240
+ * starts clean and no half-signed Delete is sent.
241
+ *
242
+ * Mirrors the activity/queue cleanup `reapDrainedTombstones` performs, but is
243
+ * unconditional (the revive supersedes the Delete) and only scoped to non-
244
+ * terminal delivery rows; terminal rows are removed alongside the activity.
245
+ * Returns the number of Delete activities cancelled.
246
+ */
247
+ // D1 has no interactive transactions, but both the D1 and libsql drivers expose
248
+ // `db.batch([...])`, which commits a list of prepared statements atomically. The
249
+ // shared `Database` union aliases the abstract `BaseSQLiteDatabase` base (which
250
+ // does not surface `batch`), so we narrow to the concrete batch surface here
251
+ // rather than weakening the shared type (mirrors inbox-interaction-handlers.ts).
252
+ type BatchStatement = BatchItem<"sqlite">;
253
+ interface BatchableDb {
254
+ batch(
255
+ statements: readonly [BatchStatement, ...BatchStatement[]],
256
+ ): Promise<unknown>;
257
+ }
258
+
259
+ export async function cancelTombstoneDelete(
260
+ db: Database,
261
+ apId: string,
262
+ ): Promise<number> {
263
+ const deleteActivities = await db
264
+ .select({ apId: activities.apId })
265
+ .from(activities)
266
+ .where(and(eq(activities.actorApId, apId), eq(activities.type, "Delete")));
267
+ const deleteActivityIds = deleteActivities.map((a) => a.apId);
268
+ if (deleteActivityIds.length === 0) return 0;
269
+
270
+ // Drop every delivery_queue row for those Delete activities (any status — the
271
+ // Delete is superseded, including in-flight retry_wait jobs) together with the
272
+ // preserved Delete activity rows, so no signer can pick up a job referencing
273
+ // an activity whose actor row has been rotated. Each chunk's two deletes stay
274
+ // paired in one atomic batch; chunked because a prolific actor can have >100
275
+ // Delete activities, which would blow D1's 100-bound-param cap and 500 the
276
+ // revive.
277
+ for (const chunk of chunkForInClause(deleteActivityIds)) {
278
+ await (db as unknown as BatchableDb).batch([
279
+ db
280
+ .delete(deliveryQueue)
281
+ .where(inArray(deliveryQueue.activityApId, chunk)),
282
+ db.delete(activities).where(inArray(activities.apId, chunk)),
283
+ ]);
284
+ }
285
+
286
+ return deleteActivityIds.length;
287
+ }
288
+
289
+ // Best-effort, opportunistic tombstone reaping on the read path. This Worker
290
+ // has no `scheduled` handler, so (mirroring maybeCleanupExpiredStories) the
291
+ // sweep is triggered probabilistically and guarded so at most one runs per
292
+ // isolate at a time. Tombstones are already excluded from every serving query,
293
+ // so a missed sweep only delays storage/key-material reclamation.
294
+ let tombstoneReapInFlight = false;
295
+
296
+ export function maybeReapDrainedTombstones(db: Database): void {
297
+ if (tombstoneReapInFlight) return;
298
+ if (Math.random() >= 0.01) return; // ~1% of eligible requests per isolate
299
+
300
+ tombstoneReapInFlight = true;
301
+ reapDrainedTombstones(db)
302
+ .catch((err) => {
303
+ log.warn("Failed to reap drained tombstones", {
304
+ event: "actors.tombstone.reap_failed",
305
+ error: err,
306
+ });
307
+ })
308
+ .finally(() => {
309
+ tombstoneReapInFlight = false;
310
+ });
311
+ }
312
+
313
+ const actorsRoute = new Hono<{ Bindings: Env; Variables: Variables }>();
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Routes
317
+ // ---------------------------------------------------------------------------
318
+
319
+ // Get all local actors (cached 5 minutes)
320
+ actorsRoute.get(
321
+ "/",
322
+ withCache({
323
+ ttl: CacheTTL.ACTOR_PROFILE,
324
+ cacheTag: CacheTags.ACTOR,
325
+ }),
326
+ async (c) => {
327
+ const db = c.get("db");
328
+ const limit = parseLimit(c.req.query("limit"), 100, 500);
329
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
330
+
331
+ const actorsList = await db
332
+ .select({
333
+ apId: actors.apId,
334
+ preferredUsername: actors.preferredUsername,
335
+ name: actors.name,
336
+ summary: actors.summary,
337
+ iconUrl: actors.iconUrl,
338
+ role: actors.role,
339
+ followerCount: actors.followerCount,
340
+ followingCount: actors.followingCount,
341
+ postCount: actors.postCount,
342
+ createdAt: actors.createdAt,
343
+ })
344
+ .from(actors)
345
+ .where(notDeleted(actors))
346
+ .orderBy(asc(actors.createdAt))
347
+ .limit(limit)
348
+ .offset(offset);
349
+
350
+ return c.json({
351
+ actors: actorsList.map((a) => ({
352
+ ap_id: a.apId,
353
+ preferred_username: a.preferredUsername,
354
+ name: a.name,
355
+ summary: a.summary,
356
+ icon_url: a.iconUrl,
357
+ role: a.role,
358
+ follower_count: a.followerCount,
359
+ following_count: a.followingCount,
360
+ post_count: a.postCount,
361
+ created_at: a.createdAt,
362
+ username: formatUsername(a.apId),
363
+ })),
364
+ });
365
+ },
366
+ );
367
+
368
+ // Get blocked users for current actor
369
+ actorsRoute.get("/me/blocked", async (c) => {
370
+ return listRelation(
371
+ c,
372
+ (db, actorId, limit, offset) =>
373
+ db
374
+ .select({
375
+ blockedApId: blocks.blockedApId,
376
+ createdAt: blocks.createdAt,
377
+ })
378
+ .from(blocks)
379
+ .where(eq(blocks.blockerApId, actorId))
380
+ // createdAt is non-unique millisecond text; add the PK discriminator
381
+ // (blockedApId) so OFFSET paging is deterministic and same-ms ties are
382
+ // never skipped/duplicated across a page boundary.
383
+ .orderBy(desc(blocks.createdAt), desc(blocks.blockedApId))
384
+ .limit(limit)
385
+ .offset(offset),
386
+ "blockedApId",
387
+ "blocked",
388
+ );
389
+ });
390
+
391
+ // Block a user
392
+ actorsRoute.post("/me/blocked", async (c) => {
393
+ return createRelation(
394
+ c,
395
+ "block",
396
+ async (db, actorId, targetId) => {
397
+ await db
398
+ .insert(blocks)
399
+ .values({ blockerApId: actorId, blockedApId: targetId })
400
+ .onConflictDoNothing();
401
+ // Sever BOTH follow edges + reconcile counts (mirrors the federated
402
+ // handleBlock). Without this a blocked actor who was an accepted follower
403
+ // stays in the fan-out set and keeps receiving the blocker's posts, and
404
+ // both actors' follower/following counts stay inflated — defeating the
405
+ // whole point of the block. severFollowEdge gates each decrement on an
406
+ // EXISTS(... status='accepted') subquery so a pending/absent edge is a
407
+ // clean no-op (no under-count). Pending follow-request rows are deleted by
408
+ // the edge delete inside severFollowEdge regardless of status.
409
+ await severFollowEdge(db, targetId, actorId); // target follows actor
410
+ await severFollowEdge(db, actorId, targetId); // actor follows target
411
+ },
412
+ async (db, actorId) =>
413
+ (
414
+ await db
415
+ .select({ n: count() })
416
+ .from(blocks)
417
+ .where(eq(blocks.blockerApId, actorId))
418
+ .get()
419
+ )?.n ?? 0,
420
+ );
421
+ });
422
+
423
+ // Unblock a user
424
+ actorsRoute.delete("/me/blocked", async (c) => {
425
+ return deleteRelation(c, "block", (db, actorId, targetId) =>
426
+ db
427
+ .delete(blocks)
428
+ .where(
429
+ and(eq(blocks.blockerApId, actorId), eq(blocks.blockedApId, targetId)),
430
+ ),
431
+ );
432
+ });
433
+
434
+ // Get muted users for current actor
435
+ actorsRoute.get("/me/muted", async (c) => {
436
+ return listRelation(
437
+ c,
438
+ (db, actorId, limit, offset) =>
439
+ db
440
+ .select({
441
+ mutedApId: mutes.mutedApId,
442
+ createdAt: mutes.createdAt,
443
+ })
444
+ .from(mutes)
445
+ .where(eq(mutes.muterApId, actorId))
446
+ // Deterministic tiebreaker on the PK discriminator (mutedApId); see the
447
+ // blocked list above.
448
+ .orderBy(desc(mutes.createdAt), desc(mutes.mutedApId))
449
+ .limit(limit)
450
+ .offset(offset),
451
+ "mutedApId",
452
+ "muted",
453
+ );
454
+ });
455
+
456
+ // Mute a user
457
+ actorsRoute.post("/me/muted", async (c) => {
458
+ return createRelation(
459
+ c,
460
+ "mute",
461
+ (db, actorId, targetId) =>
462
+ db
463
+ .insert(mutes)
464
+ .values({ muterApId: actorId, mutedApId: targetId })
465
+ .onConflictDoNothing(),
466
+ async (db, actorId) =>
467
+ (
468
+ await db
469
+ .select({ n: count() })
470
+ .from(mutes)
471
+ .where(eq(mutes.muterApId, actorId))
472
+ .get()
473
+ )?.n ?? 0,
474
+ );
475
+ });
476
+
477
+ // Unmute a user
478
+ actorsRoute.delete("/me/muted", async (c) => {
479
+ return deleteRelation(c, "mute", (db, actorId, targetId) =>
480
+ db
481
+ .delete(mutes)
482
+ .where(and(eq(mutes.muterApId, actorId), eq(mutes.mutedApId, targetId))),
483
+ );
484
+ });
485
+
486
+ // Delete own account (local only)
487
+ actorsRoute.post("/me/delete", async (c) => {
488
+ const result = requireActor(c);
489
+ if (result instanceof Response) return result;
490
+ const actor = result;
491
+
492
+ const actorApIdVal = actor.ap_id;
493
+ const db = c.get("db");
494
+ const baseUrl = c.env.APP_URL;
495
+
496
+ try {
497
+ // Gather the owner's SUB-ACCOUNTS (profiles minted via /accounts + /switch)
498
+ // BEFORE the owner's own teardown. A sub-account is a first-class actor that
499
+ // can post / follow / like / join+own communities, so it needs the FULL
500
+ // cascade, not a mere tombstone. They are keyed by ownerActorApId === the
501
+ // owner's apId, which the owner teardown does NOT change (it only nulls the
502
+ // owner's own ownerActorApId field), so gathering them here is order-safe.
503
+ const subAccounts = await db
504
+ .select({ apId: actors.apId, followersUrl: actors.followersUrl })
505
+ .from(actors)
506
+ .where(eq(actors.ownerActorApId, actorApIdVal));
507
+
508
+ // Full owner teardown through the SINGLE shared cascade: it federates a
509
+ // Delete(Actor) (snapshotting follower inboxes before the graph is dropped),
510
+ // reconciles every counterparty's denormalized counters, deletes the actor's
511
+ // edges / interactions / memberships / media / DM state, hands off sole-owned
512
+ // communities to an heir, hard-deletes its authored objects, and
513
+ // tombstones+scrubs the actor row. This is the EXACT cascade teardownActor
514
+ // applies to each sub-account below — one chokepoint instead of a ~440-line
515
+ // hand-rolled copy that had to be kept in sync.
516
+ await teardownActor(db, c.env, baseUrl, actorApIdVal, actor.followers_url);
517
+ // Now fully tear down each sub-account (same cascade the owner just received)
518
+ // so no "deleted" sub-account content / edge / counter / membership / sole
519
+ // ownership survives, and each federates its own Delete(Actor).
520
+ for (const sub of subAccounts) {
521
+ await teardownActor(db, c.env, baseUrl, sub.apId, sub.followersUrl);
522
+ }
523
+
524
+ deleteCookie(c, "session");
525
+
526
+ return c.json({ success: true });
527
+ } catch (error) {
528
+ log.error("Account deletion failed", {
529
+ event: "actors.account.delete_failed",
530
+ actor: actorApIdVal,
531
+ error,
532
+ });
533
+ return c.json({ error: "Account deletion failed" }, 500);
534
+ }
535
+ });
536
+
537
+ // Get posts for a specific actor
538
+ actorsRoute.get("/:identifier/posts", async (c) => {
539
+ const currentActor = c.get("actor");
540
+ const identifier = c.req.param("identifier");
541
+ const db = c.get("db");
542
+
543
+ const apId = await resolveActorApId(db, c.env.APP_URL, identifier);
544
+ if (!apId) return c.json({ error: "Actor not found" }, 404);
545
+
546
+ if (!(await actorExists(db, apId))) {
547
+ return c.json({ error: "Actor not found" }, 404);
548
+ }
549
+
550
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_ACTOR_POSTS_LIMIT);
551
+ const before = c.req.query("before");
552
+ const isOwnProfile = currentActor && currentActor.ap_id === apId;
553
+
554
+ const conditions = [
555
+ eq(objects.type, "Note"),
556
+ isNull(objects.inReplyTo),
557
+ eq(objects.attributedTo, apId),
558
+ ];
559
+ if (isOwnProfile) {
560
+ conditions.push(ne(objects.visibility, "direct"));
561
+ // Exclude community GROUP-CHAT messages from the profile post feed. A chat
562
+ // message is a Note addressed to a community audience (audienceJson !== "[]")
563
+ // with NO communityApId, whereas a personal post has an empty audience and a
564
+ // community FEED post has communityApId set. Without this, your own chat
565
+ // messages leak into your profile's posts list (they are correctly hidden
566
+ // from other viewers by the public/empty-audience guard below).
567
+ conditions.push(
568
+ or(eq(objects.audienceJson, "[]"), isNotNull(objects.communityApId))!,
569
+ );
570
+ } else {
571
+ // Non-own profile: only globally-public posts. `visibility = "public"`
572
+ // alone is insufficient because community-scoped and explicitly-addressed
573
+ // posts can carry public-ish visibility while their reach is the audience
574
+ // list; without the empty-audience guard those would leak to anyone
575
+ // viewing the author's profile. An empty `audienceJson` ("[]") marks a
576
+ // post with no community/addressed scope, i.e. truly public reach.
577
+ conditions.push(eq(objects.visibility, "public"));
578
+ conditions.push(eq(objects.audienceJson, "[]"));
579
+ }
580
+ // Composite (published, apId) cursor so posts sharing a published millisecond
581
+ // aren't skipped at a page boundary (see lib/feed-cursor.ts).
582
+ const profileCursor = feedCursorWhere(
583
+ objects.published,
584
+ objects.apId,
585
+ before,
586
+ );
587
+ if (profileCursor) conditions.push(profileCursor);
588
+
589
+ // Project only the columns the response below reads — the profile feed must
590
+ // not load the large `raw_json` blob (and other unused columns), mirroring the
591
+ // timeline's POST_FEED_COLUMNS optimization which this path had missed.
592
+ const posts = await db
593
+ .select({
594
+ apId: objects.apId,
595
+ type: objects.type,
596
+ attributedTo: objects.attributedTo,
597
+ content: objects.content,
598
+ summary: objects.summary,
599
+ attachmentsJson: objects.attachmentsJson,
600
+ inReplyTo: objects.inReplyTo,
601
+ visibility: objects.visibility,
602
+ communityApId: objects.communityApId,
603
+ likeCount: objects.likeCount,
604
+ replyCount: objects.replyCount,
605
+ announceCount: objects.announceCount,
606
+ published: objects.published,
607
+ updated: objects.updated,
608
+ })
609
+ .from(objects)
610
+ .where(and(...conditions))
611
+ .orderBy(desc(objects.published), desc(objects.apId))
612
+ .limit(limit + 1);
613
+
614
+ const hasMore = posts.length > limit;
615
+ if (hasMore) posts.pop();
616
+ const lastPost = posts[posts.length - 1];
617
+ const nextCursor =
618
+ hasMore && lastPost
619
+ ? encodeFeedCursor(lastPost.published, lastPost.apId)
620
+ : null;
621
+
622
+ const postApIds = posts.map((p) => p.apId);
623
+ const authorApIds = [...new Set(posts.map((p) => p.attributedTo))];
624
+
625
+ const [authorMap, interactions] = await Promise.all([
626
+ loadActorInfoMap(db, authorApIds, "author"),
627
+ loadPostInteractions(db, currentActor?.ap_id ?? null, postApIds),
628
+ ]);
629
+
630
+ const resultList = posts.map((p) => {
631
+ const author = authorMap.get(p.attributedTo);
632
+ return {
633
+ ap_id: p.apId,
634
+ type: p.type,
635
+ author: {
636
+ ap_id: p.attributedTo,
637
+ username: formatUsername(p.attributedTo),
638
+ preferred_username: author?.preferredUsername || null,
639
+ name: author?.name || null,
640
+ icon_url: author?.iconUrl || null,
641
+ },
642
+ content: p.content,
643
+ summary: p.summary,
644
+ attachments: safeJsonParse(p.attachmentsJson, []),
645
+ in_reply_to: p.inReplyTo,
646
+ visibility: p.visibility,
647
+ community_ap_id: p.communityApId,
648
+ like_count: p.likeCount,
649
+ reply_count: p.replyCount,
650
+ announce_count: p.announceCount,
651
+ published: p.published,
652
+ edited_at: p.updated && p.updated !== p.published ? p.updated : null,
653
+ liked: interactions.likedIds.has(p.apId),
654
+ bookmarked: interactions.bookmarkedIds.has(p.apId),
655
+ reposted: interactions.repostedIds.has(p.apId),
656
+ };
657
+ });
658
+
659
+ return c.json({
660
+ posts: resultList,
661
+ has_more: hasMore,
662
+ next_cursor: nextCursor,
663
+ });
664
+ });
665
+
666
+ // Get actor by AP ID or username
667
+ actorsRoute.get("/:identifier", async (c) => {
668
+ const currentActor = c.get("actor");
669
+ const identifier = c.req.param("identifier");
670
+ const baseUrl = c.env.APP_URL;
671
+ const db = c.get("db");
672
+
673
+ // For @user@remote-domain, we may need to return cached data directly
674
+ // (resolveActorApId only returns an apId when the cache has a match)
675
+ const apId = await resolveActorApId(db, baseUrl, identifier);
676
+ if (!apId) return c.json({ error: "Actor not found" }, 404);
677
+
678
+ // Try local actor first
679
+ const localActor = await db
680
+ .select({
681
+ apId: actors.apId,
682
+ preferredUsername: actors.preferredUsername,
683
+ name: actors.name,
684
+ summary: actors.summary,
685
+ iconUrl: actors.iconUrl,
686
+ headerUrl: actors.headerUrl,
687
+ role: actors.role,
688
+ followerCount: actors.followerCount,
689
+ followingCount: actors.followingCount,
690
+ postCount: actors.postCount,
691
+ isPrivate: actors.isPrivate,
692
+ createdAt: actors.createdAt,
693
+ fieldsJson: actors.fieldsJson,
694
+ alsoKnownAsJson: actors.alsoKnownAsJson,
695
+ movedTo: actors.movedTo,
696
+ })
697
+ .from(actors)
698
+ // Exclude tombstoned local actors so a deleted handle is not served as a
699
+ // live profile (consistent with the notDeleted filter used by the actor
700
+ // list and federation-serving queries).
701
+ .where(and(eq(actors.apId, apId), notDeleted(actors)))
702
+ .get();
703
+
704
+ if (!localActor) {
705
+ const cachedActor = await db
706
+ .select()
707
+ .from(actorCache)
708
+ .where(eq(actorCache.apId, apId))
709
+ .get();
710
+ if (!cachedActor) return c.json({ error: "Actor not found" }, 404);
711
+
712
+ // Project the remote actor's AS Person document (cached `rawJson`) so the
713
+ // client banner/fields work for REMOTE actors too: `attachment` ->
714
+ // PropertyValue fields, `alsoKnownAs` -> also_known_as, `movedTo` ->
715
+ // moved_to. Mirrors the local-actor projection below.
716
+ const raw = safeJsonParse<Record<string, unknown>>(cachedActor.rawJson, {});
717
+ const rawAttachment = Array.isArray(raw?.attachment) ? raw.attachment : [];
718
+ const rawAlsoKnownAs = Array.isArray(raw?.alsoKnownAs)
719
+ ? raw.alsoKnownAs.filter((a): a is string => typeof a === "string")
720
+ : [];
721
+ const rawMovedTo = typeof raw?.movedTo === "string" ? raw.movedTo : null;
722
+
723
+ return c.json({
724
+ actor: {
725
+ ap_id: cachedActor.apId,
726
+ preferred_username: cachedActor.preferredUsername,
727
+ name: cachedActor.name,
728
+ summary: cachedActor.summary,
729
+ icon_url: cachedActor.iconUrl,
730
+ username: formatUsername(cachedActor.apId),
731
+ fields: sanitizeProfileFields(rawAttachment),
732
+ also_known_as: rawAlsoKnownAs.slice(0, MAX_ALSO_KNOWN_AS),
733
+ moved_to: rawMovedTo,
734
+ is_following: false,
735
+ is_followed_by: false,
736
+ },
737
+ });
738
+ }
739
+
740
+ // Check follow status if logged in and viewing a different actor
741
+ let is_following = false;
742
+ let is_followed_by = false;
743
+
744
+ if (currentActor && currentActor.ap_id !== apId) {
745
+ const [followingStatus, followedByStatus] = await Promise.all([
746
+ db
747
+ .select({ followerApId: follows.followerApId })
748
+ .from(follows)
749
+ .where(
750
+ and(
751
+ eq(follows.followerApId, currentActor.ap_id),
752
+ eq(follows.followingApId, apId),
753
+ eq(follows.status, "accepted"),
754
+ ),
755
+ )
756
+ .get(),
757
+ db
758
+ .select({ followerApId: follows.followerApId })
759
+ .from(follows)
760
+ .where(
761
+ and(
762
+ eq(follows.followerApId, apId),
763
+ eq(follows.followingApId, currentActor.ap_id),
764
+ eq(follows.status, "accepted"),
765
+ ),
766
+ )
767
+ .get(),
768
+ ]);
769
+ is_following = !!followingStatus;
770
+ is_followed_by = !!followedByStatus;
771
+ }
772
+
773
+ return c.json({
774
+ actor: {
775
+ ap_id: localActor.apId,
776
+ preferred_username: localActor.preferredUsername,
777
+ name: localActor.name,
778
+ summary: localActor.summary,
779
+ icon_url: localActor.iconUrl,
780
+ header_url: localActor.headerUrl,
781
+ role: localActor.role,
782
+ follower_count: localActor.followerCount,
783
+ following_count: localActor.followingCount,
784
+ post_count: localActor.postCount,
785
+ is_private: localActor.isPrivate,
786
+ created_at: localActor.createdAt,
787
+ username: formatUsername(localActor.apId),
788
+ fields: sanitizeProfileFields(safeJsonParse(localActor.fieldsJson, [])),
789
+ also_known_as: safeJsonParse<string[]>(localActor.alsoKnownAsJson, []),
790
+ moved_to: localActor.movedTo,
791
+ is_following,
792
+ is_followed_by,
793
+ },
794
+ });
795
+ });
796
+
797
+ // Update own profile
798
+ actorsRoute.put("/me", async (c) => {
799
+ const result = requireActor(c);
800
+ if (result instanceof Response) return result;
801
+ const actor = result;
802
+
803
+ const body = await c.req.json<{
804
+ name?: string;
805
+ summary?: string;
806
+ icon_url?: string;
807
+ header_url?: string;
808
+ is_private?: boolean;
809
+ fields?: Array<{ name?: unknown; value?: unknown }>;
810
+ also_known_as?: unknown;
811
+ }>();
812
+
813
+ const updates: Record<string, string | number | null> = {};
814
+
815
+ if (body.name !== undefined) {
816
+ // The json<{...}>() cast is compile-time only; a client can send a non-string
817
+ // (number/object/array), so guard before .trim() (else TypeError → 500).
818
+ if (typeof body.name !== "string") {
819
+ return c.json({ error: "Invalid name" }, 400);
820
+ }
821
+ const name = body.name.trim();
822
+ if (name.length > MAX_PROFILE_NAME_LENGTH) {
823
+ return c.json(
824
+ {
825
+ error: `Name too long (max ${MAX_PROFILE_NAME_LENGTH} chars)`,
826
+ },
827
+ 400,
828
+ );
829
+ }
830
+ updates.name = name;
831
+ }
832
+ if (body.summary !== undefined) {
833
+ if (typeof body.summary !== "string") {
834
+ return c.json({ error: "Invalid summary" }, 400);
835
+ }
836
+ const summary = body.summary.trim();
837
+ if (summary.length > MAX_PROFILE_SUMMARY_LENGTH) {
838
+ return c.json(
839
+ {
840
+ error: `Summary too long (max ${MAX_PROFILE_SUMMARY_LENGTH} chars)`,
841
+ },
842
+ 400,
843
+ );
844
+ }
845
+ updates.summary = summary.length > 0 ? summary : null;
846
+ }
847
+ for (const [bodyKey, dbKey, label] of [
848
+ ["icon_url", "iconUrl", "Icon URL"],
849
+ ["header_url", "headerUrl", "Header URL"],
850
+ ] as const) {
851
+ const raw = body[bodyKey];
852
+ if (raw !== undefined) {
853
+ if (typeof raw !== "string") {
854
+ return c.json({ error: `Invalid ${bodyKey}` }, 400);
855
+ }
856
+ const trimmed = raw.trim();
857
+ if (trimmed.length > MAX_PROFILE_URL_LENGTH) {
858
+ return c.json(
859
+ {
860
+ error: `${label} too long (max ${MAX_PROFILE_URL_LENGTH} chars)`,
861
+ },
862
+ 400,
863
+ );
864
+ }
865
+ if (trimmed.length > 0 && !isValidProfileImageUrl(trimmed)) {
866
+ return c.json({ error: `Invalid ${bodyKey}` }, 400);
867
+ }
868
+ updates[dbKey] = trimmed.length > 0 ? trimmed : null;
869
+ }
870
+ }
871
+ if (body.is_private !== undefined) {
872
+ updates.isPrivate = body.is_private ? 1 : 0;
873
+ }
874
+
875
+ // Structured profile metadata (PropertyValue). Sanitized + capped so the
876
+ // served actor document and federated Update(Person) stay bounded.
877
+ let nextFields: ProfileField[] | undefined;
878
+ if (body.fields !== undefined) {
879
+ nextFields = sanitizeProfileFields(body.fields);
880
+ updates.fieldsJson = JSON.stringify(nextFields);
881
+ }
882
+
883
+ // Account-migration aliases (alsoKnownAs). Accept an array of AP-ID URLs the
884
+ // account claims; bound + validate so a Move target can reference us.
885
+ let nextAlsoKnownAs: string[] | undefined;
886
+ if (body.also_known_as !== undefined) {
887
+ if (!Array.isArray(body.also_known_as)) {
888
+ return c.json({ error: "also_known_as must be an array" }, 400);
889
+ }
890
+ const aliases: string[] = [];
891
+ for (const raw of body.also_known_as) {
892
+ if (aliases.length >= MAX_ALSO_KNOWN_AS) break;
893
+ if (typeof raw !== "string") continue;
894
+ const trimmed = raw.trim();
895
+ if (trimmed.length === 0) continue;
896
+ if (!isValidHttpUrl(trimmed)) {
897
+ return c.json({ error: `Invalid alsoKnownAs entry: ${trimmed}` }, 400);
898
+ }
899
+ if (!aliases.includes(trimmed)) aliases.push(trimmed);
900
+ }
901
+ nextAlsoKnownAs = aliases;
902
+ updates.alsoKnownAsJson = JSON.stringify(aliases);
903
+ }
904
+
905
+ if (Object.keys(updates).length === 0) {
906
+ return c.json({ error: "No fields to update" }, 400);
907
+ }
908
+
909
+ const db = c.get("db");
910
+ await db.update(actors).set(updates).where(eq(actors.apId, actor.ap_id));
911
+
912
+ // A replaced avatar/header is attached to no object, so no GC path reclaims
913
+ // the prior blob — reap it now if the old URL is a local /media upload no
914
+ // longer referenced anywhere (best-effort; never fails the update).
915
+ for (const [dbKey, oldUrl] of [
916
+ ["iconUrl", actor.icon_url],
917
+ ["headerUrl", actor.header_url],
918
+ ] as const) {
919
+ if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
920
+ await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
921
+ }
922
+ }
923
+
924
+ // The `actor` context snapshot predates the new columns, so to federate a
925
+ // faithful Person we read the current persisted values when the request did
926
+ // not itself supply them.
927
+ if (nextFields === undefined || nextAlsoKnownAs === undefined) {
928
+ const persisted = await db
929
+ .select({
930
+ fieldsJson: actors.fieldsJson,
931
+ alsoKnownAsJson: actors.alsoKnownAsJson,
932
+ })
933
+ .from(actors)
934
+ .where(eq(actors.apId, actor.ap_id))
935
+ .get();
936
+ if (nextFields === undefined) {
937
+ nextFields = sanitizeProfileFields(
938
+ safeJsonParse(persisted?.fieldsJson, []),
939
+ );
940
+ }
941
+ if (nextAlsoKnownAs === undefined) {
942
+ const parsed = safeJsonParse<string[]>(persisted?.alsoKnownAsJson, []);
943
+ nextAlsoKnownAs = Array.isArray(parsed)
944
+ ? parsed.filter((a): a is string => typeof a === "string")
945
+ : [];
946
+ }
947
+ }
948
+
949
+ // Federate the profile change so remote followers do not see a stale
950
+ // Person. Every field this route can mutate (name / summary / icon /
951
+ // header / is_private) is part of the published actor document, so any
952
+ // applied update is federated-visible: build a fresh Update(Person) from
953
+ // the post-update values and fan it out to followers.
954
+ const baseUrl = c.env.APP_URL;
955
+ const nextName =
956
+ "name" in updates ? (updates.name as string | null) : actor.name;
957
+ const nextSummary =
958
+ "summary" in updates ? (updates.summary as string | null) : actor.summary;
959
+ const nextIconUrl =
960
+ "iconUrl" in updates ? (updates.iconUrl as string | null) : actor.icon_url;
961
+ const nextHeaderUrl =
962
+ "headerUrl" in updates
963
+ ? (updates.headerUrl as string | null)
964
+ : actor.header_url;
965
+ const nextIsPrivate =
966
+ "isPrivate" in updates ? (updates.isPrivate as number) : actor.is_private;
967
+
968
+ // Mirror the actor document served at the federation actor endpoint
969
+ // (routes/activitypub.ts) so remote servers receive a consistent Person.
970
+ const personObject: Record<string, unknown> = {
971
+ id: actor.ap_id,
972
+ type: actor.type,
973
+ preferredUsername: actor.preferred_username,
974
+ name: nextName,
975
+ summary: nextSummary,
976
+ inbox: actor.inbox,
977
+ outbox: actor.outbox,
978
+ followers: actor.followers_url,
979
+ following: actor.following_url,
980
+ endpoints: {
981
+ sharedInbox: `${baseUrl}/ap/inbox`,
982
+ },
983
+ publicKey: {
984
+ id: `${actor.ap_id}#main-key`,
985
+ owner: actor.ap_id,
986
+ publicKeyPem: actor.public_key_pem,
987
+ },
988
+ discoverable: !nextIsPrivate,
989
+ manuallyApprovesFollowers: Boolean(nextIsPrivate),
990
+ };
991
+ if (nextIconUrl) {
992
+ // Relative `/media/...` upload paths must be absolutized so remote servers
993
+ // can dereference the avatar; absolute URLs pass through unchanged.
994
+ personObject.icon = {
995
+ type: "Image",
996
+ url: safeUrlJoin(baseUrl, nextIconUrl),
997
+ };
998
+ }
999
+ if (nextHeaderUrl) {
1000
+ personObject.image = {
1001
+ type: "Image",
1002
+ url: safeUrlJoin(baseUrl, nextHeaderUrl),
1003
+ };
1004
+ }
1005
+ if (nextFields && nextFields.length > 0) {
1006
+ personObject.attachment = fieldsToAttachments(nextFields);
1007
+ }
1008
+ if (nextAlsoKnownAs && nextAlsoKnownAs.length > 0) {
1009
+ personObject.alsoKnownAs = nextAlsoKnownAs;
1010
+ }
1011
+
1012
+ const updateActivityId = activityApId(baseUrl, generateId());
1013
+ const updateActivity = {
1014
+ "@context": [
1015
+ "https://www.w3.org/ns/activitystreams",
1016
+ "https://w3id.org/security/v1",
1017
+ {
1018
+ schema: "http://schema.org#",
1019
+ PropertyValue: "schema:PropertyValue",
1020
+ value: "schema:value",
1021
+ toot: "http://joinmastodon.org/ns#",
1022
+ alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" },
1023
+ movedTo: { "@id": "as:movedTo", "@type": "@id" },
1024
+ manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
1025
+ },
1026
+ ],
1027
+ id: updateActivityId,
1028
+ type: "Update",
1029
+ actor: actor.ap_id,
1030
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
1031
+ cc: [actor.followers_url],
1032
+ object: personObject,
1033
+ };
1034
+
1035
+ try {
1036
+ await db.insert(activities).values({
1037
+ apId: updateActivityId,
1038
+ type: "Update",
1039
+ actorApId: actor.ap_id,
1040
+ objectApId: actor.ap_id,
1041
+ rawJson: JSON.stringify(updateActivity),
1042
+ direction: "outbound",
1043
+ });
1044
+ await enqueueFanoutToFollowers(c.env, updateActivityId, actor.ap_id);
1045
+ } catch (err) {
1046
+ // Federation is best-effort; the local profile update already succeeded.
1047
+ log.error("Failed to enqueue profile Update federation", {
1048
+ event: "actors.profile.update_federation_failed",
1049
+ actor: actor.ap_id,
1050
+ error: err,
1051
+ });
1052
+ }
1053
+
1054
+ return c.json({ success: true });
1055
+ });
1056
+
1057
+ // Initiate an account migration: declare the destination and federate a
1058
+ // Move(actor -> target) to followers. The destination must already list this
1059
+ // account in its own `alsoKnownAs` (verified by remote servers); we persist
1060
+ // `moved_to` so the served actor document advertises the migration and emit
1061
+ // the Move so followers can re-follow the target.
1062
+ actorsRoute.post("/me/move", async (c) => {
1063
+ const result = requireActor(c);
1064
+ if (result instanceof Response) return result;
1065
+ const actor = result;
1066
+
1067
+ const body = await c.req
1068
+ .json<{ target?: unknown }>()
1069
+ .catch(() => ({}) as { target?: unknown });
1070
+ const rawTarget = typeof body.target === "string" ? body.target.trim() : "";
1071
+ if (rawTarget.length === 0) {
1072
+ return c.json({ error: "target is required" }, 400);
1073
+ }
1074
+ // Accept either a full actor URL or a @user@domain fediverse handle (the
1075
+ // latter is WebFinger-resolved to its actor URL — what the move field's
1076
+ // placeholder shows and what users actually know).
1077
+ const target = await resolveMoveTarget(rawTarget);
1078
+ if (!target || !isValidHttpUrl(target)) {
1079
+ return c.json({ error: "Invalid move target" }, 400);
1080
+ }
1081
+ if (target === actor.ap_id) {
1082
+ return c.json({ error: "Cannot move an account to itself" }, 400);
1083
+ }
1084
+ if (!isSafeRemoteUrl(target)) {
1085
+ return c.json({ error: "Invalid move target" }, 400);
1086
+ }
1087
+
1088
+ // Refuse to advertise a migration the destination has not consented to. A
1089
+ // compliant receiver (Mastodon, and our own inbound handleMove) REJECTS a
1090
+ // Move whose destination does not list this account in its `alsoKnownAs`, so
1091
+ // without this check the move would silently no-op on every follower's server
1092
+ // while appearing successful locally. Verifying here gives the user an
1093
+ // actionable error: add this account as an alias on the destination first.
1094
+ if (
1095
+ !(await destinationDeclaresAlias(
1096
+ target,
1097
+ actor.ap_id,
1098
+ await getInstanceFetchSigner(c),
1099
+ ))
1100
+ ) {
1101
+ return c.json(
1102
+ {
1103
+ error:
1104
+ "The destination account must list this account in its aliases (alsoKnownAs) before migrating. Add this account as an alias there, then retry.",
1105
+ },
1106
+ 422,
1107
+ );
1108
+ }
1109
+
1110
+ const db = c.get("db");
1111
+ const baseUrl = c.env.APP_URL;
1112
+
1113
+ await db
1114
+ .update(actors)
1115
+ .set({ movedTo: target })
1116
+ .where(eq(actors.apId, actor.ap_id));
1117
+
1118
+ // Federate Move(actor) addressed to followers so they migrate their follow.
1119
+ const moveActivityId = activityApId(baseUrl, generateId());
1120
+ const moveActivity = {
1121
+ "@context": "https://www.w3.org/ns/activitystreams",
1122
+ id: moveActivityId,
1123
+ type: "Move",
1124
+ actor: actor.ap_id,
1125
+ object: actor.ap_id,
1126
+ target,
1127
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
1128
+ cc: [actor.followers_url],
1129
+ };
1130
+
1131
+ try {
1132
+ await db.insert(activities).values({
1133
+ apId: moveActivityId,
1134
+ type: "Move",
1135
+ actorApId: actor.ap_id,
1136
+ objectApId: actor.ap_id,
1137
+ rawJson: JSON.stringify(moveActivity),
1138
+ direction: "outbound",
1139
+ });
1140
+ await enqueueFanoutToFollowers(c.env, moveActivityId, actor.ap_id);
1141
+ } catch (err) {
1142
+ // Federation is best-effort; the local moved_to marker already persisted.
1143
+ log.error("Failed to enqueue account Move federation", {
1144
+ event: "actors.account.move_federation_failed",
1145
+ actor: actor.ap_id,
1146
+ error: err,
1147
+ });
1148
+ }
1149
+
1150
+ return c.json({ success: true, moved_to: target });
1151
+ });
1152
+
1153
+ // Personal-portability data export: a bounded JSON archive of the actor's
1154
+ // profile + authored posts (outbox-style) + follow graph + media manifest.
1155
+ // Every collection is capped so a single request cannot OOM the worker; this
1156
+ // is a portability aid, not a streaming full backup.
1157
+ actorsRoute.get("/me/export", async (c) => {
1158
+ const result = requireActor(c);
1159
+ if (result instanceof Response) return result;
1160
+ const actor = result;
1161
+ const db = c.get("db");
1162
+ const actorApIdVal = actor.ap_id;
1163
+
1164
+ const profileRow = await db
1165
+ .select({
1166
+ apId: actors.apId,
1167
+ type: actors.type,
1168
+ preferredUsername: actors.preferredUsername,
1169
+ name: actors.name,
1170
+ summary: actors.summary,
1171
+ iconUrl: actors.iconUrl,
1172
+ headerUrl: actors.headerUrl,
1173
+ isPrivate: actors.isPrivate,
1174
+ role: actors.role,
1175
+ createdAt: actors.createdAt,
1176
+ fieldsJson: actors.fieldsJson,
1177
+ alsoKnownAsJson: actors.alsoKnownAsJson,
1178
+ movedTo: actors.movedTo,
1179
+ })
1180
+ .from(actors)
1181
+ .where(eq(actors.apId, actorApIdVal))
1182
+ .get();
1183
+
1184
+ if (!profileRow) return c.json({ error: "Actor not found" }, 404);
1185
+
1186
+ const [authoredPosts, following, followers, media] = await Promise.all([
1187
+ db
1188
+ .select()
1189
+ .from(objects)
1190
+ .where(and(eq(objects.attributedTo, actorApIdVal), notDeleted(objects)))
1191
+ .orderBy(desc(objects.published))
1192
+ .limit(MAX_EXPORT_POSTS),
1193
+ db
1194
+ .select({
1195
+ followingApId: follows.followingApId,
1196
+ status: follows.status,
1197
+ createdAt: follows.createdAt,
1198
+ })
1199
+ .from(follows)
1200
+ .where(eq(follows.followerApId, actorApIdVal))
1201
+ .orderBy(desc(follows.createdAt))
1202
+ .limit(MAX_EXPORT_RELATIONS),
1203
+ db
1204
+ .select({
1205
+ followerApId: follows.followerApId,
1206
+ status: follows.status,
1207
+ createdAt: follows.createdAt,
1208
+ })
1209
+ .from(follows)
1210
+ .where(eq(follows.followingApId, actorApIdVal))
1211
+ .orderBy(desc(follows.createdAt))
1212
+ .limit(MAX_EXPORT_RELATIONS),
1213
+ db
1214
+ .select({
1215
+ id: mediaUploads.id,
1216
+ r2Key: mediaUploads.r2Key,
1217
+ contentType: mediaUploads.contentType,
1218
+ size: mediaUploads.size,
1219
+ createdAt: mediaUploads.createdAt,
1220
+ })
1221
+ .from(mediaUploads)
1222
+ .where(eq(mediaUploads.uploaderApId, actorApIdVal))
1223
+ .orderBy(desc(mediaUploads.createdAt))
1224
+ .limit(MAX_EXPORT_MEDIA),
1225
+ ]);
1226
+
1227
+ const archive = {
1228
+ "@context": "https://www.w3.org/ns/activitystreams",
1229
+ exported_at: new Date().toISOString(),
1230
+ actor: {
1231
+ ap_id: profileRow.apId,
1232
+ type: profileRow.type,
1233
+ preferred_username: profileRow.preferredUsername,
1234
+ name: profileRow.name,
1235
+ summary: profileRow.summary,
1236
+ icon_url: profileRow.iconUrl,
1237
+ header_url: profileRow.headerUrl,
1238
+ is_private: profileRow.isPrivate,
1239
+ role: profileRow.role,
1240
+ created_at: profileRow.createdAt,
1241
+ fields: sanitizeProfileFields(safeJsonParse(profileRow.fieldsJson, [])),
1242
+ also_known_as: safeJsonParse<string[]>(profileRow.alsoKnownAsJson, []),
1243
+ moved_to: profileRow.movedTo,
1244
+ username: formatUsername(profileRow.apId),
1245
+ },
1246
+ outbox: {
1247
+ type: "OrderedCollection",
1248
+ total_items: authoredPosts.length,
1249
+ truncated: authoredPosts.length >= MAX_EXPORT_POSTS,
1250
+ ordered_items: authoredPosts.map((p) => ({
1251
+ ap_id: p.apId,
1252
+ type: p.type,
1253
+ content: p.content,
1254
+ summary: p.summary,
1255
+ attachments: safeJsonParse(p.attachmentsJson, []),
1256
+ in_reply_to: p.inReplyTo,
1257
+ visibility: p.visibility,
1258
+ community_ap_id: p.communityApId,
1259
+ published: p.published,
1260
+ updated: p.updated,
1261
+ })),
1262
+ },
1263
+ following: {
1264
+ total_items: following.length,
1265
+ truncated: following.length >= MAX_EXPORT_RELATIONS,
1266
+ items: following.map((f) => ({
1267
+ ap_id: f.followingApId,
1268
+ status: f.status,
1269
+ created_at: f.createdAt,
1270
+ })),
1271
+ },
1272
+ followers: {
1273
+ total_items: followers.length,
1274
+ truncated: followers.length >= MAX_EXPORT_RELATIONS,
1275
+ items: followers.map((f) => ({
1276
+ ap_id: f.followerApId,
1277
+ status: f.status,
1278
+ created_at: f.createdAt,
1279
+ })),
1280
+ },
1281
+ media: {
1282
+ total_items: media.length,
1283
+ truncated: media.length >= MAX_EXPORT_MEDIA,
1284
+ items: media.map((m) => ({
1285
+ id: m.id,
1286
+ key: m.r2Key,
1287
+ content_type: m.contentType,
1288
+ size: m.size,
1289
+ created_at: m.createdAt,
1290
+ })),
1291
+ },
1292
+ };
1293
+
1294
+ c.header(
1295
+ "Content-Disposition",
1296
+ `attachment; filename="${profileRow.preferredUsername}-export.json"`,
1297
+ );
1298
+ return c.json(archive);
1299
+ });
1300
+
1301
+ // Get actor's followers
1302
+ actorsRoute.get("/:identifier/followers", async (c) =>
1303
+ listFollowRelation(c, "followers"),
1304
+ );
1305
+
1306
+ // Get actor's following
1307
+ actorsRoute.get("/:identifier/following", async (c) =>
1308
+ listFollowRelation(c, "following"),
1309
+ );
1310
+
1311
+ export default actorsRoute;