What changed, and why nothing changes at merge

Everwhen's hosting moves to Vercel. This PR is the app half, and merging it changes nothing a player can see: playeverwhen.com keeps serving from the cluster until the DNS flip in the companion infra PR (mseeks/zo#26), which also owns the cutover ordering and the rollback plan.

For what it does, the diff is tiny. The 57-line Dockerfile goes, along with a 41-line .dockerignore and 66 lines of CI publish machinery, because the Nuxt/Nitro Vercel preset needs no in-repo config at all and Hobby fluid compute's defaults already cover this app. What arrives: an 8-line README Hosting section, a one-line Sentry config addition (plus its rewritten comment), and a doc sweep of the two files that still described the image era. After cutover a push to main deploys, and any PR gets a working preview URL. The label-gated k8s preview system this replaces was retired earlier.

One scheduling fact belongs up front. The moment this merges, the cluster's :latest image freezes (its publisher job is gone), so nothing new can ship to players until the DNS flip. Merge and cutover belong in the same sitting; mseeks/zo#26 sequences them that way.

CI: the gate stays, the shipping leaves

Two jobs are gone. publish built the SSR image on green main builds and pushed ghcr.io/mseeks/everwhen as :latest plus a :<sha>. publish-preview built a :pr-<N> image whenever a PR carried the preview label. Vercel builds from source on its own runners. The images these jobs made fed the cluster Deployment's :latest pull, and that tag freezes the moment this merges, which is the freeze window named above. check survives byte-identical: the same lint, typecheck, unit suite, build, and mocked Playwright run, on every push and every PR.

The whole remaining jobs section — check, now the end of the file.

.github/workflows/ci.yml · 49 lines
.github/workflows/ci.yml49 lines · YAML
⋯ 19 lines hidden (lines 1–19)
1name: CI
2 
3on:
4 push:
5 branches: [main]
6 # `labeled` is included so adding the `preview` label fires a run (and thus the
7 # preview-image build below) without waiting for the next push — the common
8 # case is labeling a finished PR you want to play before approving.
9 pull_request:
10 types: [opened, synchronize, reopened, labeled]
11 
12# Cancel superseded runs on the same ref to save CI minutes.
13concurrency:
14 group: ci-${{ github.ref }}
15 cancel-in-progress: true
16 
17permissions:
18 contents: read
19 
20jobs:
21 check:
22 name: check (lint · typecheck · tests)
23 runs-on: ubuntu-latest
24 steps:
25 - uses: actions/checkout@v6
26 - uses: actions/setup-node@v6
27 with:
28 node-version: "22"
29 cache: npm
30 # npm ci installs the committed lockfile exactly, so a dependency-patch
31 # PR is verified against the lockfile it ships.
32 - run: npm ci
33 # Static gates first — fast, and they fail before the heavier build/e2e (#273).
34 - run: npm run lint
35 - run: npm run format:check
36 - run: npx nuxt typecheck
37 - run: npx vitest run
38 # The agents/ maintenance package is self-contained (its own deps); typecheck
39 # it too so the MHE loops can't silently rot — it's gated nowhere else.
40 - run: npm --prefix agents ci
41 - run: npm --prefix agents run typecheck
42 # Build the SSR app and run the mocked Playwright suite on PRs too (#237), so build
43 # breakage and e2e regressions surface HERE, not only after merge. The build is an
44 # explicit step (fast, clear signal); the Playwright webServer then reuses it via
45 # `nuxt preview` in CI (see playwright.config.ts) instead of rebuilding.
46 - run: npm run build
47 - name: Install Playwright chromium
48 run: npx playwright install --with-deps chromium
49 - run: npm run test:e2e

The Sentry seam: the one real code change

Server-side Sentry used to boot from the Dockerfile CMD: node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs. There is no CMD on Vercel. The platform invokes the bundled handler directly, so without a change the server half of Sentry would silently vanish while the client half kept reporting. The Sentry module has a mode for exactly this situation: autoInjectServerSentry: "top-level-import" compiles the init into the server bundle itself.

The rewritten sentry block: source-map upload still off, server init now injected.

nuxt.config.ts · 143 lines
nuxt.config.ts143 lines · TypeScript
⋯ 118 lines hidden (lines 1–118)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5import { DEFAULT_SENTRY_DSN } from "./sentry.shared";
6 
7export default defineNuxtConfig({
8 // Ensure compatibility with modern Nuxt features
9 compatibilityDate: "2025-05-15",
10 
11 // Enable devtools for development
12 devtools: { enabled: true },
13 
14 // Declare the document language app-wide (issue #118). Set here rather than in app.vue so
15 // it also reaches routes that bypass the app component — Nuxt's built-in error/404 page —
16 // keeping <html lang="en"> consistent everywhere (accessibility; pairs with og:locale).
17 app: {
18 head: {
19 htmlAttrs: { lang: "en" },
20 },
21 },
22 
23 // Register required modules
24 modules: [
25 "@nuxt/ui",
26 "@pinia/nuxt",
27 "@nuxtjs/supabase",
28 "@nuxt/eslint",
29 "@sentry/nuxt/module",
30 ],
31 
32 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
33 // user so the balance keys on a real user id from the first second; an OAuth
34 // sign-in lands on a fresh or returning account (see utils/sign-in.ts).
35 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
36 // free-trial hook); buying is what requires an account. It also means the module
37 // registers no /confirm callback route, so the sign-in return is finalized in
38 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
39 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
40 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
41 // back to the Site URL and the link never reaches the handler. The client key is
42 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
43 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
44 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
45 supabase: {
46 url: process.env.SUPABASE_URL || "https://placeholder.supabase.co",
47 key: process.env.SUPABASE_PUBLISHABLE_KEY || "sb_publishable_placeholder",
48 redirect: false,
49 // We don't use generated DB types (the balance store talks to Supabase with the
50 // service client directly); disable the lookup so the module doesn't warn.
51 types: false,
52 },
53 
54 // Global CSS imports
55 css: ["~/assets/css/main.css"],
56 
57 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
58 // class on <html> is exactly `dark` / `light` — what the .dark token block and
59 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
60 // the module's inline no-flash script + localStorage persistence replace the old
61 // manual classList toggle (which didn't persist and could FOUC).
62 colorMode: {
63 classSuffix: "",
64 preference: "system",
65 fallback: "light",
66 },
67 
68 // Runtime configuration
69 runtimeConfig: {
70 // Private keys (only available on server-side)
71 openaiApiKey: process.env.OPENAI_API_KEY,
72 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
73 // Supabase — the run store (server-side only). Absent → in-memory dev store.
74 supabaseUrl: process.env.SUPABASE_URL,
75 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
76 // Stripe — run packs (server-side only). Absent → checkout returns 503.
77 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
78 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
79 // Canonical site origin for Stripe redirect URLs (never the client Host header).
80 siteUrl: process.env.EVERWHEN_BASE_URL,
81 // Referral credits (issue #96) — server-side only; sane defaults, env-overridable.
82 // reward: runs credited to a sharer per qualified email signup. cap: soft per-sharer
83 // lifetime limit on credited referrals (<=0 disables). digest: the batched-notice
84 // throttle (a kill-switch + threshold + window). See server/utils/referral.ts.
85 referralReward: process.env.EVERWHEN_REFERRAL_REWARD,
86 referralCap: process.env.EVERWHEN_REFERRAL_CAP,
87 referralDigestEnabled: process.env.EVERWHEN_REFERRAL_DIGEST,
88 referralDigestThreshold: process.env.EVERWHEN_REFERRAL_DIGEST_THRESHOLD,
89 referralDigestWindowHours:
90 process.env.EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS,
91 // Operator allowlist for the Steward's Ledger ops dashboard (/steward) and its
92 // /api/steward/* endpoints — comma-separated emails. Server-side only; empty →
93 // no operators (deny-all). See server/utils/operator-auth.ts.
94 operatorEmails: process.env.EVERWHEN_OPERATOR_EMAILS,
95 // Public keys (exposed to client-side)
96 public: {
97 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
98 // this flag additionally opts a DEPLOYED staging preview in. Never set in
99 // production. The server gates the fixture lane on the same env var, so the
100 // client panel and the server seam agree on one source of truth.
101 devMode: process.env.EVERWHEN_DEV_MODE === "true",
102 // The brand's X/Twitter @handle for twitter:site / twitter:creator attribution
103 // (issue #118). Unset → those two tags are simply omitted (a guessed handle would
104 // mis-attribute the card to a stranger). Set EVERWHEN_TWITTER_HANDLE (e.g.
105 // "@everwhen") in production to activate them.
106 twitterHandle: process.env.EVERWHEN_TWITTER_HANDLE || "",
107 // Sentry monitoring (client init reads this in sentry.client.config.ts). The DSN
108 // is a public client identifier — safe to ship (see sentry.shared.ts). Override
109 // with EVERWHEN_SENTRY_DSN per environment, or "" to disable; `environment` tags
110 // events (production vs a preview) and defaults to NODE_ENV. Sentry reports only
111 // from production builds, so local dev stays silent regardless.
112 sentry: {
113 dsn: process.env.EVERWHEN_SENTRY_DSN ?? DEFAULT_SENTRY_DSN,
114 environment:
115 process.env.EVERWHEN_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
116 },
117 },
118 },
119 
120 // @sentry/nuxt build-time options. Source-map upload is OFF: it needs a Sentry auth
121 // token (a secret) plus the org/project slugs wired into CI, none of which exist yet,
122 // so production stack traces stay minified for now — a clean follow-up once a
123 // SENTRY_AUTH_TOKEN is added to the build. Server init: hosting is Vercel
124 // Functions, where Sentry documents that `--import` cannot be configured (the
125 // old Dockerfile's CMD did that), so the module injects the init into the
126 // server bundle itself. top-level-import is Sentry's documented Vercel mode —
127 // "limited server tracing": errors and native-Node spans (fetch/http, which
128 // covers the outbound AI/Supabase/Stripe calls) are captured, deeper
129 // framework/db auto-instrumentation is not. The old Docker pairing is gone,
130 // so this no longer risks a double-init.
131 sentry: {
132 sourceMapsUploadOptions: { enabled: false },
⋯ 11 lines hidden (lines 133–143)
133 autoInjectServerSentry: "top-level-import",
134 },
135 
136 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
137 // out of the app's type program so `nuxt typecheck` never compiles the loops.
138 typescript: {
139 tsConfig: {
140 exclude: ["../agents"],
141 },
142 },
143});

