@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,190 @@
1
+ // Single-object read-gate combining ALL visibility dimensions: the private-
2
+ // community membership gate (delegated to canViewerReadObject) AND the
3
+ // per-post public / unlisted / followers / direct visibility check. This is the
4
+ // canonical "can this viewer read this object" predicate; the inline gates in
5
+ // posts/routes.ts (post-detail + filterVisibleReplies), posts/interactions.ts
6
+ // (bookmarks), and notifications.ts implement the same logic and may migrate to
7
+ // this helper over time.
8
+
9
+ import { and, eq } from "drizzle-orm";
10
+ import type { Database } from "../../db/index.ts";
11
+ import { blocks, follows } from "../../db/index.ts";
12
+ import { canViewerReadObject } from "./community-visibility.ts";
13
+ import { safeJsonParse } from "../federation-helpers.ts";
14
+
15
+ export type ReadGateObject = {
16
+ visibility: string;
17
+ attributedTo: string;
18
+ toJson: string;
19
+ ccJson: string;
20
+ audienceJson: string;
21
+ communityApId: string | null;
22
+ // A Story is stored visibility="public" / audienceJson="[]" but its REAL reach
23
+ // is followers (personal) or members (community), and it is revoked at endTime.
24
+ // When these are supplied the gate applies the Story reach rule; omit them for
25
+ // non-story callers (the branch is then never taken).
26
+ type?: string;
27
+ endTime?: string | null;
28
+ };
29
+
30
+ /**
31
+ * A viewer the author EXPLICITLY addressed (in `to` or `cc`) — e.g. an
32
+ * @mention — may read the post even without an accepted-follow edge: the author
33
+ * chose to send it to them, and it was delivered to that actor's inbox.
34
+ * Mentions land in `cc` (mergeCc in posts/routes.ts); direct recipients in `to`.
35
+ */
36
+ export function isExplicitRecipient(
37
+ obj: { toJson: string; ccJson: string },
38
+ viewerApId: string,
39
+ ): boolean {
40
+ return (
41
+ safeJsonParse<string[]>(obj.toJson, []).includes(viewerApId) ||
42
+ safeJsonParse<string[]>(obj.ccJson, []).includes(viewerApId)
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Resolve whether `viewerApId` has an accepted follow edge to `authorApId`.
48
+ */
49
+ async function hasAcceptedFollow(
50
+ db: Database,
51
+ viewerApId: string,
52
+ authorApId: string,
53
+ ): Promise<boolean> {
54
+ const row = await db
55
+ .select({ followerApId: follows.followerApId })
56
+ .from(follows)
57
+ .where(
58
+ and(
59
+ eq(follows.followerApId, viewerApId),
60
+ eq(follows.followingApId, authorApId),
61
+ eq(follows.status, "accepted"),
62
+ ),
63
+ )
64
+ .get();
65
+ return Boolean(row);
66
+ }
67
+
68
+ /**
69
+ * Per-post visibility decision EXCLUDING the private-community membership gate.
70
+ * This is the single source of truth for the public / unlisted / followers /
71
+ * direct rules plus the Story reach + expiry rule, and is shared by the async
72
+ * single-object helper (`canViewerReadObjectFull`) and by batched page gates
73
+ * (bookmarks, etc.) so the to/cc explicit-recipient and Story branches cannot
74
+ * drift per surface. The community membership gate is applied SEPARATELY by the
75
+ * caller (inline async in the single-object helper, batched in page gates), and
76
+ * a follower lookup is injected via `isAcceptedFollower` so both a per-object DB
77
+ * query and a precomputed batched Set satisfy the same rules without an N+1.
78
+ *
79
+ * Returns true/false; for a COMMUNITY story it returns true after the author /
80
+ * expiry shortcuts so the caller's community gate decides membership.
81
+ */
82
+ export function passesPostVisibilitySync(
83
+ obj: ReadGateObject,
84
+ viewerApId: string | null | undefined,
85
+ isAcceptedFollower: (authorApId: string) => boolean,
86
+ now: string = new Date().toISOString(),
87
+ ): boolean {
88
+ // A Story's stored visibility ("public") does NOT encode its reach: a personal
89
+ // story is followers-only and a community story is members-only, and BOTH are
90
+ // revoked at endTime.
91
+ if (obj.type === "Story") {
92
+ if (viewerApId && obj.attributedTo === viewerApId) return true; // own story
93
+ if (obj.endTime && obj.endTime <= now) return false; // expired → revoked
94
+ if (obj.communityApId) return true; // members gate applied by caller
95
+ if (!viewerApId) return false;
96
+ return isAcceptedFollower(obj.attributedTo); // personal → followers reach
97
+ }
98
+
99
+ if (obj.visibility === "direct") {
100
+ if (!viewerApId) return false;
101
+ if (obj.attributedTo === viewerApId) return true;
102
+ return isExplicitRecipient(obj, viewerApId);
103
+ }
104
+
105
+ if (obj.visibility === "followers") {
106
+ if (!viewerApId) return false;
107
+ if (obj.attributedTo === viewerApId) return true;
108
+ if (isExplicitRecipient(obj, viewerApId)) return true;
109
+ return isAcceptedFollower(obj.attributedTo);
110
+ }
111
+
112
+ return true; // public / unlisted
113
+ }
114
+
115
+ /**
116
+ * Whether `viewerApId` may read `obj`, honoring BOTH the community membership
117
+ * gate and the per-post visibility:
118
+ * - public / unlisted → readable (subject to the community gate);
119
+ * - followers → author, an accepted follower, OR an explicitly
120
+ * addressed (to/cc) recipient such as a mention;
121
+ * - direct → author or an addressed recipient (to/cc);
122
+ * - Story → author always; else revoked past endTime; community
123
+ * story → members-only; personal story → followers.
124
+ * An anonymous viewer (`null`) can never satisfy followers/direct. Fails closed.
125
+ */
126
+ export async function canViewerReadObjectFull(
127
+ db: Database,
128
+ obj: ReadGateObject,
129
+ viewerApId: string | null | undefined,
130
+ ): Promise<boolean> {
131
+ const now = new Date().toISOString();
132
+
133
+ // Story author + expiry shortcuts need no community/follow query.
134
+ if (obj.type === "Story") {
135
+ if (viewerApId && obj.attributedTo === viewerApId) return true;
136
+ if (obj.endTime && obj.endTime <= now) return false;
137
+ }
138
+
139
+ // Private-community membership gate first (non-community objects short to true).
140
+ if (
141
+ !(await canViewerReadObject(
142
+ db,
143
+ { audienceJson: obj.audienceJson, communityApId: obj.communityApId },
144
+ viewerApId,
145
+ ))
146
+ ) {
147
+ return false;
148
+ }
149
+
150
+ // Resolve the single accepted-follow edge only when a follower-gated branch
151
+ // actually needs it (personal story, or a followers-only post with no explicit
152
+ // to/cc recipient), then defer to the shared per-post predicate.
153
+ const needsFollow =
154
+ !!viewerApId &&
155
+ obj.attributedTo !== viewerApId &&
156
+ ((obj.type === "Story" && !obj.communityApId) ||
157
+ (obj.type !== "Story" &&
158
+ obj.visibility === "followers" &&
159
+ !isExplicitRecipient(obj, viewerApId)));
160
+ const following = needsFollow
161
+ ? await hasAcceptedFollow(db, viewerApId, obj.attributedTo)
162
+ : false;
163
+
164
+ return passesPostVisibilitySync(obj, viewerApId, () => following, now);
165
+ }
166
+
167
+ /**
168
+ * True if `targetApId` (a post author / follow target) has blocked `actorApId`.
169
+ * Callers reject the interaction (like / repost / follow) with a 404 so a blocked
170
+ * actor cannot bump the target's counts, establish a follow edge, or insert into
171
+ * the target's inbox — and the 404 (not 403) avoids leaking the block. Mirrors
172
+ * the inline guard already used by the story-like and DM-send paths.
173
+ */
174
+ export async function actorIsBlockedBy(
175
+ db: Database,
176
+ targetApId: string,
177
+ actorApId: string,
178
+ ): Promise<boolean> {
179
+ const row = await db
180
+ .select({ blockerApId: blocks.blockerApId })
181
+ .from(blocks)
182
+ .where(
183
+ and(
184
+ eq(blocks.blockerApId, targetApId),
185
+ eq(blocks.blockedApId, actorApId),
186
+ ),
187
+ )
188
+ .get();
189
+ return Boolean(row);
190
+ }
@@ -0,0 +1,61 @@
1
+ import type { Context } from "hono";
2
+ import { getCookie } from "hono/cookie";
3
+ import { eq } from "drizzle-orm";
4
+ import type { Actor, Env, Variables } from "../types.ts";
5
+ import { sessions } from "../../db/index.ts";
6
+ import { hashSessionIdForEnv } from "./crypto.ts";
7
+
8
+ function isExpired(expiresAt: string): boolean {
9
+ const expiresMs = Date.parse(expiresAt);
10
+ return !Number.isFinite(expiresMs) || expiresMs <= Date.now();
11
+ }
12
+
13
+ /**
14
+ * Look up the session cookie, load the associated member, and set `c.var.actor`
15
+ * if the session is valid and unexpired.
16
+ */
17
+ export async function extractActorFromSession(
18
+ c: Context<{ Bindings: Env; Variables: Variables }>,
19
+ ): Promise<void> {
20
+ const sessionId = getCookie(c, "session");
21
+ if (!sessionId) return;
22
+
23
+ const db = c.get("db");
24
+ const sessionKey = await hashSessionIdForEnv(c.env, sessionId);
25
+ const session = await db.query.sessions.findFirst({
26
+ where: eq(sessions.id, sessionKey),
27
+ with: { member: true },
28
+ });
29
+
30
+ if (!session || isExpired(session.expiresAt)) return;
31
+
32
+ const m = session.member;
33
+ // A tombstoned actor (account-deletion soft-delete: `deletedAt` set) must
34
+ // never resolve to a live session actor, even if a stale session row somehow
35
+ // survived teardown. Account deletion deletes the actor's sessions, but this
36
+ // guard fail-closes so a tombstone can never be re-inhabited via a session.
37
+ if (!m || m.deletedAt != null) return;
38
+ const actor: Actor = {
39
+ ap_id: m.apId,
40
+ type: m.type,
41
+ preferred_username: m.preferredUsername,
42
+ name: m.name,
43
+ summary: m.summary,
44
+ icon_url: m.iconUrl,
45
+ header_url: m.headerUrl,
46
+ inbox: m.inbox,
47
+ outbox: m.outbox,
48
+ followers_url: m.followersUrl,
49
+ following_url: m.followingUrl,
50
+ public_key_pem: m.publicKeyPem,
51
+ private_key_pem: m.privateKeyPem,
52
+ takos_user_id: m.takosUserId,
53
+ follower_count: m.followerCount,
54
+ following_count: m.followingCount,
55
+ post_count: m.postCount,
56
+ is_private: m.isPrivate,
57
+ role: m.role as "owner" | "moderator" | "member",
58
+ created_at: m.createdAt,
59
+ };
60
+ c.set("actor", actor);
61
+ }
@@ -0,0 +1,428 @@
1
+ const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/i;
2
+ const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
3
+ // DoH lookups gate every outbound federation request. A stalled DoH call
4
+ // would let an unreachable upstream hang inbox/delivery loops, so cap each
5
+ // lookup at 5s.
6
+ const DOH_TIMEOUT_MS = 5_000;
7
+ const LOCAL_SUBSTRATE_REMOTE_FETCH_ENV =
8
+ "YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES";
9
+
10
+ type DnsRecordType = "A" | "AAAA";
11
+
12
+ export type RemoteUrlSafetyOptions = {
13
+ allowLocalSubstrateRemoteFetches?: boolean;
14
+ localResolver?: (
15
+ hostname: string,
16
+ recordType: DnsRecordType,
17
+ ) => Promise<string[]>;
18
+ remoteResolver?: (hostname: string) => Promise<string[]>;
19
+ };
20
+
21
+ function parseIPv4(hostname: string): number[] | null {
22
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return null;
23
+ const parts = hostname.split(".").map((part) => Number(part));
24
+ if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
25
+ return null;
26
+ }
27
+ return parts;
28
+ }
29
+
30
+ function isPrivateIPv4(hostname: string): boolean {
31
+ const parts = parseIPv4(hostname);
32
+ if (!parts) return false;
33
+ const [a, b, c] = parts;
34
+ if (a === 0 || a === 10 || a === 127) return true;
35
+ if (a === 169 && b === 254) return true;
36
+ if (a === 172 && b >= 16 && b <= 31) return true;
37
+ if (a === 192 && b === 168) return true;
38
+ if (a === 100 && b >= 64 && b <= 127) return true;
39
+ if (a === 192 && b === 0 && c === 0) return true;
40
+ if (a === 192 && b === 0 && c === 2) return true;
41
+ if (a === 198 && (b === 18 || b === 19)) return true;
42
+ if (a === 198 && b === 51 && c === 100) return true;
43
+ if (a === 203 && b === 0 && c === 113) return true;
44
+ if (a >= 224) return true;
45
+ return false;
46
+ }
47
+
48
+ const PRIVATE_IPV6_EXACT = ["::1", "0:0:0:0:0:0:0:1", "::", "0:0:0:0:0:0:0:0"];
49
+ // fc/fd = unique-local; fe8/fe9/fea/feb = link-local; fec/fed/fee/fef =
50
+ // deprecated site-local; ff = multicast. Used ONLY as a textual fallback when
51
+ // the address cannot be expanded (the numeric classifier below is canonical).
52
+ const PRIVATE_IPV6_PREFIXES = [
53
+ "fc",
54
+ "fd",
55
+ "fe8",
56
+ "fe9",
57
+ "fea",
58
+ "feb",
59
+ "fec",
60
+ "fed",
61
+ "fee",
62
+ "fef",
63
+ "ff",
64
+ ];
65
+
66
+ /**
67
+ * Expand an IPv6 textual address to its 8 numeric hextets, resolving `::`
68
+ * compression and a trailing dotted-IPv4 tail (e.g. ::ffff:127.0.0.1 or
69
+ * 64:ff9b::1.2.3.4). Returns null if `input` is not a well-formed IPv6 literal.
70
+ * This canonicalization is what lets the classifier treat every encoding of an
71
+ * embedded IPv4 (mapped hex/dotted, IPv4-compatible, NAT64, 6to4) uniformly.
72
+ */
73
+ function expandIPv6(input: string): number[] | null {
74
+ let s = input.toLowerCase().replace(/^\[|\]$/g, "");
75
+ const zone = s.indexOf("%");
76
+ if (zone !== -1) s = s.slice(0, zone);
77
+ if (s.length === 0) return null;
78
+
79
+ // Fold a trailing dotted-IPv4 tail into two hex groups so the rest of the
80
+ // parse is uniform regardless of the surrounding IPv6 prefix.
81
+ if (s.includes(".")) {
82
+ const m = s.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
83
+ if (!m) return null;
84
+ const v4 = parseIPv4(m[2]);
85
+ if (!v4) return null;
86
+ const h1 = ((v4[0] << 8) | v4[1]).toString(16);
87
+ const h2 = ((v4[2] << 8) | v4[3]).toString(16);
88
+ s = `${m[1]}${h1}:${h2}`;
89
+ }
90
+
91
+ const doubleIdx = s.indexOf("::");
92
+ if (doubleIdx !== s.lastIndexOf("::")) return null; // at most one "::"
93
+
94
+ const parseGroups = (str: string): number[] | null => {
95
+ if (str === "") return [];
96
+ const out: number[] = [];
97
+ for (const g of str.split(":")) {
98
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
99
+ out.push(parseInt(g, 16));
100
+ }
101
+ return out;
102
+ };
103
+
104
+ let groups: number[];
105
+ if (doubleIdx !== -1) {
106
+ const head = parseGroups(s.slice(0, doubleIdx));
107
+ const tail = parseGroups(s.slice(doubleIdx + 2));
108
+ if (head === null || tail === null) return null;
109
+ const missing = 8 - head.length - tail.length;
110
+ if (missing < 1) return null; // "::" must stand for >= 1 zero group
111
+ groups = [...head, ...new Array(missing).fill(0), ...tail];
112
+ } else {
113
+ const all = parseGroups(s);
114
+ if (all === null) return null;
115
+ groups = all;
116
+ }
117
+ return groups.length === 8 ? groups : null;
118
+ }
119
+
120
+ function embeddedV4IsPrivate(hi: number, lo: number): boolean {
121
+ return isPrivateIPv4(
122
+ `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`,
123
+ );
124
+ }
125
+
126
+ function isPrivateIPv6(ipv6Raw: string): boolean {
127
+ const g = expandIPv6(ipv6Raw);
128
+ if (!g) {
129
+ // Unparseable: fall back to the conservative textual checks so a form the
130
+ // string matcher caught is never regressed.
131
+ const s = ipv6Raw.toLowerCase().replace(/^\[|\]$/g, "");
132
+ if (PRIVATE_IPV6_EXACT.includes(s)) return true;
133
+ return PRIVATE_IPV6_PREFIXES.some((prefix) => s.startsWith(prefix));
134
+ }
135
+
136
+ const allZeroHigh =
137
+ g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
138
+ // :: (unspecified) and ::1 (loopback)
139
+ if (allZeroHigh && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) {
140
+ return true;
141
+ }
142
+
143
+ const hi8 = g[0] >> 8;
144
+ if (hi8 === 0xfc || hi8 === 0xfd) return true; // fc00::/7 unique-local
145
+ if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
146
+ if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local (deprecated)
147
+ if (hi8 === 0xff) return true; // ff00::/8 multicast
148
+
149
+ // Embedded-IPv4 transition ranges — decode the embedded IPv4 and classify it,
150
+ // so ::ffff:7f00:1, ::127.0.0.1, 64:ff9b::7f00:1 and 2002:7f00:1:: are all
151
+ // recognized as 127.0.0.1 etc.
152
+ if (allZeroHigh && g[5] === 0xffff) return embeddedV4IsPrivate(g[6], g[7]); // IPv4-mapped
153
+ if (allZeroHigh && g[5] === 0) return embeddedV4IsPrivate(g[6], g[7]); // IPv4-compatible (::/96)
154
+ if (g[0] === 0x64 && g[1] === 0xff9b) return embeddedV4IsPrivate(g[6], g[7]); // NAT64 64:ff9b::/96
155
+ if (g[0] === 0x2002) return embeddedV4IsPrivate(g[1], g[2]); // 6to4 2002:V4::/16
156
+
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * True if `host` is a well-formed IPv4 or IPv6 literal. Used to reject
162
+ * unparseable DNS RDATA before the private-IP classifier runs, so a malformed
163
+ * or ambiguous resolved-IP string can never slip past as "not private".
164
+ */
165
+ export function isWellFormedIp(host: string): boolean {
166
+ if (parseIPv4(host)) return true;
167
+ if (host.includes(":")) return expandIPv6(host) !== null;
168
+ return false;
169
+ }
170
+
171
+ export function isPrivateIpAddress(host: string): boolean {
172
+ if (isPrivateIPv4(host)) return true;
173
+ if (host.includes(":")) return isPrivateIPv6(host);
174
+ return false;
175
+ }
176
+
177
+ export function normalizeHostname(hostname: string): string {
178
+ const normalized = hostname.trim().toLowerCase();
179
+ return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
180
+ }
181
+
182
+ function isTruthyEnv(value: string | undefined): boolean {
183
+ if (!value) return false;
184
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
185
+ }
186
+
187
+ export function localSubstrateRemoteFetchesEnabled(): boolean {
188
+ const processEnv = (
189
+ globalThis as {
190
+ process?: { env?: Record<string, string | undefined> };
191
+ }
192
+ ).process?.env;
193
+ return isTruthyEnv(processEnv?.[LOCAL_SUBSTRATE_REMOTE_FETCH_ENV]);
194
+ }
195
+
196
+ const BLOCKED_HOSTNAME_SUFFIXES = [
197
+ ".localhost",
198
+ ".local",
199
+ ".localdomain",
200
+ ".internal",
201
+ ];
202
+
203
+ function isBlockedHostname(hostname: string): boolean {
204
+ const lower = normalizeHostname(hostname);
205
+ if (lower === "localhost") return true;
206
+ if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
207
+ return true;
208
+ }
209
+ return isPrivateIpAddress(lower);
210
+ }
211
+
212
+ export function isSafeRemoteUrl(url: string): boolean {
213
+ try {
214
+ const parsed = new URL(url);
215
+ if (parsed.username || parsed.password) return false;
216
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
217
+ return false;
218
+ }
219
+ if (!HOSTNAME_PATTERN.test(parsed.hostname)) return false;
220
+ if (!parsed.hostname.includes(".")) return false;
221
+ if (isBlockedHostname(parsed.hostname)) return false;
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ export function normalizeRemoteDomain(domain: string): string | null {
229
+ const trimmed = domain.trim();
230
+ if (!trimmed) return null;
231
+ try {
232
+ const parsed = new URL(`https://${trimmed}`);
233
+ if (parsed.username || parsed.password) return null;
234
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
235
+ const hostname = parsed.hostname;
236
+ if (!HOSTNAME_PATTERN.test(hostname)) return null;
237
+ if (!hostname.includes(".")) return null;
238
+ if (isBlockedHostname(hostname)) return null;
239
+ return parsed.host;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ async function dohResolve(
246
+ hostname: string,
247
+ type: "A" | "AAAA" | "CNAME",
248
+ ): Promise<Array<{ type: number; data: string }>> {
249
+ const response = await fetch(
250
+ `${DOH_ENDPOINT}?name=${encodeURIComponent(hostname)}&type=${type}`,
251
+ {
252
+ headers: { Accept: "application/dns-json" },
253
+ redirect: "manual",
254
+ signal: AbortSignal.timeout(DOH_TIMEOUT_MS),
255
+ },
256
+ );
257
+
258
+ if (!response.ok) {
259
+ throw new Error(`DoH lookup failed (${response.status})`);
260
+ }
261
+
262
+ const json = (await response.json()) as {
263
+ Answer?: Array<{ type?: number; data?: string }>;
264
+ };
265
+
266
+ return (json.Answer ?? []).filter(
267
+ (answer): answer is { type: number; data: string } =>
268
+ typeof answer.type === "number" && typeof answer.data === "string",
269
+ );
270
+ }
271
+
272
+ export async function resolveRemoteHostnameIPs(
273
+ hostname: string,
274
+ ): Promise<string[]> {
275
+ const visited = new Set<string>();
276
+ const ips = new Set<string>();
277
+
278
+ async function walk(name: string, depth: number): Promise<void> {
279
+ if (depth > 10) {
280
+ throw new Error("DNS resolution exceeded max depth");
281
+ }
282
+
283
+ const normalized = normalizeHostname(name);
284
+ if (visited.has(normalized)) return;
285
+ visited.add(normalized);
286
+
287
+ const [aAnswers, aaaaAnswers, cnameAnswers] = await Promise.all([
288
+ dohResolve(normalized, "A"),
289
+ dohResolve(normalized, "AAAA"),
290
+ dohResolve(normalized, "CNAME"),
291
+ ]);
292
+
293
+ // type 1 = A record, type 28 = AAAA record
294
+ for (const answer of [...aAnswers, ...aaaaAnswers]) {
295
+ if (answer.type === 1 || answer.type === 28) ips.add(answer.data);
296
+ }
297
+
298
+ for (const answer of cnameAnswers) {
299
+ if (answer.type === 5) await walk(answer.data, depth + 1);
300
+ }
301
+ }
302
+
303
+ await walk(hostname, 0);
304
+ return Array.from(ips);
305
+ }
306
+
307
+ export function isTakosTestHostname(hostname: string): boolean {
308
+ const normalized = normalizeHostname(hostname);
309
+ return normalized === "takos.test" || normalized.endsWith(".takos.test");
310
+ }
311
+
312
+ function isLocalSubstrateUrlShape(parsed: URL): boolean {
313
+ return (
314
+ parsed.protocol === "https:" &&
315
+ (parsed.port === "" || parsed.port === "443")
316
+ );
317
+ }
318
+
319
+ function isAllowedLocalSubstrateIp(ip: string): boolean {
320
+ const parts = parseIPv4(ip);
321
+ if (!parts) return false;
322
+ const [a, b, c, d] = parts;
323
+ if (a === 127 && b === 0 && c === 0 && d === 1) return true;
324
+ return a === 172 && b >= 16 && b <= 31;
325
+ }
326
+
327
+ async function resolveLocalSubstrateHostnameIPs(
328
+ hostname: string,
329
+ resolver?: RemoteUrlSafetyOptions["localResolver"],
330
+ ): Promise<string[]> {
331
+ const resolve =
332
+ resolver ??
333
+ (async (name: string, recordType: DnsRecordType): Promise<string[]> => {
334
+ return await nodeLookupByRecordType(name, recordType);
335
+ });
336
+
337
+ const [aRecords, aaaaRecords] = await Promise.all([
338
+ resolve(hostname, "A"),
339
+ resolve(hostname, "AAAA"),
340
+ ]);
341
+ return [...aRecords, ...aaaaRecords];
342
+ }
343
+
344
+ /**
345
+ * Resolve `url`'s hostname and assert every resolved IP is public (or, in
346
+ * local-substrate mode, inside the local-substrate allowlist). On success
347
+ * returns the validated IP set so callers can PIN the actual connection to
348
+ * one of these exact IPs and avoid a second, independent DNS resolution.
349
+ */
350
+ export async function assertSafeRemoteUrlResolved(
351
+ url: string,
352
+ options: RemoteUrlSafetyOptions = {},
353
+ ): Promise<string[]> {
354
+ if (!isSafeRemoteUrl(url)) {
355
+ throw new Error(`Unsafe remote URL: ${url}`);
356
+ }
357
+
358
+ // isSafeRemoteUrl already validated the hostname is not blocked,
359
+ // so we only need to verify resolved IPs are not private.
360
+ const parsed = new URL(url);
361
+ const hostname = normalizeHostname(parsed.hostname);
362
+ const allowLocalSubstrate =
363
+ options.allowLocalSubstrateRemoteFetches ??
364
+ localSubstrateRemoteFetchesEnabled();
365
+
366
+ if (allowLocalSubstrate && isTakosTestHostname(hostname)) {
367
+ if (!isLocalSubstrateUrlShape(parsed)) {
368
+ throw new Error(`Unsafe local-substrate remote URL: ${url}`);
369
+ }
370
+ const resolvedIps = await resolveLocalSubstrateHostnameIPs(
371
+ hostname,
372
+ options.localResolver,
373
+ );
374
+ if (resolvedIps.length === 0) {
375
+ throw new Error(`Failed to resolve hostname: ${hostname}`);
376
+ }
377
+ for (const ip of resolvedIps) {
378
+ if (!isAllowedLocalSubstrateIp(ip)) {
379
+ throw new Error(
380
+ `Hostname ${hostname} resolved outside local-substrate allowlist: ${ip}`,
381
+ );
382
+ }
383
+ }
384
+ return resolvedIps;
385
+ }
386
+
387
+ const resolvedIps = await (
388
+ options.remoteResolver ?? resolveRemoteHostnameIPs
389
+ )(hostname);
390
+ if (resolvedIps.length === 0) {
391
+ throw new Error(`Failed to resolve hostname: ${hostname}`);
392
+ }
393
+
394
+ for (const ip of resolvedIps) {
395
+ // Reject unparseable RDATA first: an IP string the classifier cannot parse
396
+ // must NOT be allowed through as "not private" (fail closed).
397
+ if (!isWellFormedIp(ip)) {
398
+ throw new Error(`Hostname ${hostname} resolved to unparseable IP ${ip}`);
399
+ }
400
+ if (isPrivateIpAddress(ip)) {
401
+ throw new Error(`Hostname ${hostname} resolved to private IP ${ip}`);
402
+ }
403
+ }
404
+
405
+ return resolvedIps;
406
+ }
407
+
408
+ export async function nodeLookupAll(hostname: string): Promise<string[]> {
409
+ const { lookup } = await import("node:dns/promises");
410
+ const records = await lookup(hostname, { all: true });
411
+ return records.map((record) => record.address);
412
+ }
413
+
414
+ export async function nodeLookupByRecordType(
415
+ hostname: string,
416
+ recordType: DnsRecordType,
417
+ ): Promise<string[]> {
418
+ try {
419
+ const { lookup } = await import("node:dns/promises");
420
+ const records = await lookup(hostname, {
421
+ all: true,
422
+ family: recordType === "A" ? 4 : 6,
423
+ });
424
+ return records.map((record) => record.address);
425
+ } catch {
426
+ return [];
427
+ }
428
+ }