@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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
|
+
}
|