@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,872 @@
1
+ import { Hono, type Context } from "hono";
2
+ import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
3
+ import type { Env, EnvVars, Variables } from "./types.ts";
4
+ import { extractActorFromSession } from "./lib/session-actor.ts";
5
+ import { isBackendPath } from "./lib/backend-paths.ts";
6
+ import { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
7
+ import {
8
+ getOidcClientCredentials,
9
+ getOidcIssuerUrl,
10
+ } from "./lib/oauth-providers.ts";
11
+
12
+ import authRoutes from "./routes/auth.ts";
13
+ import actorsRoutes from "./routes/actors.ts";
14
+ import followRoutes from "./routes/follow.ts";
15
+ import timelineRoutes from "./routes/timeline.ts";
16
+ import postsRoutes from "./routes/posts.ts";
17
+ import notificationsRoutes from "./routes/notifications.ts";
18
+ import storiesRoutes from "./routes/stories.ts";
19
+ import searchRoutes from "./routes/search.ts";
20
+ import communitiesRoutes from "./routes/communities.ts";
21
+ import dmRoutes from "./routes/dm.ts";
22
+ import mediaRoutes from "./routes/media.ts";
23
+ import activitypubRoutes from "./routes/activitypub.ts";
24
+ import takosToolsRoutes from "./routes/takos-tools.ts";
25
+ import recommendationsRoutes from "./routes/recommendations.ts";
26
+ import { moderationRoutes } from "./routes/moderation.ts";
27
+ import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
28
+ import mobileRoutes from "./routes/mobile.ts";
29
+
30
+ import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
31
+ import { csrfProtection } from "./middleware/csrf.ts";
32
+ import { createErrorMiddleware } from "./middleware/error-handler.ts";
33
+ import {
34
+ bodyLimit,
35
+ DEFAULT_BODY_LIMIT_BYTES,
36
+ } from "./middleware/body-limit.ts";
37
+ import { logger } from "./lib/logger.ts";
38
+
39
+ const log = logger.child({ component: "backend.index" });
40
+ import type { MessageBatch } from "@cloudflare/workers-types";
41
+ import type {
42
+ DeliveryDlqMessageV1,
43
+ DeliveryQueueMessageV1,
44
+ } from "./lib/delivery/types.ts";
45
+ import {
46
+ handleDeliveryDlqBatch,
47
+ handleDeliveryQueueBatch,
48
+ } from "./lib/delivery/queue.ts";
49
+
50
+ type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
51
+
52
+ export const YURUCOMMU_BACKEND_PLUGIN_API_VERSION = 1 as const;
53
+
54
+ export interface BackendPluginContextV1 {
55
+ app: YurucommuApp;
56
+ }
57
+
58
+ export interface YurucommuBackendPluginV1 {
59
+ apiVersion: typeof YURUCOMMU_BACKEND_PLUGIN_API_VERSION;
60
+ name: string;
61
+ setup?: (ctx: BackendPluginContextV1) => void;
62
+ beforeRoutes?: (ctx: BackendPluginContextV1) => void;
63
+ afterRoutes?: (ctx: BackendPluginContextV1) => void;
64
+ }
65
+
66
+ export interface CreateYurucommuBackendAppOptionsV1 {
67
+ plugins?: YurucommuBackendPluginV1[];
68
+ discovery?: YurucommuBackendDiscoveryOptionsV1;
69
+ }
70
+
71
+ export interface YurucommuBackendDiscoveryClientV1 {
72
+ id: string;
73
+ name: string;
74
+ defaultEntry: string;
75
+ }
76
+
77
+ export interface YurucommuBackendDiscoveryOptionsV1 {
78
+ product?: string;
79
+ name?: string;
80
+ serverId?: string;
81
+ serverName?: string;
82
+ clients?: YurucommuBackendDiscoveryClientV1[];
83
+ capabilities?: string[];
84
+ }
85
+
86
+ const DEFAULT_DISCOVERY_OPTIONS = {
87
+ product: "yurucommu",
88
+ name: "Yurucommu",
89
+ serverId: "yurucommu-server",
90
+ serverName: "Yurucommu Server",
91
+ clients: [
92
+ {
93
+ id: "yurucommu",
94
+ name: "Yurucommu",
95
+ defaultEntry: "feed",
96
+ },
97
+ {
98
+ id: "yurume",
99
+ name: "Yurumeet",
100
+ defaultEntry: "messages",
101
+ },
102
+ ],
103
+ capabilities: [
104
+ "api.social.v1",
105
+ "activitypub.server.v1",
106
+ "client.yurucommu.feed.v1",
107
+ "client.yurume.messages.v1",
108
+ ],
109
+ } satisfies Required<YurucommuBackendDiscoveryOptionsV1>;
110
+
111
+ const MIME_TYPES: Record<string, string> = {
112
+ ".html": "text/html; charset=utf-8",
113
+ ".css": "text/css; charset=utf-8",
114
+ ".js": "application/javascript; charset=utf-8",
115
+ ".json": "application/json; charset=utf-8",
116
+ ".png": "image/png",
117
+ ".jpg": "image/jpeg",
118
+ ".jpeg": "image/jpeg",
119
+ ".gif": "image/gif",
120
+ ".svg": "image/svg+xml",
121
+ ".ico": "image/x-icon",
122
+ ".woff": "font/woff",
123
+ ".woff2": "font/woff2",
124
+ ".ttf": "font/ttf",
125
+ ".eot": "application/vnd.ms-fontobject",
126
+ ".webp": "image/webp",
127
+ ".wasm": "application/wasm",
128
+ };
129
+
130
+ function getMimeType(path: string): string {
131
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
132
+ return MIME_TYPES[ext] || "application/octet-stream";
133
+ }
134
+
135
+ // Hard runtime preconditions: without these the worker cannot serve a request
136
+ // correctly, so /readyz must 503 when any is missing. A correctly-provisioned
137
+ // fresh install (Capsule supplies APP_URL + ENCRYPTION_KEY + an auth method,
138
+ // platform supplies DB + KV) satisfies all of these and is therefore ready.
139
+ function collectMissingRequiredBindings(env: Env): string[] {
140
+ const missing: string[] = [];
141
+ const hasValue = (value: string | undefined): boolean => !!value?.trim();
142
+ if (!env.DB_INSTANCE) missing.push("DB");
143
+ if (!env.KV) missing.push("KV");
144
+ if (!hasValue(env.APP_URL)) missing.push("APP_URL");
145
+ if (!hasValue(env.ENCRYPTION_KEY)) missing.push("ENCRYPTION_KEY");
146
+ const hasPassword = hasValue(env.AUTH_PASSWORD_HASH);
147
+ const hasGoogle =
148
+ hasValue(env.GOOGLE_CLIENT_ID) && hasValue(env.GOOGLE_CLIENT_SECRET);
149
+ const hasX = hasValue(env.X_CLIENT_ID) && hasValue(env.X_CLIENT_SECRET);
150
+ const oidcCredentials = getOidcClientCredentials(env);
151
+ // The client SECRET is optional — Takosumi materializes a PUBLIC (PKCE-only,
152
+ // no-secret) OIDC client for auto-provisioned Capsules. issuer + client_id is
153
+ // a usable auth method (mirrors getAuthConfig's provider gate).
154
+ const hasAccountsOidc =
155
+ hasValue(getOidcIssuerUrl(env) ?? undefined) &&
156
+ hasValue(oidcCredentials.clientId);
157
+ if (!hasPassword && !hasGoogle && !hasX && !hasAccountsOidc) {
158
+ missing.push("AUTH_METHOD");
159
+ }
160
+ return missing;
161
+ }
162
+
163
+ // Optional capabilities the worker can run WITHOUT and still be "ready":
164
+ // - MEDIA: media storage; uploads are unavailable but the rest of the app
165
+ // (timeline, posts, federation reads) serves normally. MEDIA is optional in
166
+ // the Env type and may be replaced by a STORAGE-backed asset path.
167
+ // - DELIVERY_QUEUE / DELIVERY_DLQ: outbound federation delivery. When unbound,
168
+ // enqueued activities are buffered/persisted and re-fire once the bindings
169
+ // appear (see lib/delivery/queue.ts); local dev only attaches them behind
170
+ // YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE. Treating these as hard-required
171
+ // would 503 a perfectly serviceable install, so they are reported as
172
+ // degraded info on /healthz but do not fail /readyz.
173
+ function collectMissingOptionalBindings(env: Env): string[] {
174
+ const missing: string[] = [];
175
+ if (!env.MEDIA) missing.push("MEDIA");
176
+ if (!env.DELIVERY_QUEUE) missing.push("DELIVERY_QUEUE");
177
+ if (!env.DELIVERY_DLQ) missing.push("DELIVERY_DLQ");
178
+ return missing;
179
+ }
180
+
181
+ // Full binding report in the original declaration order (DB, MEDIA, KV,
182
+ // DELIVERY_QUEUE, DELIVERY_DLQ, APP_URL, ENCRYPTION_KEY, AUTH_METHOD). Used by
183
+ // /healthz and surfaced in /readyz's missingBindings for visibility; /readyz's
184
+ // status/code is driven by the required subset only (see collectMissingRequiredBindings).
185
+ function collectMissingRuntimeBindings(env: Env): string[] {
186
+ const missing: string[] = [];
187
+ const hasValue = (value: string | undefined): boolean => !!value?.trim();
188
+ if (!env.DB_INSTANCE) missing.push("DB");
189
+ if (!env.MEDIA) missing.push("MEDIA");
190
+ if (!env.KV) missing.push("KV");
191
+ if (!env.DELIVERY_QUEUE) missing.push("DELIVERY_QUEUE");
192
+ if (!env.DELIVERY_DLQ) missing.push("DELIVERY_DLQ");
193
+ if (!hasValue(env.APP_URL)) missing.push("APP_URL");
194
+ if (!hasValue(env.ENCRYPTION_KEY)) missing.push("ENCRYPTION_KEY");
195
+ const hasPassword = hasValue(env.AUTH_PASSWORD_HASH);
196
+ const hasGoogle =
197
+ hasValue(env.GOOGLE_CLIENT_ID) && hasValue(env.GOOGLE_CLIENT_SECRET);
198
+ const hasX = hasValue(env.X_CLIENT_ID) && hasValue(env.X_CLIENT_SECRET);
199
+ const oidcCredentials = getOidcClientCredentials(env);
200
+ // The client SECRET is optional — Takosumi materializes a PUBLIC (PKCE-only,
201
+ // no-secret) OIDC client for auto-provisioned Capsules. issuer + client_id is
202
+ // a usable auth method (mirrors getAuthConfig's provider gate).
203
+ const hasAccountsOidc =
204
+ hasValue(getOidcIssuerUrl(env) ?? undefined) &&
205
+ hasValue(oidcCredentials.clientId);
206
+ if (!hasPassword && !hasGoogle && !hasX && !hasAccountsOidc) {
207
+ missing.push("AUTH_METHOD");
208
+ }
209
+ return missing;
210
+ }
211
+
212
+ function buildSocialServerDiscovery(
213
+ appUrl: string,
214
+ issuer: string,
215
+ options: YurucommuBackendDiscoveryOptionsV1 = {},
216
+ ) {
217
+ const discovery = {
218
+ ...DEFAULT_DISCOVERY_OPTIONS,
219
+ ...options,
220
+ clients: options.clients ?? DEFAULT_DISCOVERY_OPTIONS.clients,
221
+ capabilities: options.capabilities ?? DEFAULT_DISCOVERY_OPTIONS.capabilities,
222
+ };
223
+ return {
224
+ product: discovery.product,
225
+ name: discovery.name,
226
+ server: {
227
+ id: discovery.serverId,
228
+ name: discovery.serverName,
229
+ canonicalOrigin: appUrl,
230
+ activitypubOrigin: appUrl,
231
+ },
232
+ clients: discovery.clients,
233
+ issuer,
234
+ apiBaseUrl: appUrl,
235
+ activitypubOrigin: appUrl,
236
+ mediaOrigin: `${appUrl}/media`,
237
+ socialServerCapabilitiesUrl: `${appUrl}/.well-known/social-server`,
238
+ capabilities: discovery.capabilities,
239
+ endpoints: {
240
+ api: `${appUrl}/api`,
241
+ authProviders: `${appUrl}/api/auth/providers`,
242
+ currentUser: `${appUrl}/api/auth/me`,
243
+ timeline: `${appUrl}/api/timeline`,
244
+ conversations: `${appUrl}/api/dm/contacts`,
245
+ notifications: `${appUrl}/api/notifications`,
246
+ mobilePushRegistrations: `${appUrl}${MOBILE_PUSH_REGISTRATION_PATH}`,
247
+ },
248
+ };
249
+ }
250
+
251
+ function isStrictReadinessEnabled(env: Env): boolean {
252
+ const value = env.YURUCOMMU_STRICT_READINESS?.trim().toLowerCase();
253
+ return value === "1" || value === "true" || value === "yes";
254
+ }
255
+
256
+ function mountReadinessRoutes(
257
+ app: YurucommuApp,
258
+ discovery: YurucommuBackendDiscoveryOptionsV1 = {},
259
+ ): void {
260
+ app.get("/healthz", (c) => {
261
+ // /healthz reports the full picture (required + optional capabilities) so
262
+ // operators can see degraded-but-serving states. The 503 gate stays opt-in
263
+ // via YURUCOMMU_STRICT_READINESS for a hard all-or-nothing health check.
264
+ const missing = collectMissingRuntimeBindings(c.env);
265
+ const strict = isStrictReadinessEnabled(c.env);
266
+ return c.json(
267
+ {
268
+ status:
269
+ missing.length === 0 ? "ok" : strict ? "misconfigured" : "degraded",
270
+ service: "yurucommu",
271
+ missingBindings: missing,
272
+ },
273
+ strict && missing.length > 0 ? 503 : 200,
274
+ );
275
+ });
276
+
277
+ app.get("/readyz", (c) => {
278
+ // Readiness keys off the HARD preconditions only. Optional capabilities
279
+ // (MEDIA, DELIVERY_QUEUE, DELIVERY_DLQ) are still reported in
280
+ // missingBindings for visibility, but their absence does NOT flip the
281
+ // worker to not-ready: a correctly-provisioned fresh install with
282
+ // APP_URL + ENCRYPTION_KEY + an auth method (and DB + KV) is ready even
283
+ // before media storage / federation delivery queues are bound. The status
284
+ // and HTTP code are therefore driven solely by the required set, while
285
+ // missingBindings still surfaces every gap.
286
+ const missingRequired = collectMissingRequiredBindings(c.env);
287
+ const missing = collectMissingRuntimeBindings(c.env);
288
+ return c.json(
289
+ {
290
+ status: missingRequired.length === 0 ? "ok" : "misconfigured",
291
+ service: "yurucommu",
292
+ missingBindings: missing,
293
+ },
294
+ missingRequired.length === 0 ? 200 : 503,
295
+ );
296
+ });
297
+
298
+ // Operator-sensible crawler defaults. Allow the public landing / actor
299
+ // profile HTML to be indexed, but keep the JSON API and the machine-only
300
+ // ActivityPub federation surface out of search crawlers. Mounted alongside
301
+ // the readiness probes so they answer BEFORE body-size / payload validation
302
+ // middleware and stay reachable in any runtime mode.
303
+ app.get("/robots.txt", (c) => {
304
+ const body = [
305
+ "User-agent: *",
306
+ "Disallow: /api/",
307
+ "Disallow: /ap/",
308
+ "Disallow: /.takos/",
309
+ "Disallow: /hosted/",
310
+ "Disallow: /media/",
311
+ "Allow: /",
312
+ "",
313
+ ].join("\n");
314
+ return c.body(body, 200, {
315
+ "Content-Type": "text/plain; charset=utf-8",
316
+ "Cache-Control": "public, max-age=3600",
317
+ });
318
+ });
319
+
320
+ const wellKnownSocialServer = (
321
+ c: Context<{ Bindings: Env; Variables: Variables }>,
322
+ ) => {
323
+ const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
324
+ const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
325
+ return c.json(buildSocialServerDiscovery(appUrl, issuer, discovery), 200, {
326
+ "Cache-Control": "public, max-age=300",
327
+ });
328
+ };
329
+
330
+ app.get("/.well-known/yurucommu", wellKnownSocialServer);
331
+ app.get("/.well-known/social-server", wellKnownSocialServer);
332
+
333
+ // Minimal RFC 9116 security.txt. The contact points operators at the
334
+ // instance admin; APP_URL (when configured) makes the policy line concrete.
335
+ app.get("/.well-known/security.txt", (c) => {
336
+ // RFC 9116: `Contact` and `Expires` are REQUIRED. The previous file used a
337
+ // placeholder `mailto:security@yurucommu.invalid` (the .invalid TLD is
338
+ // non-routable, so vulnerabilities could not actually be reported), pointed
339
+ // `Policy` circularly at this very file, and omitted `Expires` entirely —
340
+ // making the document non-compliant (scanners reject it). Default `Contact`
341
+ // to the upstream project's working security-advisory channel, overridable
342
+ // by an operator via the SECURITY_CONTACT env (their own mailto:/https
343
+ // report path), and always emit a future `Expires`.
344
+ const contact =
345
+ (c.env as { SECURITY_CONTACT?: string }).SECURITY_CONTACT?.trim() ||
346
+ "https://github.com/tako0614/yurucommu/security/advisories/new";
347
+ // Kept ~1 year out (recomputed per request, cached 1h) — well within the
348
+ // RFC's "less than a year" recommendation and never stale.
349
+ const expires = new Date(
350
+ Date.now() + 365 * 24 * 60 * 60 * 1000,
351
+ ).toISOString();
352
+ const lines = [
353
+ `Contact: ${contact}`,
354
+ `Expires: ${expires}`,
355
+ "Policy: https://github.com/tako0614/yurucommu/security/policy",
356
+ "Preferred-Languages: en, ja",
357
+ "",
358
+ ];
359
+ return c.body(lines.join("\n"), 200, {
360
+ "Content-Type": "text/plain; charset=utf-8",
361
+ "Cache-Control": "public, max-age=3600",
362
+ });
363
+ });
364
+ }
365
+
366
+ function normalizeOrigin(
367
+ appUrl: string | undefined,
368
+ requestUrl: string,
369
+ ): string {
370
+ const raw = appUrl?.trim() || new URL(requestUrl).origin;
371
+ const url = new URL(raw);
372
+ url.pathname = "";
373
+ url.search = "";
374
+ url.hash = "";
375
+ return url.toString().replace(/\/+$/g, "");
376
+ }
377
+
378
+ // media.ts advertises MAX_VIDEO_SIZE = 40MB (and MAX_IMAGE_SIZE = 20MB) and
379
+ // returns a friendly 413 citing those numbers. The pre-route body cap MUST sit
380
+ // at or above the largest advertised media size, otherwise an upload between
381
+ // the body cap and the advertised limit is rejected by the cap FIRST with a
382
+ // generic error, making the advertised limit unreachable and the friendly 413
383
+ // dead. media.ts buffers the whole file in Worker memory (formData() +
384
+ // arrayBuffer(), ~2x the file size), so the cap is also the memory ceiling:
385
+ // it is sized to just cover MAX_VIDEO_SIZE = 40MB (40 * 1024 * 1024) plus
386
+ // multipart/form-data envelope overhead (= 48 MiB), keeping peak buffering
387
+ // well under the Workers ~128MB per-request memory budget while leaving the
388
+ // friendly per-size 413 in media.ts reachable.
389
+ const MEDIA_UPLOAD_BODY_LIMIT_BYTES = 48 * 1024 * 1024; // 48 MiB
390
+ const INBOX_BODY_LIMIT_BYTES = 512 * 1024; // 512 KiB
391
+
392
+ // Every ActivityPub inbox handler (inbox.ts): the shared inbox `/ap/inbox`, the
393
+ // per-actor singleton `/ap/actor/inbox`, and the two-segment per-recipient
394
+ // inboxes `/ap/users/:username/inbox` + `/ap/groups/:name/inbox`. Hono's single
395
+ // `*` matches EXACTLY ONE path segment, so `/ap/*/inbox` covers `/ap/actor/inbox`
396
+ // but NOT the two-segment user/group inboxes — those must be listed explicitly,
397
+ // or they silently fall through to the lax global default cap + miss the per-IP
398
+ // inbox rate limit. Every inbox is unauthenticated and verifies an HTTP
399
+ // signature + touches the DB *before* any throttle runs, so each one needs BOTH
400
+ // the strict pre-auth body cap AND the dedicated per-IP `inbox` rate limit; this
401
+ // single list is the source of truth for both (applyBodyLimits +
402
+ // applyGlobalMiddleware) so they can never drift out of coverage.
403
+ const INBOX_PATH_PATTERNS = [
404
+ "/ap/inbox",
405
+ "/ap/*/inbox",
406
+ "/ap/users/*/inbox",
407
+ "/ap/groups/*/inbox",
408
+ ] as const;
409
+
410
+ function applyBodyLimits(app: YurucommuApp): void {
411
+ // Per-route caps are registered BEFORE the global default cap. The stricter
412
+ // inbox cap (512 KiB) wins for /ap/*/inbox; the LARGER media-upload cap
413
+ // (48 MiB) wins for /api/media/*. ActivityPub inbox is unauthenticated and
414
+ // federation peers can hammer it, so its cap matches the rate-limit
415
+ // assumption (small JSON activities).
416
+ //
417
+ // IMPORTANT: Hono runs every matching `use()` middleware in registration
418
+ // order, and bodyLimit always calls next() when the request is within its
419
+ // own cap. That means the trailing default `*` cap below ALSO runs on
420
+ // /api/media/* and /ap/*/inbox. For the inbox routes that is harmless — the
421
+ // stricter 512 KiB cap already rejected anything the 1 MiB default would,
422
+ // and a body that passed 512 KiB trivially passes 1 MiB. For media it is
423
+ // NOT harmless: a 30 MB upload that passes the 48 MiB media cap would then
424
+ // be rejected by the 1 MiB default cap with a generic `body_too_large`,
425
+ // making the friendly per-size 413 in routes/media.ts (which advertises
426
+ // MAX_VIDEO_SIZE = 40MB / MAX_IMAGE_SIZE = 20MB) unreachable. So the default
427
+ // cap is registered with a path guard that SKIPS the media prefix, leaving
428
+ // /api/media/* governed solely by its own 48 MiB cap.
429
+ //
430
+ // The inbox cap runs pre-auth, so a chunked body with no `Content-Length`
431
+ // could otherwise bypass the declared-length check entirely. We require
432
+ // `Content-Length` there and reject with 411 when it is missing — a
433
+ // conformant ActivityPub delivery always sets it, so this only refuses
434
+ // chunked-only senders (which are the DoS vector). Legitimate authenticated
435
+ // upload routes keep the default streaming cap instead so chunked uploads
436
+ // are not broken.
437
+ // Apply the strict pre-auth body cap to EVERY inbox route (shared, per-actor,
438
+ // and the two-segment user/group inboxes). See INBOX_PATH_PATTERNS: a single
439
+ // `*` only matches one segment, so the user/group inboxes must be listed
440
+ // explicitly or they fall through to the lax 1 MiB default cap (which also
441
+ // omits requireContentLength).
442
+ for (const pattern of INBOX_PATH_PATTERNS) {
443
+ app.use(
444
+ pattern,
445
+ bodyLimit({
446
+ maxBytes: INBOX_BODY_LIMIT_BYTES,
447
+ requireContentLength: true,
448
+ }),
449
+ );
450
+ }
451
+ // Media uploads carry binary payloads (images, short videos). The cap covers
452
+ // the largest advertised media size so routes/media.ts owns the friendly,
453
+ // per-size 413; see the MEDIA_UPLOAD_BODY_LIMIT_BYTES note above.
454
+ // mediaRoutes is mounted at BOTH /api/media and the bare /media prefix
455
+ // (the latter is the public serve path used by AP/HTML), but media.ts also
456
+ // registers POST /upload. That means /media/upload exists too and MUST get
457
+ // the same large media cap — otherwise an advertised-size upload posted to
458
+ // /media/upload would be rejected by the 1 MiB default cap with a generic
459
+ // 413, a dead path. Apply the identical 48 MiB cap to /media/* so the cap
460
+ // is consistent across both mounts.
461
+ for (const prefix of ["/api/media/*", "/media/*"]) {
462
+ app.use(prefix, bodyLimit({ maxBytes: MEDIA_UPLOAD_BODY_LIMIT_BYTES }));
463
+ }
464
+ // Default global cap: 1 MiB covers JSON-shaped API traffic. It must NOT also
465
+ // clamp the media prefixes (whose intended cap is larger), so guard both.
466
+ const defaultCap = bodyLimit({ maxBytes: DEFAULT_BODY_LIMIT_BYTES });
467
+ app.use("*", async (c, next) => {
468
+ const path = c.req.path;
469
+ if (path.startsWith("/api/media/") || path.startsWith("/media/")) {
470
+ return next();
471
+ }
472
+ return defaultCap(c, next);
473
+ });
474
+ }
475
+
476
+ function applyGlobalMiddleware(app: YurucommuApp): void {
477
+ app.onError(createErrorMiddleware());
478
+
479
+ app.use("*", async (c, next) => {
480
+ await next();
481
+
482
+ const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
483
+ const setSecurityHeader = (name: string, value: string) => {
484
+ if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
485
+ return;
486
+ }
487
+ c.header(name, value);
488
+ };
489
+
490
+ setSecurityHeader("Cross-Origin-Opener-Policy", "same-origin");
491
+ setSecurityHeader("Cross-Origin-Embedder-Policy", "credentialless");
492
+
493
+ const takosUrl = c.env.TAKOS_URL?.trim();
494
+ const oidcIssuer = getOidcIssuerUrl(c.env);
495
+ // unpkg.com is only used by the official client to fetch @ffmpeg/core
496
+ // assets (JS + WASM). The fetched body is wrapped in a blob: URL via
497
+ // toBlobURL before being imported, so script-src does NOT need unpkg —
498
+ // only connect-src (for fetch) and blob: in script-src (for the wrapped
499
+ // worker script). Pinning unpkg in script-src would make any compromised
500
+ // npm package directly executable on this origin.
501
+ const connectSrc = ["'self'", "https://unpkg.com", "wss:"];
502
+ const formAction = ["'self'"];
503
+ if (takosUrl) {
504
+ connectSrc.push(takosUrl);
505
+ formAction.push(takosUrl);
506
+ }
507
+ if (oidcIssuer) {
508
+ connectSrc.push(oidcIssuer);
509
+ formAction.push(oidcIssuer);
510
+ }
511
+ const csp = [
512
+ "default-src 'self'",
513
+ "script-src 'self' blob: https://static.cloudflareinsights.com",
514
+ "style-src 'self' 'unsafe-inline'",
515
+ "img-src 'self' data: blob: https:",
516
+ "media-src 'self' data: blob:",
517
+ "font-src 'self' data:",
518
+ `connect-src ${connectSrc.join(" ")}`,
519
+ "worker-src 'self' blob:",
520
+ "frame-ancestors 'none'",
521
+ `form-action ${formAction.join(" ")}`,
522
+ "base-uri 'self'",
523
+ ].join("; ");
524
+ setSecurityHeader("Content-Security-Policy", csp);
525
+
526
+ setSecurityHeader("X-Content-Type-Options", "nosniff");
527
+ setSecurityHeader("X-Frame-Options", "DENY");
528
+ setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
529
+ setSecurityHeader(
530
+ "Permissions-Policy",
531
+ "camera=(), microphone=(), geolocation=()",
532
+ );
533
+ // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
534
+ // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
535
+ // when delivered over plain HTTP, so it is harmless for an HTTP-only
536
+ // self-host, and correct behind a TLS-terminating proxy where the worker
537
+ // sees HTTP but the client is on HTTPS. Deliberately NO includeSubDomains /
538
+ // preload: this is a self-hostable app and must not force HTTPS onto sibling
539
+ // subdomains the operator may serve over HTTP.
540
+ setSecurityHeader("Strict-Transport-Security", "max-age=31536000");
541
+ });
542
+
543
+ app.use("*", async (c, next) => {
544
+ c.set("db", c.env.DB_INSTANCE);
545
+ await next();
546
+ });
547
+
548
+ app.use("/api/*", async (c, next) => {
549
+ await extractActorFromSession(c);
550
+ await next();
551
+ });
552
+
553
+ // The bare /media/* serve path (the URL stored in every attachment + actor
554
+ // icon/header, embedded in AP docs and rendered by the SPA) also needs the
555
+ // session: media authorization gates non-public blobs (followers-only /
556
+ // direct / private-community story) and must recognize an authenticated
557
+ // in-app viewer. A federation peer or logged-out visitor sends no session
558
+ // cookie, so `extractActorFromSession` early-returns and they stay anonymous —
559
+ // seeing only public media, exactly as before. No CSRF (these are GET reads),
560
+ // and public media still returns a public/cacheable response.
561
+ app.use("/media/*", async (c, next) => {
562
+ await extractActorFromSession(c);
563
+ await next();
564
+ });
565
+
566
+ // Takos tools endpoints may be called from the browser (same-origin) and rely on
567
+ // the same session cookie auth as the rest of the API.
568
+ app.use("/.takos/tools/*", async (c, next) => {
569
+ await extractActorFromSession(c);
570
+ await next();
571
+ });
572
+
573
+ app.use("/api/*", csrfProtection());
574
+ app.use("/.takos/tools/*", csrfProtection());
575
+ // mediaRoutes is double-mounted at /api/media AND the bare /media, and it
576
+ // registers POST /upload — so /media/upload is a session-cookie-authenticated
577
+ // state-changing write (R2 + DB) that, without this, would bypass the CSRF
578
+ // control its /api/media/upload alias enforces. csrfProtection only guards
579
+ // POST/PUT/DELETE/PATCH, so the public GET /media/* serve path is unaffected.
580
+ // (Inert today behind the SameSite=Strict session cookie, but this keeps the
581
+ // two upload aliases under the same Origin/Referer check regardless of any
582
+ // future cookie-policy change.)
583
+ app.use("/media/*", csrfProtection());
584
+
585
+ app.use("/api/*", rateLimit(RateLimitConfigs.general));
586
+ app.use("/.takos/tools/*", rateLimit(RateLimitConfigs.general));
587
+ app.use("/api/auth/*", rateLimit(RateLimitConfigs.auth));
588
+ app.use("/api/search/*", rateLimit(RateLimitConfigs.search));
589
+ // The remote resolver makes an attacker-controlled outbound fetch to an
590
+ // arbitrary host; throttle it far tighter than the general search budget,
591
+ // like the other federation-discovery endpoints.
592
+ app.use(
593
+ "/api/search/remote",
594
+ rateLimit(RateLimitConfigs.federationDiscovery),
595
+ );
596
+ app.use("/api/media/*", rateLimit(RateLimitConfigs.mediaUpload));
597
+ // The bare /media mount serves media and also exposes POST /media/upload;
598
+ // throttle it with the same media budget as /api/media/* for consistency.
599
+ app.use("/media/*", rateLimit(RateLimitConfigs.mediaUpload));
600
+ app.use("/api/dm/*", rateLimit(RateLimitConfigs.dm));
601
+ app.post("/api/posts", rateLimit(RateLimitConfigs.postCreate));
602
+ // Like/repost are federated WRITES (they sign + deliver activities to remote
603
+ // inboxes), so bound them at the write budget rather than the general read
604
+ // budget to limit mass-interaction delivery storms.
605
+ app.post("/api/posts/:id/like", rateLimit(RateLimitConfigs.postCreate));
606
+ app.post("/api/posts/:id/repost", rateLimit(RateLimitConfigs.postCreate));
607
+ // Community creation generates an RSA keypair + actor; bound it at the write
608
+ // budget rather than the general read budget.
609
+ app.post("/api/communities", rateLimit(RateLimitConfigs.postCreate));
610
+ // Follow / unfollow / accept / reject are federated WRITES (they sign + deliver
611
+ // Follow / Undo / Accept / Reject to remote inboxes), so bound them at the
612
+ // write budget like like/repost — a follow-toggle loop otherwise drives ~1000
613
+ // signed remote deliveries/min at the general read budget.
614
+ app.post("/api/follow", rateLimit(RateLimitConfigs.postCreate));
615
+ app.delete("/api/follow", rateLimit(RateLimitConfigs.postCreate));
616
+ app.post("/api/follow/accept", rateLimit(RateLimitConfigs.postCreate));
617
+ app.post("/api/follow/reject", rateLimit(RateLimitConfigs.postCreate));
618
+ // Apply the dedicated per-IP `inbox` budget (1k/min) to EVERY inbox route.
619
+ // The two-segment user/group inboxes are NOT matched by `/ap/*/inbox` and the
620
+ // user inbox would otherwise be throttled only by the much tighter 60/min
621
+ // `/ap/users/*` discovery limiter below (wrongly 429'ing legitimate inbound
622
+ // federation), while the group inbox would get NO per-IP throttle at all (an
623
+ // unauthenticated per-IP DoS forcing an unthrottled DB lookup + signature
624
+ // verify per request). See INBOX_PATH_PATTERNS.
625
+ for (const pattern of INBOX_PATH_PATTERNS) {
626
+ app.use(pattern, rateLimit(RateLimitConfigs.inbox));
627
+ }
628
+
629
+ // Federation discovery endpoints are unauthenticated and can be probed by
630
+ // any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
631
+ app.use(
632
+ "/.well-known/webfinger",
633
+ rateLimit(RateLimitConfigs.federationDiscovery),
634
+ );
635
+ app.use(
636
+ "/.well-known/nodeinfo",
637
+ rateLimit(RateLimitConfigs.federationDiscovery),
638
+ );
639
+ app.use("/nodeinfo/*", rateLimit(RateLimitConfigs.federationDiscovery));
640
+ // `/ap/users/*` covers actor docs, outbox, followers, etc. — but NOT the user
641
+ // inbox, which gets the dedicated 1k/min `inbox` budget above. Both limiters
642
+ // use separate buckets (distinct keyPrefix), so without this skip an inbox
643
+ // delivery would increment BOTH and stay capped at the stricter 60/min — the
644
+ // exact over-throttling the dedicated inbox budget exists to avoid.
645
+ const fedDiscoveryLimiter = rateLimit(RateLimitConfigs.federationDiscovery);
646
+ app.use("/ap/users/*", async (c, next) => {
647
+ if (c.req.path.endsWith("/inbox")) {
648
+ return next();
649
+ }
650
+ return fedDiscoveryLimiter(c, next);
651
+ });
652
+ app.use("/ap/objects/*", rateLimit(RateLimitConfigs.federationDiscovery));
653
+ app.use(
654
+ "/ap/users/*/outbox",
655
+ rateLimit(RateLimitConfigs.federationDiscovery),
656
+ );
657
+ // Parity for the structurally-identical Group + instance-actor collection
658
+ // endpoints (/ap/groups/:name/{outbox,followers,moderators}, /ap/actor/{outbox,
659
+ // followers}). Without this they were the only unauthenticated AP discovery
660
+ // GETs with NO per-IP throttle, while /ap/users/*/outbox was capped at 60/min.
661
+ // Skip the inbox sub-routes so the dedicated 1k/min inbox budget still governs
662
+ // them (the same skip the /ap/users/* limiter uses), avoiding double-counting.
663
+ app.use("/ap/groups/*", async (c, next) => {
664
+ if (c.req.path.endsWith("/inbox")) return next();
665
+ return fedDiscoveryLimiter(c, next);
666
+ });
667
+ app.use("/ap/actor/*", async (c, next) => {
668
+ if (c.req.path.endsWith("/inbox")) return next();
669
+ return fedDiscoveryLimiter(c, next);
670
+ });
671
+ }
672
+
673
+ function mountCoreRoutes(app: YurucommuApp): void {
674
+ app.route("/api/auth", authRoutes);
675
+ app.route("/api/actors", actorsRoutes);
676
+ app.route("/api/follow", followRoutes);
677
+ app.route("/api/timeline", timelineRoutes);
678
+ app.route("/api/posts", postsRoutes);
679
+
680
+ app.get("/api/bookmarks", async (c) => {
681
+ const url = new URL(c.req.url);
682
+ url.pathname = "/api/posts/bookmarks";
683
+ const newReq = new Request(url.toString(), c.req.raw);
684
+ return app.fetch(newReq, c.env, c.executionCtx);
685
+ });
686
+
687
+ app.route("/api/notifications", notificationsRoutes);
688
+ app.route("/api/mobile", mobileRoutes);
689
+ app.route("/api/stories", storiesRoutes);
690
+ app.route("/api/search", searchRoutes);
691
+ app.route("/api/communities", communitiesRoutes);
692
+ app.route("/api/dm", dmRoutes);
693
+ app.route("/api/media", mediaRoutes);
694
+ app.route("/media", mediaRoutes);
695
+ app.route("/.takos/tools", takosToolsRoutes);
696
+ app.route("/api/recommendations", recommendationsRoutes);
697
+ app.route("/api/moderation", moderationRoutes);
698
+ app.route("/api/apps", appsApiRoutes);
699
+ app.route("/hosted", appsServeRoutes);
700
+ app.route("/", activitypubRoutes);
701
+ }
702
+
703
+ function mountStaticFallback(app: YurucommuApp): void {
704
+ app.all("*", async (c) => {
705
+ // A request that reaches the static fallback under a backend route prefix
706
+ // means no API / AP / media route matched it — return a genuine JSON 404
707
+ // instead of the SPA HTML shell. Without this, the Cloudflare ASSETS binding
708
+ // (single-page-application mode) served index.html with a 200 for unmatched
709
+ // /api/* paths, so an API client (or our own fetch) got HTML 200 instead of
710
+ // a 404 — the Bun runtime already guarded this; share one source of truth.
711
+ if (isBackendPath(new URL(c.req.url).pathname)) {
712
+ return c.json({ error: "Not Found", code: "NOT_FOUND" }, 404);
713
+ }
714
+
715
+ if (c.env.ASSETS) {
716
+ return c.env.ASSETS.fetch(c.req.raw);
717
+ }
718
+
719
+ const storage = (c.env as { STORAGE?: R2Bucket }).STORAGE;
720
+ if (storage) {
721
+ const url = new URL(c.req.url);
722
+ let assetPath = url.pathname;
723
+
724
+ if (assetPath === "/" || assetPath === "") {
725
+ assetPath = "/index.html";
726
+ }
727
+
728
+ const r2Key = `_assets${assetPath}`;
729
+
730
+ try {
731
+ const object = await storage.get(r2Key);
732
+ if (object) {
733
+ const headers = new Headers();
734
+ headers.set("Content-Type", getMimeType(assetPath));
735
+ headers.set(
736
+ "Cache-Control",
737
+ assetPath.includes("/assets/")
738
+ ? "public, max-age=31536000, immutable"
739
+ : "public, max-age=3600",
740
+ );
741
+ if (object.httpEtag) {
742
+ headers.set("ETag", object.httpEtag);
743
+ }
744
+ return new Response(object.body as unknown as BodyInit, { headers });
745
+ }
746
+
747
+ if (!assetPath.includes(".")) {
748
+ const indexObject = await storage.get("_assets/index.html");
749
+ if (indexObject) {
750
+ const headers = new Headers();
751
+ headers.set("Content-Type", "text/html; charset=utf-8");
752
+ headers.set("Cache-Control", "no-cache");
753
+ return new Response(indexObject.body as unknown as BodyInit, {
754
+ headers,
755
+ });
756
+ }
757
+ }
758
+ } catch (err) {
759
+ log.error("Failed to serve asset from R2", {
760
+ event: "assets.r2.serve_failed",
761
+ error: err,
762
+ });
763
+ }
764
+ }
765
+
766
+ return c.json(
767
+ {
768
+ error: "Static assets not configured",
769
+ message:
770
+ "This instance is running in API-only mode. Frontend assets are not available.",
771
+ hint: "Access /api/* endpoints for API functionality.",
772
+ },
773
+ 503,
774
+ );
775
+ });
776
+ }
777
+
778
+ export function createYurucommuBackendApp(
779
+ options: CreateYurucommuBackendAppOptionsV1 = {},
780
+ ): YurucommuApp {
781
+ const app = new Hono<{ Bindings: Env; Variables: Variables }>();
782
+ const plugins = options.plugins ?? [];
783
+ const pluginContext: BackendPluginContextV1 = { app };
784
+
785
+ for (const plugin of plugins) {
786
+ if (plugin.apiVersion !== YURUCOMMU_BACKEND_PLUGIN_API_VERSION) {
787
+ throw new Error(
788
+ `[yurucommu] backend plugin "${plugin.name}" uses unsupported apiVersion=${plugin.apiVersion}. ` +
789
+ `Expected ${YURUCOMMU_BACKEND_PLUGIN_API_VERSION}.`,
790
+ );
791
+ }
792
+ }
793
+
794
+ mountReadinessRoutes(app, options.discovery);
795
+ // Body-size cap must run BEFORE any handler reads the body or executes
796
+ // expensive auth / rate-limit logic. Mounted after readiness probes so
797
+ // /healthz and /readyz stay reachable even when payload validation
798
+ // misbehaves.
799
+ applyBodyLimits(app);
800
+ applyGlobalMiddleware(app);
801
+ for (const plugin of plugins) {
802
+ plugin.setup?.(pluginContext);
803
+ }
804
+ for (const plugin of plugins) {
805
+ plugin.beforeRoutes?.(pluginContext);
806
+ }
807
+
808
+ mountCoreRoutes(app);
809
+ for (const plugin of plugins) {
810
+ plugin.afterRoutes?.(pluginContext);
811
+ }
812
+
813
+ mountStaticFallback(app);
814
+ return app;
815
+ }
816
+
817
+ const app = createYurucommuBackendApp();
818
+
819
+ export const backendApp = app;
820
+
821
+ export async function handleYurucommuQueueBatch(
822
+ batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
823
+ env: Env,
824
+ ): Promise<void> {
825
+ const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
826
+ const deliveryDlqName = env.DELIVERY_DLQ_NAME ?? "yurucommu-delivery-dlq";
827
+
828
+ if (batch.queue === deliveryQueueName) {
829
+ return handleDeliveryQueueBatch(
830
+ batch as MessageBatch<DeliveryQueueMessageV1>,
831
+ env,
832
+ );
833
+ }
834
+ if (batch.queue === deliveryDlqName) {
835
+ return handleDeliveryDlqBatch(
836
+ batch as MessageBatch<DeliveryDlqMessageV1>,
837
+ env,
838
+ );
839
+ }
840
+
841
+ log.warn("Unknown queue", {
842
+ event: "queue.unknown",
843
+ queue: batch.queue,
844
+ });
845
+ batch.ackAll();
846
+ }
847
+
848
+ type WorkerBindings = EnvVars & {
849
+ DB: D1Database;
850
+ MEDIA?: R2Bucket;
851
+ KV: KVNamespace;
852
+ ASSETS?: Fetcher;
853
+ DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
854
+ DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
855
+ };
856
+
857
+ export default {
858
+ async fetch(
859
+ request: Request,
860
+ bindings: WorkerBindings,
861
+ ctx: ExecutionContext,
862
+ ): Promise<Response> {
863
+ return app.fetch(request, wrapCloudflareBindings(bindings), ctx);
864
+ },
865
+
866
+ async queue(
867
+ batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
868
+ bindings: WorkerBindings,
869
+ ): Promise<void> {
870
+ return handleYurucommuQueueBatch(batch, wrapCloudflareBindings(bindings));
871
+ },
872
+ };