@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,615 @@
1
+ import { Hono } from "hono";
2
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import { actorApId, formatUsername } from "../federation-helpers.ts";
5
+ import { verifyBootstrapOrPassword } from "../lib/crypto.ts";
6
+ import {
7
+ fetchUserInfo,
8
+ getAuthConfig,
9
+ getClientCredentials,
10
+ getProvider,
11
+ } from "../lib/oauth-providers.ts";
12
+ import { verifyOidcIdToken } from "../lib/oidc-id-token.ts";
13
+ import {
14
+ deleteOAuthState,
15
+ generateCodeChallenge,
16
+ generateCodeVerifier,
17
+ generateId as generateOAuthId,
18
+ generateNonce,
19
+ getOAuthState,
20
+ saveOAuthState,
21
+ } from "../lib/oauth-utils.ts";
22
+ import {
23
+ clearLoginLockout,
24
+ getLoginLockoutStatus,
25
+ recordFailedLoginAttempt,
26
+ } from "../lib/auth-lockout.ts";
27
+ import { getClientIP } from "../lib/client-ip.ts";
28
+ import { and, asc, count, eq, or } from "drizzle-orm";
29
+ import { actors, notDeleted, sessions } from "../../db/index.ts";
30
+ import { hashSessionIdForEnv } from "../lib/crypto.ts";
31
+ import {
32
+ createActor,
33
+ deleteSessionSafely,
34
+ exchangeOAuthToken,
35
+ findOrCreateOAuthActor,
36
+ formatAccountResponse,
37
+ lockoutErrorResponse,
38
+ parseJsonObject,
39
+ parseNonEmptyString,
40
+ rotateSession,
41
+ } from "./auth-helpers.ts";
42
+ import { logger } from "../lib/logger.ts";
43
+
44
+ const log = logger.child({ component: "auth" });
45
+
46
+ const KNOWN_OAUTH_ERRORS = new Set([
47
+ "access_denied",
48
+ "invalid_request",
49
+ "unauthorized_client",
50
+ "unsupported_response_type",
51
+ "invalid_scope",
52
+ "server_error",
53
+ "temporarily_unavailable",
54
+ "interaction_required",
55
+ "login_required",
56
+ "consent_required",
57
+ ]);
58
+
59
+ const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
60
+
61
+ // Maximum number of sub-accounts a single instance owner may mint, capping
62
+ // abuse even from the legitimate owner session (#23).
63
+ const MAX_SUB_ACCOUNTS = 20;
64
+
65
+ // 認証設定取得
66
+ auth.get("/providers", async (c) => {
67
+ const config = getAuthConfig(c.env);
68
+ return c.json({
69
+ providers: config.providers.map((p) => ({
70
+ id: p.id,
71
+ name: p.name,
72
+ icon: p.icon,
73
+ })),
74
+ password_enabled: config.passwordEnabled,
75
+ });
76
+ });
77
+
78
+ // 現在のユーザー情報
79
+ auth.get("/me", async (c) => {
80
+ const actor = c.get("actor");
81
+ if (!actor) return c.json({ error: "Not authenticated" }, 401);
82
+
83
+ const sessionId = getCookie(c, "session");
84
+ let provider: string | null = null;
85
+ let hasTakosAccess = false;
86
+
87
+ if (sessionId) {
88
+ const db = c.get("db");
89
+ const sessionKey = await hashSessionIdForEnv(c.env, sessionId);
90
+ const session = await db
91
+ .select({
92
+ provider: sessions.provider,
93
+ providerAccessToken: sessions.providerAccessToken,
94
+ })
95
+ .from(sessions)
96
+ .where(eq(sessions.id, sessionKey))
97
+ .get();
98
+ if (session) {
99
+ provider = session.provider;
100
+ hasTakosAccess =
101
+ session.provider === "takos" && !!session.providerAccessToken;
102
+ }
103
+ }
104
+
105
+ return c.json({
106
+ actor: {
107
+ ap_id: actor.ap_id,
108
+ username: formatUsername(actor.ap_id),
109
+ preferred_username: actor.preferred_username,
110
+ name: actor.name,
111
+ summary: actor.summary,
112
+ icon_url: actor.icon_url,
113
+ header_url: actor.header_url,
114
+ follower_count: actor.follower_count,
115
+ following_count: actor.following_count,
116
+ post_count: actor.post_count,
117
+ role: actor.role,
118
+ },
119
+ provider,
120
+ has_takos_access: hasTakosAccess,
121
+ });
122
+ });
123
+
124
+ // パスワード認証
125
+ auth.post("/login", async (c) => {
126
+ const config = getAuthConfig(c.env);
127
+ if (!config.passwordEnabled) {
128
+ return c.json({ error: "Password auth not enabled" }, 400);
129
+ }
130
+
131
+ const clientIp = getClientIP(c);
132
+ const lockoutKey = `password:${clientIp}`;
133
+
134
+ const lockoutStatus = await getLoginLockoutStatus(c.env.KV, lockoutKey);
135
+ if (lockoutStatus.locked) {
136
+ c.header("Retry-After", String(lockoutStatus.retryAfterSeconds));
137
+ return c.json(lockoutErrorResponse(lockoutStatus.retryAfterSeconds), 429);
138
+ }
139
+
140
+ const body = await parseJsonObject(c);
141
+ if (!body) {
142
+ return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
143
+ }
144
+
145
+ const password = parseNonEmptyString(body.password);
146
+ if (!password) {
147
+ return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
148
+ }
149
+
150
+ // Verify password against AUTH_PASSWORD_HASH. Properly-hashed values use the
151
+ // PBKDF2 salt:hash path; a fresh Capsule install generates a colon-less
152
+ // bootstrap token that is matched verbatim in constant time so the install
153
+ // is actually loginnable out of the box (#11).
154
+ let isValid = false;
155
+
156
+ // Treat a blank / whitespace-only AUTH_PASSWORD_HASH as "password login
157
+ // disabled", not "the password is whitespace": otherwise a misconfigured
158
+ // `AUTH_PASSWORD_HASH=" "` would let an attacker log in by submitting " ".
159
+ if (c.env.AUTH_PASSWORD_HASH?.trim()) {
160
+ isValid = await verifyBootstrapOrPassword(
161
+ password,
162
+ c.env.AUTH_PASSWORD_HASH,
163
+ );
164
+ }
165
+
166
+ if (!isValid) {
167
+ const failedStatus = await recordFailedLoginAttempt(c.env.KV, lockoutKey);
168
+ if (failedStatus.locked) {
169
+ c.header("Retry-After", String(failedStatus.retryAfterSeconds));
170
+ return c.json(lockoutErrorResponse(failedStatus.retryAfterSeconds), 429);
171
+ }
172
+ return c.json({ error: "Invalid password" }, 401);
173
+ }
174
+
175
+ const db = c.get("db");
176
+
177
+ // Single-user instance: find existing owner or create default.
178
+ // Exclude tombstoned rows (`notDeleted`): a wave-8 account deletion keeps the
179
+ // owner row as a scrubbed tombstone (role still "owner") for the federation
180
+ // Delete signer. Without this guard the owner-resolution query would re-resolve
181
+ // that zombie and log a fresh login into a deleted account; with it, login
182
+ // falls through to createActor and provisions a clean owner.
183
+ const actorData =
184
+ (await db
185
+ .select()
186
+ .from(actors)
187
+ .where(and(eq(actors.role, "owner"), notDeleted(actors)))
188
+ .get()) ??
189
+ (await createActor(db, c.env, {
190
+ username: "tako",
191
+ name: "tako",
192
+ takosUserId: "password:owner",
193
+ role: "owner",
194
+ }));
195
+
196
+ await rotateSession(
197
+ c,
198
+ actorData.apId,
199
+ null,
200
+ null,
201
+ c.env.ENCRYPTION_KEY,
202
+ "password login rotation",
203
+ );
204
+ await clearLoginLockout(c.env.KV, lockoutKey);
205
+
206
+ return c.json({ success: true });
207
+ });
208
+
209
+ // OAuth: 認証開始
210
+ auth.get("/login/:provider", async (c) => {
211
+ const providerId = c.req.param("provider");
212
+ const provider = getProvider(c.env, providerId);
213
+
214
+ if (!provider) {
215
+ return c.json({ error: "Unknown or unconfigured provider" }, 400);
216
+ }
217
+
218
+ const state = generateOAuthId();
219
+ const codeVerifier = generateCodeVerifier();
220
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
221
+
222
+ const nonce = generateNonce();
223
+ await saveOAuthState(c.env.KV, state, {
224
+ provider: providerId,
225
+ codeVerifier,
226
+ createdAt: Date.now(),
227
+ nonce,
228
+ });
229
+ // Bind OAuth state to this browser session to prevent login CSRF (Issue 107).
230
+ // Secure unless served over explicit http:// (see the session cookie).
231
+ setCookie(c, "oauth_nonce", nonce, {
232
+ httpOnly: true,
233
+ secure: !(c.env.APP_URL ?? "").startsWith("http://"),
234
+ sameSite: "Lax",
235
+ path: "/",
236
+ maxAge: 600,
237
+ });
238
+
239
+ const { clientId } = getClientCredentials(c.env, providerId);
240
+ const redirectUri = `${c.env.APP_URL}/api/auth/callback/${providerId}`;
241
+
242
+ const params = new URLSearchParams({
243
+ response_type: "code",
244
+ client_id: clientId,
245
+ redirect_uri: redirectUri,
246
+ scope: provider.scopes.join(" "),
247
+ state,
248
+ });
249
+
250
+ if (provider.supportsPkce) {
251
+ params.set("code_challenge", codeChallenge);
252
+ params.set("code_challenge_method", "S256");
253
+ }
254
+ // For OIDC providers, send the `nonce` so the issuer echoes it in the ID Token
255
+ // and the callback can bind the token to this exact login (replay protection).
256
+ // We reuse the same random value that backs the state/CSRF binding.
257
+ if (provider.issuer) {
258
+ params.set("nonce", nonce);
259
+ }
260
+
261
+ return c.redirect(`${provider.authorizeUrl}?${params.toString()}`);
262
+ });
263
+
264
+ // OAuth: コールバック
265
+ auth.get("/callback/:provider", async (c) => {
266
+ const providerId = c.req.param("provider");
267
+ const error = c.req.query("error");
268
+
269
+ if (error) {
270
+ log.error("OAuth provider returned error", {
271
+ event: "auth.oauth.callback_error",
272
+ provider: providerId,
273
+ oauthError: error,
274
+ errorDescription: c.req.query("error_description"),
275
+ });
276
+ const safeError = KNOWN_OAUTH_ERRORS.has(error) ? error : "oauth_error";
277
+ return c.redirect(`/?error=${safeError}`);
278
+ }
279
+
280
+ const code = c.req.query("code");
281
+ const state = c.req.query("state");
282
+ if (!code || !state) {
283
+ return c.redirect("/?error=missing_params");
284
+ }
285
+
286
+ const storedState = await getOAuthState(c.env.KV, state);
287
+ if (!storedState) return c.redirect("/?error=invalid_state");
288
+ if (storedState.provider !== providerId) {
289
+ return c.redirect("/?error=provider_mismatch");
290
+ }
291
+ // Verify browser-session binding nonce to prevent login CSRF (Issue 107).
292
+ if (storedState.nonce) {
293
+ const cookieNonce = getCookie(c, "oauth_nonce");
294
+ if (!cookieNonce || cookieNonce !== storedState.nonce) {
295
+ await deleteOAuthState(c.env.KV, state);
296
+ deleteCookie(c, "oauth_nonce");
297
+ return c.redirect("/?error=csrf_check_failed");
298
+ }
299
+ }
300
+ await deleteOAuthState(c.env.KV, state);
301
+ deleteCookie(c, "oauth_nonce");
302
+
303
+ const provider = getProvider(c.env, providerId);
304
+ if (!provider) return c.redirect("/?error=unknown_provider");
305
+
306
+ const tokens = await exchangeOAuthToken(
307
+ providerId,
308
+ code,
309
+ storedState.codeVerifier,
310
+ c.env,
311
+ provider,
312
+ );
313
+ if (!tokens) return c.redirect("/?error=token_exchange_failed");
314
+
315
+ let userInfo;
316
+ try {
317
+ userInfo = await fetchUserInfo(provider, tokens.access_token);
318
+ } catch (err) {
319
+ log.error("Failed to fetch user info", {
320
+ event: "auth.oauth.user_info_failed",
321
+ provider: providerId,
322
+ error: err,
323
+ });
324
+ return c.redirect("/?error=user_info_failed");
325
+ }
326
+
327
+ // For an OIDC provider (Takosumi Accounts) the ID Token is the authoritative
328
+ // identity assertion and carries name/email/sub that the minimal userinfo
329
+ // endpoint omits. Verify it (ES256 against the issuer JWKS + iss/aud/exp) and
330
+ // let its claims fill/override the userinfo; an ID Token that is present but
331
+ // invalid fails the login (fail closed — never trust an unverified subject).
332
+ // `issuer`+`jwksUrl` is the "this is an OIDC provider" discriminator. For such
333
+ // a provider the id_token MUST be present and valid — a token response that
334
+ // simply omits it must NOT fall through to unverified userinfo (that would skip
335
+ // the aud + nonce binding the rest of this flow enforces). Fail closed.
336
+ if (provider.issuer && provider.jwksUrl) {
337
+ if (!tokens.id_token) {
338
+ log.error("OIDC provider returned no id_token", {
339
+ event: "auth.oauth.id_token_missing",
340
+ provider: providerId,
341
+ });
342
+ return c.redirect("/?error=id_token_missing");
343
+ }
344
+ try {
345
+ const { clientId } = getClientCredentials(c.env, providerId);
346
+ const claims = await verifyOidcIdToken(tokens.id_token, {
347
+ issuer: provider.issuer,
348
+ clientId,
349
+ jwksUrl: provider.jwksUrl,
350
+ expectedNonce: storedState.nonce,
351
+ });
352
+ userInfo = {
353
+ ...userInfo,
354
+ id: claims.sub || userInfo.id,
355
+ name: claims.name ?? userInfo.name,
356
+ email: claims.email ?? userInfo.email,
357
+ username: claims.preferred_username ?? userInfo.username,
358
+ };
359
+ } catch (err) {
360
+ log.error("OIDC ID token verification failed", {
361
+ event: "auth.oauth.id_token_invalid",
362
+ provider: providerId,
363
+ error: err,
364
+ });
365
+ return c.redirect("/?error=id_token_invalid");
366
+ }
367
+ }
368
+
369
+ const actorData = await findOrCreateOAuthActor(
370
+ c.get("db"),
371
+ c.env,
372
+ providerId,
373
+ userInfo,
374
+ );
375
+ if (!actorData) return c.redirect("/?error=actor_creation_failed");
376
+
377
+ await rotateSession(
378
+ c,
379
+ actorData.apId,
380
+ providerId,
381
+ providerId === "takos" ? tokens : null,
382
+ c.env.ENCRYPTION_KEY,
383
+ "oauth login rotation",
384
+ );
385
+
386
+ return c.redirect("/");
387
+ });
388
+
389
+ // ログアウト
390
+ auth.post("/logout", async (c) => {
391
+ const sessionId = getCookie(c, "session");
392
+ if (sessionId) {
393
+ await deleteSessionSafely(c.get("db"), c.env, sessionId, "logout");
394
+ deleteCookie(c, "session");
395
+ }
396
+ return c.json({ success: true });
397
+ });
398
+
399
+ auth.get("/accounts", async (c) => {
400
+ const actor = c.get("actor");
401
+ if (!actor) return c.json({ error: "Not authenticated" }, 401);
402
+
403
+ const db = c.get("db");
404
+
405
+ // Find the root owner ap_id: current actor may be a sub-account.
406
+ // ownerActorApId is set on actors created via POST /accounts; root actors have null.
407
+ const currentActorRecord = await db
408
+ .select({
409
+ ownerActorApId: actors.ownerActorApId,
410
+ })
411
+ .from(actors)
412
+ .where(eq(actors.apId, actor.ap_id))
413
+ .get();
414
+ const rootOwnerApId = currentActorRecord?.ownerActorApId ?? actor.ap_id;
415
+
416
+ // Return only the root actor and any sub-accounts they own (Issue 106).
417
+ const accounts = await db
418
+ .select({
419
+ apId: actors.apId,
420
+ preferredUsername: actors.preferredUsername,
421
+ name: actors.name,
422
+ iconUrl: actors.iconUrl,
423
+ })
424
+ .from(actors)
425
+ .where(
426
+ or(
427
+ eq(actors.apId, rootOwnerApId),
428
+ eq(actors.ownerActorApId, rootOwnerApId),
429
+ ),
430
+ )
431
+ .orderBy(asc(actors.createdAt))
432
+ .all();
433
+
434
+ return c.json({
435
+ accounts: accounts.map(formatAccountResponse),
436
+ current_ap_id: actor.ap_id,
437
+ });
438
+ });
439
+
440
+ auth.post("/switch", async (c) => {
441
+ const currentActor = c.get("actor");
442
+ if (!currentActor) return c.json({ error: "Not authenticated" }, 401);
443
+
444
+ const sessionId = getCookie(c, "session");
445
+ if (!sessionId) return c.json({ error: "No session" }, 401);
446
+
447
+ const body = await parseJsonObject(c);
448
+ if (!body) {
449
+ return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
450
+ }
451
+
452
+ const targetApId = parseNonEmptyString(body.ap_id);
453
+ if (!targetApId) {
454
+ return c.json({ error: "ap_id required", code: "BAD_REQUEST" }, 400);
455
+ }
456
+
457
+ const db = c.get("db");
458
+
459
+ // Resolve the root owner of the current session to enforce ownership (Issue 106).
460
+ const currentActorRecord = await db
461
+ .select({
462
+ ownerActorApId: actors.ownerActorApId,
463
+ })
464
+ .from(actors)
465
+ .where(eq(actors.apId, currentActor.ap_id))
466
+ .get();
467
+ const rootOwnerApId =
468
+ currentActorRecord?.ownerActorApId ?? currentActor.ap_id;
469
+
470
+ const targetActor = await db
471
+ .select({
472
+ apId: actors.apId,
473
+ ownerActorApId: actors.ownerActorApId,
474
+ })
475
+ .from(actors)
476
+ .where(eq(actors.apId, targetApId))
477
+ .get();
478
+
479
+ if (!targetActor) return c.json({ error: "Account not found" }, 404);
480
+
481
+ // Only allow switching to the root account or its own sub-accounts.
482
+ const isAllowed =
483
+ targetActor.apId === rootOwnerApId ||
484
+ targetActor.ownerActorApId === rootOwnerApId;
485
+ if (!isAllowed) return c.json({ error: "Forbidden" }, 403);
486
+
487
+ // Rotate the session instead of repointing `member_id` in place. Two reasons:
488
+ // (1) a fresh session id is minted and the old one invalidated (fixation
489
+ // hygiene), and (2) — critically — the owner's encrypted provider/OAuth tokens
490
+ // do NOT carry over to the sub-account (provider=null, tokens=null). A raw
491
+ // member_id rewrite would leave the owner's `provider_access_token` attached to
492
+ // a session now acting as a different actor.
493
+ await rotateSession(
494
+ c,
495
+ targetApId,
496
+ null,
497
+ null,
498
+ c.env.ENCRYPTION_KEY,
499
+ "account switch",
500
+ );
501
+
502
+ return c.json({ success: true });
503
+ });
504
+
505
+ auth.post("/accounts", async (c) => {
506
+ const currentActor = c.get("actor");
507
+ if (!currentActor) return c.json({ error: "Not authenticated" }, 401);
508
+
509
+ const body = await parseJsonObject(c);
510
+ if (!body) {
511
+ return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
512
+ }
513
+
514
+ const username = parseNonEmptyString(body.username);
515
+ if (!username) {
516
+ return c.json({ error: "username required", code: "BAD_REQUEST" }, 400);
517
+ }
518
+ if (!/^[a-zA-Z0-9_]{1,30}$/.test(username)) {
519
+ // Length-bound the identifier (1-30): it becomes the actor apId primary key
520
+ // and preferredUsername, so the unanchored character class let an
521
+ // arbitrarily long string into a PK column.
522
+ return c.json(
523
+ {
524
+ error: "Invalid username. Use 1-30 letters, numbers, and underscores.",
525
+ },
526
+ 400,
527
+ );
528
+ }
529
+
530
+ if (body.name !== undefined && typeof body.name !== "string") {
531
+ return c.json({ error: "name must be a string", code: "BAD_REQUEST" }, 400);
532
+ }
533
+ if (typeof body.name === "string" && body.name.length > 50) {
534
+ // Mirror the profile-update cap (MAX_PROFILE_NAME_LENGTH) so the display name
535
+ // can't be an unbounded string.
536
+ return c.json(
537
+ { error: "name too long (max 50)", code: "BAD_REQUEST" },
538
+ 400,
539
+ );
540
+ }
541
+ const name: string | undefined =
542
+ typeof body.name === "string" ? body.name : undefined;
543
+
544
+ const db = c.get("db");
545
+
546
+ // Resolve the root owner of the current session, then gate sub-account
547
+ // creation to the instance owner only (#23). Sub-accounts are minted with
548
+ // role "member", so an arbitrary logged-in member must never be able to
549
+ // create more accounts. We resolve the root actor (sub-accounts carry
550
+ // ownerActorApId pointing at their root) and require that root to be the
551
+ // instance owner. This still lets the owner manage accounts while switched
552
+ // into one of their own sub-accounts.
553
+ const creatorRecord = await db
554
+ .select({
555
+ ownerActorApId: actors.ownerActorApId,
556
+ })
557
+ .from(actors)
558
+ .where(eq(actors.apId, currentActor.ap_id))
559
+ .get();
560
+ const rootOwnerApId = creatorRecord?.ownerActorApId ?? currentActor.ap_id;
561
+
562
+ const rootOwnerRecord = await db
563
+ .select({ role: actors.role })
564
+ .from(actors)
565
+ .where(eq(actors.apId, rootOwnerApId))
566
+ .get();
567
+ if (!rootOwnerRecord || rootOwnerRecord.role !== "owner") {
568
+ return c.json({ error: "Forbidden" }, 403);
569
+ }
570
+
571
+ // Cap the number of sub-accounts owned by this root owner (#23).
572
+ const subAccountCount = await db
573
+ .select({ value: count() })
574
+ .from(actors)
575
+ .where(eq(actors.ownerActorApId, rootOwnerApId))
576
+ .get();
577
+ if ((subAccountCount?.value ?? 0) >= MAX_SUB_ACCOUNTS) {
578
+ return c.json(
579
+ {
580
+ error: "Sub-account limit reached",
581
+ code: "SUB_ACCOUNT_LIMIT_REACHED",
582
+ },
583
+ 400,
584
+ );
585
+ }
586
+
587
+ const apId = actorApId(c.env.APP_URL, username);
588
+
589
+ // Exclude tombstoned rows: a deleted account keeps its row (with a renamed
590
+ // sentinel handle, but the original apId is unique-keyed) until the reaper
591
+ // drains it. A freed handle must be immediately re-registerable, so only a
592
+ // LIVE collision blocks creation (#9).
593
+ const existing = await db
594
+ .select({ apId: actors.apId })
595
+ .from(actors)
596
+ .where(and(eq(actors.apId, apId), notDeleted(actors)))
597
+ .get();
598
+
599
+ if (existing) return c.json({ error: "Username already taken" }, 400);
600
+
601
+ const newActor = await createActor(db, c.env, {
602
+ username,
603
+ name: name || username,
604
+ takosUserId: `local:${username}`,
605
+ role: "member",
606
+ ownerActorApId: rootOwnerApId,
607
+ });
608
+
609
+ return c.json({
610
+ success: true,
611
+ account: formatAccountResponse(newActor),
612
+ });
613
+ });
614
+
615
+ export default auth;