Platform fit: checked, not assumed

The claims below were verified against current Vercel and repo state rather than carried over from the static-site migrations, because this app is a different animal — SSR, streaming turns, payments, a native binary:

surfaceverdict
streaming turn endpoints (SSE via createEventStream)supported; duration includes streamed response, and Hobby fluid compute allows 300s default and max — wide margin
function memory2 GB Hobby default vs the pod's 512 Mi limit
OG card's native @resvg/resvg-jsbundles within the 250 MB function limit; the route already falls back to the committed static card on rasterizer failure
Stripe webhookURL and endpoint secret are domain-bound; the domain doesn't change, so nothing moves
env inventoryunchanged — the same production vars, set in the Vercel dashboard, never in-repo
vercel.jsonnone needed; the Nuxt preset auto-detects
Each risk surface, with its verdict.

How this was verified

The repo forbids building from submodule-worktree paths, where Nitro auto-imports silently break, so the gate runs from fresh clones on clean paths. The first gate run proved less than intended. Adversarial review noticed it had cloned before the commit existed, meaning its green results established the baseline, and only that. So the gate ran again, against the actual PR tip this time, and the build produced its own discriminating evidence: line 1 of .output/server/index.mjs reads import './sentry.server.config.mjs';. The injection is in the bundle, byte-visible. The suite passed 1458 tests there too, and CI's full check (mocked Playwright included) is green on the PR.

Runtime verification is deliberately staged in mseeks/zo#26. The Vercel deployment gets exercised on its .vercel.app URL first, including /, /play, a real streamed turn, and the OG card. DNS moves only after that passes, and the k8s namespace keeps running afterward as the instant rollback.