マルチテナントSaaS化 — Phase 0機械部分+C0+C1 実装計画

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: spec docs/superpowers/specs/2026-10-08-multitenant-saas-c-design.md(v4確定版)のうち、コードで完結する部分(C0定数掃出し・Phase 0のKPI集計実装・C1軽量コントロールプレーン)を実装する。

Architecture: 既存GAS予約Bot(無変更流用・定数のみ外部化)に対し、①定数インベントリ自動生成スクリプト(C0)②KPI集計サービス(Phase 0・additive)③中央のテナント登録簿+ハートビート受け(Workers+D1・C1軽量版)を追加。データプレーン(予約処理ロジック)は一切変更しない。

Tech Stack: Google Apps Script(既存テストランナー=関数が Array<{name, passed, message}> を返す方式・GAS実行は gas-run.sh deploy <functionName> でWeb App経由〔2026-10-09実測確認済み〕)・Cloudflare Workers(TypeScript+vitest・既存 worker/)・D1(SQLite)・pytest(定数スキャナ)

スコープ外(この計画に含めない): C2(sandboxテナント実機検証)、C3(Stripe課金・ライセンス)、営業資材。これらは C0成果物(定数インベントリ)と Phase 0実測値に依存するため、C1ゲート通過後に別計画として作成する。Phase 0の人間タスク(兼業確認・模擬面接・メトリクス2〜4週記録・適法性チェック)はコードでないため本計画末尾の「人間タスク チェックリスト」に記載するのみ。


Task 1: C0-1 定数スキャナ(テナント固有定数の摘出ツール)

Files:

  • Create: scripts/tenant_constant_scan.py
  • Create: tests/test_tenant_constant_scan.py
  • Output(実行で生成): docs/tenant-constants.tsv

  • Step 1: 失敗するテストを書く
# tests/test_tenant_constant_scan.py
"""tenant_constant_scan.py の単体テスト(リポジトリルートで pytest 実行)"""
from pathlib import Path

from scripts.tenant_constant_scan import scan_file, scan_repo, write_tsv

FIXTURE = """
var SHEET_ID = '1AbCdEfGhIjKlMnOpQrStUvWx12345'; // Sheet master
var CHANNEL_ID = 'U1234567890abcdef1234567890abcdef';
var BASE_URL = 'https://script.google.com/macros/s/AKfycbXYZ/exec';
var RETRY_MAX = 3; // 共通設定・テナント固有でない
"""

def test_scan_file_detects_sheet_id():
    findings = scan_file("gas-project/Code.js", FIXTURE)
    cats = [f["category"] for f in findings]
    assert "spreadsheet_id" in cats

def test_scan_file_detects_line_channel_id():
    findings = scan_file("gas-project/Code.js", FIXTURE)
    cats = [f["category"] for f in findings]
    assert "line_channel_id" in cats

def test_scan_file_detects_webapp_url():
    findings = scan_file("gas-project/Code.js", FIXTURE)
    cats = [f["category"] for f in findings]
    assert "gas_webapp_url" in cats

def test_scan_file_ignores_common_numbers():
    findings = scan_file("gas-project/Code.js", FIXTURE)
    cats = [f["category"] for f in findings]
    assert "common_number" not in cats  # RETRY_MAX=3 は摘出しない

def test_tsv_has_required_columns(tmp_path):
    findings = scan_file("gas-project/Code.js", FIXTURE)
    out = tmp_path / "out.tsv"
    write_tsv(findings, out)
    header = out.read_text().splitlines()[0]
    assert header == "category\tvalue\tfile\tline\tjudgment"
  • Step 2: テストが失敗することを確認

Run: cd /home/yn4416/projects/reserve-optimizer && python3 -m pytest tests/test_tenant_constant_scan.py -v Expected: FAIL(ModuleNotFoundError: No module named 'scripts.tenant_constant_scan')

  • Step 3: スキャナ本体を実装
# scripts/tenant_constant_scan.py
"""C0: GASコードからテナント固有定数を摘出しTSVインベントリを出力する。

spec §4 C0ゲートの成果物生成ツール。カテゴリ= spec §3の主要ストア配置表の
前提となる分類。judgment列は人間(CC)が Task 2 で記入するため空で出力する。
"""
from __future__ import annotations

import argparse
import re
from pathlib import Path

# (カテゴリ, 正規表現) — spec「無変更流用の定義」の対象=テナント固有値のみ
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
    ("spreadsheet_id", re.compile(r"'([A-Za-z0-9_-]{25,60})'\s*;\s*//\s*[Ss]heet|[Ss]preadsheet[^'\n]*'([A-Za-z0-9_-]{25,60})'")),
    ("line_channel_id", re.compile(r"'(U[0-9a-fA-F]{32})'")),
    ("line_channel_access_token", re.compile(r"'([A-Za-z0-9+/=]{100,})'\s*(?://.*)?$")),
    ("stripe_key_prefix", re.compile(r"'((?:sk|pk|whsec)_[A-Za-z0-9_]{8,})'")),
    ("gas_webapp_url", re.compile(r"'(https://script\.google\.com/macros/s/[A-Za-z0-9_-]+/exec)'")),
]

TSV_HEADER = "category\tvalue\tfile\tline\tjudgment"


