Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
77689f3
chore(deps): add graphql-yoga stack and tsconfig for BFF type checking
turtton Jul 19, 2026
79b745f
feat(bff): add GraphQL SDL schema for Account/Profile/Metadata
turtton Jul 19, 2026
b5885ec
chore(spago): bump package set to 77.7.0 and add graphql-client
turtton Jul 19, 2026
f4f80f9
feat(bff): add EmumetClient abstraction, real implementation, session…
turtton Jul 19, 2026
4b9df65
feat(bff): implement resolvers with DataLoader and auth guards
turtton Jul 19, 2026
0babb43
feat(bff): port stateful mock API to MockEmumetClient
turtton Jul 19, 2026
f65a382
feat(bff): serve GraphQL endpoint via graphql-yoga with mock/real switch
turtton Jul 19, 2026
7dad88c
feat(scripts): add sync-graphql.ts and generated PureScript GraphQL t…
turtton Jul 19, 2026
2ab6f03
test(bff): add resolver, tristate, session, dataloader, and real-cont…
turtton Jul 20, 2026
aa9d8fc
feat(client): add GraphQL query layer with stable app-owned DTO types
turtton Jul 20, 2026
f4160ff
refactor(client): migrate data fetching to GraphQL and drop stale-ref…
turtton Jul 20, 2026
78c466d
fix(deps): use graphql v16 to satisfy @urql/core subpath imports in c…
turtton Jul 20, 2026
fa03bfd
chore: remove legacy REST proxy, mock REST API, and OpenAPI codegen p…
turtton Jul 20, 2026
a62e1d3
docs: update README and AGENTS.md for GraphQL BFF architecture
turtton Jul 20, 2026
1ad7c01
fix(bff): sanitize nested field loader errors
turtton Jul 20, 2026
890104f
fix(ssr): serve theme-boot script as static file to avoid HTML escaping
turtton Jul 20, 2026
8f2a7d7
style: apply purs-tidy formatting and format generated types on sync
turtton Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Development tools are managed via Nix flake + direnv. Entering the project direc
- **Bundle server:** `spago bundle --platform node --module Server --outfile dist/server.js --bundle-type module`
- **Bundle CSS:** `bunx @tailwindcss/cli -i src/style.css -o dist/style.css`
- **Run tests:** `spago test`
- **BFF tests:** `bun test`
- **GraphQL codegen:** `bun scripts/sync-graphql.ts` (regenerate PureScript types from `bff/schema.graphql`)
- **Install JS dependencies:** `bun install`

## Architecture
Expand All @@ -27,17 +29,32 @@ Development tools are managed via Nix flake + direnv. Entering the project direc
- **SSR:** `Server.purs` renders full HTML via `Flame.Renderer.String`, with serialized state embedded in `<template-state>` for hydration
- **Client:** `Client.purs` hydrates SSR HTML via `Flame.resumeMount`, then handles client-side routing with `matchesWith`
- **Styling:** Tailwind CSS v4 — utility classes applied directly via `HA.class'` in PureScript views; `src/style.css` is the entry point with `@source "../src/**/*.purs"` to scan PureScript files for class names
- **Auth (BFF):** `index.ts` implements BFF (Backend-for-Frontend) auth — mock mode (built-in) and real mode (Kratos + Hydra OAuth2 PKCE). Session is stored in an AES-GCM encrypted HttpOnly cookie (`ratcap_session`). `/api/*` proxy injects Bearer token from session cookie.
- **Dev server:** `index.ts` — Bun.serve() handles SSR (all routes), static files (`/app.js`, `/style.css`), auth endpoints (`/auth/*`), and API proxy (`/api/*`)
- **Auth (BFF):** `index.ts` implements BFF (Backend-for-Frontend) auth — mock mode (built-in) and real mode (Kratos + Hydra OAuth2 PKCE). Session is stored in an AES-GCM encrypted HttpOnly cookie (`ratcap_session`).
- **Data API (BFF):** `bff/server.ts` serves `/graphql` via graphql-yoga. The schema is defined in `bff/schema.graphql` (single source of truth), with resolvers in `bff/resolvers.ts`. Session resolution happens in `bff/context.ts` via `SessionAdapter` DI. DataLoaders in `bff/loaders.ts` batch Emumet API calls per request. Errors use `extensions.code` (UNAUTHENTICATED / NOT_FOUND / INTERNAL_SERVER_ERROR). Set-Cookie via WeakMap + yoga onResponse plugin.
- **Dev server:** `index.ts` — Bun.serve() handles SSR (all routes), static files (`/app.js`, `/style.css`), auth endpoints (`/auth/*`), and GraphQL (`/graphql`)
- **Dev script:** `scripts/dev.sh` — triple bundle (client + server + CSS) + watchexec (auto-rebuild on `.purs` changes) + Tailwind `--watch` + Bun dev server
- **PureScript packages:** managed by `spago.yaml`, uses registry package set
- **PureScript packages:** managed by `spago.yaml`, uses registry package set (includes `graphql-client` for /graphql queries)
- **JS dependencies:** managed by `package.json` / `bun.lock`

