| 1 | #!/usr/bin/env bash |
| 2 | # Idempotent production setup for Diffinery on a Cloudflare account. |
| 3 | # |
| 4 | # Creates (only when missing): the D1 database, R2 bucket, event and mail queues, the |
| 5 | # subscription that sends Email Sending's bounces and complaints to the diffinery-mail-events |
| 6 | # queue, the Email Sending setup for the domain (scripts/email-sending.sh), the Turnstile |
| 7 | # widget for the security report form (scripts/turnstile.sh) and the |
| 8 | # Cloudflare Access application for /auth/access, the email-based account |
| 9 | # recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners |
| 10 | # use Diffinery tokens. |
| 11 | # Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations. |
| 12 | # |
| 13 | # Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. |
| 14 | # Optional: DIFFINERY_DOMAIN (default diffinery.com; the Worker's route, PUBLIC_URL |
| 15 | # and sender follow it), DIFFINERY_ZONE (the Cloudflare zone holding that host, |
| 16 | # default its last two labels), MAIL_DOMAIN (default DIFFINERY_DOMAIN), |
| 17 | # ACCESS_TEAM_DOMAIN (e.g. codician.cloudflareaccess.com) when the token cannot read it. |
| 18 | set -euo pipefail |
| 19 | |
| 20 | : "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}" |
| 21 | : "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}" |
| 22 | DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}" |
| 23 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 24 | APP="$ROOT/apps/diffinery" |
| 25 | WRANGLER=(pnpm --dir "$APP" exec wrangler) |
| 26 | API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" |
| 27 | |
| 28 | cf() { # cf METHOD PATH [JSON] |
| 29 | local method="$1" path="$2" body="${3:-}" |
| 30 | if [[ -n "$body" ]]; then |
| 31 | curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body" |
| 32 | else |
| 33 | curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" |
| 34 | fi |
| 35 | } |
| 36 | log() { echo "::group::$*"; } |
| 37 | endlog() { echo "::endgroup::"; } |
| 38 | warn() { echo "::warning::$*" >&2; } |
| 39 | |
| 40 | # ------------------------------------------------------------------ D1 |
| 41 | log "D1 database" |
| 42 | D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)" |
| 43 | if [[ -z "$D1_ID" ]]; then |
| 44 | D1_ID="$(cf POST /d1/database '{"name":"diffinery"}' | jq -r '.result.uuid // empty')" |
| 45 | [[ -n "$D1_ID" ]] || { echo "could not create D1 database (does the token have D1: Edit?)" >&2; exit 1; } |
| 46 | echo "created D1 database $D1_ID" |
| 47 | else |
| 48 | echo "D1 database exists: $D1_ID" |
| 49 | fi |
| 50 | echo "read replication: $(cf GET "/d1/database/$D1_ID" | jq -r '.result.read_replication.mode // "unknown"') (turn it on with the 'D1 read replication' workflow)" |
| 51 | endlog |
| 52 | |
| 53 | # ------------------------------------------------------------------ R2 |
| 54 | log "R2 bucket" |
| 55 | if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then |
| 56 | echo "R2 bucket exists" |
| 57 | else |
| 58 | cf POST /r2/buckets '{"name":"diffinery-data"}' | jq -e '.success' >/dev/null || { echo "could not create R2 bucket (R2 enabled? token has R2: Edit?)" >&2; exit 1; } |
| 59 | echo "created R2 bucket diffinery-data" |
| 60 | fi |
| 61 | # Lifecycle rules (layout: apps/diffinery/src/lib/storage-keys.ts). Only transient prefixes |
| 62 | # age out; repos/ (git objects, LFS, release assets), registry/ and actions-cache/ (evicted |
| 63 | # by Diffinery) have no age rule. Multipart uploads left unfinished are aborted after a day. |
| 64 | LIFECYCLE='{"rules":[ |
| 65 | {"id":"abort-multipart-1d","enabled":true,"conditions":{"prefix":""},"abortMultipartUploadsTransition":{"condition":{"type":"Age","maxAge":86400}}}, |
| 66 | {"id":"artifacts-90d","enabled":true,"conditions":{"prefix":"artifacts/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}}}, |
| 67 | {"id":"logs-90d","enabled":true,"conditions":{"prefix":"logs/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}}}, |
| 68 | {"id":"actions-staging-1d","enabled":true,"conditions":{"prefix":"actions-staging/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":86400}}}, |
| 69 | {"id":"archives-30d","enabled":true,"conditions":{"prefix":"archives/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":2592000}}}, |
| 70 | {"id":"exports-8d","enabled":true,"conditions":{"prefix":"exports/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":691200}}}, |
| 71 | {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}} |
| 72 | ]}' |
| 73 | # Applied on every deploy; a failure stops it, since a wrong rule can delete data. |
| 74 | RESULT="$(cf PUT "/r2/buckets/diffinery-data/lifecycle" "$LIFECYCLE")" |
| 75 | if ! jq -e '.success' >/dev/null <<<"$RESULT"; then |
| 76 | echo "could not set the R2 lifecycle rules (token has R2: Edit?): $(jq -c '.errors // .' <<<"$RESULT" 2>/dev/null || echo "$RESULT")" >&2 |
| 77 | exit 1 |
| 78 | fi |
| 79 | CURRENT="$(cf GET "/r2/buckets/diffinery-data/lifecycle")" |
| 80 | if jq -e '[.result.rules[]? | select(.deleteObjectsTransition != null and ((.conditions.prefix // "") | startswith("repos/") or . == ""))] | length > 0' >/dev/null <<<"$CURRENT"; then |
| 81 | echo "an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continue" >&2 |
| 82 | exit 1 |
| 83 | fi |
| 84 | echo "R2 lifecycle rules set" |
| 85 | endlog |
| 86 | |
| 87 | # ------------------------------------------------------------------ Queues |
| 88 | log "Queues" |
| 89 | EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')" |
| 90 | for q in diffinery-events diffinery-events-dlq diffinery-mail diffinery-mail-dlq diffinery-mail-events; do |
| 91 | if grep -qx "$q" <<<"$EXISTING_QUEUES"; then |
| 92 | echo "queue $q exists" |
| 93 | else |
| 94 | cf POST /queues "{\"queue_name\":\"$q\"}" | jq -e '.success' >/dev/null || { echo "could not create queue $q (token has Queues: Edit?)" >&2; exit 1; } |
| 95 | echo "created queue $q" |
| 96 | fi |
| 97 | done |
| 98 | endlog |
| 99 | |
| 100 | # ------------------------------------------------------------------ Email Sending events |
| 101 | # Bounces and complaints reach the Worker through a Queues event subscription: source email.sending |
| 102 | # (one per sending domain), events message.bounced and message.complained, destination the |
| 103 | # diffinery-mail-events queue. Created only when missing; one that lacks an event gets it added. |
| 104 | # Create: POST /accounts/{account_id}/event_subscriptions/subscriptions |
| 105 | # https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/ |
| 106 | # List: GET /accounts/{account_id}/event_subscriptions/subscriptions?queue_id=... |
| 107 | # Update: PATCH /accounts/{account_id}/event_subscriptions/subscriptions/{id} |
| 108 | # Source and event names: https://developers.cloudflare.com/queues/event-subscriptions/events-schemas/ |
| 109 | # and https://developers.cloudflare.com/email-service/platform/event-subscriptions/. The email.sending |
| 110 | # source takes zone_id and domain, as `wrangler queues subscription create --source email.sending |
| 111 | # --zone-id ... --domain ...` sends them (https://developers.cloudflare.com/workers/wrangler/commands/queues/). |
| 112 | # The token needs Account → Queues → Edit (the API reference's "Queues Write") for this, and |
| 113 | # Zone → Zone → Read on the sending domain to find its zone. Without them this warns and the deploy |
| 114 | # goes on: bounces and complaints then don't reach the suppression list until someone subscribes the |
| 115 | # queue in the dashboard (docs/admin/site-administration.md). |
| 116 | log "Email Sending event subscription" |
| 117 | SEND_DOMAIN="${MAIL_DOMAIN:-$DOMAIN}" |
| 118 | SUB_EVENTS='["message.bounced","message.complained"]' |
| 119 | SUB_PERMISSION="Account → Queues → Edit (\"Queues Write\" in the API reference)" |
| 120 | api_errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "$1"; } |
| 121 | subscribe_mail_events() { |
| 122 | local qid zone="" name="$SEND_DOMAIN" list existing id body res |
| 123 | qid="$(cf GET "/queues?per_page=100" | jq -r '.result[]? | select(.queue_name=="diffinery-mail-events") | .queue_id' | head -n1 || true)" |
| 124 | if [[ -z "$qid" ]]; then warn "Could not find the diffinery-mail-events queue, so it isn't subscribed to Email Sending's bounce and complaint events."; return; fi |
| 125 | # The zone that holds the sending domain (the domain itself or a parent), as scripts/email-sending.sh finds it. |
| 126 | while [[ "$name" == *.* ]]; do |
| 127 | zone="$(curl -sS "https://api.cloudflare.com/client/v4/zones?name=$name" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[0].id // empty' || true)" |
| 128 | [[ -n "$zone" ]] && break |
| 129 | name="${name#*.}" |
| 130 | done |
| 131 | if [[ -z "$zone" ]]; then warn "Could not find the Cloudflare zone for $SEND_DOMAIN (the token needs Zone → Zone → Read on it), so diffinery-mail-events isn't subscribed to Email Sending's bounce and complaint events. Subscribe it in the dashboard: see Site administration → Email in the docs."; return; fi |
| 132 | list="$(cf GET "/event_subscriptions/subscriptions?queue_id=$qid&per_page=100" || true)" |
| 133 | if ! jq -e '.success' >/dev/null 2>&1 <<<"$list"; then |
| 134 | warn "Could not read the queue's event subscriptions, so diffinery-mail-events may not get bounces and complaints. The token needs $SUB_PERMISSION. Cloudflare said: $(api_errors "$list")" |
| 135 | return |
| 136 | fi |
| 137 | existing="$(jq -c --arg d "$SEND_DOMAIN" --arg q "$qid" '[.result[]? | select(.source.type == "email.sending" and .source.domain == $d and (.destination.queue_id // $q) == $q)][0] // empty' <<<"$list")" |
| 138 | if [[ -n "$existing" ]]; then |
| 139 | id="$(jq -r '.id' <<<"$existing")" |
| 140 | if jq -e --argjson want "$SUB_EVENTS" '.enabled != false and ((.events // []) as $have | all($want[]; . as $e | $have | index($e)))' >/dev/null <<<"$existing"; then |
| 141 | echo "event subscription $id sends $SEND_DOMAIN's bounces and complaints to diffinery-mail-events" |
| 142 | return |
| 143 | fi |
| 144 | body="$(jq -c --argjson want "$SUB_EVENTS" '{events: ((.events // []) + $want | unique), enabled: true}' <<<"$existing")" |
| 145 | res="$(cf PATCH "/event_subscriptions/subscriptions/$id" "$body" || true)" |
| 146 | if jq -e '.success' >/dev/null 2>&1 <<<"$res"; then echo "event subscription $id now includes bounced and complained events"; else warn "Could not update event subscription $id. The token needs $SUB_PERMISSION. Cloudflare said: $(api_errors "$res")"; fi |
| 147 | return |
| 148 | fi |
| 149 | body="$(jq -nc --arg d "$SEND_DOMAIN" --arg z "$zone" --arg q "$qid" --argjson events "$SUB_EVENTS" '{ |
| 150 | name: ("diffinery-mail-events " + $d), enabled: true, |
| 151 | source: {type: "email.sending", zone_id: $z, domain: $d}, |
| 152 | destination: {type: "queues.queue", queue_id: $q}, |
| 153 | events: $events |
| 154 | }')" |
| 155 | res="$(cf POST /event_subscriptions/subscriptions "$body" || true)" |
| 156 | if jq -e '.success' >/dev/null 2>&1 <<<"$res"; then |
| 157 | echo "created event subscription $(jq -r '.result.id' <<<"$res"): $SEND_DOMAIN's bounces and complaints go to diffinery-mail-events" |
| 158 | else |
| 159 | warn "Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for $SEND_DOMAIN. The token needs $SUB_PERMISSION (and $SEND_DOMAIN onboarded to Email Sending). Cloudflare said: $(api_errors "$res")" |
| 160 | fi |
| 161 | } |
| 162 | subscribe_mail_events |
| 163 | endlog |
| 164 | |
| 165 | # ------------------------------------------------------------------ Email Sending |
| 166 | # notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>; warns (and email is skipped) until the domain is onboarded. |
| 167 | DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/email-sending.sh" || warn "Email Sending check failed; email may not be delivered." |
| 168 | |
| 169 | # ------------------------------------------------------------------ Turnstile |
| 170 | # The widget "diffinery <DIFFINERY_DOMAIN>", created when missing, so people who aren't signed in can |
| 171 | # send security reports (security.txt points researchers at the form). Its site key is deployed as the |
| 172 | # TURNSTILE_SITE_KEY var (render-config below); ensure-secrets.sh sets TURNSTILE_SECRET when it's missing, |
| 173 | # and turnstile.sh replaces it here, before the deploy, when it goes with another widget. Needs |
| 174 | # Account → Turnstile → Edit; without it turnstile.sh warns, naming it, and the deploy goes on with |
| 175 | # anonymous reports refused. Sign-up stays as SIGNUP says: keys alone don't open it. |
| 176 | log "Turnstile" |
| 177 | TURNSTILE_SITE_KEY="$(DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/turnstile.sh")" || { warn "scripts/turnstile.sh failed; anonymous security reports stay refused."; TURNSTILE_SITE_KEY=""; } |
| 178 | echo "site key: ${TURNSTILE_SITE_KEY:-none}" |
| 179 | endlog |
| 180 | |
| 181 | # ------------------------------------------------------------------ Access |
| 182 | log "Cloudflare Access" |
| 183 | TEAM="${ACCESS_TEAM_DOMAIN:-}" |
| 184 | if [[ -z "$TEAM" ]]; then |
| 185 | TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)" |
| 186 | fi |
| 187 | APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')" |
| 188 | AUD="" |
| 189 | if ! jq -e '.success' >/dev/null <<<"$APPS"; then |
| 190 | warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off." |
| 191 | else |
| 192 | # People sign in with passkeys. Access guards only /auth/access, the email-based |
| 193 | # recovery path, so everything else (web, git, API, MCP, runners) reaches the Worker directly. |
| 194 | WEB_BODY="$(jq -nc --arg d "$DOMAIN" '{ |
| 195 | name: "Diffinery account recovery", type: "self_hosted", domain: ($d + "/auth/access"), |
| 196 | destinations: [{type: "public", uri: ($d + "/auth/access")}], |
| 197 | session_duration: "15m", app_launcher_visible: false, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax" |
| 198 | }')" |
| 199 | POLICY='[{"name": "Anyone who proves their email (Diffinery decides access)", "decision": "allow", "include": [{"everyone": {}}], "precedence": 1}]' |
| 200 | WEB_ID="$(jq -r '.result[] | select(.name=="Diffinery account recovery" or .name=="Diffinery") | .id' <<<"$APPS" | head -n1)" |
| 201 | if [[ -z "$WEB_ID" ]]; then |
| 202 | WEB_APP="$(cf POST /access/apps "$(jq -c --argjson p "$POLICY" '. + {policies: $p}' <<<"$WEB_BODY")" | jq -c '.result // empty')" |
| 203 | [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN/auth/access" || warn "Could not create the Access application; email recovery stays off." |
| 204 | else |
| 205 | WEB_APP="$(cf PUT "/access/apps/$WEB_ID" "$WEB_BODY" | jq -c '.result // empty')" |
| 206 | [[ -n "$WEB_APP" ]] && echo "Access app covers $DOMAIN/auth/access" || warn "Could not update the Access application." |
| 207 | fi |
| 208 | AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-null}")" |
| 209 | |
| 210 | # Earlier versions put Access in front of the whole site and needed a bypass app; remove it. |
| 211 | BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)" |
| 212 | if [[ -n "$BYPASS_ID" ]]; then |
| 213 | cf DELETE "/access/apps/$BYPASS_ID" | jq -e '.success' >/dev/null && echo "removed the old Access bypass app" || warn "Could not remove the old Access bypass application." |
| 214 | fi |
| 215 | fi |
| 216 | if [[ -z "$TEAM" ]]; then |
| 217 | # Fall back to the sign-in redirect, which names the team domain. |
| 218 | TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/auth/access" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)" |
| 219 | fi |
| 220 | [[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)." |
| 221 | echo "team=${TEAM:-?} aud=${AUD:+set}" |
| 222 | endlog |
| 223 | |
| 224 | # ------------------------------------------------------------------ config + migrations |
| 225 | log "Render config and migrate D1" |
| 226 | TURNSTILE_SITE_KEY="$TURNSTILE_SITE_KEY" node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "notifications@${MAIL_DOMAIN:-$DOMAIN}" |
| 227 | "${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc |
| 228 | endlog |
| 229 | |
| 230 | if [[ -n "${GITHUB_OUTPUT:-}" ]]; then |
| 231 | { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT" |
| 232 | fi |