@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,616 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+ import { and, count, eq, sql } from "drizzle-orm";
4
+ import type { Env, Variables } from "../types.ts";
5
+ import { actors, objects as objectsTable } from "../../db/index.ts";
6
+ import { notDeleted } from "../../db/index.ts";
7
+ import { actorApId, getDomain, safeJsonParse } from "../federation-helpers.ts";
8
+ import { communityApId } from "../lib/ap-ids.ts";
9
+ import {
10
+ getInstanceActor,
11
+ INSTANCE_ACTOR_USERNAME,
12
+ loadFederatedCommunity,
13
+ } from "./activitypub/query-helpers.ts";
14
+ import inboxRoutes from "./activitypub/inbox.ts";
15
+ import outboxRoutes from "./activitypub/outbox.ts";
16
+ import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
17
+ import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
18
+ import { activityJson, jrdJson } from "../lib/ap-response.ts";
19
+
20
+ type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
21
+
22
+ const ap = new Hono<{ Bindings: Env; Variables: Variables }>();
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared constants and helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const AP_CONTENT_TYPE = "application/activity+json";
29
+
30
+ // Default software.version advertised in NodeInfo when the deploy pipeline
31
+ // does not inject `YURUCOMMU_SOFTWARE_VERSION`. Keep this in sync with
32
+ // package.json; the env override lets the build report the real build version
33
+ // without editing source.
34
+ const YURUCOMMU_VERSION = "2.0.0";
35
+
36
+ const AP_CONTEXT = [
37
+ "https://www.w3.org/ns/activitystreams",
38
+ "https://w3id.org/security/v1",
39
+ ] as const;
40
+
41
+ // Conventional Mastodon-style JSON-LD context extension. The served Person
42
+ // actor doc uses PropertyValue attachments, alsoKnownAs / movedTo migration
43
+ // declarations, and manuallyApprovesFollowers; strict consumers (Mastodon)
44
+ // only interpret these terms when they are declared in @context. Mirrors what
45
+ // Mastodon emits on its own actor documents.
46
+ const ACTOR_CONTEXT_EXTENSION = {
47
+ schema: "http://schema.org#",
48
+ PropertyValue: "schema:PropertyValue",
49
+ value: "schema:value",
50
+ toot: "http://joinmastodon.org/ns#",
51
+ alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" },
52
+ movedTo: { "@id": "as:movedTo", "@type": "@id" },
53
+ manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
54
+ // The actor docs emit `discoverable` (Mastodon's search-indexing opt-out
55
+ // signal); declare its term so a strict JSON-LD processor doesn't drop it on
56
+ // expansion. Mirrors Mastodon's own context.
57
+ discoverable: "toot:discoverable",
58
+ } as const;
59
+
60
+ // Full @context for the served Person actor doc: AS2 + security + the
61
+ // Mastodon-style extension terms above.
62
+ const ACTOR_CONTEXT = [...AP_CONTEXT, ACTOR_CONTEXT_EXTENSION] as const;
63
+
64
+ /** Build a standard WebFinger JRD response. */
65
+ function buildWebFingerResponse(
66
+ username: string,
67
+ domain: string,
68
+ apId: string,
69
+ profileHref: string,
70
+ ): Record<string, unknown> {
71
+ return {
72
+ subject: `acct:${username}@${domain}`,
73
+ aliases: [apId],
74
+ links: [
75
+ { rel: "self", type: AP_CONTENT_TYPE, href: apId },
76
+ {
77
+ rel: "http://webfinger.net/rel/profile-page",
78
+ type: "text/html",
79
+ href: profileHref,
80
+ },
81
+ ],
82
+ };
83
+ }
84
+
85
+ /** Build an ActivityPub public-key block for an actor. */
86
+ function buildPublicKey(
87
+ actorApId: string,
88
+ publicKeyPem: string,
89
+ ): Record<string, string> {
90
+ return {
91
+ id: `${actorApId}#main-key`,
92
+ owner: actorApId,
93
+ publicKeyPem,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Normalize a stored timestamp to an xsd:dateTime string for AS2 `published`.
99
+ * Post timestamps are written with `toISOString()`, but an actor's `created_at`
100
+ * is a SQLite `datetime('now')` value (`YYYY-MM-DD HH:MM:SS[.mmm]`, UTC, space-
101
+ * separated, no zone) — emitting it verbatim yields an INVALID xsd:dateTime
102
+ * (Mastodon can't parse the join date). Convert it to ISO 8601; pass through
103
+ * values that already carry the `T` separator.
104
+ */
105
+ function toIso8601(value: string | null | undefined): string | undefined {
106
+ if (!value) return undefined;
107
+ if (value.includes("T")) return value;
108
+ const d = new Date(value.replace(" ", "T") + "Z");
109
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
110
+ }
111
+
112
+ async function countRows(
113
+ c: HonoContext,
114
+ table: typeof actors | typeof objectsTable,
115
+ where: ReturnType<typeof and> | ReturnType<typeof eq>,
116
+ ): Promise<number> {
117
+ const db = c.get("db");
118
+ const rows = await db.select({ total: count() }).from(table).where(where);
119
+ return Number(rows[0]?.total ?? 0);
120
+ }
121
+
122
+ function canViewPrivateActorCollections(
123
+ c: HonoContext,
124
+ actorApId: string,
125
+ ): boolean {
126
+ const viewer = c.get("actor");
127
+ return viewer?.ap_id === actorApId;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // NodeInfo - Instance Metadata
132
+ // ---------------------------------------------------------------------------
133
+
134
+ ap.get("/.well-known/nodeinfo", (c) => {
135
+ const baseUrl = c.env.APP_URL.replace(/\/+$/, "");
136
+ // Advertise BOTH 2.0 and 2.1. NodeInfo 2.0 is the baseline version the widest
137
+ // set of fediverse servers / relays / statistics crawlers fetch; advertising
138
+ // only 2.1 (and letting /nodeinfo/2.0 fall through to the SPA HTML shell) made
139
+ // those consumers see HTML instead of JSON. Consumers pick the highest schema
140
+ // they understand from this list.
141
+ return c.json({
142
+ links: [
143
+ {
144
+ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
145
+ href: `${baseUrl}/nodeinfo/2.0`,
146
+ },
147
+ {
148
+ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1",
149
+ href: `${baseUrl}/nodeinfo/2.1`,
150
+ },
151
+ ],
152
+ });
153
+ });
154
+
155
+ // Shared instance telemetry for both NodeInfo schema versions. The only
156
+ // difference between 2.0 and 2.1 output is the `software` block (2.1 adds
157
+ // `repository`) and the `version` discriminator, so the rest lives here.
158
+ async function nodeinfoCommon(c: HonoContext) {
159
+ const [totalUsers, localPosts] = await Promise.all([
160
+ countRows(c, actors, notDeleted(actors)),
161
+ countRows(
162
+ c,
163
+ objectsTable,
164
+ and(
165
+ eq(objectsTable.type, "Note"),
166
+ eq(objectsTable.isLocal, 1),
167
+ notDeleted(objectsTable),
168
+ ),
169
+ ),
170
+ ]);
171
+
172
+ return {
173
+ protocols: ["activitypub"],
174
+ services: {
175
+ inbound: [],
176
+ outbound: [],
177
+ },
178
+ usage: {
179
+ // We do not track per-user last-activity timestamps, so we report only
180
+ // the total user count and OMIT activeMonth / activeHalfyear rather than
181
+ // fabricating them (NodeInfo permits omitting the active-window fields).
182
+ // Reporting total as "active" would advertise false liveness telemetry
183
+ // to relay/instance directories.
184
+ users: {
185
+ total: totalUsers,
186
+ },
187
+ localPosts,
188
+ },
189
+ openRegistrations: false,
190
+ metadata: {
191
+ singleUser: true,
192
+ },
193
+ };
194
+ }
195
+
196
+ ap.get("/nodeinfo/2.0", async (c) => {
197
+ return c.json({
198
+ version: "2.0",
199
+ software: {
200
+ name: "yurucommu",
201
+ // Real running version (build-injected env, else the in-sync default
202
+ // constant). NodeInfo 2.0's software block has no `repository` field —
203
+ // that was introduced in 2.1.
204
+ version: c.env.YURUCOMMU_SOFTWARE_VERSION || YURUCOMMU_VERSION,
205
+ },
206
+ ...(await nodeinfoCommon(c)),
207
+ });
208
+ });
209
+
210
+ ap.get("/nodeinfo/2.1", async (c) => {
211
+ return c.json({
212
+ version: "2.1",
213
+ software: {
214
+ name: "yurucommu",
215
+ // Real running version (build-injected env, else the in-sync default
216
+ // constant) instead of a hardcoded literal that silently goes stale.
217
+ version: c.env.YURUCOMMU_SOFTWARE_VERSION || YURUCOMMU_VERSION,
218
+ repository: "https://github.com/tako0614/yurucommu",
219
+ },
220
+ ...(await nodeinfoCommon(c)),
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // WebFinger - Actor Discovery (cached 1 hour)
226
+ // ---------------------------------------------------------------------------
227
+
228
+ // host-meta: the legacy XRD discovery document that points a peer holding only
229
+ // our domain at the WebFinger endpoint (the `lrdd` link template). Modern
230
+ // servers fetch /.well-known/webfinger directly, but some crawlers and older
231
+ // software still resolve host-meta first — without this route it fell through
232
+ // to the SPA HTML shell. The template's `{uri}` is a literal placeholder the
233
+ // caller substitutes, so it must not be URL-encoded.
234
+ ap.get("/.well-known/host-meta", (c) => {
235
+ const baseUrl = c.env.APP_URL.replace(/\/+$/, "");
236
+ const xrd =
237
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
238
+ '<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n' +
239
+ ` <Link rel="lrdd" type="application/jrd+json" template="${baseUrl}/.well-known/webfinger?resource={uri}"/>\n` +
240
+ "</XRD>\n";
241
+ c.header("Content-Type", "application/xrd+xml; charset=utf-8");
242
+ return c.body(xrd);
243
+ });
244
+
245
+ ap.get(
246
+ "/.well-known/webfinger",
247
+ withCache({
248
+ ttl: CacheTTL.WEBFINGER,
249
+ cacheTag: CacheTags.WEBFINGER,
250
+ queryParamsToInclude: ["resource"],
251
+ }),
252
+ async (c) => {
253
+ const db = c.get("db");
254
+ const resource = c.req.query("resource");
255
+ if (!resource) return c.json({ error: "resource parameter required" }, 400);
256
+
257
+ // Parse resource format: acct:username@domain or https://domain/ap/users/username
258
+ let username: string | null = null;
259
+ let domain: string | null = null;
260
+
261
+ if (resource.startsWith("acct:")) {
262
+ const acctPart = resource.slice(5);
263
+ const [user, host] = acctPart.split("@");
264
+ username = user;
265
+ // DNS host names are case-insensitive (RFC 4343). Lowercase the host so a
266
+ // mixed-case authority (`acct:alice@Example.Test`) matches currentDomain
267
+ // (always lowercased by the URL parser) — the `http` branch below gets this
268
+ // for free from `new URL(...).host`.
269
+ domain = host ? host.toLowerCase() : null;
270
+ } else if (resource.startsWith("http")) {
271
+ try {
272
+ const url = new URL(resource);
273
+ domain = url.host;
274
+ const match = resource.match(/\/users\/([^\/]+)$/);
275
+ if (match) {
276
+ username = match[1];
277
+ }
278
+ } catch {
279
+ return c.json({ error: "Invalid resource format" }, 400);
280
+ }
281
+ } else {
282
+ return c.json({ error: "Invalid resource format" }, 400);
283
+ }
284
+
285
+ if (!username || !domain) {
286
+ return c.json(
287
+ {
288
+ error: "Invalid resource format",
289
+ },
290
+ 400,
291
+ );
292
+ }
293
+
294
+ const baseUrl = c.env.APP_URL;
295
+ const currentDomain = getDomain(baseUrl);
296
+
297
+ if (domain !== currentDomain) {
298
+ return c.json({ error: "Actor not found" }, 404);
299
+ }
300
+
301
+ if (username.toLowerCase() === INSTANCE_ACTOR_USERNAME.toLowerCase()) {
302
+ const instanceActor = await getInstanceActor(c);
303
+ return jrdJson(
304
+ c,
305
+ buildWebFingerResponse(
306
+ INSTANCE_ACTOR_USERNAME,
307
+ domain,
308
+ instanceActor.apId,
309
+ `${baseUrl}/groups`,
310
+ ),
311
+ );
312
+ }
313
+
314
+ const resolvedActor = await db.query.actors.findFirst({
315
+ // Case-insensitive handle resolution: a remote may WebFinger a local handle
316
+ // with different casing than stored (RFC 7033 local-parts are technically
317
+ // case-sensitive but most fediverse software preserves typed casing), and a
318
+ // case-sensitive `eq` would 404 it and break inbound discovery.
319
+ where: and(
320
+ sql`lower(${actors.preferredUsername}) = ${username.toLowerCase()}`,
321
+ notDeleted(actors),
322
+ ),
323
+ columns: { apId: true, preferredUsername: true },
324
+ });
325
+
326
+ // An unknown local handle MUST 404 — never fall back to the owner actor.
327
+ // The old fallback resolved ANY `acct:<anything>@<domain>` to the owner with
328
+ // a `subject` echoing the requested (wrong) username, which (1) violates
329
+ // WebFinger (the subject must identify the account the links describe), (2)
330
+ // claims every possible username exists, and (3) does not even work for
331
+ // federation: a conformant peer fetches the resolved actor, reads its real
332
+ // `preferredUsername`, and re-WebFingers THAT — the round-trip subject then
333
+ // mismatches the original query and the actor is rejected. So the fallback
334
+ // had no working upside and real downsides (identity confusion / spoofed
335
+ // existence). Resolve only an exact local actor — but a handle may instead
336
+ // name a federated COMMUNITY, so try a public Group actor before 404ing.
337
+ if (!resolvedActor) {
338
+ const community = await loadFederatedCommunity(
339
+ db,
340
+ communityApId(baseUrl.replace(/\/+$/, ""), username),
341
+ );
342
+ if (community) {
343
+ return jrdJson(
344
+ c,
345
+ buildWebFingerResponse(
346
+ community.preferredUsername,
347
+ domain,
348
+ community.apId,
349
+ `${baseUrl}/groups/${community.preferredUsername}`,
350
+ ),
351
+ );
352
+ }
353
+ return c.json({ error: "Actor not found" }, 404);
354
+ }
355
+
356
+ // Echo the CANONICAL username as the subject (not the requested casing) so
357
+ // the WebFinger round-trip a remote performs stays self-consistent.
358
+ const canonicalUsername = resolvedActor.preferredUsername;
359
+ return jrdJson(
360
+ c,
361
+ buildWebFingerResponse(
362
+ canonicalUsername,
363
+ domain,
364
+ resolvedActor.apId,
365
+ `${baseUrl}/users/${canonicalUsername}`,
366
+ ),
367
+ );
368
+ },
369
+ );
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Actor Profile Endpoint (cached 10 minutes)
373
+ // ---------------------------------------------------------------------------
374
+
375
+ ap.get(
376
+ "/ap/users/:username",
377
+ // NOTE: no varyByActor — the session actor is never extracted on /ap/* (that
378
+ // middleware is mounted only on /api, /media, /.takos/tools), so this is an
379
+ // anonymous public AP document. The owner-only showCollections branch is
380
+ // therefore unreachable here by design (AP identity is the HTTP signature, not
381
+ // a session); a per-viewer flag would be dead + a latent footgun.
382
+ withCache({
383
+ ttl: CacheTTL.ACTIVITYPUB_ACTOR,
384
+ cacheTag: CacheTags.ACTOR,
385
+ }),
386
+ async (c) => {
387
+ const db = c.get("db");
388
+ const username = c.req.param("username");
389
+ const baseUrl = c.env.APP_URL;
390
+ const apId = actorApId(baseUrl, username);
391
+
392
+ const actor = await db.query.actors.findFirst({
393
+ where: and(eq(actors.apId, apId), notDeleted(actors)),
394
+ columns: {
395
+ apId: true,
396
+ type: true,
397
+ preferredUsername: true,
398
+ name: true,
399
+ summary: true,
400
+ iconUrl: true,
401
+ headerUrl: true,
402
+ inbox: true,
403
+ outbox: true,
404
+ followersUrl: true,
405
+ followingUrl: true,
406
+ publicKeyPem: true,
407
+ followerCount: true,
408
+ followingCount: true,
409
+ postCount: true,
410
+ isPrivate: true,
411
+ createdAt: true,
412
+ fieldsJson: true,
413
+ alsoKnownAsJson: true,
414
+ movedTo: true,
415
+ },
416
+ });
417
+
418
+ if (!actor) return c.json({ error: "Actor not found" }, 404);
419
+
420
+ const showCollections =
421
+ !actor.isPrivate || canViewPrivateActorCollections(c, actor.apId);
422
+
423
+ const actorResponse: Record<string, unknown> = {
424
+ "@context": ACTOR_CONTEXT,
425
+ id: actor.apId,
426
+ type: actor.type,
427
+ preferredUsername: actor.preferredUsername,
428
+ name: actor.name,
429
+ summary: actor.summary,
430
+ url: `${baseUrl}/users/${username}`,
431
+ icon: actor.iconUrl
432
+ ? { type: "Image", url: safeUrlJoin(baseUrl, actor.iconUrl) }
433
+ : undefined,
434
+ image: actor.headerUrl
435
+ ? { type: "Image", url: safeUrlJoin(baseUrl, actor.headerUrl) }
436
+ : undefined,
437
+ inbox: actor.inbox,
438
+ outbox: showCollections ? actor.outbox : undefined,
439
+ followers: showCollections ? actor.followersUrl : undefined,
440
+ following: showCollections ? actor.followingUrl : undefined,
441
+ // Advertise sharedInbox so remote servers can deduplicate fan-out
442
+ // delivery (Mastodon convention). The endpoint accepts signed
443
+ // activities just like the per-actor inbox.
444
+ endpoints: {
445
+ sharedInbox: `${baseUrl}/ap/inbox`,
446
+ },
447
+ publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
448
+ discoverable: !actor.isPrivate,
449
+ // Advertise the lock so remote servers know inbound follows are held
450
+ // pending (handleFollow keeps them pending for private accounts). Mirrors
451
+ // the Update(Person) object built in actors.ts.
452
+ manuallyApprovesFollowers: Boolean(actor.isPrivate),
453
+ published: toIso8601(actor.createdAt),
454
+ };
455
+
456
+ // Structured profile metadata -> PropertyValue attachments (Mastodon
457
+ // convention). Stored as a JSON array of { name, value }.
458
+ const fields = safeJsonParse<Array<{ name?: unknown; value?: unknown }>>(
459
+ actor.fieldsJson,
460
+ [],
461
+ );
462
+ if (Array.isArray(fields) && fields.length > 0) {
463
+ actorResponse.attachment = fields
464
+ .filter(
465
+ (f) => typeof f?.name === "string" && typeof f?.value === "string",
466
+ )
467
+ .map((f) => ({
468
+ type: "PropertyValue",
469
+ name: f.name as string,
470
+ value: f.value as string,
471
+ }));
472
+ }
473
+
474
+ // Account-migration declarations. `alsoKnownAs` lists the aliases this
475
+ // account claims; `movedTo` (when set) points remote servers at the new
476
+ // account so they can process a Move.
477
+ const alsoKnownAs = safeJsonParse<string[]>(actor.alsoKnownAsJson, []);
478
+ if (Array.isArray(alsoKnownAs) && alsoKnownAs.length > 0) {
479
+ actorResponse.alsoKnownAs = alsoKnownAs.filter(
480
+ (a) => typeof a === "string",
481
+ );
482
+ }
483
+ if (actor.movedTo) {
484
+ actorResponse.movedTo = actor.movedTo;
485
+ }
486
+
487
+ // Remove undefined fields
488
+ for (const key of Object.keys(actorResponse)) {
489
+ if (actorResponse[key] === undefined) {
490
+ delete actorResponse[key];
491
+ }
492
+ }
493
+
494
+ return activityJson(c, actorResponse);
495
+ },
496
+ );
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Group Actor / Instance Community (cached 10 minutes)
500
+ // ---------------------------------------------------------------------------
501
+
502
+ ap.get(
503
+ "/ap/actor",
504
+ withCache({
505
+ ttl: CacheTTL.ACTIVITYPUB_ACTOR,
506
+ cacheTag: CacheTags.COMMUNITY,
507
+ }),
508
+ async (c) => {
509
+ const baseUrl = c.env.APP_URL;
510
+ const instanceActor = await getInstanceActor(c);
511
+
512
+ const actorResponse = {
513
+ "@context": [
514
+ ...AP_CONTEXT,
515
+ {
516
+ apc: "https://yurucommu.com/ns/apc#",
517
+ rooms: { "@id": "apc:rooms", "@type": "@id" },
518
+ joinPolicy: "apc:joinPolicy",
519
+ postPolicy: "apc:postPolicy",
520
+ visibility: "apc:visibility",
521
+ },
522
+ ],
523
+ id: instanceActor.apId,
524
+ type: "Group",
525
+ preferredUsername: instanceActor.preferredUsername,
526
+ name: instanceActor.name || "Yurucommu",
527
+ summary: instanceActor.summary || "",
528
+ inbox: `${baseUrl}/ap/actor/inbox`,
529
+ outbox: `${baseUrl}/ap/actor/outbox`,
530
+ followers: `${baseUrl}/ap/actor/followers`,
531
+ following: `${baseUrl}/ap/actor/following`,
532
+ endpoints: {
533
+ sharedInbox: `${baseUrl}/ap/inbox`,
534
+ },
535
+ publicKey: buildPublicKey(instanceActor.apId, instanceActor.publicKeyPem),
536
+ };
537
+
538
+ return activityJson(c, actorResponse);
539
+ },
540
+ );
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Community Group Actor (cached 10 minutes)
544
+ //
545
+ // A yurucommu community federates as a standard fediverse Group actor
546
+ // (Lemmy/Mobilizon style): remotes WebFinger `acct:<name>@host`, fetch this
547
+ // document, and Follow the inbox to join. Only PUBLIC, non-deleted communities
548
+ // are served (a private community's existence is members-only). The human web
549
+ // page (`/groups/<name>`) is already an SPA route, so `url` resolves.
550
+ // ---------------------------------------------------------------------------
551
+
552
+ ap.get(
553
+ "/ap/groups/:name",
554
+ withCache({
555
+ ttl: CacheTTL.ACTIVITYPUB_ACTOR,
556
+ cacheTag: CacheTags.COMMUNITY,
557
+ }),
558
+ async (c) => {
559
+ const db = c.get("db");
560
+ const baseUrl = c.env.APP_URL;
561
+ const name = c.req.param("name");
562
+ const community = await loadFederatedCommunity(
563
+ db,
564
+ communityApId(baseUrl.replace(/\/+$/, ""), name),
565
+ );
566
+ if (!community) return c.json({ error: "Community not found" }, 404);
567
+
568
+ const actorResponse: Record<string, unknown> = {
569
+ "@context": ACTOR_CONTEXT,
570
+ id: community.apId,
571
+ type: "Group",
572
+ preferredUsername: community.preferredUsername,
573
+ name: community.name,
574
+ summary: community.summary ?? "",
575
+ url: `${baseUrl}/groups/${community.preferredUsername}`,
576
+ icon: community.iconUrl
577
+ ? { type: "Image", url: safeUrlJoin(baseUrl, community.iconUrl) }
578
+ : undefined,
579
+ inbox: community.inbox,
580
+ outbox: community.outbox,
581
+ followers: community.followersUrl,
582
+ // Lemmy/Mobilizon convention: the collection of actors that moderate this
583
+ // group (its owner + moderators). Lets those consumers attribute and
584
+ // authorize moderation activities.
585
+ moderators: `${community.apId}/moderators`,
586
+ endpoints: {
587
+ sharedInbox: `${baseUrl}/ap/inbox`,
588
+ },
589
+ publicKey: buildPublicKey(community.apId, community.publicKeyPem),
590
+ discoverable: true,
591
+ // open-join communities auto-accept Follows; approval/invite hold them
592
+ // pending (the inbox Follow handler enforces this).
593
+ manuallyApprovesFollowers: community.joinPolicy !== "open",
594
+ published: toIso8601(community.createdAt),
595
+ };
596
+
597
+ for (const key of Object.keys(actorResponse)) {
598
+ if (actorResponse[key] === undefined) delete actorResponse[key];
599
+ }
600
+
601
+ return activityJson(c, actorResponse);
602
+ },
603
+ );
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Shared inbox (Mastodon convention)
607
+ //
608
+ // The shared inbox POST handler (`/ap/inbox`) lives in `inbox.ts` alongside
609
+ // the per-actor inbox so it reuses the same signature-verify / dedup / store
610
+ // pipeline and dispatch logic. It is mounted via `inboxRoutes` below.
611
+ // ---------------------------------------------------------------------------
612
+
613
+ ap.route("/", inboxRoutes);
614
+ ap.route("/", outboxRoutes);
615
+
616
+ export default ap;