def scan_file(rel_path: str, content: str) -> list[dict[str, str]]:
    findings: list[dict[str, str]] = []
    for lineno, line in enumerate(content.splitlines(), start=1):
        for category, pattern in PATTERNS:
            for match in pattern.finditer(line):
                value = next(g for g in match.groups() if g)
                findings.append(
                    {
                        "category": category,
                        "value": value[:40],
                        "file": rel_path,
                        "line": str(lineno),
                        "judgment": "",
                    }
                )
    return findings


def scan_repo(root: Path) -> list[dict[str, str]]:
    results: list[dict[str, str]] = []
    for js in sorted(root.glob("gas-project/**/*.js")):
        if "tests" in js.parts or "node_modules" in js.parts:
            continue
        results.extend(scan_file(str(js.relative_to(root)), js.read_text(errors="replace")))
    return results


def write_tsv(findings: list[dict[str, str]], out: Path) -> None:
    lines = [TSV_HEADER]
    lines += [
        f"{f['category']}\t{f['value']}\t{f['file']}\t{f['line']}\t{f['judgment']}"
        for f in findings
    ]
    out.write_text("\n".join(lines) + "\n")


def main() -> None:
    parser = argparse.ArgumentParser(description="C0 tenant constant scanner")
    parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parent.parent)
    parser.add_argument("--out", type=Path, default=None)
    args = parser.parse_args()
    out = args.out or (args.repo / "docs" / "tenant-constants.tsv")
    findings = scan_repo(args.repo)
    write_tsv(findings, out)
    print(f"scanned: {len(findings)} findings -> {out}")


if __name__ == "__main__":
    main()

注: tests/ パッケージ化のため tests/__init__.py が無い場合は空ファイルを作成する(scripts/__init__.py も同様)。

  • Step 4: テストが通ることを確認

Run: python3 -m pytest tests/test_tenant_constant_scan.py -v Expected: 5 passed

  • Step 5: 実リポジトリに走らせインベントリを生成

Run: python3 scripts/tenant_constant_scan.py && head -20 docs/tenant-constants.tsv && wc -l docs/tenant-constants.tsv Expected: 「scanned: N findings」出力+TSV先頭行=ヘッダ

  • Step 6: Commit
git add scripts/tenant_constant_scan.py tests/test_tenant_constant_scan.py tests/__init__.py scripts/__init__.py docs/tenant-constants.tsv
git commit -m "feat(c0): tenant constant scanner + inventory (spec §4 C0)"

Task 2: C0-2 定数インベントリの判定記入

Files:

  • Modify: docs/tenant-constants.tsv(judgment列)

  • Step 1: 各行に判定を記入する

TSVのjudgment列に以下の3値のいずれかを記入(spec §3「無変更流用の定義」準拠):

  • properties = Script Propertiesへ移すべき(シークレット・チャネルID等テナント固有値)
  • common = 共通設定として残す(タイムアウト秒数等・複数院で同一値)
  • remove = 未使用定数(削除候補として C1後に消す)

判定ルール: 値が「院ごとに異なる」または「漏洩時に害がある」→properties。全院で同一かつ無害→common。

  • Step 2: 集計して全行判定済みを確認

Run: awk -F'\t' 'NR>1 && $5=="" {c++} END {print c+0, "rows without judgment"}' docs/tenant-constants.tsv Expected: 0 rows without judgment

  • Step 3: Commit
git add docs/tenant-constants.tsv
git commit -m "docs(c0): tenant constants inventory with judgments (spec §4 C0 gate)"

Task 3: Phase 0-1 KPIログサービス(GAS側・additive)

Files:

  • Create: gas-project/services/KpiLogService.js
  • Create: gas-project/tests/KpiLogServiceTests.js
  • Modify: gas-project/tests/TestRunner.js(末尾のテスト連結配列に1行追加)
  • Modify: gas-project/handlers/ReservationHandler.js(予約成功/失敗パスに2行追加・ロジック変更はしない)

  • Step 1: 失敗するGASテストを書く
// gas-project/tests/KpiLogServiceTests.js
/**
 * KpiLogService unit tests - additivity only, no core logic touched.
 * Run in GAS environment: testKpiLogService()
 */

function testKpiLogService() {
  var tests = [];

  // ── logReservationEvent: 成功イベント行を書き込む ──
  try {
    var mockSheet = { appendRow: function(row) { mockSheet.lastRow = row; } };
    KpiLogService._setSheetForTest(mockSheet);
    KpiLogService.logReservationEvent('success', 'T0001', 'req-001', 'system');
    tests.push(_assert('logReservationEvent: writes row',
      mockSheet.lastRow && mockSheet.lastRow.length === 5, 'row must have 5 columns'));
    tests.push(_assert('logReservationEvent: success column',
      mockSheet.lastRow[1] === 'success', '2nd column is event type'));
  } catch (e) {
    tests.push({name: 'logReservationEvent', passed: false, message: 'Error: ' + e.toString()});
  }

  // ── logReservationEvent: 不正イベント種別は無視(silently skip)──
  try {
    var called = false;
    var mockSheet2 = { appendRow: function() { called = true; } };
    KpiLogService._setSheetForTest(mockSheet2);
    KpiLogService.logReservationEvent('bogus', 'T0001', 'req-002', 'system');
    tests.push(_assert('logReservationEvent: bogus type ignored', called === false,
      'unknown event type must not write'));
  } catch (e) {
    tests.push({name: 'logReservationEvent: bogus', passed: false, message: 'Error: ' + e.toString()});
  }

  // ── weeklySummary: 成功率を計算する ──
  try {
    var rows = [
      ['2026/10/01', 'success', 'T0001', 'r1', 'system'],
      ['2026/10/01', 'success', 'T0001', 'r2', 'system'],
      ['2026/10/02', 'failure', 'T0001', 'r3', 'system']
    ];
    var summary = KpiLogService.computeSuccessRate(rows);
    tests.push(_assert('computeSuccessRate: 2/3 ≈ 66.7%',
      Math.abs(summary.rate - 66.7) < 0.1, 'rate=' + summary.rate));
    tests.push(_assert('computeSuccessRate: total', summary.total === 3, 'total must be 3'));
  } catch (e) {
    tests.push({name: 'computeSuccessRate', passed: false, message: 'Error: ' + e.toString()});
  }

  return tests;
}
  • Step 2: テストをTestRunnerへ登録(連結を追加・既存行は触らない)

