feat(BOXP-120): lolice cluster メンバーポータルのセルフサービス化 - #11314
Merged
Merged
Conversation
…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>
This comment has been minimized.
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>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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>
This comment has been minimized.
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>
This comment has been minimized.
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>
This comment has been minimized.
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.
Contributor
❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validateDetails |
…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>
This comment has been minimized.
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
Contributor
❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validateDetails |
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>
Contributor
❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validateDetails |
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>
This comment has been minimized.
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>
This comment has been minimized.
This comment has been minimized.
Contributor
❌ terraform/cloudflare/b0xp.io/lolice-member-portal: terraform validateDetails |
…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
This comment has been minimized.
This comment has been minimized.
addEmailToAccessPolicy でKV list()の1ページ目(最大1,000件)しか 処理していなかった問題を修正。list_complete/cursorによるページネーション ループで全メールアドレスを取得するように変更。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This comment has been minimized.
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>
This comment has been minimized.
This comment has been minimized.
…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>
Contributor
|
❌ Details |
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>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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>
Owner
Author
fix: Access ポリシー更新とメール送信の結果を分離codex-review の指摘( 問題
という不整合が発生していました。 修正内容処理を3段階に分離:
これにより、管理者は常に正確な状態(アクセス権が付与済みかどうか)を把握できます。 |
This comment has been minimized.
This comment has been minimized.
- 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
Contributor
Plan Result (terraform/cloudflare/b0xp.io/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) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[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.
|
Contributor
✅ Apply Succeeded (terraform/cloudflare/b0xp.io/lolice-member-portal)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.
|
Merged
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
lolice cluster(PalWorldゲームサーバー)への参加をセルフサービス化するメンバーポータルを実装します。
実装内容
フロー
並行承認の安全性
addEmailToAccessPolicyでCloudflare Access PolicyのRead-Modify-Writeを行う際:これにより、複数の承認が並行した場合でも最終的にすべての承認済みメールがポリシーに反映される。
デプロイ手順
1. terraform apply
通常通り tfaction の PR → plan → apply フローで実行します。Workerリソース・KV・D1・Pagesプロジェクトが作成されます。
注意: シークレット(
CF_API_TOKEN・RESEND_API_KEY)はTerraformステートへの平文保存を避けるため、Terraformでは管理しません。apply後に手動で設定します(次のステップ)。2. terraform apply 後にWorker Secretを手動設定
SSM Parameter Storeに登録済みの値を取得してCloudflare Dashboard(またはWrangler)で設定してください。
Cloudflare Dashboardから設定する場合
CF_API_TOKEN: SSM/lolice-member-portal/CF_API_TOKENの値RESEND_API_KEY: SSM/lolice-member-portal/RESEND_API_KEYの値Wrangler CLIから設定する場合(SSMから値を取得して投入)
tfaction
terraform/cloudflare/b0xp.io/lolice-member-portal/ディレクトリが追加されます。PRマージ後に tfaction が terraform plan → apply を実行します。