Skip to content

feat(BOXP-120): lolice cluster メンバーポータルのセルフサービス化 - #11314

Merged
boxp merged 24 commits into
mainfrom
feature/BOXP-120-lolice-member-portal
Jul 20, 2026
Merged

boxp merged 24 commits into
mainfrom
feature/BOXP-120-lolice-member-portal

Conversation

@boxp

@boxp boxp commented Jul 17, 2026

Copy link
Copy Markdown
Owner

概要

lolice cluster(PalWorldゲームサーバー)への参加をセルフサービス化するメンバーポータルを実装します。

実装内容

  • Cloudflare Pages: 申請フォーム(/index.html)と参加手順(/guide.html)のホスティング
  • Cloudflare Workers: フォーム受信・承認メール送信・Cloudflare Access Policy自動更新のバックエンド
  • Cloudflare KV: 承認待ちリクエスト(メールアドレス+トークン)の一時保存(TTL 7日)
  • Cloudflare D1 (SQLite): 承認済みメールの強整合ストレージ(KVの結果整合性を回避)
  • Resend: メール送信(承認メール・案内メール)

フロー

  1. 参加希望者がフォームにメールアドレスを入力して送信
  2. boxp(tiyotiyouda@gmail.com)に承認確認ページのリンクが届く
  3. 確認ページでPOSTボタンを押すとメールアドレスがCloudflare Access policyに自動追加
  4. 申請者に参加手順ページのURLが案内される

並行承認の安全性

addEmailToAccessPolicy でCloudflare Access PolicyのRead-Modify-Writeを行う際:

  • 承認済みメールをCloudflare D1(SQLite、ACID保証)に永続化してからAPI呼び出し
  • PUTの度にD1から全承認メールを読み込んでポリシーにマージ(D1は強整合なので並行書き込みも即座に反映)
  • 検証ステップでD1の最新スナップショットとポリシーの一致を確認、差異があれば再試行(最大5回)

これにより、複数の承認が並行した場合でも最終的にすべての承認済みメールがポリシーに反映される。

デプロイ手順

1. terraform apply

通常通り tfaction の PR → plan → apply フローで実行します。Workerリソース・KV・D1・Pagesプロジェクトが作成されます。

注意: シークレット(CF_API_TOKENRESEND_API_KEY)はTerraformステートへの平文保存を避けるため、Terraformでは管理しません。apply後に手動で設定します(次のステップ)。

2. terraform apply 後にWorker Secretを手動設定

SSM Parameter Storeに登録済みの値を取得してCloudflare Dashboard(またはWrangler)で設定してください。

Cloudflare Dashboardから設定する場合

  1. Cloudflare Dashboard を開く
  2. Settings > Variables を開き、Secret Variables セクションで以下を追加:
    • CF_API_TOKEN : SSM /lolice-member-portal/CF_API_TOKEN の値
    • RESEND_API_KEY : SSM /lolice-member-portal/RESEND_API_KEY の値

Wrangler CLIから設定する場合(SSMから値を取得して投入)

# CF_API_TOKEN(Zero Trust: Edit + Account Settings: Read 権限のAPIトークン)
aws ssm get-parameter \
  --name /lolice-member-portal/CF_API_TOKEN \
  --with-decryption \
  --query Parameter.Value \
  --output text | wrangler secret put CF_API_TOKEN --name lolice-member-portal

# RESEND_API_KEY(resend.com で取得したAPIキー)
aws ssm get-parameter \
  --name /lolice-member-portal/RESEND_API_KEY \
  --with-decryption \
  --query Parameter.Value \
  --output text | wrangler secret put RESEND_API_KEY --name lolice-member-portal

tfaction

terraform/cloudflare/b0xp.io/lolice-member-portal/ ディレクトリが追加されます。
PRマージ後に tfaction が terraform plan → apply を実行します。

…ber-portal

CI失敗の原因: aqua/ディレクトリが存在せずterraformバイナリが見つからなかった。
moltworkerと同じv1.15.8のterraform、v0.63.1のtflint、v0.72.0のtrivyを設定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

- GET /api/approve now returns a confirmation HTML page; POST /api/approve
  performs the actual approval, preventing link-preview bots from approving
  requests without admin intent
- Validate payload is a non-null object in POST /api/request before accessing
  payload.email, returning 400 instead of 500 for null/non-object bodies
- Wrap read-modify-write in addEmailToAccessPolicy with a 3-attempt retry
  loop to handle concurrent approval race conditions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

@boxp-tfaction

This comment has been minimized.

acquirePolicyLock/releasePolicyLockを追加し、addEmailToAccessPolicy内の
read-modify-write全体をKVベースのアドバイザリーロックで保護する。

KVはアトミックCASを持たないためベストエフォートだが、既存のPUT後の
検証(verify)ループと組み合わせることで、並行承認による上書きを
検出・再試行し、全メールアドレスが最終的にポリシーに含まれることを保証する。
maxRetriesも5→8に増加。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

…limiting

- Replace KV-based advisory lock (non-atomic, racy) with a singleton
  PolicyUpdater Durable Object. The DO runtime serializes concurrent
  requests, eliminating the read-modify-write race on Access Policy PUT.
