@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,323 @@
1
+ /**
2
+ * ActivityPub structural validators.
3
+ *
4
+ * Network ingress from arbitrary fediverse servers is untyped JSON. This
5
+ * module narrows `unknown` into the shapes Yurucommu expects, throwing a
6
+ * typed {@link ActivityPubContractError} with a field path on contract
7
+ * violations so callers can log + reject rather than panic on `undefined`
8
+ * field access deep in handler code.
9
+ *
10
+ * Rules:
11
+ * - ActivityPub is lenient about extension fields; parsers accept unknown
12
+ * extra fields and copy through only the keys we care about.
13
+ * - Required-but-wrong-type fields fail closed.
14
+ * - Optional fields that are present but malformed are dropped silently
15
+ * (treated as absent) rather than rejecting the whole document.
16
+ */
17
+
18
+ /** Error thrown when a remote ActivityPub document violates our contract. */
19
+ export class ActivityPubContractError extends Error {
20
+ readonly path: string;
21
+ constructor(path: string, message: string) {
22
+ super(`[ActivityPub contract] ${path}: ${message}`);
23
+ this.name = "ActivityPubContractError";
24
+ this.path = path;
25
+ }
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Generic JSON helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export function isJsonRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function asString(value: unknown): string | undefined {
37
+ return typeof value === "string" ? value : undefined;
38
+ }
39
+
40
+ function getString(
41
+ record: Record<string, unknown>,
42
+ key: string,
43
+ ): string | undefined {
44
+ return asString(record[key]);
45
+ }
46
+
47
+ function getRecord(
48
+ record: Record<string, unknown>,
49
+ key: string,
50
+ ): Record<string, unknown> | undefined {
51
+ const value = record[key];
52
+ return isJsonRecord(value) ? value : undefined;
53
+ }
54
+
55
+ function getStringArray(
56
+ record: Record<string, unknown>,
57
+ key: string,
58
+ ): string[] | undefined {
59
+ const value = record[key];
60
+ // AS2 permits an addressing field (`to`/`cc`) to be a SINGLE IRI string OR an
61
+ // array. Normalize a scalar to a one-element array so the array-shape
62
+ // consumers don't silently drop it: a scalar-addressed Note would otherwise
63
+ // fail isDirectNote (a DM mis-routed as a visible unlisted post) and a scalar
64
+ // `to: "…#Public"` would be classified unlisted instead of public.
65
+ if (typeof value === "string") return [value];
66
+ if (!Array.isArray(value)) return undefined;
67
+ const result: string[] = [];
68
+ for (const item of value) {
69
+ if (typeof item === "string") result.push(item);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ // AS2 permits `type` to be a single string OR an array (e.g. yurucommu's own
75
+ // stories emit `type: ["Story", "Note"]`). Preserve both shapes so the
76
+ // array-aware downstream classifiers (isStoryType / isActorTypeUpdate /
77
+ // typeIncludes) actually receive the array instead of a collapsed `undefined`.
78
+ function getStringOrStringArray(
79
+ record: Record<string, unknown>,
80
+ key: string,
81
+ ): string | string[] | undefined {
82
+ const value = record[key];
83
+ if (typeof value === "string") return value;
84
+ if (Array.isArray(value)) {
85
+ const result = value.filter((v): v is string => typeof v === "string");
86
+ return result.length > 0 ? result : undefined;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function requireRecord(value: unknown, path: string): Record<string, unknown> {
92
+ if (!isJsonRecord(value)) {
93
+ throw new ActivityPubContractError(
94
+ path,
95
+ `expected JSON object, got ${value === null ? "null" : typeof value}`,
96
+ );
97
+ }
98
+ return value;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Remote Actor
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export interface RemoteActorDocument {
106
+ id: string;
107
+ type?: string;
108
+ preferredUsername?: string;
109
+ name?: string;
110
+ summary?: string;
111
+ icon?: { url?: string };
112
+ inbox?: string;
113
+ outbox?: string;
114
+ followers?: string;
115
+ following?: string;
116
+ endpoints?: { sharedInbox?: string };
117
+ publicKey?: { id?: string; publicKeyPem?: string };
118
+ }
119
+
120
+ function parseIcon(
121
+ record: Record<string, unknown>,
122
+ ): { url?: string } | undefined {
123
+ const icon = getRecord(record, "icon");
124
+ if (!icon) return undefined;
125
+ const url = getString(icon, "url");
126
+ return url ? { url } : {};
127
+ }
128
+
129
+ function parsePublicKey(
130
+ record: Record<string, unknown>,
131
+ ): { id?: string; publicKeyPem?: string } | undefined {
132
+ const publicKey = getRecord(record, "publicKey");
133
+ if (!publicKey) return undefined;
134
+ return {
135
+ id: getString(publicKey, "id"),
136
+ publicKeyPem: getString(publicKey, "publicKeyPem"),
137
+ };
138
+ }
139
+
140
+ function parseEndpoints(
141
+ record: Record<string, unknown>,
142
+ ): { sharedInbox?: string } | undefined {
143
+ const endpoints = getRecord(record, "endpoints");
144
+ if (!endpoints) return undefined;
145
+ const sharedInbox = getString(endpoints, "sharedInbox");
146
+ return sharedInbox ? { sharedInbox } : {};
147
+ }
148
+
149
+ /**
150
+ * Narrow an `unknown` (typically a `await res.json()` result from a remote
151
+ * actor endpoint) into a {@link RemoteActorDocument}. Throws
152
+ * {@link ActivityPubContractError} if `id` is missing/non-string. Other
153
+ * fields are best-effort: optional fields with wrong types are dropped.
154
+ */
155
+ export function parseRemoteActor(
156
+ value: unknown,
157
+ path = "$",
158
+ ): RemoteActorDocument {
159
+ const record = requireRecord(value, path);
160
+ const id = getString(record, "id");
161
+ if (!id) {
162
+ throw new ActivityPubContractError(
163
+ `${path}.id`,
164
+ "actor must have a string id",
165
+ );
166
+ }
167
+ return {
168
+ id,
169
+ type: getString(record, "type"),
170
+ preferredUsername: getString(record, "preferredUsername"),
171
+ name: getString(record, "name"),
172
+ summary: getString(record, "summary"),
173
+ icon: parseIcon(record),
174
+ inbox: getString(record, "inbox"),
175
+ outbox: getString(record, "outbox"),
176
+ followers: getString(record, "followers"),
177
+ following: getString(record, "following"),
178
+ endpoints: parseEndpoints(record),
179
+ publicKey: parsePublicKey(record),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Like {@link parseRemoteActor} but returns `null` instead of throwing.
185
+ * Useful for best-effort caches where a malformed response should be
186
+ * skipped silently.
187
+ */
188
+ export function tryParseRemoteActor(
189
+ value: unknown,
190
+ ): RemoteActorDocument | null {
191
+ try {
192
+ return parseRemoteActor(value);
193
+ } catch (e) {
194
+ if (e instanceof ActivityPubContractError) return null;
195
+ throw e;
196
+ }
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Activity envelope
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export interface ActivityObjectDocument {
204
+ id?: string;
205
+ type?: string | string[];
206
+ object?: string;
207
+ inReplyTo?: string;
208
+ to?: string[];
209
+ cc?: string[];
210
+ conversation?: string;
211
+ content?: string;
212
+ summary?: string | null;
213
+ attachment?: unknown;
214
+ // AS2 `tag` (Mention / Hashtag / Emoji). Passed through untyped like
215
+ // `attachment`/`overlays` — handlers read object.tag (e.g. inbound @-mention
216
+ // notifications in handleCreate). Without copying it here, every handler sees
217
+ // `tag === undefined` and federated mentions/hashtags are silently dropped.
218
+ tag?: unknown;
219
+ overlays?: unknown;
220
+ endTime?: string;
221
+ displayDuration?: string;
222
+ published?: string;
223
+ room?: string;
224
+ }
225
+
226
+ export interface ActivityDocument {
227
+ id?: string;
228
+ type?: string;
229
+ actor?: string;
230
+ object?: string | ActivityObjectDocument;
231
+ target?: string | ActivityObjectDocument;
232
+ room?: string;
233
+ }
234
+
235
+ function parseActivityObjectFields(
236
+ record: Record<string, unknown>,
237
+ ): ActivityObjectDocument {
238
+ const summaryRaw = record["summary"];
239
+ return {
240
+ id: getString(record, "id"),
241
+ type: getStringOrStringArray(record, "type"),
242
+ object: getString(record, "object"),
243
+ inReplyTo: getString(record, "inReplyTo"),
244
+ to: getStringArray(record, "to"),
245
+ cc: getStringArray(record, "cc"),
246
+ conversation: getString(record, "conversation"),
247
+ content: getString(record, "content"),
248
+ summary:
249
+ typeof summaryRaw === "string"
250
+ ? summaryRaw
251
+ : summaryRaw === null
252
+ ? null
253
+ : undefined,
254
+ attachment: record["attachment"],
255
+ tag: record["tag"],
256
+ overlays: record["overlays"],
257
+ endTime: getString(record, "endTime"),
258
+ displayDuration: getString(record, "displayDuration"),
259
+ published: getString(record, "published"),
260
+ room: getString(record, "room"),
261
+ };
262
+ }
263
+
264
+ function parseActivityObjectOrIri(
265
+ value: unknown,
266
+ ): string | ActivityObjectDocument | undefined {
267
+ if (typeof value === "string") return value;
268
+ if (isJsonRecord(value)) return parseActivityObjectFields(value);
269
+ return undefined;
270
+ }
271
+
272
+ /**
273
+ * Narrow an `unknown` (an already-`JSON.parse`d inbox body) into an
274
+ * {@link ActivityDocument}. Throws if the value is not a JSON object.
275
+ * Callers are expected to enforce required activity fields (`type`,
276
+ * `actor`) separately, since the legitimate set of required fields
277
+ * varies by activity type.
278
+ */
279
+ export function parseActivity(value: unknown, path = "$"): ActivityDocument {
280
+ const record = requireRecord(value, path);
281
+ return {
282
+ id: getString(record, "id"),
283
+ type: getString(record, "type"),
284
+ actor: getString(record, "actor"),
285
+ object: parseActivityObjectOrIri(record["object"]),
286
+ target: parseActivityObjectOrIri(record["target"]),
287
+ room: getString(record, "room"),
288
+ };
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // WebFinger
293
+ // ---------------------------------------------------------------------------
294
+
295
+ export interface WebFingerLinkDocument {
296
+ rel?: string;
297
+ type?: string;
298
+ href?: string;
299
+ }
300
+
301
+ export interface WebFingerDocument {
302
+ links?: WebFingerLinkDocument[];
303
+ }
304
+
305
+ /**
306
+ * Narrow an `unknown` from a `.well-known/webfinger` response into
307
+ * {@link WebFingerDocument}. Links with non-string fields are dropped.
308
+ */
309
+ export function parseWebFinger(value: unknown, path = "$"): WebFingerDocument {
310
+ const record = requireRecord(value, path);
311
+ const rawLinks = record["links"];
312
+ if (!Array.isArray(rawLinks)) return {};
313
+ const links: WebFingerLinkDocument[] = [];
314
+ for (const link of rawLinks) {
315
+ if (!isJsonRecord(link)) continue;
316
+ links.push({
317
+ rel: getString(link, "rel"),
318
+ type: getString(link, "type"),
319
+ href: getString(link, "href"),
320
+ });
321
+ }
322
+ return { links };
323
+ }
@@ -0,0 +1,16 @@
1
+ // Shared JSON-LD @context for SERVED / DELIVERED objects (Note + the Create that
2
+ // wraps it). The objects we emit carry `tag` entries of type `Hashtag` and a
3
+ // `sensitive` flag (content warnings) — neither is defined in the vanilla AS2
4
+ // context, so a strict JSON-LD processor (relays / indexers that expand)
5
+ // silently DROPS them: a CW post can render unblurred and a hashtag is not
6
+ // indexed remotely. Declaring the terms (to their AS2 IRIs) keeps our egress
7
+ // JSON-LD-valid. Mirrors the ACTOR_CONTEXT_EXTENSION pattern used for the actor
8
+ // doc (routes/activitypub.ts) but for the object/Create surfaces.
9
+ export const OBJECT_CONTEXT = [
10
+ "https://www.w3.org/ns/activitystreams",
11
+ "https://w3id.org/security/v1",
12
+ {
13
+ Hashtag: "https://www.w3.org/ns/activitystreams#Hashtag",
14
+ sensitive: "https://www.w3.org/ns/activitystreams#sensitive",
15
+ },
16
+ ] as const;
@@ -0,0 +1,101 @@
1
+ import { logger } from "./logger.ts";
2
+ import { bytesToHex } from "./hex.ts";
3
+
4
+ const utilsLog = logger.child({ component: "utils" });
5
+
6
+ export function safeJsonParse<T>(
7
+ json: string | null | undefined,
8
+ defaultValue: T,
9
+ ): T {
10
+ if (!json) return defaultValue;
11
+ try {
12
+ return JSON.parse(json) as T;
13
+ } catch (err) {
14
+ // MEDIUM FIX: Log the error for debugging
15
+ utilsLog.warn("safeJsonParse failed", {
16
+ event: "utils.json.parse_failed",
17
+ error: err,
18
+ });
19
+ return defaultValue;
20
+ }
21
+ }
22
+
23
+ export function parseLimit(
24
+ value: string | undefined,
25
+ fallback: number,
26
+ max: number,
27
+ ): number {
28
+ const parsed = parseInt(value || "", 10);
29
+ if (!Number.isFinite(parsed)) return fallback;
30
+ return Math.min(Math.max(parsed, 1), max);
31
+ }
32
+
33
+ export function parseOffset(
34
+ value: string | undefined,
35
+ fallback: number,
36
+ max: number,
37
+ ): number {
38
+ const parsed = parseInt(value || "", 10);
39
+ if (!Number.isFinite(parsed)) return fallback;
40
+ return Math.min(Math.max(parsed, 0), max);
41
+ }
42
+
43
+ export function generateId(): string {
44
+ // 256-bit (32-byte) session/OAuth identifiers. Session ids in particular are
45
+ // bearer credentials, so we keep the entropy high; 96-bit was too low.
46
+ const bytes = new Uint8Array(32);
47
+ crypto.getRandomValues(bytes);
48
+ return bytesToHex(bytes);
49
+ }
50
+
51
+ export function actorApId(baseUrl: string, username: string): string {
52
+ return `${baseUrl}/ap/users/${username}`;
53
+ }
54
+
55
+ export function objectApId(baseUrl: string, id: string): string {
56
+ return `${baseUrl}/ap/objects/${id}`;
57
+ }
58
+
59
+ export function activityApId(baseUrl: string, id: string): string {
60
+ return `${baseUrl}/ap/activities/${id}`;
61
+ }
62
+
63
+ export function communityApId(baseUrl: string, name: string): string {
64
+ // Normalize a trailing slash so the apId minted at community CREATION matches
65
+ // the one reconstructed on every federation READ (actor doc / inbox / outbox /
66
+ // followers / moderators / webfinger). Without this, a `APP_URL` configured
67
+ // with a trailing slash would store `…//ap/groups/<name>` while reads look up
68
+ // `…/ap/groups/<name>`, 404ing every community.
69
+ return `${baseUrl.replace(/\/+$/, "")}/ap/groups/${name}`;
70
+ }
71
+
72
+ export function getDomain(apId: string): string {
73
+ return new URL(apId).host;
74
+ }
75
+
76
+ export function isLocal(apId: string, baseUrl: string): boolean {
77
+ // Compare hostname (and port, if specified by `baseUrl`) rather than
78
+ // string-prefix `baseUrl`. Prefix comparison is unsafe because a remote
79
+ // host like `https://yurucommu.example.evil` would match a baseUrl of
80
+ // `https://yurucommu.example`.
81
+ try {
82
+ const apUrl = new URL(apId);
83
+ const baseUrlObj = new URL(baseUrl);
84
+ if (apUrl.hostname !== baseUrlObj.hostname) return false;
85
+ if (baseUrlObj.port !== "") {
86
+ return apUrl.port === baseUrlObj.port;
87
+ }
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function formatUsername(apId: string): string {
95
+ const url = new URL(apId);
96
+ const match = apId.match(/\/users\/([^\/]+)$/);
97
+ if (match) {
98
+ return `${match[1]}@${url.host}`;
99
+ }
100
+ return apId;
101
+ }
@@ -0,0 +1,30 @@
1
+ // AP / WebFinger responses must carry the correct Content-Type. Hono's
2
+ // `c.json()` force-sets `application/json` and CLOBBERS any `Content-Type`
3
+ // header set before it — so an ActivityPub document served via `c.json()`
4
+ // reaches a remote (Mastodon et al.) as `application/json`, which strict
5
+ // consumers REJECT (`Request` only accepts `application/activity+json` /
6
+ // `application/ld+json`), breaking inbound federation: a remote cannot resolve
7
+ // this actor/object/collection. These helpers serialize manually and set the
8
+ // spec content type via `c.body()`, which does not override it.
9
+
10
+ // Minimal structural shape of the Hono context we touch, so this stays
11
+ // decoupled from a route's specific Bindings/Variables typing.
12
+ interface ApResponseContext {
13
+ header(name: string, value: string): void;
14
+ body(data: string): Response;
15
+ }
16
+
17
+ const AP_CONTENT_TYPE = "application/activity+json";
18
+ const JRD_CONTENT_TYPE = "application/jrd+json";
19
+
20
+ /** Serialize `body` as an ActivityStreams document (`application/activity+json`). */
21
+ export function activityJson(c: ApResponseContext, body: unknown): Response {
22
+ c.header("Content-Type", AP_CONTENT_TYPE);
23
+ return c.body(JSON.stringify(body));
24
+ }
25
+
26
+ /** Serialize `body` as a WebFinger JRD document (`application/jrd+json`). */
27
+ export function jrdJson(c: ApResponseContext, body: unknown): Response {
28
+ c.header("Content-Type", JRD_CONTENT_TYPE);
29
+ return c.body(JSON.stringify(body));
30
+ }
@@ -0,0 +1,87 @@
1
+ import { bufferToBase64 } from "./base64.ts";
2
+
3
+ const RSA_ALGORITHM = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
4
+
5
+ function wrapPem(label: string, base64: string): string {
6
+ const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64;
7
+ return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`;
8
+ }
9
+
10
+ export async function generateKeyPair(): Promise<{
11
+ publicKeyPem: string;
12
+ privateKeyPem: string;
13
+ }> {
14
+ const keyPair = await crypto.subtle.generateKey(
15
+ {
16
+ ...RSA_ALGORITHM,
17
+ modulusLength: 2048,
18
+ publicExponent: new Uint8Array([1, 0, 1]),
19
+ },
20
+ true,
21
+ ["sign", "verify"],
22
+ );
23
+
24
+ const [publicKey, privateKey] = await Promise.all([
25
+ crypto.subtle.exportKey("spki", keyPair.publicKey),
26
+ crypto.subtle.exportKey("pkcs8", keyPair.privateKey),
27
+ ]);
28
+
29
+ return {
30
+ publicKeyPem: wrapPem("PUBLIC KEY", bufferToBase64(publicKey)),
31
+ privateKeyPem: wrapPem("PRIVATE KEY", bufferToBase64(privateKey)),
32
+ };
33
+ }
34
+
35
+ export async function signRequest(
36
+ privateKeyPem: string,
37
+ keyId: string,
38
+ method: string,
39
+ url: string,
40
+ body?: string,
41
+ ): Promise<Record<string, string>> {
42
+ const urlObj = new URL(url);
43
+ const date = new Date().toUTCString();
44
+ const digest = body
45
+ ? `SHA-256=${bufferToBase64(
46
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
47
+ )}`
48
+ : undefined;
49
+
50
+ const signedHeaders = digest
51
+ ? "(request-target) host date digest"
52
+ : "(request-target) host date";
53
+ // The request-target must include the query string (verifiers reconstruct
54
+ // `pathname + search`). For the common query-less actor/inbox URL `search` is
55
+ // empty, so this is a no-op there and a correctness fix for any URL that does
56
+ // carry a query (e.g. a signed object GET).
57
+ const requestTarget = `${urlObj.pathname}${urlObj.search}`;
58
+ let signatureString = `(request-target): ${method.toLowerCase()} ${requestTarget}\nhost: ${urlObj.host}\ndate: ${date}`;
59
+ if (digest) signatureString += `\ndigest: ${digest}`;
60
+
61
+ const pemContents = privateKeyPem
62
+ .replace(/-----[^-]+-----/g, "")
63
+ .replace(/\s/g, "");
64
+ const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
65
+ const cryptoKey = await crypto.subtle.importKey(
66
+ "pkcs8",
67
+ binaryKey,
68
+ RSA_ALGORITHM,
69
+ false,
70
+ ["sign"],
71
+ );
72
+ const signatureBuffer = await crypto.subtle.sign(
73
+ "RSASSA-PKCS1-v1_5",
74
+ cryptoKey,
75
+ new TextEncoder().encode(signatureString),
76
+ );
77
+ const signature = bufferToBase64(signatureBuffer);
78
+
79
+ const headers: Record<string, string> = {
80
+ Date: date,
81
+ Host: urlObj.host,
82
+ Signature: `keyId="${keyId}",algorithm="rsa-sha256",headers="${signedHeaders}",signature="${signature}"`,
83
+ };
84
+ if (digest) headers["Digest"] = digest;
85
+
86
+ return headers;
87
+ }