# Fix: "register new user fails" — CSP + CORS + fonts

**Date:** 2026-06-08
**Scope:** zync-www (Astro/CF Worker) + zync-api (Hono/CF Worker)
**Goal:** A user can complete signup on `dev.zync.is/signup` and land on `app.dev.zync.is/onboarding`.

## Problem (empirically confirmed against live dev)

Signup is dead due to **three independent, all-blocking** defects. Fixing any subset still leaves register broken.

1. **CSP blocks Astro island hydration.** `zync-www/src/middleware.ts` sets
   `script-src 'self' 'nonce-<X>'`. Astro 6 client islands emit **inline bootstrap
   scripts with no nonce** (verified: the two console-blocked hashes
   `sha256-QzWFZi+FLIx…` and `sha256-SaCkFfPru…` are the astro-island runtime +
   renderer; page has 7 `astro-island` elements, all inline scripts `nonce=false`).
   Blocked → `SignupForm` (`client:load`) never hydrates → `onSubmit` never binds →
   the register button does nothing.
2. **CSP `connect-src` omits the API origin.** Header is
   `connect-src 'self' https://api.zync.is`, but the form POSTs to
   `https://app.dev.zync.is/api/auth/signup` (`PUBLIC_API_BASE_URL`). Even hydrated,
   the fetch is CSP-blocked.
3. **zync-api CORS allowlist omits `dev.zync.is`.** `zync-api/src/middleware/cors.ts`
   `ALLOWED_ORIGINS` = {app.zync.is, admin.zync.is, *.workers.dev}. The cross-origin
   credentialed POST from `dev.zync.is` gets no `Access-Control-Allow-Origin` →
   browser discards the response.

Cosmetic (same page, include): **font 404s** — `packages/ui/src/tokens/fonts.css`
references `/fonts/*.woff2`; `zync-www/public/fonts/` does not exist.

## Root architecture decision: who owns CSP

The manual per-request **nonce** in middleware cannot reach Astro's per-island inline
scripts — Astro generates them and does not know the middleware nonce. The blessed fix
is Astro 6 **`security.csp`** (stable, hash-based): Astro auto-computes `sha256` hashes
for every bundled script (client islands) and bundled style, and injects the CSP itself.

