内線・PTT 配布管理システム仕様書
1. システム概要
1.1 目的
学祭スタッフに配る 内線電話(SIP)と PTT(トランシーバ)の認証情報を、Sho-Room から発行・管理できるようにする。
現在この操作は、電話システム側の Worker が持つ /admin という HTML 画面から行っている。
この画面は 共有の ADMIN_TOKEN 1本 で守られており、次の問題がある。
- 誰が発行・変更したかが残らない。
- トークンの取り消しは、全員分を一斉に変えるしかない。
- トークンを知っている人は誰でも、名簿を自由に操作できる。
Sho-Room は Discord OAuth2 による個人単位の認証、権限ビットフラグ、BAN を既に持っている。 認可の作り直しをせずに、この最も弱い箇所だけを置き換える のが本システムの目的である。
1.2 基本方針
| 方針 | 理由 |
|---|---|
| UI だけを Sho-Room に移す。名簿の正は電話システム側に残す | D1 が別(soshosai-system-db / soshosai-phone)で 1 クエリでは跨げない。内線番号と SIP 資格情報は電話システムが所有し続けるのが素直 |
| 電話 Worker は 1 行も変更しない | 学祭本番が近く、PTT の実機試験がまだ終わっていない。既存の requireAdmin(Bearer + 定数時間比較)をそのまま使う |
| Service Binding で呼ぶ。公開インターネットに出さない | 電話 Worker は 2026-08-21 に学園祭アカウントへ移行済みで、モノレポと同一アカウント。リポジトリが別でも Service Binding を張れる |
| ADMIN_TOKEN は Sho-Room Worker の secret にのみ置く | ブラウザには一切出さない。誰が操作したかは Sho-Room の Discord ID で残る |
電話 Worker の /admin は非常用として残す | Sho-Room 経由にすると Discord OAuth・login-api・Gateway が全部生きている必要が出る。当日 QR を再発行できないのは詰み |
1.3 対象ユーザー
| 権限 | できること |
|---|---|
PHONE_VIEW | 配布状況の閲覧(誰が受け取ったか、内線番号、部署、role、最終利用) |
PHONE_MANAGE | エンロールトークンの発行・再発行、QR 表示、無効化・再有効化、削除、部署ごとの番台の割り当て、配布カード PDF の出力 |
role='hq'(緊急割り込みの権限)の付与には SYSTEM_MANAGE を要求する。
PHONE_MANAGE(発行・無効化)では通さない — 緊急割り込みは全チャンネルの会話を潰すため、
配布担当と同じ粒度で配らない。経緯は 7.2 を参照。
2. アーキテクチャ構成
2.1 全体像
2.2 配置
| 要素 | 置き場所 |
|---|---|
| 画面 | packages/web/sho-room/src/pages/PhoneSystem/ |
| BFF | packages/web/sho-room/worker/bff/phone.ts |
| ルート登録 | packages/web/sho-room/worker/index.ts(/api/bff/phone/* を generic proxy より前に判定する) |
| Service Binding | packages/web/sho-room/wrangler.jsonc に PHONE_PROVISION を追加 |
新しい system-worker は作らない。 worker/bff/monitoring.ts と visitors.ts が既に同じ形をしている。
Gateway に手を入れる必要もない。
2.3 なぜ Gateway ではなく BFF か
Gateway 配下の worker は soshosai-system-db を共有する前提で並んでいる。
電話システムは別 D1・別リポジトリで、そこに混ぜると「Gateway の下は system-db」という読み方が崩れる。
Sho-Room 専用の集約であることが明確なので、既存の BFF の流儀に従う。
2.4 認証・認可
worker/bff/monitoring.ts と同じ手順を踏む。
- Cookie から JWT を取り出す(
extractJwt)。 env.GATEWAY.fetch('https://gateway/staffs/me')で 署名を検証し、DB 由来のpermissionsを得る。- 必要なビットを検査する。
- 通ったら
env.PHONE_PROVISION.fetch()にAuthorization: Bearer <ADMIN_TOKEN>を付けて転送する。
フロントの
PermissionGuardだけで守らないこと。 既存の/api/*汎用プロキシは JWT の存在しか見ない(worker/proxies.ts)。 BFF 側でビットを検証しなければ、URL を直接叩けば通ってしまう。
2.5 環境の非対称に注意
Sho-Room は production / staging / preproduction / dev1〜5 の 8 環境ある。 電話 Worker は 単一デプロイしかない。
PHONE_PROVISION binding と ADMIN_TOKEN secret は production にのみ設定する。
他環境では BFF が 501 を返して塞ぐ。staging から本番の名簿を操作できる構成にしない。
3. 権限ビット
3.1 追加する値
packages/shared/src/permissions.ts の 1 << 0 〜 1 << 21(BOT_MANAGE)は使用済み。
空いている 1 << 23 から採る(1 << 22 は SHIFT_MANAGE が使用済み)。
// 内線・PTT 配布
PHONE_VIEW: 1 << 23, // 8388608 : 配布状況の閲覧
PHONE_MANAGE: 1 << 24, // 16777216 : エンロール発行・再発行・無効化
値をコードへ写さないこと。 @soshosai/shared から import する。
写経していたせいで、main 側が同じ 1 << 22 を SHIFT_MANAGE に使っても
どちらのテストも気づけない状態になっていた。
BASE_STAFF_PERMISSIONS には 含めない。 既定では誰も配布操作をできない状態から始める。
3.2 追加時の罠(2つ)
(1) フロントは shared を import せず手書きコピーしている。
packages/web/sho-room/src/hooks/useStaffAuth.ts の Permissions は shared の写しだが、
既にドリフトしている(EQUIPMENT_*・EVENT_*・BOT_MANAGE が抜けている)。
「shared と一致させること」というコメントは現状では嘘になっている。
ビットを足すときは両方に足す。あるいはこの機会に shared の import に寄せる
(config.ts が既に @soshosai/shared/environments を import しており、依存は張れている)。
(2) ALL_PERMISSIONS は動的計算なので、ビットを足すと値が変わる。
export const ALL_PERMISSIONS = Object.values(Permissions).reduce((a, b) => a | b, 0);
// hasPermission は === ALL_PERMISSIONS の完全一致で super_admin を判定している
DB の staffs.authority に古い ALL_PERMISSIONS の値が入っている最上位ユーザーは、
Discord ロールから再計算されるまで新ビットを持たない。
デプロイ直後に最上位でも権限が足りない時間帯が生じる。承知の上で進めること。
4. データベース
電話側(soshosai-phone)のスキーマは変更しない。 既存の users をそのまま使う。
| 列 | 用途 |
|---|---|
id | UUID。PTT の participantId としてそのまま使われる |
display_name | 表示名。アプリ側では変更できない(名簿とロスターを一致させるため) |
extension | 内線番号 |
sip_secret | SIP パスワード。外に出さない(5.2 参照) |
role | 'staff' / 'hq'。hq だけが緊急割り込みを打てる。変更には SYSTEM_MANAGE が要る(7.2) |
status | 'active' / 'disabled'。disabled にすると内線が止まり、緊急割り込みも数秒で止まる |
staff_id 列の追加(電話側に Sho-Room の Staffs を持たせること)は採らない。
本番前に名簿の正を持つ側のスキーマを触らない方針を優先する。
4.1 監査ログ
log-throw-man(LOGGER)へ BFF から送る。電話側にテーブルを作らない。
記録する内容: 操作種別 / 対象 user_id / 操作者の Discord ID / 変更前後の status。
sip_secret とエンロールトークンは記録しない。
/admin(非常用)経由の操作は記録されない。これは承知の上での妥協であり、
だからこそ通常時は Sho-Room 経由に寄せる。
4.2 Sho-Room 側のテーブル(0028)
電話側には部署の概念が無い。部署ごとの番台と、電話側利用者と委員の対応は
soshosai-system-db(staff-worker が所有)に置く。マイグレーションは
packages/shared/migrations/0028_phone_extension_admin.sql。
| テーブル | 内容 |
|---|---|
PhoneExtensionRanges | 部署ごとの番台(Block = 内線番号の千の位 1〜9)。行が無い部署は未設定で、電話 Worker の自動採番に任せる。Block には UNIQUE を張り、同じ番台を2部署に割り当てられないようにする |
PhoneUserLinks | 電話側 users.id ↔ Staffs.DiscordId ↔ Departments.DepartmentId。内線番号は表示用の写し(正は電話側)だが、採番の衝突判定にも使う |
PhoneUserLinks.DiscordId に UNIQUE は張っていない。1人が2本持つ場面(本部席の据置きと
自分の端末)が当日に出たとき、DB 制約で塞ぐと break-glass の /admin へ逃がすしかなくなる。
二重発行は画面側で「発行済 <番号>」を見せて防ぐ。ただしこの表示は、電話側に実在する
利用者に結びついている対応だけを数える。削除で対応の掃除にだけ失敗した行を信じると、
持っていない内線番号を「発行済」と出し続けることになる。
別 D1 なので外部キーは張れない。 電話側の利用者を消したときに PhoneUserLinks も
消すのは BFF の責任(7.1)。/admin から直接消された利用者の対応は残り続けるが、
一覧は電話側を正として描くので画面には出ない。
4.3 採番 — 部署ごとの「番台」
部署には番台をひとつ割り当てる。番台は内線番号の千の位で、対応部 = 1000番台 (1000〜1999)、総務 = 2000番台、本部 = 9000番台、というように使う。 電話 Worker の内線範囲は 1000〜9999。
| 設定する値 | 番台 1つ(千の位 1〜9)。開始・終了は決めさせない |
| 採番 | その番台の中で若い番号から空きを取る |
| 未設定の部署 | 番号を指定せず、電話 Worker の自動採番に任せる |
| 重複 | 同じ番台を2部署には割り当てない(Block の UNIQUE + アプリ側の検査) |
帯の開始・終了を自由に決めさせない。 当日は名前ではなく番号で相手を呼ぶので、 「頭の数字を見れば部署が分かる」ことのほうが、帯を細かく刻めることより効く。 境界が部署ごとに違うと、聞き間違いの切り分けもできない。
番台未設定の部署は、他部署の番台に入り込みうる。 未設定の部署では番号を指定せず電話 Worker の自動採番に任せるが、あちらは 1000〜9999 の全域から最小の空きを取るだけで、番台の存在を知らない。 未設定の部署が1つでもあると、その部署の誰かが 1000番台の空き番号を取ることがあり、 「頭の数字=部署」が崩れる。番号の衝突そのものは起きない(採番は電話側の一覧を見る)。
本番までに全部署へ番台を割り当てること。 それが唯一の回避策になる。 どうしても未設定を残すなら、電話 Worker の
EXT_RANGE_START/EXT_RANGE_ENDを 未割り当ての番台へ寄せて、自動採番の落ちどころを固定する。
使用中かどうかは、電話側の GET /admin/users と Sho-Room 側の PhoneUserLinks の
両方を見る。 電話側だけを見ると、対応表には載っているのに電話側への反映が失敗した
直後の番号を空きと誤認する。対応表だけを見ると、break-glass の /admin から直接
作られた利用者を見落とす。どちらか一方では足りない。
採番そのものは worker/bff/extensionAllocation.ts(純粋関数)に切り出してあり、
worker/__tests__/extensionAllocation.test.ts で見ている。
5. 非機能要件
5.1 可用性 — 本システムで最も重要
| 経路 | 依存 | 用途 |
|---|---|---|
| Sho-Room 経由 | Discord OAuth + login-api + Gateway + BFF + provision Worker | 通常時 |
/admin HTML | provision Worker のみ | 非常用(break-glass) |
/admin を消してはならない。 当日 Discord または login-api が落ちた状態で新しいスタッフに QR を配れないと、
その人は内線も PTT も使えないまま本番を迎える。依存の少ない経路を1本残すこと自体が要件である。
運用上の必須事項:
- ADMIN_TOKEN を人間が読める場所に控えておく。 Cloudflare の secret と Sho-Room の secret にしか 存在しない状態にすると、当日「誰もトークンを知らない」ことになる。パスワードマネージャに入れる。
- ローテーションすると break-glass 保持者の手元も同時に失効する。 学祭終了後に回す。
5.2 SIP パスワードの扱い
POST /admin/enroll の応答には freepbx_sync: { extension, secret } が含まれる(FreePBX 同期用)。
BFF は freepbx_sync を落としてからブラウザへ返すこと。 QR 発行に不要であり、
そのまま流すと SIP パスワードが平文でブラウザに届く。
FreePBX への反映は既存の GET /admin/export-extensions(CSV)か break-glass で行う。
一覧(/admin/users)と再発行(/admin/users/:id/reissue)は secret を返さない。
5.3 その他
- エンロールトークンと QR は 発行直後の画面でのみ表示する。 一覧には出さない。
- 想定は 100〜200 人規模、同時操作は数人。ページネーションはサーバー側(既存方針どおり)。
6. BAN と無効化の関係
ここは混同すると危ないので明記する。失効経路が交差していない。
| 操作 | 効果 |
|---|---|
| Sho-Room で BAN | その人が Sho-Room を使えなくなる。内線も PTT も止まらない |
電話側で 無効化(status='disabled') | 内線が使えなくなり、緊急割り込みも数秒で止まる |
端末を紛失したときに必要なのは 後者 である。 画面上でもそう読めるラベルにすること(「Sho-Room の利用停止」と「内線・PTT の停止」を別の場所に置く)。
当日の失効手段は「電話側の無効化」の一本。 運用ドキュメントにもこれを一本化して書く。
7. API 仕様
BFF が /api/bff/phone/* として提供する。中身は既存の /admin/* への転送。
| メソッド | パス | 必要権限 | 転送先 | 備考 |
|---|---|---|---|---|
| GET | /api/bff/phone/users | PHONE_VIEW | GET /admin/users | 検索・ページネーションは BFF 側で処理 |
| POST | /api/bff/phone/users | PHONE_MANAGE | POST /admin/enroll | freepbx_sync を除去して返す |
| POST | /api/bff/phone/users/:id/reissue | PHONE_MANAGE | POST /admin/users/:id/reissue | |
| POST | /api/bff/phone/users/:id/disable | PHONE_MANAGE | POST /admin/users/:id/disable | |
| POST | /api/bff/phone/users/:id/enable | PHONE_MANAGE | POST /admin/users/:id/enable | |
| POST | /api/bff/phone/users/:id/role | SYSTEM_MANAGE | POST /admin/users/:id/role | 本部ロールの切替。body は { role: 'hq' | 'staff' }。PHONE_MANAGE では通さない(7.2) |
| DELETE | /api/bff/phone/users/:id | PHONE_MANAGE | DELETE /admin/users/:id + 対応の削除 | 完全削除。戻せない(7.1) |
| GET | /api/bff/phone/directory | PHONE_VIEW | GET /staffs/phone/directory(Gateway) | 部署(番台つき)・委員・対応を1回で返す |
| PUT | /api/bff/phone/ranges/:departmentId | PHONE_MANAGE | PUT /staffs/phone/ranges/:id(Gateway) | 部署の番台。body は { block: 1〜9 | null }。null で解除 |
POST /api/bff/phone/users は2つの入り方を受ける。
{ discord_id, department_id? }… 委員から作る。 表示名はStaffs.RealName(本名)を使い、 未登録ならStaffs.StaffNameを使う(#1133)。本名は PII なので/directoryには載せず、 BFF が発行時にGET /staffs/phone/staffs/:discordId/real-name(PHONE_MANAGE)で 1 人分だけ引く。 引けなかったときも発行は止めずStaffNameで出す。画面から自由入力させない。電話側のdisplay_nameは PTT のロスターにも出るので、 名簿と食い違うと当日「誰が喋っているか」が分からなくなる。部署に番台があればその中で採番する。{ display_name }… これまでどおり素通し。委員名簿に居ない人(外部の業者など)を 当日その場で足すための経路として残す。
gateway 系統(/directory・/ranges)は PHONE_PROVISION binding が無い環境でも動く。
部署の番台は環境ごとの soshosai-system-db にあり、本番の名簿には触れないため。
電話側へ向かう経路だけが 501 で塞がる(2.5)。
7.1 削除(2026-09-18 に提供へ変更)
以前は「提供しない」としていた。 理由は、電話 Worker の DELETE /admin/users/:id が
一度でもエンロールした利用者に対して enroll_uses の外部キー制約で失敗することだった
(2026-08-21 の検証)。押しても失敗するボタンを置かない、という判断である。
その後、電話 Worker 側の handleDeleteUser(phone/workers/src/admin.ts)が
voip_devices / enroll_uses / enroll_tokens を先に消してから users を消すように
変わっており、外部キーで失敗しなくなっている。前提が消えたので提供へ切り替えた。
前提: 電話 Worker のデプロイ。 参照行を先に消す版がデプロイされていないと、削除は上流で失敗する。 BFF はその失敗をそのまま画面へ返す(握り潰さない)。
停止(disable)とは別物であることを画面でも言い切る。
| 操作 | 効果 | 使う場面 |
|---|---|---|
| 停止 | 内線と PTT が止まる。行は残り、再開できる | 当日の失効(端末の紛失など) |
| 削除 | 電話側の利用者・エンロール履歴・push 先ごと消える。戻せない | 名簿の片付け |
削除は電話側 → Sho-Room 側の対応、の順で消す。 電話側が失敗したら対応は消さない。 片方だけ消えると、電話側に残った利用者が 画面から見えなくなる。
7.2 role 変更(2026-09-02 に提供へ変更)
以前は「提供しない」としていたが、方針を変えた。
当初は電話 Worker に role を更新するエンドポイントが無かった。その後 2026-08-24 に
電話 Worker 側へ POST /admin/users/:id/role(body { role: 'hq' | 'staff' })が追加された。
これを提供へ切り替えた理由:
- 付与経路が電話 Worker の
/admin(共有ADMIN_TOKEN)しか無く、誰が付与したかが残らない。 - その
ADMIN_TOKENは Cloudflare の secret にしか無く、人間が読める場所に控えられていない。 当日この値を取り出せないと、本部を任命することすらできない。 - 本部ロールの保有者が 0 人のままでは、誰も緊急割り込みを打てない。
必要権限は SYSTEM_MANAGE。PHONE_MANAGE(発行・無効化)では通さない。
緊急割り込みは全チャンネルの会話を潰すため、配布担当と同じ粒度で配らない。
前提: 電話 Worker のデプロイ。 このエンドポイントは 2026-08-24 のコミットで追加されたもので、それ以前にデプロイされた 電話 Worker には存在しない。BFF から叩くと上流が 404 を返す。 Sho-Room 側を出す前に、電話 Worker を再デプロイすること。
7.3 配布カード PDF
PHONE_MANAGE を持つ人が、有効な利用者ぜんいんぶんの配布カードを1つの PDF にできる。
A4縦に名刺サイズ(91×55mm)を 2列×5行 で面付けし、氏名・部署・内線番号・QR を載せる。
- 1人1枚に切って渡す形にする。 全員分を1枚の表にすると、他人の QR を読める状態で 配ることになる。QR はそれ1つで内線と PTT の資格情報に化ける。
- トークンは一覧に載らない(5.3)ので、1人ずつ再発行してその場のトークンで QR を作る。 再発行しても前のトークンは失効しない(期限内は両方使える)ので、配布済みの QR を壊さない。 再発行はすべて監査ログに残る。
- 停止中の人は刷らない。 配ってもその場では使えず、紙だけが残る。
- トークンを取れなかった人がいたら、その人数を画面に出す。黙って落とすと、 刷った枚数と人数が合わないまま配ることになる。
日本語のためにフォントを埋め込まない。 DOM をブラウザに描かせた結果を
html-to-image で画像にして jsPDF に貼る(TrackableLinks/qrBatchPdf.ts と同じ方式)。
jsPDF の内蔵フォントに CJK が無い問題を踏まない。新しい依存は増えていない
(jspdf / qrcode / html-to-image はいずれも導入済み)。
8. やらないこと
2026-09-18 に「ユーザー削除」を削除した。 外部キーで失敗するという前提が 電話 Worker 側の修正で消えたため(7.1)。
2026-09-02 に 1 項目を削除した。
role='hq'の付与 UI/API は当初ここに置いていたが、 付与経路が共有ADMIN_TOKENの/adminしか無く、誰が付与したかが残らないこと、 そのADMIN_TOKENが人間の読める場所に無いこと、本部ロール保有者が 0 人のままでは 誰も緊急割り込みを打てないことから、提供する方針へ変えた(7.2)。
| 項目 | 理由 |
|---|---|
| PTT Worker への変更 | 本計画で触る必要が実際に無い |
| 電話 Worker への変更 | 既存の requireAdmin をそのまま使えば足りる |
/admin HTML の廃止 | 当日の最後の手段として必要(5.1) |
| 名簿の統合・同期 | Staffs と users の突合は PhoneUserLinks で持つようにしたが(4.2)、電話側 users の soshosai-system-db への移行は学祭後 |
| 既存の利用者への部署の後付け | /admin から作られた既存の利用者に、画面から部署を紐づける経路は作っていない。作り直す(削除 → 委員から発行)か、D1 に直接入れる |
| staging / dev1〜5 への binding 展開 | production のみ(2.5) |
| PTT のチャンネル管理 UI | チャンネルは事前定義せず現場で名前を付けて作る設計。管理対象ではない |
9. 実装順
| 段階 | 内容 |
|---|---|
| 1 | shared とフロントに PHONE_VIEW / PHONE_MANAGE を追加 |
| 2 | Sho-Room に PHONE_PROVISION binding と ADMIN_TOKEN secret(production のみ) |
| 3 | worker/bff/phone.ts(ビット検証 + freepbx_sync 除去 + 監査ログ) |
| 4 | src/pages/PhoneSystem/ の一覧画面(閲覧のみ) |
| 5 | 発行・再発行・無効化の UI |
| 6 | 委員名簿からの発行・部署ごとの番台・削除・配布カード PDF(0028 / staffs/phone/*) |
段階 4 まで入れるだけでも、「いま誰が緊急割り込みを打てるのか」が Sho-Room から見えるようになる。
現状これを知る手段が /admin にログインする以外に無い。
段階 5 まで終われば、ADMIN_TOKEN を日常的に使わなくて済むようになる。