gas-project/tests/TestRunner.js の results.tests = results.tests.concat(testValidationUtils()); 等の連結列の末尾に追加:

  results.tests = results.tests.concat(testKpiLogService());
  • Step 3: テストが失敗することを確認

Run: bash gas-project/gas-run.sh deploy testKpiLogService 2>&1 | tail -20; echo "EXIT=$?" Expected: FAIL(テスト結果に KpiLogService is not defined のpassed:false行が含まれる)。判別条件: passed:false行が出た=RED成立・404/HTMLが返った=デプロイ失敗でありREDではない(r5 MiniMax#1反映)※ gas-run.shはWeb App経由

  • Step 4: KpiLogServiceを実装
// gas-project/services/KpiLogService.js
/**
 * KpiLogService - Phase 0 KPI集計のための予約イベント記録(additive・spec §8)。
 * 予約処理ロジックは一切変更しない。成功/失敗パスから fire-and-forget で呼ばれる。
 * シート: 「KPI_LOG」(5列: 日時, イベント種別, tenant_id, リクエストID, 起因)
 */
var KpiLogService = (function() {
  var SHEET_NAME = 'KPI_LOG';
  var VALID_EVENTS = ['success', 'failure'];
  var _testSheet = null;

  function _getSheet() {
    if (_testSheet) return _testSheet;
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) {
      sheet = ss.insertSheet(SHEET_NAME);
      sheet.appendRow(['日時', 'イベント', 'tenant_id', 'リクエストID', '起因']);
    }
    return sheet;
  }

  function logReservationEvent(eventType, tenantId, requestId, cause) {
    if (VALID_EVENTS.indexOf(eventType) === -1) return; // 不正種別は無視
    var now = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
    _getSheet().appendRow([now, eventType, tenantId, requestId, cause]);
  }

  function computeSuccessRate(rows) {
    var total = rows.length;
    var success = 0;
    for (var i = 0; i < total; i++) {
      if (rows[i][1] === 'success') success++;
    }
    return { total: total, success: success, rate: total ? Math.round(success / total * 1000) / 10 : 0 };
  }

  function getWeeklyRows() {
    var sheet = _getSheet();
    var last = sheet.getLastRow();
    if (last < 2) return [];
    return sheet.getRange(2, 1, last - 1, 5).getValues();
  }

  // テスト用(本番コードから呼ばないこと)
  function _setSheetForTest(sheet) { _testSheet = sheet; }

  return {
    logReservationEvent: logReservationEvent,
    computeSuccessRate: computeSuccessRate,
    getWeeklyRows: getWeeklyRows,
    _setSheetForTest: _setSheetForTest
  };
})();
  • Step 5: 予約ハンドラへの差し込み(2行追加のみ)

gas-project/handlers/ReservationHandler.js 内、予約作成成功のreturn直前と失敗パスに以下を追加(既存行は1行も変更しない・追加のみ):

  // 成功パスの return 直前:
  try {
    KpiLogService.logReservationEvent('success', 'T0001', requestId || '', 'system');
    PropertiesService.getScriptProperties().setProperty('LAST_RESERVATION_AT', new Date().toISOString()); // ISO8601統一(r6 GLM#5)
  } catch (e) { /* KPI・ハートビートとも予約を止めない(r5・3機一致指摘で追加) */ }
  // 失敗パス(catch節内):
  try { KpiLogService.logReservationEvent('failure', 'T0001', requestId || '', 'system'); } catch (e) { /* KPIは予約を止めない */ }

