加算マネージャー — 単体販売アーキテクチャ(CPOS 連携はオプション)
このドキュメントは「単体販売を基本、CPOS 連携をオプション」に転換した後の 認証・データ保存・匿名化の設計をまとめる。実装は本ブランチ (
claude/kassan-manager-cpos-integration-gygjv7) で行う。
1. 方針(要件)
- 単体販売が基本、CPOS 連携はオプション。CPOS が未設定でも製品として成立する。
- CPOS のログイン・manifest 等は現行 CPOS の流儀に従う。CPOS ログインは 極力目立たせず、ログイン時は CPOS のデータ(事業所・職員・請求の匿名集計)を使う。
- CPOS ログイン以外のユーザーのデータは、ユーザーごとに保存する。保存先は コンテナ同梱の PostgreSQL を自前で起動して使う(Cloud SQL 等の外部マネージド DB は 使わず、設定項目を最小化する)。(従来は他事業所ぶんも含めすべて CPOS app-data に保存していた。)
- ユーザー名 + パスワードでユーザーを識別する(ローカル認証)。
- 無料利用の機能は廃止する。匿名アクセスの無料枠・「無料版」導線は撤去し、 利用にはログイン(購入済みライセンス)を必須とする。
- CPOS 連携時以外のユーザーは、ローカル LLM をダウンロードして端末内で個人情報を 匿名化・JSON 化し、匿名化済みデータだけをサーバへアップロードして解析する。
2. 認証(2 プロバイダ)
同じ封緘セッション cookie (kasan_session, AES-256-GCM) を 2 系統のログインで発行する。
| provider | 識別 | 保存先 | 位置づけ |
|---|---|---|---|
local |
ユーザー名 + パスワード(scrypt ハッシュ) | 同梱 PostgreSQL users |
既定(単体販売) |
cpos |
CPOS 共通ログインゲートウェイ(現行の流儀) | CPOS 側 | オプション・控えめに表示 |
- ローカル認証:
POST /api/auth/register(ユーザー名・パスワード)、POST /api/auth/login。 パスワードはcrypto.scryptでscrypt$N$r$p$salt$hash形式に保存(外部依存なし)。 - CPOS ログイン: 既存の
/api/auth/cpos/start→ CPOS/api/auth/login?next=…ゲートウェイ →/api/auth/cpos/callback(現行のまま)。UI では「CPOS でログイン」を小さく副導線として置く。 - セッション payload に
providerを持たせ、resolveUser()がauthProviderを返す。 - 無料枠は無い。
authMiddlewareは従来どおり cookie があればreq.userを張るが、 解析・保存系ルートはすべてrequireAuth。認証済みユーザーは全員ライセンス保有=全機能利用可。
3. データ保存(プロバイダ別バックエンド)
req.user.authProvider で保存バックエンドを切り替える。ルートのコードは
AsyncLocalStorage(リクエスト単位でユーザーを保持)+ファサードで呼び出し側を変えずに振り分ける。
routes ──▶ services/persistence/index.js (facade)
│ currentUser().authProvider で分岐
├─ 'cpos' ▶ services/cpos/store.js (CPOS app-data:kasan:*)
└─ 'local' ▶ services/persistence/sql-store.js (同梱 PostgreSQL, ユーザー別)
- CPOS ユーザー: 従来どおり CPOS app-data に保存(組織隔離・監査・webhook を共有)。
- ローカルユーザー: 同梱 PostgreSQL にユーザー別に保存。
organizationId = ユーザー自身の id(1 ユーザー = 1 スコープ)。他事業所ぶんも当該ユーザーの領域に閉じて保存する。 - リソースは CPOS app-data と同一(
analyses/reviews/facility-profiles/staff-rosters/drafts/entitlements/user-prefs/facility-documents)。 ドキュメント形状も CPOS と同一({ id, organizationId, createdBy, status, data, createdAt, updatedAt }) にそろえ、ルート側は保存先を意識しない。
同梱 PostgreSQL(Cloud SQL 不要・設定不要)
Cloud SQL のような外部マネージド DB は使わず、アプリと同じコンテナに PostgreSQL を
同梱して起動時に自動セットアップする(services/persistence/embedded-postgres.js)。
- 起動時に
initPersistence()がinitdb(初回)→ postmaster 起動 →kasanDB 作成まで自動化。 - 接続はローカル Unix ソケットのみ(TCP を開かない)。認証は trust (ソケットはコンテナ内からのみ到達可能)。接続文字列・インスタンス設定は不要。
KASAN_SESSION_SECRETも未設定ならapp_configテーブルに自動生成・保存し、process.envに注入する。- 接続決定の優先順位:
DATABASE_URL(外部 PostgreSQL の任意上書き)> 同梱 PostgreSQL > プロセス内メモリ(PG バイナリ無し /KASAN_EMBEDDED_PG=0の開発・テスト時)。
スキーマ(app_config / users / app_records / app_attachments)は起動時に
CREATE TABLE IF NOT EXISTS で用意する。app_records はユーザー別隔離キー owner_id
+ resource で全リソースを 1 テーブルに格納し、CPOS app-data と同じドキュメント形状を返す。
運用上の注意(Cloud Run):
- 単一インスタンス運用(deploy が
max-instances=1を自動強制)。同梱 DB の所有者を 1 つに保つ。 - 永続化: データは
PGDATA(既定/var/lib/kasan/pgdata)に置く。Cloud Run の コンテナ FS は揮発性のため、永続化するにはPGDATAの親(/var/lib/kasan)に Filestore(NFS)をマウントする。未マウント時は再デプロイ/コールドスタートでデータが消える。- GCS FUSE は PostgreSQL のファイル整合性(fsync/ロック)を満たさないため使わない。
- マウントは PGDATA そのものではなく親ディレクトリ に対して行う(
initdbがpgdataサブディレクトリを node 所有・700 で作成するため。PGDATA を直接マウントすると 共有ルートの所有者が node でなく postmaster が起動を拒否する場合がある)。
- 非 root で動かす(PostgreSQL は root 起動不可)。イメージは
USER node。 - SIGTERM 時はプールを閉じてから
pg_ctl stop -m fastで安全停止する。
永続化のセットアップ手順(推奨: Firestore)
.env に 2 行足すだけ。Filestore・VPC コネクタ・複合インデックスは不要。
KASAN_DATA_BACKEND=firestore
KASAN_GCS_BUCKET=<PROJECT_ID>-kasan-attachments
一度だけのインフラ準備 (すべて Node/gcloud。シェルスクリプト不要):
# 1) Firestore (Native) が有効なことを確認 (CPOS と同一プロジェクトなら作成済み)
gcloud firestore databases list --project=$PROJECT
# 2) 添付ファイル用の GCS バケット
gcloud storage buckets create gs://$PROJECT-kasan-attachments \
--project=$PROJECT --location=asia-northeast1 \
--uniform-bucket-level-access
# 3) Cloud Run のサービスアカウントに権限
gcloud projects add-iam-policy-binding $PROJECT \
--member=serviceAccount:<RUNTIME_SA> --role=roles/datastore.user
gcloud storage buckets add-iam-policy-binding gs://$PROJECT-kasan-attachments \
--member=serviceAccount:<RUNTIME_SA> --role=roles/storage.objectAdmin
あとは通常どおり npm run deploy:cloudrun。Firestore バックエンドでは
NFS マウント・VPC 下り・max-instances=1 の強制はすべて外れる。
保存先: kasan_records / kasan_attachments (実体は GCS) / kasan_users /
kasan_usernames / kasan_config。クエリは等値条件のみで引くため
複合インデックスは不要 (並べ替えはアプリ側)。
Filestore の撤去 (旧方式から移行したら必ず行う)
Filestore Basic HDD は 最小 1TiB のプロビジョニング課金 (東京で月 200 ドル前後・ 空でも満額)。2026-07 の高額請求の原因。Firestore へ切替えたら残さないこと。
順序が重要 — 削除は firestore バックエンドでのデプロイが成功した後に行う。
gcloud run deploy は前リビジョンの NFS マウント・VPC コネクタ設定を引き継ぐため、
deploy-cloudrun.js は firestore バックエンド時に --clear-volumes
--clear-volume-mounts --clear-vpc-connector で明示的に外す (2026-08-04 修正)。
修正前の版でデプロイしたまま先に Filestore/コネクタを削除すると、サービング中の
リビジョンがインスタンスを起動できなくなり、全リクエストが Google Frontend の
500 (Server Error) になる (2026-08-04 に実際に起きた障害)。さらにコネクタ削除後は
引き継ぎ検証に失敗して再デプロイ自体が通らなくなる。その状態からの復旧は
リビルド不要で次の 1 コマンド (現行イメージから残骸設定だけ外した新リビジョンを作る):
gcloud run services update kasan-manager --region=asia-northeast1 \
--project=$PROJECT --clear-volumes --clear-volume-mounts --clear-vpc-connector
# 0) firestore バックエンドで再デプロイ済みであることを確認
# (出力に volumes / vpc-access-connector が現れないこと)
gcloud run services describe kasan-manager --region=asia-northeast1 \
--project=$PROJECT --format='yaml(spec.template.spec.volumes, spec.template.metadata.annotations)'
# 心配なら先にバックアップ (容量課金は安い)
gcloud filestore backups create kasan-fs-final \
--file-share=kasan --instance=kasan-fs --instance-zone=asia-northeast1-b \
--region=asia-northeast1 --project=$PROJECT
# Filestore インスタンスの削除 (これで課金が止まる)
gcloud filestore instances delete kasan-fs \
--zone=asia-northeast1-b --project=$PROJECT
# VPC コネクタも Filestore 専用だったので削除
gcloud compute networks vpc-access connectors delete kasan-conn \
--region=asia-northeast1 --project=$PROJECT
.env からは KASAN_PGDATA_NFS / KASAN_VPC_CONNECTOR を消す (残っていても Firestore バックエンドでは無視されるが、紛らわしい)。
旧方式のセットアップ手順(Filestore + VPC。非推奨・参考として残す)
以下は一度だけ実行するインフラ準備。作成後は .env に値を書けば
npm run deploy:cloudrun が自動でマウントする。
# 変数(自分の環境に合わせて)
PROJECT=your-gcp-project-id
REGION=asia-northeast1
ZONE=asia-northeast1-b
NETWORK=default
# 1) Filestore(NFS)インスタンスを作成(最小 1TiB。BASIC_HDD)
gcloud filestore instances create kasan-fs \
--project=$PROJECT --zone=$ZONE --tier=BASIC_HDD \
--file-share=name=kasan,capacity=1TiB \
--network=name=$NETWORK
# 作成された NFS の IP を控える(KASAN_PGDATA_NFS に使う)
gcloud filestore instances describe kasan-fs --project=$PROJECT --zone=$ZONE \
--format="value(networks.ipAddresses[0])"
# 2) Cloud Run から Filestore(VPC) へ到達するための Serverless VPC アクセスコネクタ
gcloud compute networks vpc-access connectors create kasan-conn \
--project=$PROJECT --region=$REGION --network=$NETWORK \
--range=10.8.0.0/28
.env に設定(<FILESTORE_IP> は 1) の出力):
KASAN_PGDATA_NFS=<FILESTORE_IP>:/kasan
KASAN_PGDATA_MOUNT_PATH=/var/lib/kasan
KASAN_VPC_CONNECTOR=projects/your-gcp-project-id/locations/asia-northeast1/connectors/kasan-conn
# コネクタ名だけでも可: KASAN_VPC_CONNECTOR=kasan-conn
CLOUD_RUN_MEMORY=1Gi
あとは通常どおりデプロイ(--add-volume / --add-volume-mount / --vpc-egress は自動付与):
npm run deploy:cloudrun
手動で gcloud run deploy/gcloud run services update に付ける場合の同等フラグ:
gcloud run services update kasan-manager --project=$PROJECT --region=$REGION \
--execution-environment=gen2 --max-instances=1 \
--add-volume=name=pgdata,type=nfs,location=<FILESTORE_IP>:/kasan \
--add-volume-mount=volume=pgdata,mount-path=/var/lib/kasan \
--vpc-connector=kasan-conn --vpc-egress=private-ranges-only
補足: Filestore は最小 1TiB で費用が大きい。ごく小規模で永続化コストを避けたい場合は、 揮発運用(ボリューム無し)+定期バックアップ、または外部の小さな PostgreSQL を
DATABASE_URLで指す選択肢もある(その場合max-instances制限は外れる)。
4. 匿名化(ローカル LLM ダウンロード)
- CPOS ユーザー: 匿名化は不要。CPOS が
analysis-source(includePii=false)で すでに個人情報を含まない集計を返すため、それをそのまま判定に使う。 - ローカルユーザー: 端末内でファイルを処理し、ダウンロード済みのローカル LLMで
個人情報を匿名化・JSON 化してから、匿名化済みバンドルだけをアップロードする。
public/local/llm-anonymizer.js:WebLLM(@mlc-ai/web-llm, WebGPU)を CDN 遅延ロードし、 小型モデルを端末にダウンロード & IndexedDB キャッシュ。anonymizeToJson(text)は 氏名・住所・電話・被保番等を伏字化し、構造化 JSON を返す。- WebGPU 非対応・モデル取得不可の場合は既存の決定的ヒューリスティック
(
pii.js/tabular.js)に自動フォールバック(多層防御は維持)。 - サーバ側
anonymize.jsは最終防衛線として従来どおり残す。生ファイルは送信しない。
5. 無料枠の廃止
- 匿名(未ログイン)で使えた
/・/api/analyze/from-local・/api/portfolio/optimize・/api/analyze・/api/judge・/api/import-receipt・カタログはrequireAuth化。 /はローカル取込 UI のままだがログイン必須(ローカル認証 or CPOS ログイン)。- 「無料版」「無料プラン」等の文言・アップセル・死んだアクセスコード UI を撤去。
planTierの free/paid 二層は撤廃。認証済み = ライセンス保有 = 全機能。
6. CPOS 側(流儀に従う)
- kasan は CPOS App Platform の登録アプリ(
appId=kasan)のまま。ログインは共通 ゲートウェイ、保存はapp-data:kasan:*、analysis-sourceを入力に使う(現行どおり)。 cpos.manifest.jsonは正準スキーマ(apps/admin/src/server/apps/manifest-import.ts)に 合わせ、apiTokenScopesとrequiredPermissionsの両方を宣言する(drift 防止)。- CPOS 連携はオプション。未設定でも単体(ローカル認証 + 同梱 PostgreSQL)で動作する。
7. 環境変数(すべて任意 — 単体運用は無設定で動く)
| 変数 | 既定 | 用途 |
|---|---|---|
| (なし) | — | 同梱 PostgreSQL が自動起動するため、基本設定は不要 |
KASAN_SESSION_SECRET |
自動生成 | セッション cookie 暗号鍵。未設定なら DB に自動生成・保存 |
DATABASE_URL |
(空) | 外部 PostgreSQL を使う場合のみ。設定すると同梱 PG は使わない |
KASAN_EMBEDDED_PG |
1 |
0 で同梱 PG を無効化しメモリ実装に(開発・テスト用) |
PGDATA / KASAN_PG_SOCKET_DIR |
Docker 既定 | データディレクトリ / ソケット |
KASAN_LOCAL_AUTH_ENABLED |
true |
ローカル認証。CPOS 専用運用時のみ false |
KASAN_SIGNUP_ENABLED |
true |
新規登録の受付。招待制運用時は false |
KASAN_CPOS_APP_TOKEN 他 |
(空) | CPOS 連携(任意) |
単体運用で必須の設定はない(永続化のための PGDATA ボリュームマウントはインフラ設定)。
CPOS 連携用の変数はすべて任意。