decouple the dashboard from the server binary

Move the Svelte dashboard out of internal/web into its own top-level
frontend/ so it can be built and hosted independently, and make the
subidx binary API-only: drop the embedded dist and the catch-all /
handler, so unknown paths return a plain 404.

The frontend already knew how to live apart (VITE_API_BASE, -cors-origins,
vercel.json), so only the embed glue is gone. Also wire in Vercel Web
Analytics via @vercel/analytics.
This commit is contained in:
lakshit verma 2026-08-28 23:45:25 +05:30
parent faa4e1b58a
commit 41a2828ef0
No known key found for this signature in database
27 changed files with 85 additions and 247 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
SPEC.md
/docs/
node_modules/
/frontend/dist/

View file

@ -2,9 +2,11 @@
all: build
# Rebuild the dashboard UI (output is committed at internal/web/dist).
# Build the dashboard UI for a separate host (Vercel, Render, or any
# static host). Point it at the subidx API with VITE_API_BASE; the
# binary no longer embeds or serves the UI.
ui:
cd internal/web && npm ci && npm run build
cd frontend && npm ci && npm run build
build: go build -o subidx .
@ -15,7 +17,7 @@ vet: go vet ./...
serve: go run . serve -store ./data -addr 127.0.0.1:8099
dev:
cd internal/web && SUBIDX_API=$${SUBIDX_API:-http://127.0.0.1:8080} npm run dev
cd frontend && SUBIDX_API=$${SUBIDX_API:-http://127.0.0.1:8080} npm run dev
clean:
rm -f subidx

View file

@ -1,6 +1,6 @@
# subidx
Self-hosted passive subdomain enumeration from Certificate Transparency. subidx tails public CT logs (the records of every HTTPS certificate issued), indexes every hostname it sees, and serves them through a search API and dashboard you control. Think crt.name, but the data lives on your machine.
Self-hosted passive subdomain enumeration from Certificate Transparency. subidx tails public CT logs (the records of every HTTPS certificate issued), indexes every hostname it sees, and serves them through a search API you control. Think crt.name, but the data lives on your machine.
![subidx dashboard](assets/dashboard.png)
@ -16,7 +16,7 @@ go build -o subidx .
curl "http://localhost:8099/v1/search?apex=letsencrypt.org"
```
or open `http://localhost:8099/` for the dashboard: search any apex, filter and sort results instantly, toggle first-seen dates, export txt/csv, and watch a live feed of newly collected names. The UI is compiled into the binary; there is nothing extra to deploy.
The binary is API-only. The dashboard is a separate SPA in `frontend/` (Vite + Svelte): search any apex, filter and sort results instantly, toggle first-seen dates, export txt/csv, and watch a live feed of newly collected names. Deploy it to any static host (Vercel, Render, or your own), pointing it at the API with `VITE_API_BASE` and allowing its origin with `-cors-origins`. For local work, `make dev` runs it against a local `subidx serve`.
## What gets stored
@ -35,7 +35,7 @@ Tools like [subfinder](https://github.com/projectdiscovery/subfinder) query othe
- **No keys, no quotas.** Your only dependencies are the log lists themselves.
- **Verified provenance.** Log keys are pinned against the log lists, tree heads are cryptographically verified, and each fetched batch must pass an RFC 6962 inclusion-proof spot check before it is stored.
- **Historical drains.** Years of history from retired logs can be backfilled, if you have the terabytes.
- **An API and dashboard, not just a CLI.** Pipe results into your tooling or browse them.
- **An API and dashboard, not just a CLI.** Pipe results into your tooling, or browse them in the separately hosted UI.
Limitations: CT only sees names that were issued a certificate. Names that live only in DNS are invisible here, which is why subidx complements rather than replaces broader-source tools.
@ -46,14 +46,14 @@ Limitations: CT only sees names that were issued a certificate. Names that live
3. **Parsing.** Entries are decoded just far enough to read the SAN list; everything else is discarded.
4. **Normalizing.** Names are lowercased, wildcards and trailing dots stripped, reserved names rejected, and each name assigned to its registered domain (apex) via the Public Suffix List.
5. **Storing.** A single writer inserts into an embedded Pebble store. Duplicates keep only the earliest date; nothing is deleted.
6. **Serving.** An HTTP API with rate limiting and health checks, plus the embedded dashboard.
6. **Serving.** An HTTP API with rate limiting and health checks. The dashboard is a separate frontend built from `frontend/` and served by any static host.
## Commands
| Command | What it does |
|---|---|
| `tail` | Watch CT logs and store names. Runs until you stop it. |
| `serve` | Serve the API and dashboard. Add `-no-tail` to serve without collecting. |
| `serve` | Serve the read-only search API and health endpoints. Add `-no-tail` to serve without collecting. |
| `stats` | Print total records and the top 10 domains. `-recount` fixes drifted counters. |
| `version` | Print the version. |

View file

@ -7,6 +7,9 @@
"": {
"name": "subidx-ui",
"version": "0.1.0",
"dependencies": {
"@vercel/analytics": "^2.0.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"svelte": "^5.0.0",
@ -461,7 +464,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -472,7 +475,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@ -483,7 +486,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -493,14 +496,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -920,7 +923,7 @@
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@ -979,21 +982,63 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@vercel/analytics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
"license": "MIT",
"peerDependencies": {
"@remix-run/react": "^2",
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"nuxt": ">= 3",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -1006,7 +1051,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -1016,7 +1061,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -1042,7 +1087,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -1062,7 +1107,7 @@
"version": "5.9.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz",
"integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/esbuild": {
@ -1111,14 +1156,14 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz",
"integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@ -1169,7 +1214,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@ -1179,14 +1224,14 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@ -1371,7 +1416,7 @@
"version": "5.56.10",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
"integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -1550,7 +1595,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
}
}

View file

@ -14,5 +14,8 @@
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^7.0.0"
},
"dependencies": {
"@vercel/analytics": "^2.0.1"
}
}

View file

Before

Width:  |  Height:  |  Size: 572 B

After

Width:  |  Height:  |  Size: 572 B

Before After
Before After

View file

@ -1,7 +1,10 @@
import { mount } from 'svelte'
import { inject } from '@vercel/analytics'
import './app.css'
import App from './App.svelte'
const app = mount(App, { target: document.getElementById('app')! })
inject()
export default app

View file

@ -16,7 +16,6 @@ import (
"subidx/internal/apex"
"subidx/internal/store"
"subidx/internal/web"
)
type Server struct {
@ -80,7 +79,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/v1/feed", s.handleFeed)
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/readyz", s.handleReady)
mux.Handle("/", web.Handler())
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.hostAllowed(r.Host) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")

View file

@ -281,10 +281,10 @@ func TestHeadNotAllowed(t *testing.T) {
if po.Code != http.StatusMethodNotAllowed {
t.Errorf("post code = %d", po.Code)
}
// Unknown paths serve the embedded UI shell instead of a bare 404.
// Unknown paths are a bare 404; the UI is a separately hosted app now.
nf := do(t, s, http.MethodGet, "/nope")
if nf.Code != http.StatusOK || !strings.Contains(nf.Header().Get("Content-Type"), "text/html") {
t.Errorf("ui shell code = %d ct = %q", nf.Code, nf.Header().Get("Content-Type"))
if nf.Code != http.StatusNotFound {
t.Errorf("unknown path code = %d, want 404", nf.Code)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="subidx: self-hosted passive subdomain index from Certificate Transparency logs" />
<title>subidx</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script type="module" crossorigin src="/assets/index-B5egPO6h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LEhbGqLZ.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#12151a"/>
<path d="M9 8v16" stroke="#e2a54a" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="9" cy="8" r="3" fill="#e2a54a"/>
<path d="M9 14h7a3 3 0 0 1 3 3v3" stroke="#5b6472" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M9 21h11a3 3 0 0 1 3 3v0" stroke="#5b6472" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="20" r="2.4" fill="#8a919d"/>
<circle cx="23" cy="24" r="2.4" fill="#8a919d"/>
</svg>

Before

Width:  |  Height:  |  Size: 572 B

View file

@ -1,65 +0,0 @@
// Package web serves the dashboard UI embedded from ./dist (the Vite
// build output). Assets are immutable-cached by content hash; the HTML
// shell is never cached and ships a strict CSP.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var dist embed.FS
const csp = "default-src 'none'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
// Handler returns a handler serving the built UI. Paths that look like
// API routes but matched nothing fall through to a plain 404; anything
// else unknown serves the HTML shell so the app owns its own states.
func Handler() http.Handler {
files, err := fs.Sub(dist, "dist")
if err != nil {
panic("web: embedded dist missing: " + err.Error())
}
fileServer := http.FileServerFS(files)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("Method Not Allowed\n"))
return
}
p := path.Clean(r.URL.Path)
if strings.HasPrefix(p, "/v1/") || p == "/v1" || p == "/healthz" || p == "/readyz" {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(p, "/")
isShell := name == "" || name == "index.html"
if !isShell {
if _, err := fs.Stat(files, name); err != nil {
name = "index.html"
isShell = true
}
}
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
if isShell {
h.Set("Content-Security-Policy", csp)
h.Set("Cache-Control", "no-cache")
r.URL.Path = "/"
} else {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(w, r)
})
}

View file

@ -1,113 +0,0 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = "localhost"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestShellHeaders(t *testing.T) {
rec := get(t, Handler(), "/")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("content-type = %q", ct)
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "script-src 'self'") {
t.Errorf("csp = %q", csp)
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("missing nosniff")
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("cache-control = %q", cc)
}
if !strings.Contains(rec.Body.String(), `id="app"`) {
t.Error("body is not the app shell")
}
}
func TestAssetsImmutable(t *testing.T) {
files, err := fs.Sub(dist, "dist")
if err != nil {
t.Fatal(err)
}
asset := ""
err = fs.WalkDir(files, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if asset == "" && !d.IsDir() && strings.HasPrefix(d.Name(), "index-") &&
(strings.HasSuffix(p, ".js") || strings.HasSuffix(p, ".css")) {
asset = p
}
return nil
})
if err != nil {
t.Fatal(err)
}
if asset == "" {
t.Fatal("no built assets found under dist/assets")
}
h := Handler()
rec := get(t, h, "/"+asset)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("cache-control = %q", cc)
}
if strings.HasSuffix(asset, ".css") {
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
t.Errorf("css content-type = %q", ct)
}
} else {
// Go resolves .js via the OS mime database, which differs between
// platforms; both spellings are fine for browsers.
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("js content-type = %q", ct)
}
}
if csp := rec.Header().Get("Content-Security-Policy"); csp != "" {
t.Errorf("csp on hashed asset: %q", csp)
}
}
func TestFallbackAndReservedPaths(t *testing.T) {
h := Handler()
fb := get(t, h, "/some/deep/link")
if fb.Code != http.StatusOK || !strings.Contains(fb.Body.String(), `id="app"`) {
t.Errorf("fallback code = %d", fb.Code)
}
api := get(t, h, "/v1/nope")
if api.Code != http.StatusNotFound {
t.Errorf("/v1/nope code = %d, want 404", api.Code)
}
hz := get(t, h, "/healthz")
if hz.Code != http.StatusNotFound {
t.Errorf("/healthz code = %d, want 404 (server mux owns it)", hz.Code)
}
post := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, post)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("post / code = %d", rec.Code)
}
}