注: シート名 KPI_LOG は全院共通設定のため C0スキャナ対象外(common判定相当)をここに明記する(r5 MiniMax#2反映)。requestId は実装時に ReservationHandler.js 内の実変数名を実測して使用(無ければ空文字 ''・r5 OR1#1反映)。LAST_RESERVATION_AT 更新は Task 6 のハートビート last_reservation_at を生きさせる必須ペア(無いと恒久nullになる・r5 Gemini#1/GLM#5/MiniMax#9の3機一致)。tenant_id は現行単一院のため 'T0001' 固定(spec §4 C1どおり)。

  • Step 6: テストが通ることを確認

Run: bash gas-project/gas-run.sh deploy testKpiLogService 2>&1 | tail -10; echo "EXIT=$?" Expected: 全テストPASS(exit 0)

  • Step 7: E2E回帰(データプレーン無変更の確認)

Run: set -o pipefail && node tests/run-all.js 2>&1 | tail -5; echo "EXIT=$?" Expected: 38系統green(36+新規テスト関数2系統・r6 OR1#4反映)・EXIT=0(pipefail付き・tailでなくnodeのexitを見る・r6 GLM#1反映)

  • Step 8: Commit
git add gas-project/services/KpiLogService.js gas-project/tests/KpiLogServiceTests.js gas-project/tests/TestRunner.js gas-project/handlers/ReservationHandler.js
git commit -m "feat(phase0): KPI log service (additive, no core logic change) - spec §8"

Task 4: C1-1 D1スキーママイグレーション

Files:

  • Create: worker/migrations/0001_tenant_registry.sql

  • Step 1: マイグレーションSQLを書く

-- worker/migrations/0001_tenant_registry.sql
-- C1軽量版: テナント登録簿+ハートビートのみ(spec §4 C1・Stripe/冪等はC2/C3で別マイグレーション)

CREATE TABLE IF NOT EXISTS tenants (
  tenant_id TEXT PRIMARY KEY,          -- 例: T0001
  clinic_name TEXT NOT NULL,
  line_channel_id TEXT NOT NULL,       -- 検証用(自己診断照合)
  heartbeat_token TEXT NOT NULL UNIQUE, -- ハートビート認証用(UNIQUE=トークン衝突で別院上書きを防ぐ・r5 MiniMax#4)
  gas_deploy_url TEXT NOT NULL,
  stripe_customer_id TEXT,             -- C3で使用(C1ではNULL許容)
  status TEXT NOT NULL DEFAULT 'active',  -- active|warning|stopped|unknown
  license_valid_until TEXT,            -- ISO8601・C3で使用
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS heartbeats (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tenant_id TEXT NOT NULL REFERENCES tenants(tenant_id),
  last_reservation_at TEXT,            -- 非PII(spec §3ストア配置表)
  received_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_heartbeats_tenant_time ON heartbeats(tenant_id, received_at);
  • Step 2: ローカルD1へ適用して動作確認

Run: cd worker && npx wrangler d1 execute reserve-optimizer --local --file=migrations/0001_tenant_registry.sql && npx wrangler d1 execute reserve-optimizer --local --command "SELECT name FROM sqlite_master WHERE type='table'" | tail -15; echo "EXIT=$?" Expected: tenants と heartbeats が表示される・EXIT=0 注: D1データベース名が未作成の場合は npx wrangler d1 create reserve-optimizer を先に実行し、出力の database_id を worker/wrangler.toml の [[env.d1_databases]] へ追記する(本番は --env demo 側にも同じく適用)。

  • Step 3: Commit
git add worker/migrations/0001_tenant_registry.sql worker/wrangler.toml
git commit -m "feat(c1): D1 schema for tenant registry + heartbeats (spec §4 C1)"

Task 5: C1-2 Worker実装(登録簿+ハートビート+ヘルス)

Files:

  • Create: worker/src/registry.ts
  • Create: worker/src/registry.test.ts
  • Modify: worker/src/index.ts(既存fetchハンドラにルーティング3行を追加)

  • Step 1: 失敗するテストを書く
// worker/src/registry.test.ts
/**
 * registry ルートの単体テスト(vitest・インメモリD1スタブ)
 * spec §4 C1: 登録簿照会 / ハートビート受信 / ヘルス
 */
import { describe, expect, it } from "vitest";
import { createRegistryD1Stub, handleRegistryRequest } from "./registry";

const TENANT = {
  tenant_id: "T0001",
  clinic_name: "テスト整骨院",
  line_channel_id: "U1234567890abcdef123456",
  heartbeat_token: "hb-token-0001",
  gas_deploy_url: "https://script.google.com/macros/s/AKfycb/exec",
  stripe_customer_id: null,
  status: "active",
  license_valid_until: null,
  created_at: "2026-10-09 00:00:00",
};

describe("registry routes", () => {
  it("GET /health returns ok", async () => {
    const env = { DB: createRegistryD1Stub([[TENANT]]) };
    const res = await handleRegistryRequest(new Request("https://x/health"), env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { status: string };
    expect(body.status).toBe("ok");
  });

  it("POST /heartbeat with valid token records heartbeat", async () => {
    const env = { DB: createRegistryD1Stub([[TENANT]]) };
    const req = new Request("https://x/heartbeat", {
      method: "POST",
      headers: {
        "Authorization": "Bearer hb-token-0001",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ tenant_id: "T0001", last_reservation_at: null }),
    });
    const res = await handleRegistryRequest(req, env);
    expect(res.status).toBe(200);
  });

  it("POST /heartbeat with invalid token returns 401", async () => {
    const env = { DB: createRegistryD1Stub([[TENANT]]) };
    const req = new Request("https://x/heartbeat", {
      method: "POST",
      headers: { "Authorization": "Bearer wrong-token", "Content-Type": "application/json" },
      body: JSON.stringify({ tenant_id: "T0001" }),
    });
    const res = await handleRegistryRequest(req, env);
    expect(res.status).toBe(401);
  });

  it("GET /tenants/:id returns tenant without secrets", async () => {
    const env = { DB: createRegistryD1Stub([[TENANT]]), ADMIN_TOKEN: "admin-tok" };
    const res = await handleRegistryRequest(
      new Request("https://x/tenants/T0001", { headers: { Authorization: "Bearer admin-tok" } }),
      env
    );
    expect(res.status).toBe(200);
    const body = (await res.json()) as { heartbeat_token?: string };
    expect(body.heartbeat_token).toBeUndefined(); // シークレットは返さない
  });

  it("GET /tenants/unknown returns 404", async () => {
    const env = { DB: createRegistryD1Stub([]), ADMIN_TOKEN: "admin-tok" };
    const res = await handleRegistryRequest(
      new Request("https://x/tenants/T9999", { headers: { Authorization: "Bearer admin-tok" } }),
      env
    );
    expect(res.status).toBe(404);
  });

  it("POST /heartbeat without Authorization header returns 401", async () => {
    const env = { DB: createRegistryD1Stub([[TENANT]]) };
    const res = await handleRegistryRequest(
      new Request("https://x/heartbeat", { method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ tenant_id: "T0001" }) }),
      env
    );
    expect(res.status).toBe(401);
  });
});
  • Step 2: テストが失敗することを確認

Run: cd worker && set -o pipefail && npx vitest run src/registry.test.ts 2>&1 | tail -10; echo "EXIT=$?" Expected: FAIL(Cannot find module './registry')

  • Step 3: registry.tsを実装
// worker/src/registry.ts
/**
 * C1軽量コントロールプレーン: テナント登録簿照会・ハートビート受信・ヘルス。
 * spec §4 C1(Stripe/ライセンスはC3・冪等テーブルはC2で別モジュール)。
 * 個人情報は保持しない(spec §3設計保証・ペイロードは非PIIのみ)。
 */

export interface Env {
  DB: D1Database;
  ADMIN_TOKEN?: string; // /tenants照会用(wrangler secret put ADMIN_TOKEN・r6 GLM#2)
}

interface TenantRow {
  tenant_id: string;
  clinic_name: string;
  line_channel_id: string;
  heartbeat_token: string;
  gas_deploy_url: string;
  stripe_customer_id: string | null;
  status: string;
  license_valid_until: string | null;
  created_at: string;
}

/** テスト用インメモリD1スタブ(prepare/bind/first/all/run の最小契約) */
export function createRegistryD1Stub(tenantRows: TenantRow[][]) {
  const tenants = tenantRows[0] ?? [];
  return {
    prepare(sql: string) {
      return {
        bind(...values: unknown[]) {
          return {
            async first<T>() {
              if (sql.includes("FROM tenants WHERE tenant_id")) {
                const id = values[0] as string;
                return (tenants.find((t) => t.tenant_id === id) as T) ?? null;
              }
              return null;
            },
            async run() {
              return { success: true };
            },
            async all<T>() {
              return { results: tenants as T[] };
            },
          };
        },
      };
    },
  } as unknown as D1Database;
}

function json(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

export async function handleRegistryRequest(request: Request, env: Env): Promise<Response> {
  const url = new URL(request.url);
  const path = url.pathname;

  if (path === "/health") {
    return json({ status: "ok" });
  }

  if (path === "/heartbeat" && request.method === "POST") {
    const auth = request.headers.get("Authorization") ?? "";
    const token = auth.replace(/^Bearer\s+/i, "");
    let payload: { tenant_id?: string; last_reservation_at?: string | null };
    try {
      payload = (await request.json()) as typeof payload;
    } catch {
      return json({ error: "invalid json" }, 400);
    }
    if (!payload.tenant_id) return json({ error: "tenant_id required" }, 400);
    const tenant = await env.DB.prepare(
      "SELECT * FROM tenants WHERE tenant_id = ?1"
    )
      .bind(payload.tenant_id)
      .first<TenantRow>();
    if (!tenant || tenant.heartbeat_token !== token) {
      return json({ error: "unauthorized" }, 401);
    }
    await env.DB.prepare(
      "INSERT INTO heartbeats (tenant_id, last_reservation_at) VALUES (?1, ?2)"
    )
      .bind(payload.tenant_id, payload.last_reservation_at ?? null)
      .run();
    return json({ status: "recorded" });
  }

  const tenantMatch = path.match(/^\/tenants\/([A-Za-z0-9]+)$/);
  if (tenantMatch && request.method === "GET") {
    // /tenants照会は管理操作のため ADMIN_TOKEN 認証を必須化(無認証だと院名・URLが公開列挙される・r6 GLM#2)
    const adminAuth = request.headers.get("Authorization") ?? "";
    if (adminAuth !== `Bearer ${env.ADMIN_TOKEN}`) {
      return json({ error: "unauthorized" }, 401);
    }
    const tenant = await env.DB.prepare(
      "SELECT * FROM tenants WHERE tenant_id = ?1"
    )
      .bind(tenantMatch[1])
      .first<TenantRow>();
    if (!tenant) return json({ error: "not found" }, 404);
    // シークレット(heartbeat_token)は返さない
    const { heartbeat_token, ...safe } = tenant;
    return json(safe);
  }

  return json({ error: "not found" }, 404);
}

注: 既存 worker/src/index.ts のD1バインディング名と異なる場合は本モジュールの Env に合わせる(wrangler.tomlの binding 名を DB に統一する方が変更が小さい)。

  • Step 4: テストが通ることを確認

Run: cd worker && set -o pipefail && npx vitest run src/registry.test.ts 2>&1 | tail -10; echo "EXIT=$?" Expected: 5 passed

  • Step 5: 既存index.tsへルーティングを追加

worker/src/index.ts の fetch ハンドラ冒頭(他ルーティングより前)に追加:

import { handleRegistryRequest } from "./registry";

// (fetchハンドラ内・url解析後の冒頭に):
const pathname = new URL(request.url).pathname;
if (
  pathname === "/health" ||
  pathname === "/heartbeat" ||
  /^\/tenants\/[A-Za-z0-9]+$/.test(pathname) // パラメータ付きルートは全体マッチ正規表現(部分一致禁止規則はLINE webhook系パス向け・r6 GLM#3で注記と統一)
) {
  return handleRegistryRequest(request, env as never);
}

注: 実装前に既存 index.ts の /health ルート有無を必ず実測する。ある場合は応答契約(JSON形式)を比較し、一致するなら本モジュールに統合・不一致なら既存を優先して本ブロックの /health を /registry/health へ変更。pathname判定は完全一致 === のみ(startsWith/正規表現は禁止・LINE webhook経路の誤飲防止・r5 GLM#3/OR1#3/MiniMax#5反映)。env as never は既存Env型と型が競合しないための一時措置で、既存Envへ DB: D1Database を足せば as never は削除してよい。

  • Step 6: 全テスト(既存含む)が通ることを確認

Run: cd worker && set -o pipefail && npx vitest run 2>&1 | tail -8; echo "EXIT=$?" Expected: 既存テスト含め全PASS・EXIT=0

  • Step 7: デプロイして実機ヘルス確認

Run: cd worker && npx wrangler deploy 2>&1 | tail -5 && curl -s https://reserve-optimizer.fukukei44161.workers.dev/health; echo; echo "EXIT=$?" Expected: {"status":"ok"}

  • Step 8: Commit
git add worker/src/registry.ts worker/src/registry.test.ts worker/src/index.ts
git commit -m "feat(c1): tenant registry + heartbeat + health endpoints (spec §4 C1)"

Task 6: C1-3 GAS側ハートビート送信

Files:

  • Create: gas-project/services/HeartbeatService.js
  • Create: gas-project/tests/HeartbeatServiceTests.js
  • Modify: gas-project/tests/TestRunner.js(Task 3と同様の1行連結追加)

  • Step 1: 失敗するテストを書く
// gas-project/tests/HeartbeatServiceTests.js
/**
 * HeartbeatService unit tests - 中央への日次ハートビート送信(spec §4 C1)
 * Run in GAS environment: testHeartbeatService()
 */

function testHeartbeatService() {
  var tests = [];

  // ── buildPayload: 非PIIのみ(tenant_id+最終予約時刻)──
  try {
    var payload = HeartbeatService.buildPayload('T0001', '2026/10/08 22:00:00');
    var keys = Object.keys(payload);
    tests.push(_assert('buildPayload: only non-PII keys',
      keys.indexOf('tenant_id') !== -1 && keys.indexOf('last_reservation_at') !== -1 && keys.length === 2,
      'keys=' + keys.join(',')));
  } catch (e) {
    tests.push({name: 'buildPayload', passed: false, message: 'Error: ' + e.toString()});
  }

  // ── send: 中央へのPOST(モックUrlFetchApp)──
  try {
    var captured = null;
    var mockFetch = function(url, params) {
      captured = { url: url, params: params };
      return { getResponseCode: function() { return 200; } };
    };
    var result = HeartbeatService.send('T0001', null, 'https://central.example/heartbeat', 'hb-token-0001', mockFetch);
    tests.push(_assert('send: returns true on 200', result === true, 'must be true'));
    tests.push(_assert('send: Bearer token set',
      captured.params.headers.Authorization === 'Bearer hb-token-0001', 'token header missing'));
  } catch (e) {
    tests.push({name: 'send', passed: false, message: 'Error: ' + e.toString()});
  }

  // ── send: 5xx時は例外にせずfalse(予約を止めない)──
  try {
    var mock500 = function() { return { getResponseCode: function() { return 500; } }; };
    var result2 = HeartbeatService.send('T0001', null, 'https://central.example/heartbeat', 'tok', mock500);
    tests.push(_assert('send: returns false on 500', result2 === false, 'must be false, not throw'));
  } catch (e) {
    tests.push({name: 'send 500', passed: false, message: 'Error: ' + e.toString()});
  }

  return tests;
}
  • Step 2: TestRunnerへ連結追加

Task 3 Step 2と同様に results.tests = results.tests.concat(testHeartbeatService()); を末尾へ追加。

  • Step 3: テストが失敗することを確認

Run: bash gas-project/gas-run.sh deploy testHeartbeatService 2>&1 | tail -10; echo "EXIT=$?" Expected: FAIL(テスト結果に HeartbeatService is not defined のpassed:false行)

  • Step 4: HeartbeatServiceを実装
// gas-project/services/HeartbeatService.js
/**
 * HeartbeatService - 中央コントロールプレーンへの日次ハートビート送信(spec §4 C1)。
 * トリガー=既存の日次定期トリガー(無イベント院の静的死対策・spec §4 C1ゲート)。
 * 失敗しても例外を投げない(予約処理に影響しない・fire-and-forget)。
 */
var HeartbeatService = (function() {

  function buildPayload(tenantId, lastReservationAt) {
    // 非PIIのみ(spec §3ストア配置表)
    return { tenant_id: tenantId, last_reservation_at: lastReservationAt || null };
  }

  function send(tenantId, lastReservationAt, centralUrl, token, fetchImpl) {
    var doFetch = fetchImpl || UrlFetchApp.fetch;
    var payload = buildPayload(tenantId, lastReservationAt);
    try {
      var response = doFetch(centralUrl + '/heartbeat', {
        method: 'post',
        contentType: 'application/json',
        headers: { Authorization: 'Bearer ' + token },
        payload: JSON.stringify(payload),
        muteHttpExceptions: true
      });
      return response.getResponseCode() === 200;
    } catch (e) {
      return false; // 中央障害時は諦める(次の定期トリガーで再試行)
    }
  }

  /** 日次トリガーから呼ばれるエントリポイント。Token/URLは Script Properties。 */
  function sendDaily() {
    var props = PropertiesService.getScriptProperties();
    var tenantId = props.getProperty('TENANT_ID') || 'T0001';
    var url = props.getProperty('CENTRAL_URL');
    var token = props.getProperty('HEARTBEAT_TOKEN');
    if (!url || !token) return; // 未設定なら何もしない(C1途中の院を壊さない)
    var lastReservationAt = props.getProperty('LAST_RESERVATION_AT'); // 予約サービスが更新(無ければnull)
    send(tenantId, lastReservationAt, url, token);
  }

  return { buildPayload: buildPayload, send: send, sendDaily: sendDaily };
})();
  • Step 5: テストが通ることを確認

Run: bash gas-project/gas-run.sh deploy testHeartbeatService 2>&1 | tail -10; echo "EXIT=$?" Expected: 全テストPASS

  • Step 6: E2E回帰

Run: set -o pipefail && node tests/run-all.js 2>&1 | tail -5; echo "EXIT=$?" Expected: 38系統green(r6 OR1#4/GLM#1反映)

  • Step 7: Commit
git add gas-project/services/HeartbeatService.js gas-project/tests/HeartbeatServiceTests.js gas-project/tests/TestRunner.js
git commit -m "feat(c1): GAS heartbeat service (daily trigger, fire-and-forget) - spec §4 C1"

Task 7: C1-4 中央死活の外部プローブRunbook

Files:

  • Create: docs/runbooks/uptime-probe.md

  • Step 1: Runbookを書く

# 中央死活外部プローブ設定手順(spec §4 C1・「中央が死んで誰が気づくか」対策)

1. UptimeRobot(無料枠50モニター)でアカウント作成: https://uptimerobot.com
2. Add New Monitor:
   - Monitor Type: HTTP(s)
   - Friendly Name: reserve-optimizer-central
   - URL: https://reserve-optimizer.fukukei44161.workers.dev/health
   - Monitoring Interval: 60分(無料枠の制約)
   - 「Send an email alert」に開発者メールアドレス
3. 1時間後にUptimeRobotダッシュボードで「Up」表示を確認
4. 検証: ターミナルから `curl -s https://reserve-optimizer.fukukei44161.workers.dev/health`
   が `{"status":"ok"}` を返すことと一致することを確認

注意: 60分間隔のため「検知遅延3h以内」KPI(spec §9)の中央側要件はこのプローブ+ハートビート欠落検知の組合せで満たす。プローブがDownになったら開発者が手動で復旧(spec §3 復旧権限=開発者)。
  • Step 2: プローブ設定を実施しDown/Upを確認

Run: curl -s https://reserve-optimizer.fukukei44161.workers.dev/health; echo Expected: {"status":"ok"}(プローブ対象が生きていることを実機確認)

  • Step 3: Commit
git add docs/runbooks/uptime-probe.md
git commit -m "docs(c1): uptime probe runbook (spec §4 C1)"

Task 8: C1回帰確認+ゲート判定材料の記録

Files:

  • Create: docs/c1-gate-checklist.md

  • Step 1: 全回帰を実行

Run: cd /home/yn4416/projects/reserve-optimizer && set -o pipefail && node tests/run-all.js 2>&1 | tail -3; echo "E2E_EXIT=$?" && cd worker && set -o pipefail && npx vitest run 2>&1 | tail -5; echo "VITEST_EXIT=$?" Expected: E2E 38系統green(件数増を確認=concat追加漏れの検知・r5 MiniMax#7/r6 OR1#4反映・pipefailでnode実exit code取得・r6 GLM#1反映)・vitest全PASS・両方EXIT=0

  • Step 2: ゲートチェックリストを書く(C1ゲート=spec §4・MLRとKPI基準値は別途)
# C1ゲート チェックリスト(spec §4 C1)

| 項目 | チェック | ステータス |
|---|---|---|
| E2E36本全緑(EXIT=0添付) | [ ] | 実装完了時に入力 |
| worker vitest 全緑 | [ ] | 実装完了時に入力 |
| ハートビート源=日次定期トリガー7日連続発火(合格基準=T0001で7日+外部再作成手順の動作確認) | [ ] | 実機運用待ち(開始日: ____ / 判定日: +7日) |
| 中央死活の外部プローブ設定済み | [ ] | Task 7完了時に入力 |
| KPI基準値確定 | [ ] | Phase 0メトリクス記録後 |
| MLR(C1成果物に対する3機レビュー) | [ ] | 別回実施 |

**本チェックリストの完了は「実装完了」を示すものでC1ゲート通過(C1-go)を含意しない**(r5 MiniMax#6反映・C1-goは7日連続発火+MLR+KPI基準値の3点セット)。

※ 「7日連続発火」と「KPI基準値」が未達の間、C1は**ゲート通過でなく実装完了**と呼ぶ(spec §4のゲート判定はこのチェックリスト全項目完了で確定)。
※ **スコープ明示(r6 GLM#4反映)**: spec §5の「ハートビート24h不着→unknown化」機構(status書き換えCron)は**C2計画で実装**する・C1ではstatusは'active'のまま。C1の死活検知は外部プローブ(中央)+7日連続トリガー確認(テナント)のみ。
※ last_reservation_at は初回ハートビートまではNULL正常(初回以降の値存在をC1ゲート確認項目に含める・r6 OR1#7反映)。
  • Step 3: Commit
git add docs/c1-gate-checklist.md
git commit -m "docs(c1): gate checklist - implementation complete, gate pending on 7-day trigger + KPI baseline"

Task 9: C0-3 セキュリティ自己レビュー(OWASP Top10視点・spec §4 C0ゲート)

Files:

  • Create: docs/c0-security-review.md

  • Step 1: 既存GASコードへの機械チェックを実行(OWASP対応表のうち機械可能な項目)

Run: cd /home/yn4416/projects/reserve-optimizer && grep -rn "eval(\|new Function(\|innerHTML" gas-project --include='*.js' | grep -v node_modules | grep -v tests | head -10; echo "GREP_EXIT=$?" && grep -rn "Secret\|TOKEN\|KEY" gas-project/config/ScriptProperties.js | head -15; echo "EXIT=$?" Expected: コード実行系API(eval等)の使用=0件が理想・Secret類はScriptProperties経由で集中管理されていることを目視

  • Step 2: 脅威モデルごとの目視チェックを記録

docs/c0-security-review.md に以下の観点ごとに「確認方法/結果/要対応」を記入(spec固有の脅威モデル+r4 OR1指摘のOWASP適用限界を踏まえたもの):

脅威 確認方法 対応方針
LINE webhookのパラメータ注入(Invalidated input) ReservationHandler.js の doPost 内でユーザー入力をシートに書く箇所のサニタイズ有無を目視 予約氏名等はスプレッドシート限定=影響小・SQLなし
シークレットのハードコード Task 1の定数インベントリ stripe_key_prefix・line_channel_access_token カテゴリが0件か確認 1件以上あればproperties判定へ
LOG経由の機密漏えい Logger.log にtoken/本文全文を出していないか grep tokenを出す箇所はマスク
URL Fetch先の固定性(SSRF) fetch先URLがユーザー入力由来でないか確認 全URLはconfig固定値であること
過度な権限(OAuth scope) appsscript.json の oauthScopes が必要最小限か目視 余剰scopeは削除候補としてC1後に判断
  • Step 3: 要対応項目を0件または起票済みにして完了

Run: grep -c "要対応: 有" docs/c0-security-review.md; echo "EXIT=$?" Expected: 0(表の要対応列は「要対応: 有/なし」の固定形式で記入する・r5 OR1#6反映)

  • Step 4: Commit
git add docs/c0-security-review.md
git commit -m "docs(c0): security self-review (OWASP view + stack-specific threats, spec §4 C0 gate)"

人間タスク チェックリスト(コードでないため本計画では実行しない・spec §8)

  • 兼業確認「明確OK」の取得(確認方法・担当・期日付きで記録)— C3課金開始の前提
  • 模擬面接1回以上(CC資料初版が前提資料)
  • 適法性チェックリスト(Stripe名義・特商法・インボイス・ココナラ経路)
  • 現行院メトリクスの2〜4週記録(KPIベースライン)
  • 撤退基準の運用開始(面談1件+往復2回=操作的定義の自動カウント)
  • GASクォータ消費率測定(webhookリトライ最悪値シナリオ含む)

完了条件

Task 1〜8の全チェックボックス完了+人間タスクのうち「兼業確認」以外は並行進行可。C1のゲート通過判定は7日連続トリガー発火確認後に確定する。C2以降の計画は本計画のC0成果物(docs/tenant-constants.tsvのjudgment分布)を見てから作成する。


r5レビュー反映記録(2026-10-09・4機25指摘)

採用10件(本計画へ反映済み): フィクスチャ×正規表現不整合修正(GLM#1/#2・機械検証で確認)・LAST_RESERVATION_AT更新行追加(Gemini#1/GLM#5/MiniMax#9・3機一致)・heartbeat_token UNIQUE化(MiniMax#4)・/health競合の実測+完全一致規則(GLM#3/OR1#3/MiniMax#5)・チェックリスト[x]事前記入廃止(GLM#4)・Task 9判定値形式(OR1#6)・KPI_LOGのC0対象外明記(MiniMax#2)・run-all件数確認(MiniMax#7部分)・C1-go語彙分離(MiniMax#6)・RED判別条件(MiniMax#1)

却下(理由付き):

  • OR1#5「LINEトークンはxoxb-プレフィックス」= 誤認識(xoxb-はSlack形式・LINE Messaging APIのトークンはJWT的long文字列で別形式)※外部知識に基づく判定・実装時に実トークン形式で最終確認
  • Gemini#3「value[:40]切り捨てをやめ全文保存」= git管理ファイルにシークレット全文を置くのは漏洩面を増やす(C0の目的は移行でなく判断)
  • OR1#2「heartbeats.last_reservation_atのNULL制約」= DDLはNULL許容でスタブと整合(乖離なし)
  • MiniMax#3「sendDaily失敗時のリトライ機構」= 送信失敗は中央側の「ハートビート24h不着→unknown化」検知(spec §5)が担う・二重化は不要
  • MiniMax#8「grep監査の除外ルール」= Task 9コマンドにnode_modules/tests除外は既存(記録のみ)
  • OR1#4「TENANT_IDデフォルト例外化」= 未設定早期リターンは「C1途中の院を壊さない」意図的設計
  • Gemini#2「D1スタブrun()失敗注入」= 将来改善(YAGNI・C1スコープ外)

This site uses Just the Docs, a documentation theme for Jekyll.