- Add IP-based rate limiting (3 req/hour) to POST /api/request to prevent
  admin email spam from bots.
- Remove dead KV lock code (acquirePolicyLock, releasePolicyLock).
- Add durable_object_namespace_binding to worker.tf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

…om worker.tf

cloudflare/cloudflare v4.52 does not support durable_object_namespace_binding
in cloudflare_workers_script. Replace PolicyUpdater Durable Object with
inline read-modify-write+verify retry loop (3 attempts) in addEmailToAccessPolicy.
Manual approvals are sequential in practice so the retry handles the edge case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

…e Object

Replace the retry-based read-modify-write in addEmailToAccessPolicy with a
singleton PolicyUpdateDO Durable Object. The DO's single-threaded event loop
queues concurrent approval requests, so each GET→PUT sees the fully committed
result of the previous write and no email addition can be silently overwritten.

Also add durable_object_namespace_binding and migrations block to worker.tf.
@boxp-tfaction

boxp-tfaction Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validate

Build link

terraform validate
Details
Jul 17 13:06:30.688 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 17, in resource "cloudflare_workers_script" "lolice_member_portal":
│   17:   durable_object_namespace_binding {
│ 
│ Blocks of type "durable_object_namespace_binding" are not expected here.
╵
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 57, in resource "cloudflare_workers_script" "lolice_member_portal":
│   57:   migrations {
│ 
│ Blocks of type "migrations" are not expected here.
╵

…pdates

Cloudflare provider v4.52 does not support durable_object_namespace_binding
or migrations blocks in cloudflare_workers_script, causing terraform validate
to fail. Replace the DO-based serialization with an addEmailToAccessPolicy
function that performs a read-modify-write loop and verifies the email was
persisted after each PUT. If a concurrent PUT overwrote the write, the
verify step detects the missing entry and retries with backoff (up to 5
attempts), ensuring eventual consistency without requiring Durable Objects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

Replace read-modify-write retry loop with PolicyUpdater Durable Object
so concurrent approvals are queued and executed serially

Replace non-atomic KV rate limiting with atomic DO storage counters

Remove default="" from cf_api_token/resend_api_key to prevent
deploying with blank secrets
@boxp-tfaction

boxp-tfaction Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validate

Build link

terraform validate
Details
Jul 18 01:48:14.634 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 52, in resource "cloudflare_workers_script" "lolice_member_portal":
│   52:   durable_object_namespace_binding {
│ 
│ Blocks of type "durable_object_namespace_binding" are not expected here.
╵
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 57, in resource "cloudflare_workers_script" "lolice_member_portal":
│   57:   migrations {
│ 
│ Blocks of type "migrations" are not expected here.
╵

DO storage does not support expirationTtl option (unlike KV).
Store {count, windowStart} record and reset when window expires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

boxp-tfaction Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validate

Build link

terraform validate
Details
Jul 18 02:01:57.921 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 52, in resource "cloudflare_workers_script" "lolice_member_portal":
│   52:   durable_object_namespace_binding {
│ 
│ Blocks of type "durable_object_namespace_binding" are not expected here.
╵
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 57, in resource "cloudflare_workers_script" "lolice_member_portal":
│   57:   migrations {
│ 
│ Blocks of type "migrations" are not expected here.
╵

Cloudflare TF provider v4.52.8 does not support durable_object_namespace_binding
or migrations blocks in cloudflare_workers_script, causing CI failures.

Replace the DO approach with a KV-sourced strategy:
- Add APPROVED_EMAILS KV namespace as durable source of truth for approvals
- On each policy update: write email to KV first, then merge ALL KV emails
  into the policy (deduplicating with existing include rules)
- Verify email appears after PUT; retry with fresh GET if absent (handles
  the rare case where a concurrent PUT overwrote our addition)
- Rate limiting uses PENDING_REQUESTS KV with expirationTtl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

Replace secret_text_binding (which required Terraform variables for secret
values) with separate cloudflare_workers_secret resources. Secrets are
initialized with a dummy placeholder and lifecycle.ignore_changes prevents
Terraform from overwriting values set manually via Cloudflare Dashboard.

This eliminates the need for cf_api_token and resend_api_key variables,
so terraform plan in CI succeeds without secret values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

@boxp-tfaction

boxp-tfaction Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validate

Build link

terraform validate
Details
Jul 18 02:29:44.420 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
╷
│ Error: Unsupported block type
│ 
│   on worker.tf line 17, in resource "cloudflare_workers_script" "lolice_member_portal":
│   17:   durable_object_namespace_binding {
│ 
│ Blocks of type "durable_object_namespace_binding" are not expected here.
╵

…ET verification

- Remove PolicyUpdater Durable Object (unsupported in cloudflare provider v4.52)
- Restore APPROVED_EMAILS KV namespace as durable source of truth
- Rewrite addEmailToAccessPolicy to merge ALL KV emails on every PUT attempt
- Verify with a separate fresh GET (not PUT response) that ALL KV emails exist
- Retry up to 5 times with exponential backoff on concurrent-overwrite detection
@boxp-tfaction

This comment has been minimized.

addEmailToAccessPolicy でKV list()の1ページ目(最大1,000件)しか
処理していなかった問題を修正。list_complete/cursorによるページネーション
ループで全メールアドレスを取得するように変更。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

…rrent approvals

The verification step was checking kvEmails from the start of the loop
iteration. If a concurrent approval was persisted to KV after that
snapshot was taken, our PUT could overwrite their addition and the
stale-snapshot check would still pass (missing their email).

Fix: re-scan APPROVED_EMAILS KV in step 6 to get the current complete
set, then verify the live policy contains all of them. If any are
absent, the loop retries the full read-modify-write cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

boxp and others added 3 commits July 18, 2026 02:53
…and SSM for secrets

- Replace APPROVED_EMAILS KV namespace with Cloudflare D1 database so that the
  retry+verify loop in addEmailToAccessPolicy always sees a consistent view of
  approved emails. KV's eventual consistency meant concurrent approvals could
  miss each other's writes even during the verify re-read; D1 (SQLite, ACID)
  eliminates this gap.
- Remove dummy secret values and lifecycle ignore_changes from cloudflare_workers_secret.
  Secrets are now read from AWS SSM Parameter Store via data sources, ensuring
  terraform apply always deploys the real credentials and no dummy placeholder
  can be accidentally left in place.
- Add AWS provider (hashicorp/aws ~> 5.0) to backend.tf and provider.tf.
- SSM prerequisites: aws ssm put-parameter --name /lolice-member-portal/CF_API_TOKEN
  and /lolice-member-portal/RESEND_API_KEY must be created before first apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sion constraint

- Bump aws provider constraint from ~> 5.0 to ~> 6.0 to match other directories in this repo
- Add hashicorp/aws 6.54.0 entry to .terraform.lock.hcl (copied from bastion which uses same version)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

boxp-tfaction Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Build link

terraform init -input=false -upgrade
Details
Jul 18 02:56:07.190 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
╷
│ Error: Duplicate provider lock
│ 
│   on .terraform.lock.hcl line 75:
│   (source code not available)
│ 
│ This lockfile already declared a lock for provider
│ registry.terraform.io/hashicorp/aws at .terraform.lock.hcl:4,1-47.
╵

Remove the manually-added 6.54.0 entry and keep only the CI-generated 5.100.0
entry from update-aqua-checksums. Align backend.tf constraint to ~> 5.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp-tfaction

This comment has been minimized.

@boxp-tfaction

This comment has been minimized.

…ation email

Access policy update (critical) and approval email send (non-critical) were in
the same try-catch block. If email sending failed after the policy was already
updated, the admin saw a generic "failure" page while the user actually had
access — an inconsistent state with no indication of what succeeded.

Now the two operations are handled independently:
- Policy update failure → 502 with retry instruction (token kept, link still valid)
- Policy update success → token deleted immediately (idempotent; link invalidated)
- Email send failure → 200 HTML page clearly stating access was granted and
  asking the admin to share the guide URL manually

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@boxp

boxp commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

fix: Access ポリシー更新とメール送信の結果を分離

codex-review の指摘(index.js:360 でメール送信失敗時の不整合)を修正しました。

問題

addEmailToAccessPolicy(ポリシー更新)と sendEmail(案内メール送信)が同じ try-catch ブロックに入っており、ポリシー更新成功→メール送信失敗の場合に:

  • ユーザーのアクセス権は付与済み
  • トークンは KV に残ったまま(リンク有効)
  • 管理者には「承認処理に失敗しました」と表示される

という不整合が発生していました。

修正内容

処理を3段階に分離:

  1. ポリシー更新(クリティカル) — 失敗時は 502 でリトライ案内(トークン保持、リンク有効のまま)
  2. トークン削除 — ポリシー更新成功後に即座に削除(同じリンクの再使用を防止)
  3. メール送信(非クリティカル) — 失敗時はアクセス権付与済みを明示したページを表示し、手動で案内 URL を共有するよう管理者に伝える

これにより、管理者は常に正確な状態(アクセス権が付与済みかどうか)を把握できます。

@boxp-tfaction

This comment has been minimized.

boxp and others added 2 commits July 20, 2026 08:02
- Remove cloudflare_workers_secret resources to avoid storing decrypted SSM values in terraform state. Secrets should be set via wrangler CLI.\n- Add unit tests for approve/reject/request flows using vitest
@boxp-tfaction

boxp-tfaction Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Plan Result (terraform/cloudflare/b0xp.io/lolice-member-portal)

CI link

Plan: 5 to add, 0 to change, 0 to destroy.
  • Create
    • cloudflare_d1_database.approved_emails
    • cloudflare_record.lolice_member_portal
    • cloudflare_worker_route.lolice_member_portal
    • cloudflare_workers_kv_namespace.pending_requests
    • cloudflare_workers_script.lolice_member_portal
Change Result (Click me)
  # cloudflare_d1_database.approved_emails will be created
  + resource "cloudflare_d1_database" "approved_emails" {
      + account_id = "1984a4314b3e75f3bedce97c7a8e0c81"
      + id         = (known after apply)
      + name       = "lolice-member-portal-approved-emails"
      + version    = (known after apply)
    }

  # cloudflare_record.lolice_member_portal will be created
  + resource "cloudflare_record" "lolice_member_portal" {
      + allow_overwrite = false
      + content         = "100::"
      + created_on      = (known after apply)
      + hostname        = (known after apply)
      + id              = (known after apply)
      + metadata        = (known after apply)
      + modified_on     = (known after apply)
      + name            = "lolice"
      + proxiable       = (known after apply)
      + proxied         = true
      + ttl             = (known after apply)
      + type            = "AAAA"
      + value           = (known after apply)
      + zone_id         = "ec593206d0ef695c3aae3a4cb3173264"
    }

  # cloudflare_worker_route.lolice_member_portal will be created
  + resource "cloudflare_worker_route" "lolice_member_portal" {
      + id          = (known after apply)
      + pattern     = "lolice.b0xp.io/*"
      + script_name = "lolice-member-portal"
      + zone_id     = "ec593206d0ef695c3aae3a4cb3173264"
    }

  # cloudflare_workers_kv_namespace.pending_requests will be created
  + resource "cloudflare_workers_kv_namespace" "pending_requests" {
      + account_id = "1984a4314b3e75f3bedce97c7a8e0c81"
      + id         = (known after apply)
      + title      = "lolice-member-portal-pending-requests"
    }

  # cloudflare_workers_script.lolice_member_portal will be created
  + resource "cloudflare_workers_script" "lolice_member_portal" {
      + account_id          = "1984a4314b3e75f3bedce97c7a8e0c81"
      + compatibility_flags = (known after apply)
      + content             = <<-EOT
            const CORS_HEADERS = {
              "Access-Control-Allow-Origin": "*",
              "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
              "Access-Control-Allow-Headers": "Content-Type",
            };
            
            const REQUEST_TTL_SECONDS = 60 * 60 * 24 * 7;
            const RATE_LIMIT_MAX = 3;
            const RATE_LIMIT_WINDOW_SECONDS = 3600;
            
            const INDEX_HTML = `<!doctype html>
            <html lang="ja">
              <head>
                <meta charset="UTF-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                <title>lolice cluster - メンバー参加申請</title>
                <style>
                  :root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
                  body { align-items: center; background: #111827; color: #f9fafb; display: flex; justify-content: center; margin: 0; min-height: 100vh; padding: 1.5rem; }
                  main { background: #1f2937; border-radius: 16px; box-shadow: 0 20px 45px #0006; max-width: 480px; padding: 2rem; width: 100%; }
                  h1 { font-size: 1.5rem; margin-top: 0; } p { color: #d1d5db; line-height: 1.7; }
                  label { display: block; font-weight: 600; margin: 1.5rem 0 .5rem; }
                  input, button { border-radius: 8px; box-sizing: border-box; font: inherit; padding: .8rem; width: 100%; }
                  input { border: 1px solid #6b7280; } button { background: #2563eb; border: 0; color: white; cursor: pointer; font-weight: 700; margin-top: 1rem; }
                  button:disabled { cursor: wait; opacity: .65; } #message { min-height: 1.5rem; margin-bottom: 0; } .success { color: #86efac; } .error { color: #fca5a5; }
                </style>
              </head>
              <body>
                <main>
                  <h1>lolice cluster - メンバー参加申請</h1>
                  <p>PalWorld ゲームサーバーへの参加を希望する方は、メールアドレスを入力してください。承認後に参加手順をお送りします。</p>
                  <form id="request-form">
                    <label for="email">メールアドレス</label>
                    <input id="email" name="email" type="email" autocomplete="email" required />
                    <button type="submit">参加を申請する</button>
                  </form>
                  <p id="message" aria-live="polite"></p>
                </main>
                <script>
                  const form = document.getElementById("request-form");
                  const message = document.getElementById("message");
                  const button = form.querySelector("button");
                  form.addEventListener("submit", async (event) => {
                    event.preventDefault();
                    button.disabled = true;
                    message.className = "";
                    message.textContent = "申請を送信しています…";
                    try {
                      const response = await fetch("/api/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: form.email.value }) });
                      const result = await response.json();
                      if (!response.ok) throw new Error(result.error || "申請に失敗しました。");
                      message.className = "success";
                      message.textContent = result.message;
                      form.reset();
                    } catch (error) {
                      message.className = "error";
                      message.textContent = error.message || "申請に失敗しました。時間をおいて再度お試しください。";
                    } finally { button.disabled = false; }
                  });
                </script>
              </body>
            </html>`;
            
            const GUIDE_HTML = `<!doctype html>
            <html lang="ja">
              <head>
                <meta charset="UTF-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                <title>lolice cluster - 参加手順</title>
                <style>
                  :root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
                  body { background: #111827; color: #f9fafb; line-height: 1.75; margin: 0; padding: 1.5rem; }
                  main { background: #1f2937; border-radius: 16px; box-shadow: 0 20px 45px #0006; margin: 2rem auto; max-width: 720px; padding: 2rem; }
                  h1 { font-size: 1.7rem; } li { margin: 1rem 0; } a { color: #93c5fd; } code { background: #374151; border-radius: 4px; padding: .15rem .35rem; }
                </style>
              </head>
              <body>
                <main>
                  <h1>lolice cluster - 参加手順</h1>
                  <p>参加承認後は、Cloudflare WARP を接続してから PalWorld の専用サーバーにアクセスしてください。</p>
                  <ol>
                    <li><strong>Cloudflare One Client をダウンロードします。</strong><br />Windows と Mac は <a href="https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/download/">Cloudflare One Client のダウンロードページ</a> を参照してください。iOS / Android は App Store または Google Play から Cloudflare One Client を入手してください。</li>
                    <li>インストールが完了したら、Cloudflare One Client を起動します。</li>
                    <li>チーム名に <code>boxp</code> と入力し、<strong>OK</strong> を押します。</li>
                    <li>承認されたメールアドレスを入力し、届いた OTP(ワンタイムパスワード)で認証します。</li>
                    <li>Cloudflare One Client の <strong>WARP 接続</strong>ボタンを押して接続します。</li>
                    <li>PalWorld を起動し、<strong>マルチプレイ</strong> → <strong>専用サーバー</strong> を選びます。サーバーアドレスに <code>192.168.10.97:8211</code> を入力して参加してください。</li>
                  </ol>
                </main>
              </body>
            </html>`;
            
            function jsonResponse(body, status = 200) {
              return new Response(JSON.stringify(body), {
                status,
                headers: { ...CORS_HEADERS, "Content-Type": "application/json; charset=utf-8" },
              });
            }
            
            function htmlResponse(body, status = 200) {
              return new Response(body, {
                status,
                headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" },
              });
            }
            
            function escapeHtml(value) {
              return value.replace(/[&<>"']/g, (character) => ({
                "&": "&amp;",
                "<": "&lt;",
                ">": "&gt;",
                '"': "&quot;",
                "'": "&#39;",
              })[character]);
            }
            
            function isValidEmail(email) {
              return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
            }
            
            async function sendEmail(env, { to, subject, html }) {
              const response = await fetch("https://api.resend.com/emails", {
                method: "POST",
                headers: {
                  Authorization: `Bearer ${env.RESEND_API_KEY}`,
                  "Content-Type": "application/json",
                },
                body: JSON.stringify({ from: "noreply@b0xp.io", to, subject, html }),
              });
            
              if (!response.ok) {
                throw new Error(`Resend API request failed with status ${response.status}`);
              }
            }
            
            // KV-based approximate rate limiting using PENDING_REQUESTS namespace.
            // get→put is non-atomic; for this low-traffic personal server the race window
            // is negligible, and the purpose (admin notification spam prevention) is met.
            async function isRateLimited(env, ip) {
              if (!ip) return false;
              const key = `rate:${ip}`;
              const count = parseInt((await env.PENDING_REQUESTS.get(key)) ?? "0", 10);
              if (count >= RATE_LIMIT_MAX) return true;
              await env.PENDING_REQUESTS.put(key, String(count + 1), {
                expirationTtl: RATE_LIMIT_WINDOW_SECONDS,
              });
              return false;
            }
            
            // Ensures the approved-emails D1 table exists.
            async function ensureApprovedEmailsTable(db) {
              await db
                .prepare(
                  "CREATE TABLE IF NOT EXISTS approved_emails (email TEXT PRIMARY KEY, approved_at INTEGER NOT NULL)"
                )
                .run();
            }
            
            // Adds an email to the Cloudflare Access policy using D1 as the strongly
            // consistent source of truth for approved emails.
            //
            // Safety model for concurrent approvals:
            //  1. The email is atomically written to D1 (ACID, strongly consistent) before
            //     any Cloudflare API call, making the approval durable regardless of
            //     subsequent policy update races.
            //  2. Every PUT reads ALL approved emails from D1 (consistent snapshot) so
            //     that concurrent requests converge to the same complete set.
            //  3. After each PUT a fresh GET is issued to verify that ALL D1-persisted
            //     emails appear in the live policy. Because D1 is strongly consistent, the
            //     fresh re-read in the verify step always reflects any concurrently inserted
            //     rows — unlike KV which is eventually consistent. If any email is absent
            //     (e.g. overwritten by a concurrent PUT that read an older policy snapshot),
            //     the loop retries from step 2 with exponential backoff until convergence.
            const MAX_POLICY_RETRIES = 5;
            
            async function addEmailToAccessPolicy(env, email) {
              const normalizedEmail = email.trim().toLowerCase();
            
              await ensureApprovedEmailsTable(env.APPROVED_EMAILS_DB);
            
              // Step 1: persist email to D1 atomically (strongly consistent).
              await env.APPROVED_EMAILS_DB.prepare(
                "INSERT OR IGNORE INTO approved_emails (email, approved_at) VALUES (?, ?)"
              )
                .bind(normalizedEmail, Date.now())
                .run();
            
              const cfHeaders = {
                Authorization: `Bearer ${env.CF_API_TOKEN}`,
                "Content-Type": "application/json",
              };
              const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/access/apps/${env.CF_APP_ID}/policies/${env.CF_POLICY_ID}`;
            
              for (let attempt = 0; attempt < MAX_POLICY_RETRIES; attempt++) {
                if (attempt > 0) {
                  await new Promise((r) => setTimeout(r, 200 * Math.pow(2, attempt - 1)));
                }
            
                // Step 2: load ALL approved emails from D1 (strongly consistent — no
                // eventual-consistency lag unlike KV).
                const { results: dbRows } = await env.APPROVED_EMAILS_DB.prepare(
                  "SELECT email FROM approved_emails"
                ).all();
                const allApprovedEmails = dbRows.map((r) => r.email);
            
                // Step 3: GET current policy.
                const getResponse = await fetch(apiUrl, { headers: cfHeaders });
                if (!getResponse.ok) {
                  throw new Error(`Cloudflare API GET failed: ${getResponse.status}`);
                }
                const { result: policy } = await getResponse.json();
                if (!policy || !Array.isArray(policy.include)) {
                  throw new Error("Invalid policy response from Cloudflare API");
                }
            
                // Step 4: merge existing non-email rules + deduplicated email list from D1.
                const existingEmails = policy.include
                  .filter((r) => r.email?.email)
                  .map((r) => r.email.email.toLowerCase());
                const nonEmailRules = policy.include.filter((r) => !r.email?.email);
                const mergedEmails = [...new Set([...existingEmails, ...allApprovedEmails])];
            
                const updated = {
                  name: policy.name,
                  decision: policy.decision,
                  include: [
                    ...nonEmailRules,
                    ...mergedEmails.map((e) => ({ email: { email: e } })),
                  ],
                  exclude: policy.exclude ?? [],
                  require: policy.require ?? [],
                };
            
                // Step 5: PUT the merged policy.
                const putResponse = await fetch(apiUrl, {
                  method: "PUT",
                  headers: cfHeaders,
                  body: JSON.stringify(updated),
                });
                if (!putResponse.ok) {
                  throw new Error(`Cloudflare API PUT failed: ${putResponse.status}`);
                }
            
                // Step 6: verify with a FRESH GET and a FRESH D1 read.
                // Because D1 is strongly consistent, the re-read always reflects any emails
                // inserted concurrently during this iteration. If another concurrent PUT
                // overwrote our update (dropping our email or someone else's), the
                // discrepancy is detected here and the full read-modify-write loop retries.
                const { results: latestDbRows } = await env.APPROVED_EMAILS_DB.prepare(
                  "SELECT email FROM approved_emails"
                ).all();
                const latestApprovedEmails = latestDbRows.map((r) => r.email);
            
                const verifyResponse = await fetch(apiUrl, { headers: cfHeaders });
                if (!verifyResponse.ok) {
                  throw new Error(`Cloudflare API verify-GET failed: ${verifyResponse.status}`);
                }
                const { result: verifiedPolicy } = await verifyResponse.json();
                const verifiedEmails = (verifiedPolicy?.include ?? [])
                  .filter((r) => r.email?.email)
                  .map((r) => r.email.email.toLowerCase());
            
                const allPresent = latestApprovedEmails.every((e) => verifiedEmails.includes(e));
                if (allPresent) return;
              }
            
              throw new Error("Failed to confirm all approved emails in Access policy after retries");
            }
            
            async function handleRequest(request, env) {
              const ip = request.headers.get("CF-Connecting-IP") ?? "";
              if (await isRateLimited(env, ip)) {
                return jsonResponse({ error: "リクエストが多すぎます。しばらく待ってから再試行してください。" }, 429);
              }
            
              let payload;
              try {
                payload = await request.json();
              } catch {
                return jsonResponse({ error: "リクエスト形式が正しくありません。" }, 400);
              }
            
              if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
                return jsonResponse({ error: "リクエスト形式が正しくありません。" }, 400);
              }
            
              const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
              if (!isValidEmail(email)) {
                return jsonResponse({ error: "有効なメールアドレスを入力してください。" }, 400);
              }
            
              const token = crypto.randomUUID();
              await env.PENDING_REQUESTS.put(token, JSON.stringify({ email }), {
                expirationTtl: REQUEST_TTL_SECONDS,
              });
            
              try {
                const approveUrl = `${env.PORTAL_BASE_URL}/api/approve?token=${encodeURIComponent(token)}`;
                const rejectUrl = `${env.PORTAL_BASE_URL}/api/reject?token=${encodeURIComponent(token)}`;
                await sendEmail(env, {
                  to: env.ADMIN_EMAIL,
                  subject: `[lolice] 参加申請: ${email}`,
                  html: `<p><strong>${escapeHtml(email)}</strong> から lolice cluster への参加申請がありました。</p><p><a href="${approveUrl}">承認確認画面へ</a> / <a href="${rejectUrl}">却下確認画面へ</a></p>`,
                });
              } catch (error) {
                await env.PENDING_REQUESTS.delete(token);
                console.error("Failed to send approval email", error);
                return jsonResponse({ error: "申請メールの送信に失敗しました。時間をおいて再度お試しください。" }, 502);
              }
            
              return jsonResponse({ message: "申請を受け付けました。承認後、メールでご案内します。" }, 202);
            }
            
            async function getPendingEmail(token, env) {
              if (!token) {
                return { error: htmlResponse("<h1>無効な申請リンクです。</h1>", 400) };
              }
            
              const pendingRequest = await env.PENDING_REQUESTS.get(token, "json");
              if (!pendingRequest?.email || !isValidEmail(pendingRequest.email)) {
                return { error: htmlResponse("<h1>この申請リンクは無効か、有効期限が切れています。</h1>", 404) };
              }
            
              return { token, email: pendingRequest.email };
            }
            
            async function handleApproveConfirmation(url, env) {
              const token = url.searchParams.get("token");
              const pending = await getPendingEmail(token, env);
              if (pending.error) return pending.error;
            
              const safeEmail = escapeHtml(pending.email);
              const safeToken = encodeURIComponent(token);
              return htmlResponse(`<!DOCTYPE html>
            <html lang="ja">
            <head><meta charset="utf-8"><title>参加申請の承認確認</title></head>
            <body>
            <h1>参加申請の承認確認</h1>
            <p><strong>${safeEmail}</strong> からの lolice cluster 参加申請を承認しますか?</p>
            <form method="POST" action="/api/approve">
              <input type="hidden" name="token" value="${safeToken}">
              <button type="submit">承認する</button>
            </form>
            </body>
            </html>`);
            }
            
            async function handleApproval(request, env) {
              let token;
              const contentType = request.headers.get("Content-Type") ?? "";
              if (contentType.includes("application/x-www-form-urlencoded")) {
                const body = await request.text();
                token = new URLSearchParams(body).get("token");
              } else {
                try {
                  const body = await request.json();
                  token = body?.token ?? null;
                } catch {
                  return htmlResponse("<h1>リクエスト形式が正しくありません。</h1>", 400);
                }
              }
            
              const pending = await getPendingEmail(token, env);
              if (pending.error) return pending.error;
            
              // Step 1: Update Access policy (critical — must succeed before proceeding).
              try {
                await addEmailToAccessPolicy(env, pending.email);
              } catch (error) {
                console.error("Failed to update Access policy", error);
                return htmlResponse("<h1>承認処理に失敗しました。</h1><p>Cloudflare Access ポリシーの更新に失敗しました。時間をおいて同じリンクを再度開いてください。</p>", 502);
              }
            
              // Step 2: Policy update succeeded — delete token so the link cannot be reused.
              await env.PENDING_REQUESTS.delete(pending.token);
            
              // Step 3: Send notification email (non-critical — policy update already completed).
              let emailError = null;
              try {
                await sendEmail(env, {
                  to: pending.email,
                  subject: "[lolice] 参加が承認されました",
                  html: `<p>lolice cluster への参加が承認されました。</p><p><a href="${env.PORTAL_BASE_URL}/guide.html">参加手順ページ</a>をご確認ください。</p>`,
                });
              } catch (error) {
                emailError = error;
                console.error("Failed to send approval notification email (Access policy update already succeeded)", error);
              }
            
              if (emailError) {
                const safeEmail = escapeHtml(pending.email);
                return htmlResponse(`<!DOCTYPE html>
            <html lang="ja">
            <head><meta charset="utf-8"><title>承認完了(メール送信失敗)</title></head>
            <body>
            <h1>承認が完了しました</h1>
            <p><strong>${safeEmail}</strong> の Cloudflare Access ポリシーへの追加は<strong>成功</strong>しました。</p>
            <p>ただし、申請者への案内メールの送信に失敗しました。<br>
            参加者には手動で <a href="${env.PORTAL_BASE_URL}/guide.html">参加手順ページ</a> の URL をお知らせください。</p>
            <p><small>(このリンクは無効になりました。再送信が必要な場合は手動で案内してください。)</small></p>
            </body>
            </html>`);
              }
            
              return new Response(null, {
                status: 302,
                headers: { ...CORS_HEADERS, Location: `${env.PORTAL_BASE_URL}/guide.html` },
              });
            }
            
            async function handleRejectConfirmation(url, env) {
              const token = url.searchParams.get("token");
              const pending = await getPendingEmail(token, env);
              if (pending.error) return pending.error;
            
              const safeEmail = escapeHtml(pending.email);
              const safeToken = encodeURIComponent(token);
              return htmlResponse(`<!DOCTYPE html>
            <html lang="ja">
            <head><meta charset="utf-8"><title>参加申請の却下確認</title></head>
            <body>
            <h1>参加申請の却下確認</h1>
            <p><strong>${safeEmail}</strong> からの lolice cluster 参加申請を却下しますか?</p>
            <form method="POST" action="/api/reject">
              <input type="hidden" name="token" value="${safeToken}">
              <button type="submit">却下する</button>
            </form>
            </body>
            </html>`);
            }
            
            async function handleRejection(request, env) {
              let token;
              const contentType = request.headers.get("Content-Type") ?? "";
              if (contentType.includes("application/x-www-form-urlencoded")) {
                const body = await request.text();
                token = new URLSearchParams(body).get("token");
              } else {
                try {
                  const body = await request.json();
                  token = body?.token ?? null;
                } catch {
                  return htmlResponse("<h1>リクエスト形式が正しくありません。</h1>", 400);
                }
              }
            
              const pending = await getPendingEmail(token, env);
              if (pending.error) return pending.error;
            
              await env.PENDING_REQUESTS.delete(pending.token);
              return htmlResponse("<h1>参加申請を却下しました。</h1><p>この申請は削除されました。</p>");
            }
            
            export default {
              async fetch(request, env) {
                if (request.method === "OPTIONS") {
                  return new Response(null, { status: 204, headers: CORS_HEADERS });
                }
            
                const url = new URL(request.url);
                if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
                  return htmlResponse(INDEX_HTML);
                }
                if (request.method === "GET" && url.pathname === "/guide.html") {
                  return htmlResponse(GUIDE_HTML);
                }
            
                if (request.method === "POST" && url.pathname === "/api/request") {
                  return handleRequest(request, env);
                }
                if (request.method === "GET" && url.pathname === "/api/approve") {
                  return handleApproveConfirmation(url, env);
                }
                if (request.method === "POST" && url.pathname === "/api/approve") {
                  return handleApproval(request, env);
                }
                if (request.method === "GET" && url.pathname === "/api/reject") {
                  return handleRejectConfirmation(url, env);
                }
                if (request.method === "POST" && url.pathname === "/api/reject") {
                  return handleRejection(request, env);
                }
            
                return jsonResponse({ error: "Not Found" }, 404);
              },
            };
        EOT
      + id                  = (known after apply)
      + module              = true
      + name                = "lolice-member-portal"
      + tags                = (known after apply)

      + d1_database_binding {
          + database_id = (known after apply)
          + name        = "APPROVED_EMAILS_DB"
        }

      + kv_namespace_binding {
          + name         = "PENDING_REQUESTS"
          + namespace_id = (known after apply)
        }

      + plain_text_binding {
          + name = "ADMIN_EMAIL"
          + text = "tiyotiyouda@gmail.com"
        }
      + plain_text_binding {
          + name = "CF_ACCOUNT_ID"
          + text = "1984a4314b3e75f3bedce97c7a8e0c81"
        }
      + plain_text_binding {
          + name = "CF_APP_ID"
          + text = "ccb49999-7a12-476d-8724-0f4cc6a6c0cb"
        }
      + plain_text_binding {
          + name = "CF_POLICY_ID"
          + text = "d807cdcb-141e-40f3-ac82-5b6f97468f19"
        }
      + plain_text_binding {
          + name = "PORTAL_BASE_URL"
          + text = "https://lolice.b0xp.io"
        }
    }

Plan: 5 to add, 0 to change, 0 to destroy.

Warning: Deprecated Resource

  with cloudflare_worker_route.lolice_member_portal,
  on worker.tf line 56, in resource "cloudflare_worker_route" "lolice_member_portal":
  56: resource "cloudflare_worker_route" "lolice_member_portal" {

`cloudflare_worker_route` is now deprecated and will be removed in the next
major version. Use `cloudflare_workers_route` instead.

⚠️ Warnings

Warning: Deprecated Resource

  with cloudflare_worker_route.lolice_member_portal,
  on worker.tf line 56, in resource "cloudflare_worker_route" "lolice_member_portal":
  56: resource "cloudflare_worker_route" "lolice_member_portal" {

`cloudflare_worker_route` is now deprecated and will be removed in the next
major version. Use `cloudflare_workers_route` instead.

⚠️ Errors

  • failed to add a label terraform/cloudflare/b0xp.io/lolice-member-portal/add-or-update: label name is too long (max: 50)

@boxp
boxp merged commit 43995ea into main Jul 20, 2026
13 checks passed
@boxp
boxp deleted the feature/BOXP-120-lolice-member-portal branch July 20, 2026 08:12
@boxp-tfaction

boxp-tfaction Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

✅ Apply Succeeded (terraform/cloudflare/b0xp.io/lolice-member-portal)

CI link

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Details (Click me)
Jul 20 08:13:34.822 WRN The policy file is ignored unless it is allowed by "aqua policy allow" command.

$ aqua policy allow "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

If you want to keep ignoring the policy file without the warning, please run "aqua policy deny" command.

$ aqua policy deny "/home/runner/work/arch/arch/aqua/aqua-policy.yaml"

  program=aqua version=2.62.0 env=linux/amd64 exe_name=terraform package_name=hashicorp/terraform package_version=v1.15.8 registry=standard policy_file=/home/runner/work/arch/arch/aqua/aqua-policy.yaml doc=https://aquaproj.github.io/docs/reference/codes/003
cloudflare_workers_kv_namespace.pending_requests: Creating...
cloudflare_d1_database.approved_emails: Creating...
cloudflare_record.lolice_member_portal: Creating...
cloudflare_record.lolice_member_portal: Creation complete after 1s [id=015e0258d30e3e63ff6927adf14ea6db]
cloudflare_workers_kv_namespace.pending_requests: Creation complete after 1s [id=bf00c815435543cbbceaa953b32179e9]
cloudflare_d1_database.approved_emails: Creation complete after 1s [id=eb362715-ea59-4618-aaf3-d42b8c4c13dc]
cloudflare_workers_script.lolice_member_portal: Creating...
cloudflare_workers_script.lolice_member_portal: Creation complete after 1s [id=lolice-member-portal]
cloudflare_worker_route.lolice_member_portal: Creating...
cloudflare_worker_route.lolice_member_portal: Creation complete after 0s [id=af3fefcf32ad4b8e98c0bf118f48e990]

Warning: Deprecated Resource

  with cloudflare_worker_route.lolice_member_portal,
  on worker.tf line 56, in resource "cloudflare_worker_route" "lolice_member_portal":
  56: resource "cloudflare_worker_route" "lolice_member_portal" {

`cloudflare_worker_route` is now deprecated and will be removed in the next
major version. Use `cloudflare_workers_route` instead.

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant