Aller au contenu principal

Zero-friction flow runbook (EW-617)

Companion to the EW-617 spec series. Lives in the platform repo so it ships with the code; mirrored to Workspace/knowledge/runbooks/EVER_WORKS_ZERO_FRICTION_FLOW.md on the next Workspace sync.

What's wired up

GapWhat it doesPR / status
G1Landing prompt + URL hand-offever-works-website#37
G2Anonymous (TTL) users + cleanup taskplatform #756
G3Claim-account flowplatform #757 (stacked on G2)
G4Wizard "Generate now" + /api/works/quick-createplatform #758
G5*.ever.works subdomain + Cloudflare DNSplatform #759
G6Default deployProvider = 'ever-works'platform #752
G7Captcha + global capstracked, follow-up
G8Funnel telemetry + this runbookplatform (this PR)

End-to-end demo

1. Open https://ever.works/ in a fresh-incognito window.
2. Type a directory description into the hero textarea.
3. Click Generate.
→ Browser redirects to https://app.ever.works/onboarding#prompt=…
4. Wizard mounts:
- Reads the prompt out of the URL fragment.
- Mints a temporary user via POST /api/auth/anonymous (G2).
- Jumps to the final "Generate now" step.
5. Click Generate now.
→ POST /api/works/quick-create (G4) creates the Work and starts
generation. Response: { work, generation.historyId }.
6. Background: the agent pushes 3 repos to ever-works-cloud (EW-614),
the deploy workflow runs (G5), Cloudflare CNAME is provisioned, and
the site comes up at https://<slug>.ever.works/.
7. Optional: post-deploy banner offers Claim Account
(POST /api/auth/claim, G3).

Debugging by stage

Each stage emits a [zero-friction] JSON log line via ZeroFrictionFunnelService.emit (G8). All eight events share a correlationId so a single correlationId=… filter follows one user across services. Until the emit call sites land in their respective PRs, fall back to the per-component logs below.

Stage 1 — landing prompt (G1)

  • Browser console: form submit posts no XHR; it just sets window.location.href. Open Network → Doc to see the redirect.
  • If the form doesn't appear: confirm the website build deployed LandingPromptForm — check the rendered HTML in DevTools for data-testid="landing-prompt-form".

Stage 2 — anon user (G2)

  • POST /api/auth/anonymous should return 201 with { access_token, user.isAnonymous: true }.
  • 429 → the per-IP throttle (5/hour) tripped. Log in or wait.
  • 500 → check AnonymousAuthService logs; common cause is the users table not yet having is_anonymous / anonymous_expires_at columns. Run the G2 migration step or set database.autoMigrate=true in dev.

Stage 3 — wizard finished (G4)

  • The wizard reads the URL fragment on mount via the EverWorksOnboardingWizard effect. If the prompt doesn't pre-fill, the most likely cause is a hash-fragment URL encoding issue — open DevTools and inspect window.location.hash before any redirect.

Stage 4 — work created (G4)

  • POST /api/works/quick-create returns 202 with { status: 'pending', work, generation }. The work id from the response is what to use for downstream polling.
  • 400 → slug regex failed. The wizard derives slug from the prompt; if it produced an invalid slug log a bug.

Stage 5 — repos pushed (EW-614)

  • Search WorkLifecycleService logs for EverWorksGitProvider.createRepository lines. Three repos should be created in ever-works-cloud.

Stage 6 — deploy started (G5)

  • DeployService.deploy logs the workflow dispatch. The K8S_INGRESS_HOST Action variable should be set to ${slug}.ever.works.
  • Cloudflare CNAME provisioning: search CloudflareDns CNAME created/updated in EverWorksDnsService logs.

Stage 7 — deploy ready

  • DeploymentVerifierService polls the cluster for Ready. The site should respond on https://<slug>.ever.works/ within ~2 min after the workflow finishes.

Stage 8 — claim account (G3)

  • Authenticated POST /api/auth/claim with { email, password }. 409 → email already taken by a different user. 403 → the caller's session is already non-anonymous.

Rolling back per gap

GapRoll-back
G1Revert ever-works-website#37 and redeploy the website. The platform side keeps working — wizard hash-hydration is a no-op when no prompt is in the URL.
G2Revert platform #756. Existing anonymous rows orphan in the DB; safe to leave (the nightly cleanup task still runs once the schema columns exist).
G3Revert platform #757. Anonymous users stay anonymous; they can sign up normally via /register.
G4Revert platform #758. The wizard falls back to the legacy /works/new hand-off automatically (the "Generate now" button only renders when prompt is set).
G5Unset CLOUDFLARE_API_TOKEN (or rotate it). EverWorksDnsService.getProvider() returns null and the deploy pipeline reverts to the LB hostname fallback.
G6Revert platform #752. New rows go back to defaulting deployProvider='vercel'; existing rows are untouched.
G7(Not yet wired.)
G8Revert this PR. [zero-friction] log lines disappear; nothing else breaks.

Configuration cheat-sheet

Env varOwned byNotes
ANONYMOUS_USER_TTL_DAYSG2Default 7. Garbage values fall back to 7.
CAPTCHA_PROVIDERG7turnstile / hcaptcha / recaptcha or empty (disabled).
CAPTCHA_SECRETG7Server-side secret for the provider's /siteverify.
CLOUDFLARE_API_TOKENG5Scoped: DNS:Edit on the ever.works zone only. Never the global key.
CLOUDFLARE_ZONE_IDG532-char hex.
EVER_WORKS_DEPLOY_LB_HOSTNAMEG5Cluster ingress LB DNS name. Required for DNS automation.
EVER_WORKS_DOMAING5Defaults to ever.works.
DEPLOY_EVER_WORKS_ENABLEDEW-608Gates the ever-works deploy provider end-to-end.
NEXT_PUBLIC_APP_URLG1Where the landing page sends users. Defaults to https://app.ever.works.

Live values (dev / stage / prod, as of 2026-05-15)

Env varValue (or source)
DEPLOY_EVER_WORKS_ENABLEDtrue (inlined in deploy-do-{dev,stage,prod}.yml)
EVER_WORKS_DOMAINever.works (inlined in workflows)
EVER_WORKS_DEPLOY_LB_HOSTNAMElb.ever.works → proxied CNAME → Cloudflare Tunnel (see "DNS anchor record")
CLOUDFLARE_API_TOKENGH secret CLOUDFLARE_API_TOKEN (Zone:DNS edit on ever.works)
CLOUDFLARE_ZONE_IDGH secret CLOUDFLARE_ZONE_ID14b28d7fd73db5679d4280e4278ec6cf
CAPTCHA_PROVIDERGH secret CAPTCHA_PROVIDERempty until Turnstile widget + client wiring ship
CAPTCHA_SECRETGH secret CAPTCHA_SECRETempty until Turnstile widget + client wiring ship

Deploy mechanism

Env vars land in pods via the envsubst step in .github/workflows/deploy-do-{dev,stage,prod}.yml against the .deploy/k8s/k8s-manifest.{dev,stage,prod}.yaml placeholders. Each workflow's env: map maps GH secrets / inline values → $VAR → container env. Adding a new env var = (a) declare - name: VAR / value: "$VAR" in the 3 manifest YAMLs, (b) pass VAR: ${{ secrets.VAR }} (or inline) in the 3 workflow YAMLs, (c) gh secret set VAR --repo ever-works/ever-works.

DNS anchor record

lb.ever.works is the anchor that customer {slug}.ever.works CNAMEs point at. Created via the Cloudflare API 2026-05-15; repointed 2026-07-29.

Current shape: lb.ever.worksproxied CNAME5a1c27a6-7d93-4f25-a329-3154995e16db.cfargotunnel.com, i.e. the same Cloudflare Tunnel every other ever.works hostname uses. The tunnel has a catch-all rule to ingress-nginx-controller.ingress-nginx on the self-hosted ever-k8s cluster.

🛑 It must stay proxied. *.cfargotunnel.com only resolves through the Cloudflare proxy; a grey-cloud (unproxied) record pointing at the tunnel will not resolve.

History: it previously pointed at A 157.230.74.11, the DigitalOcean k8s-works ingress LB. That droplet was decommissioned, so the anchor resolved to a dead origin (Cloudflare HTTP 521) until it was repointed. Any customer subdomain CNAMEd to it during that window would have been unreachable.

Note on current practice: the customer Work subdomains that exist today (chairs, dir, startup-books, timetrack, vectordb, compliance-automation, mcpserver) are CNAMEd directly at the tunnel rather than at this anchor, so the anchor currently has zero dependents. It is kept working because EVER_WORKS_DEPLOY_LB_HOSTNAME still defaults to it in managed-subdomain.service.

Rotating: because the target is now a tunnel rather than an IP, there is normally nothing to rotate — changing which cluster serves a hostname is done in the tunnel's ingress rules / the k8s Ingress, not in DNS.

⚠️ A subdomain will still 404 unless a matching k8s Ingress rule exists. Pointing DNS at the tunnel is necessary but not sufficient: the tunnel catch-all forwards to ingress-nginx, which returns 404 for a Host it has no server block for.

Cloudflare token security note

The token currently in GH secrets has broader scope than strictly needed (Zone:DNS edit on ever.works is the only operation EverWorksDnsService performs, but the token currently used can also read other zones in the account). Follow-up: rotate to a zone-scoped token via the Cloudflare dashboard → My Profile → API Tokens → "Create Token" → "Edit zone DNS" template scoped to ever.works only. Update the CLOUDFLARE_API_TOKEN GH secret with the new value; the platform code path is unchanged.

Dashboards

  • PostHog board for the funnel: TBD (G8 wires emit calls; dashboard follow-up).
  • Cloudflare → ever.works zone → DNS records: shows the CNAME pool.
  • k8s-works → ingress → *.ever.works cert: verify cert-manager is serving the wildcard.

Known sharp edges

  • The 6-char random suffix on quick-create slugs is meant to dodge collisions across users typing the same prompt — but two users hitting the same suffix is still ~one-in-a-million per attempt. The Cloudflare PUT path handles that gracefully (idempotent update) but the platform write hits a unique-constraint error first; ops will see a 409 from POST /api/works/quick-create. Resolve by retrying; long-term fix is a stronger suffix or a server-side UUID slug.
  • Anonymous users created during a DB outage that prevented the users.is_anonymous column from being created may end up with null email but isAnonymous=false (the default). Detection query: SELECT id FROM users WHERE email IS NULL AND is_anonymous = false. Repair: set is_anonymous=true and seed an anonymous_expires_at in the past so the nightly cleanup picks them up.