diff --git a/.env.example b/.env.example index b47cd96..7a4e77f 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # Global Settings NODE_ENV=development JWT_SECRET="your_common_jwt_secret_key" -DATABASE_URL="postgresql://username:password@host:port/database" +DATABASE_URL="postgresql://app_runtime_login:password@host:port/database" # Frontend Configuration (Next.js) FRONTEND_URL="http://localhost:3000" @@ -15,17 +15,13 @@ NEXT_PUBLIC_MEMBER_API_URL="http://localhost:8788" # Community API Configuration (Hono / Wrangler) COMMUNITY_URL="http://localhost:8787" BETTER_AUTH_SECRET="a_very_long_and_secure_random_string_32_chars" -SUPABASE_ID="your_supabase_id" -SUPABASE_SECRET_KEY="your_supabase_secret_key" DISCORD_TOKEN="your_discord_token" DISCORD_GUILD_ID="your_discord_guild_id" # Member (backend) API Configuration (Hono / Wrangler) DEV_USER_ID="replace_with_an_existing_better_auth_user_id" -GITHUB_CLIENT_ID="your_github_client_id" -GITHUB_CLIENT_SECRET="your_github_client_secret" DISCORD_CLIENT_ID="your_discord_client_id" DISCORD_CLIENT_SECRET="your_discord_client_secret" # Wrangler Hyperdrive Local Connection Emulation String (Secure, ignored by git) -CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://postgres:your_password@db.your_project_id.supabase.co:5432/postgres" +CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://app_runtime_login:your_password@your_postgres_host:5432/postgres" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4cddc8..6e4d3b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,30 @@ concurrency: cancel-in-progress: true jobs: + database: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: supabase/setup-cli@v1 + with: + # Pinned: resolving "latest" queries the GitHub API unauthenticated, + # which rate-limits as soon as a few pull requests build at once. + version: 2.101.0 + # Replays every migration into a fresh database, so a migration that only + # works against an already-populated schema fails here instead of in a + # maintenance window. + - name: Start the local database + run: supabase db start + # The RLS boundary and the directory allowlist are enforced in SQL, so the + # pgTAP suites under supabase/tests are the only thing that proves them. + - name: Run pgTAP suites + run: supabase test db + - name: Stop the local database + if: always() + # Never turn a teardown problem into the reported failure. + continue-on-error: true + run: supabase stop --no-backup + member: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index ec3c96d..b0f4b24 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,23 @@ cd ../frontend && npm install cd .. ``` -### 2. 環境変数(`.env`)の設定 -リポジトリのルートディレクトリに存在する `.env` ファイル(Git管理外)を作成・編集します。テンプレートとして `.env.example` をコピーして作成してください。 +### 2. 環境変数の設定 +各Workerには必要な秘密だけを渡します。ルートの `.env` をWorkerへ丸ごとコピーしないでください。 ```bash cp .env.example .env +cp community/.dev.vars.example community/.dev.vars +cp member/.dev.vars.example member/.dev.vars ``` **【重要】Hyperdrive ローカルエミュレーション設定** -ローカル開発時(`wrangler dev`)にも、Wrangler がローカルでデータベース接続プール(Hyperdrive)をエミュレートするため、以下の環境変数をルートの `.env` に設定する必要があります。 +ローカル開発時(`wrangler dev`)は、Hyperdriveの接続先をWranglerプロセスの環境変数で上書きします。`.dev.vars` の通常のWorker bindingとは別なので、APIを起動する各シェルで設定してください。 -```ini -# Wrangler Hyperdrive Local Connection Emulation String -CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://username:password@host:5432/database" +```bash +export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://app_runtime_login:password@host:5432/database" ``` -*※ローカル開発でご自身の PostgreSQL(Docker 等)を使用する場合は、上記のアドレスをローカルDBのものに書き換えてください。* + +`app_runtime_login` はDB運用runbookで作る非superuser・非`BYPASSRLS`の専用ロールです。アプリケーションから`postgres`/owner資格情報を使わないでください。 ### 3. 各プロジェクトのローカル起動 それぞれのディレクトリで開発サーバーを起動します。 @@ -120,6 +122,6 @@ npm run deploy ## 🛠️ 開発規約 (Development Conventions) -* **Database migrations**: 適用履歴の正本は `/supabase/migrations` です。`supabase migration new ` で作成し、`/share/drizzle/schema.ts` を同じPRで同期してください。Drizzleの生成SQLはレビュー補助であり、直接適用しません。詳細は [`docs/database-migrations.md`](docs/database-migrations.md) を参照してください。 +* **PostgreSQL & Drizzle**: 実行時は Supabase SDK/Data API を使わず、Workerでは Cloudflare Hyperdrive のプールを介し、ローカルでは `DATABASE_URL` を使って `pg` + Drizzle で PostgreSQL へ接続します。スキーマ定義は `/share/drizzle/schema.ts` で共有し、変更時は PostgreSQL migration と同じPRで同期してください。現在のDB適用履歴とポータビリティ方針は [`docs/database-migrations.md`](docs/database-migrations.md) を参照してください。 * **OpenAPI 仕様の遵守**: API のルートやバリデーションスキーマの定義には `@hono/zod-openapi` を厳格に使用し、コードと Swagger UI(`/ui` エンドポイント)が常に同期するように実装してください。 * **型安全の確保**: バックエンドとフロントエンド間の API 通信は、Hono Client (`hc`) を用いて `frontend/src/lib/client.ts` でクライアント化され、エンドツーエンドでの型安全性が保証されています。 diff --git a/community/.dev.vars.example b/community/.dev.vars.example index 0bed29a..6e444b8 100644 --- a/community/.dev.vars.example +++ b/community/.dev.vars.example @@ -1,14 +1,20 @@ # Database -DATABASE_URL=postgresql://user:password@host:port/dbname +DATABASE_URL=postgresql://app_runtime_login:password@host:port/dbname # Better Auth Configuration BETTER_AUTH_SECRET=your_secure_random_32_char_string +JWT_SECRET=use_a_different_secure_random_32_char_string BETTER_AUTH_URL=http://localhost:8787 COMMUNITY_URL=http://localhost:8787 FRONTEND_URL=http://localhost:3000 -# Social Providers -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret +# Social Provider (Discord is the only login provider) DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_SECRET=your_discord_client_secret + +# Discord guild verification +DISCORD_TOKEN=your_discord_bot_token +DISCORD_GUILD_ID=your_guild_id + +# Optional database-backed local identity +DEV_USER_ID=your_local_user_id diff --git a/community/.env.example b/community/.env.example index 59130c1..37a188c 100644 --- a/community/.env.example +++ b/community/.env.example @@ -1,15 +1,14 @@ # Database -DATABASE_URL=postgresql://user:password@host:port/dbname +DATABASE_URL=postgresql://app_runtime_login:password@host:port/dbname # Better Auth Configuration BETTER_AUTH_SECRET=your_secure_random_32_char_string +JWT_SECRET=use_a_different_secure_random_32_char_string BETTER_AUTH_URL=http://localhost:8787 # The base URL of your auth server COMMUNITY_URL=http://localhost:8787 FRONTEND_URL=http://localhost:3000 -# Social Providers -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret +# Social Provider (Discord is the only login provider) DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_SECRET=your_discord_client_secret diff --git a/community/package.json b/community/package.json index 759879e..925cf1a 100644 --- a/community/package.json +++ b/community/package.json @@ -5,7 +5,7 @@ "node": ">=22.0.0" }, "scripts": { - "dev": "cp ../.env .dev.vars && wrangler dev", + "dev": "wrangler dev", "deploy": "wrangler deploy --minify", "test": "tsx --test \"src/**/*.test.ts\"", "test:discord:live": "RUN_DISCORD_LIVE_TESTS=1 tsx --test src/lib/community/discord/live-send.test.ts", diff --git a/community/src/api_v0/message/reactions.test.ts b/community/src/api_v0/message/reactions.test.ts index 37422f5..6ca0587 100644 --- a/community/src/api_v0/message/reactions.test.ts +++ b/community/src/api_v0/message/reactions.test.ts @@ -63,12 +63,20 @@ const linkedUsers: LinkedReactionUser[] = [ email: 'taro-account@example.com', memberId: 'member-1', memberName: '山田 太郎', + memberStatus: 'active', displayGrade: 'B2', studentId: 'S001', studentEmail: 'taro@example.edu', emergencyContact: '090-0000-0000', insurance: true, someAllergy: false, + allergyDetails: null, + skills: ['TypeScript'], + interests: ['Robotics'], + currentActivities: 'Robot controller', + bio: 'Embedded developer', + discordNickname: 'たろう', + discordRoles: ['Member', 'Developer'], }, ]; @@ -100,12 +108,20 @@ test('buildMessageReactionSummary includes reaction users, linked personal infor email: 'taro-account@example.com', memberId: 'member-1', memberName: '山田 太郎', + memberStatus: 'active', displayGrade: 'B2', studentId: 'S001', studentEmail: 'taro@example.edu', emergencyContact: '090-0000-0000', insurance: true, someAllergy: false, + allergyDetails: null, + skills: ['TypeScript'], + interests: ['Robotics'], + currentActivities: 'Robot controller', + bio: 'Embedded developer', + discordNickname: 'たろう', + discordRoles: ['Member', 'Developer'], reactions: ['✅'], }); assert.deepEqual(summary.reactions[0].users[1], { @@ -118,12 +134,20 @@ test('buildMessageReactionSummary includes reaction users, linked personal infor email: null, memberId: null, memberName: null, + memberStatus: null, displayGrade: null, studentId: null, studentEmail: null, emergencyContact: null, insurance: null, someAllergy: null, + allergyDetails: null, + skills: [], + interests: [], + currentActivities: null, + bio: null, + discordNickname: null, + discordRoles: [], reactions: ['✅'], }); assert.deepEqual(summary.members.find(member => member.discordUserId === '423456789012345678')?.reactions, ['✅', '🍱']); diff --git a/community/src/api_v0/message/reactions.ts b/community/src/api_v0/message/reactions.ts index 11f561a..588f68e 100644 --- a/community/src/api_v0/message/reactions.ts +++ b/community/src/api_v0/message/reactions.ts @@ -1,4 +1,5 @@ import type { DiscordMessage, DiscordReactionUser } from "../../lib/community/type"; +import type { MemberStatus } from "../../../../share/drizzle/schema"; export type EventMessageRecord = { id: string; @@ -11,19 +12,27 @@ export type EventMessageRecord = { }; export type LinkedReactionUser = { - discordUserId: string | null; + discordUserId: string; userId: string; userName: string; - displayName: string | null; email: string; memberId: string | null; memberName: string | null; + memberStatus: MemberStatus | null; + displayName: string | null; displayGrade: string | null; studentId: string | null; studentEmail: string | null; emergencyContact: string | null; insurance: boolean | null; someAllergy: boolean | null; + allergyDetails: string | null; + skills: string[] | null; + interests: string[] | null; + currentActivities: string | null; + bio: string | null; + discordNickname: string | null; + discordRoles: string[] | null; }; export type ReactionUsersByEmoji = { @@ -42,12 +51,20 @@ export type ReactionMember = { email: string | null; memberId: string | null; memberName: string | null; + memberStatus: MemberStatus | null; displayGrade: string | null; studentId: string | null; studentEmail: string | null; emergencyContact: string | null; insurance: boolean | null; someAllergy: boolean | null; + allergyDetails: string | null; + skills: string[]; + interests: string[]; + currentActivities: string | null; + bio: string | null; + discordNickname: string | null; + discordRoles: string[]; reactions: string[]; }; @@ -64,9 +81,7 @@ export const buildMessageReactionSummary = ( linkedUsers: LinkedReactionUser[], ) => { const linkedUserMap = new Map( - linkedUsers - .filter((user) => user.discordUserId) - .map((user) => [user.discordUserId, user]) + linkedUsers.map((user) => [user.discordUserId, user]) ); const memberMap = new Map(); @@ -83,12 +98,20 @@ export const buildMessageReactionSummary = ( email: linkedUser?.email ?? null, memberId: linkedUser?.memberId ?? null, memberName: linkedUser?.memberName ?? null, + memberStatus: linkedUser?.memberStatus ?? null, displayGrade: linkedUser?.displayGrade ?? null, studentId: linkedUser?.studentId ?? null, studentEmail: linkedUser?.studentEmail ?? null, emergencyContact: linkedUser?.emergencyContact ?? null, insurance: linkedUser?.insurance ?? null, someAllergy: linkedUser?.someAllergy ?? null, + allergyDetails: linkedUser?.allergyDetails ?? null, + skills: linkedUser?.skills ?? [], + interests: linkedUser?.interests ?? [], + currentActivities: linkedUser?.currentActivities ?? null, + bio: linkedUser?.bio ?? null, + discordNickname: linkedUser?.discordNickname ?? null, + discordRoles: linkedUser?.discordRoles ?? [], reactions: [reaction.emoji], }; diff --git a/community/src/api_v0/message/schema.ts b/community/src/api_v0/message/schema.ts index 724e470..f9f9e26 100644 --- a/community/src/api_v0/message/schema.ts +++ b/community/src/api_v0/message/schema.ts @@ -49,12 +49,20 @@ export const reactionMemberSchema = z.object({ email: z.string().nullable(), memberId: z.string().nullable(), memberName: z.string().nullable(), + memberStatus: z.enum(["pending", "active", "rejected", "withdrawn"]).nullable(), displayGrade: z.string().nullable(), studentId: z.string().nullable(), studentEmail: z.string().nullable(), emergencyContact: z.string().nullable(), insurance: z.boolean().nullable(), someAllergy: z.boolean().nullable(), + allergyDetails: z.string().nullable(), + skills: z.array(z.string()), + interests: z.array(z.string()), + currentActivities: z.string().nullable(), + bio: z.string().nullable(), + discordNickname: z.string().nullable(), + discordRoles: z.array(z.string()), reactions: z.array(z.string()), }).openapi("ReactionMember") diff --git a/community/src/api_v0/message/service.test.ts b/community/src/api_v0/message/service.test.ts index 49b1445..aab2bb3 100644 --- a/community/src/api_v0/message/service.test.ts +++ b/community/src/api_v0/message/service.test.ts @@ -36,7 +36,7 @@ const createContext = (overrides: { const community = overrides.community ?? { sendMessage: async () => ({ messageId: '323456789012345678' }), }; - const db = overrides.db ?? { + const transactionDatabase = overrides.db ?? { insert: () => ({ values: (values: unknown) => { valuesCalls.push(values); @@ -52,6 +52,17 @@ const createContext = (overrides: { }, }), }; + let openTransactions = 0; + const db = { + transaction: async (operation: (tx: unknown) => Promise) => { + openTransactions += 1; + try { + return await operation(transactionDatabase); + } finally { + openTransactions -= 1; + } + }, + }; return { c: { @@ -72,19 +83,24 @@ const createContext = (overrides: { } as any, jsonCalls, valuesCalls, + isTransactionOpen: () => openTransactions > 0, }; }; test('createMessageService sends an event notification to Discord and saves the event message', async () => { const sendMessageCalls: unknown[] = []; - const { c, valuesCalls } = createContext({ + let isTransactionOpen = () => false; + const testContext = createContext({ community: { sendMessage: async (input: unknown) => { + assert.equal(isTransactionOpen(), false); sendMessageCalls.push(input); return { messageId: '323456789012345678' }; }, }, }); + const { c, valuesCalls } = testContext; + isTransactionOpen = testContext.isTransactionOpen; const response = await (createMessageService as any)(c); diff --git a/community/src/api_v0/message/service.ts b/community/src/api_v0/message/service.ts index 3496518..a4e8d0f 100644 --- a/community/src/api_v0/message/service.ts +++ b/community/src/api_v0/message/service.ts @@ -7,8 +7,16 @@ import { getMessageRoute, listMessagesRoute } from "./schema" -import { eventMessages, grades, members, user as authUsers } from "../../../../share/drizzle/schema" -import { desc, eq, inArray } from "drizzle-orm" +import { + communityIdentities, + communityMemberships, + eventMessages, + grades, + memberDirectoryProfiles, + members, + user as authUsers, +} from "../../../../share/drizzle/schema" +import { and, desc, eq, inArray } from "drizzle-orm" import { buildMessageReactionSummary, collectDiscordUserIds, @@ -39,7 +47,7 @@ export const createMessageService: RouteHandler tx .insert(eventMessages) .values({ channelId: body.channelId, @@ -47,7 +55,7 @@ export const createMessageService: RouteHandler tx .select() .from(eventMessages) - .orderBy(desc(eventMessages.createdAt)); + .orderBy(desc(eventMessages.createdAt))); return c.json(messages, 200); }; @@ -85,11 +93,11 @@ export const getMessageService: RouteHandler return c.json({ error: "Forbidden" }, 403); } - const [eventMessage] = await c.get("db") + const [eventMessage] = await c.get("db").transaction((tx) => tx .select() .from(eventMessages) .where(eq(eventMessages.id, c.req.param("id"))) - .limit(1); + .limit(1)); if (!eventMessage) { return c.json({ error: "Event message not found" }, 404); @@ -107,11 +115,11 @@ export const getMessageReactionsService: RouteHandler tx .select() .from(eventMessages) .where(eq(eventMessages.id, c.req.param("id"))) - .limit(1); + .limit(1)); if (!eventMessage) { return c.json({ error: "Event message not found" }, 404); @@ -134,26 +142,43 @@ export const getMessageReactionsService: RouteHandler 0 - ? await db + ? await db.transaction((tx) => tx .select({ - discordUserId: authUsers.discordUserId, + discordUserId: communityIdentities.providerAccountId, userId: authUsers.id, userName: authUsers.name, - displayName: authUsers.displayName, email: authUsers.email, memberId: authUsers.memberId, memberName: members.name, + memberStatus: members.memberStatus, + displayName: memberDirectoryProfiles.displayName, displayGrade: grades.displayGrade, studentId: members.studentId, studentEmail: members.studentEmail, emergencyContact: members.emergencyContact, insurance: members.insurance, someAllergy: members.someAllergy, + allergyDetails: members.allergyDetails, + skills: memberDirectoryProfiles.skills, + interests: memberDirectoryProfiles.interests, + currentActivities: memberDirectoryProfiles.currentActivities, + bio: memberDirectoryProfiles.bio, + discordNickname: communityMemberships.nickname, + discordRoles: communityMemberships.roleNames, }) - .from(authUsers) + .from(communityIdentities) + .innerJoin(authUsers, eq(communityIdentities.userId, authUsers.id)) .leftJoin(members, eq(authUsers.memberId, members.memberId)) .leftJoin(grades, eq(members.grade, grades.id)) - .where(inArray(authUsers.discordUserId, discordUserIds)) + .leftJoin(memberDirectoryProfiles, eq(members.memberId, memberDirectoryProfiles.memberId)) + .leftJoin(communityMemberships, and( + eq(communityMemberships.identityId, communityIdentities.identityId), + eq(communityMemberships.communityId, c.env.DISCORD_GUILD_ID), + )) + .where(and( + eq(communityIdentities.provider, "discord"), + inArray(communityIdentities.providerAccountId, discordUserIds), + ))) : []; const summary = buildMessageReactionSummary( @@ -163,6 +188,7 @@ export const getMessageReactionsService: RouteHandler() .openapi(getUserByIdRoute, getUserByIdService) .openapi(updateUserByIdRoute, updateUserByIdService) .openapi(deleteUserByIdRoute, deleteUserByIdService) - .route("/role", userDetailRoleRouter); diff --git a/community/src/api_v0/user/id/service.ts b/community/src/api_v0/user/id/service.ts index 7914279..f90b682 100644 --- a/community/src/api_v0/user/id/service.ts +++ b/community/src/api_v0/user/id/service.ts @@ -14,7 +14,11 @@ export const getUserByIdService: RouteHandler tx + .select() + .from(user) + .where(eq(user.id, userId)) + .limit(1)); if (result.length === 0) { return c.json({ message: "User not found" }, 404); @@ -35,13 +39,13 @@ export const updateUserByIdService: RouteHandler tx.update(user) .set({ ...body, updatedAt: new Date() }) .where(eq(user.id, userId)) - .returning(); + .returning()); if (updated.length === 0) { return c.json({ message: "User not found" }, 404); @@ -60,7 +64,7 @@ export const deleteUserByIdService: RouteHandler tx.delete(user).where(eq(user.id, userId))); return c.body(null, 204); }; diff --git a/community/src/api_v0/user/me/identity/router.ts b/community/src/api_v0/user/me/identity/router.ts new file mode 100644 index 0000000..c4a5b42 --- /dev/null +++ b/community/src/api_v0/user/me/identity/router.ts @@ -0,0 +1,7 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { AppContext } from '../../../../core/types'; +import { verifyDiscordIdentityRoute } from './schema'; +import { verifyDiscordIdentityService } from './service'; + +export const userIdentityRouter = new OpenAPIHono() + .openapi(verifyDiscordIdentityRoute, verifyDiscordIdentityService); diff --git a/community/src/api_v0/user/me/identity/schema.ts b/community/src/api_v0/user/me/identity/schema.ts new file mode 100644 index 0000000..314b490 --- /dev/null +++ b/community/src/api_v0/user/me/identity/schema.ts @@ -0,0 +1,57 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +const errorSchema = z.object({ + code: z.string(), + message: z.string(), +}).openapi('IdentityVerificationError'); + +const verifiedDiscordIdentitySchema = z.object({ + identityId: z.string().uuid(), + provider: z.literal('discord'), + providerAccountId: z.string(), + username: z.string(), + providerDisplayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + oauthVerifiedAt: z.string(), + membership: z.object({ + communityId: z.string(), + membershipStatus: z.literal('member'), + nickname: z.string().nullable(), + roleIds: z.array(z.string()), + roleNames: z.array(z.string()), + verifiedAt: z.string(), + }), +}).openapi('VerifiedDiscordIdentity'); + +export const verifyDiscordIdentityRoute = createRoute({ + method: 'post', + path: '/discord/verify', + responses: { + 200: { + description: 'Discord account and target guild membership verified', + content: { + 'application/json': { + schema: verifiedDiscordIdentitySchema, + }, + }, + }, + 401: { + description: 'Application or provider authentication is required', + content: { 'application/json': { schema: errorSchema } }, + }, + 409: { + description: 'Discord account is not linked or does not match the current user', + content: { 'application/json': { schema: errorSchema } }, + }, + 412: { + description: 'The linked Discord user is not a member of the target guild', + content: { 'application/json': { schema: errorSchema } }, + }, + 502: { + description: 'Discord could not be reached or returned an invalid response', + content: { 'application/json': { schema: errorSchema } }, + }, + }, +}); + +export type VerifiedDiscordIdentity = z.infer; diff --git a/community/src/api_v0/user/me/identity/service.test.ts b/community/src/api_v0/user/me/identity/service.test.ts new file mode 100644 index 0000000..07b6e14 --- /dev/null +++ b/community/src/api_v0/user/me/identity/service.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CommunityProviderError } from '../../../../lib/community/error'; +import { createVerifyDiscordIdentityService } from './service'; + +const fixedNow = '2026-07-16T04:00:00.000Z'; +const appUser = { + id: 'user-1', + name: 'Test User', + memberId: null, + role: 'user' as const, +}; +const linkedAccount = { + id: 'account-row-1', + accountId: '123456789012345678', +}; +const oauthUser = { + id: linkedAccount.accountId, + username: 'test-user', + globalName: 'Test User', + avatarUrl: null, +}; +const guildMembership = { + userId: linkedAccount.accountId, + nickname: '部員名', + roles: [{ id: '323456789012345678', name: '部員' }], +}; +const persisted = { + identityId: '11111111-1111-4111-8111-111111111111', + provider: 'discord' as const, + providerAccountId: linkedAccount.accountId, + username: oauthUser.username, + providerDisplayName: oauthUser.globalName, + avatarUrl: null, + oauthVerifiedAt: fixedNow, + membership: { + communityId: '223456789012345678', + membershipStatus: 'member' as const, + nickname: guildMembership.nickname, + roleIds: ['323456789012345678'], + roleNames: ['部員'], + verifiedAt: fixedNow, + }, +}; + +type JsonResponse = { body: unknown; status: number }; +const asJsonResponse = (response: unknown): JsonResponse => response as JsonResponse; + +const createContext = (community: { + getGuildMembership(userId: string): Promise; +}) => ({ + env: { DISCORD_GUILD_ID: '223456789012345678' }, + req: { raw: new Request('https://api.example.com/api/v0/user/me/identities/discord/verify') }, + get: (key: string) => { + if (key === 'appUser') return appUser; + if (key === 'community') return community; + throw new Error(`Unexpected context key: ${key}`); + }, + json: (body: unknown, status: number): JsonResponse => ({ body, status }), +}); + +const baseDependencies = { + getAuthApi: () => ({ + getSession: async () => ({ user: { id: appUser.id } }), + getAccessToken: async () => ({ accessToken: 'oauth-token' }), + }), + getCurrentDiscordUser: async () => oauthUser, + findLinkedDiscordAccounts: async () => [linkedAccount], + persistVerification: async () => persisted, + now: () => fixedNow, +}; + +test('Discord identity verification requires matching application and Better Auth sessions', async () => { + let providerCalled = false; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + getAuthApi: () => ({ + getSession: async () => ({ user: { id: 'another-user' } }), + getAccessToken: async () => ({ accessToken: 'oauth-token' }), + }), + }); + const response = await service(createContext({ + getGuildMembership: async () => { + providerCalled = true; + return guildMembership; + }, + }) as never, async () => {}); + + assert.equal(asJsonResponse(response).status, 401); + assert.equal(providerCalled, false); +}); + +test('Discord identity verification correlates OAuth and guild identities before persisting', async () => { + const persistedInputs: unknown[] = []; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + persistVerification: async (_c, input) => { + persistedInputs.push(input); + return persisted; + }, + }); + const response = await service(createContext({ + getGuildMembership: async (userId) => { + assert.equal(userId, linkedAccount.accountId); + return guildMembership; + }, + }) as never, async () => {}); + + assert.deepEqual(response, { body: persisted, status: 200 }); + assert.deepEqual(persistedInputs, [{ + appUserId: appUser.id, + linkedAccount, + oauthUser, + guildId: '223456789012345678', + guildMembership, + verifiedAt: fixedNow, + }]); +}); + +test('Discord identity verification requires an explicitly linked Discord account', async () => { + let oauthCalled = false; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + findLinkedDiscordAccounts: async () => [], + getCurrentDiscordUser: async () => { + oauthCalled = true; + return oauthUser; + }, + }); + const response = await service(createContext({ + getGuildMembership: async () => guildMembership, + }) as never, async () => {}); + + const jsonResponse = asJsonResponse(response); + assert.equal(jsonResponse.status, 409); + assert.deepEqual(jsonResponse.body, { + code: 'DISCORD_ACCOUNT_NOT_LINKED', + message: 'Link a Discord account before verifying guild membership.', + }); + assert.equal(oauthCalled, false); +}); + +test('Discord identity verification rejects an OAuth subject that differs from the linked account', async () => { + let guildCalled = false; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + getCurrentDiscordUser: async () => ({ + ...oauthUser, + id: '999456789012345678', + }), + }); + const response = await service(createContext({ + getGuildMembership: async () => { + guildCalled = true; + return guildMembership; + }, + }) as never, async () => {}); + + assert.equal(asJsonResponse(response).status, 409); + assert.equal(guildCalled, false); +}); + +test('Discord identity verification stores non-membership evidence and returns 412', async () => { + const persistedInputs: Array<{ guildMembership: unknown }> = []; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + persistVerification: async (_c, input) => { + persistedInputs.push(input); + return null; + }, + }); + const response = await service(createContext({ + getGuildMembership: async () => { + throw new CommunityProviderError('Unknown Member', 404, 'discord', { code: 10007 }); + }, + }) as never, async () => {}); + + assert.equal(asJsonResponse(response).status, 412); + assert.equal(persistedInputs.length, 1); + assert.equal(persistedInputs[0].guildMembership, null); +}); + +test('Discord identity verification maps Discord outages to 502 without persisting', async () => { + let persistedCalled = false; + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + persistVerification: async () => { + persistedCalled = true; + return persisted; + }, + }); + const response = await service(createContext({ + getGuildMembership: async () => { + throw new CommunityProviderError('Discord unavailable', 503, 'discord'); + }, + }) as never, async () => {}); + + assert.equal(asJsonResponse(response).status, 502); + assert.equal(persistedCalled, false); +}); + +test('Discord identity verification does not mistake an unknown guild for an absent member', async () => { + const service = createVerifyDiscordIdentityService(baseDependencies); + const response = await service(createContext({ + getGuildMembership: async () => { + throw new CommunityProviderError('Unknown Guild', 404, 'discord', { code: 10004 }); + }, + }) as never, async () => {}); + + assert.equal(asJsonResponse(response).status, 502); +}); + +test('Discord identity verification treats OAuth rate limits as a provider outage', async () => { + const service = createVerifyDiscordIdentityService({ + ...baseDependencies, + getCurrentDiscordUser: async () => { + throw new CommunityProviderError('Rate limited', 429, 'discord'); + }, + }); + const response = await service(createContext({ + getGuildMembership: async () => guildMembership, + }) as never, async () => {}); + + const jsonResponse = asJsonResponse(response); + assert.equal(jsonResponse.status, 502); + assert.deepEqual(jsonResponse.body, { + code: 'DISCORD_SERVICE_UNAVAILABLE', + message: 'Discord could not verify the linked account.', + }); +}); diff --git a/community/src/api_v0/user/me/identity/service.ts b/community/src/api_v0/user/me/identity/service.ts new file mode 100644 index 0000000..57d85d6 --- /dev/null +++ b/community/src/api_v0/user/me/identity/service.ts @@ -0,0 +1,335 @@ +import type { Context } from 'hono'; +import type { RouteHandler } from '@hono/zod-openapi'; +import { and, eq } from 'drizzle-orm'; +import { + account, + communityIdentities, + communityMemberships, +} from '../../../../../../share/drizzle/schema'; +import type { AppContext } from '../../../../core/types'; +import { getAuth } from '../../../../auth/better-auth'; +import { CommunityProviderError } from '../../../../lib/community/error'; +import { getCurrentDiscordUser } from '../../../../lib/community/discord/oauth'; +import type { + DiscordGuildMembership, + DiscordOAuthUser, +} from '../../../../lib/community/type'; +import { + verifyDiscordIdentityRoute, + type VerifiedDiscordIdentity, +} from './schema'; + +type LinkedDiscordAccount = { + id: string; + accountId: string; +}; + +type PersistVerificationInput = { + appUserId: string; + linkedAccount: LinkedDiscordAccount; + oauthUser: DiscordOAuthUser; + guildId: string; + guildMembership: DiscordGuildMembership | null; + verifiedAt: string; +}; + +type IdentityAuthApi = { + getSession(input: { headers: Headers }): Promise<{ user: { id: string } } | null>; + getAccessToken(input: { + body: { providerId: string; accountId: string }; + headers: Headers; + }): Promise<{ accessToken: string }>; +}; + +type VerificationDependencies = { + getAuthApi(c: Context): IdentityAuthApi; + getCurrentDiscordUser(accessToken: string): Promise; + findLinkedDiscordAccounts( + c: Context, + userId: string, + ): Promise; + persistVerification( + c: Context, + input: PersistVerificationInput, + ): Promise; + now(): string; +}; + +const findLinkedDiscordAccounts: VerificationDependencies['findLinkedDiscordAccounts'] = async ( + c, + userId, +) => c.get('db').transaction((tx) => tx + .select({ + id: account.id, + accountId: account.accountId, + }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, 'discord'))) + .limit(2)); + +const persistVerification: VerificationDependencies['persistVerification'] = async (c, input) => { + const db = c.get('db'); + const membershipStatus = input.guildMembership ? 'member' : 'not_member'; + + return db.transaction(async (tx) => { + const [identity] = await tx + .insert(communityIdentities) + .values({ + userId: input.appUserId, + authAccountId: input.linkedAccount.id, + provider: 'discord', + providerAccountId: input.oauthUser.id, + username: input.oauthUser.username, + providerDisplayName: input.oauthUser.globalName, + avatarUrl: input.oauthUser.avatarUrl, + oauthVerifiedAt: input.verifiedAt, + lastSyncedAt: input.verifiedAt, + updatedAt: input.verifiedAt, + }) + .onConflictDoUpdate({ + target: [communityIdentities.userId, communityIdentities.provider], + set: { + authAccountId: input.linkedAccount.id, + providerAccountId: input.oauthUser.id, + username: input.oauthUser.username, + providerDisplayName: input.oauthUser.globalName, + avatarUrl: input.oauthUser.avatarUrl, + oauthVerifiedAt: input.verifiedAt, + lastSyncedAt: input.verifiedAt, + updatedAt: input.verifiedAt, + }, + }) + .returning(); + + if (!identity) { + throw new Error('Discord identity upsert did not return a row'); + } + + const roleIds = input.guildMembership?.roles.map((role) => role.id) ?? []; + const roleNames = input.guildMembership?.roles.map((role) => role.name) ?? []; + const nickname = input.guildMembership?.nickname ?? null; + const membershipVerifiedAt = input.guildMembership ? input.verifiedAt : null; + + await tx + .insert(communityMemberships) + .values({ + identityId: identity.identityId, + communityId: input.guildId, + membershipStatus, + nickname, + roleIds, + roleNames, + verifiedAt: membershipVerifiedAt, + lastCheckedAt: input.verifiedAt, + updatedAt: input.verifiedAt, + }) + .onConflictDoUpdate({ + target: [communityMemberships.identityId, communityMemberships.communityId], + set: { + membershipStatus, + nickname, + roleIds, + roleNames, + verifiedAt: membershipVerifiedAt, + lastCheckedAt: input.verifiedAt, + updatedAt: input.verifiedAt, + }, + }); + + if (!input.guildMembership) { + return null; + } + + return { + identityId: identity.identityId, + provider: 'discord', + providerAccountId: identity.providerAccountId, + username: identity.username, + providerDisplayName: identity.providerDisplayName, + avatarUrl: identity.avatarUrl, + oauthVerifiedAt: identity.oauthVerifiedAt, + membership: { + communityId: input.guildId, + membershipStatus: 'member', + nickname, + roleIds, + roleNames, + verifiedAt: input.verifiedAt, + }, + }; + }); +}; + +const defaultDependencies: VerificationDependencies = { + getAuthApi: (c) => getAuth(c).api, + getCurrentDiscordUser, + findLinkedDiscordAccounts, + persistVerification, + now: () => new Date().toISOString(), +}; + +const errorBody = (code: string, message: string) => ({ code, message }); + +const isUniqueViolation = (error: unknown): boolean => ( + typeof error === 'object' + && error !== null + && 'code' in error + && error.code === '23505' +); + +const isDiscordUnknownMember = (error: unknown): error is CommunityProviderError => { + if (!(error instanceof CommunityProviderError) || error.status !== 404) { + return false; + } + + return typeof error.details === 'object' + && error.details !== null + && 'code' in error.details + && error.details.code === 10007; +}; + +export const createVerifyDiscordIdentityService = ( + dependencyOverrides: Partial = {}, +): RouteHandler => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + + return async (c) => { + const appUser = c.get('appUser'); + const headers = c.req.raw.headers; + const authApi = dependencies.getAuthApi(c); + const authSession = await authApi.getSession({ headers }); + + if (!authSession || authSession.user.id !== appUser.id) { + return c.json(errorBody( + 'AUTH_SESSION_MISMATCH', + 'The Better Auth session does not match the signed-in application user.', + ), 401); + } + + const linkedAccounts = await dependencies.findLinkedDiscordAccounts(c, appUser.id); + if (linkedAccounts.length === 0) { + return c.json(errorBody( + 'DISCORD_ACCOUNT_NOT_LINKED', + 'Link a Discord account before verifying guild membership.', + ), 409); + } + if (linkedAccounts.length > 1) { + return c.json(errorBody( + 'MULTIPLE_DISCORD_ACCOUNTS', + 'More than one Discord account is linked; remove the unintended account first.', + ), 409); + } + + const [linkedAccount] = linkedAccounts; + let accessToken: string; + let oauthUser: DiscordOAuthUser; + + try { + ({ accessToken } = await authApi.getAccessToken({ + body: { + providerId: 'discord', + accountId: linkedAccount.accountId, + }, + headers, + })); + oauthUser = await dependencies.getCurrentDiscordUser(accessToken); + } catch (error) { + if (error instanceof CommunityProviderError) { + if (error.status !== 401 && error.status !== 403) { + console.error('[Discord Identity] OAuth provider unavailable', { status: error.status }); + return c.json(errorBody( + 'DISCORD_SERVICE_UNAVAILABLE', + 'Discord could not verify the linked account.', + ), 502); + } + } + + console.warn('[Discord Identity] Linked account requires reauthentication'); + return c.json(errorBody( + 'DISCORD_REAUTHENTICATION_REQUIRED', + 'Reconnect Discord and try again.', + ), 401); + } + + if (oauthUser.id !== linkedAccount.accountId) { + console.error('[Discord Identity] OAuth subject did not match the linked account'); + return c.json(errorBody( + 'DISCORD_ACCOUNT_MISMATCH', + 'The Discord OAuth identity does not match the linked account.', + ), 409); + } + + const verifiedAt = dependencies.now(); + + try { + const guildMembership = await c.get('community').getGuildMembership(oauthUser.id); + if (guildMembership.userId !== oauthUser.id) { + throw new CommunityProviderError( + 'Discord guild membership did not match the OAuth user', + 502, + 'discord', + ); + } + + const result = await dependencies.persistVerification(c, { + appUserId: appUser.id, + linkedAccount, + oauthUser, + guildId: c.env.DISCORD_GUILD_ID, + guildMembership, + verifiedAt, + }); + + if (!result) { + throw new Error('Verified Discord membership was not persisted'); + } + + return c.json(result, 200); + } catch (error) { + if (isDiscordUnknownMember(error)) { + try { + await dependencies.persistVerification(c, { + appUserId: appUser.id, + linkedAccount, + oauthUser, + guildId: c.env.DISCORD_GUILD_ID, + guildMembership: null, + verifiedAt, + }); + } catch (persistError) { + if (isUniqueViolation(persistError)) { + return c.json(errorBody( + 'DISCORD_IDENTITY_CONFLICT', + 'This Discord account is already linked to another user.', + ), 409); + } + throw persistError; + } + + return c.json(errorBody( + 'DISCORD_GUILD_MEMBERSHIP_REQUIRED', + 'Join the configured Discord guild before continuing.', + ), 412); + } + + if (isUniqueViolation(error)) { + return c.json(errorBody( + 'DISCORD_IDENTITY_CONFLICT', + 'This Discord account is already linked to another user.', + ), 409); + } + + if (error instanceof CommunityProviderError) { + console.error('[Discord Identity] Guild verification failed', { status: error.status }); + return c.json(errorBody( + 'DISCORD_SERVICE_UNAVAILABLE', + 'Discord could not verify guild membership.', + ), 502); + } + + throw error; + } + }; +}; + +export const verifyDiscordIdentityService = createVerifyDiscordIdentityService(); diff --git a/community/src/api_v0/user/me/router.ts b/community/src/api_v0/user/me/router.ts index 3a6a942..9035887 100644 --- a/community/src/api_v0/user/me/router.ts +++ b/community/src/api_v0/user/me/router.ts @@ -2,9 +2,11 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { AppContext } from "../../../core/types"; import { getUserMeRoute, updateUserMeRoute } from "./schema"; import { getUserMeService, updateUserMeService } from "./service"; +import { userIdentityRouter } from "./identity/router"; // import { userMeRoleRouter } from "./role/router"; export const userMeRouter = new OpenAPIHono() .openapi(getUserMeRoute, getUserMeService) .openapi(updateUserMeRoute, updateUserMeService) + .route("/identities", userIdentityRouter) // .route("/role", userMeRoleRouter); diff --git a/community/src/api_v0/user/me/schema.ts b/community/src/api_v0/user/me/schema.ts index 6840cc8..28b5d51 100644 --- a/community/src/api_v0/user/me/schema.ts +++ b/community/src/api_v0/user/me/schema.ts @@ -1,12 +1,12 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { getUserSchema, UpdateUserMeSchema } from "../schema"; +import { CurrentUserSchema, UpdateUserMeSchema } from "../schema"; // 自身の情報を取得する export const getUserMeRoute = createRoute({ method: "get", path: "/", responses: { - 200: { description: "成功", content: { "application/json": { schema: getUserSchema } } }, + 200: { description: "成功", content: { "application/json": { schema: CurrentUserSchema } } }, 404: { description: "Not Found", content: { "application/json": { schema: z.object({ error: z.string() }) } } } } }); @@ -17,7 +17,7 @@ export const updateUserMeRoute = createRoute({ path: "/", request: { body: { content: { "application/json": { schema: UpdateUserMeSchema } } } }, responses: { - 200: { description: "成功", content: { "application/json": { schema: getUserSchema } } }, + 200: { description: "成功", content: { "application/json": { schema: CurrentUserSchema } } }, 404: { description: "Not Found", content: { "application/json": { schema: z.object({ error: z.string() }) } } } } }); diff --git a/community/src/api_v0/user/me/service.ts b/community/src/api_v0/user/me/service.ts index 7b34f04..ea4ba2d 100644 --- a/community/src/api_v0/user/me/service.ts +++ b/community/src/api_v0/user/me/service.ts @@ -1,19 +1,36 @@ import { RouteHandler } from "@hono/zod-openapi"; +import type { Context } from "hono"; import { AppContext } from "../../../core/types"; import { getUserMeRoute, updateUserMeRoute } from "./schema"; import { eq } from "drizzle-orm"; import { user } from "../../../../../share/drizzle/schema"; +const currentUserSelection = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + memberId: user.memberId, + role: user.role, +}; + +const setPrivateNoStore = (c: Context) => { + c.header('Cache-Control', 'private, no-store, max-age=0'); + c.header('Pragma', 'no-cache'); +}; + export const getUserMeService: RouteHandler = async (c) => { + setPrivateNoStore(c); const db = c.get("db"); const appUser = c.get("appUser"); // DBから自身の情報を取得 - const [dbUser] = await db - .select() + const [dbUser] = await db.transaction((tx) => tx + .select(currentUserSelection) .from(user) .where(eq(user.id, appUser.id)) - .limit(1); + .limit(1)); if (!dbUser) { return c.json({ error: "User not found in database" }, 404); @@ -23,18 +40,19 @@ export const getUserMeService: RouteHandler = }; export const updateUserMeService: RouteHandler = async (c) => { + setPrivateNoStore(c); const db = c.get("db"); const appUser = c.get("appUser"); const body = c.req.valid("json"); - const [updatedUser] = await db + const [updatedUser] = await db.transaction((tx) => tx .update(user) .set({ ...body, updatedAt: new Date(), }) .where(eq(user.id, appUser.id)) - .returning(); + .returning(currentUserSelection)); if (!updatedUser) { return c.json({ error: "User not found in database" }, 404); diff --git a/community/src/api_v0/user/router.ts b/community/src/api_v0/user/router.ts index fb85270..dde8a11 100644 --- a/community/src/api_v0/user/router.ts +++ b/community/src/api_v0/user/router.ts @@ -1,20 +1,11 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { AppContext } from "../../core/types"; -import { createUserRoute, listUsersRoute } from "./schema"; -import { createUserService, listUsersService } from "./service"; import { userMeRouter } from "./me/router"; -import { userDetailRouter } from "./id/router"; // ***** user router ***** // /api/v0/user export const userRouter = new OpenAPIHono() - // 1. コレクション操作 (/api/v0/user/) - .openapi(createUserRoute, createUserService) - .openapi(listUsersRoute, listUsersService) - - // 2. 自身の情報 (/api/v0/user/me/**) + // Better Auth owns user/account lifecycle. The application exposes only + // current-user presentation fields and explicit community identity linking. .route("/me", userMeRouter) - - // 3. 個別ユーザーの情報 (/api/v0/user/id/**) - .route("/:id", userDetailRouter); diff --git a/community/src/api_v0/user/schema.ts b/community/src/api_v0/user/schema.ts index 63de04a..6de37cd 100644 --- a/community/src/api_v0/user/schema.ts +++ b/community/src/api_v0/user/schema.ts @@ -8,16 +8,27 @@ export const createUserSchema = createInsertSchema(user) export const getUserSchema = createSelectSchema(user).openapi("User") -export const UpdateUserSchema = createInsertSchema(user) - .omit({ id: true, createdAt: true, updatedAt: true }) - .partial() - .openapi("UpdateUserRequest") +export const CurrentUserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + emailVerified: z.boolean(), + image: z.string().nullable(), + memberId: z.string().uuid().nullable(), + role: z.enum(["user", "admin"]), +}).openapi("CurrentUser") + +// Better Auth owns identity, account linkage, member linkage, and application +// roles. This legacy admin endpoint may only edit the same presentation fields +// as the self-service endpoint; role/memberId changes remain explicit DB ops. +export const UpdateUserSchema = z.object({ + name: z.string().trim().min(1).max(200).optional(), + image: z.string().nullable().optional(), +}).strict().openapi("UpdateUserRequest") export const UpdateUserMeSchema = z.object({ name: z.string().optional(), - displayName: z.string().nullable().optional(), image: z.string().nullable().optional(), - discordUserId: z.string().nullable().optional(), }).openapi("UpdateUserMeRequest") const errorMessageSchema = z.object({ message: z.string() }); diff --git a/community/src/api_v0/user/service.ts b/community/src/api_v0/user/service.ts index 28b18fa..3c508d0 100644 --- a/community/src/api_v0/user/service.ts +++ b/community/src/api_v0/user/service.ts @@ -13,7 +13,7 @@ export const createUserService: RouteHandler return c.json({ message: "Forbidden" }, 403); } - const [createdUser] = await db + const [createdUser] = await db.transaction((tx) => tx .insert(user) .values({ ...body, @@ -21,7 +21,7 @@ export const createUserService: RouteHandler createdAt: now, updatedAt: now, }) - .returning(); + .returning()); return c.json(createdUser, 201); }; @@ -34,7 +34,7 @@ export const listUsersService: RouteHandler = return c.json({ message: "Forbidden" }, 403); } - const users = await db.select().from(user); + const users = await db.transaction((tx) => tx.select().from(user)); return c.json(users, 200); }; diff --git a/community/src/auth/better-auth.test.ts b/community/src/auth/better-auth.test.ts index 5f85d3c..af8b3b2 100644 --- a/community/src/auth/better-auth.test.ts +++ b/community/src/auth/better-auth.test.ts @@ -1,24 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { Context } from "hono"; -import { getAuth } from "./better-auth"; +import { getAuth, getPostLoginRedirectPath } from "./better-auth"; import type { AppContext } from "../core/types"; +import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "../../../share/drizzle/schema"; + +const testDatabase = drizzle.mock({ schema }); const createContext = (communityUrl: string) => ({ get: () => ({}), env: { - JWT_SECRET: "mV8qP2xL7nR4cT9wK3sF6hJ1dB5yG0uQ8aZ2eN7pC4rX9kM6", + BETTER_AUTH_SECRET: "better-auth-secret-mV8qP2xL7nR4cT9wK3sF6hJ1", + JWT_SECRET: "jwt-secret-dB5yG0uQ8aZ2eN7pC4rX9kM6sF7hJ2", COMMUNITY_URL: communityUrl, FRONTEND_URL: communityUrl.replace("api", "app"), - GITHUB_CLIENT_ID: "test-github-client", - GITHUB_CLIENT_SECRET: "test-github-secret", DISCORD_CLIENT_ID: "test-discord-client", DISCORD_CLIENT_SECRET: "test-discord-secret", }, }) as unknown as Context; test("Better Auth marks session cookies secure for an HTTPS deployment", async () => { - const authContext = await getAuth(createContext("https://api.example.com")).$context; + const authContext = await getAuth(createContext("https://api.example.com"), testDatabase).$context; const sessionCookie = authContext.authCookies.sessionToken; assert.equal(sessionCookie.attributes.secure, true); @@ -26,9 +29,44 @@ test("Better Auth marks session cookies secure for an HTTPS deployment", async ( }); test("Better Auth keeps local HTTP development cookies usable", async () => { - const authContext = await getAuth(createContext("http://localhost:8787")).$context; + const authContext = await getAuth(createContext("http://localhost:8787"), testDatabase).$context; const sessionCookie = authContext.authCookies.sessionToken; assert.equal(sessionCookie.attributes.secure, false); assert.doesNotMatch(sessionCookie.name, /^__Secure-/); }); + +test("Better Auth signs members in with Discord as the only provider", async () => { + const authContext = await getAuth(createContext("https://api.example.com"), testDatabase).$context; + const linking = authContext.options.account?.accountLinking; + const providers = authContext.options.socialProviders ?? {}; + const discord = providers.discord; + + assert.equal(authContext.secret, "better-auth-secret-mV8qP2xL7nR4cT9wK3sF6hJ1"); + assert.equal(authContext.options.account?.encryptOAuthTokens, true); + assert.equal(linking?.enabled, true); + assert.equal(linking?.disableImplicitLinking, true); + assert.equal(linking?.allowDifferentEmails, true); + assert.equal(linking?.allowUnlinkingAll, false); + assert.equal(linking?.updateUserInfoOnLink, false); + // Discord is the sole identity provider, so sign-up through it must stay open. + assert.deepEqual(Object.keys(providers), ["discord"]); + assert.ok(!("disableSignUp" in (discord ?? {}))); + // Better Auth appends this to its default identify+email scopes. + assert.deepEqual(discord?.scope, ["guilds"]); + assert.deepEqual( + Object.keys(authContext.options.user?.additionalFields ?? {}).sort(), + ["memberId", "role"], + ); + assert.equal(authContext.options.user?.additionalFields?.memberId?.input, false); + assert.equal(authContext.options.user?.additionalFields?.role?.input, false); +}); + +test("post-login routing only sends active members to their profile", () => { + assert.equal(getPostLoginRedirectPath("admin", null), "/"); + assert.equal(getPostLoginRedirectPath("user", "active"), "/me"); + assert.equal(getPostLoginRedirectPath("user", "pending"), "/join"); + assert.equal(getPostLoginRedirectPath("user", "rejected"), "/join"); + assert.equal(getPostLoginRedirectPath("user", "withdrawn"), "/join"); + assert.equal(getPostLoginRedirectPath("user", null), "/join"); +}); diff --git a/community/src/auth/better-auth.ts b/community/src/auth/better-auth.ts index 664f330..66af1c1 100644 --- a/community/src/auth/better-auth.ts +++ b/community/src/auth/better-auth.ts @@ -6,35 +6,46 @@ import { Context } from "hono"; import { createRoute, z } from "@hono/zod-openapi"; import { sign } from "hono/jwt"; import { setCookie } from "hono/cookie"; +import { eq } from "drizzle-orm"; +import { getAuthDatabase, type AppDatabase } from "../core/db"; -export const getAuth = (c: Context) => { - const db = c.get("db"); +export const getAuth = (c: Context, database?: AppDatabase) => { + const db = database ?? getAuthDatabase(c); return betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: schema }), - secret: c.env.JWT_SECRET, + secret: c.env.BETTER_AUTH_SECRET, baseURL: c.env.COMMUNITY_URL || "http://localhost:8787", trustedOrigins: [ c.env.FRONTEND_URL || "http://localhost:3000" ], user: { additionalFields: { - discordUserId: { type: "string", required: false }, - displayName: { type: "string", required: false }, - memberId: { type: "string", required: false }, - role: { type: "string", defaultValue: "user" } + memberId: { type: "string", required: false, input: false }, + role: { type: "string", defaultValue: "user", input: false } } }, - socialProviders: { - github: { - clientId: c.env.GITHUB_CLIENT_ID, - clientSecret: c.env.GITHUB_CLIENT_SECRET, + account: { + encryptOAuthTokens: true, + accountLinking: { + enabled: true, + disableImplicitLinking: true, + allowDifferentEmails: true, + allowUnlinkingAll: false, + updateUserInfoOnLink: false, }, + }, + socialProviders: { + // Discord is the only identity provider. The same account both signs + // the member in and supplies the guild membership evidence the join + // flow verifies, so there is no second provider to link or reconcile. discord: { clientId: c.env.DISCORD_CLIENT_ID, clientSecret: c.env.DISCORD_CLIENT_SECRET, + // Appended to Better Auth's default identify+email scopes. + scope: ["guilds"], } }, advanced: { @@ -43,6 +54,11 @@ export const getAuth = (c: Context) => { }); }; +export const getPostLoginRedirectPath = ( + role: 'admin' | 'user', + memberStatus: string | null | undefined, +) => role === 'admin' ? '/' : memberStatus === 'active' ? '/me' : '/join'; + // JWT Endpoint Route definition export const getJwtRoute = createRoute({ method: "get", @@ -80,13 +96,46 @@ export const getJwtHandler = async (c: Context) => { } try { + const authDb = getAuthDatabase(c); + const [currentUser] = await authDb + .select({ + role: schema.user.role, + memberId: schema.user.memberId, + }) + .from(schema.user) + .where(eq(schema.user.id, session.user.id)) + .limit(1); + + if (!currentUser) { + console.error("[JWT Endpoint] Authenticated user was not found in the application database."); + return c.redirect(`${frontendUrl}/login?error=user_not_found`); + } + + const role = currentUser.role === "admin" ? "admin" : "user"; + const db = c.get("db"); + db.setIdentity({ + userId: session.user.id, + memberId: currentUser.memberId, + role, + }); + + const memberId = currentUser.memberId; + const [member] = memberId + ? await db.transaction((tx) => tx + .select({ memberStatus: schema.members.memberStatus }) + .from(schema.members) + .where(eq(schema.members.memberId, memberId)) + .limit(1)) + : []; const payload = { id: session.user.id, - discordid: (session.user as any).discordUserId || null, + sid: session.session.id, name: session.user.name, - displayName: (session.user as any).displayName || session.user.name, - role: ((session.user as any).role as 'admin' | 'user') || 'user', - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days + role, + exp: Math.min( + Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, + Math.floor(new Date(session.session.expiresAt).getTime() / 1000), + ), }; const token = await sign(payload, c.env.JWT_SECRET, 'HS256'); @@ -101,9 +150,7 @@ export const getJwtHandler = async (c: Context) => { domain: c.env.COOKIE_DOMAIN || undefined, }); - const redirectPath = payload.role === 'admin' - ? '/' - : (session.user as any).memberId ? '/me' : '/join'; + const redirectPath = getPostLoginRedirectPath(role, member?.memberStatus); console.log(`[JWT Endpoint] Successfully issued JWT for user ${session.user.id} with role ${payload.role}`); return c.redirect(`${frontendUrl}${redirectPath}`); } catch (error) { diff --git a/community/src/auth/router.test.ts b/community/src/auth/router.test.ts new file mode 100644 index 0000000..ed08a9a --- /dev/null +++ b/community/src/auth/router.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { withClearedAppAuthorizationCookie } from './router' + +test('sign-out response preserves Better Auth cookies and expires the application JWT', async () => { + const betterAuthResponse = new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': 'app-auth.session_token=; Max-Age=0; Path=/; HttpOnly', + }, + }) + + const response = withClearedAppAuthorizationCookie(betterAuthResponse, { + isLocal: false, + domain: 'example.com', + }) + const setCookie = response.headers.get('set-cookie') ?? '' + + assert.match(setCookie, /app-auth\.session_token=/) + assert.match(setCookie, /app-authorization=/) + assert.match(setCookie, /Max-Age=0/) + assert.match(setCookie, /HttpOnly/) + assert.match(setCookie, /Secure/) + assert.match(setCookie, /Domain=example\.com/) + assert.deepEqual(await response.json(), { success: true }) +}) diff --git a/community/src/auth/router.ts b/community/src/auth/router.ts index 99abb4d..f340772 100644 --- a/community/src/auth/router.ts +++ b/community/src/auth/router.ts @@ -1,6 +1,28 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { AppContext } from "../core/types"; import { getAuth, getJwtRoute, getJwtHandler } from "./better-auth"; +import { serialize } from "hono/utils/cookie"; + +export const withClearedAppAuthorizationCookie = ( + response: Response, + options: { isLocal: boolean; domain?: string }, +) => { + const headers = new Headers(response.headers); + headers.append('Set-Cookie', serialize('app-authorization', '', { + path: '/', + httpOnly: true, + secure: !options.isLocal, + sameSite: 'Lax', + maxAge: 0, + domain: options.domain || undefined, + })); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; export const authRouter = new OpenAPIHono() // 1. JWT Endpoint (OpenAPI Route) @@ -15,15 +37,10 @@ export const authRouter = new OpenAPIHono() // Clear custom JWT cookie on sign-out const url = new URL(c.req.url); if (url.pathname.endsWith('/sign-out')) { - const { setCookie } = await import('hono/cookie'); const isLocal = !c.env.COMMUNITY_URL || c.env.COMMUNITY_URL.includes('localhost'); - setCookie(c, 'app-authorization', '', { - path: '/', - httpOnly: true, - secure: !isLocal, - sameSite: 'Lax', - maxAge: 0, - domain: c.env.COOKIE_DOMAIN || undefined, + return withClearedAppAuthorizationCookie(res, { + isLocal, + domain: c.env.COOKIE_DOMAIN, }); } diff --git a/community/src/core/auth.test.ts b/community/src/core/auth.test.ts new file mode 100644 index 0000000..6675c49 --- /dev/null +++ b/community/src/core/auth.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sign } from 'hono/jwt'; +import { authMiddleware, type appUser } from './auth'; + +test('auth middleware reloads the application user and does not turn downstream failures into 401 responses', async () => { + const jwtSecret = 'jwt-secret-for-community-auth-boundary-test'; + const token = await sign({ id: 'user-1', sid: 'session-1' }, jwtSecret, 'HS256'); + let capturedUser: appUser | undefined; + let capturedIdentity: unknown; + + const transaction = { + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: async () => [{ + id: 'user-1', + name: 'Test User', + memberId: null, + role: 'user', + }], + }), + }), + }), + }), + }; + const db = { + transaction: (operation: (tx: unknown) => Promise) => operation(transaction), + setIdentity: (identity: unknown) => { + capturedIdentity = identity; + }, + }; + const context = { + env: { + NODE_ENV: 'production', + JWT_SECRET: jwtSecret, + }, + req: { + header: (name: string) => name === 'Authorization' ? `Bearer ${token}` : undefined, + }, + get: (key: string) => { + if (key === 'db') return db; + throw new Error(`Unexpected context key: ${key}`); + }, + set: (key: string, value: appUser) => { + assert.equal(key, 'appUser'); + capturedUser = value; + }, + json: (body: unknown, status: number) => ({ body, status }), + }; + + await assert.rejects( + authMiddleware(context as never, async () => { + throw new Error('downstream database failure'); + }), + /downstream database failure/, + ); + + assert.deepEqual(capturedUser, { + id: 'user-1', + name: 'Test User', + memberId: null, + role: 'user', + }); + assert.deepEqual(capturedIdentity, { + userId: 'user-1', + memberId: null, + role: 'user', + }); +}); + +test('auth middleware rejects legacy JWTs without a Better Auth session binding', async () => { + const jwtSecret = 'jwt-secret-for-community-session-binding-test'; + const token = await sign({ id: 'user-1' }, jwtSecret, 'HS256'); + const result = await authMiddleware({ + env: { NODE_ENV: 'production', JWT_SECRET: jwtSecret }, + req: { header: () => `Bearer ${token}` }, + json: (body: unknown, status: number) => ({ body, status }), + } as never, async () => {}); + + assert.deepEqual(result, { + body: { error: 'Unauthorized: Session binding is missing' }, + status: 401, + }); +}); diff --git a/community/src/core/auth.ts b/community/src/core/auth.ts index add2ef3..a373d2e 100644 --- a/community/src/core/auth.ts +++ b/community/src/core/auth.ts @@ -1,8 +1,8 @@ import type { Context, Next } from 'hono' import { AppContext } from './types' import { verify } from 'hono/jwt' -import { eq, sql } from 'drizzle-orm' -import { user } from '../../../share/drizzle/schema' +import { and, eq, gt } from 'drizzle-orm' +import { session, user } from '../../../share/drizzle/schema' export type authUser = { id: string @@ -10,90 +10,107 @@ export type authUser = { export type appUser = { id: string - discordid: string | null name: string - displayName: string memberId: string | null role: 'admin' | 'user' } -export const authMiddleware = async (c: Context, next: Next) => { - if (c.env.NODE_ENV === 'development') { - // For development purposes, you might want to set a mock user - c.set('appUser', { - id: 'dev-user-id', - discordid: null, - name: 'Dev User', - displayName: 'Dev User', - memberId: null, - role: 'admin' - }); - console.warn("[Auth Middleware] Running in development mode. Mock user has been set."); - await next(); - return; - } - - // Authorizationヘッダーか、Cookie 'app-authorization' からJWTを取得 - const authHeader = c.req.header('Authorization'); - let token: string | undefined; +const userSelection = { + id: user.id, + name: user.name, + memberId: user.memberId, + role: user.role, +} - if (authHeader && authHeader.startsWith('Bearer ')) { - token = authHeader.substring(7); - } else { - const { getCookie } = await import('hono/cookie'); - token = getCookie(c, 'app-authorization'); - } +const loadAppUser = async ( + c: Context, + userId: string, + sessionId?: string, +): Promise => { + const [currentUser] = await c.get('db').transaction(async (db) => sessionId + ? db + .select(userSelection) + .from(user) + .innerJoin(session, eq(session.userId, user.id)) + .where(and( + eq(user.id, userId), + eq(session.id, sessionId), + gt(session.expiresAt, new Date()), + )) + .limit(1) + : db + .select(userSelection) + .from(user) + .where(eq(user.id, userId)) + .limit(1)); - if (!token) { - return c.json({ error: 'Unauthorized: No token provided' }, 401); + if (!currentUser) { + return null; } - try { - const payload = await verify(token, c.env.JWT_SECRET, 'HS256'); - const userId = payload.id; + const role = currentUser.role === 'admin' ? 'admin' : 'user'; + c.get('db').setIdentity({ + userId: currentUser.id, + memberId: currentUser.memberId, + role, + }); - if (typeof userId !== 'string' || userId.length === 0) { - return c.json({ error: 'Unauthorized: Invalid token subject' }, 401); - } + return { + id: currentUser.id, + name: currentUser.name, + memberId: currentUser.memberId, + role, + }; +} - const [currentUser] = await c.get('db') - .select({ - id: user.id, - discordUserId: user.discordUserId, - name: user.name, - displayName: user.displayName, - memberId: user.memberId, - role: user.role, - }) - .from(user) - .where(eq(user.id, userId)) - .limit(1); +export const authMiddleware = async (c: Context, next: Next) => { + let userId: string; + let sessionId: string | undefined; - if (!currentUser) { - return c.json({ error: 'Unauthorized: User not found' }, 401); + if (c.env.NODE_ENV === 'development') { + const developmentUserId = c.env.DEV_USER_ID?.trim(); + if (!developmentUserId) { + return c.json({ error: 'Development authentication requires DEV_USER_ID' }, 500); } + userId = developmentUserId; + console.warn("[Auth Middleware] Running in development mode. Database-backed mock user has been set."); + } else { + // Authorizationヘッダーか、Cookie 'app-authorization' からJWTを取得 + const authHeader = c.req.header('Authorization'); + let token: string | undefined; - const role = currentUser.role === 'admin' ? 'admin' : 'user'; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } else { + const { getCookie } = await import('hono/cookie'); + token = getCookie(c, 'app-authorization'); + } - await c.get('db').execute(sql` - select - set_config('app.current_user_id', ${currentUser.id}, false), - set_config('app.current_member_id', ${currentUser.memberId ?? ''}, false), - set_config('app.current_user_role', ${role}, false) - `); + if (!token) { + return c.json({ error: 'Unauthorized: No token provided' }, 401); + } - c.set('appUser', { - id: currentUser.id, - discordid: currentUser.discordUserId, - name: currentUser.name, - displayName: currentUser.displayName || currentUser.name, - memberId: currentUser.memberId, - role, - }); + try { + const payload = await verify(token, c.env.JWT_SECRET, 'HS256'); + if (typeof payload.id !== 'string' || payload.id.length === 0) { + return c.json({ error: 'Unauthorized: Invalid token subject' }, 401); + } + if (typeof payload.sid !== 'string' || payload.sid.length === 0) { + return c.json({ error: 'Unauthorized: Session binding is missing' }, 401); + } + userId = payload.id; + sessionId = payload.sid; + } catch (error) { + console.error("[Auth Middleware] Invalid token:", error); + return c.json({ error: 'Unauthorized: Invalid token' }, 401); + } + } - await next(); - } catch (error) { - console.error("[Auth Middleware] Invalid token:", error); - return c.json({ error: 'Unauthorized: Invalid token' }, 401); + const currentUser = await loadAppUser(c, userId, sessionId); + if (!currentUser) { + return c.json({ error: 'Unauthorized: User not found' }, 401); } + + c.set('appUser', currentUser); + await next(); } diff --git a/community/src/core/db.test.ts b/community/src/core/db.test.ts new file mode 100644 index 0000000..6885b25 --- /dev/null +++ b/community/src/core/db.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { PgDialect } from 'drizzle-orm/pg-core' +import type { SQL } from 'drizzle-orm' +import { assertSafeRuntimeRole, createRlsDatabase } from './db' + +const dialect = new PgDialect() + +test('RLS transactions snapshot identity and serialize SET LOCAL context', async () => { + const transactions: Array> = [] + const database = { + transaction: async (operation: (tx: { execute(statement: SQL): Promise }) => Promise) => { + const statements: Array<{ sql: string; params: unknown[] }> = [] + transactions.push(statements) + return operation({ + execute: async (statement) => { + const query = dialect.sqlToQuery(statement) + statements.push({ sql: query.sql, params: query.params }) + }, + }) + }, + } + const rls = createRlsDatabase(database as never) + + rls.setIdentity({ userId: 'user-one', memberId: null, role: 'user' }) + const first = rls.transaction(async () => 'first') + rls.setIdentity({ + userId: 'user-two', + memberId: '00000000-0000-4000-8000-000000000002', + role: 'admin', + }) + const second = rls.transaction(async () => 'second') + + assert.deepEqual(await Promise.all([first, second]), ['first', 'second']) + assert.equal(transactions.length, 2) + + for (const statements of transactions) { + assert.match(statements[0]?.sql ?? '', /^set local role app_rls$/) + assert.match(statements[1]?.sql ?? '', /set_config\('app\.current_user_id'/) + assert.match(statements[1]?.sql ?? '', /set_config\('statement_timeout'/) + assert.match(statements[1]?.sql ?? '', /set_config\('lock_timeout'/) + assert.match(statements[1]?.sql ?? '', /set_config\('idle_in_transaction_session_timeout'/) + } + assert.deepEqual(transactions[0][1]?.params.slice(0, 3), ['user-one', '', 'user']) + assert.deepEqual( + transactions[1][1]?.params.slice(0, 3), + ['user-two', '00000000-0000-4000-8000-000000000002', 'admin'], + ) +}) + +test('database runtime role validation fails closed for privileged or non-RLS roles', () => { + assert.doesNotThrow(() => assertSafeRuntimeRole({ + roleName: 'app_runtime', + isSuperuser: false, + bypassesRls: false, + usesAppRls: true, + })) + + for (const probe of [ + { roleName: 'postgres', isSuperuser: true, bypassesRls: true, usesAppRls: true }, + { roleName: 'bypass', isSuperuser: false, bypassesRls: true, usesAppRls: true }, + { roleName: 'unprivileged', isSuperuser: false, bypassesRls: false, usesAppRls: false }, + ]) { + assert.throws(() => assertSafeRuntimeRole(probe), /Unsafe database runtime role/) + } +}) + diff --git a/community/src/core/db.ts b/community/src/core/db.ts index c6e2299..ab4937e 100644 --- a/community/src/core/db.ts +++ b/community/src/core/db.ts @@ -1,18 +1,124 @@ import type { Context, Next } from 'hono' -import { AppContext } from './types' -import { drizzle } from 'drizzle-orm/node-postgres' +import { sql, type ExtractTablesWithRelations } from 'drizzle-orm' +import { + drizzle, + type NodePgDatabase, + type NodePgTransaction, +} from 'drizzle-orm/node-postgres' import { Client } from 'pg' +import * as schema from '../../../share/drizzle/schema' +import type { AppContext } from './types' -const resetRlsSession = async (client: Client) => { - await client.query('reset role') - await client.query('reset all') - await client.query('set role app_rls') - await client.query(` +export type AppDatabase = NodePgDatabase +export type AppTransaction = NodePgTransaction< + typeof schema, + ExtractTablesWithRelations +> + +export type RlsIdentity = { + userId: string + memberId: string | null + role: 'admin' | 'user' | '' +} + +export type RlsDatabase = { + /** + * Replaces the request's identity for transactions started after this call. + * The identity is copied when transaction() is invoked. + */ + setIdentity(identity: RlsIdentity): void + /** + * Runs database-only work in a short transaction. Never await network I/O in + * the callback: Hyperdrive pins one origin connection until it completes. + */ + transaction(operation: (tx: AppTransaction) => Promise): Promise +} + +// Keep Better Auth's unscoped adapter database out of AppContext.Variables so +// ordinary route services cannot accidentally bypass the RLS transaction API. +const authDatabases = new WeakMap() + +export const getAuthDatabase = (context: object): AppDatabase => { + const database = authDatabases.get(context) + if (!database) throw new Error('Authentication database is not initialized') + return database +} + +type RuntimeRoleProbe = { + roleName: string + isSuperuser: boolean + bypassesRls: boolean + usesAppRls: boolean +} + +export const assertSafeRuntimeRole = (probe: RuntimeRoleProbe) => { + if (probe.isSuperuser || probe.bypassesRls || !probe.usesAppRls) { + throw new Error( + `Unsafe database runtime role ${probe.roleName}: require a non-superuser, ` + + 'NOBYPASSRLS login role that inherits app_rls', + ) + } +} + +const anonymousIdentity: RlsIdentity = { + userId: '', + memberId: null, + role: '', +} + +export const createRlsDatabase = (database: AppDatabase): RlsDatabase => { + let identity = anonymousIdentity + let transactionTail: Promise = Promise.resolve() + + const runSerialized = (operation: () => Promise): Promise => { + const result = transactionTail.then(operation, operation) + transactionTail = result.then(() => undefined, () => undefined) + return result + } + + return { + setIdentity(nextIdentity) { + identity = { ...nextIdentity } + }, + transaction(operation) { + // Snapshot now so later authentication/context changes cannot alter work + // that has already been queued on this request's single pg Client. + const transactionIdentity = { ...identity } + + return runSerialized(() => database.transaction(async (tx) => { + // Hyperdrive uses transaction pooling. Role and RLS settings must be + // LOCAL to this transaction so a pooled origin connection cannot carry + // one request's authorization context into another request. + await tx.execute(sql.raw('set local role app_rls')) + await tx.execute(sql` + select + set_config('app.current_user_id', ${transactionIdentity.userId}, true), + set_config('app.current_member_id', ${transactionIdentity.memberId ?? ''}, true), + set_config('app.current_user_role', ${transactionIdentity.role}, true), + set_config('statement_timeout', '15000', true), + set_config('lock_timeout', '5000', true), + set_config('idle_in_transaction_session_timeout', '5000', true) + `) + return operation(tx) + })) + }, + } +} + +const verifyRuntimeRole = async (client: Client) => { + const result = await client.query(` select - set_config('app.current_user_id', '', false), - set_config('app.current_member_id', '', false), - set_config('app.current_user_role', '', false) + current_user as "roleName", + current_setting('is_superuser') = 'on' as "isSuperuser", + coalesce( + (select rolbypassrls from pg_roles where rolname = current_user), + true + ) as "bypassesRls", + pg_has_role(current_user, 'app_rls', 'USAGE') as "usesAppRls" `) + const probe = result.rows[0] + if (!probe) throw new Error('Could not verify the database runtime role') + assertSafeRuntimeRole(probe) } export const dbMiddleware = async (c: Context, next: Next) => { @@ -22,32 +128,18 @@ export const dbMiddleware = async (c: Context, next: Next) => { throw new Error('Database connection string (DATABASE_URL or HYPERDRIVE) is not set') } - const client = new Client({ - connectionString, - ssl: { - rejectUnauthorized: false - } - }) + // Hyperdrive and PostgreSQL connection strings own their TLS policy. Never + // disable certificate verification in application code. + const client = new Client({ connectionString }) await client.connect() - await resetRlsSession(client) - - const db = drizzle(client) - c.set('db', db) try { + await verifyRuntimeRole(client) + const authDb = drizzle(client, { schema }) + authDatabases.set(c, authDb) + c.set('db', createRlsDatabase(authDb)) await next() } finally { - try { - await resetRlsSession(client) - } catch (error) { - console.error('[DB Middleware] Failed to reset RLS settings:', error) - } - try { - await client.query('reset role') - await client.query('reset all') - } catch (error) { - console.error('[DB Middleware] Failed to reset database role:', error) - } c.executionCtx.waitUntil(client.end()) } } diff --git a/community/src/core/error.test.ts b/community/src/core/error.test.ts new file mode 100644 index 0000000..50ae660 --- /dev/null +++ b/community/src/core/error.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { errorHandler } from './error' + +test('internal errors are logged without exposing SQL or personal data', async () => { + const internalError = new Error('Failed query with params: student@example.test') + const logged: unknown[][] = [] + const originalConsoleError = console.error + console.error = (...values: unknown[]) => logged.push(values) + + try { + const response = await errorHandler(internalError, { + json: (body: unknown, status: number) => ({ body, status }), + } as never) + assert.deepEqual(response, { + body: { error: 'Internal Server Error' }, + status: 500, + }) + assert.deepEqual(logged, [['Unhandled Exception:', internalError]]) + } finally { + console.error = originalConsoleError + } +}) diff --git a/community/src/core/error.ts b/community/src/core/error.ts index 947fbc9..c5ec5f9 100644 --- a/community/src/core/error.ts +++ b/community/src/core/error.ts @@ -5,6 +5,5 @@ export const errorHandler: ErrorHandler = (err, c) => { console.error('Unhandled Exception:', err) return c.json({ error: 'Internal Server Error', - message: err instanceof Error ? err.message : 'Unknown error', }, 500) -} \ No newline at end of file +} diff --git a/community/src/core/types.ts b/community/src/core/types.ts index cae440d..301a186 100644 --- a/community/src/core/types.ts +++ b/community/src/core/types.ts @@ -1,29 +1,29 @@ -import { authUser, appUser } from './auth' -import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { appUser } from './auth' import type { CommunityProvider } from '../lib/community/interface' +import type { RlsDatabase } from './db' + +type HyperdriveBinding = { connectionString: string } export type CloudflareBindings = { - DATABASE_URL: string + DATABASE_URL?: string DISCORD_TOKEN: string DISCORD_GUILD_ID: string - SUPABASE_ID: string - SUPABASE_SECRET_KEY: string FRONTEND_URL: string COMMUNITY_URL: string + BETTER_AUTH_SECRET: string JWT_SECRET: string COOKIE_DOMAIN: string - GITHUB_CLIENT_ID: string - GITHUB_CLIENT_SECRET: string DISCORD_CLIENT_ID: string DISCORD_CLIENT_SECRET: string NODE_ENV: 'development' | 'production' | null - HYPERDRIVE?: any + DEV_USER_ID?: string + HYPERDRIVE?: HyperdriveBinding } export type AppContext = { Bindings: CloudflareBindings Variables: { - db: NodePgDatabase + db: RlsDatabase community: CommunityProvider appUser: appUser } diff --git a/community/src/index.ts b/community/src/index.ts index b7834c9..d35cba7 100644 --- a/community/src/index.ts +++ b/community/src/index.ts @@ -24,7 +24,7 @@ const app = new OpenAPIHono() return corsMiddleware(c, next) }) .use('/api/*', dbMiddleware) - .use('/api/*', communityMiddleware) + .use('/api/v0/*', communityMiddleware) .use('/api/v0/*', authMiddleware) .get('/health', (c: any): Response => c.json({ status: 'ok' })) diff --git a/community/src/lib/community/discord/main.ts b/community/src/lib/community/discord/main.ts index 460f9de..ba894ad 100644 --- a/community/src/lib/community/discord/main.ts +++ b/community/src/lib/community/discord/main.ts @@ -1,7 +1,7 @@ import type { CommunityProvider } from '../interface'; -import type { DiscordMessage, DiscordReactionUser, Role, SendMessageInput, SendMessageResult } from '../type'; +import type { DiscordGuildMembership, DiscordMessage, DiscordReactionUser, Role, SendMessageInput, SendMessageResult } from '../type'; import { CommunityProviderError } from '../error'; -import { listUserRolesAPI } from './role'; +import { getGuildMembershipAPI, listUserRolesAPI } from './role'; import { getMessageAPI, listMessageReactionUsersAPI, sendMessageAPI } from './message'; export class DiscordProvider implements CommunityProvider { @@ -35,6 +35,10 @@ export class DiscordProvider implements CommunityProvider { return listUserRolesAPI(this, userId); } + async getGuildMembership(userId: string): Promise { + return getGuildMembershipAPI(this, userId); + } + async sendMessage(input: SendMessageInput): Promise { return sendMessageAPI(this, input); } diff --git a/community/src/lib/community/discord/message.test.ts b/community/src/lib/community/discord/message.test.ts index 0be8f7d..4e5aa9a 100644 --- a/community/src/lib/community/discord/message.test.ts +++ b/community/src/lib/community/discord/message.test.ts @@ -21,6 +21,21 @@ const createProvider = (response: unknown) => { }; }; +const createPagedProvider = (responses: unknown[]) => { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + let index = 0; + + return { + provider: { + request: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }); + return responses[index++]; + }, + } as any, + calls, + }; +}; + test('sendMessageAPI posts an event notification to the Discord channel with role mentions', async () => { const { provider, calls } = createProvider({ id: '323456789012345678' }); @@ -108,3 +123,42 @@ test('listMessageReactionUsersAPI fetches and parses users who reacted to a mess bot: false, }]); }); + +test('listMessageReactionUsersAPI follows Discord pagination and excludes bots', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + id: String(600000000000000000n + BigInt(index)), + username: `user-${index}`, + global_name: null, + bot: false, + })); + const secondPage = [ + { + id: '700000000000000001', + username: 'last-user', + global_name: 'Last User', + bot: false, + }, + { + id: '700000000000000002', + username: 'ignored-bot', + global_name: null, + bot: true, + }, + ]; + const { provider, calls } = createPagedProvider([firstPage, secondPage]); + + const users = await listMessageReactionUsersAPI( + provider, + '123456789012345678', + '323456789012345678', + '✅', + ); + + assert.equal(users.length, 101); + assert.equal(calls.length, 2); + assert.equal( + calls[1].path, + '/channels/123456789012345678/messages/323456789012345678/reactions/%E2%9C%85?limit=100&after=600000000000000099', + ); + assert.equal(users.at(-1)?.username, 'last-user'); +}); diff --git a/community/src/lib/community/discord/message.ts b/community/src/lib/community/discord/message.ts index 4ac56f6..d1c6f5a 100644 --- a/community/src/lib/community/discord/message.ts +++ b/community/src/lib/community/discord/message.ts @@ -76,17 +76,33 @@ export async function listMessageReactionUsersAPI( messageId: string, emoji: string ): Promise { - const users = await provider.request( - 'GET', - `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}?limit=100` - ); + const users: DiscordReactionUser[] = []; + let after: string | undefined; - return users.map((user) => - DiscordReactionUserSchema.parse({ - id: user.id, - username: user.username, - globalName: user.global_name ?? null, - bot: user.bot ?? false, - }) - ); + while (true) { + const query = new URLSearchParams({ limit: '100' }); + if (after) query.set('after', after); + + const page = await provider.request( + 'GET', + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}?${query.toString()}` + ); + + const parsedPage = page.map((user) => + DiscordReactionUserSchema.parse({ + id: user.id, + username: user.username, + globalName: user.global_name ?? null, + bot: user.bot ?? false, + }) + ); + users.push(...parsedPage.filter((user) => !user.bot)); + + if (page.length < 100) break; + const nextAfter = parsedPage.at(-1)?.id; + if (!nextAfter || nextAfter === after) break; + after = nextAfter; + } + + return users; } diff --git a/community/src/lib/community/discord/oauth.test.ts b/community/src/lib/community/discord/oauth.test.ts new file mode 100644 index 0000000..28070ab --- /dev/null +++ b/community/src/lib/community/discord/oauth.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CommunityProviderError } from '../error'; +import { getCurrentDiscordUser } from './oauth'; + +test('getCurrentDiscordUser verifies the bearer token and maps the Discord profile', async () => { + const calls: Array<{ input: string; authorization: string | null }> = []; + const user = await getCurrentDiscordUser('oauth-token', async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + input: String(input), + authorization: headers.get('Authorization'), + }); + return Response.json({ + id: '123456789012345678', + username: 'club-member', + global_name: 'Club Member', + avatar: 'avatar-hash', + }); + }); + + assert.deepEqual(calls, [{ + input: 'https://discord.com/api/v10/users/@me', + authorization: 'Bearer oauth-token', + }]); + assert.deepEqual(user, { + id: '123456789012345678', + username: 'club-member', + globalName: 'Club Member', + avatarUrl: 'https://cdn.discordapp.com/avatars/123456789012345678/avatar-hash.png', + }); +}); + +test('getCurrentDiscordUser reports provider authentication failures without exposing the token', async () => { + await assert.rejects( + () => getCurrentDiscordUser('sensitive-token', async () => Response.json( + { message: '401: Unauthorized' }, + { status: 401 }, + )), + (error) => ( + error instanceof CommunityProviderError + && error.status === 401 + && !error.message.includes('sensitive-token') + ), + ); +}); + +test('getCurrentDiscordUser rejects malformed provider responses as a bad gateway', async () => { + await assert.rejects( + () => getCurrentDiscordUser('oauth-token', async () => Response.json({ username: '' })), + (error) => error instanceof CommunityProviderError && error.status === 502, + ); +}); diff --git a/community/src/lib/community/discord/oauth.ts b/community/src/lib/community/discord/oauth.ts new file mode 100644 index 0000000..b920937 --- /dev/null +++ b/community/src/lib/community/discord/oauth.ts @@ -0,0 +1,61 @@ +import { CommunityProviderError } from '../error'; +import { DiscordOAuthUser, DiscordOAuthUserSchema } from '../type'; + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +type DiscordOAuthProfile = { + id?: unknown; + username?: unknown; + global_name?: unknown; + avatar?: unknown; +}; + +const buildDiscordAvatarUrl = (profile: DiscordOAuthProfile): string | null => { + if (typeof profile.id !== 'string' || typeof profile.avatar !== 'string') { + return null; + } + + const format = profile.avatar.startsWith('a_') ? 'gif' : 'png'; + return `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`; +}; + +export const getCurrentDiscordUser = async ( + accessToken: string, + fetcher: typeof fetch = fetch, +): Promise => { + const response = await fetcher(`${DISCORD_API_BASE}/users/@me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const details = await response.json().catch(() => undefined); + throw new CommunityProviderError( + 'Discord OAuth user verification failed', + response.status, + 'discord', + details, + ); + } + + const profile = await response.json(); + const parsed = DiscordOAuthUserSchema.safeParse({ + id: profile.id, + username: profile.username, + globalName: profile.global_name ?? null, + avatarUrl: buildDiscordAvatarUrl(profile), + }); + + if (!parsed.success) { + throw new CommunityProviderError( + 'Discord OAuth user response was invalid', + 502, + 'discord', + parsed.error.issues, + ); + } + + return parsed.data; +}; diff --git a/community/src/lib/community/discord/role.test.ts b/community/src/lib/community/discord/role.test.ts new file mode 100644 index 0000000..50b9f0f --- /dev/null +++ b/community/src/lib/community/discord/role.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CommunityProviderError } from '../error'; +import { DiscordProvider } from './main'; +import { getGuildMembershipAPI } from './role'; + +test('getGuildMembershipAPI correlates the requested user and maps nickname and roles', async () => { + const provider = new DiscordProvider('bot-token', '223456789012345678'); + const calls: string[] = []; + const responses: unknown[] = [ + { + user: { id: '123456789012345678' }, + nick: '部内ニックネーム', + roles: ['323456789012345678'], + }, + [ + { id: '323456789012345678', name: '部員' }, + { id: '423456789012345678', name: '管理者' }, + ], + ]; + provider.request = async (_method: string, path: string): Promise => { + calls.push(path); + return responses.shift() as T; + }; + + const membership = await getGuildMembershipAPI(provider, '123456789012345678'); + + assert.deepEqual(calls, [ + '/guilds/223456789012345678/members/123456789012345678', + '/guilds/223456789012345678/roles', + ]); + assert.deepEqual(membership, { + userId: '123456789012345678', + nickname: '部内ニックネーム', + roles: [{ id: '323456789012345678', name: '部員' }], + }); +}); + +test('getGuildMembershipAPI rejects a guild member response for another user', async () => { + const provider = new DiscordProvider('bot-token', '223456789012345678'); + provider.request = async (): Promise => ({ + user: { id: '999456789012345678' }, + nick: null, + roles: [], + }) as T; + + await assert.rejects( + () => getGuildMembershipAPI(provider, '123456789012345678'), + (error) => error instanceof CommunityProviderError && error.status === 502, + ); +}); diff --git a/community/src/lib/community/discord/role.ts b/community/src/lib/community/discord/role.ts index eca4250..27894a5 100644 --- a/community/src/lib/community/discord/role.ts +++ b/community/src/lib/community/discord/role.ts @@ -1,17 +1,65 @@ import type { DiscordProvider } from './main'; -import { Role, RoleSchema } from '../type'; +import { + DiscordGuildMembership, + DiscordGuildMembershipSchema, + DiscordSnowflakeSchema, + Role, + RoleSchema, +} from '../type'; +import { CommunityProviderError } from '../error'; -export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { - const member = await provider.request('GET', `/guilds/${provider.guildId}/members/${userId}`); - const userRoleIds: string[] = member.roles; - const allRoles = await provider.request('GET', `/guilds/${provider.guildId}/roles`); +type DiscordGuildMemberResponse = { + user?: { id?: unknown }; + nick?: unknown; + roles?: unknown; +}; + +type DiscordRoleResponse = { + id?: unknown; + name?: unknown; +}; - return allRoles - .filter((role) => userRoleIds.includes(role.id)) - .map((role) => - RoleSchema.parse({ - id: role.id, - name: role.name, - }) +export async function getGuildMembershipAPI( + provider: DiscordProvider, + userId: string, +): Promise { + const expectedUserId = DiscordSnowflakeSchema.parse(userId); + const member = await provider.request( + 'GET', + `/guilds/${provider.guildId}/members/${expectedUserId}`, + ); + const returnedUserId = DiscordSnowflakeSchema.safeParse(member.user?.id); + + if (!returnedUserId.success || returnedUserId.data !== expectedUserId) { + throw new CommunityProviderError( + 'Discord guild member identity did not match the requested user', + 502, + 'discord', ); + } + + const memberRoleIds = DiscordSnowflakeSchema.array().parse(member.roles); + const allRoles = await provider.request( + 'GET', + `/guilds/${provider.guildId}/roles`, + ); + const roleMap = new Map( + allRoles.map((role) => { + const parsed = RoleSchema.parse(role); + return [parsed.id, parsed] as const; + }), + ); + + return DiscordGuildMembershipSchema.parse({ + userId: expectedUserId, + nickname: member.nick ?? null, + roles: memberRoleIds.flatMap((roleId) => { + const role = roleMap.get(roleId); + return role ? [role] : []; + }), + }); +} + +export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { + return (await getGuildMembershipAPI(provider, userId)).roles; } diff --git a/community/src/lib/community/interface.ts b/community/src/lib/community/interface.ts index 3b84239..ab4c7e5 100644 --- a/community/src/lib/community/interface.ts +++ b/community/src/lib/community/interface.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { + DiscordGuildMembership, DiscordMessage, DiscordReactionUser, Role, @@ -14,6 +15,7 @@ export interface CommunityProvider { // role listUserRoles(userId: string): Promise; + getGuildMembership(userId: string): Promise; // message sendMessage(input: SendMessageInput): Promise; diff --git a/community/src/lib/community/type.ts b/community/src/lib/community/type.ts index dfe2ef2..159f68b 100644 --- a/community/src/lib/community/type.ts +++ b/community/src/lib/community/type.ts @@ -9,6 +9,21 @@ export const RoleSchema = z.object({ }); export type Role = z.infer; +export const DiscordOAuthUserSchema = z.object({ + id: DiscordSnowflakeSchema, + username: z.string().min(1), + globalName: z.string().nullable(), + avatarUrl: z.string().url().nullable(), +}); +export type DiscordOAuthUser = z.infer; + +export const DiscordGuildMembershipSchema = z.object({ + userId: DiscordSnowflakeSchema, + nickname: z.string().nullable(), + roles: z.array(RoleSchema), +}); +export type DiscordGuildMembership = z.infer; + export const GetUserRolesInputSchema = z.object({ userId: z.string(), }); diff --git a/community/wrangler.jsonc b/community/wrangler.jsonc index eade1d2..fa8c303 100644 --- a/community/wrangler.jsonc +++ b/community/wrangler.jsonc @@ -54,7 +54,7 @@ { "binding": "HYPERDRIVE", "id": "9fdd8328502e4698ae4433453a49acd3", - "localConnectionString": "postgresql://postgres:password@localhost:5432/postgres" + "localConnectionString": "postgresql://app_runtime_login:password@localhost:5432/postgres" } ] -} \ No newline at end of file +} diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 8d40c9f..ca4d940 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,5 +1,22 @@ # Database migration policy +## Runtime portability + +The application does not use Supabase Auth, the Supabase JavaScript client, or +the Supabase Data API. Both Workers connect to the PostgreSQL connection string +with `pg` and `drizzle-orm/node-postgres`. In Workers, the connection string from +the Cloudflare Hyperdrive binding is preferred so connections are pooled; local +development falls back to `DATABASE_URL`. The shared application schema lives +in `share/drizzle/schema.ts`. Moving database providers therefore requires a +PostgreSQL connection string and running the reviewed SQL migrations, not +rewriting the application data-access layer. + +The `supabase/migrations/` path records the ledger of the database currently +hosted by Supabase. Its new application migrations are PostgreSQL SQL and the +directory name does not make Supabase a runtime dependency. Historical files +fetched from the old deployment are retained only so that the existing database +ledger can be audited. + ## Source of truth `supabase/migrations/` is the only executable migration history. The files @@ -8,7 +25,7 @@ ledger without changing their timestamps. The former Drizzle SQL journal was removed because it referenced a missing migration, omitted live migrations, and could not rebuild the Better Auth schema. -`share/drizzle/schema.ts` remains the application type definition. A schema +`share/drizzle/schema.ts` remains the application schema and type definition. A schema change is complete only when the Supabase migration and Drizzle schema agree. `drizzle-kit generate` may be used to inspect a diff, but its output is not an executable migration and is ignored under each package's `.drizzle/` folder. @@ -44,3 +61,78 @@ The membership workflow, fixed grade master, generic community identities, status history, and approved-member directory are introduced in a subsequent migration together with the code that consumes them. This avoids an interval where the database has advanced but the deployed application cannot run. + +## Membership workflow migration + +`20260716123951_membership_workflow.sql` never deletes identity, membership, or +grade data. It takes `ACCESS EXCLUSIVE` locks on `verification`, `session`, +`account`, `user`, `members`, and `grades`, and aborts unless all six tables are +empty. This makes destructive cleanup an explicit operator decision instead of +a migration side effect. `event_messages` are retained. + +For confirmed disposable data only, fill and run +`supabase/runbooks/purge_confirmed_test_membership_data.sql` first. The runbook +locks the same six tables and requires exact primary-key sets plus exact +account-to-user, session-to-user, user-to-member, and member-to-grade mappings. +Any mismatch aborts before deletion; issue `ROLLBACK`, investigate, and prepare +a new reviewed input set rather than weakening an assertion. + +The fixed grade master is B1-B4, M1-M2, D1-D3, OB/OG, and その他. Grade rows are +changed by migration only. + +Application uniqueness is deliberately individual, not composite: + +- `members.student_id` is trimmed, upper-cased, and unique. +- `members.student_email` is trimmed, lower-cased, and unique. +- `user.member_id` is unique when non-null. +- `account(provider_id, account_id)` is unique. +- `community_identities(provider, provider_account_id)` is unique. +- `community_identities(user_id, provider)` is unique. + +Rejected and withdrawn applications retain these identifiers because the same +member row is edited and resubmitted. Resolving a genuinely abandoned claim is +an explicit administrator operation, not a second application record. + +Membership state transitions are enforced by a database trigger, including the +actor role, review metadata, and an exact one-step application-version increment. +New applications can only start as `pending` version 1. A reviewed user cannot +be deleted while `members.reviewed_by_user_id` references it, and a member with +status history cannot be deleted implicitly. `member_status_history.changed_by_user_id` +is a required text snapshot with deliberately no user foreign key, so deleting +an authentication account does not null or erase the recorded actor identity. + +## Dedicated runtime login and Hyperdrive cutover + +Run `supabase/runbooks/provision_app_runtime_login.sql` through a private +operator connection after replacing its role and random-password placeholders +in a local, uncommitted copy. The runbook rejects database/object owners and +creates a `LOGIN INHERIT` role with no superuser, database/role creation, +replication, or RLS-bypass capability. Its only role membership is `app_rls`; +it receives no direct object privileges. + +`INHERIT` is required because Better Auth issues autocommit queries outside the +application's short request-context transactions. The inherited `app_rls` +policies allow those auth-table queries. Business tables still use forced RLS +and expose nothing with an empty request context; each business operation must +run in a short transaction that sets validated `app.current_*` values locally. + +Cut over without using the owner credential in application traffic: + +1. Store the dedicated login URL as a secret and create/update the Hyperdrive + configuration; URL-encode the generated password. +2. Connect as that role and run the verification queries printed by the + runbook. Confirm `pg_has_role(current_user, 'app_rls', 'USAGE')`, auth-table + access, and closed business-table RLS with empty context. +3. Deploy one Worker as a canary and verify sign-in plus a membership operation, + then move the remaining Worker to the same Hyperdrive configuration. +4. Keep the prior configuration only for a bounded rollback window. After both + Workers are healthy, rotate or revoke the old application credential. + +## Initial administrator + +There is no automatic first-admin or email-based bootstrap. After the first +operator signs in, explicitly links Discord, and verifies membership in the +target server, run the checked template in +`supabase/runbooks/promote_initial_admin.sql`. The template selects exactly one +user through the verified OAuth provider account and guild membership; it does +not embed a personal ID in a migration. diff --git a/docs/hyperdrive-security.md b/docs/hyperdrive-security.md new file mode 100644 index 0000000..53c04df --- /dev/null +++ b/docs/hyperdrive-security.md @@ -0,0 +1,111 @@ +# Hyperdrive security operations + +Both Workers access PostgreSQL through their `HYPERDRIVE` binding with `pg` and +Drizzle. Supabase SDK, Supabase Auth, and the Data API are not part of the +runtime path. + +## Security invariants + +1. **Use a dedicated database login.** Hyperdrive must never store `postgres`, + a database owner, a superuser, or a role with `BYPASSRLS`. Provision the + dedicated login from a private copy of + `supabase/runbooks/provision_app_runtime_login.sql`; never commit its + password. +2. **Keep authorization state transaction-local.** Hyperdrive uses transaction + pooling. Each protected database unit starts a short transaction, executes + `SET LOCAL ROLE app_rls`, installs the validated request identity with local + `set_config`, performs only its database work, and commits or rolls back. + Request identity must never be installed with session-level `SET`. +3. **Never wait on an external service in a database transaction.** Discord and + OAuth requests happen before or after the short database unit. This prevents + a slow upstream from pinning an origin connection and exhausting the pool. +4. **Fail closed on an unsafe login.** Worker database initialization rejects a + login that is a superuser, has `BYPASSRLS`, or cannot use `app_rls`. +5. **Disable query caching for authorization-sensitive traffic.** Authentication, + sessions, permissions, RLS-filtered directory results, admin data, and + read-after-write responses all share these bindings, so both Hyperdrive + configurations must have `caching.disabled: true`. +6. **Do not override TLS verification in application code.** The connection + string supplied by Hyperdrive owns the TLS policy. Never set + `rejectUnauthorized: false`. + +These rules follow Cloudflare's current guidance for +[transaction pooling](https://developers.cloudflare.com/hyperdrive/concepts/connection-pooling/), +[short connection lifecycles](https://developers.cloudflare.com/hyperdrive/concepts/connection-lifecycle/), +and [cache-disabled authorization reads](https://developers.cloudflare.com/hyperdrive/concepts/query-caching/). + +## Production change order + +Use a maintenance window. Do not deploy the new Worker code halfway through +this sequence. + +1. Back up the database and stop application writes. +2. Audit every existing test row, fill a private copy of + `supabase/runbooks/purge_confirmed_test_membership_data.sql`, and execute it. +3. Apply `20260716123951_membership_workflow.sql`. It refuses to run while any + replaced authentication/member/grade table is non-empty. +4. Fill and execute a private copy of + `supabase/runbooks/provision_app_runtime_login.sql` with a generated password + of at least 32 characters. +5. Update both Hyperdrive origin credentials through a secret-safe Cloudflare + surface. Do not put the password in source control, CI logs, chat, or shell + history. +6. Verify both configurations before deploying: + + ```bash + cd community + npx wrangler hyperdrive get 9fdd8328502e4698ae4433453a49acd3 + npx wrangler hyperdrive get b28287cdd8df4631b3f0b3014d36122a + ``` + + Each result must show `caching.disabled: true`, and the origin user must be + the dedicated login rather than `postgres`. +7. Deploy both Workers and exercise sign-in, Discord verification, application + submission, approval, and directory access. Restore writes only after these + checks pass. + +## Database verification + +Connect using the dedicated runtime credentials and verify the boundary: + +```sql +select + session_user, + current_user, + rolsuper, + rolbypassrls, + pg_has_role(current_user, 'app_rls', 'USAGE') as can_use_app_rls +from pg_roles +where rolname = current_user; + +reset all; +select count(*) from public.members; +``` + +The role must not be privileged, `can_use_app_rls` must be true, and the final +query must expose no business rows without a validated transaction-local +request context. Better Auth tables remain available to the runtime role so its +adapter can use normal autocommit queries. + +## TLS hardening + +Hyperdrive requires encrypted origin connections by default. If the database +CA for the deployment is available as a single regional certificate, upload it +to Cloudflare and move both configurations to `verify-full` so the certificate +chain and hostname are explicitly pinned. This requires a Cloudflare CA +certificate ID; do not select `verify-full` without first uploading the correct +database CA. See Cloudflare's +[Hyperdrive TLS certificate guide](https://developers.cloudflare.com/hyperdrive/configuration/tls-ssl-certificates-for-hyperdrive/). + +## Local development + +Keep each Worker's secrets in its own ignored `.dev.vars`. Hyperdrive's local +connection override is a Wrangler **process environment variable**, not a +normal Worker variable: + +```bash +export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE='postgresql://app_runtime_login:password@localhost:5432/postgres' +``` + +Do not copy the repository-wide `.env` into both Workers; that exposes secrets +to components that do not need them. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fa8fa86..c5a4508 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,8 +10,6 @@ "dependencies": { "@better-auth/utils": "^0.4.0", "@hono/zod-openapi": "^1.3.0", - "@supabase/ssr": "^0.10.2", - "@supabase/supabase-js": "^2.105.3", "@tanstack/react-query": "^5.100.9", "@tanstack/react-table": "^8.21.3", "better-auth": "^1.6.23", @@ -4222,104 +4220,6 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@supabase/auth-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.3.tgz", - "integrity": "sha512-hMFuzP++mjRfe0/BUq4/e82CXIDgyjUgg0khLN8waol/gzoM1t2iGmhfJSGvQHQ1dr3XqWpP6ThAw4bLHMot5Q==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.3.tgz", - "integrity": "sha512-KyutUwLLUZ9fRXsiFACL6lq7akBVHFl0fnqQnrxjbsPco8jeb4EyirQuvr52QCLnikzjMRC0uxAHOSM54aDrZA==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/phoenix": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", - "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", - "license": "MIT" - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.3.tgz", - "integrity": "sha512-jFVYRHcri0ZMcTzKpQ2r2wWOB8/rPsbj92kxmCmVJUiRrdgiMtuYlkS06Fhs8UJZhEOL0UpGhh06XDwh8JwtBQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.3.tgz", - "integrity": "sha512-L+qPiJlq1RKh3QD2fORGCFo2RKDKlvG9mjvPtUEQJ2tMixrx70VIV6j8BdWzQkbc1Nao6mvTWajyDhX3TFgljw==", - "license": "MIT", - "dependencies": { - "@supabase/phoenix": "^0.4.1", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/ssr": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz", - "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.2" - }, - "peerDependencies": { - "@supabase/supabase-js": "^2.102.1" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.3.tgz", - "integrity": "sha512-M7oPCCcHim/FsR6rKIs10Nd9mW051N2SQvA27jiVLa7oQMFFb7faX5dCQRV4GS5QeFsBcV5J/fWl4Ppoaw8cBQ==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.105.3", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.3.tgz", - "integrity": "sha512-5Dm9+I61LAWwjw+0zcqXhSmTxUJaYHBPyHwMCIBH4TBUNwDn2pYUIsi6oUu0I5r9HtLtaFl7w4wa+DV9gRsbDg==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.105.3", - "@supabase/functions-js": "2.105.3", - "@supabase/postgrest-js": "2.105.3", - "@supabase/realtime-js": "2.105.3", - "@supabase/storage-js": "2.105.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4778,6 +4678,7 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4814,15 +4715,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", @@ -6349,6 +6241,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8457,15 +8350,6 @@ "ms": "^2.0.0" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -12113,6 +11997,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unenv": { @@ -12953,6 +12838,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/frontend/package.json b/frontend/package.json index 59ef4f4..b6c0b30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,6 @@ "dependencies": { "@better-auth/utils": "^0.4.0", "@hono/zod-openapi": "^1.3.0", - "@supabase/ssr": "^0.10.2", - "@supabase/supabase-js": "^2.105.3", "@tanstack/react-query": "^5.100.9", "@tanstack/react-table": "^8.21.3", "better-auth": "^1.6.23", diff --git a/frontend/src/app/admin/_components/AdminMemberDetail.tsx b/frontend/src/app/admin/_components/AdminMemberDetail.tsx new file mode 100644 index 0000000..edd9ed2 --- /dev/null +++ b/frontend/src/app/admin/_components/AdminMemberDetail.tsx @@ -0,0 +1,281 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { client } from '@/lib/client' +import AdminMemberHeader from './AdminMemberHeader' +import AdminMemberEditForm from './AdminMemberEditForm' +import MemberStatusBadge from './MemberStatusBadge' +import { + MemberApiError, + formatMemberDate, + memberStatusLabels, + normalizeRejectionReason, + readMemberApiError, + type AdminMemberDetail as AdminMemberDetailData, +} from '../_lib/adminMemberUtils' + +const Field = ({ label, children, mono = false }: { label: string; children: React.ReactNode; mono?: boolean }) => ( +
+
{label}
+
{children || '—'}
+
+) + +export default function AdminMemberDetail({ memberId }: { memberId: string }) { + const queryClient = useQueryClient() + const [rejectReason, setRejectReason] = useState('') + const [decision, setDecision] = useState<'approve' | 'reject' | null>(null) + const [decisionError, setDecisionError] = useState(null) + const [notice, setNotice] = useState(null) + + const { data: member, isLoading, error, refetch } = useQuery({ + queryKey: ['admin-member', memberId], + queryFn: async () => { + const response = await client.api.v0.member[':id'].$get({ param: { id: memberId } }) + if (response.status !== 200) { + throw await readMemberApiError(response, '部員詳細の取得に失敗しました') + } + return await response.json() as AdminMemberDetailData + }, + }) + + const refreshAfterDecision = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-member', memberId] }), + queryClient.invalidateQueries({ queryKey: ['admin-members'] }), + ]) + } + + const approve = async () => { + if (!member || !window.confirm(`${member.name}さんの入部申請を承認しますか?`)) return + setDecision('approve') + setDecisionError(null) + setNotice(null) + + try { + const response = await client.api.v0.member[':id'].approve.$post({ + param: { id: memberId }, + json: { expectedVersion: member.applicationVersion }, + }) + if (response.status !== 200) { + throw await readMemberApiError(response, '承認に失敗しました') + } + setNotice('入部申請を承認しました。') + await refreshAfterDecision() + } catch (decisionFailure) { + const message = decisionFailure instanceof MemberApiError && decisionFailure.status === 409 + ? '申請が別の操作で更新されました。最新状態を読み込みました。内容を確認してから再操作してください。' + : decisionFailure instanceof Error ? decisionFailure.message : '承認に失敗しました。' + setDecisionError(message) + if (decisionFailure instanceof MemberApiError && decisionFailure.status === 409) await refetch() + } finally { + setDecision(null) + } + } + + const reject = async () => { + if (!member) return + const reason = normalizeRejectionReason(rejectReason) + if (!reason) { + setDecisionError('却下理由を入力してください。') + return + } + if (!window.confirm(`${member.name}さんの入部申請を却下しますか?`)) return + + setDecision('reject') + setDecisionError(null) + setNotice(null) + try { + const response = await client.api.v0.member[':id'].reject.$post({ + param: { id: memberId }, + json: { expectedVersion: member.applicationVersion, reason }, + }) + if (response.status !== 200) { + throw await readMemberApiError(response, '却下に失敗しました') + } + setRejectReason('') + setNotice('入部申請を却下しました。') + await refreshAfterDecision() + } catch (decisionFailure) { + const message = decisionFailure instanceof MemberApiError && decisionFailure.status === 409 + ? '申請が別の操作で更新されました。最新状態を読み込みました。内容を確認してから再操作してください。' + : decisionFailure instanceof Error ? decisionFailure.message : '却下に失敗しました。' + setDecisionError(message) + if (decisionFailure instanceof MemberApiError && decisionFailure.status === 409) await refetch() + } finally { + setDecision(null) + } + } + + const loadError = error instanceof MemberApiError && error.status === 403 + ? '管理者権限がないため、この情報は閲覧できません。' + : error instanceof MemberApiError && error.status === 404 + ? '指定された部員・申請は見つかりません。' + : error instanceof Error ? error.message : null + + if (isLoading) { + return
部員詳細を読み込んでいます…
+ } + + if (!member || loadError) { + return ( +
+
+

{loadError || '部員詳細を表示できません。'}

+
+ + 台帳へ戻る +
+
+
+ ) + } + + return ( +
+
+ + +
+
+ + 申請バージョン {member.applicationVersion} +
+ + 一覧へ戻る + +
+ + {(notice || decisionError) && ( +
+ {decisionError || notice} +
+ )} + +
+
+

登録情報

+
+
+ {member.name} + {member.displayGrade} + {member.studentId} + {member.studentEmail} + {member.userEmail} + {member.emergencyContact} + {member.insurance ? '加入済み' : '未加入'} + {member.someAllergy ? '申告あり' : '申告なし'} + {member.allergyDetails || '—'} + {formatMemberDate(member.submittedAt)} + {formatMemberDate(member.reviewedAt)} + {member.userId} + {member.memberId} + {formatMemberDate(member.updatedAt)} + {member.reviewReason || '—'} +
+
+ + { + setNotice(feedback?.type === 'success' ? feedback.message : null) + setDecisionError(feedback?.type === 'error' ? feedback.message : null) + }} + /> + +
+
+

Discord本人確認

+ {member.discord ? ( +
+ {member.discord.username} + {member.discord.providerDisplayName || '—'} + {member.discord.nickname || '—'} + {member.discord.communityId} + {member.discord.roles.join(', ') || '—'} + {formatMemberDate(member.discord.verifiedAt)} + {formatMemberDate(member.discord.lastCheckedAt)} +
+ ) : ( +

Discordの検証済み情報がありません。

+ )} +
+ +
+

公開プロフィール

+ {member.directoryProfile ? ( +
+ {member.directoryProfile.displayName} + {member.directoryProfile.directoryVisible ? '公開' : '非公開'} + {member.directoryProfile.skills.join(', ') || '—'} + {member.directoryProfile.interests.join(', ') || '—'} +
{member.directoryProfile.currentActivities || '—'}
+
{member.directoryProfile.bio || '—'}
+
+ ) : ( +

公開プロフィールはまだありません。

+ )} +
+
+ + {member.memberStatus === 'pending' && ( +
+

申請を審査

+

承認前に登録情報とDiscord本人確認を照合してください。却下には理由が必須です。

+
+