Beta Request Flow Implementation Plan
Beta Request Flow Implementation Plan
Section titled “Beta Request Flow Implementation Plan”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: Let Chris send friends/family a link on the Dark Cosmos site where they request TestFlight access with one tap; Chris approves each request with one tap from his phone (via a signed Pushover link); approved people get invited into a dedicated App Store Connect “Friends & Family” group, distinct from the public TestFlight join link’s “Public” group.
Architecture: A static form on bullet-heaven-site posts to an n8n webhook (“Beta Request”), which HMAC-signs a link and Pushover-notifies Chris. Tapping that link hits a second n8n webhook (“Beta Approve”), which verifies the signature and calls the App Store Connect API to invite the tester by email — no database, the pending request lives entirely inside the signed URL.
Tech Stack: Plain HTML/JS (no build step, matches the existing site), n8n (self-hosted, n8n.lumara.digital, workflows authored via its REST API), App Store Connect API (JWT/ES256 auth), Pushover.
Global Constraints
Section titled “Global Constraints”- No em dashes, no AI-tell phrasing in any user-facing copy (global content rule).
- Never embed production credentials in n8n workflow JSON — reference
$env.VAR_NAME, values live in/opt/lumara/n8n/.envonly. - Every new n8n workflow MUST set
settings.errorWorkflow = "mbNM85YFutYZ54ii"(the standing n8n Error Handler) at creation time. - n8n Code nodes: use
this.helpers.httpRequest({...})for HTTP calls, neverfetch/URLSearchParams. Hand-build form-encoded bodies withencodeURIComponent. Alwaysreturn [{ json: {...} }]. - Site changes deploy only via
bullet-heaven-site/scripts/deploy-site.sh(stages a clean public-only dir — never rawwrangler pages deploy .). - All ASC API calls use the existing key at
~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8,$ASC_KEY_ID/$ASC_ISSUER_IDfrom~/.secrets, app id6783842805.
Task 1: Create the “Friends & Family” App Store Connect beta group
Section titled “Task 1: Create the “Friends & Family” App Store Connect beta group”Files: none (API-only setup step, run from the bullet-heaven repo directory so ~/.secrets sourcing matches existing convention).
Interfaces:
-
Consumes:
$ASC_KEY_ID,$ASC_ISSUER_ID,~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8(all pre-existing). -
Produces: a new App Store Connect beta group id (referred to as
<FF_GROUP_ID>in later tasks) with the current build(s) attached, ready to receive invited testers. -
Step 1: Verify the group doesn’t already exist
source ~/.secrets && python3 << 'EOF'import jwt, time, requests, os, jsonkey_id, issuer_id = os.environ['ASC_KEY_ID'], os.environ['ASC_ISSUER_ID']with open(f'/Users/chris/.appstoreconnect/private_keys/AuthKey_{key_id}.p8') as f: key = f.read()token = jwt.encode({'iss': issuer_id, 'exp': int(time.time())+1200, 'aud': 'appstoreconnect-v1'}, key, algorithm='ES256', headers={'kid': key_id})headers = {'Authorization': f'Bearer {token}'}app_id = '6783842805'r = requests.get(f'https://api.appstoreconnect.apple.com/v1/betaGroups?filter[app]={app_id}', headers=headers)for g in r.json()['data']: print(g['id'], g['attributes']['name'])EOFExpected: lists only the existing “Internal” and “Public” groups — no “Friends & Family” yet.
- Step 2: Create the group
source ~/.secrets && python3 << 'EOF'import jwt, time, requests, os, jsonkey_id, issuer_id = os.environ['ASC_KEY_ID'], os.environ['ASC_ISSUER_ID']with open(f'/Users/chris/.appstoreconnect/private_keys/AuthKey_{key_id}.p8') as f: key = f.read()token = jwt.encode({'iss': issuer_id, 'exp': int(time.time())+1200, 'aud': 'appstoreconnect-v1'}, key, algorithm='ES256', headers={'kid': key_id})headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}app_id = '6783842805'body = { "data": { "type": "betaGroups", "attributes": { "name": "Friends & Family", "isInternalGroup": False, "publicLinkEnabled": False, "feedbackEnabled": True }, "relationships": {"app": {"data": {"type": "apps", "id": app_id}}} }}r = requests.post('https://api.appstoreconnect.apple.com/v1/betaGroups', headers=headers, json=body)print(r.status_code)print(json.dumps(r.json(), indent=2))EOFExpected: 201, response includes a new group id. Note it down as <FF_GROUP_ID>.
Correction (found live 2026-07-13, during Task 1 review): hasAccessToAllBuilds is NOT a writable attribute — the API silently ignores it on POST (the created group comes back with hasAccessToAllBuilds: null) and explicitly rejects it on PATCH with 409 ENTITY_ERROR.ATTRIBUTE.NOT_ALLOWED. It appears to be read-only/computed by Apple, not something a group’s creator can set. This means Friends & Family behaves exactly like “Public”: builds must be explicitly attached, and re-attached after every future upload. The step below reflects this.
- Step 3: Attach the current build(s), then verify
source ~/.secrets && python3 << 'EOF'import jwt, time, requests, os, jsonkey_id, issuer_id = os.environ['ASC_KEY_ID'], os.environ['ASC_ISSUER_ID']with open(f'/Users/chris/.appstoreconnect/private_keys/AuthKey_{key_id}.p8') as f: key = f.read()token = jwt.encode({'iss': issuer_id, 'exp': int(time.time())+1200, 'aud': 'appstoreconnect-v1'}, key, algorithm='ES256', headers={'kid': key_id})headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}group_id = "<FF_GROUP_ID>" # replace with the id from Step 2app_id = '6783842805'
# Attach every currently-VALID build (both platforms) — same mechanism as the "Public" group.builds = requests.get(f'https://api.appstoreconnect.apple.com/v1/builds?filter[app]={app_id}&filter[processingState]=VALID&limit=50', headers=headers).json()build_ids = [b['id'] for b in builds['data']]print(f"attaching {len(build_ids)} build(s): {build_ids}")r = requests.post(f'https://api.appstoreconnect.apple.com/v1/betaGroups/{group_id}/relationships/builds', headers=headers, json={"data": [{"type": "builds", "id": bid} for bid in build_ids]})print("attach:", r.status_code)
r2 = requests.get(f'https://api.appstoreconnect.apple.com/v1/betaGroups/{group_id}/builds', headers=headers)for b in r2.json()['data']: print(b['id'], b['attributes'].get('version'))EOFExpected: attach: 204, then the final listing shows at least the latest iOS and tvOS builds. Operational note: unlike a true hasAccessToAllBuilds group, Friends & Family needs this same attach step re-run after every future bh-appstore-release upload — same as “Public” already requires.
No commit needed — this task only touches App Store Connect state.
Task 2: Add secrets to n8n’s .env
Section titled “Task 2: Add secrets to n8n’s .env”Files:
- Modify (on the VPS, not in git):
/opt/lumara/n8n/.env
Interfaces:
-
Consumes:
<FF_GROUP_ID>from Task 1;$ASC_KEY_ID,$ASC_ISSUER_IDfrom local~/.secrets; the p8 key file content. -
Produces: five new n8n environment variables that Tasks 3 and 4’s Code nodes read via
$env:BETA_REQUEST_HMAC_SECRET— shared secret for signing/verifying approve links.ASC_KEY_ID,ASC_ISSUER_ID— same values as local~/.secrets, copied into the VPS.ASC_PRIVATE_KEY_B64— the p8 private key, base64-encoded (avoids multi-line.envnewline issues).ASC_FF_GROUP_ID— the group id from Task 1.
-
Step 1: Generate the HMAC secret and base64-encode the ASC private key
source ~/.secretsopenssl rand -hex 32# copy this value — it's BETA_REQUEST_HMAC_SECRET
base64 < ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8 | tr -d '\n'# copy this value — it's ASC_PRIVATE_KEY_B64echoecho "ASC_KEY_ID=$ASC_KEY_ID"echo "ASC_ISSUER_ID=$ASC_ISSUER_ID"- Step 2: Append the five vars to the VPS
.envand reload n8n
Replace the placeholder values below with the real output from Step 1 and the group id from Task 1 before running:
ssh hetzner "cat >> /opt/lumara/n8n/.env << 'EOF'BETA_REQUEST_HMAC_SECRET=<paste hex secret>ASC_KEY_ID=<paste key id>ASC_ISSUER_ID=<paste issuer id>ASC_PRIVATE_KEY_B64=<paste base64 key, one line, no wrapping>ASC_FF_GROUP_ID=<paste FF_GROUP_ID>EOF"ssh hetzner "cd /opt/lumara/n8n && docker compose up -d"- Step 3: Verify the vars are visible inside the container
ssh hetzner "docker exec \$(docker ps --filter name=n8n --format '{{.Names}}' | head -1) env | grep -E 'BETA_REQUEST_HMAC_SECRET|ASC_KEY_ID|ASC_ISSUER_ID|ASC_FF_GROUP_ID'"Expected: all five vars print (values will show — this is an interactive verification step on your own VPS, not something to log anywhere).
No git commit — VPS-only config, matches the project’s standing “never embed secrets in workflow JSON” rule.
Task 3: Build the “Dark Cosmos: Beta Approve” n8n workflow
Section titled “Task 3: Build the “Dark Cosmos: Beta Approve” n8n workflow”Files: none locally — workflow is authored directly via the n8n REST API and lives in n8n’s own database.
Interfaces:
-
Consumes:
BETA_REQUEST_HMAC_SECRET,ASC_KEY_ID,ASC_ISSUER_ID,ASC_PRIVATE_KEY_B64,ASC_FF_GROUP_ID(Task 2). Query paramsemail,name,sigonGET /webhook/beta-approve. -
Produces: the live URL
https://n8n.lumara.digital/webhook/beta-approve?email=...&name=...&sig=...that Task 4 embeds in the Pushover notification. Signature scheme:sig = HMAC-SHA256(BETA_REQUEST_HMAC_SECRET, "<email>|<name>"), hex-encoded — Task 4 MUST use the exact same scheme to sign. -
Step 1: Create the workflow via the n8n API
source ~/.secrets && python3 << 'PYEOF'import requests, json, os
headers = { "CF-Access-Client-Id": os.environ["N8N_CF_ACCESS_CLIENT_ID"], "CF-Access-Client-Secret": os.environ["N8N_CF_ACCESS_CLIENT_SECRET"], "X-N8N-API-KEY": os.environ["N8N_API_KEY"], "Content-Type": "application/json",}
code_js = r'''const crypto = require('crypto');
const q = ($input.first().json.query) || {};const email = (q.email || '').trim();const name = (q.name || '').trim();const sig = (q.sig || '').trim();
function respond(html, ok) { return [{ json: { html, ok } }];}
if (!email || !sig) { return respond('<html><body style="font-family:sans-serif;padding:40px;text-align:center"><h2>Invalid link</h2><p>Missing parameters.</p></body></html>', false);}
const secret = $env.BETA_REQUEST_HMAC_SECRET;const expected = crypto.createHmac('sha256', secret).update(`${email}|${name}`).digest('hex');
const a = Buffer.from(sig, 'hex');const b = Buffer.from(expected, 'hex');const validSig = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!validSig) { return respond('<html><body style="font-family:sans-serif;padding:40px;text-align:center"><h2>Invalid or expired link</h2><p>This approval link does not match a real request.</p></body></html>', false);}
// Build an App Store Connect JWT (ES256) using only Node built-ins.function base64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');}const keyId = $env.ASC_KEY_ID;const issuerId = $env.ASC_ISSUER_ID;const privateKeyPem = Buffer.from($env.ASC_PRIVATE_KEY_B64, 'base64').toString('utf8');const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };const now = Math.floor(Date.now() / 1000);const payload = { iss: issuerId, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' };const signingInput = base64url(JSON.stringify(header)) + '.' + base64url(JSON.stringify(payload));const signature = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKeyPem, dsaEncoding: 'ieee-p1363' });const ascJwt = signingInput + '.' + base64url(signature);
const groupId = $env.ASC_FF_GROUP_ID;const nameParts = name.split(/\s+/).filter(Boolean);const firstName = nameParts[0] || undefined;const lastName = nameParts.slice(1).join(' ') || undefined;
const attributes = { email };if (firstName) attributes.firstName = firstName;if (lastName) attributes.lastName = lastName;
try { await this.helpers.httpRequest({ url: 'https://api.appstoreconnect.apple.com/v1/betaTesters', method: 'POST', headers: { Authorization: `Bearer ${ascJwt}`, 'Content-Type': 'application/json' }, body: { data: { type: 'betaTesters', attributes, relationships: { betaGroups: { data: [{ type: 'betaGroups', id: groupId }] } }, }, }, json: true, });} catch (e) { const detail = (e.response && e.response.body) ? JSON.stringify(e.response.body) : String(e.message || e); throw new Error(`ASC betaTesters invite failed: ${detail}`);}
return respond(`<html><body style="font-family:sans-serif;padding:40px;text-align:center"><h2>✓ ${name || email} invited</h2><p>Apple will email them a TestFlight invite shortly.</p></body></html>`, true);'''
body = { "name": "Dark Cosmos: Beta Approve", "nodes": [ { "id": "webhook", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], "parameters": { "httpMethod": "GET", "path": "beta-approve", "responseMode": "responseNode", "options": {}, }, }, { "id": "verify_and_invite", "name": "Verify Signature and Invite", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [460, 300], "parameters": {"language": "javaScript", "jsCode": code_js}, }, { "id": "respond", "name": "Respond to Webhook", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [680, 300], "parameters": { "respondWith": "text", "responseBody": "={{ $json.html }}", "options": { "responseHeaders": { "entries": [{"name": "Content-Type", "value": "text/html"}] } }, }, }, ], "connections": { "Webhook": {"main": [[{"node": "Verify Signature and Invite", "type": "main", "index": 0}]]}, "Verify Signature and Invite": {"main": [[{"node": "Respond to Webhook", "type": "main", "index": 0}]]}, }, "settings": {"executionOrder": "v1", "errorWorkflow": "mbNM85YFutYZ54ii"},}
r = requests.post("https://n8n.lumara.digital/api/v1/workflows", headers=headers, json=body)print(r.status_code)d = r.json()print(json.dumps(d, indent=2)[:500])if r.status_code == 200 or r.status_code == 201: print("WORKFLOW_ID:", d["id"])PYEOFExpected: 200/201, prints a WORKFLOW_ID. Note it down as <APPROVE_WF_ID>.
- Step 2: Activate the workflow
source ~/.secretscurl -s -X POST "https://n8n.lumara.digital/api/v1/workflows/<APPROVE_WF_ID>/activate" \ -H "CF-Access-Client-Id: $N8N_CF_ACCESS_CLIENT_ID" -H "CF-Access-Client-Secret: $N8N_CF_ACCESS_CLIENT_SECRET" \ -H "X-N8N-API-KEY: $N8N_API_KEY" | python3 -m json.tool | head -5Expected: "active": true in the response.
- Step 3: Verify the signature-rejection path (no real invite sent)
curl -s "https://n8n.lumara.digital/webhook/beta-approve?email=test@example.com&name=Test&sig=deadbeef"Expected: HTML body containing Invalid or expired link. This confirms the webhook is live and signature checking runs — it must NOT show “invited” for a bad signature.
- Step 4: Verify the happy path end-to-end
Compute a valid signature locally (mirrors the Code node’s algorithm exactly) and hit the live webhook:
python3 -c "import hmac, hashlib, urllib.parsesecret = '<paste BETA_REQUEST_HMAC_SECRET from Task 2>'email, name = 'YOUR_REAL_EMAIL@example.com', 'Test Person'sig = hmac.new(secret.encode(), f'{email}|{name}'.encode(), hashlib.sha256).hexdigest()print(f'https://n8n.lumara.digital/webhook/beta-approve?email={urllib.parse.quote(email)}&name={urllib.parse.quote(name)}&sig={sig}')"Open the printed URL in a browser (use a real email you control — this will actually invite that address). Expected: page shows ✓ Test Person invited. Then confirm in App Store Connect:
source ~/.secrets && python3 << 'EOF'import jwt, time, requests, oskey_id, issuer_id = os.environ['ASC_KEY_ID'], os.environ['ASC_ISSUER_ID']with open(f'/Users/chris/.appstoreconnect/private_keys/AuthKey_{key_id}.p8') as f: key = f.read()token = jwt.encode({'iss': issuer_id, 'exp': int(time.time())+1200, 'aud': 'appstoreconnect-v1'}, key, algorithm='ES256', headers={'kid': key_id})headers = {'Authorization': f'Bearer {token}'}group_id = "<FF_GROUP_ID>"r = requests.get(f'https://api.appstoreconnect.apple.com/v1/betaGroups/{group_id}/betaTesters', headers=headers)for t in r.json()['data']: print(t['id'], t['attributes'].get('email'))EOFExpected: the test email appears in the Friends & Family group’s tester list.
The respondToWebhook node’s respondWith: "text" / responseBody / options.responseHeaders parameter shape used in Step 1 has been verified empirically against this n8n instance (2026-07-13, scratch workflow test) — a Code node returning { json: { html } } feeding a respondToWebhook node with that exact parameter shape renders real HTML with a Content-Type: text/html header, confirmed via curl. No fallback should be needed.
No git commit — this workflow exists only in n8n, not in this repo.
Task 4: Build the “Dark Cosmos: Beta Request” n8n workflow
Section titled “Task 4: Build the “Dark Cosmos: Beta Request” n8n workflow”Files: none locally — same as Task 3.
Interfaces:
-
Consumes:
BETA_REQUEST_HMAC_SECRET,PUSHOVER_TOKEN,PUSHOVER_USER(all in n8n.envalready,PUSHOVER_*pre-existing). POST body{ name, email, website }from the site form (websiteis the honeypot — must be empty). -
Produces: the live URL
https://n8n.lumara.digital/webhook/beta-requestthat Task 5’s site form posts to. -
Step 1: Create the workflow via the n8n API
source ~/.secrets && python3 << 'PYEOF'import requests, json, os
headers = { "CF-Access-Client-Id": os.environ["N8N_CF_ACCESS_CLIENT_ID"], "CF-Access-Client-Secret": os.environ["N8N_CF_ACCESS_CLIENT_SECRET"], "X-N8N-API-KEY": os.environ["N8N_API_KEY"], "Content-Type": "application/json",}
code_js = r'''const crypto = require('crypto');
const body = ($input.first().json.body) || {};const name = (body.name || '').toString().trim().slice(0, 100);const email = (body.email || '').toString().trim().slice(0, 200);const honeypot = (body.website || '').toString().trim();
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (honeypot || !emailOk) { // Bot, or malformed submission: stay silent, no Pushover. return [{ json: { notified: false, reason: honeypot ? 'honeypot' : 'bad_email' } }];}
const secret = $env.BETA_REQUEST_HMAC_SECRET;const sig = crypto.createHmac('sha256', secret).update(`${email}|${name}`).digest('hex');
const approveUrl = 'https://n8n.lumara.digital/webhook/beta-approve' + `?email=${encodeURIComponent(email)}` + `&name=${encodeURIComponent(name)}` + `&sig=${encodeURIComponent(sig)}`;
const fields = { token: $env.PUSHOVER_TOKEN, user: $env.PUSHOVER_USER, title: 'Dark Cosmos beta request', message: `${name || '(no name)'} (${email}) wants into the beta`, url: approveUrl, url_title: 'Approve & invite', priority: '0',};const formBody = Object.keys(fields).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(fields[k])).join('&');
await this.helpers.httpRequest({ url: 'https://api.pushover.net/1/messages.json', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody, json: false,});
return [{ json: { notified: true, email } }];'''
body_wf = { "name": "Dark Cosmos: Beta Request", "nodes": [ { "id": "webhook", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], "parameters": { "httpMethod": "POST", "path": "beta-request", "responseMode": "onReceived", "options": {}, }, }, { "id": "notify", "name": "Validate and Notify", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [460, 300], "parameters": {"language": "javaScript", "jsCode": code_js}, }, ], "connections": { "Webhook": {"main": [[{"node": "Validate and Notify", "type": "main", "index": 0}]]}, }, "settings": {"executionOrder": "v1", "errorWorkflow": "mbNM85YFutYZ54ii"},}
r = requests.post("https://n8n.lumara.digital/api/v1/workflows", headers=headers, json=body_wf)print(r.status_code)d = r.json()print(json.dumps(d, indent=2)[:500])if r.status_code in (200, 201): print("WORKFLOW_ID:", d["id"])PYEOFExpected: 200/201, prints a WORKFLOW_ID. Note it down as <REQUEST_WF_ID>.
- Step 2: Activate the workflow
source ~/.secretscurl -s -X POST "https://n8n.lumara.digital/api/v1/workflows/<REQUEST_WF_ID>/activate" \ -H "CF-Access-Client-Id: $N8N_CF_ACCESS_CLIENT_ID" -H "CF-Access-Client-Secret: $N8N_CF_ACCESS_CLIENT_SECRET" \ -H "X-N8N-API-KEY: $N8N_API_KEY" | python3 -m json.tool | head -5Expected: "active": true.
- Step 3: Verify the honeypot silently drops bot submissions
curl -s -X POST "https://n8n.lumara.digital/webhook/beta-request" \ -H "Content-Type: application/json" \ -d '{"name":"Bot","email":"bot@example.com","website":"http://spam.example"}'Expected: fast 200 response body (n8n’s default onReceived ack). Then check the execution’s output in the n8n UI (https://n8n.lumara.digital/workflow/<REQUEST_WF_ID>/executions) — the “Validate and Notify” node’s output should show {"notified": false, "reason": "honeypot"}, and no Pushover notification should arrive.
- Step 4: Verify a real submission triggers Pushover
curl -s -X POST "https://n8n.lumara.digital/webhook/beta-request" \ -H "Content-Type: application/json" \ -d '{"name":"Test Person","email":"YOUR_REAL_EMAIL@example.com","website":""}'Expected: a Pushover notification arrives on Chris’s phone within a few seconds, titled “Dark Cosmos beta request”, with a tappable “Approve & invite” link. Tap it and confirm it lands on the Task 3 confirmation page (this exercises the full chain, Request → Pushover → Approve → ASC invite).
No git commit — workflow exists only in n8n.
Task 5: Add the site request-access form
Section titled “Task 5: Add the site request-access form”Files:
- Modify:
bullet-heaven-site/index.html
Interfaces:
-
Consumes:
https://n8n.lumara.digital/webhook/beta-request(Task 4). -
Produces: an anchor
#requestChris can link directly to friends/family, plus a footer link. -
Step 1: Add the CSS for the request card, matching the existing
.beta-callout/.biblevisual language
In bullet-heaven-site/index.html, find the .beta-callout CSS block (added 2026-07-11) and add a new block immediately after it:
/* friends & family request form */.request-card{background:linear-gradient(180deg,#0c0c1a,#0a0a14);border:1px solid var(--line);border-radius:18px;padding:34px;text-align:center;max-width:480px;margin:0 auto}.request-card h2{margin:0 0 8px;font-size:clamp(22px,4vw,30px)}.request-card p{color:var(--muted);max-width:420px;margin:0 auto 22px}.request-form{display:flex;flex-direction:column;gap:12px;max-width:340px;margin:0 auto}.request-form input[type=text],.request-form input[type=email]{font-family:'Space Grotesk';font-size:15px;padding:11px 14px;border-radius:8px;border:1px solid var(--line);background:#0a0a14;color:var(--text)}.request-form input::placeholder{color:var(--muted)}.request-form .hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.request-status{margin-top:14px;color:var(--muted);font-size:14px;min-height:20px}.request-status.ok{color:var(--green)}- Step 2: Add the HTML section
Find the <section id="beta"> block (the existing “Join the TestFlight beta” callout, added 2026-07-11). Add a new section immediately after its closing </section>:
<section id="request"> <div class="request-card"> <div class="eyebrow">Know Chris?</div> <h2>Request a personal invite</h2> <p>If you're a friend or family member Chris sent this link to, drop your name and email below. He'll approve it personally and Apple will email you a TestFlight invite.</p> <form class="request-form" id="requestForm"> <input type="text" name="website" class="hp" tabindex="-1" autocomplete="off"> <input type="text" name="name" placeholder="Your name" required> <input type="email" name="email" placeholder="you@example.com" required> <button type="submit" class="btn gold">Request access</button> <div class="request-status" id="requestStatus"></div> </form> </div> </section>- Step 3: Add the submit handler
Find the closing </body> tag and add a <script> block immediately before it:
<script>document.getElementById('requestForm').addEventListener('submit', async function (e) { e.preventDefault(); var form = e.target; var status = document.getElementById('requestStatus'); var btn = form.querySelector('button'); var data = { name: form.name.value.trim(), email: form.email.value.trim(), website: form.website.value, }; btn.disabled = true; status.textContent = 'Sending…'; status.className = 'request-status'; try { await fetch('https://n8n.lumara.digital/webhook/beta-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); status.textContent = 'Thanks, you\'ll get an email if approved.'; status.className = 'request-status ok'; form.reset(); } catch (err) { status.textContent = 'Something went wrong, try again in a moment.'; } finally { btn.disabled = false; }});</script>- Step 4: Add a footer link
Find the <footer> block and add #request to the link list, e.g.:
<footer><div class="wrap"> Dark Cosmos, a work in progress, built with Godot. · <a href="https://testflight.apple.com/join/MUCrRgEP" target="_blank" rel="noopener">Join the Beta</a> · <a href="#request">Request a personal invite</a> · <a href="/bible/">Balance Bible</a> · <a href="https://bullet-heaven-play.lumara.digital/index.html">Play in browser</a> · <a href="/privacy/">Privacy</a></div></footer>(Adjust to match whatever the footer’s exact current link list is at implementation time — this task’s job is to insert the one new <a href="#request"> link into it, not to rewrite the rest.)
- Step 5: Manual verification in a browser
Open bullet-heaven-site/index.html directly in a browser (open bullet-heaven-site/index.html or via file://). Confirm:
-
#requestsection renders with the neon card styling matching the rest of the page. -
Tabbing through the form skips the hidden honeypot field.
-
Filling name+email and clicking “Request access” shows “Sending…” then “Thanks — you’ll get an email if approved.” (network tab shows a POST to the live n8n webhook — this will actually trigger Task 4’s workflow, so use a real email you control if you want to see the full chain fire).
-
Step 6: Commit and deploy
cd ~/Claude/bullet-heaven-sitegit add index.htmlgit commit -m "Add friends & family beta request form"bash scripts/deploy-site.shExpected: deploy script’s built-in verification (/, /bible/, /privacy/ all 200; /CLAUDE.md not exposed) passes.
Task 6: End-to-end verification
Section titled “Task 6: End-to-end verification”Files: none — verification only.
- Step 1: Full happy path from the live site
Visit https://bullet-heaven.lumara.digital/#request in a browser. Submit the form with a real email you control. Confirm the Pushover notification arrives, tap it from your phone, confirm the confirmation page shows “invited”, and confirm the TestFlight invite email arrives at that address from Apple within a few minutes.
- Step 2: Confirm the tester lands in the correct group, not “Public”
source ~/.secrets && python3 << 'EOF'import jwt, time, requests, oskey_id, issuer_id = os.environ['ASC_KEY_ID'], os.environ['ASC_ISSUER_ID']with open(f'/Users/chris/.appstoreconnect/private_keys/AuthKey_{key_id}.p8') as f: key = f.read()token = jwt.encode({'iss': issuer_id, 'exp': int(time.time())+1200, 'aud': 'appstoreconnect-v1'}, key, algorithm='ES256', headers={'kid': key_id})headers = {'Authorization': f'Bearer {token}'}app_id = '6783842805'r = requests.get(f'https://api.appstoreconnect.apple.com/v1/apps/{app_id}/betaTesters', headers=headers)for t in r.json()['data']: print(t['id'], t['attributes'].get('email'))EOFCross-check the email from Step 1 appears here, then re-run the <FF_GROUP_ID> query from Task 3 Step 4 to confirm it’s specifically in Friends & Family (not just anywhere in the app).
- Step 3: Tamper test
Take a real approve URL from a past Pushover notification (or construct one), change the email query param to a different address, and open it. Expected: “Invalid or expired link” — confirms the signature can’t be forged by editing the URL.
No commit — this task is pure verification of the already-deployed system.