# Proxy — `@sonordev/site-kit/proxy`

Composable Next.js Proxy factory. Zero-config redirects + security headers. Opt-in AI discovery headers.

Next 16 renamed the `middleware` file convention to `proxy`. Through 7.x this
module is also served on `@sonordev/site-kit/middleware`, with `createMiddleware`
as an alias of `createProxy`; both go in 8.0. `npx sonor-setup codemod --write`
moves a site over (the file too).

## Usage

```ts
// proxy.ts — at the project root, or in src/
import { createProxy } from '@sonordev/site-kit/proxy'

export default createProxy()

// Inlined on purpose. See "The matcher must be inlined" below — importing
// siteKitMatcher here is a build error.
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:ico|png|jpg|jpeg|gif|webp|svg|woff2?)$).*)',
  ],
}
```

With options:

```ts
export default createProxy({
  securityHeaders: { frameAncestors: ["'self'", 'https://partner.example.com'] },
  llmsDiscovery: { siteUrl: 'https://example.com' },
  before: (req) => {
    // Custom auth check, geolocation, etc.
  },
})
```

## The matcher must be inlined

**`export const config = siteKitMatcher` is a build error.** Next statically
parses `config.matcher` at build time and Turbopack rejects any imported value,
so the array has to be a literal in your own file. Copy it from the example
above, or read it off `siteKitMatcher` — but paste the contents, don't export
the binding.

`siteKitMatcher` exists so the canonical pattern lives in one place that the
scaffold, the docs, and the tests all read. It is a **reference value**.

## Migrating from `middleware.ts`

```bash
npx sonor-setup next16
```

Runs the codemod's `next-16-proxy` transform (deterministic and offline: it
moves the file, renames a named `middleware` export to `proxy`, and never
moves onto an existing `proxy.ts`) and re-runs the doctor check afterwards, so
the result is verified rather than assumed. Safe to run twice. A file that
sets `runtime` is flagged rather than moved (see below).

Two Next 16 traps to know:

- **No `runtime` export.** Proxy defaults to the Node.js runtime and the
  `runtime` config option is unavailable in proxy files — setting it throws at
  build time. (`middleware.ts` still accepts `runtime: 'nodejs'`, and it does
  run on Netlify; remove the export before moving the file.)
- **The matcher**, as above.

## Config

```ts
interface SiteKitProxyConfig {
  redirects?: boolean | RedirectConfig               // Sonor-managed redirects (default: true)
  securityHeaders?: boolean | SecurityHeadersConfig  // Security headers (default: true)
  llmsDiscovery?: LlmsDiscoveryConfig | false        // AI crawler discovery header
  identity?: boolean | IdentityConfig                // Opt-in edge identity pass

  before?: (req: NextRequest) => NextResponse | undefined | Promise<NextResponse | undefined>
  after?: (req: NextRequest, res: NextResponse) => NextResponse | Promise<NextResponse>
}

interface LlmsDiscoveryConfig {
  siteUrl: string          // e.g. 'https://example.com'
  llmsPath?: string        // Default: '/llms.txt'
  mcpServerCard?: boolean | string  // Also link the MCP server card (rel="service-desc"); true = '/.well-known/mcp-server-card'
}
```

`redirects: true` (the default) checks managed rules before each page render.
The rule list is cached in memory for five minutes per instance, and a fetch is
bounded at 300ms with a 30s cooldown after a failure, so page loads rarely wait
on it. Set `redirects: false` only on a site that uses no Sonor-managed
redirects.

Don't resolve redirects in `app/not-found.tsx` with `resolveManagedRedirect()`.
Next renders the root not-found boundary inside every page, so its `headers()`
call makes every route dynamic: on a Next 16 build every static route turned
into a dynamic one. The resolver is deprecated.

## What It Handles (in order)

1. **Before hook** — optional custom logic (return `NextResponse` to short-circuit)
2. **Sonor-managed redirects** — 301/302/307/308 with query param preservation
3. **Security headers** — see the table below
4. **AI discovery header** — `Link: <url>; rel="describedby"; type="text/markdown"` on every request that
   could be for a page: GET or HEAD, an Accept header that allows HTML (or no Accept header at all, which is
   how curl and many crawlers ask), not a file-like path (`/llms.txt`, `/sitemap.xml`), not `/api/`, and not
   a Next RSC navigation request. See `wantsLlmsDiscoveryLink` in `@sonordev/site-kit/llms`.
5. **After hook** — mutate response headers

## Security Headers (defaults)

| Header                  | Value                                                    |
| ----------------------- | -------------------------------------------------------- |
| X-DNS-Prefetch-Control  | `on`                                                     |
| Content-Security-Policy | `frame-ancestors` from `DEFAULT_FRAME_ANCESTORS` (below) |
| X-Content-Type-Options  | `nosniff`                                                |
| X-XSS-Protection        | `1; mode=block`                                          |
| Referrer-Policy         | `strict-origin-when-cross-origin`                        |
| Permissions-Policy      | `camera=(), microphone=(), geolocation=()`               |

Note there is no `X-Frame-Options` row — that is deliberate, see below.

### Framing / `frame-ancestors`

Managed sites ship a CSP `frame-ancestors` allowlist instead of
`X-Frame-Options`. The default, `DEFAULT_FRAME_ANCESTORS`, is `'self'`,
Sonor's dashboard (`https://app.sonor.io`, for Edit on page) and the Upforge
showcase origins (`upforge.io`, `upforgelabs.com` and their subdomains), where
live client sites appear in portfolio frames. XFO has no allowlist form
(`ALLOW-FROM` is dead), and emitting both would let a stray `DENY` silently
re-block the embed, so `X-Frame-Options` is **omitted** whenever
`frameAncestors` is active. Every origin not on the list is still blocked.

`frameAncestors` replaces the default, so spread it to add an origin:

```ts
import { createProxy, DEFAULT_FRAME_ANCESTORS } from '@sonordev/site-kit/proxy'

createProxy({
  securityHeaders: {
    frameAncestors: [...DEFAULT_FRAME_ANCESTORS, 'https://partner.example.com'],
  },
})

// opt out entirely → falls back to X-Frame-Options: DENY
createProxy({ securityHeaders: { frameAncestors: false } })
```

`DEFAULT_FRAME_ANCESTORS` is the single source of truth — add origins there
rather than re-forking the list into individual sites.