**Delivery channel (pinned against source):** `@astrojs/cloudflare` declares no
`adapterFeatures.staticHeaders`, so `manifest.csp.cspDestination` is `undefined` and
Astro resolves it at render via
`fetch-state.js:273`: `cspDestination ?? (routeData.prerender ? "meta" : "header")`.
Signup (and the www pages using middleware) are **on-demand** (`output: 'server'`,
non-prerendered) → destination = **`header`**: Astro calls
`headers.set("content-security-policy", …)` itself (`page.js:90`). So:
- `frame-ancestors 'none'` **works** (it's a real header, not meta) — keep it in the
  Astro directives.
- The middleware currently sets the CSP header **after** `next()`, which would
  **clobber Astro's** CSP header. Removing the middleware CSP is therefore mandatory,
  not just cleanup.
- Any *prerendered* page would instead get `meta` delivery (frame-ancestors ignored
  there) — so we also set `X-Frame-Options: DENY` as cheap defense-in-depth.

CSP ownership:
- **Astro `security.csp`** owns the full policy (`default-src`/`script-src`/`style-src`/
  `connect-src`/`img-src`/`font-src`/`base-uri`/`form-action`/`frame-ancestors`), with
  auto-injected script/style hashes.
- **Middleware** emits no CSP and only sets header-only extras
  (`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`).

This is global (all www pages), which is correct: the strict CSP currently breaks every
hydrated page, not just signup.

## Changes

### A. zync-www — `astro.config.mjs`

Add `security.csp`. **Do not** derive `connect-src` from `process.env` — there is no
`.env` in zync-www and `PUBLIC_API_BASE_URL` is a wrangler `[vars]` *runtime* binding,
so `process.env.PUBLIC_API_BASE_URL` is empty at build and would silently fall back to
prod, re-breaking dev. List both real API origins statically (only two exist):

```js
export default defineConfig({
  // …existing…
  security: {
    csp: {
      directives: [
        "default-src 'self'",
        // both app origins — prod + dev. Harmless to allow both everywhere.
        "connect-src 'self' https://app.zync.is https://app.dev.zync.is",
        "img-src 'self' data: https://*.r2.dev",
        "font-src 'self' https://*.r2.dev",
        "base-uri 'self'",
        "form-action 'self'",
        "frame-ancestors 'none'", // real header for on-demand pages; XFO backs up meta pages
      ],
      // Runtime React inline style="" attributes cannot be build-hashed.
      // style-src-attr 'unsafe-inline' allows ONLY style attributes (not <style>
      // elements, not scripts) — a scoped, deliberate allowance.
      styleDirective: {
        resources: ["'self'"],
      },
    },
  },
})
```

- `script-src` and `style-src` are managed by Astro (hashes auto-added). We do **not**
  list `'unsafe-inline'` anywhere for scripts.
- **Verification required during build:** confirm the BaseLayout `is:inline` theme
  script is allowed. If Astro does not auto-hash `is:inline` scripts, add its exact
  hash to `security.csp.scriptDirective.hashes`
  (`sha256-qX1uPShALpogUGl5XUUlkq6ph/jat4lU/pW7lLEOuHg=` for the current body) — the
  script is static so the hash is stable.
- **Inline `style=""` attributes:** React components (and the blocked `vv9Io` style)
  emit runtime `style=""` attributes that build-time hashes cannot cover — **expect to
  need** `style-src-attr 'unsafe-inline'` in the style directive (scoped to attributes
  only; not `<style>`, not scripts). Add it; confirm it clears the style violation.

### B. zync-www — `src/middleware.ts`

Remove the nonce generation and the `Content-Security-Policy` header entirely (Astro
owns CSP now). Keep the middleware only to set header-only security headers:

```ts
export const onRequest = defineMiddleware(async (_context, next) => {
  const res = await next()
  res.headers.set('X-Frame-Options', 'DENY')
  res.headers.set('X-Content-Type-Options', 'nosniff')
  return res
})
```

- Drop `context.locals.nonce`. Remove the `nonce` usage in
  `BaseLayout.astro:94` (`<script nonce={nonce} is:inline>` → `<script is:inline>`).
  Remove the now-dead `nonce`/`locals` wiring (and the `Locals` type if declared).
- Reconcile `public/_headers`: if it sets a static CSP fallback, align or remove it so
  it does not reintroduce a conflicting header.

### C. zync-api — `src/middleware/cors.ts`

Add the www origins to `ALLOWED_ORIGINS`:

```ts
const ALLOWED_ORIGINS = new Set([
  'https://app.zync.is',
  'https://admin.zync.is',
  'https://zync.is',
  'https://dev.zync.is',          // www dev — REQUIRED for signup from dev
  'https://app.dev.zync.is',
  'https://zync-app.dry-salad-ffa1.workers.dev',
  'https://zync-admin.dry-salad-ffa1.workers.dev',
])
```

Keep credentialed/origin-pinned behavior (no wildcard). Preflight already handled.

For every allowed origin, `corsMiddleware` MUST finalize CORS headers after downstream
handlers complete. This includes handlers returning raw `Response` objects from service
bindings or Durable Objects; those responses replace headers staged before `next()`.
Rationale: the AuthWriteDO login offload introduced raw responses, causing browsers to
discard otherwise-valid 200/401 login responses while direct HTTP probes stayed green.

### D. zync-www — fonts

Add the four self-hosted woff2 files to `apps/zync-www/public/fonts/` so `/fonts/*.woff2`
resolves: `ibm-plex-sans-variable.woff2`, `heebo-variable.woff2`,
`ibm-plex-mono-variable.woff2`, `fraunces-variable.woff2`. Source copies exist at
`apps/zync-app/public/fonts/`. `font-src 'self'` already permits them.

## Out of scope (note, do not fix here)

`define:vars` `<style>` on `sign/[token]`, `c/[token]`, `p/[token]` is incompatible with
Astro CSP build-time hashing (runtime-injected brand color). Enabling global CSP will
break those styles. They are **already broken** under the current CSP (inline `<style>`
blocked by `default-src 'self'`), so this fix does not regress them — but it does not fix
them either. Flag for a follow-up spec (inject brand var via the hashed island or an
external stylesheet, not `define:vars`).

## Verification (the bar is end-to-end, not "console clean")

1. Build zync-www with `PUBLIC_API_BASE_URL` and `PUBLIC_APP_URL`; the build MUST fail
   if the emitted auth client does not contain both values. Rationale: Wrangler
   `[vars]` are runtime bindings and cannot populate Vite's client bundle.
2. Confirm no build error and the on-demand signup response carries a
   `content-security-policy` **header** with auto-injected script/style `sha256-` hashes
   (header, not meta, for on-demand pages).
3. Deploy zync-www (dev) + zync-api (dev).
4. On `https://dev.zync.is/signup`: DevTools console shows **no** CSP script/style
   violations; `SignupForm` is interactive (password toggle works = hydrated).
5. Submit a fresh signup → network: `OPTIONS` + `POST` to
   `app.dev.zync.is/api/auth/signup` both succeed (no CORS error), response has
   `Access-Control-Allow-Origin: https://dev.zync.is`.
6. Submit invalid credentials on `https://dev.zync.is/login`; browser receives the 401
   response and shows the credential error, never the communication-error banner.
7. Browser lands on `app.dev.zync.is/onboarding` after valid signup/login.
8. Fonts: `/fonts/heebo-variable.woff2` and `/fonts/fraunces-variable.woff2` return 200.
9. Regression: a non-signup hydrated page (e.g. home) still renders and hydrates;
   theme flash-prevention still works (no light flash on dark).

## Architecture Decisions

- **Astro `security.csp` as sole CSP source; middleware reduced to header-only security
  headers.** Rejected: extending the manual nonce to islands — Astro provides no hook;
  would be a fragile workaround.
- **On-demand pages get CSP via header** (`prerender ? "meta" : "header"`), so
  `frame-ancestors` works; middleware CSP removed because it would clobber Astro's
  header. `X-Frame-Options: DENY` added as backup for any prerendered (meta) pages.
- **`connect-src` lists both app origins statically** (`app.zync.is` +
  `app.dev.zync.is`) — `process.env` is empty at build (wrangler runtime var), so
  build-time derivation would silently fall back to prod. The original
  `'self' https://api.zync.is` (a non-existent origin) was the bug.
- **`style-src-attr 'unsafe-inline'` permitted only if observed necessary** — scoped to
  style attributes; never `'unsafe-inline'` for scripts.
- **define:vars token pages deferred** — pre-existing breakage, separate spec.
