@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,139 @@
1
+ export const DELIVERY_QUEUE_MESSAGE_VERSION = 1 as const;
2
+
3
+ export type DeliveryFanoutFollowersMessageV1 = {
4
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
5
+ type: "fanout_followers";
6
+ activityId: string;
7
+ followeeApId: string;
8
+ scheduledAt: string; // ISO8601 UTC
9
+ };
10
+
11
+ /**
12
+ * Fan-out of an activity to a community's audience (members + community
13
+ * followers) rather than the author's personal follower graph. Used for
14
+ * community-scoped posts so reach == community, not author-followers.
15
+ */
16
+ export type DeliveryFanoutCommunityMessageV1 = {
17
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
18
+ type: "fanout_community";
19
+ activityId: string;
20
+ communityApId: string;
21
+ // The Group's Announce of the post (Announce-relay): delivered to REMOTE
22
+ // community followers in place of the raw author activity, so the post is
23
+ // attributed to the group (Lemmy/Mobilizon convention). Local members still
24
+ // receive `activityId`. Absent for non-Create activities (edit/delete relay
25
+ // the activity directly).
26
+ announceActivityId?: string;
27
+ scheduledAt: string; // ISO8601 UTC
28
+ };
29
+
30
+ export type DeliveryResolveActorMessageV1 = {
31
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
32
+ type: "resolve_actor";
33
+ activityId: string;
34
+ recipientActorApId: string;
35
+ // How many resolve attempts this message represents. Each failed actor fetch
36
+ // re-enqueues with attempts+1; the handler gives up at a cap so a permanently
37
+ // unresolvable recipient does not generate a queue message forever. Absent on
38
+ // the initial enqueue (treated as 0).
39
+ attempts?: number;
40
+ scheduledAt: string; // ISO8601 UTC
41
+ };
42
+
43
+ export type DeliveryDeliverEndpointMessageV1 = {
44
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
45
+ type: "deliver_endpoint";
46
+ jobId: string;
47
+ // How many reconcile cycles this job has already been through. Carried in-band
48
+ // (deliver_endpoint -> dlq -> reconcile_job -> deliver_endpoint) so the
49
+ // reconcile loop has a DURABLE-across-attempts terminal condition without a DB
50
+ // column. Absent on the initial delivery (treated as 0).
51
+ reconcileAttempt?: number;
52
+ scheduledAt: string; // ISO8601 UTC
53
+ };
54
+
55
+ export type DeliveryReconcileJobMessageV1 = {
56
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
57
+ type: "reconcile_job";
58
+ jobId: string;
59
+ reconcileAttempt: number;
60
+ scheduledAt: string; // ISO8601 UTC
61
+ };
62
+
63
+ export type DeliveryQueueMessageV1 =
64
+ | DeliveryFanoutFollowersMessageV1
65
+ | DeliveryFanoutCommunityMessageV1
66
+ | DeliveryResolveActorMessageV1
67
+ | DeliveryDeliverEndpointMessageV1
68
+ | DeliveryReconcileJobMessageV1;
69
+
70
+ export type DeliveryDlqMessageV1 = {
71
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
72
+ type: "dlq";
73
+ jobId: string;
74
+ activityId: string;
75
+ endpoint: string;
76
+ attempts: number;
77
+ lastError: string | null;
78
+ // The reconcile-cycle count carried from the deliver_endpoint that
79
+ // dead-lettered, so the DLQ consumer can stop reconciling once it hits the cap.
80
+ reconcileAttempt?: number;
81
+ deadLetteredAt: string; // ISO8601 UTC
82
+ };
83
+
84
+ export function isDeliveryQueueMessageV1(
85
+ value: unknown,
86
+ ): value is DeliveryQueueMessageV1 {
87
+ if (!value || typeof value !== "object") return false;
88
+ const v = value as Record<string, unknown>;
89
+ if (v.version !== DELIVERY_QUEUE_MESSAGE_VERSION) return false;
90
+ if (typeof v.type !== "string") return false;
91
+
92
+ switch (v.type) {
93
+ case "fanout_followers":
94
+ return (
95
+ typeof v.activityId === "string" &&
96
+ typeof v.followeeApId === "string" &&
97
+ typeof v.scheduledAt === "string"
98
+ );
99
+ case "fanout_community":
100
+ return (
101
+ typeof v.activityId === "string" &&
102
+ typeof v.communityApId === "string" &&
103
+ typeof v.scheduledAt === "string"
104
+ );
105
+ case "resolve_actor":
106
+ return (
107
+ typeof v.activityId === "string" &&
108
+ typeof v.recipientActorApId === "string" &&
109
+ typeof v.scheduledAt === "string"
110
+ );
111
+ case "deliver_endpoint":
112
+ return typeof v.jobId === "string" && typeof v.scheduledAt === "string";
113
+ case "reconcile_job":
114
+ return (
115
+ typeof v.jobId === "string" &&
116
+ typeof v.reconcileAttempt === "number" &&
117
+ typeof v.scheduledAt === "string"
118
+ );
119
+ default:
120
+ return false;
121
+ }
122
+ }
123
+
124
+ export function isDeliveryDlqMessageV1(
125
+ value: unknown,
126
+ ): value is DeliveryDlqMessageV1 {
127
+ if (!value || typeof value !== "object") return false;
128
+ const v = value as Record<string, unknown>;
129
+ return (
130
+ v.version === DELIVERY_QUEUE_MESSAGE_VERSION &&
131
+ v.type === "dlq" &&
132
+ typeof v.jobId === "string" &&
133
+ typeof v.activityId === "string" &&
134
+ typeof v.endpoint === "string" &&
135
+ typeof v.attempts === "number" &&
136
+ (v.lastError === null || typeof v.lastError === "string") &&
137
+ typeof v.deadLetteredAt === "string"
138
+ );
139
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Standardized error handling for Yurucommu.
3
+ * All errors extend AppError with code, message, statusCode, and optional details.
4
+ */
5
+
6
+ import { logger } from "./logger.ts";
7
+ import { maskSensitiveData, maskSensitiveString } from "./log-mask.ts";
8
+
9
+ const log = logger.child({ component: "errors" });
10
+
11
+ const ErrorCodes = {
12
+ INTERNAL_ERROR: "INTERNAL_ERROR",
13
+ BAD_REQUEST: "BAD_REQUEST",
14
+ } as const;
15
+
16
+ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
17
+
18
+ // FLAT error envelope: `error` is the human-readable message STRING, matching
19
+ // the ~357 per-route handlers (which all return `{ error: "..." }`) and the web
20
+ // client parser (extractErrorMessage reads `data.error` as a string). A nested
21
+ // `{ error: { code, message } }` here would render as "[object Object]" client-
22
+ // side on every 500 / malformed-body 400. `code`/`correlation_id` ride alongside.
23
+ interface ErrorResponse {
24
+ error: string;
25
+ code: string;
26
+ correlation_id?: string;
27
+ details?: unknown;
28
+ }
29
+
30
+ export class AppError extends Error {
31
+ public readonly code: ErrorCode;
32
+ public readonly statusCode: number;
33
+ public readonly details?: unknown;
34
+
35
+ constructor(
36
+ message: string,
37
+ code: ErrorCode = ErrorCodes.INTERNAL_ERROR,
38
+ statusCode = 500,
39
+ details?: unknown,
40
+ ) {
41
+ super(message);
42
+ this.name = this.constructor.name;
43
+ this.code = code;
44
+ this.statusCode = statusCode;
45
+ this.details = details;
46
+ Error.captureStackTrace?.(this, this.constructor);
47
+ }
48
+
49
+ toResponse(): ErrorResponse {
50
+ return {
51
+ error: this.message,
52
+ code: this.code,
53
+ ...(this.details !== undefined && { details: this.details }),
54
+ };
55
+ }
56
+ }
57
+
58
+ // --- Concrete error classes ---
59
+ // Each binds a fixed error code and HTTP status to AppError.
60
+ // InternalError is the only subclass with an actual `new` site
61
+ // (error-handler.ts resolveAppError fallback).
62
+
63
+ export class InternalError extends AppError {
64
+ constructor(message = "Internal server error", details?: unknown) {
65
+ super(message, ErrorCodes.INTERNAL_ERROR, 500, details);
66
+ }
67
+ }
68
+
69
+ export class BadRequestError extends AppError {
70
+ constructor(message = "Bad request", details?: unknown) {
71
+ super(message, ErrorCodes.BAD_REQUEST, 400, details);
72
+ }
73
+ }
74
+
75
+ // --- Utility functions ---
76
+
77
+ export function isAppError(error: unknown): error is AppError {
78
+ return error instanceof AppError;
79
+ }
80
+
81
+ export function logError(
82
+ error: unknown,
83
+ context?: Record<string, unknown>,
84
+ ): void {
85
+ // Pass message / stack / details through the PII masker before
86
+ // emitting. The logger applies the same masker again as a safety net,
87
+ // but masking here keeps the structured `details` shape (which may be
88
+ // a nested object) honest even if logger transports change.
89
+ const errorInfo = isAppError(error)
90
+ ? {
91
+ name: error.name,
92
+ code: error.code,
93
+ message: maskSensitiveString(error.message),
94
+ statusCode: error.statusCode,
95
+ details: maskSensitiveData(error.details),
96
+ stack: error.stack ? maskSensitiveString(error.stack) : undefined,
97
+ }
98
+ : {
99
+ message:
100
+ error instanceof Error
101
+ ? maskSensitiveString(error.message)
102
+ : maskSensitiveString(String(error)),
103
+ stack:
104
+ error instanceof Error && error.stack
105
+ ? maskSensitiveString(error.stack)
106
+ : undefined,
107
+ };
108
+
109
+ log.error("AppError", {
110
+ event: "app.error",
111
+ ...errorInfo,
112
+ context: context ? maskSensitiveData(context) : undefined,
113
+ });
114
+ }
@@ -0,0 +1,296 @@
1
+ import {
2
+ assertSafeRemoteUrlResolved,
3
+ isTakosTestHostname,
4
+ localSubstrateRemoteFetchesEnabled,
5
+ nodeLookupAll,
6
+ normalizeHostname,
7
+ resolveRemoteHostnameIPs,
8
+ } from "./ssrf.ts";
9
+
10
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
11
+
12
+ // Upper bound on the body of any remote federation fetch (actor / object /
13
+ // WebFinger documents). These are small JSON documents in practice, so a
14
+ // couple of MiB is generous; the cap exists to stop a malicious or buggy
15
+ // remote from streaming a multi-GB / never-ending body into memory on the
16
+ // attacker-reachable, pre-auth fetchActorPublicKey hot path. Env-overridable
17
+ // for operators that federate with peers shipping unusually large actor docs.
18
+ const DEFAULT_MAX_FEDERATION_BODY_BYTES = 2 * 1024 * 1024;
19
+ const MAX_FEDERATION_BODY_BYTES_ENV = "YURUCOMMU_MAX_FEDERATION_BODY_BYTES";
20
+
21
+ function maxFederationBodyBytes(): number {
22
+ const processEnv = (
23
+ globalThis as {
24
+ process?: { env?: Record<string, string | undefined> };
25
+ }
26
+ ).process?.env;
27
+ const raw = processEnv?.[MAX_FEDERATION_BODY_BYTES_ENV];
28
+ if (!raw) return DEFAULT_MAX_FEDERATION_BODY_BYTES;
29
+ const parsed = Number(raw);
30
+ if (!Number.isFinite(parsed) || parsed <= 0) {
31
+ return DEFAULT_MAX_FEDERATION_BODY_BYTES;
32
+ }
33
+ return Math.floor(parsed);
34
+ }
35
+
36
+ export class FederationBodyTooLargeError extends Error {
37
+ constructor(
38
+ public readonly url: string,
39
+ public readonly limit: number,
40
+ ) {
41
+ super(`Remote federation response body exceeded ${limit} bytes: ${url}`);
42
+ this.name = "FederationBodyTooLargeError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read `response`'s body fully into a byte buffer while enforcing a hard byte
48
+ * cap and keeping the read under `signal` (so the request timeout still bounds
49
+ * the body, not just the headers). Rejects early on an oversized Content-Length
50
+ * and otherwise streams the body counting bytes, aborting once the cap is
51
+ * exceeded.
52
+ *
53
+ * Mirrors the capped-reader discipline used elsewhere for tenant-influenced
54
+ * fetches (e.g. the inbox MAX_PAYLOAD_BYTES guard and the node-postgres /
55
+ * git-fetch capped readers).
56
+ */
57
+ async function readBodyBytesWithCap(
58
+ response: Response,
59
+ url: string,
60
+ limit: number,
61
+ signal: AbortSignal,
62
+ ): Promise<Uint8Array> {
63
+ // Short-circuit on an honest, oversized Content-Length before reading.
64
+ const contentLengthHeader = response.headers.get("content-length");
65
+ if (contentLengthHeader) {
66
+ const declared = Number(contentLengthHeader);
67
+ if (Number.isFinite(declared) && declared > limit) {
68
+ // Drain/cancel so the connection isn't left half-read.
69
+ try {
70
+ await response.body?.cancel();
71
+ } catch {
72
+ /* ignore cancel failures */
73
+ }
74
+ throw new FederationBodyTooLargeError(url, limit);
75
+ }
76
+ }
77
+
78
+ // A consumed/absent body (e.g. 204) reads as empty bytes.
79
+ if (!response.body) {
80
+ return new Uint8Array(0);
81
+ }
82
+
83
+ if (signal.aborted) {
84
+ try {
85
+ await response.body.cancel();
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ throw signal.reason instanceof Error ? signal.reason : new Error("Aborted");
90
+ }
91
+
92
+ const reader = response.body.getReader();
93
+ const chunks: Uint8Array[] = [];
94
+ let total = 0;
95
+
96
+ const onAbort = () => {
97
+ void reader.cancel(signal.reason).catch(() => {});
98
+ };
99
+ signal.addEventListener("abort", onAbort, { once: true });
100
+
101
+ try {
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ if (value) {
106
+ total += value.byteLength;
107
+ if (total > limit) {
108
+ await reader.cancel().catch(() => {});
109
+ throw new FederationBodyTooLargeError(url, limit);
110
+ }
111
+ chunks.push(value);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ if (signal.aborted && !(err instanceof FederationBodyTooLargeError)) {
116
+ throw signal.reason instanceof Error ? signal.reason : err;
117
+ }
118
+ throw err;
119
+ } finally {
120
+ signal.removeEventListener("abort", onAbort);
121
+ }
122
+
123
+ const merged = new Uint8Array(total);
124
+ let offset = 0;
125
+ for (const chunk of chunks) {
126
+ merged.set(chunk, offset);
127
+ offset += chunk.byteLength;
128
+ }
129
+ return merged;
130
+ }
131
+
132
+ /**
133
+ * Wrap a `Response` so `.json()` / `.text()` (and the other body accessors)
134
+ * read the body under a LIVE timeout + a hard size cap, instead of the raw
135
+ * `Response` whose body read is unbounded once the headers arrive. The wrapper
136
+ * delegates every non-body member (`ok`, `status`, `headers`, `url`, ...) to
137
+ * the underlying response, so all callers keep their existing `res.ok` /
138
+ * `res.status` / `res.json()` / `res.text()` usage with no signature churn.
139
+ */
140
+ function wrapResponseWithCap(
141
+ response: Response,
142
+ url: string,
143
+ timeout: number,
144
+ ): Response {
145
+ const limit = maxFederationBodyBytes();
146
+ let bodyConsumed = false;
147
+
148
+ const readBytes = async (): Promise<Uint8Array> => {
149
+ if (bodyConsumed) {
150
+ // Surface the native "Body already consumed" failure mode by reading the
151
+ // (already-locked) underlying body.
152
+ return new Uint8Array(await response.arrayBuffer());
153
+ }
154
+ bodyConsumed = true;
155
+ // Fresh timeout covering ONLY the body read, so a slow/never-ending body
156
+ // is bounded even though the headers already arrived.
157
+ const bodyAbort = AbortSignal.timeout(timeout);
158
+ try {
159
+ return await readBodyBytesWithCap(response, url, limit, bodyAbort);
160
+ } catch (err) {
161
+ if (err instanceof Error && err.name === "TimeoutError") {
162
+ throw new Error(
163
+ `Response body read timed out after ${
164
+ timeout / 1000
165
+ } seconds: ${url}`,
166
+ );
167
+ }
168
+ throw err;
169
+ }
170
+ };
171
+
172
+ const readText = async (): Promise<string> =>
173
+ new TextDecoder().decode(await readBytes());
174
+
175
+ return new Proxy(response, {
176
+ get(target, prop, _receiver) {
177
+ if (prop === "text") {
178
+ return () => readText();
179
+ }
180
+ if (prop === "json") {
181
+ return async () => JSON.parse(await readText());
182
+ }
183
+ if (prop === "arrayBuffer") {
184
+ return async () => {
185
+ const bytes = await readBytes();
186
+ // Return a standalone ArrayBuffer copy (the merged buffer may be a
187
+ // view into a larger allocation in some runtimes).
188
+ return bytes.slice().buffer;
189
+ };
190
+ }
191
+ const value = Reflect.get(target, prop, target);
192
+ return typeof value === "function" ? value.bind(target) : value;
193
+ },
194
+ }) as Response;
195
+ }
196
+
197
+ /**
198
+ * Resolve a remote hostname using the SAME resolver `fetch` will use on this
199
+ * runtime, so the SSRF validation in `assertSafeRemoteUrlResolved` is not
200
+ * split from the resolution the actual connection performs.
201
+ *
202
+ * DNS-rebinding TOCTOU has two distinct vectors here:
203
+ *
204
+ * 1. Resolver split — validating via Cloudflare DoH while `fetch` connects
205
+ * using the host OS resolver. An attacker who controls authoritative DNS
206
+ * can deterministically serve a public IP to DoH and a private IP to the
207
+ * OS resolver. On a Bun/Node host, we close this deterministic split by
208
+ * validating with the host resolver instead of DoH, so validation and the
209
+ * connection both go through the
210
+ * host's configured DNS rather than two different trust domains. (This
211
+ * removes the resolver-split exploit; it does not by itself guarantee
212
+ * fetch reuses the identical IP — see vector 2.) On Workers `fetch`
213
+ * resolves at the edge with no host-OS resolver to diverge from, so DoH
214
+ * and the connection resolve in the same trust domain.
215
+ * 2. Low-TTL flip — the record changes between validation and connection.
216
+ * Neither Workers' nor host `fetch` exposes a hook to pin the
217
+ * connection to an already-resolved IP, so we cannot eliminate this
218
+ * sub-resolution window through `fetch`; we minimize it by resolving
219
+ * immediately before the request with no other awaited work in between.
220
+ */
221
+ async function resolveConnectionResolverIPs(
222
+ hostname: string,
223
+ ): Promise<string[]> {
224
+ const processLike = (globalThis as { process?: unknown }).process;
225
+ if (processLike) return await nodeLookupAll(hostname);
226
+ return resolveRemoteHostnameIPs(hostname);
227
+ }
228
+
229
+ export async function fetchWithTimeout(
230
+ url: string,
231
+ options: RequestInit & { timeout?: number; skipSafetyCheck?: boolean } = {},
232
+ ): Promise<Response> {
233
+ const {
234
+ timeout = DEFAULT_FETCH_TIMEOUT_MS,
235
+ skipSafetyCheck = false,
236
+ ...fetchOptions
237
+ } = options;
238
+
239
+ if (!skipSafetyCheck) {
240
+ // Validate using the resolver that `fetch` itself will use on this
241
+ // runtime (closes the resolver-split rebinding vector — see
242
+ // resolveConnectionResolverIPs). The local-substrate path keeps its own
243
+ // resolver logic, so only override the remote-resolver default here.
244
+ const parsed = new URL(url);
245
+ const hostname = normalizeHostname(parsed.hostname);
246
+ const allowLocalSubstrate = localSubstrateRemoteFetchesEnabled();
247
+ const useConnectionResolver = !(
248
+ allowLocalSubstrate && isTakosTestHostname(hostname)
249
+ );
250
+
251
+ await assertSafeRemoteUrlResolved(
252
+ url,
253
+ useConnectionResolver
254
+ ? { remoteResolver: resolveConnectionResolverIPs }
255
+ : {},
256
+ );
257
+ }
258
+
259
+ const controller = new AbortController();
260
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
261
+
262
+ try {
263
+ const response = await fetch(url, {
264
+ ...fetchOptions,
265
+ signal: controller.signal,
266
+ redirect: "manual", // Prevent redirect-based SSRF bypassing DNS safety checks
267
+ });
268
+ // Reject redirects to prevent SSRF via open redirects on remote servers
269
+ if (response.status >= 300 && response.status < 400) {
270
+ throw new Error(
271
+ `Redirect not allowed from remote URL: ${url} -> ${response.headers.get(
272
+ "location",
273
+ )}`,
274
+ );
275
+ }
276
+ // The headers-phase timer is cleared in `finally` below, but a raw
277
+ // `Response` then reads its body with NO time bound and NO size bound — on
278
+ // the attacker-reachable, pre-auth federation ingress (fetchActorPublicKey
279
+ // runs on every inbound activity) a malicious remote can stream a
280
+ // multi-GB / never-ending body and exhaust memory. Wrap the response so
281
+ // `.json()` / `.text()` read the body under a fresh timeout and a hard
282
+ // byte cap. Callers keep their `res.ok` / `res.status` / `res.json()` /
283
+ // `res.text()` usage unchanged; paths that never touch the body (outbound
284
+ // POST delivery) simply never trigger the capped read.
285
+ return wrapResponseWithCap(response, url, timeout);
286
+ } catch (err) {
287
+ if (err instanceof Error && err.name === "AbortError") {
288
+ throw new Error(
289
+ `Request timed out after ${timeout / 1000} seconds: ${url}`,
290
+ );
291
+ }
292
+ throw err;
293
+ } finally {
294
+ clearTimeout(timeoutId);
295
+ }
296
+ }
@@ -0,0 +1,57 @@
1
+ // Shared keyset cursor for `(published, apId)`-ordered feeds.
2
+ //
3
+ // Several list endpoints (replies, bookmarks, profile posts, DM + community
4
+ // chat, the MCP timeline) page back through rows ordered `desc(published),
5
+ // desc(apId)`. `published`/`created_at` is NOT unique (many rows can share a
6
+ // millisecond — local writes collide, and federated inbound timestamps are
7
+ // remote-controlled), so a cursor on the timestamp alone would skip the rows on
8
+ // either side of a page boundary that share the boundary's millisecond. The
9
+ // unique `apId` is the tiebreaker that makes the ordering total.
10
+ //
11
+ // The cursor is encoded as "<published> <apId>". A SPACE separator is used (NOT
12
+ // a raw NUL — typing a single-char separator in a string literal has repeatedly
13
+ // landed as a raw 0x00 byte, which corrupts the source file): a space sorts
14
+ // strictly below every character that can appear in an ISO-8601 timestamp or an
15
+ // https:// ap_id, so the concatenated lexical order matches the (published,
16
+ // apId) tuple order, and neither field can contain a space to break the split.
17
+ // The actual SQL comparison is a real column tuple predicate, so even a
18
+ // variable-width `published` is compared correctly.
19
+
20
+ import { and, eq, lt, or } from "drizzle-orm";
21
+ import type { SQL } from "drizzle-orm";
22
+ import type { AnyColumn } from "drizzle-orm";
23
+
24
+ export const FEED_CURSOR_SEP = " ";
25
+
26
+ /**
27
+ * Encode a row's `(sortKey, tiebreaker)` into an opaque `before` cursor string.
28
+ * `sortKey` is the published/created timestamp; `tiebreaker` is the unique id
29
+ * (apId / objectApId) that makes the ordering total.
30
+ */
31
+ export function encodeFeedCursor(sortKey: string, tiebreaker: string): string {
32
+ return `${sortKey}${FEED_CURSOR_SEP}${tiebreaker}`;
33
+ }
34
+
35
+ /**
36
+ * Build the WHERE predicate for "rows strictly older than `before`" against the
37
+ * given published/apId columns. A composite `before` ("<published> <apId>")
38
+ * yields the tuple predicate `published < p OR (published = p AND apId < a)`; a
39
+ * legacy bare-published `before` (no separator) falls back to `published < before`
40
+ * so older clients/cursors keep working. Returns `undefined` when `before` is
41
+ * absent (no cursor → no predicate).
42
+ */
43
+ export function feedCursorWhere(
44
+ publishedCol: AnyColumn,
45
+ apIdCol: AnyColumn,
46
+ before: string | undefined | null,
47
+ ): SQL | undefined {
48
+ if (!before) return undefined;
49
+ const sepIdx = before.indexOf(FEED_CURSOR_SEP);
50
+ if (sepIdx < 0) return lt(publishedCol, before);
51
+ const cPublished = before.slice(0, sepIdx);
52
+ const cApId = before.slice(sepIdx + FEED_CURSOR_SEP.length);
53
+ return or(
54
+ lt(publishedCol, cPublished),
55
+ and(eq(publishedCol, cPublished), lt(apIdCol, cApId)),
56
+ );
57
+ }
@@ -0,0 +1,48 @@
1
+ import { and, type AnyColumn, eq, notInArray, type SQL } from "drizzle-orm";
2
+ import type { Database } from "../../db/index.ts";
3
+ import { blocks, mutes, objects } from "../../db/index.ts";
4
+
5
+ /**
6
+ * Predicate excluding posts authored by anyone the viewer has blocked or muted.
7
+ *
8
+ * Expressed as `objects.attributed_to NOT IN (SELECT blocked) AND ... NOT IN
9
+ * (SELECT muted)` — i.e. `NOT IN (blocked ∪ muted)` — using db.select
10
+ * SUBQUERIES rather than materialising the id lists into an `inArray`. Each
11
+ * subquery binds a single bound parameter (the viewer) regardless of how many
12
+ * accounts the viewer has blocked/muted, so it never approaches Cloudflare D1's
13
+ * 100-bound-parameter-per-query ceiling. (The previous `notInArray(attributedTo,
14
+ * [...blocked, ...muted])` materialised up to ~2000 ids — fine on the libsql the
15
+ * tests run on, but a "too many SQL variables" 500 on production D1 for a heavy
16
+ * moderator.)
17
+ *
18
+ * Returns `undefined` for an empty viewer (anonymous) so callers can skip it.
19
+ * Applying it unconditionally for a logged-in viewer is correct even with zero
20
+ * blocks/mutes: `NOT IN (empty set)` is true for every row.
21
+ *
22
+ * `column` defaults to the post author (`objects.attributedTo`) for feeds, but
23
+ * can be any actor-id column — e.g. `activities.actorApId` so the notifications
24
+ * list/count can suppress activity from blocked/muted accounts the same way.
25
+ */
26
+ export function excludeBlockedMutedAuthors(
27
+ db: Database,
28
+ viewerApId: string,
29
+ column: AnyColumn = objects.attributedTo,
30
+ ): SQL | undefined {
31
+ if (!viewerApId) return undefined;
32
+ return and(
33
+ notInArray(
34
+ column,
35
+ db
36
+ .select({ id: blocks.blockedApId })
37
+ .from(blocks)
38
+ .where(eq(blocks.blockerApId, viewerApId)),
39
+ ),
40
+ notInArray(
41
+ column,
42
+ db
43
+ .select({ id: mutes.mutedApId })
44
+ .from(mutes)
45
+ .where(eq(mutes.muterApId, viewerApId)),
46
+ ),
47
+ );
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Lowercase hex encoding of a byte array (each byte → 2 hex chars).
3
+ */
4
+ export function bytesToHex(bytes: Uint8Array): string {
5
+ return Array.from(bytes)
6
+ .map((b) => b.toString(16).padStart(2, "0"))
7
+ .join("");
8
+ }