### Module Structure

```
bff/
schema.graphql -- GraphQL SDL (single source of truth for the data API)
schema.ts -- SDL loader + makeExecutableSchema
server.ts -- Yoga handler: createYogaHandler(adapter, createEmumetClient)
context.ts -- Session resolution + auth context (SessionAdapter DI)
session.ts -- Session foundation: seal/unseal, cookie helpers, refresh, SessionAdapter interface
loaders.ts -- DataLoader: profile + metadata, request-scoped, memoized per request
resolvers.ts -- Query/Mutation resolvers
emumet/
client.ts -- EmumetClient interface + DTOs (camelCase)
real.ts -- REST-backed EmumetClient (snake_case ↔ camelCase mapping)
mock.ts -- In-memory stateful MockEmumetClient
*.test.ts -- BFF test files (bun test)
src/
style.css -- Tailwind CSS entry point (@import "tailwindcss" + @source)
Generated/ -- Auto-generated PureScript GraphQL types (from bff/schema.graphql via sync-graphql.ts; never hand-edit)
App/
Route.purs -- Route ADT (Home, Settings, AccountNew, AccountDetail, Login) + routing-duplex codec
Model.purs -- Model type (Maybe Route, PageModel, SessionInfo, isHydrated) + JSON instances
Expand All @@ -46,10 +63,9 @@ src/
Api/
Client.purs -- Generic HTTP client (Affjax-based: get, post, put, delete)
Auth.purs -- Auth API calls (login, checkSession, logout) via BFF /auth/* endpoints
Emumet/
Client.purs -- Emumet API wrappers over Api.Client
Types.purs -- Auto-generated Emumet API types (DO NOT EDIT)
Tristate.purs -- Tristate type for optional update fields
GraphQL.purs -- GraphQL client (graphql-client): queries + mutations over /graphql
GraphQL/
Types.purs -- App-owned stable DTOs (AccountResponse, ProfileResponse, etc.)
View/
Layout.purs -- HTML shell (<html>/<head>/<body>) + auth-aware navigation
Login.purs -- Login page view (email + password form)
Expand All @@ -72,9 +88,7 @@ src/
scripts/
dev.sh -- Dev server (build + watch + serve)
register-hydra-client.ts -- Register OAuth2 client in Hydra (real mode setup)
fetch-openapi.ts -- Fetch OpenAPI spec from Emumet
generate-api.ts -- Generate PureScript types from OpenAPI spec
sync-api.ts -- Sync API types (fetch + generate)
sync-graphql.ts -- Generate PureScript types from bff/schema.graphql (SDL → introspection → src/Generated/)
```

### Key Design Decisions
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ COOKIE_SECRET_BASE64="$YOUR_PERSISTENT_SECRET" USE_MOCK=false bun index.ts
```bash
spago build # ビルド
spago test # テスト
bun test # BFF テスト (bff/ 配下の Jest テスト)
bun index.ts # サーバー起動(要事前ビルド)
bun scripts/sync-graphql.ts # SDL → PureScript 型生成 (bff/schema.graphql 変更時)
```

## 認証

`index.ts` は BFF (Backend-for-Frontend) パターンで認証を処理します。Mock モードと Real モード (Kratos + Hydra) の 2 つの動作モードがあります。
`index.ts` は BFF (Backend-for-Frontend) パターンで認証とデータ API を処理します。データ API は graphql-yoga を使った `/graphql` エンドポイントで提供され、GraphQL クライアントは PureScript 側で `graphql-client` ライブラリを使います。認証は引き続き REST のままです (`/auth/*` エンドポイント)。Mock モードと Real モード (Kratos + Hydra) の 2 つの動作モードがあります。

### 起動モード

Expand Down Expand Up @@ -113,3 +115,13 @@ bun scripts/register-hydra-client.ts

- **メールアドレス**: 任意の文字列
- **パスワード**: `password`

### GraphQL の型再生成

SDL スキーマ (`bff/schema.graphql`) を変更した場合、PureScript 側の型を再生成する必要があります。

```bash
bun scripts/sync-graphql.ts
```

`src/Generated/` 配下の型が再生成されます。生成物は git で管理 (コミット) する運用です。`--schema <path>` フラグで別の SDL ファイルを指定することもできます。
57 changes: 57 additions & 0 deletions bff/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// ============================================================
// GraphQL context — SessionAdapter 経由のセッション解決 + 認証
// アダプタと EmumetClient ファクトリは index.ts から DI される。
// ============================================================

import type { SessionAdapter } from "./session.ts";
import type { EmumetClient } from "./emumet/client.ts";
import { emptyLoaders, makeLoaders as createLoaders, type Loaders } from "./loaders.ts";

export type { Loaders } from "./loaders.ts";

export type GraphQLContext = {
readonly accessToken: string | null;
readonly emumet: EmumetClient | null;
/** 非 null ならレスポンスに Set-Cookie として付与する (refresh 成功時の seal 済み / 期限切れ時の clear-cookie) */
readonly sessionCookieHeader: string | null;
makeLoaders(): Loaders;
};

