Skip to content

01 — foundation-infra (design)

Status: Draft Date: 2026-05-23 Owner: infra agent Parent: docs/superpowers/specs/2026-05-23-nowyourlink-master-design.md Process diagram: docs/superpowers/processes/01-infra-provisioning.md


Bootstrapping layer for nowyourlink.com. Owns what a new contributor (or cloud agent) needs to run, deploy, and tear down the platform — but no domain logic.

In scope: pnpm/Turbo monorepo skeleton; ghardenlab.config.json + the ghardenlab create / add command sequence; Terraform for CF zone, Access (admin), R2, D1, KV, Queues, Workflows scaffolding, Crons, custom-domain bindings (R2 s3 state backend, world-forest pattern infra/terraform/main.tf:11-22); GitHub Actions (ci, preview, deploy, lint-seo, claude*); wrangler.jsonc per env; secrets via wrangler secret put + docs/cloud-environment.md; .env.example as source of truth.

Out of scope: auction logic / DOs → 04; Stripe + saga → 05; WS protocol → 06; legal copy → 12.


Matches Master §4.8 (apps/web serves apex + app.*). Pattern: world-forest/ root (CLAUDE.md §Monorepo Structure).

nowyourlink/
├── apps/
│ ├── web/ # Astro+islands → nowyourlink.com + nowyourlink.com/app
│ ├── api/ # REST + webhook + pixel ingest → api.*
│ ├── ws/ # WS hub + per-auction DOs → ws.*
│ ├── admin/ # CF Access-gated → admin.*
│ ├── queue/ # Notify, moderation, pixel rollup consumer
│ ├── cron/ # auction.open/close + report sweeps
│ └── webhooks/ # Stripe + Resend webhooks (isolated blast radius)
├── packages/
│ ├── shared/ config/ observability/ security/ consent/ legal/ data-rights/
│ ├── seo/ web-vitals/ email/ stripe/ ui/
├── docs-site/ # Starlight → docs.nowyourlink.com (separate Pages deploy)
├── infra/terraform/ # See §5
├── scripts/ # lint-seo, dev-seed, perf-cost-audit
├── docs/{cloud-environment.md, superpowers/{specs,processes,runbooks}/}
├── ghardenlab.config.json, legal.config.json, security.config.json
├── pnpm-workspace.yaml, turbo.json, tsconfig.base.json, .env.example

Cross-worker types (User, Bid, Auction, event taxonomy §4.4) live in packages/shared.


3. ghardenlab CLI invocation (exact commands)

Section titled “3. ghardenlab CLI invocation (exact commands)”

Run from ~/dev-arne/:

ghardenlab create nowyourlink --type mvp --domain nowyourlink.com --workers web,api,admin,queue,cron,webhooks --features legal,consent,auth,email,payments,dataRights,seo,schemaOrg,discovery,webVitals,lintSeo,sentry --db d1 --storage kv,r2,queues --ci github-actions --ci-claude --cloud-env claude --git-init --default-branch main

This emits the monorepo skeleton, ghardenlab.config.json, wrangler.jsonc per worker, GitHub Actions matrix, and the cloud-env doc stub.

Follow-on add calls (idempotent; run in this order):

ghardenlab add worker ws --type websocket
ghardenlab add sentry --workers web,api,ws,admin,queue,cron,webhooks
ghardenlab add seo
ghardenlab add schema-org
ghardenlab add discovery
ghardenlab add web-vitals
ghardenlab add lint-seo

add worker ws --type websocket emits the hibernatable WebSocketHub DO + ws.* route (per add worker help). --cloud-env claude writes docs/cloud-environment.md (extended in §7). feature payments scaffolds Stripe boilerplate; 05-payment-saga owns implementation.


4. ghardenlab CLI gaps (manual wrangler.jsonc patches)

Section titled “4. ghardenlab CLI gaps (manual wrangler.jsonc patches)”

CLI covers Workers, Queues, KV, D1, R2 (via --storage), Sentry, SEO. It does not scaffold:

GapWorkaroundFollow-up
G1 — Workflows binding (workflows[]). Needed on api + webhooks for the payment saga.Hand-add {"name":"payment-saga","binding":"PAYMENT_SAGA","class_name":"PaymentSagaWorkflow","script_name":"nowyourlink-api-${env}"} to apps/api/wrangler.jsonc per env.Issue: add feature workflows.
G2 — Cron triggers (triggers.crons). Need apps/cron to fire daily auction open/close + hourly VIES retry + post-campaign sweep.Append "triggers": {"crons": ["0 0 * * *", "5 * * * *"]}.Issue: worker --type cron with crons[].
G3 — Multi-queue named bindings. Need notify-queue, moderation-queue, pixel-queue, saga-deadletter-queue.Hand-edit producers/consumers per worker.Issue: multi-queue producers.
G4 — R2 named bindings per worker. Need CREATIVES, INVOICES, OG_CACHE, PIXEL_LOGS_ARCHIVE.Hand-edit.Issue.
G5 — D1 named binding (DB) on api/admin/queue/cron/webhooks all pointing at one prod + one preview DB.Hand-edit.Issue.
G6 — CF Access application for admin.* (not in CLI).Author infra/terraform/access.tf (§5).n/a (TF).
G7 — DO migrations (migrations[] tag="v1" new_sqlite_classes=[...]).Hand-edit after add worker ws.Issue.
G8 — Hyperdrive / D1 Sessions API.Defer; not MVP-blocking.Phase 2.

Each gap-diff is committed to the spec PR before implementation.


Mirrors world-forest/infra/terraform/main.tf (R2 s3 backend, cloudflare/cloudflare ~> 5.0, ghardenlab worker-app module). Split for readability:

infra/terraform/
├── backend.tf # providers + R2 s3 backend (key=apps/nowyourlink/terraform.tfstate)
├── variables.tf # cloudflare_account_id, cloudflare_zone_id
├── locals.tf # project_slug, domain, worker_domains[], dns_records[]
├── workers.tf # module "worker_app" → ghardenlab//terraform/modules/worker-app
├── d1.tf # cloudflare_d1_database (prod + preview)
├── kv.tf # sessions, rate-limit, saga-dedupe, config
├── r2.tf # creatives, invoices, og-cache, pixel-archive
├── queues.tf # notify, moderation, pixel, saga-dlq (if module doesn't cover)
├── access.tf # zero_trust_access_application + policy for admin.*
├── dns.tf # apex, cdn.* CNAME, MX (Resend), SPF/DKIM TXT
└── outputs.tf # IDs for wrangler.jsonc paste (not auto-injected MVP)

State backend verbatim from main.tf:11-22: R2 bucket gharden-terraform-state, path-style, HMAC TF_R2_* → AWS_* (DEPLOY.md §7). Superseded 2026-08-16: the state now lives at nowyourlink/terraform.tfstate in the terraform-state bucket with endpoints.s3 inlined in backend.tf — see the R6 supersession note in 2026-08-16-nowyourlink-rename-and-rebuild-design.md; the gharden-terraform-state bucket is shared with the owner’s other projects; this project’s key was migrated out and the retired stack’s state object was deleted with that stack on 2026-08-16. Modules: github.com/Ghardenlab/ghardenlab//terraform/modules/worker-app?ref=main (call site = main.tf:79-93) + inline cloudflare_zero_trust_access_application/_policy. Email-Sending + Zaraz stay dashboard-configured (provider gap, precedent main.tf:95-147).

Shipped layout collapses the 11-file split above into five files, per the HashiCorp Terraform style guide (file-names): group by logical function (a feature/component), not per resource type.

infra/terraform/
├── backend.tf # providers + R2 s3 backend
├── main.tf # vars + locals + module "worker_app" + docs custom domain
├── access.tf # docs-internal + admin Zero Trust apps
├── outputs.tf # one output per __*_ID__ in apps/*/wrangler.jsonc
└── github.tf # branch protection + tag ruleset (separate provider; out of spec-01 scope, kept verbatim)

dns.tf is deferred to the sibling DNS issue. variables.tf / locals.tf / workers.tf are absorbed into main.tf; d1.tf / kv.tf / r2.tf / queues.tf are absorbed into the module "worker_app" call (the module owns those resources, so a per-type split would be vacuous).

Two KV namespaces beyond the §6 matrix are provisioned because apps/*/wrangler.jsonc already reference them as __KV_MAGIC_LINKS_*_ID__ (admin AUTH_TOKENS) and __KV_MOD_BLACKLIST_*_ID__ (spec 03 §2.5 keyword blacklist). nowyourlink-oauth-state-kv from the §6 list is not provisioned (no wrangler reference).

Three additional Queues beyond the §6 matrix are provisioned because apps/api/wrangler.jsonc already binds them as producers today: q-moderation (spec 03 §4 auto-filter pipeline), q-transforms (spec 03 §9 image-transforms consumer), and q-vies-retry (spec 02 §4 VIES degraded-mode retry). All three include -preview variants.

Resolution mechanism: a new scripts/render-wrangler.sh reads terraform output -json and substitutes the __*_ID__ tokens into per-app wrangler.rendered.jsonc before wrangler deploy. See issue #120 for the full inventory and acceptance.


ResourceName(s)BindingWorker(s)EnvPurpose
D1nowyourlink-prod, nowyourlink-previewDBapi, admin, queue, cron, webhooksbothUsers, bids, ads, auctions, saga mirror, strikes
KVnowyourlink-sessionsSESSIONSapi, webbothMagic-link sessions (TTL 30d)
KVnowyourlink-rate-limitRATE_LIMITapibothPer-IP, per-user buckets
KVnowyourlink-saga-dedupeSAGA_DEDUPEapi, webhooksbothStripe webhook event.id dedupe
KVnowyourlink-configCONFIGallbothAnti-snipe flags, feature toggles
R2nowyourlink-creativesCREATIVESapi, admin, queuebothApproved + pending creatives
R2nowyourlink-invoicesINVOICESwebhooks, adminboth§14 UStG PDF mirror (10y retention, see master §10.3)
R2nowyourlink-og-cacheOG_CACHEwebbothPer-day OG images
R2nowyourlink-pixel-archivePIXEL_LOGS_ARCHIVEqueuebothRaw conversion-pixel events
Queuenotify-queueNOTIFY_QUEUE (P), consumer @ queueapi → queuebothEmail + push fan-out
Queuemoderation-queueMODERATION_QUEUE (P), consumer @ queueapi → queuebothAuto-filter pipeline
Queuepixel-queuePIXEL_QUEUE (P), consumer @ queueapi → queuebothConversion pixel ingest rollup
Queuesaga-deadletter-queueSAGA_DLQ (P), consumer @ queuewebhooks → queuebothWorkflow-fail escalation
DO migrationv1: AuctionDO, WebSocketHub (SQLite-backed)AUCTION, HUBwsbothOne DO per YYYY-MM-DD; one hibernatable hub
Workflowpayment-saga (class PaymentSagaWorkflow)PAYMENT_SAGAapi, webhooksbothMaster §10.1 invoice-first saga
Cron0 0 * * *—cronbothDaily auction open + close fan-out
Cron5 * * * *—cronbothHourly VIES retry + post-campaign-report sweep

Preview names suffixed -preview (precedent main.tf:89).


.env.example reproduces master §4.7 verbatim (empty values). Local dev uses per-worker .dev.vars. Prod + preview use wrangler secret put <KEY> --env <env>.

WorkerSecrets
apiSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY, TURNSTILE_SECRET_KEY, VAPID_*, CONVERSION_PIXEL_SECRET, SENTRY_DSN_API
webhooksSTRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, SENTRY_DSN_QUEUE
wsSENTRY_DSN_WS
adminADMIN_API_TOKEN, CF_ACCESS_AUD, CF_ACCESS_TEAM, SENTRY_DSN_ADMIN
webTURNSTILE_SITE_KEY (vars), SENTRY_DSN_WEB
queueRESEND_API_KEY, VAPID_PRIVATE_KEY, SENTRY_DSN_QUEUE
cronSENTRY_DSN_CRON

docs/cloud-environment.md extends world-forest/docs/cloud-environment.md:1-60 with STRIPE_*, RESEND_API_KEY, VAPID_*, TURNSTILE_*, SENTRY_DSN_*, CONVERSION_PIXEL_SECRET, CF_ACCESS_AUD, CF_ACCESS_TEAM, ADMIN_API_TOKEN. Six Sentry projects (one per worker) created in docs/superpowers/runbooks/sentry-setup.md. Set with: wrangler secret put <KEY> --env <preview|production>.

#122 rename: the row originally listed ACCESS_AUD. The shipped admin worker middleware (apps/admin/src/cf-access.ts) reads CF_ACCESS_AUD + CF_ACCESS_TEAM; implementation wins. The Terraform Access apps in infra/terraform/access.tf emit one AUD per env; the team slug (e.g. nyl, not nyl.cloudflareaccess.com — the middleware appends the suffix itself) is supplied via TF_VAR_cloudflare_access_team_name. See docs/superpowers/runbooks/admin-access-bootstrap.md for the apply

  • secret-put + deploy order.

Sentry — one project, not six (supersedes the SENTRY_DSN_* column). The table’s per-worker SENTRY_DSN_API / _WS / _ADMIN / _WEB / _QUEUE / _CRON names implied six projects. The shipped design uses a single nowyourlink Sentry project: every worker reads a plain SENTRY_DSN (same value) plus the committed ENVIRONMENT var (preview/production), and is attributed by a hardcoded service tag in its Sentry.withSentry(...) options. Rationale (code is DSN-agnostic, world-forest precedent, org-level quota, the §7 table already reused _QUEUE for webhook) and the split-a-worker-out-later path are recorded in docs/superpowers/runbooks/sentry-setup.md. The only Sentry secret is SENTRY_DSN (not the SENTRY_DSN_* names); the environment is the single committed ENVIRONMENT var — shared by the logger and (in admin) the CF-Access docker-local bypass — replacing the earlier SENTRY_ENVIRONMENT.

Live per-worker secret list (#267). The reconciled, verified deploy-time secret surface — including ADMIN_SECRET (was ADMIN_API_TOKEN), CF_ACCESS_AUD/CF_ACCESS_TEAM (was ACCESS_AUD), and the reserved not-yet-wired secrets (VAPID_*, OAuth, webhook Stripe) — is maintained in docs/cloud-environment.md and .env.example. Treat those as canonical for what to wrangler secret put per worker; this §7 table is the original intent.


Mirrors world-forest .github/workflows/:

WorkflowTriggerPurpose
ci.ymlPR + push maininstall / lint / build / test / wrangler deploy --dry-run --env preview per worker (pattern: world-forest/ci.yml:9-50).
preview.ymlPR touching apps/**, packages/**, infra/terraform/**terraform apply preview + wrangler deploy --env preview per worker. Concurrency preview-nowyourlink, cancel-in-progress.
deploy.ymlpush main + workflow_dispatchterraform apply prod + wrangler deploy --env production. Concurrency deploy-nowyourlink, no cancel. GitHub production env gate.
lint-seo.ymlPRFrom ghardenlab add lint-seo; fails PRs regressing AI-SEO contract.
claude.yml + claude-code-review.ymlPR/issueFrom --ci-claude.
perf-cost-audit.yml (optional, weekly)cronAlerts on DO-duration creep (master §10.2).
perf-hibernation-lint.yml (optional)PREnforces acceptWebSocket + auto-response usage.

Env block shared from world-forest/deploy.yml:18-25: CLOUDFLARE_API_TOKEN, AWS_ACCESS_KEY_ID=R2_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY=R2_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3=R2_ENDPOINT, TF_VAR_cloudflare_*.


git clone git@github.com:ghardenlab/nowyourlink.git && cd nowyourlink
cp .env.example .dev.vars
pnpm install && pnpm build
ghardenlab doctor && ghardenlab config validate
# Per worker, in separate terminals:
cd apps/web && npx wrangler dev --local --persist-to=.wrangler/state
cd apps/api && npx wrangler dev --local --port 8788
cd apps/ws && npx wrangler dev --local --port 8789
cd apps/queue && npx wrangler dev --local --port 8790
cd apps/cron && npx wrangler dev --local --port 8791 --test-scheduled
cd apps/admin && npx wrangler dev --local --port 8792
stripe listen --forward-to localhost:8788/webhooks/stripe

D1 bootstraps via miniflare SQLite (--persist-to); pnpm dev:seed seeds. Cron: curl http://localhost:8791/__scheduled?cron=0+0+*+*+*.


  • §3 ghardenlab create runs to completion against empty dir.
  • All seven ghardenlab add calls idempotent.
  • ghardenlab config validate + ghardenlab doctor pass.
  • pnpm install && build && lint && test green.
  • wrangler deploy --dry-run --env preview passes for every worker.
  • terraform validate + plan succeed with prod vars.
  • §6 resources exist in CF dashboard for both envs after terraform apply.
  • wrangler secret list --env production returns every §7 secret per worker.
  • docs/cloud-environment.md lists every §7 secret.
  • CF Access gates admin.nowyourlink.com with allowlist.
  • ci, preview, deploy, lint-seo workflows green on fresh PR.
  • Smoke: curl api.preview.nowyourlink.com/healthz → 200; wss://ws.preview.nowyourlink.com/auction/2026-06-01 upgrades.

  • Wrangler v4 = JSONC, not TOML. Gap-patches (§4) edit wrangler.jsonc.
  • R2 creds scoped per bucket. TF_R2_* authenticates the terraform-state bucket (rotated 2026-08-16 to the account-wide pair); a bucket-scoped HMAC answers HeadObject with an opaque 403 at init. Do not reuse for app buckets (world-forest DEPLOY.md:89-99).
  • Workflows bindings on every worker that calls instance.create() / sendEvent() — minimum api + webhooks.
  • CF Access JWT subject is the admin identity (master §4.1); never create users rows for admins.
  • Hibernation is non-negotiable (master §10.2). Hub + auction DOs must use state.acceptWebSocket() + setWebSocketAutoResponse(). perf-hibernation-lint.yml enforces this.
  • Sentry DSNs are per-worker (§7) — one project each, do not collapse.
  • Cron triggers cost Worker invocations (cost-projection §6). Keep handlers lean; offload to Queues.
  • TF outputs IDs but does not auto-write wrangler.jsonc (G4–G5). First-apply requires manual paste; subsequent runs stable.
  • Mimic verbatim: world-forest/infra/terraform/main.tf (backend + module), .github/workflows/deploy.yml (env + tf steps), DEPLOY.md §7 (tf init under cloud-env), docs/cloud-environment.md (cloud-agent setup script).