Skip to content

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.

  • 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/.env only.
  • 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, never fetch/URLSearchParams. Hand-build form-encoded bodies with encodeURIComponent. Always return [{ json: {...} }].
  • Site changes deploy only via bullet-heaven-site/scripts/deploy-site.sh (stages a clean public-only dir — never raw wrangler pages deploy .).
  • All ASC API calls use the existing key at ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8, $ASC_KEY_ID/$ASC_ISSUER_ID from ~/.secrets, app id 6783842805.

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

Terminal window
source ~/.secrets && python3 << 'EOF'
import jwt, time, requests, os, json
key_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'])
EOF

Expected: lists only the existing “Internal” and “Public” groups — no “Friends & Family” yet.

  • Step 2: Create the group
Terminal window
source ~/.secrets && python3 << 'EOF'
import jwt, time, requests, os, json
key_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))
EOF

Expected: 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
Terminal window
source ~/.secrets && python3 << 'EOF'
import jwt, time, requests, os, json
key_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 2
app_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'))
EOF

Expected: 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.


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_ID from 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 .env newline issues).
    • ASC_FF_GROUP_ID — the group id from Task 1.
  • Step 1: Generate the HMAC secret and base64-encode the ASC private key

Terminal window
source ~/.secrets
openssl 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_B64
echo
echo "ASC_KEY_ID=$ASC_KEY_ID"
echo "ASC_ISSUER_ID=$ASC_ISSUER_ID"
  • Step 2: Append the five vars to the VPS .env and reload n8n

Replace the placeholder values below with the real output from Step 1 and the group id from Task 1 before running:

Terminal window
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
Terminal window
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 params email, name, sig on GET /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

Terminal window
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>&#10003; ${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"])
PYEOF

Expected: 200/201, prints a WORKFLOW_ID. Note it down as <APPROVE_WF_ID>.

  • Step 2: Activate the workflow
Terminal window
source ~/.secrets
curl -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 -5

Expected: "active": true in the response.

  • Step 3: Verify the signature-rejection path (no real invite sent)
Terminal window
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:

Terminal window
python3 -c "
import hmac, hashlib, urllib.parse
secret = '<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:

Terminal window
source ~/.secrets && python3 << 'EOF'
import jwt, time, requests, os
key_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'))
EOF

Expected: 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 .env already, PUSHOVER_* pre-existing). POST body { name, email, website } from the site form (website is the honeypot — must be empty).

  • Produces: the live URL https://n8n.lumara.digital/webhook/beta-request that Task 5’s site form posts to.

  • Step 1: Create the workflow via the n8n API

Terminal window
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"])
PYEOF

Expected: 200/201, prints a WORKFLOW_ID. Note it down as <REQUEST_WF_ID>.

  • Step 2: Activate the workflow
Terminal window
source ~/.secrets
curl -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 -5

Expected: "active": true.

  • Step 3: Verify the honeypot silently drops bot submissions
Terminal window
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
Terminal window
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.


Files:

  • Modify: bullet-heaven-site/index.html

Interfaces:

  • Consumes: https://n8n.lumara.digital/webhook/beta-request (Task 4).

  • Produces: an anchor #request Chris can link directly to friends/family, plus a footer link.

  • Step 1: Add the CSS for the request card, matching the existing .beta-callout/.bible visual 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:

  • #request section 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

Terminal window
cd ~/Claude/bullet-heaven-site
git add index.html
git commit -m "Add friends & family beta request form"
bash scripts/deploy-site.sh

Expected: deploy script’s built-in verification (/, /bible/, /privacy/ all 200; /CLAUDE.md not exposed) passes.


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”
Terminal window
source ~/.secrets && python3 << 'EOF'
import jwt, time, requests, os
key_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'))
EOF

Cross-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.