このレシピは @event/networking(実装済み・純ライブラリ)をイベント app に配線する完全手順。 真実源は content/directory.json なので Neon 不要で dev が動く(本番で DB を使う場合は loadDirectorySubjects を registry テーブル取得に差し替えるだけ)。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/networking": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/networking" を足す。
2. 機能を有効化
apps/<app>/src/event.config.ts の features で:
networking: true,
FEATURES に networking descriptor が無ければ足す(starter には既に入っている):
{
key: "networking",
label: "マッチング & ディレクトリ",
description: "名鑑ディレクトリ + entity-linking + privacy 射影(@event/networking、dev は content/directory.json)",
nav: [
{ id: "directory-admin", group: "ネットワーキング", label: "ディレクトリ", href: "/admin/directory", code: "DIR", order: 1 },
],
env: [],
manualChapters: [32, 33, 34],
},
DB を要求しない(dev は content/directory.json、requires 無し)。
3. サンプルデータ
apps/<app>/content/directory.json(privacy.ts の RegistrySubject 形 + 表示/マッチ用フィールド):
[
{
"slug": "yamada-taro",
"name": "山田 太郎",
"org": "株式会社ゼットフィルムズ",
"cohort": 3,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "slug",
"aliases": ["やまだ たろう", "Taro Yamada"],
"contextTerms": ["映像", "東京"],
"consents": [
{ "granted": true, "scope": "list_with_name" },
{ "granted": true, "scope": "contact" }
]
},
{
"slug": "suzuki-hanako",
"name": "鈴木 花子",
"org": "フォーエス株式会社",
"cohort": 3,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "manual",
"aliases": ["すずき はなこ"],
"contextTerms": ["イベント運営"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
},
{
"slug": "sato-ken",
"name": "佐藤 健",
"org": "株式会社ゼットフィルムズ",
"cohort": 3,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "slug",
"aliases": [],
"contextTerms": ["映像"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
},
{
"slug": "takahashi-mei",
"name": "高橋 芽衣",
"org": "一般社団法人スパイクス",
"cohort": 3,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "slug",
"aliases": ["たかはし めい"],
"contextTerms": ["学生団体", "関西"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
},
{
"slug": "tanaka-riku",
"name": "田中 陸",
"org": "フォーエス株式会社",
"cohort": 3,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "manual",
"aliases": [],
"contextTerms": ["イベント運営"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
},
{
"slug": "ito-yui",
"name": "伊藤 結衣",
"org": "株式会社ゼットフィルムズ",
"cohort": 4,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "slug",
"aliases": ["いとう ゆい"],
"contextTerms": ["映像", "東京"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
},
{
"slug": "watanabe-sora",
"name": "渡辺 空",
"org": "一般社団法人スパイクス",
"cohort": 4,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "slug",
"aliases": [],
"contextTerms": ["学生団体"],
"consents": [{ "granted": true, "scope": "contact" }]
},
{
"slug": "nakamura-jun",
"name": "中村 純",
"org": "フォーエス株式会社",
"cohort": 5,
"publishState": "public",
"suppressed": false,
"minor": false,
"matchMethod": "name_norm",
"aliases": [],
"contextTerms": ["イベント運営"],
"consents": [{ "granted": true, "scope": "list_with_name" }]
}
]
このサンプルは 3 種の非掲載を仕込んである: watanabe-sora(consent scope が contact のみ = 氏名掲載同意なし → listable ×)、nakamura-jun(matchMethod=name_norm = 氏名だけの自動マッチ → publishable ×)。残り 6 名が掲載され、cohort 3 は 5 名で k匿名を通過、cohort 4 は 1 名で内訳が秘匿される。
4. 配線ファイル(lib)
apps/<app>/src/lib/networking.ts:
import {
applyKAnonymity,
buildDictionary,
canListWithName,
evaluateListingGrants,
isSubjectPublishable,
linkFromText,
matchName,
safeScalar,
sanitizeSubjectsForPublic,
type ConsentRecord,
type CountCell,
type Dictionary,
type ListingGrants,
type MatchResult,
type RegistrySubject,
} from "@event/networking";
import { store as contentStore } from "@/lib/cms"; // dev fs fallback(content/directory.json)
export interface DirectorySubject extends RegistrySubject {
name: string;
org: string;
cohort: number;
aliases?: string[];
contextTerms?: string[];
consents?: ConsentRecord[];
}
export interface PublicDirectoryCard {
slug: string;
name: string;
org: string;
cohort: number;
}
export interface CohortCount extends CountCell {
cohort: number;
count: number;
}
export interface SubjectAudit {
subject: DirectorySubject;
publishable: boolean;
grants: ListingGrants;
}
const CONTENT_KEY = "directory";
export async function loadDirectorySubjects(): Promise<DirectorySubject[]> {
return contentStore.readResourceFresh<DirectorySubject[]>(CONTENT_KEY, []);
}
// 二層防御: consent gating → 公開射影。
export function projectPublicDirectory(
subjects: readonly DirectorySubject[],
): PublicDirectoryCard[] {
const consented = subjects.filter((s) => canListWithName(s, s.consents ?? []));
return sanitizeSubjectsForPublic<DirectorySubject, PublicDirectoryCard>(consented, (s) => ({
slug: s.slug,
name: s.name,
org: s.org,
cohort: s.cohort,
}));
}
export async function getPublicDirectory(): Promise<PublicDirectoryCard[]> {
return projectPublicDirectory(await loadDirectorySubjects());
}
export function cohortBreakdown(cards: readonly PublicDirectoryCard[]): CohortCount[] {
const counts = new Map<number, number>();
for (const c of cards) counts.set(c.cohort, (counts.get(c.cohort) ?? 0) + 1);
const cells: CohortCount[] = [...counts.entries()]
.map(([cohort, count]) => ({ cohort, count }))
.sort((a, b) => a.cohort - b.cohort);
return applyKAnonymity(cells);
}
export function publicTotal(cards: readonly PublicDirectoryCard[]): number | null {
return safeScalar(cards.length);
}
export function buildDirectoryDictionary(subjects: readonly DirectorySubject[]): Dictionary {
return buildDictionary(
subjects.map((s) => ({
id: s.slug ?? s.name,
canonicalName: s.name,
aliases: s.aliases,
contextTerms: s.contextTerms ?? [s.org],
})),
);
}
export function matchDemo(query: string, subjects: readonly DirectorySubject[]): MatchResult {
return matchName(query, buildDirectoryDictionary(subjects));
}
export function linkDemo(text: string, subjects: readonly DirectorySubject[]): MatchResult[] {
return linkFromText(text, buildDirectoryDictionary(subjects));
}
export function auditSubjects(subjects: readonly DirectorySubject[]): SubjectAudit[] {
return subjects.map((subject) => ({
subject,
publishable: isSubjectPublishable(subject),
grants: evaluateListingGrants(subject, subject.consents ?? []),
}));
}
5. 公開名鑑ページ(認証不要・feature-gate 不要)
apps/<app>/src/app/directory/page.tsx:
import { K_MIN } from "@event/networking";
import { cohortBreakdown, getPublicDirectory, publicTotal } from "@/lib/networking";
export const dynamic = "force-dynamic";
const panel: React.CSSProperties = {
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "16px 20px",
marginBottom: 24,
};
export default async function DirectoryPage() {
const cards = await getPublicDirectory();
const cohorts = cohortBreakdown(cards);
const total = publicTotal(cards);
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
<h1 style={{ fontSize: 26, margin: "0 0 4px" }}>参加者ディレクトリ</h1>
<p style={{ fontSize: 13, opacity: 0.65, margin: "0 0 20px" }}>
氏名つき掲載に同意した参加者のみ表示。集計は {K_MIN} 件未満のセルを秘匿する(k匿名)。
</p>
<section style={panel}>
<h2 style={{ fontSize: 14, margin: "0 0 8px" }}>掲載 {total ?? `< ${K_MIN}`} 名</h2>
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
{cards.map((c) => (
<li key={c.slug} style={{ marginBottom: 6 }}>
<b>{c.name}</b>
<span style={{ color: "var(--ink-3)", fontSize: 12, marginLeft: 8 }}>
{c.org} / {c.cohort}期
</span>
</li>
))}
</ul>
</section>
<section style={panel}>
<h2 style={{ fontSize: 14, margin: "0 0 8px" }}>期(cohort)別内訳 — k匿名</h2>
{cohorts.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--ink-3)", margin: 0 }}>
{K_MIN} 件以上のセルがありません。
</p>
) : (
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
{cohorts.map((cell) => (
<li key={cell.cohort}>
{cell.cohort}期 <b>{cell.count}</b> 名
</li>
))}
</ul>
)}
</section>
</main>
);
}
6. admin 監査ページ(adminAuth + feature-gate)
apps/<app>/src/app/admin/directory/page.tsx:
import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { AUTO_MIN, REVIEW_MIN } from "@event/networking";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { auditSubjects, linkDemo, loadDirectorySubjects, matchDemo } from "@/lib/networking";
export const dynamic = "force-dynamic";
export default async function AdminDirectoryPage() {
if (!features.isEnabled("networking")) notFound();
const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");
const subjects = await loadDirectorySubjects();
const audit = auditSubjects(subjects);
const match = matchDemo("やまだ たろう", subjects);
const links = linkDemo("本日の登壇者は山田太郎と鈴木花子です。", subjects);
return (
<main style={{ maxWidth: 820, margin: "0 auto", padding: "48px 24px" }}>
<h1 style={{ fontSize: 26, margin: "0 0 16px" }}>ディレクトリ管理</h1>
<table style={{ borderCollapse: "collapse", width: "100%", marginBottom: 24 }}>
<thead>
<tr>
{["slug", "名称", "publishState", "matchMethod", "期", "publishable", "listable"].map((h) => (
<th key={h} style={{ textAlign: "left", fontSize: 11, color: "var(--ink-3)", padding: "4px 10px" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{audit.map(({ subject, publishable, grants }) => (
<tr key={subject.slug ?? subject.name}>
<td style={{ fontSize: 12, padding: "4px 10px" }}>{subject.slug}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.name}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.publishState}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.matchMethod}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.cohort}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{publishable ? "◯" : "×"}</td>
<td style={{ fontSize: 13, padding: "4px 10px" }}>{grants.listWithName ? "◯" : "×"}</td>
</tr>
))}
</tbody>
</table>
<p style={{ fontSize: 13 }}>
matchName(しきい値 auto ≥ {AUTO_MIN} / review {REVIEW_MIN}): 「やまだ たろう」→{" "}
<b>{match.best?.canonicalName ?? "候補なし"}</b> / {match.decision} / score {match.best?.score ?? 0}
</p>
<p style={{ fontSize: 13 }}>
linkFromText: {links.map((r) => r.best?.canonicalName).filter(Boolean).join(" · ") || "抽出なし"}
</p>
</main>
);
}
7. マニュアル側の登録(任意)
apps/manual/src/content/index.ts に章 32 を import して CHAPTERS に足し、 apps/manual/src/lib/toc.ts の c(32, ...) を "published" にすると guide に「マッチング & ディレクトリ」章が現れる(33/34 は未執筆なら planned のまま)。
8. インストールと型チェック
monorepo root で:
yarn install
yarn workspace <app> typecheck # 対象 app にスコープ
9. 検証(Neon 無しで動く)
yarn workspace <app> dev
GET /directory(認証不要)→ 掲載 6 名。cohort 3 は「5 名」、cohort 4 は k匿名で内訳に出ない。
watanabe-sora(同意 scope 不足)と nakamura-jun(name_norm)は載らない。
GET /admin/directory(dev bypass でログイン不要)→ 8 件の表で publishable/listable の ◯× が割れる。
matchName「やまだ たろう」→ 山田 太郎 / auto、linkFromText → 山田 太郎・鈴木 花子。
content/directory.jsonを編集して掲載/非掲載が即反映されることを確認。
完了条件: typecheck が通り、/directory が二層防御で 6 名を出し、/admin/directory の監査表と entity-linking デモが描画される。