@takosjp/yurucommu-core 3.4.3 → 3.4.4

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 (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -2,7 +2,6 @@ import { and, eq, isNull, or, sql } from "drizzle-orm";
2
2
  import {
3
3
  activities,
4
4
  communities,
5
- communityMembers,
6
5
  follows,
7
6
  objectRecipients,
8
7
  objects,
@@ -10,19 +9,26 @@ import {
10
9
  import {
11
10
  activityApId,
12
11
  generateId,
13
- getDomain,
14
12
  isLocal,
15
- objectApId,
16
13
  } from "../../../federation-helpers.ts";
17
14
  import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
18
- import { communityRequiresMembership } from "../../../lib/community-visibility.ts";
15
+ import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
16
+ import { severFollowDirection } from "../../../lib/follow-edge-mutations.ts";
19
17
  import { isMemberBanned } from "../../communities/membership-shared.ts";
20
18
  import {
21
- boundAttachmentsJson,
22
19
  boundInboundContent,
20
+ boundInboundNoteAttachmentsJson,
23
21
  boundInboundSummary,
22
+ boundInboundTagsJson,
24
23
  } from "../../posts/transformers.ts";
25
24
  import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
25
+ import { canActorPostToInboundCommunity } from "./inbound-community-scope.ts";
26
+ import { validateInboundObjectIdentity } from "./inbound-object-identity.ts";
27
+ import {
28
+ findFollowByActivityId,
29
+ findFollowByFollowerIdentity,
30
+ runBatch,
31
+ } from "./inbox-shared-helpers.ts";
26
32
  import type { InstanceActorResult } from "../query-helpers.ts";
27
33
  import {
28
34
  type Activity,
@@ -67,12 +73,11 @@ export async function handleGroupFollow(
67
73
  followingApId: group.apId,
68
74
  };
69
75
 
70
- const existing = await db.query.follows.findFirst({
71
- where: and(
72
- eq(follows.followerApId, followerKey.followerApId),
73
- eq(follows.followingApId, followerKey.followingApId),
74
- ),
75
- });
76
+ const existing = await findFollowByFollowerIdentity(
77
+ db,
78
+ followerKey.followerApId,
79
+ followerKey.followingApId,
80
+ );
76
81
 
77
82
  // A banned actor re-Following an OPEN community must NOT auto-rejoin: force a
78
83
  // Reject and record no edge (durable kick — mirrors the local open-join ban
@@ -173,30 +178,24 @@ export async function handleGroupUndo(
173
178
  const objectId = getActivityObjectId(activity);
174
179
  if (!objectId) return;
175
180
 
176
- // Try exact match by activity AP ID first.
177
- const follow = await db.query.follows.findFirst({
178
- where: and(
179
- eq(follows.activityApId, objectId),
180
- eq(follows.followingApId, group.apId),
181
- ),
181
+ // Resolve either the retained local ledger IRI or the peer's wire id within
182
+ // the verified signer. This also makes a bare-string Undo(Follow) work for a
183
+ // Group instead of requiring an embedded typed Follow object.
184
+ const follow = await findFollowByActivityId(db, objectId, {
185
+ actorApId: actorApIdStr,
186
+ localBaseUrl: c.env.APP_URL,
182
187
  });
183
188
 
184
189
  if (follow) {
190
+ if (follow.followingApId !== group.apId) return;
185
191
  // Bind to the VERIFIED signer: only the follow's own follower may undo it.
186
192
  // The activity id is public (it appears in the follower's outbox), so
187
193
  // without this check a remote attacker who signs an Undo as any actor on
188
194
  // their own domain could resolve a VICTIM's follow by that id and sever the
189
195
  // victim's follow/membership edge — the same cross-actor forgery already
190
196
  // guarded in the user-inbox undoFollow handler.
191
- if (follow.followerApId !== actorApIdStr) return;
192
- await db
193
- .delete(follows)
194
- .where(
195
- and(
196
- eq(follows.followerApId, follow.followerApId),
197
- eq(follows.followingApId, follow.followingApId),
198
- ),
199
- );
197
+ if (!isSameActivityPubActor(follow.followerApId, actorApIdStr)) return;
198
+ await severFollowDirection(db, follow.followerApId, follow.followingApId);
200
199
  return;
201
200
  }
202
201
 
@@ -204,14 +203,17 @@ export async function handleGroupUndo(
204
203
  // the verified signer, so it can only remove the signer's OWN follow.
205
204
  if (getActivityObject(activity)?.type !== "Follow") return;
206
205
 
207
- await db
208
- .delete(follows)
209
- .where(
210
- and(
211
- eq(follows.followerApId, actorApIdStr),
212
- eq(follows.followingApId, group.apId),
213
- ),
214
- );
206
+ const retainedFollow = await findFollowByFollowerIdentity(
207
+ db,
208
+ actorApIdStr,
209
+ group.apId,
210
+ );
211
+ if (!retainedFollow) return;
212
+ await severFollowDirection(
213
+ db,
214
+ retainedFollow.followerApId,
215
+ retainedFollow.followingApId,
216
+ );
215
217
  }
216
218
 
217
219
  export async function handleGroupCreate(
@@ -225,18 +227,12 @@ export async function handleGroupCreate(
225
227
  const object = getActivityObject(activity);
226
228
  if (!object || !typeIncludes(object.type, "Note")) return;
227
229
 
228
- if (object.id) {
229
- try {
230
- if (
231
- isLocal(object.id, baseUrl) ||
232
- getDomain(object.id) !== getDomain(actorApIdStr)
233
- ) {
234
- return;
235
- }
236
- } catch {
237
- return;
238
- }
239
- }
230
+ const identity = validateInboundObjectIdentity(
231
+ object.id,
232
+ actorApIdStr,
233
+ baseUrl,
234
+ );
235
+ if (!identity.ok) return;
240
236
 
241
237
  const roomUrl = object.room || activity.room;
242
238
  if (!roomUrl || typeof roomUrl !== "string") return;
@@ -273,40 +269,13 @@ export async function handleGroupCreate(
273
269
  .get();
274
270
  if (!community) return;
275
271
 
276
- const memberFollow = await db.query.follows.findFirst({
277
- where: and(
278
- eq(follows.followerApId, actorApIdStr),
279
- eq(follows.followingApId, community.apId),
280
- eq(follows.status, "accepted"),
281
- ),
282
- });
283
- const memberRow = await db.query.communityMembers.findFirst({
284
- where: and(
285
- eq(communityMembers.communityApId, community.apId),
286
- eq(communityMembers.actorApId, actorApIdStr),
287
- ),
288
- columns: { role: true },
289
- });
290
- const isMember = Boolean(memberFollow) || Boolean(memberRow);
291
- const role = memberRow?.role;
292
- const isManager = role === "owner" || role === "moderator";
293
- const policy = community.postPolicy || "members";
294
-
295
- // A non-public community requires membership to post regardless of policy.
296
- if (communityRequiresMembership(community.visibility) && !isMember) return;
297
- if (policy !== "anyone" && !isMember) return;
298
- if (policy === "mods" && !isManager) return;
299
- if (policy === "owners" && role !== "owner") return;
272
+ if (!(await canActorPostToInboundCommunity(db, actorApIdStr, community))) {
273
+ return;
274
+ }
300
275
 
301
- const newObjectId = object.id || objectApId(baseUrl, generateId());
302
- const existingObj = await db.query.objects.findFirst({
303
- where: eq(objects.apId, newObjectId),
304
- });
305
- if (existingObj) return;
276
+ const newObjectId = identity.objectId;
277
+ const audienceJson = JSON.stringify([community.apId]);
306
278
 
307
- const attachments = object.attachment
308
- ? JSON.stringify(object.attachment)
309
- : "[]";
310
279
  // Clamp + normalize the remote-controlled `published` exactly like the other
311
280
  // inbound Note paths (handleCreate / insertDirectNote / handleCreateStory). The
312
281
  // community chat reader sorts + keyset-paginates on `desc(objects.published)`
@@ -325,31 +294,61 @@ export async function handleGroupCreate(
325
294
  // from the feed object-set (which carries `communityApId`). Leave
326
295
  // `communityApId` NULL here so the message surfaces in the group chat and
327
296
  // unread count rather than being misrouted into the community feed.
328
- await db.insert(objects).values({
329
- apId: newObjectId,
330
- type: "Note",
331
- attributedTo: actorApIdStr,
332
- content: boundInboundContent(object.content),
333
- summary: boundInboundSummary(object.summary),
334
- attachmentsJson: boundAttachmentsJson(attachments),
335
- visibility: "group",
336
- // Record the community in audienceJson too (not just the object_recipients
337
- // row): the canonical single-object read-gate (canViewerReadObject) keys on
338
- // audienceJson/communityApId and does NOT consult object_recipients, so
339
- // without this a federated chat message in a PRIVATE community was served to
340
- // ANY (even anonymous) caller via GET /api/posts/:id. The local chat POST sets
341
- // this for the same reason. Safe for the chat reader (joins on
342
- // object_recipients + communityApId IS NULL, not audienceJson) and the feed
343
- // (audienceJson != "[]" already excludes it).
344
- audienceJson: JSON.stringify([community.apId]),
345
- communityApId: null,
346
- published: now,
347
- isLocal: 0,
348
- });
349
-
350
- // Using raw SQL with INSERT OR IGNORE since ObjectRecipient FK expects Actor, not Community
351
- await db.run(sql`
352
- INSERT OR IGNORE INTO object_recipients (object_ap_id, recipient_ap_id, type, created_at)
353
- VALUES (${newObjectId}, ${community.apId}, 'audience', ${now})
354
- `);
297
+ // The object and its audience membership are one logical message. Commit
298
+ // them atomically so an isolate failure cannot leave an invisible object;
299
+ // both statements are idempotent so a retry also repairs partial rows left by
300
+ // older deployments. Migration 0010 deliberately removed the incorrect
301
+ // object_recipients -> actors FK, so a community ap_id is valid here.
302
+ await runBatch(db, [
303
+ db
304
+ .insert(objects)
305
+ .values({
306
+ apId: newObjectId,
307
+ type: "Note",
308
+ attributedTo: actorApIdStr,
309
+ content: boundInboundContent(object.content),
310
+ summary: boundInboundSummary(object.summary),
311
+ attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
312
+ tagsJson: boundInboundTagsJson(object.tag),
313
+ visibility: "group",
314
+ // Record the community in audienceJson too (not just the
315
+ // object_recipients row): the canonical single-object read-gate
316
+ // (canViewerReadObject) keys on audienceJson/communityApId and does NOT
317
+ // consult object_recipients, so without this a federated chat message in
318
+ // a PRIVATE community was served to ANY caller via GET /api/posts/:id.
319
+ audienceJson,
320
+ communityApId: null,
321
+ published: now,
322
+ isLocal: 0,
323
+ })
324
+ .onConflictDoNothing(),
325
+ db
326
+ .insert(objectRecipients)
327
+ // Select from the object that ACTUALLY exists after the preceding insert,
328
+ // rather than trusting the attempted values. This closes the concurrent
329
+ // id-reuse race: if another community won the object-id insert, its
330
+ // different audience shape cannot be linked into this room.
331
+ .select(
332
+ db
333
+ .select({
334
+ objectApId: objects.apId,
335
+ recipientApId: sql<string>`${community.apId}`.as("recipient_ap_id"),
336
+ type: sql<string>`'audience'`.as("type"),
337
+ createdAt: sql<string>`${now}`.as("created_at"),
338
+ })
339
+ .from(objects)
340
+ .where(
341
+ and(
342
+ eq(objects.apId, newObjectId),
343
+ eq(objects.type, "Note"),
344
+ eq(objects.attributedTo, actorApIdStr),
345
+ eq(objects.visibility, "group"),
346
+ eq(objects.audienceJson, audienceJson),
347
+ isNull(objects.communityApId),
348
+ eq(objects.isLocal, 0),
349
+ ),
350
+ ),
351
+ )
352
+ .onConflictDoNothing(),
353
+ ]);
355
354
  }
@@ -0,0 +1,121 @@
1
+ import { and, eq, inArray, isNull } from "drizzle-orm";
2
+
3
+ import type { Database } from "../../../../db/index.ts";
4
+ import { communities, communityMembers } from "../../../../db/index.ts";
5
+ import { communityRequiresMembership } from "../../../lib/community-visibility.ts";
6
+ import { isMemberBanned } from "../../communities/membership-shared.ts";
7
+ import { findFollowByFollowerIdentity } from "./inbox-shared-helpers.ts";
8
+
9
+ export type InboundCommunityTarget = {
10
+ apId: string;
11
+ visibility: string | null;
12
+ postPolicy: string | null;
13
+ };
14
+
15
+ export type InboundCommunityScope =
16
+ | { allowed: true; communityApId: string | null }
17
+ | { allowed: false; communityApId: null };
18
+
19
+ /**
20
+ * Apply the same posting authority to every inbound community projection.
21
+ *
22
+ * Remote membership is an accepted Follow of the community Group; local
23
+ * membership carries a role in community_members. A durable ban wins before
24
+ * either representation. Keeping this decision in one helper prevents the
25
+ * Group inbox and user-inbox Note/Story paths from drifting into different
26
+ * definitions of who may inject content into a community feed.
27
+ */
28
+ export async function canActorPostToInboundCommunity(
29
+ db: Database,
30
+ actorApId: string,
31
+ community: InboundCommunityTarget,
32
+ ): Promise<boolean> {
33
+ const [banned, memberFollow, memberRow] = await Promise.all([
34
+ isMemberBanned(db, community.apId, actorApId),
35
+ findFollowByFollowerIdentity(db, actorApId, community.apId),
36
+ db.query.communityMembers.findFirst({
37
+ where: and(
38
+ eq(communityMembers.communityApId, community.apId),
39
+ eq(communityMembers.actorApId, actorApId),
40
+ ),
41
+ columns: { role: true },
42
+ }),
43
+ ]);
44
+ if (banned) return false;
45
+
46
+ const isMember = memberFollow?.status === "accepted" || Boolean(memberRow);
47
+ const role = memberRow?.role;
48
+ const isManager = role === "owner" || role === "moderator";
49
+ const policy = community.postPolicy || "members";
50
+
51
+ if (communityRequiresMembership(community.visibility) && !isMember) {
52
+ return false;
53
+ }
54
+ if (policy !== "anyone" && !isMember) return false;
55
+ if (policy === "mods" && !isManager) return false;
56
+ if (policy === "owners" && role !== "owner") return false;
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Resolve a bounded AS2 audience against local live communities and authorize
62
+ * the signer for the one matching scope.
63
+ *
64
+ * Unknown/external audiences are preserved by the caller but do not acquire a
65
+ * local community_ap_id. More than one local match is ambiguous and rejected.
66
+ * The ap_id and followers_url lookups are intentionally separate: with the
67
+ * 64-address ingress cap, combining both IN lists would compile to 128 bound
68
+ * parameters and fail on production D1's 100-variable ceiling.
69
+ */
70
+ export async function resolveInboundCommunityScope(
71
+ db: Database,
72
+ actorApId: string,
73
+ audience: readonly string[],
74
+ ): Promise<InboundCommunityScope> {
75
+ const addresses = [...new Set(audience)].filter(Boolean).slice(0, 64);
76
+ if (addresses.length === 0) {
77
+ return { allowed: true, communityApId: null };
78
+ }
79
+
80
+ const columns = {
81
+ apId: communities.apId,
82
+ visibility: communities.visibility,
83
+ postPolicy: communities.postPolicy,
84
+ };
85
+ const [byApId, byFollowersUrl] = await Promise.all([
86
+ db
87
+ .select(columns)
88
+ .from(communities)
89
+ .where(
90
+ and(
91
+ inArray(communities.apId, addresses),
92
+ isNull(communities.deletedAt),
93
+ ),
94
+ ),
95
+ db
96
+ .select(columns)
97
+ .from(communities)
98
+ .where(
99
+ and(
100
+ inArray(communities.followersUrl, addresses),
101
+ isNull(communities.deletedAt),
102
+ ),
103
+ ),
104
+ ]);
105
+ const matches = new Map<string, InboundCommunityTarget>();
106
+ for (const community of [...byApId, ...byFollowersUrl]) {
107
+ matches.set(community.apId, community);
108
+ }
109
+ if (matches.size === 0) {
110
+ return { allowed: true, communityApId: null };
111
+ }
112
+ if (matches.size !== 1) {
113
+ return { allowed: false, communityApId: null };
114
+ }
115
+
116
+ const community = [...matches.values()][0]!;
117
+ if (!(await canActorPostToInboundCommunity(db, actorApId, community))) {
118
+ return { allowed: false, communityApId: null };
119
+ }
120
+ return { allowed: true, communityApId: community.apId };
121
+ }
@@ -0,0 +1,54 @@
1
+ import { isLocal, isSafeRemoteUrl } from "../../../federation-helpers.ts";
2
+
3
+ export const MAX_INBOUND_OBJECT_ID_LENGTH = 2048;
4
+
5
+ export type InboundObjectIdentityResult =
6
+ | { ok: true; objectId: string }
7
+ | {
8
+ ok: false;
9
+ reason:
10
+ | "missing"
11
+ | "too_long"
12
+ | "unsafe"
13
+ | "local_origin"
14
+ | "actor_origin_mismatch";
15
+ };
16
+
17
+ /**
18
+ * Bind an inbound object's durable identity to the verified remote actor.
19
+ *
20
+ * Server-to-server Create needs a stable remote object IRI: minting a local IRI
21
+ * for an anonymous remote object would make this instance appear to own bytes
22
+ * controlled by another server and would generate a different row on retry.
23
+ * Exact URL origin (scheme + host + port), credential-free HTTP(S), the local
24
+ * origin exclusion, and a storage-safe length are all one ownership decision.
25
+ */
26
+ export function validateInboundObjectIdentity(
27
+ objectId: unknown,
28
+ actorApId: string,
29
+ baseUrl: string,
30
+ ): InboundObjectIdentityResult {
31
+ if (objectId === undefined || objectId === null || objectId === "") {
32
+ return { ok: false, reason: "missing" };
33
+ }
34
+ if (typeof objectId !== "string") {
35
+ return { ok: false, reason: "unsafe" };
36
+ }
37
+ if (objectId.length > MAX_INBOUND_OBJECT_ID_LENGTH) {
38
+ return { ok: false, reason: "too_long" };
39
+ }
40
+ if (!isSafeRemoteUrl(objectId)) {
41
+ return { ok: false, reason: "unsafe" };
42
+ }
43
+ if (isLocal(objectId, baseUrl)) {
44
+ return { ok: false, reason: "local_origin" };
45
+ }
46
+ try {
47
+ if (new URL(objectId).origin !== new URL(actorApId).origin) {
48
+ return { ok: false, reason: "actor_origin_mismatch" };
49
+ }
50
+ } catch {
51
+ return { ok: false, reason: "unsafe" };
52
+ }
53
+ return { ok: true, objectId };
54
+ }
@@ -0,0 +1,34 @@
1
+ import { isSafeRemoteUrl } from "../../../federation-helpers.ts";
2
+
3
+ export const MAX_INBOUND_REPLY_TARGET_LENGTH = 2048;
4
+
5
+ export type InboundReplyTargetResult =
6
+ | { ok: true; parentId: string | null | undefined }
7
+ | {
8
+ ok: false;
9
+ reason: "malformed" | "too_long" | "unsafe";
10
+ };
11
+
12
+ /**
13
+ * Normalize one inbound Note reply edge before it reaches storage or SQL.
14
+ *
15
+ * Absence preserves partial-Update compatibility and explicit null clears an
16
+ * existing edge. A concrete parent must be one bounded, credential-free
17
+ * HTTP(S) IRI. Unlike remote object identity, the parent may be local or on a
18
+ * different remote origin: cross-origin replies are the normal federation
19
+ * case. Unknown parents remain valid links and are never fetched here.
20
+ */
21
+ export function validateInboundReplyTarget(
22
+ value: unknown,
23
+ ): InboundReplyTargetResult {
24
+ if (value === undefined) return { ok: true, parentId: undefined };
25
+ if (value === null) return { ok: true, parentId: null };
26
+ if (typeof value !== "string" || value.length === 0) {
27
+ return { ok: false, reason: "malformed" };
28
+ }
29
+ if (value.length > MAX_INBOUND_REPLY_TARGET_LENGTH) {
30
+ return { ok: false, reason: "too_long" };
31
+ }
32
+ if (!isSafeRemoteUrl(value)) return { ok: false, reason: "unsafe" };
33
+ return { ok: true, parentId: value };
34
+ }