@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,566 @@
1
+ import type { Context } from "hono";
2
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import {
5
+ actorApId,
6
+ generateId,
7
+ generateKeyPair,
8
+ } from "../federation-helpers.ts";
9
+ import { encrypt, hashSessionIdForEnv } from "../lib/crypto.ts";
10
+ import { getClientCredentials } from "../lib/oauth-providers.ts";
11
+ import type { Database } from "../../db/index.ts";
12
+ import { and, count, eq, isNotNull } from "drizzle-orm";
13
+ import { actors, notDeleted, sessions } from "../../db/index.ts";
14
+ import {
15
+ isUniqueConstraintError,
16
+ parseJsonObject,
17
+ parseNonEmptyString,
18
+ } from "../lib/parse-helpers.ts";
19
+ import { cancelTombstoneDelete } from "./actors.ts";
20
+ import {
21
+ isValidProfileImageUrl,
22
+ MAX_PROFILE_NAME_LENGTH,
23
+ MAX_PROFILE_URL_LENGTH,
24
+ } from "./actors-helpers.ts";
25
+ import { logger } from "../lib/logger.ts";
26
+
27
+ /**
28
+ * Bound + validate an OAuth/OIDC provider's `name` / `picture` before they are
29
+ * written to the local `actors` row. The provider is the operator's trust anchor
30
+ * but is still attacker-influenceable (a malicious / compromised / multi-tenant
31
+ * issuer), and these values are served verbatim in the actor document and
32
+ * federated out in Update(Person). Every OTHER write path to actors.name/iconUrl
33
+ * is capped + validated (POST /accounts, PUT /me); this is the one path that
34
+ * escaped them. Mirror the PUT /me bounds: name slice 50, icon must pass
35
+ * isValidProfileImageUrl + the URL length cap.
36
+ */
37
+ function sanitizeOAuthProfile(userInfo: { name?: string; picture?: string }): {
38
+ name: string;
39
+ iconUrl: string | null;
40
+ } {
41
+ const name =
42
+ typeof userInfo.name === "string"
43
+ ? userInfo.name.trim().slice(0, MAX_PROFILE_NAME_LENGTH)
44
+ : "";
45
+ const picture =
46
+ typeof userInfo.picture === "string" ? userInfo.picture.trim() : "";
47
+ const iconUrl =
48
+ picture.length > 0 &&
49
+ picture.length <= MAX_PROFILE_URL_LENGTH &&
50
+ isValidProfileImageUrl(picture)
51
+ ? picture
52
+ : null;
53
+ return { name, iconUrl };
54
+ }
55
+
56
+ const log = logger.child({ component: "auth.helpers" });
57
+
58
+ // Upper bound for the OAuth token-exchange / userinfo fetches so a hung provider
59
+ // can't stall the login request (the self-host runtime has no wall-clock cap).
60
+ export const OAUTH_FETCH_TIMEOUT_MS = 10_000;
61
+
62
+ /** Classify HTTP status into a coarse error kind safe to log. */
63
+ function classifyOAuthErrorKind(status: number): string {
64
+ if (status === 400) return "invalid_request";
65
+ if (status === 401) return "invalid_client";
66
+ if (status === 403) return "forbidden";
67
+ if (status === 404) return "endpoint_not_found";
68
+ if (status === 429) return "rate_limited";
69
+ if (status >= 500) return "upstream_error";
70
+ return "client_error";
71
+ }
72
+
73
+ /** Session lifetime: 30 days in seconds. */
74
+ export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
75
+
76
+ export type OAuthTokens = {
77
+ access_token: string;
78
+ refresh_token?: string;
79
+ expires_in?: number;
80
+ // OIDC ID Token (a signed JWT). For an OIDC provider (Takosumi Accounts) this
81
+ // is the primary identity assertion and carries `name`/`email`/`sub` that the
82
+ // minimal userinfo endpoint may omit; the callback verifies + reads it.
83
+ id_token?: string;
84
+ };
85
+
86
+ export type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
87
+
88
+ export { parseJsonObject, parseNonEmptyString };
89
+
90
+ export function formatAccountResponse(a: {
91
+ apId: string;
92
+ preferredUsername: string;
93
+ name: string | null;
94
+ iconUrl: string | null;
95
+ }): {
96
+ ap_id: string;
97
+ preferred_username: string;
98
+ name: string | null;
99
+ icon_url: string | null;
100
+ } {
101
+ return {
102
+ ap_id: a.apId,
103
+ preferred_username: a.preferredUsername,
104
+ name: a.name,
105
+ icon_url: a.iconUrl,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Delete a session row by its raw (cookie) id. The raw id is hashed before
111
+ * the lookup because the stored key is `sha256:<salt:rawId>`, never the raw id.
112
+ */
113
+ export async function deleteSessionSafely(
114
+ db: Database,
115
+ env: Env,
116
+ rawSessionId: string,
117
+ context: string,
118
+ ): Promise<void> {
119
+ try {
120
+ const sessionKey = await hashSessionIdForEnv(env, rawSessionId);
121
+ await db.delete(sessions).where(eq(sessions.id, sessionKey));
122
+ } catch (err) {
123
+ log.warn("Failed to delete session", {
124
+ event: "auth.session.delete_failed",
125
+ context,
126
+ error: err,
127
+ });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Invalidates any existing session, creates a fresh one, and sets the cookie.
133
+ * Centralises session-rotation logic used by both password and OAuth login.
134
+ */
135
+ export async function rotateSession(
136
+ c: HonoContext,
137
+ memberApId: string,
138
+ provider: string | null,
139
+ tokens: OAuthTokens | null,
140
+ encryptionKey: string | undefined,
141
+ rotationContext: string,
142
+ ): Promise<string> {
143
+ const db = c.get("db");
144
+
145
+ // Invalidate existing session
146
+ const existingSessionId = getCookie(c, "session");
147
+ if (existingSessionId) {
148
+ await deleteSessionSafely(db, c.env, existingSessionId, rotationContext);
149
+ deleteCookie(c, "session");
150
+ }
151
+
152
+ // Create new session with encrypted tokens.
153
+ //
154
+ // SECURITY: the raw session id is a bearer credential and only ever lives in
155
+ // the client cookie. We persist SHA-256(salt:rawId) as the row key so a
156
+ // read-only leak of the sessions table cannot be replayed. `accessToken`
157
+ // mirrors the same hashed key (it was a duplicate of the lookup id).
158
+ const sessionId = generateId();
159
+ const sessionKey = await hashSessionIdForEnv(c.env, sessionId);
160
+ const expiresAt = new Date(
161
+ Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
162
+ ).toISOString();
163
+
164
+ await db.insert(sessions).values({
165
+ id: sessionKey,
166
+ memberId: memberApId,
167
+ accessToken: sessionKey,
168
+ expiresAt,
169
+ provider,
170
+ providerAccessToken: tokens?.access_token
171
+ ? await encrypt(tokens.access_token, encryptionKey)
172
+ : null,
173
+ providerRefreshToken: tokens?.refresh_token
174
+ ? await encrypt(tokens.refresh_token, encryptionKey)
175
+ : null,
176
+ providerTokenExpiresAt: tokens?.expires_in
177
+ ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
178
+ : null,
179
+ });
180
+
181
+ // Set cookie. The cookie carries the RAW session id (the only place it
182
+ // exists); the DB stores only its salted hash. SameSite=Strict to reduce
183
+ // CSRF / cross-site leakage surface. Secure unless the instance is explicitly
184
+ // served over plain http:// — a hardcoded Secure made an http self-host
185
+ // un-loginnable (the browser never sends a Secure cookie over http), so honour
186
+ // the operator's APP_URL protocol while defaulting to Secure for https/unknown.
187
+ setCookie(c, "session", sessionId, {
188
+ httpOnly: true,
189
+ secure: !(c.env.APP_URL ?? "").startsWith("http://"),
190
+ sameSite: "Strict",
191
+ path: "/",
192
+ maxAge: SESSION_MAX_AGE_SECONDS,
193
+ });
194
+
195
+ return sessionId;
196
+ }
197
+
198
+ export function actorEndpoints(apId: string) {
199
+ return {
200
+ inbox: `${apId}/inbox`,
201
+ outbox: `${apId}/outbox`,
202
+ followersUrl: `${apId}/followers`,
203
+ followingUrl: `${apId}/following`,
204
+ };
205
+ }
206
+
207
+ /** Resolves a unique username by appending a counter on collision. */
208
+ export async function resolveUniqueUsername(
209
+ db: Database,
210
+ baseUrl: string,
211
+ baseUsername: string,
212
+ ): Promise<string> {
213
+ let username = baseUsername;
214
+ let counter = 1;
215
+ // Exclude tombstoned rows from the collision probe: a deleted account's row
216
+ // lingers (renamed handle, `deletedAt` set) only until the reaper drains it,
217
+ // and createActor revives that row on re-registration. Treating a tombstone
218
+ // as a live collision would needlessly force a freed handle onto a suffixed
219
+ // alias until the reaper runs (#9).
220
+ while (
221
+ await db
222
+ .select({ apId: actors.apId })
223
+ .from(actors)
224
+ .where(
225
+ and(eq(actors.apId, actorApId(baseUrl, username)), notDeleted(actors)),
226
+ )
227
+ .get()
228
+ ) {
229
+ username = `${baseUsername}${counter}`;
230
+ counter++;
231
+ }
232
+ return username;
233
+ }
234
+
235
+ export async function createActor(
236
+ db: Database,
237
+ env: Env,
238
+ opts: {
239
+ username: string;
240
+ name: string;
241
+ iconUrl?: string | null;
242
+ takosUserId: string;
243
+ role: string;
244
+ ownerActorApId?: string | null;
245
+ },
246
+ ) {
247
+ const apId = actorApId(env.APP_URL, opts.username);
248
+ const { publicKeyPem, privateKeyPem } = await generateKeyPair();
249
+
250
+ // `apId` is deterministic from the username and is the PRIMARY KEY, so
251
+ // re-registering a freed handle resolves to the SAME apId that a tombstone
252
+ // row may still hold (account deletion renames `preferredUsername` to a
253
+ // sentinel and sets `deletedAt`, but keeps `apId` for the federation Delete
254
+ // signer). A plain insert would PK-collide while that tombstone lingers, so
255
+ // we first REVIVE any tombstone on this apId: clear `deletedAt`, restore the
256
+ // requested handle, and rotate to fresh signing keys + identity so no
257
+ // scrubbed data or stale key material from the deleted account survives the
258
+ // re-registration (#9). Only a tombstoned (deletedAt IS NOT NULL) row is ever
259
+ // revived; a LIVE collision is impossible here because every caller probes
260
+ // for a live collision (notDeleted) before reaching createActor.
261
+ const tombstone = await db
262
+ .select({ apId: actors.apId })
263
+ .from(actors)
264
+ .where(and(eq(actors.apId, apId), isNotNull(actors.deletedAt)))
265
+ .get();
266
+
267
+ if (tombstone) {
268
+ // The tombstone preserved the OLD signing key so any still-queued
269
+ // Delete(actor) delivery jobs could sign with it at send time. Reviving the
270
+ // row below rotates to a FRESH key + identity, which would make those
271
+ // in-flight Delete jobs sign with the wrong key (invalid signature) or
272
+ // target a now-live actor. Cancel the stranded Delete FIRST — drop its
273
+ // pending/retry_wait/failed delivery_queue rows and the preserved Delete
274
+ // activity rows — so re-registration starts clean and no half-signed Delete
275
+ // is sent (#revive).
276
+ await cancelTombstoneDelete(db, apId);
277
+
278
+ return await db
279
+ .update(actors)
280
+ .set({
281
+ type: "Person",
282
+ preferredUsername: opts.username,
283
+ name: opts.name,
284
+ summary: null,
285
+ iconUrl: opts.iconUrl ?? null,
286
+ headerUrl: null,
287
+ ...actorEndpoints(apId),
288
+ publicKeyPem,
289
+ privateKeyPem,
290
+ takosUserId: opts.takosUserId,
291
+ followerCount: 0,
292
+ followingCount: 0,
293
+ postCount: 0,
294
+ isPrivate: 0,
295
+ role: opts.role,
296
+ fieldsJson: "[]",
297
+ alsoKnownAsJson: "[]",
298
+ movedTo: null,
299
+ ownerActorApId: opts.ownerActorApId ?? null,
300
+ deletedAt: null,
301
+ })
302
+ .where(eq(actors.apId, apId))
303
+ .returning()
304
+ .get();
305
+ }
306
+
307
+ return await db
308
+ .insert(actors)
309
+ .values({
310
+ apId,
311
+ type: "Person",
312
+ preferredUsername: opts.username,
313
+ name: opts.name,
314
+ iconUrl: opts.iconUrl ?? null,
315
+ ...actorEndpoints(apId),
316
+ publicKeyPem,
317
+ privateKeyPem,
318
+ takosUserId: opts.takosUserId,
319
+ role: opts.role,
320
+ ownerActorApId: opts.ownerActorApId ?? null,
321
+ })
322
+ .returning()
323
+ .get();
324
+ }
325
+
326
+ export async function createActorFromOAuth(
327
+ db: Database,
328
+ env: Env,
329
+ userInfo: {
330
+ id: string;
331
+ name: string;
332
+ email?: string;
333
+ picture?: string;
334
+ username?: string;
335
+ },
336
+ providerUserId: string,
337
+ ) {
338
+ const baseUsername =
339
+ userInfo.username ||
340
+ userInfo.name.toLowerCase().replace(/[^a-z0-9]/g, "") ||
341
+ "user";
342
+ const username = await resolveUniqueUsername(db, env.APP_URL, baseUsername);
343
+ const result = await db.select({ count: count() }).from(actors).get();
344
+ const actorCount = result?.count ?? 0;
345
+
346
+ // Owner-slot protection. yurucommu is single-tenant: the FIRST actor becomes
347
+ // `owner` and controls the instance. Without a pin, whoever completes the OAuth
348
+ // flow first wins that slot — on an OIDC-seeded Capsule a third party who can
349
+ // obtain a valid token for the materialized client could race the operator.
350
+ // When `OIDC_OWNER_SUB` (the operator's pinned Takosumi subject) is configured,
351
+ // ONLY that subject may take the owner slot; any other first-login is REFUSED
352
+ // (not downgraded to member — that would consume the owner slot and lock the
353
+ // real operator out forever). With no pin set we keep first-login-owner but warn.
354
+ if (actorCount === 0) {
355
+ const ownerSub =
356
+ parseNonEmptyString(env.OIDC_OWNER_SUB) ??
357
+ parseNonEmptyString(env.TAKOSUMI_ACCOUNTS_OWNER_SUB);
358
+ if (ownerSub) {
359
+ if (providerUserId !== ownerSub) {
360
+ log.warn(
361
+ "refused OAuth owner creation: subject does not match OIDC_OWNER_SUB pin",
362
+ );
363
+ return null;
364
+ }
365
+ } else {
366
+ log.warn(
367
+ "first OAuth login is taking the owner slot with no OIDC_OWNER_SUB pin set; " +
368
+ "set OIDC_OWNER_SUB to the operator's subject to prevent an owner-slot race",
369
+ );
370
+ }
371
+ }
372
+
373
+ // Member auto-provisioning is CLOSED by default on this single-user instance
374
+ // (nodeinfo advertises openRegistrations:false / singleUser:true). Once the
375
+ // owner exists, a brand-new external subject must NOT be able to self-provision
376
+ // a member account + session just by completing the issuer's OAuth flow — on a
377
+ // shared issuer (Google / X / a multi-tenant Takosumi Accounts) that would let
378
+ // the entire issuer population log into someone else's private instance and
379
+ // post/DM/federate under the operator's domain. Allow a non-first create ONLY
380
+ // for the pinned owner subject (defensive re-bind) or an explicitly allowlisted
381
+ // subject. Owner re-login never reaches here (resolved by takosUserId first),
382
+ // and out-of-band sub-accounts use POST /accounts (takosUserId "local:<name>").
383
+ if (actorCount > 0) {
384
+ const ownerSub =
385
+ parseNonEmptyString(env.OIDC_OWNER_SUB) ??
386
+ parseNonEmptyString(env.TAKOSUMI_ACCOUNTS_OWNER_SUB);
387
+ const allowed = new Set(
388
+ (parseNonEmptyString(env.OIDC_ALLOWED_SUBS) ?? "")
389
+ .split(",")
390
+ .map((s) => s.trim())
391
+ .filter(Boolean),
392
+ );
393
+ if (ownerSub) allowed.add(ownerSub);
394
+ if (!allowed.has(providerUserId)) {
395
+ log.warn(
396
+ "refused OAuth member auto-provisioning: registration is closed " +
397
+ "(subject not the owner pin and not in OIDC_ALLOWED_SUBS)",
398
+ );
399
+ return null;
400
+ }
401
+ }
402
+
403
+ const { name, iconUrl } = sanitizeOAuthProfile(userInfo);
404
+ return await createActor(db, env, {
405
+ username,
406
+ name,
407
+ iconUrl: iconUrl ?? undefined,
408
+ takosUserId: providerUserId,
409
+ role: actorCount === 0 ? "owner" : "member",
410
+ });
411
+ }
412
+
413
+ export function lockoutErrorResponse(retryAfterSeconds: number): {
414
+ error: string;
415
+ retry_after: number;
416
+ } {
417
+ return {
418
+ error: "Too many failed login attempts. Please try again later.",
419
+ retry_after: retryAfterSeconds,
420
+ };
421
+ }
422
+
423
+ /** Exchange an OAuth authorization code for tokens. Returns null on failure. */
424
+ export async function exchangeOAuthToken(
425
+ providerId: string,
426
+ code: string,
427
+ codeVerifier: string | undefined,
428
+ env: Env,
429
+ provider: { tokenUrl: string; supportsPkce: boolean },
430
+ ): Promise<OAuthTokens | null> {
431
+ const { clientId, clientSecret } = getClientCredentials(env, providerId);
432
+ const redirectUri = `${env.APP_URL}/api/auth/callback/${providerId}`;
433
+
434
+ const tokenBody: Record<string, string> = {
435
+ grant_type: "authorization_code",
436
+ code,
437
+ redirect_uri: redirectUri,
438
+ client_id: clientId,
439
+ };
440
+ // A PUBLIC client (Takosumi's auto-materialized OIDC client: auth method
441
+ // "none", no secret) authenticates with PKCE alone — sending an empty
442
+ // client_secret would be rejected as malformed client auth. Only include it
443
+ // for a confidential client (secret configured).
444
+ if (clientSecret) {
445
+ tokenBody.client_secret = clientSecret;
446
+ }
447
+
448
+ if (provider.supportsPkce && codeVerifier) {
449
+ tokenBody.code_verifier = codeVerifier;
450
+ }
451
+
452
+ const tokenHeaders: Record<string, string> = {
453
+ "Content-Type": "application/x-www-form-urlencoded",
454
+ };
455
+
456
+ if (providerId === "x") {
457
+ tokenHeaders["Authorization"] = `Basic ${btoa(
458
+ `${clientId}:${clientSecret}`,
459
+ )}`;
460
+ delete tokenBody.client_secret;
461
+ }
462
+
463
+ const tokenUrl = provider.tokenUrl;
464
+ const requestInit: RequestInit = {
465
+ method: "POST",
466
+ headers: tokenHeaders,
467
+ body: new URLSearchParams(tokenBody),
468
+ };
469
+
470
+ // Bound the upstream token exchange (and its body read) so a hung or
471
+ // trickling provider can't stall the login request forever — the self-host
472
+ // runtime has no wall-clock cap. Mirrors fetchJwks' AbortController pattern;
473
+ // the timer stays armed through res.json() so a slow body also aborts.
474
+ const controller = new AbortController();
475
+ const timer = setTimeout(() => controller.abort(), OAUTH_FETCH_TIMEOUT_MS);
476
+ try {
477
+ const res = await fetch(tokenUrl, {
478
+ ...requestInit,
479
+ signal: controller.signal,
480
+ });
481
+
482
+ if (!res.ok) {
483
+ // Do NOT log the raw response body. Upstream OAuth providers can
484
+ // echo the supplied `client_secret` / `code` / refresh tokens on
485
+ // validation failures. Log structured fields only.
486
+ log.error("Token exchange failed", {
487
+ event: "auth.oauth.token_exchange_failed",
488
+ provider: providerId,
489
+ status: res.status,
490
+ statusText: res.statusText,
491
+ error_kind: classifyOAuthErrorKind(res.status),
492
+ tokenUrl: provider.tokenUrl,
493
+ });
494
+ return null;
495
+ }
496
+
497
+ return (await res.json()) as OAuthTokens;
498
+ } finally {
499
+ clearTimeout(timer);
500
+ }
501
+ }
502
+
503
+ /** Look up an existing actor by provider user ID, or create a new one. */
504
+ export async function findOrCreateOAuthActor(
505
+ db: Database,
506
+ env: Env,
507
+ providerId: string,
508
+ userInfo: {
509
+ id: string;
510
+ name: string;
511
+ email?: string;
512
+ picture?: string;
513
+ username?: string;
514
+ },
515
+ ) {
516
+ // Namespace EVERY provider's subject by its provider id (`takos:<sub>`,
517
+ // `google:<id>`, `x:<id>`). Previously the `takos` subject was stored verbatim
518
+ // — the only un-namespaced source — so a trusted-but-misconfigured/compromised
519
+ // issuer that emitted sub="password:owner" or "local:tako" would resolve the
520
+ // get-or-create (keyed solely on takosUserId) to the reserved, higher-privileged
521
+ // password-owner / local sub-account row instead of provisioning a fresh actor,
522
+ // bypassing the owner-pin guard (which only runs on the CREATE path). Prefixing
523
+ // it confines the takos subject space so it can never overlap the reserved
524
+ // password:/local:/google:/x: keys. (Migration 0016 prefixes existing rows.)
525
+ const providerUserId = `${providerId}:${userInfo.id}`;
526
+
527
+ let actorData = await db
528
+ .select()
529
+ .from(actors)
530
+ .where(eq(actors.takosUserId, providerUserId))
531
+ .get();
532
+
533
+ if (!actorData) {
534
+ // createActorFromOAuth returns null when the owner-slot pin refuses this
535
+ // subject; normalize to undefined so the caller's `if (!actorData)` guard
536
+ // (-> actor_creation_failed) covers both the refusal and a missing row.
537
+ try {
538
+ actorData =
539
+ (await createActorFromOAuth(db, env, userInfo, providerUserId)) ??
540
+ undefined;
541
+ } catch (e) {
542
+ // Lost a concurrent first-login race for the same subject: the UNIQUE on
543
+ // takosUserId fired because the other request already created the actor.
544
+ // Re-resolve the winner's row instead of 500ing the loser (idempotent
545
+ // get-or-create). Re-throw anything that isn't the unique conflict.
546
+ if (!isUniqueConstraintError(e)) throw e;
547
+ actorData = await db
548
+ .select()
549
+ .from(actors)
550
+ .where(eq(actors.takosUserId, providerUserId))
551
+ .get();
552
+ }
553
+ } else {
554
+ const { name, iconUrl } = sanitizeOAuthProfile(userInfo);
555
+ await db
556
+ .update(actors)
557
+ .set({
558
+ name,
559
+ ...(iconUrl ? { iconUrl } : {}),
560
+ })
561
+ .where(eq(actors.apId, actorData.apId))
562
+ .run();
563
+ }
564
+
565
+ return actorData;
566
+ }