@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,354 @@
1
+ import { and, eq, isNull, or, sql } from "drizzle-orm";
2
+ import {
3
+ activities,
4
+ communities,
5
+ communityMembers,
6
+ follows,
7
+ objectRecipients,
8
+ objects,
9
+ } from "../../../../db/index.ts";
10
+ import {
11
+ activityApId,
12
+ generateId,
13
+ getDomain,
14
+ isLocal,
15
+ objectApId,
16
+ } from "../../../federation-helpers.ts";
17
+ import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
18
+ import { communityRequiresMembership } from "../../../lib/community-visibility.ts";
19
+ import { isMemberBanned } from "../../communities/membership-shared.ts";
20
+ import {
21
+ boundAttachmentsJson,
22
+ boundInboundContent,
23
+ boundInboundSummary,
24
+ } from "../../posts/transformers.ts";
25
+ import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
26
+ import type { InstanceActorResult } from "../query-helpers.ts";
27
+ import {
28
+ type Activity,
29
+ type ActivityContext,
30
+ getActivityObject,
31
+ getActivityObjectId,
32
+ typeIncludes,
33
+ } from "../inbox-types.ts";
34
+
35
+ const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
36
+
37
+ const JOIN_POLICY_STATUS: Record<string, "accepted" | "pending" | "rejected"> =
38
+ {
39
+ approval: "pending",
40
+ invite: "rejected",
41
+ };
42
+
43
+ /**
44
+ * Minimal shape of a Group-style actor whose inbox accepts Follows: the
45
+ * instance actor AND any community Group share this handler. Only the apId
46
+ * (the thing being followed / signed as) and the joinPolicy (auto-accept vs
47
+ * hold pending) are needed, so both `InstanceActorResult` and a loaded
48
+ * community satisfy it structurally.
49
+ */
50
+ export interface GroupFollowTarget {
51
+ apId: string;
52
+ joinPolicy?: string;
53
+ }
54
+
55
+ export async function handleGroupFollow(
56
+ c: ActivityContext,
57
+ _activity: Activity,
58
+ group: GroupFollowTarget,
59
+ actorApIdStr: string,
60
+ baseUrl: string,
61
+ activityId: string,
62
+ ) {
63
+ const db = c.get("db");
64
+ const followerKey = {
65
+ followerApId: actorApIdStr,
66
+ followingApId: group.apId,
67
+ };
68
+
69
+ const existing = await db.query.follows.findFirst({
70
+ where: and(
71
+ eq(follows.followerApId, followerKey.followerApId),
72
+ eq(follows.followingApId, followerKey.followingApId),
73
+ ),
74
+ });
75
+
76
+ // A banned actor re-Following an OPEN community must NOT auto-rejoin: force a
77
+ // Reject and record no edge (durable kick — mirrors the local open-join ban
78
+ // gate). Only the AUTO-ACCEPT path is blocked; an approval community still
79
+ // lets the Follow go pending so a moderator can re-admit (which lifts the ban).
80
+ const policyStatus = JOIN_POLICY_STATUS[group.joinPolicy ?? ""] ?? "accepted";
81
+ const banned =
82
+ !existing &&
83
+ policyStatus === "accepted" &&
84
+ (await isMemberBanned(db, group.apId, actorApIdStr));
85
+
86
+ const status = existing
87
+ ? existing.status
88
+ : banned
89
+ ? "rejected"
90
+ : policyStatus;
91
+
92
+ if (!existing && !banned) {
93
+ const now = new Date().toISOString();
94
+ // onConflictDoNothing: the same Follow delivered concurrently to two inbox
95
+ // endpoints (shared + per-actor) can re-claim an in-flight dispatch and run
96
+ // this insert twice; without it the concurrent loser throws a PK violation on
97
+ // (follower_ap_id, following_ap_id) that is caught + logged as noise (#8). The
98
+ // follows edge is idempotent, so silently skipping the duplicate is correct.
99
+ await db
100
+ .insert(follows)
101
+ .values({
102
+ ...followerKey,
103
+ status,
104
+ activityApId: activityId,
105
+ acceptedAt: status === "accepted" ? now : null,
106
+ })
107
+ .onConflictDoNothing();
108
+ // An approval-policy community holds the follow PENDING. The pending follows
109
+ // edge IS the remote join request (it cannot be mirrored into
110
+ // community_join_requests, whose actor_ap_id FKs to the local `actors` table a
111
+ // remote follower has no row in). The manager approval surface
112
+ // (GET /requests + POST /requests/accept) therefore also reads pending
113
+ // follows edges to the community.
114
+ }
115
+
116
+ if (isLocal(actorApIdStr, baseUrl)) return;
117
+ if (status === "pending") return;
118
+
119
+ const responseType = status === "accepted" ? "Accept" : "Reject";
120
+
121
+ // Idempotent (re-)emit of the Accept/Reject. We do NOT early-return on an
122
+ // existing follow: if the follow row was recorded on a prior attempt but the
123
+ // response was LOST (a transient failure between the follow insert and the
124
+ // enqueue left the activity uncommitted, so the remote retried), the old
125
+ // `if (existing) return` suppressed the Accept forever — leaving the remote
126
+ // stuck pending while we record them as accepted. Instead, if a response for
127
+ // THIS Follow already exists just re-enqueue its (dedup'd, durable) delivery;
128
+ // otherwise create + enqueue it.
129
+ const existingResponse = await db.query.activities.findFirst({
130
+ where: and(
131
+ eq(activities.type, responseType),
132
+ eq(activities.actorApId, group.apId),
133
+ eq(activities.objectApId, activityId),
134
+ ),
135
+ });
136
+ if (existingResponse) {
137
+ await enqueueDeliveryToActor(c.env, existingResponse.apId, actorApIdStr);
138
+ return;
139
+ }
140
+
141
+ const responseId = activityApId(baseUrl, generateId());
142
+ const responseActivity = {
143
+ "@context": AS_CONTEXT,
144
+ id: responseId,
145
+ type: responseType,
146
+ actor: group.apId,
147
+ object: activityId,
148
+ };
149
+
150
+ await db.insert(activities).values({
151
+ apId: responseId,
152
+ type: responseType,
153
+ actorApId: group.apId,
154
+ objectApId: activityId,
155
+ rawJson: JSON.stringify(responseActivity),
156
+ direction: "outbound",
157
+ });
158
+
159
+ // Outbound delivery must be async (no remote POST in request path). The
160
+ // Accept is signed by `group.apId` — queue-delivery resolves the community /
161
+ // instance-actor key for that apId.
162
+ await enqueueDeliveryToActor(c.env, responseId, actorApIdStr);
163
+ }
164
+
165
+ export async function handleGroupUndo(
166
+ c: ActivityContext,
167
+ activity: Activity,
168
+ group: GroupFollowTarget,
169
+ actorApIdStr: string,
170
+ ) {
171
+ const db = c.get("db");
172
+ const objectId = getActivityObjectId(activity);
173
+ if (!objectId) return;
174
+
175
+ // Try exact match by activity AP ID first.
176
+ const follow = await db.query.follows.findFirst({
177
+ where: and(
178
+ eq(follows.activityApId, objectId),
179
+ eq(follows.followingApId, group.apId),
180
+ ),
181
+ });
182
+
183
+ if (follow) {
184
+ // Bind to the VERIFIED signer: only the follow's own follower may undo it.
185
+ // The activity id is public (it appears in the follower's outbox), so
186
+ // without this check a remote attacker who signs an Undo as any actor on
187
+ // their own domain could resolve a VICTIM's follow by that id and sever the
188
+ // victim's follow/membership edge — the same cross-actor forgery already
189
+ // guarded in the user-inbox undoFollow handler.
190
+ if (follow.followerApId !== actorApIdStr) return;
191
+ await db
192
+ .delete(follows)
193
+ .where(
194
+ and(
195
+ eq(follows.followerApId, follow.followerApId),
196
+ eq(follows.followingApId, follow.followingApId),
197
+ ),
198
+ );
199
+ return;
200
+ }
201
+
202
+ // Fallback: if the undone object is a Follow, delete by actor pair — keyed on
203
+ // the verified signer, so it can only remove the signer's OWN follow.
204
+ if (getActivityObject(activity)?.type !== "Follow") return;
205
+
206
+ await db
207
+ .delete(follows)
208
+ .where(
209
+ and(
210
+ eq(follows.followerApId, actorApIdStr),
211
+ eq(follows.followingApId, group.apId),
212
+ ),
213
+ );
214
+ }
215
+
216
+ export async function handleGroupCreate(
217
+ c: ActivityContext,
218
+ activity: Activity,
219
+ _instanceActor: InstanceActorResult,
220
+ actorApIdStr: string,
221
+ baseUrl: string,
222
+ ) {
223
+ const db = c.get("db");
224
+ const object = getActivityObject(activity);
225
+ if (!object || !typeIncludes(object.type, "Note")) return;
226
+
227
+ if (object.id) {
228
+ try {
229
+ if (
230
+ isLocal(object.id, baseUrl) ||
231
+ getDomain(object.id) !== getDomain(actorApIdStr)
232
+ ) {
233
+ return;
234
+ }
235
+ } catch {
236
+ return;
237
+ }
238
+ }
239
+
240
+ const roomUrl = object.room || activity.room;
241
+ if (!roomUrl || typeof roomUrl !== "string") return;
242
+ const match = roomUrl.match(/\/ap\/rooms\/([^\/]+)$/);
243
+ if (!match) return;
244
+ const roomId = match[1];
245
+
246
+ // Resolve the TARGET community (filtered to a live, non-deleted row) and
247
+ // authorize the post against THAT community — not the instance actor. The old
248
+ // gate checked postPolicy + a follow of the single instance Group actor, which
249
+ // every open-policy instance-follower satisfied for EVERY community, letting a
250
+ // non-member inject chat messages into ANY community — including private and
251
+ // soft-deleted ones — that then surfaced to that community's members. Mirror
252
+ // checkCommunityPostPermission's semantics, but with the FEDERATION membership
253
+ // model: a remote member is an accepted Follow of the community Group actor
254
+ // (handleGroupFollow records follows.followingApId = community.apId); a local
255
+ // member additionally has a communityMembers row carrying a role.
256
+ const community = await db
257
+ .select({
258
+ apId: communities.apId,
259
+ postPolicy: communities.postPolicy,
260
+ visibility: communities.visibility,
261
+ })
262
+ .from(communities)
263
+ .where(
264
+ and(
265
+ or(
266
+ eq(communities.preferredUsername, roomId),
267
+ eq(communities.apId, roomId),
268
+ ),
269
+ isNull(communities.deletedAt),
270
+ ),
271
+ )
272
+ .get();
273
+ if (!community) return;
274
+
275
+ const memberFollow = await db.query.follows.findFirst({
276
+ where: and(
277
+ eq(follows.followerApId, actorApIdStr),
278
+ eq(follows.followingApId, community.apId),
279
+ eq(follows.status, "accepted"),
280
+ ),
281
+ });
282
+ const memberRow = await db.query.communityMembers.findFirst({
283
+ where: and(
284
+ eq(communityMembers.communityApId, community.apId),
285
+ eq(communityMembers.actorApId, actorApIdStr),
286
+ ),
287
+ columns: { role: true },
288
+ });
289
+ const isMember = Boolean(memberFollow) || Boolean(memberRow);
290
+ const role = memberRow?.role;
291
+ const isManager = role === "owner" || role === "moderator";
292
+ const policy = community.postPolicy || "members";
293
+
294
+ // A non-public community requires membership to post regardless of policy.
295
+ if (communityRequiresMembership(community.visibility) && !isMember) return;
296
+ if (policy !== "anyone" && !isMember) return;
297
+ if (policy === "mods" && !isManager) return;
298
+ if (policy === "owners" && role !== "owner") return;
299
+
300
+ const newObjectId = object.id || objectApId(baseUrl, generateId());
301
+ const existingObj = await db.query.objects.findFirst({
302
+ where: eq(objects.apId, newObjectId),
303
+ });
304
+ if (existingObj) return;
305
+
306
+ const attachments = object.attachment
307
+ ? JSON.stringify(object.attachment)
308
+ : "[]";
309
+ // Clamp + normalize the remote-controlled `published` exactly like the other
310
+ // inbound Note paths (handleCreate / insertDirectNote / handleCreateStory). The
311
+ // community chat reader sorts + keyset-paginates on `desc(objects.published)`
312
+ // and the unread count compares against `object_recipients.created_at`, so a
313
+ // verbatim far-future / malformed value would pin the message atop the chat
314
+ // forever and corrupt unread baselines.
315
+ const now = normalizeInboundTimestamp(
316
+ object.published,
317
+ new Date().toISOString(),
318
+ );
319
+
320
+ // This is a federated room (group-CHAT) message, addressed to the community
321
+ // via the object_recipients audience link below. The chat reader and the
322
+ // chat unread count (communities/messages.ts, dm/contacts.ts) treat the chat
323
+ // object-set as audience-linked Notes with `communityApId IS NULL`, disjoint
324
+ // from the feed object-set (which carries `communityApId`). Leave
325
+ // `communityApId` NULL here so the message surfaces in the group chat and
326
+ // unread count rather than being misrouted into the community feed.
327
+ await db.insert(objects).values({
328
+ apId: newObjectId,
329
+ type: "Note",
330
+ attributedTo: actorApIdStr,
331
+ content: boundInboundContent(object.content),
332
+ summary: boundInboundSummary(object.summary),
333
+ attachmentsJson: boundAttachmentsJson(attachments),
334
+ visibility: "group",
335
+ // Record the community in audienceJson too (not just the object_recipients
336
+ // row): the canonical single-object read-gate (canViewerReadObject) keys on
337
+ // audienceJson/communityApId and does NOT consult object_recipients, so
338
+ // without this a federated chat message in a PRIVATE community was served to
339
+ // ANY (even anonymous) caller via GET /api/posts/:id. The local chat POST sets
340
+ // this for the same reason. Safe for the chat reader (joins on
341
+ // object_recipients + communityApId IS NULL, not audienceJson) and the feed
342
+ // (audienceJson != "[]" already excludes it).
343
+ audienceJson: JSON.stringify([community.apId]),
344
+ communityApId: null,
345
+ published: now,
346
+ isLocal: 0,
347
+ });
348
+
349
+ // Using raw SQL with INSERT OR IGNORE since ObjectRecipient FK expects Actor, not Community
350
+ await db.run(sql`
351
+ INSERT OR IGNORE INTO object_recipients (object_ap_id, recipient_ap_id, type, created_at)
352
+ VALUES (${newObjectId}, ${community.apId}, 'audience', ${now})
353
+ `);
354
+ }
@@ -0,0 +1,29 @@
1
+ // Clamp + normalize a remote-controlled timestamp (inbound AP `published`).
2
+ // Stored verbatim it poisons the `desc(published)` feed/chat sort + the
3
+ // `lt(published, cursor)` paginator two ways: (1) a non-ISO / space-separated /
4
+ // non-`Z` value lexically mis-sorts under SQLite BINARY collation; (2) a VALID
5
+ // but far-FUTURE value ("9999-…") legitimately parses yet lexically dominates
6
+ // every real `2026-…` row, pinning the row to the top of every feed forever. So:
7
+ // reformat to the canonical ISO-8601 UTC shape every comparison operand uses,
8
+ // AND clamp a future date down to `now` (a post cannot be published in the
9
+ // future; a small skew slack is allowed). Garbage / missing → `now`. We do NOT
10
+ // clamp the past — legitimately old (backfilled) posts exist and merely sink.
11
+ // (Mirrors the `endTime` clamp already applied to inbound stories.)
12
+ //
13
+ // Shared by every inbound Note path — handleCreate / insertDirectNote /
14
+ // handleCreateStory (inbox-content-handlers) AND handleGroupCreate
15
+ // (actor-inbox-handlers, the federated community-chat path) — so none can skip it.
16
+ export const FUTURE_PUBLISHED_SKEW_MS = 5 * 60 * 1000;
17
+
18
+ export function normalizeInboundTimestamp(
19
+ raw: string | null | undefined,
20
+ fallbackNow: string,
21
+ ): string {
22
+ const nowMs = Date.parse(fallbackNow);
23
+ const ms = raw ? Date.parse(raw) : NaN;
24
+ if (!Number.isFinite(ms)) return fallbackNow;
25
+ if (Number.isFinite(nowMs) && ms > nowMs + FUTURE_PUBLISHED_SKEW_MS) {
26
+ return fallbackNow;
27
+ }
28
+ return new Date(ms).toISOString();
29
+ }