メインコンテンツまでスキップ

内線・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/
BFFpackages/web/sho-room/worker/bff/phone.ts
ルート登録packages/web/sho-room/worker/index.ts(/api/bff/phone/* を generic proxy より前に判定する)
Service Bindingpackages/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 と同じ手順を踏む。

  1. Cookie から JWT を取り出す(extractJwt)。
  2. env.GATEWAY.fetch('https://gateway/staffs/me') で 署名を検証し、DB 由来の permissions を得る。
  3. 必要なビットを検査する。
  4. 通ったら 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 をそのまま使う。

列用途
idUUID。PTT の participantId としてそのまま使われる
display_name表示名。アプリ側では変更できない(名簿とロスターを一致させるため)
extension内線番号
sip_secretSIP パスワード。外に出さない(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 HTMLprovision 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/usersPHONE_VIEWGET /admin/users検索・ページネーションは BFF 側で処理
POST/api/bff/phone/usersPHONE_MANAGEPOST /admin/enrollfreepbx_sync を除去して返す
POST/api/bff/phone/users/:id/reissuePHONE_MANAGEPOST /admin/users/:id/reissue
POST/api/bff/phone/users/:id/disablePHONE_MANAGEPOST /admin/users/:id/disable
POST/api/bff/phone/users/:id/enablePHONE_MANAGEPOST /admin/users/:id/enable
POST/api/bff/phone/users/:id/roleSYSTEM_MANAGEPOST /admin/users/:id/role本部ロールの切替。body は { role: 'hq' | 'staff' }。PHONE_MANAGE では通さない(7.2)
DELETE/api/bff/phone/users/:idPHONE_MANAGEDELETE /admin/users/:id + 対応の削除完全削除。戻せない(7.1)
GET/api/bff/phone/directoryPHONE_VIEWGET /staffs/phone/directory(Gateway)部署(番台つき)・委員・対応を1回で返す
PUT/api/bff/phone/ranges/:departmentIdPHONE_MANAGEPUT /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. 実装順​

段階内容
1shared とフロントに PHONE_VIEW / PHONE_MANAGE を追加
2Sho-Room に PHONE_PROVISION binding と ADMIN_TOKEN secret(production のみ)
3worker/bff/phone.ts(ビット検証 + freepbx_sync 除去 + 監査ログ)
4src/pages/PhoneSystem/ の一覧画面(閲覧のみ)
5発行・再発行・無効化の UI
6委員名簿からの発行・部署ごとの番台・削除・配布カード PDF(0028 / staffs/phone/*)

段階 4 まで入れるだけでも、「いま誰が緊急割り込みを打てるのか」が Sho-Room から見えるようになる。 現状これを知る手段が /admin にログインする以外に無い。

段階 5 まで終われば、ADMIN_TOKEN を日常的に使わなくて済むようになる。