export type ContextDeps = {
readonly adapter: SessionAdapter<{ accessToken: string }>;
/** 認証済みリクエストでのみ呼ばれる。Real: token から Real client、Mock: プロセス共有の Mock client を返す */
readonly createEmumetClient: (accessToken: string) => EmumetClient;
};

export async function buildContext(req: Request, deps: ContextDeps): Promise<GraphQLContext> {
const session = await deps.adapter.getSession(req);
if (!session) return unauthenticatedContext(null);

const outcome = await deps.adapter.refreshSessionIfNeeded(session);
switch (outcome.kind) {
case "fresh":
case "refresh-failed-active":
return authenticatedContext(deps, outcome.accessToken, null);
case "refreshed":
return authenticatedContext(deps, outcome.accessToken, outcome.sessionCookieHeader);
case "refresh-failed-expired":
return unauthenticatedContext(outcome.sessionCookieHeader);
}
}

function authenticatedContext(deps: ContextDeps, accessToken: string, sessionCookieHeader: string | null): GraphQLContext {
const emumet = deps.createEmumetClient(accessToken);
// DataLoader のバッチングは同一インスタンス内でのみ発生するため、
// リクエスト単位で 1 インスタンスに遅延メモ化する
let loaders: Loaders | null = null;
return {
accessToken,
emumet,
sessionCookieHeader,
makeLoaders: () => (loaders ??= createLoaders(emumet)),
};
}

function unauthenticatedContext(sessionCookieHeader: string | null): GraphQLContext {
return { accessToken: null, emumet: null, sessionCookieHeader, makeLoaders: () => emptyLoaders };
}
104 changes: 104 additions & 0 deletions bff/emumet/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// ============================================================
// EmumetClient — Emumet REST API への抽象インターフェース
// DTO は bff/schema.graphql と同じ camelCase 形状。
// Real (real.ts) と Mock (mock.ts, T6) が同一契約で実装する。
// ============================================================

export type ModerationType = "SUSPENDED" | "BANNED";

export type Moderation = {
readonly type: ModerationType;
readonly reason: string;
readonly suspendedAt: string | null;
readonly expiresAt: string | null;
readonly bannedAt: string | null;
};

export type Account = {
readonly id: string;
readonly name: string;
readonly isBot: boolean;
readonly publicKey: string;
readonly createdAt: string;
readonly moderation: Moderation | null;
};

export type AccountConnection = {
readonly items: readonly Account[];
readonly first: string | null;
readonly last: string | null;
};

export type Profile = {
readonly nanoid: string;
readonly accountId: string;
readonly displayName: string | null;
readonly summary: string | null;
readonly iconUrl: string | null;
readonly bannerUrl: string | null;
};

export type Metadata = {
readonly nanoid: string;
readonly accountId: string;
readonly label: string;
readonly content: string;
};

export type CreateAccountInput = {
readonly name: string;
readonly isBot?: boolean;
};

/**
* Tristate 更新フィールド: undefined → JSON フィールド省略 (変更なし) /
* null → JSON null (クリア) / 値 → 値をセット
*/
export type ProfileFields = {
readonly displayName?: string | null;
readonly summary?: string | null;
readonly iconUrl?: string | null;
readonly bannerUrl?: string | null;
};

export type MetadataInput = {
readonly label: string;
readonly content: string;
};

/** Emumet API が想定外のステータスを返した場合のエラー */
export class EmumetApiError extends Error {
readonly status: number;
constructor(status: number, body: string) {
super(`Emumet API error: status=${status} body=${body}`);
this.name = "EmumetApiError";
this.status = status;
}
}

/** 対象リソースが存在しない (updateMetadata の再フェッチ欠損、deleteMetadata の 404 等) */
export class EmumetNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "EmumetNotFoundError";
}
}

/**
* バッチ契約: listProfiles / listMetadata は複数 accountId を 1 リクエストで取得する
* 基本操作 (DataLoader の batchFn からそのまま呼ばれる)。getProfile は
* listProfiles([id]) の薄いラッパとして各実装が提供する。
*/
export interface EmumetClient {
listAccounts(): Promise<AccountConnection>;
getAccount(id: string): Promise<Account | null>;
createAccount(input: CreateAccountInput): Promise<Account>;
listProfiles(accountIds: readonly string[]): Promise<readonly Profile[]>;
getProfile(accountId: string): Promise<Profile | null>;
/** プロフィール未存在なら作成 (POST)、存在すれば更新 (PUT)。戻り値は更新後の Profile */
upsertProfile(accountId: string, fields: ProfileFields): Promise<Profile>;
listMetadata(accountIds: readonly string[]): Promise<readonly Metadata[]>;
createMetadata(accountId: string, input: MetadataInput): Promise<Metadata>;
updateMetadata(accountId: string, nanoid: string, input: MetadataInput): Promise<Metadata>;
deleteMetadata(accountId: string, nanoid: string): Promise<void>;
}
Loading
Loading