) : null}
-
+
>
)}
/>
diff --git a/branding/design-lab/README.md b/branding/design-lab/README.md
index 03d6a9c46..a5a6a81fa 100644
--- a/branding/design-lab/README.md
+++ b/branding/design-lab/README.md
@@ -81,13 +81,42 @@ session, a `STATIC_DIR` override and nil write-services so real Elasticsearch
stayed read-only. It went with the Go dashboard in `cb77cdf8` and is not
recoverable from here.
-The `frontend-next` equivalent — a dev server with the same read-only
-guarantees — still needs building. That is the remaining part of #1763 and is
-worth its own issue once #1753's approach is settled; `gen_palettes.py` has
-meanwhile been superseded upstream by `scripts/theme-tokens.mjs` and
-`check-contrast.mjs` in Xore/theme, which do the same job in CI over 422
-pairs. It is kept here as the record of how the palettes were derived, not as
-something to run.
+`lab.mjs` is the `frontend-next` equivalent (#1828). Run it from the repo
+root:
+
+```
+node branding/design-lab/lab.mjs # v5-picks-override.css on 19201
+node branding/design-lab/lab.mjs a.css b.css # two variants, side by side
+APIARY_BACKEND=http://10.8.0.2:8081 node branding/design-lab/lab.mjs
+```
+
+Variants take 19201-19205 and the elements playground is on 19300, the ports
+this lab has always used. A variant stylesheet is symlinked into the dev
+server's `public/static/lab/`, so saving the file and reloading the page is
+enough — no rebuild, and no change to the shipped vite config.
+
+The read-only guarantee is made at the one seam `frontend-next` has, since
+there is no service handle to pass nil to:
+
+| | in the lab |
+|---|---|
+| `BACKEND_URL` | a gate that forwards `GET`/`HEAD` and refuses everything else with 405 |
+| `BACKEND_MOUNTED_URL` | absent — every request 503s |
+
+That is not decoration. The Rust tier really exposes `generic_delete`,
+`preferences::put`, the honeyfs implant writer and the canarytoken minter, so
+a reviewer clicking around a variant would otherwise delete real documents and
+mint real tokens against live infrastructure. `lab.test.mjs` drives the actual
+harness against a recording stand-in backend and fails if a write reaches it;
+it runs in CI as `Design lab is read-only`.
+
+`OIDC_DISABLED=1` supplies the stubbed session, so there is no login
+round-trip per variant per page.
+
+`gen_palettes.py` has meanwhile been superseded upstream by
+`scripts/theme-tokens.mjs` and `check-contrast.mjs` in Xore/theme, which do the
+same job in CI over 422 pairs. It is kept here as the record of how the
+palettes were derived, not as something to run.
## Related
diff --git a/branding/design-lab/lab.mjs b/branding/design-lab/lab.mjs
new file mode 100644
index 000000000..f98cbd906
--- /dev/null
+++ b/branding/design-lab/lab.mjs
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+// The design lab's variant harness (#1828).
+//
+// Reviewing a theme against fixtures tells you the theme looks fine against
+// fixtures. Both previous design refreshes were run against real captured
+// data and that is what shipped, so #1753 wants the nine themes drafted the
+// same way -- which needs a dashboard pointed at the real Elasticsearch that
+// structurally cannot write to it.
+//
+// The Go harness this replaces (dev_serve_test.go, lost with cb77cdf8) got
+// that by constructing the server with nil write-services. frontend-next has
+// no such handle: it reaches data over HTTP through two bases, and the
+// mounted one is write-capable by definition (see backend.server.ts). So the
+// same guarantee is made at that seam instead --
+//
+// BACKEND_URL -> a gate that forwards GET/HEAD and refuses the
+// rest with 405, so a delete is a visible failure
+// BACKEND_MOUNTED_URL -> nothing at all; every request 503s
+//
+// -- which is stronger than the original rather than weaker: it is enforced
+// per request at runtime, not by remembering to pass nil.
+//
+// This matters concretely. The Rust tier really does expose stores.rs's
+// generic_delete, preferences.rs's put, the honeyfs implant writer and the
+// canarytoken minter. A reviewer clicking around a variant would otherwise
+// delete real documents and mint real tokens against live infrastructure.
+//
+// Usage:
+// node branding/design-lab/lab.mjs # v5-picks-override.css
+// node branding/design-lab/lab.mjs a.css b.css # two variants, side by side
+// APIARY_BACKEND=http://10.8.0.2:8081 node .../lab.mjs # against the homeserver
+//
+// Variants land on 19201+, the elements playground on 19300 -- the ports the
+// lab has always used, so the review log's links keep resolving.
+
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { createReadStream, existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
+import { dirname, extname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const LAB = dirname(fileURLToPath(import.meta.url))
+const FRONTEND = resolve(LAB, '../../arcane/home/honeypot-dashboard/frontend-next')
+const PLAYGROUND = join(LAB, 'playground')
+
+// Where variant stylesheets are exposed to the browser. vite serves public/
+// at the root, so a symlink here is reachable at /static/lab/.css with
+// no rebuild and no vite config change -- and because it is a symlink, saving
+// the source file and reloading is enough to see the change. The directory is
+// disposable and gitignored; the lab clears it on every start.
+const LINK_DIR = join(FRONTEND, 'public/static/lab')
+
+const GATE_PORT = 19199 // read-only door to the real backend
+const ABSENT_PORT = 19198 // the write-capable backend, deliberately not here
+const FIRST_VARIANT_PORT = 19201
+const MAX_VARIANTS = 5 // 19201-19205, the documented range
+const PLAYGROUND_PORT = 19300
+
+const REAL_BACKEND = (process.env.APIARY_BACKEND ?? 'http://127.0.0.1:8081').replace(/\/$/, '')
+const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+}
+
+const children = []
+
+function log(scope, message) {
+ process.stdout.write(`[lab:${scope}] ${message}\n`)
+}
+
+/**
+ * The read-only door. Anything that is not a read is refused here rather
+ * than reaching Elasticsearch, and the refusal is printed -- a variant that
+ * quietly stopped working because the lab blocked a write is worse than one
+ * that says so.
+ */
+function startGate() {
+ const server = createServer(async (req, res) => {
+ if (!READ_METHODS.has(req.method ?? '')) {
+ log('gate', `REFUSED ${req.method} ${req.url} -- the lab is read-only`)
+ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET, HEAD' })
+ res.end(
+ JSON.stringify({
+ error: 'design lab is read-only',
+ detail: `${req.method} ${req.url} was not forwarded. Real captured data is not a scratch pad.`,
+ }),
+ )
+ return
+ }
+ try {
+ const upstream = await fetch(`${REAL_BACKEND}${req.url}`, {
+ method: req.method,
+ headers: { ...req.headers, host: new URL(REAL_BACKEND).host },
+ })
+ res.writeHead(upstream.status, Object.fromEntries(upstream.headers))
+ if (upstream.body) {
+ const reader = upstream.body.getReader()
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ res.write(value)
+ }
+ }
+ res.end()
+ } catch (error) {
+ log('gate', `upstream ${REAL_BACKEND} unreachable: ${error.message}`)
+ res.writeHead(502, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ error: 'backend unreachable', detail: error.message }))
+ }
+ })
+ server.listen(GATE_PORT, '127.0.0.1', () => log('gate', `read-only -> ${REAL_BACKEND} on :${GATE_PORT}`))
+ return server
+}
+
+/**
+ * The write-capable tier, absent. Returning 503 rather than leaving the port
+ * closed is deliberate: a connection refused reads as "the lab is broken",
+ * a 503 with this body reads as "this is not something the lab has".
+ */
+function startAbsentBackend() {
+ const server = createServer((req, res) => {
+ log('absent', `${req.method} ${req.url} -- no write-capable backend in the lab`)
+ res.writeHead(503, { 'content-type': 'application/json' })
+ res.end(
+ JSON.stringify({
+ error: 'write-capable backend absent',
+ detail: 'The design lab runs without backend-service-mounted by design. Sandbox, Ghidra and analysis submits do not exist here.',
+ }),
+ )
+ })
+ server.listen(ABSENT_PORT, '127.0.0.1', () => log('absent', `write tier absent on :${ABSENT_PORT}`))
+ return server
+}
+
+function startStatic(root, port, scope) {
+ const server = createServer((req, res) => {
+ const requested = decodeURIComponent((req.url ?? '/').split('?')[0])
+ const path = join(root, requested === '/' ? 'index.html' : requested)
+ if (!path.startsWith(root) || !existsSync(path)) {
+ res.writeHead(404, { 'content-type': 'text/plain' })
+ res.end('not found')
+ return
+ }
+ res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' })
+ createReadStream(path).pipe(res)
+ })
+ server.listen(port, '127.0.0.1', () => log(scope, `http://127.0.0.1:${port}/`))
+ return server
+}
+
+function startVariant(cssFile, port) {
+ const source = resolve(LAB, cssFile)
+ if (!existsSync(source)) {
+ log('variant', `no such stylesheet: ${source}`)
+ return null
+ }
+ const linked = join(LINK_DIR, cssFile.replace(/\//g, '_'))
+ symlinkSync(source, linked)
+ const href = `/static/lab/${cssFile.replace(/\//g, '_')}`
+
+ const child = spawn('npx', ['vite', 'dev', '--port', String(port), '--strictPort'], {
+ cwd: FRONTEND,
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ VARIANT_CSS: href,
+ // No login round-trip per variant per page -- the reviewer is looking
+ // at type and colour, not at Keycloak.
+ OIDC_DISABLED: '1',
+ SERVE_MODE: 'all',
+ BACKEND_URL: `http://127.0.0.1:${GATE_PORT}`,
+ BACKEND_MOUNTED_URL: `http://127.0.0.1:${ABSENT_PORT}`,
+ },
+ })
+ children.push(child)
+ log('variant', `${cssFile} -> http://127.0.0.1:${port}/ (css at ${href})`)
+ return child
+}
+
+function main() {
+ const requested = process.argv.slice(2)
+ const variants = requested.length ? requested : ['v5-picks-override.css']
+ if (variants.length > MAX_VARIANTS) {
+ log('lab', `at most ${MAX_VARIANTS} variants (ports ${FIRST_VARIANT_PORT}-${FIRST_VARIANT_PORT + MAX_VARIANTS - 1})`)
+ process.exit(2)
+ }
+
+ rmSync(LINK_DIR, { recursive: true, force: true })
+ mkdirSync(LINK_DIR, { recursive: true })
+
+ const servers = [startGate(), startAbsentBackend(), startStatic(PLAYGROUND, PLAYGROUND_PORT, 'playground')]
+ variants.forEach((css, index) => startVariant(css, FIRST_VARIANT_PORT + index))
+
+ const shutdown = () => {
+ for (const child of children) child.kill('SIGTERM')
+ for (const server of servers) server.close()
+ rmSync(LINK_DIR, { recursive: true, force: true })
+ process.exit(0)
+ }
+ process.on('SIGINT', shutdown)
+ process.on('SIGTERM', shutdown)
+}
+
+// Exported for the harness's own test; main() only runs when invoked directly.
+export { READ_METHODS, GATE_PORT, ABSENT_PORT, FIRST_VARIANT_PORT, MAX_VARIANTS, PLAYGROUND_PORT }
+
+if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
+ main()
+}
diff --git a/branding/design-lab/lab.test.mjs b/branding/design-lab/lab.test.mjs
new file mode 100644
index 000000000..e97a67a83
--- /dev/null
+++ b/branding/design-lab/lab.test.mjs
@@ -0,0 +1,79 @@
+// The design lab's read-only guarantee, asserted against the real harness
+// process rather than by reading its source (#1828).
+//
+// This is the requirement the whole tool exists to satisfy: the lab points a
+// dashboard at real captured Elasticsearch data, so "we won't call the write
+// paths" is not good enough. A stand-in backend records everything that
+// reaches it, and the test fails if a write ever does.
+//
+// node --test branding/design-lab/lab.test.mjs
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+const LAB = join(dirname(fileURLToPath(import.meta.url)), 'lab.mjs')
+
+// Stand in for the real Rust backend so the test needs no infrastructure --
+// and so a write reaching it is observable rather than merely unlikely.
+const reachedUpstream = []
+const upstream = createServer((req, res) => {
+ reachedUpstream.push(`${req.method} ${req.url}`)
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end('{"ok":true}')
+})
+await new Promise((r) => upstream.listen(19099, '127.0.0.1', r))
+
+// The real harness, not a re-creation of it -- named with a stylesheet that
+// does not exist so it starts its servers without spawning vite.
+const child = spawn('node', [LAB, 'definitely-not-a-real-variant.css'], {
+ env: { ...process.env, APIARY_BACKEND: 'http://127.0.0.1:19099' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+})
+let out = ''
+child.stdout.on('data', (d) => (out += d))
+child.stderr.on('data', (d) => (out += d))
+
+await new Promise((r) => setTimeout(r, 1500))
+
+// 1. A read is forwarded.
+const read = await fetch('http://127.0.0.1:19199/api/v1/source-health')
+test('a read is forwarded to the backend', () => {
+ assert.equal(read.status, 200)
+ assert.deepEqual(reachedUpstream, ['GET /api/v1/source-health'])
+})
+
+// 2. A delete is refused *and never reaches upstream* -- the whole point.
+const before = reachedUpstream.length
+const del = await fetch('http://127.0.0.1:19199/api/v1/stores/events/abc123', { method: 'DELETE' })
+test('a delete is refused', () => assert.equal(del.status, 405))
+test('the delete never reached the backend', () => assert.equal(reachedUpstream.length, before))
+
+// 3. A preferences PUT, likewise.
+const put = await fetch('http://127.0.0.1:19199/api/v1/preferences', { method: 'PUT', body: '{}' })
+test('a preferences write is refused', () => assert.equal(put.status, 405))
+test('the preferences write never reached the backend', () => assert.equal(reachedUpstream.length, before))
+
+// 4. The write-capable tier is absent, not merely unused.
+const mounted = await fetch('http://127.0.0.1:19198/api/v1/sandbox/submit', { method: 'POST' })
+const mountedBody = await mounted.json()
+test('the write-capable tier is absent, not merely unused', () => {
+ assert.equal(mounted.status, 503)
+ assert.equal(mountedBody.error, 'write-capable backend absent')
+})
+
+// 5. Even a GET to the mounted tier finds nothing -- absence is total.
+const mountedGet = await fetch('http://127.0.0.1:19198/api/v1/sandbox/status')
+test('the write-capable tier is absent for reads too', () => assert.equal(mountedGet.status, 503))
+
+// 6. The playground is served on its documented port.
+const playground = await fetch('http://127.0.0.1:19300/elements.html')
+test('the elements playground is on its documented port', () => assert.equal(playground.status, 200))
+
+test.after(() => {
+ child.kill('SIGTERM')
+ upstream.close()
+ if (process.env.LAB_TEST_VERBOSE) console.log(out.trim())
+})
diff --git a/scripts/check-store-sorts.py b/scripts/check-store-sorts.py
new file mode 100644
index 000000000..68b962598
--- /dev/null
+++ b/scripts/check-store-sorts.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Check every store list's sort field against the live Elasticsearch mapping.
+
+A store list is paged with `sort: [{field: {..., unmapped_type: ...}}]`. That
+`unmapped_type` exists so a deployment where the index has not been created
+yet gets an empty list instead of an error -- but it also means a field the
+documents do not actually have sorts every hit as null rather than failing.
+The list then comes back in no order at all, and nothing anywhere says so.
+
+Three shipped that way (#1566): ml-anomalies asked for `timestamp` where the
+worker writes `@timestamp`, auth-events asked for `last_seen` where Keycloak
+writes `@timestamp`, and static-analysis asked for `Analysis.GeneratedUTC`,
+which its documents do not carry in any spelling.
+
+CI cannot see Elasticsearch, so this is not a CI check -- run it against a
+real cluster after a deploy, or when adding a store:
+
+ ssh homeserver "docker exec hp-elasticsearch curl -s \\
+ 'http://localhost:9200/_mapping'" > mappings.json
+ python scripts/check-store-sorts.py mappings.json
+
+Exits non-zero if any store's sort field is missing from an index that
+exists. An index that does not exist on this deployment is reported and
+skipped -- that is the case `unmapped_type` is legitimately for.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+STORES_RS = ROOT / "arcane/home/honeypot-dashboard/backend-service/src/stores.rs"
+
+# ("index", "sort field", "type", ...) in the generic store table, and the
+# direct store_page(&state, &["index"], "field", "type", ...) callers.
+TABLE_ENTRY = re.compile(
+ r'=>\s*\(\s*"(?P[^"]+)"\s*,\s*"(?P[^"]+)"\s*,\s*"(?P[^"]+)"',
+)
+DIRECT_CALL = re.compile(
+ r'store_page(?:_excluding)?\(\s*&state,\s*&\[\s*"(?P[^"]+)"\s*\]\s*,'
+ r'\s*"(?P[^"]+)"\s*,\s*"(?P[^"]+)"',
+)
+
+
+def declared_sorts(source: str) -> list[tuple[str, str, str]]:
+ seen: dict[tuple[str, str], str] = {}
+ for pattern in (TABLE_ENTRY, DIRECT_CALL):
+ for match in pattern.finditer(source):
+ seen[(match["index"], match["field"])] = match["kind"]
+ return sorted((index, field, kind) for (index, field), kind in seen.items())
+
+
+def field_type(properties: dict, dotted: str) -> str | None:
+ """Resolve a possibly-dotted field path through a mapping's properties."""
+ cursor = properties
+ parts = dotted.split(".")
+ for depth, part in enumerate(parts):
+ if part not in cursor:
+ return None
+ node = cursor[part]
+ if depth == len(parts) - 1:
+ return node.get("type", "object")
+ cursor = node.get("properties", {})
+ return None
+
+
+def indices_matching(mappings: dict, name: str) -> list[tuple[str, dict]]:
+ """Concrete indices behind a name, which may be an alias or a datastream."""
+ exact = [(key, value) for key, value in mappings.items() if key == name]
+ if exact:
+ return exact
+ # A datastream-backed name appears as .ds---.
+ return [(key, value) for key, value in mappings.items() if key.startswith(f".ds-{name}-")]
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(__doc__, file=sys.stderr)
+ return 2
+ mappings = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
+
+ problems: list[str] = []
+ skipped: list[str] = []
+ checked = 0
+
+ for index, field, kind in declared_sorts(STORES_RS.read_text(encoding="utf-8")):
+ concrete = indices_matching(mappings, index)
+ if not concrete:
+ skipped.append(f"{index}: not present on this deployment (sort={field})")
+ continue
+ for name, body in concrete:
+ actual = field_type(body.get("mappings", {}).get("properties", {}), field)
+ checked += 1
+ if actual is None:
+ problems.append(
+ f"{name}: sort field {field!r} is not in the mapping -- "
+ "every hit sorts as null and the list is unordered"
+ )
+ elif actual != kind and not (actual, kind) in {("float", "double"), ("integer", "long"), ("half_float", "double")}:
+ problems.append(
+ f"{name}: sort field {field!r} is mapped as {actual!r} but "
+ f"declared as {kind!r} -- unmapped_type must match the real type"
+ )
+
+ for line in skipped:
+ print(f" skipped: {line}")
+ if problems:
+ print(f"\nStore sort check failed ({checked} checked):", file=sys.stderr)
+ for problem in sorted(set(problems)):
+ print(f" - {problem}", file=sys.stderr)
+ return 1
+ print(f"\nStore sort check passed ({checked} sort fields verified against live mappings).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())