@crvouga/mockingbird-service-formbricks 1.7.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
183
215
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
216
  },
185
217
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
218
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
219
+ "POST /clock": ({ body, namespace }) => {
220
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
221
  if (!isRecord(body))
189
222
  return adminError(400, "expected a JSON object");
190
223
  if (body.reset === true)
191
- context.clock.reset();
224
+ clock.reset();
192
225
  if (body.freeze === true)
193
- context.clock.freeze();
226
+ clock.freeze();
194
227
  if (body.set !== void 0) {
195
228
  const instant = parseInstant(body.set);
196
229
  if (instant === void 0)
197
230
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
231
+ clock.set(instant);
199
232
  }
200
233
  if (body.advance !== void 0) {
201
234
  const delta = parseDuration(body.advance);
202
235
  if (delta === void 0)
203
236
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
237
+ clock.advance(delta);
205
238
  }
206
239
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
240
+ clock.unfreeze();
241
+ return json(200, clock.state());
209
242
  },
210
243
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
244
  "POST /faults": ({ body, namespace }) => {
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
338
371
  return json(200, { status: "ok" });
339
372
  }
340
373
  };
341
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
342
382
  const space = key.indexOf(" ");
343
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
344
384
  });
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
346
386
  namespaceOf: headerNamespace,
347
387
  async handle(request) {
348
388
  const url = new URL(request.url);
349
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
350
- return json(200, {
351
- status: "ok",
352
- service: context.name,
353
- uptimeMs: context.wallNow() - context.startedAt,
354
- clock: context.clock.state(),
355
- namespaces: context.namespaces().length,
356
- adminUi: `${ADMIN_PREFIX}/ui`,
357
- ...context.describe()
358
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
359
391
  }
360
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
361
393
  return void 0;
362
394
  }
363
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
364
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
366
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
368
400
  for (const route of routes) {
369
401
  if (route.method !== request.method)
370
402
  continue;
371
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
372
409
  if (!params)
373
410
  continue;
374
411
  let body;
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
377
414
  } catch {
378
415
  return adminError(400, "request body is not valid JSON");
379
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
380
420
  return route.handler({
381
421
  request,
382
422
  url,
383
423
  params,
384
- namespace: adminNamespace(request, url),
424
+ namespace,
385
425
  body
386
426
  });
387
427
  }
388
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
389
429
  }
390
430
  };
391
431
  };
@@ -467,7 +507,33 @@ var faultResponse = (rule) => {
467
507
  };
468
508
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
469
509
  const entries = [];
510
+ let isolated = false;
511
+ const scopedRngs = /* @__PURE__ */ new Map();
512
+ const random = (namespace) => {
513
+ if (!isolated || namespace === "default")
514
+ return rng;
515
+ let scoped = scopedRngs.get(namespace);
516
+ if (!scoped) {
517
+ scoped = createRng(rng.seed);
518
+ scopedRngs.set(namespace, scoped);
519
+ }
520
+ return scoped;
521
+ };
470
522
  return {
523
+ isolateNamespaces() {
524
+ isolated = true;
525
+ },
526
+ snapshot(namespace) {
527
+ return {
528
+ rules: structuredClone(entries.map((entry, position) => ({
529
+ ...entry.rule,
530
+ remaining: entry.remaining,
531
+ hits: entry.hits,
532
+ position
533
+ })).filter((entry) => entry.namespace === namespace)),
534
+ rngState: random(namespace).state()
535
+ };
536
+ },
471
537
  add(rule) {
472
538
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
473
539
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -487,6 +553,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
487
553
  },
488
554
  clear() {
489
555
  entries.length = 0;
556
+ rng.reset();
557
+ scopedRngs.clear();
558
+ },
559
+ restore(namespace, state) {
560
+ for (let i = entries.length - 1; i >= 0; i--)
561
+ if (entries[i]?.rule.namespace === namespace)
562
+ entries.splice(i, 1);
563
+ for (const { remaining, hits, position, ...rule } of state.rules)
564
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
565
+ random(namespace).setState(state.rngState);
490
566
  },
491
567
  async take(candidate) {
492
568
  const hits = [];
@@ -496,7 +572,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
496
572
  if (!matches(entry.rule, candidate))
497
573
  continue;
498
574
  const rate = entry.rule.rate ?? 1;
499
- if (rng.next() >= rate)
575
+ if (random(candidate.namespace).next() >= rate)
500
576
  continue;
501
577
  entry.hits++;
502
578
  if (entry.remaining !== null)
@@ -873,7 +949,8 @@ var annotateResponse = (response, extra) => {
873
949
  var responseNotes = (response) => notes.get(response);
874
950
 
875
951
  // ../core/dist/metrics.js
876
- var createMetrics = () => {
952
+ var createMetrics = (partition = true) => {
953
+ const namespaces = /* @__PURE__ */ new Map();
877
954
  let requests = 0;
878
955
  let faults = 0;
879
956
  let totalDurationMs = 0;
@@ -881,6 +958,14 @@ var createMetrics = () => {
881
958
  const unmatched = /* @__PURE__ */ new Map();
882
959
  return {
883
960
  record(entry) {
961
+ if (partition) {
962
+ let scoped = namespaces.get(entry.namespace);
963
+ if (!scoped) {
964
+ scoped = createMetrics(false);
965
+ namespaces.set(entry.namespace, scoped);
966
+ }
967
+ scoped.record(entry);
968
+ }
884
969
  requests++;
885
970
  totalDurationMs += entry.durationMs;
886
971
  if (entry.faultId !== void 0)
@@ -892,7 +977,7 @@ var createMetrics = () => {
892
977
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
893
978
  }
894
979
  },
895
- report: () => ({
980
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
896
981
  requests,
897
982
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
898
983
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -901,8 +986,13 @@ var createMetrics = () => {
901
986
  }),
902
987
  faults,
903
988
  totalDurationMs
904
- }),
905
- reset() {
989
+ },
990
+ reset(namespace) {
991
+ if (namespace !== void 0) {
992
+ namespaces.delete(namespace);
993
+ return;
994
+ }
995
+ namespaces.clear();
906
996
  requests = 0;
907
997
  faults = 0;
908
998
  totalDurationMs = 0;
@@ -1515,6 +1605,34 @@ dialog:not([open]) {
1515
1605
  function isRecord2(value) {
1516
1606
  return typeof value === "object" && value !== null && !Array.isArray(value);
1517
1607
  }
1608
+ function highlightJsonText(text) {
1609
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1610
+ switch (char) {
1611
+ case "&":
1612
+ return "&amp;";
1613
+ case "<":
1614
+ return "&lt;";
1615
+ case ">":
1616
+ return "&gt;";
1617
+ case '"':
1618
+ return "&quot;";
1619
+ default:
1620
+ return "&#39;";
1621
+ }
1622
+ });
1623
+ const token = /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:|"(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
1624
+ let cursor = 0;
1625
+ let html = "";
1626
+ for (const match of text.matchAll(token)) {
1627
+ const index = match.index ?? 0;
1628
+ const value = match[0];
1629
+ html += escapeToken(text.slice(cursor, index));
1630
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1631
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1632
+ cursor = index + value.length;
1633
+ }
1634
+ return html + escapeToken(text.slice(cursor));
1635
+ }
1518
1636
  function mountAdminBrand(doc, host, service, catalog) {
1519
1637
  const clip = (value, max) => {
1520
1638
  if (typeof value !== "string")
@@ -1876,14 +1994,16 @@ function bootAdmin(config) {
1876
1994
  const state = {
1877
1995
  namespace: "default",
1878
1996
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1879
- view: "overview",
1997
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1880
1998
  collection: "",
1881
1999
  shape: null,
1882
2000
  route: null,
2001
+ routes: [],
1883
2002
  editing: null
1884
2003
  };
1885
2004
  const standard = new Set(config.standardRoutes);
1886
2005
  const banner = el("banner", HTMLElement);
2006
+ const bannerMessage = el("banner-message", HTMLElement);
1887
2007
  const keyInput = el("admin-key", HTMLInputElement);
1888
2008
  keyInput.value = state.key;
1889
2009
  const params = new URLSearchParams(location.search);
@@ -1894,18 +2014,21 @@ function bootAdmin(config) {
1894
2014
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1895
2015
  params.delete("key");
1896
2016
  const next = params.toString();
1897
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2017
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1898
2018
  }
1899
2019
  const queryNamespace = params.get("namespace");
1900
2020
  if (queryNamespace)
1901
2021
  state.namespace = queryNamespace;
1902
2022
  const showError = (error2) => {
1903
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
2023
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
1904
2024
  banner.classList.add("show");
2025
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1905
2026
  };
1906
2027
  const clearError = () => {
1907
2028
  banner.classList.remove("show");
2029
+ bannerMessage.textContent = "";
1908
2030
  };
2031
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1909
2032
  const api = async (method, path, body) => {
1910
2033
  const headers = { accept: "application/json" };
1911
2034
  if (state.key !== "")
@@ -1949,16 +2072,21 @@ function bootAdmin(config) {
1949
2072
  namespace: () => state.namespace
1950
2073
  };
1951
2074
  const isPanelScript = (value) => typeof value === "function";
2075
+ const themeButton = el("theme", HTMLButtonElement);
1952
2076
  const setTheme = (theme) => {
1953
2077
  if (theme)
1954
2078
  document.documentElement.dataset.theme = theme;
1955
2079
  else
1956
2080
  delete document.documentElement.dataset.theme;
2081
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2082
+ themeButton.innerHTML = dark ? '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="3.2" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v1.5M10 16.5V18M18 10h-1.5M3.5 10H2M15.7 4.3l-1 1M5.3 14.7l-1 1M15.7 15.7l-1-1M5.3 5.3l-1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M16.8 12.2A7 7 0 0 1 7.8 3.2a7 7 0 1 0 9 9Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
2083
+ const label = dark ? "Use light theme" : "Use dark theme";
2084
+ themeButton.setAttribute("aria-label", label);
2085
+ themeButton.title = label;
1957
2086
  };
1958
2087
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1959
- if (storedTheme)
1960
- setTheme(storedTheme);
1961
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2088
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2089
+ themeButton.addEventListener("click", () => {
1962
2090
  const current = document.documentElement.dataset.theme;
1963
2091
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1964
2092
  const next = dark ? "light" : "dark";
@@ -1977,6 +2105,7 @@ function bootAdmin(config) {
1977
2105
  continue;
1978
2106
  section.hidden = section.id !== `view-${view}`;
1979
2107
  }
2108
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1980
2109
  };
1981
2110
  el("nav", HTMLElement).addEventListener("click", (event) => {
1982
2111
  const target = event.target;
@@ -1991,7 +2120,7 @@ function bootAdmin(config) {
1991
2120
  show(view);
1992
2121
  refresh().catch(showError);
1993
2122
  });
1994
- const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
2123
+ const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
1995
2124
  const table = (columns, rows) => {
1996
2125
  if (rows.length === 0)
1997
2126
  return '<p class="empty">Nothing here yet.</p>';
@@ -2001,16 +2130,22 @@ function bootAdmin(config) {
2001
2130
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2002
2131
  return `<tr${attrs}>${cells}</tr>`;
2003
2132
  }).join("");
2004
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2133
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2005
2134
  };
2006
2135
  const preview = (value) => {
2007
2136
  const text = JSON.stringify(value) ?? "null";
2008
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2137
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2138
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2009
2139
  };
2010
2140
  const when = (epoch) => {
2011
2141
  const date = new Date(epoch);
2012
2142
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2013
2143
  };
2144
+ const journalWhen = (value) => {
2145
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2146
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2147
+ return Number.isFinite(epoch) ? when(epoch) : value;
2148
+ };
2014
2149
  const clockCard = (epoch) => {
2015
2150
  const date = new Date(epoch);
2016
2151
  if (Number.isNaN(date.getTime()))
@@ -2130,7 +2265,7 @@ function bootAdmin(config) {
2130
2265
  };
2131
2266
  const renderOverview = async () => {
2132
2267
  const [health, shape, clock, journal] = await Promise.all([
2133
- originGet("/health"),
2268
+ originGet(`${config.adminPrefix}/health`),
2134
2269
  api("GET", "/state"),
2135
2270
  api("GET", "/clock"),
2136
2271
  api("GET", "/requests?limit=8")
@@ -2147,7 +2282,7 @@ function bootAdmin(config) {
2147
2282
  },
2148
2283
  {
2149
2284
  k: "Status",
2150
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2285
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2151
2286
  },
2152
2287
  { k: "Namespace", v: esc(state.namespace) },
2153
2288
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2234,26 +2369,46 @@ function bootAdmin(config) {
2234
2369
  const renderJournal = async () => {
2235
2370
  const journal = await api("GET", "/requests?limit=100");
2236
2371
  el("journal", HTMLElement).innerHTML = table([
2237
- { label: "When", cell: (row) => esc(row.at) },
2238
- { label: "Method", cell: (row) => esc(row.method) },
2372
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2373
+ {
2374
+ label: "Method",
2375
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2376
+ },
2239
2377
  { label: "Path", cell: (row) => esc(row.path) },
2240
- { label: "Status", cell: (row) => esc(row.status) },
2378
+ {
2379
+ label: "Status",
2380
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2381
+ },
2241
2382
  { label: "ms", cell: (row) => esc(row.durationMs) }
2242
2383
  ], decodeJournal(journal));
2243
2384
  };
2244
- const renderRoutes = async () => {
2245
- const data = await api("GET", "/");
2246
- if (!isRecord2(data))
2247
- throw new Error("Route list was not an object");
2248
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2385
+ const paintRoutes = () => {
2386
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2387
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2388
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2389
+ if (state.routes.length === 0) {
2390
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2391
+ return;
2392
+ }
2393
+ if (routes.length === 0) {
2394
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2395
+ return;
2396
+ }
2249
2397
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2250
2398
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2251
2399
  const parts = route.split(" ");
2252
2400
  const method = parts[0] ?? "";
2253
2401
  const path = parts.slice(1).join(" ");
2254
- return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}">Use</button></div>`;
2402
+ return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}" aria-label="Use ${esc(route)}">Use</button></div>`;
2255
2403
  }).join("");
2256
2404
  };
2405
+ const renderRoutes = async () => {
2406
+ const data = await api("GET", "/");
2407
+ if (!isRecord2(data))
2408
+ throw new Error("Route list was not an object");
2409
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2410
+ paintRoutes();
2411
+ };
2257
2412
  const renderers = {
2258
2413
  overview: renderOverview,
2259
2414
  state: renderState,
@@ -2262,19 +2417,50 @@ function bootAdmin(config) {
2262
2417
  journal: renderJournal,
2263
2418
  routes: renderRoutes
2264
2419
  };
2420
+ let refreshToken = 0;
2265
2421
  async function refresh() {
2422
+ const token = ++refreshToken;
2266
2423
  clearError();
2424
+ const main = el("views", HTMLElement);
2425
+ const sync = el("sync-status", HTMLElement);
2426
+ main.setAttribute("aria-busy", "true");
2427
+ sync.classList.add("busy");
2428
+ sync.textContent = "Updating";
2267
2429
  const render = renderers[state.view];
2268
- if (render)
2269
- await render();
2430
+ try {
2431
+ if (render)
2432
+ await render();
2433
+ if (token === refreshToken)
2434
+ sync.textContent = "Up to date";
2435
+ } finally {
2436
+ if (token === refreshToken) {
2437
+ main.removeAttribute("aria-busy");
2438
+ sync.classList.remove("busy");
2439
+ }
2440
+ }
2270
2441
  }
2271
2442
  const delay = (ms) => new Promise((resolve) => {
2272
2443
  window.setTimeout(resolve, ms);
2273
2444
  });
2274
- const confirmAction = async (button, pending, done, work) => {
2445
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2275
2446
  if (button.dataset.pending === "1")
2276
2447
  return;
2277
2448
  const label = button.textContent ?? "";
2449
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2450
+ button.dataset.confirming = "1";
2451
+ button.classList.add("confirming");
2452
+ button.textContent = confirmLabel;
2453
+ window.setTimeout(() => {
2454
+ if (button.dataset.confirming !== "1")
2455
+ return;
2456
+ button.textContent = label;
2457
+ button.classList.remove("confirming");
2458
+ delete button.dataset.confirming;
2459
+ }, 3e3);
2460
+ return;
2461
+ }
2462
+ button.classList.remove("confirming");
2463
+ delete button.dataset.confirming;
2278
2464
  const width = button.offsetWidth;
2279
2465
  button.dataset.pending = "1";
2280
2466
  button.classList.add("pressed");
@@ -2295,6 +2481,65 @@ function bootAdmin(config) {
2295
2481
  delete button.dataset.pending;
2296
2482
  }
2297
2483
  };
2484
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2485
+ const textarea = el(textareaId, HTMLTextAreaElement);
2486
+ const highlight = el(highlightId, HTMLElement);
2487
+ const status = el(statusId, HTMLElement);
2488
+ const shell = el(shellId, HTMLElement);
2489
+ const sync = () => {
2490
+ const text = textarea.value;
2491
+ highlight.innerHTML = `${highlightJsonText(text)}
2492
+ `;
2493
+ let valid = true;
2494
+ let message = "Valid JSON";
2495
+ if (allowEmpty && text.trim() === "")
2496
+ message = "No body";
2497
+ else {
2498
+ try {
2499
+ readJson2(text);
2500
+ } catch {
2501
+ valid = false;
2502
+ message = "Invalid JSON";
2503
+ }
2504
+ }
2505
+ shell.classList.toggle("invalid", !valid);
2506
+ status.classList.toggle("invalid", !valid);
2507
+ status.textContent = message;
2508
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2509
+ };
2510
+ const syncScroll = () => {
2511
+ const preview2 = highlight.parentElement;
2512
+ if (!(preview2 instanceof HTMLElement))
2513
+ return;
2514
+ preview2.scrollTop = textarea.scrollTop;
2515
+ preview2.scrollLeft = textarea.scrollLeft;
2516
+ };
2517
+ textarea.addEventListener("input", sync);
2518
+ textarea.addEventListener("scroll", syncScroll);
2519
+ sync();
2520
+ return sync;
2521
+ };
2522
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2523
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2524
+ for (const button of document.querySelectorAll("[data-format]")) {
2525
+ if (!(button instanceof HTMLButtonElement))
2526
+ continue;
2527
+ button.addEventListener("click", () => {
2528
+ const target = button.dataset.format;
2529
+ if (target === void 0)
2530
+ return;
2531
+ const textarea = document.getElementById(target);
2532
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2533
+ return;
2534
+ try {
2535
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2536
+ textarea.dispatchEvent(new Event("input"));
2537
+ textarea.focus();
2538
+ } catch {
2539
+ showError(new Error("Fix the JSON before formatting it"));
2540
+ }
2541
+ });
2542
+ }
2298
2543
  document.addEventListener("click", (event) => {
2299
2544
  const target = event.target;
2300
2545
  if (!(target instanceof Element))
@@ -2367,6 +2612,7 @@ function bootAdmin(config) {
2367
2612
  } else {
2368
2613
  editorValue.value = "{\n \n}";
2369
2614
  }
2615
+ syncRecordEditor();
2370
2616
  editor.showModal();
2371
2617
  }
2372
2618
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2396,10 +2642,10 @@ function bootAdmin(config) {
2396
2642
  const id = state.editing;
2397
2643
  if (!id)
2398
2644
  return;
2399
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2645
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2400
2646
  editor.close();
2401
2647
  return renderState();
2402
- }).catch(showError);
2648
+ }), "Confirm delete").catch(showError);
2403
2649
  });
2404
2650
  for (const button of document.querySelectorAll("[data-advance]")) {
2405
2651
  if (!(button instanceof HTMLButtonElement))
@@ -2445,10 +2691,10 @@ function bootAdmin(config) {
2445
2691
  }).then(() => renderFaults()).catch(showError);
2446
2692
  });
2447
2693
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2448
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2694
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2449
2695
  });
2450
2696
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2451
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2697
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2452
2698
  });
2453
2699
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2454
2700
  const target = event.target;
@@ -2462,9 +2708,15 @@ function bootAdmin(config) {
2462
2708
  return;
2463
2709
  state.route = route;
2464
2710
  el("route-title", HTMLElement).textContent = route;
2711
+ el("route-send", HTMLButtonElement).disabled = false;
2465
2712
  const method = route.split(" ")[0] ?? "";
2466
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2713
+ const routeBody = el("route-body", HTMLTextAreaElement);
2714
+ routeBody.disabled = method === "GET" || method === "DELETE";
2715
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2716
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2717
+ syncRouteEditor();
2467
2718
  });
2719
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2468
2720
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2469
2721
  event.preventDefault();
2470
2722
  const send = async () => {
@@ -2486,7 +2738,8 @@ function bootAdmin(config) {
2486
2738
  }
2487
2739
  }
2488
2740
  const data = await api(method, concrete, body);
2489
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2741
+ const text = JSON.stringify(data, null, 2) ?? "null";
2742
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2490
2743
  };
2491
2744
  send().catch(showError);
2492
2745
  });
@@ -2604,11 +2857,13 @@ function bootAdmin(config) {
2604
2857
  views.append(section);
2605
2858
  mountSqlExplorer(body, panelApi);
2606
2859
  }
2860
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2607
2861
  return refresh();
2608
2862
  }).catch(showError);
2609
2863
  }
2610
2864
  var clientPrelude = [
2611
2865
  isRecord2,
2866
+ highlightJsonText,
2612
2867
  mountAdminBrand,
2613
2868
  startAdminBrand,
2614
2869
  faultPresetList,
@@ -2631,6 +2886,7 @@ ${entry}(${payload});
2631
2886
  var defineMock = (runtime) => runtime;
2632
2887
  var STANDARD_ADMIN_ROUTES = [
2633
2888
  "GET /",
2889
+ "GET /health",
2634
2890
  "POST /reset",
2635
2891
  "GET /namespaces",
2636
2892
  "GET /clock",
@@ -2710,11 +2966,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2710
2966
  }
2711
2967
  });
2712
2968
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2713
- var renderAdminDocument = (service) => {
2969
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2714
2970
  const name = escapeHtml(service);
2715
2971
  const script = adminClientSource({
2716
2972
  standardRoutes: STANDARD_ADMIN_ROUTES,
2717
- adminPrefix: ADMIN_PREFIX,
2973
+ adminPrefix,
2718
2974
  adminKeyHeader: ADMIN_KEY_HEADER,
2719
2975
  brandsUrl: ADMIN_BRANDS_URL,
2720
2976
  service
@@ -2731,75 +2987,99 @@ var renderAdminDocument = (service) => {
2731
2987
  ${CSS_RESET}
2732
2988
  :root {
2733
2989
  color-scheme: light;
2734
- --bg: #fafafa;
2990
+ --bg: #f7f8fb;
2735
2991
  --bg-elev: #ffffff;
2736
- --bg-muted: #f4f4f5;
2737
- --bg-inset: #f4f4f5;
2738
- --line: #dedee3;
2739
- --line-strong: #c4c4cc;
2740
- --ink: #18181b;
2741
- --muted: #62626b;
2742
- --faint: #71717a;
2743
- --accent: #27272a;
2992
+ --bg-muted: #f1f3f7;
2993
+ --bg-inset: #f8f9fc;
2994
+ --line: #e4e7ec;
2995
+ --line-strong: #cfd4dc;
2996
+ --ink: #101828;
2997
+ --muted: #475467;
2998
+ --faint: #667085;
2999
+ --accent: #6941c6;
3000
+ --accent-hover: #5933b4;
2744
3001
  --accent-ink: #ffffff;
2745
- --accent-soft: #f4f4f5;
2746
- --ok: #166534;
2747
- --ok-soft: #f0fdf4;
2748
- --warn: #a16207;
2749
- --warn-soft: #fefce8;
2750
- --err: #a82d32;
2751
- --err-soft: #fef2f2;
2752
- --shadow: none;
3002
+ --accent-soft: #f4f0ff;
3003
+ --ok: #067647;
3004
+ --ok-soft: #ecfdf3;
3005
+ --warn: #b54708;
3006
+ --warn-soft: #fffaeb;
3007
+ --err: #b42318;
3008
+ --err-soft: #fef3f2;
3009
+ --code-key: #6941c6;
3010
+ --code-string: #067647;
3011
+ --code-number: #175cd3;
3012
+ --code-boolean: #c11574;
3013
+ --code-null: #667085;
3014
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3015
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3016
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2753
3017
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2754
3018
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2755
- --radius: 10px;
2756
- --header: 56px;
2757
- --nav: 200px;
3019
+ --radius: 12px;
3020
+ --header: 72px;
3021
+ --nav: 224px;
2758
3022
  }
2759
3023
  :root[data-theme="dark"] {
2760
3024
  color-scheme: dark;
2761
- --bg: #111113;
2762
- --bg-elev: #19191c;
2763
- --bg-muted: #242428;
2764
- --bg-inset: #111113;
2765
- --line: #36363c;
2766
- --line-strong: #52525b;
2767
- --ink: #f4f4f5;
2768
- --muted: #a9a9b2;
2769
- --faint: #71717a;
2770
- --accent: #e4e4e7;
2771
- --accent-ink: #18181b;
2772
- --accent-soft: #242428;
2773
- --ok: #86efac;
2774
- --ok-soft: #14241b;
2775
- --warn: #fde68a;
2776
- --warn-soft: #2a2410;
2777
- --err: #ffaaaa;
2778
- --err-soft: #2c1516;
2779
- --shadow: none;
3025
+ --bg: #0c0e14;
3026
+ --bg-elev: #151821;
3027
+ --bg-muted: #20232e;
3028
+ --bg-inset: #11141c;
3029
+ --line: #2b303d;
3030
+ --line-strong: #414858;
3031
+ --ink: #f5f7fa;
3032
+ --muted: #b6bdc9;
3033
+ --faint: #8d96a7;
3034
+ --accent: #9e77ed;
3035
+ --accent-hover: #b692f6;
3036
+ --accent-ink: #ffffff;
3037
+ --accent-soft: #2b2142;
3038
+ --ok: #75e0a7;
3039
+ --ok-soft: #102a20;
3040
+ --warn: #fec84b;
3041
+ --warn-soft: #302611;
3042
+ --err: #fda29b;
3043
+ --err-soft: #321817;
3044
+ --code-key: #c3a6ff;
3045
+ --code-string: #75e0a7;
3046
+ --code-number: #84adff;
3047
+ --code-boolean: #f9a8d4;
3048
+ --code-null: #98a2b3;
3049
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3050
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3051
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2780
3052
  }
2781
3053
  @media (prefers-color-scheme: dark) {
2782
3054
  :root:not([data-theme="light"]) {
2783
3055
  color-scheme: dark;
2784
- --bg: #111113;
2785
- --bg-elev: #19191c;
2786
- --bg-muted: #242428;
2787
- --bg-inset: #111113;
2788
- --line: #36363c;
2789
- --line-strong: #52525b;
2790
- --ink: #f4f4f5;
2791
- --muted: #a9a9b2;
2792
- --faint: #71717a;
2793
- --accent: #e4e4e7;
2794
- --accent-ink: #18181b;
2795
- --accent-soft: #242428;
2796
- --ok: #86efac;
2797
- --ok-soft: #14241b;
2798
- --warn: #fde68a;
2799
- --warn-soft: #2a2410;
2800
- --err: #ffaaaa;
2801
- --err-soft: #2c1516;
2802
- --shadow: none;
3056
+ --bg: #0c0e14;
3057
+ --bg-elev: #151821;
3058
+ --bg-muted: #20232e;
3059
+ --bg-inset: #11141c;
3060
+ --line: #2b303d;
3061
+ --line-strong: #414858;
3062
+ --ink: #f5f7fa;
3063
+ --muted: #b6bdc9;
3064
+ --faint: #8d96a7;
3065
+ --accent: #9e77ed;
3066
+ --accent-hover: #b692f6;
3067
+ --accent-ink: #ffffff;
3068
+ --accent-soft: #2b2142;
3069
+ --ok: #75e0a7;
3070
+ --ok-soft: #102a20;
3071
+ --warn: #fec84b;
3072
+ --warn-soft: #302611;
3073
+ --err: #fda29b;
3074
+ --err-soft: #321817;
3075
+ --code-key: #c3a6ff;
3076
+ --code-string: #75e0a7;
3077
+ --code-number: #84adff;
3078
+ --code-boolean: #f9a8d4;
3079
+ --code-null: #98a2b3;
3080
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3081
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3082
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2803
3083
  }
2804
3084
  }
2805
3085
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2817,13 +3097,17 @@ var renderAdminDocument = (service) => {
2817
3097
  padding-right: env(safe-area-inset-right);
2818
3098
  -webkit-text-size-adjust: 100%;
2819
3099
  }
2820
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3100
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3101
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3102
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3103
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2821
3104
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2822
3105
  header.top {
2823
- position: sticky; top: 0; z-index: 5;
3106
+ position: sticky; top: 0; z-index: 20;
2824
3107
  display: flex; align-items: center; gap: 12px;
2825
- min-height: var(--header); padding: 10px 16px;
2826
- background: var(--bg-elev);
3108
+ min-height: var(--header); padding: 12px 20px;
3109
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3110
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2827
3111
  border-bottom: 1px solid var(--line);
2828
3112
  min-width: 0; max-width: 100%;
2829
3113
  }
@@ -2831,12 +3115,21 @@ var renderAdminDocument = (service) => {
2831
3115
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2832
3116
  min-width: 0; max-width: 100%; margin-right: auto;
2833
3117
  }
2834
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2835
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3118
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3119
+ .brand-mark {
3120
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3121
+ border-radius: 11px; color: #fff;
3122
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3123
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3124
+ }
3125
+ .brand-mark svg { width: 22px; height: 22px; }
3126
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3127
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2836
3128
  .brand-id span { color: var(--faint); font-size: 12px; }
2837
3129
  .vendor {
2838
3130
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2839
3131
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3132
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2840
3133
  }
2841
3134
  .vendor[hidden] { display: none; }
2842
3135
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2848,12 +3141,17 @@ var renderAdminDocument = (service) => {
2848
3141
  .vendor-sep { color: var(--faint); }
2849
3142
  .vendor-link { color: inherit; text-decoration: none; }
2850
3143
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2851
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2852
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3144
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3145
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3146
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3147
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3148
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2853
3149
  label.field input, label.field select, .controls input[type="password"] {
2854
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2855
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3150
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3151
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2856
3152
  }
3153
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3154
+ label.field input:focus, label.field select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); outline: none; }
2857
3155
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2858
3156
  the text fields in the same form. Paint it like those fields. */
2859
3157
  label.field select {
@@ -2870,12 +3168,20 @@ var renderAdminDocument = (service) => {
2870
3168
  dialog .field > textarea { width: 100%; }
2871
3169
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2872
3170
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2873
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2874
- background: var(--bg-elev);
2875
- }
2876
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2877
- .btn-quiet { background: transparent; }
2878
- .btn-danger { color: var(--err); }
3171
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3172
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3173
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3174
+ box-shadow: var(--shadow-xs);
3175
+ }
3176
+ .icon-btn { width: 38px; padding: 0; }
3177
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3178
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3179
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); box-shadow: 0 1px 2px rgb(16 24 40 / 0.08), 0 0 0 1px rgb(255 255 255 / 0.08) inset; }
3180
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3181
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3182
+ .btn-quiet:hover { background: var(--bg-muted); }
3183
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3184
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2879
3185
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2880
3186
  so the press itself has to stay visible. */
2881
3187
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2891,93 +3197,159 @@ var renderAdminDocument = (service) => {
2891
3197
  flex: 1 0 auto; min-width: 0;
2892
3198
  }
2893
3199
  nav.side {
2894
- border-right: 1px solid var(--line); padding: 12px;
3200
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3201
+ border-right: 1px solid var(--line); padding: 18px 12px;
2895
3202
  display: flex; flex-direction: column; gap: 4px;
2896
- background: var(--bg-elev);
3203
+ background: var(--bg-elev); overflow-y: auto;
2897
3204
  }
3205
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2898
3206
  nav.side button {
3207
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2899
3208
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2900
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2901
- }
2902
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2903
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2904
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2905
- .lede { margin: 0 0 16px; color: var(--muted); }
2906
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3209
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3210
+ }
3211
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3212
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3213
+ nav.side button[aria-current="true"]::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; border-radius: 0 4px 4px 0; background: var(--accent); }
3214
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3215
+ .nav-icon svg { width: 18px; height: 18px; }
3216
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3217
+ main[aria-busy="true"] .view { opacity: 0.62; }
3218
+ .view { animation: view-in 180ms ease-out; }
3219
+ .view[hidden] { display: none; }
3220
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3221
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3222
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3223
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3224
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2907
3225
  .card {
2908
3226
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2909
- padding: 14px 16px; box-shadow: var(--shadow);
3227
+ padding: 18px; box-shadow: var(--shadow-sm);
2910
3228
  }
2911
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2912
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3229
+ .stat-card { position: relative; overflow: hidden; }
3230
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3231
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3232
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 24px; font-weight: 650; margin-top: 7px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -0.025em; }
2913
3233
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2914
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3234
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3235
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3236
+ .section-title h3 { margin: 0; }
3237
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3238
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2915
3239
  .collection {
2916
3240
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2917
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3241
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2918
3242
  }
2919
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3243
+ .collection:hover { background: var(--bg-muted); }
3244
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2920
3245
  .collection small { display: block; color: var(--faint); }
2921
3246
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2922
- .chip {
2923
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2924
- border-radius: 999px; padding: 4px 8px;
2925
- }
3247
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2926
3248
  .chip i { color: var(--faint); font-style: normal; }
2927
3249
  .sql-layout {
2928
3250
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2929
3251
  }
2930
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2931
- .sql-tables button { text-align: left; }
3252
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); }
3253
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3254
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2932
3255
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2933
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2934
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2935
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3256
+ .table-shell { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3257
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3258
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3259
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3260
+ tbody tr:last-child td { border-bottom: 0; }
2936
3261
  tr.clickable { cursor: pointer; }
2937
- tr.clickable:hover { background: var(--bg-muted); }
3262
+ tr.clickable:hover { background: var(--accent-soft); }
2938
3263
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2939
3264
  pre {
2940
3265
  margin: 0; white-space: pre-wrap; word-break: break-word;
2941
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2942
- }
2943
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3266
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3267
+ }
3268
+ .json-key { color: var(--code-key); }
3269
+ .json-string { color: var(--code-string); }
3270
+ .json-number { color: var(--code-number); }
3271
+ .json-boolean { color: var(--code-boolean); }
3272
+ .json-null { color: var(--code-null); font-style: italic; }
3273
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3274
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3275
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3276
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3277
+ .json-editor.invalid { border-color: var(--err); }
3278
+ .json-editor pre, .json-editor textarea { margin: 0; padding: 12px; border: 0; border-radius: 0; min-height: 190px; width: 100%; font: 12px/1.65 var(--mono); tab-size: 2; white-space: pre; overflow: auto; }
3279
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3280
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3281
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3282
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3283
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3284
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3285
+ .json-status.invalid { color: var(--err); }
3286
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3287
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2944
3288
  .banner {
2945
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2946
- background: var(--err-soft); color: var(--err);
2947
- }
2948
- .banner.show { display: block; }
2949
- .empty { color: var(--muted); padding: 24px 8px; }
3289
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3290
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3291
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3292
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3293
+ }
3294
+ .banner.show { display: flex; }
3295
+ .banner span { flex: 1; }
3296
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3297
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3298
+ .empty { color: var(--muted); padding: 34px 18px; text-align: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--bg-elev) 55%, transparent); }
2950
3299
  dialog {
2951
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2952
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2953
- }
2954
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2955
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3300
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3301
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3302
+ }
3303
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3304
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3305
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3306
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3307
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3308
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2956
3309
  textarea {
2957
3310
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2958
3311
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2959
3312
  font-family: var(--mono); font-size: 12px;
2960
3313
  }
2961
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2962
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3314
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3315
+ .dialog-actions .btn-danger { margin-right: auto; }
3316
+ .badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; border: 1px solid var(--line); border-radius: 999px; padding: 3px 7px; background: var(--bg-muted); color: var(--muted); }
2963
3317
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2964
3318
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2965
3319
  .status-2 { color: var(--ok); }
2966
3320
  .status-4, .status-5, .status-0 { color: var(--err); }
2967
3321
  .stack { display: flex; flex-direction: column; gap: 12px; }
3322
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3323
+ .form-grid .wide { grid-column: span 2; }
2968
3324
  .route {
2969
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2970
- padding: 8px 0; border-bottom: 1px solid var(--line);
2971
- }
2972
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3325
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3326
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3327
+ }
3328
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3329
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3330
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3331
+ .route-tools .field { flex: 1; }
3332
+ .route-tools input { width: 100%; }
3333
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3334
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3335
+ .method { width: fit-content; font-family: var(--mono); font-size: 11px; font-weight: 750; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 4px 6px; }
3336
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3337
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3338
+ @keyframes pulse { 50% { opacity: 0.45; } }
3339
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3340
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2973
3341
  @media (max-width: 800px) {
3342
+ :root { --header: 0px; }
2974
3343
  header.top {
2975
- align-items: stretch; flex-direction: column;
2976
- padding-top: max(10px, env(safe-area-inset-top));
3344
+ position: relative; align-items: stretch; flex-direction: column;
3345
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2977
3346
  }
2978
3347
  input, select, textarea { font-size: 16px; }
2979
3348
  .brand, .vendor, .controls { width: 100%; }
3349
+ .brand { gap: 10px; }
3350
+ .vendor { order: 2; border-radius: 9px; }
2980
3351
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3352
+ .sync-status { display: none; }
2981
3353
  /* The layout fills the screen. A single-column grid stretches every auto
2982
3354
  row, so the tab bar grew with the leftover height and the buttons floated
2983
3355
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2987,14 +3359,23 @@ var renderAdminDocument = (service) => {
2987
3359
  align-content: start;
2988
3360
  }
2989
3361
  nav.side {
2990
- align-self: start; height: auto;
3362
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2991
3363
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2992
3364
  border-right: 0; border-bottom: 1px solid var(--line);
2993
- padding: 8px;
3365
+ padding: 8px; box-shadow: var(--shadow-xs);
2994
3366
  }
2995
- nav.side button { white-space: nowrap; flex: none; }
3367
+ .nav-label { display: none; }
3368
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3369
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2996
3370
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2997
- main { padding: 14px; }
3371
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3372
+ .form-grid .wide { grid-column: auto; }
3373
+ main { padding: 22px 14px 48px; }
3374
+ .view-head { margin-bottom: 18px; }
3375
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3376
+ .card { padding: 14px; }
3377
+ .card .v { font-size: 20px; }
3378
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2998
3379
  table, thead, tbody, tr, th, td { display: block; }
2999
3380
  thead { display: none; }
3000
3381
  tr {
@@ -3009,50 +3390,63 @@ var renderAdminDocument = (service) => {
3009
3390
  .route { grid-template-columns: 72px 1fr; }
3010
3391
  .route button { grid-column: 1 / -1; }
3011
3392
  }
3393
+ @media (max-width: 460px) {
3394
+ .controls label.field { min-width: calc(50% - 4px); }
3395
+ .controls .icon-btn, .controls .btn { flex: 1; }
3396
+ .grid { grid-template-columns: minmax(0, 1fr); }
3397
+ .dialog-actions { flex-wrap: wrap; }
3398
+ .dialog-actions button { flex: 1; }
3399
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3400
+ }
3012
3401
  </style>
3013
3402
  </head>
3014
3403
  <body>
3015
3404
  <div class="app" data-service="${name}" data-mockingbird-admin>
3016
3405
  <header class="top">
3017
3406
  <div class="brand">
3018
- <div class="brand-id">
3019
- <strong>${name}</strong>
3020
- <span>Admin</span>
3407
+ <div class="brand-lockup">
3408
+ <div class="brand-mark" aria-hidden="true">
3409
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 17.5V7l7 5 7-5v10.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 15.5 12 18l4-2.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
3410
+ </div>
3411
+ <div class="brand-id">
3412
+ <strong>${name}</strong>
3413
+ <span>Mock admin</span>
3414
+ </div>
3021
3415
  </div>
3022
3416
  <div class="vendor" id="vendor" hidden></div>
3023
3417
  </div>
3024
3418
  <div class="controls">
3419
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3025
3420
  <label class="field">Namespace
3026
3421
  <select id="namespace" aria-label="Namespace"></select>
3027
3422
  </label>
3028
3423
  <label class="field">Admin key
3029
3424
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3030
3425
  </label>
3031
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3032
- <button class="btn" id="refresh" type="button">Refresh</button>
3426
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3427
+ <button class="btn" id="refresh" type="button"><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M15.8 7.2A6.2 6.2 0 1 0 16 12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12.8 7.2h3v-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>Refresh</button>
3033
3428
  </div>
3034
3429
  </header>
3035
3430
  <div class="layout">
3036
3431
  <nav class="side" id="nav" aria-label="Admin sections">
3037
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3038
- <button type="button" data-view="state">State</button>
3039
- <button type="button" data-view="clock">Clock</button>
3040
- <button type="button" data-view="faults">Faults</button>
3041
- <button type="button" data-view="journal">Journal</button>
3042
- <button type="button" data-view="routes">Routes</button>
3432
+ <span class="nav-label">Workspace</span>
3433
+ <button type="button" data-view="overview" aria-current="true"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><rect x="2.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="2.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg></span>Overview</button>
3434
+ <button type="button" data-view="state"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><ellipse cx="10" cy="5" rx="6.5" ry="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 5v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5V5M3.5 10v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5v-5" stroke="currentColor" stroke-width="1.5"/></svg></span>State</button>
3435
+ <button type="button" data-view="clock"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 5.8v4.6l3 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Clock</button>
3436
+ <button type="button" data-view="faults"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m11.2 2.8-6.5 9h5.1l-1 5.4 6.5-9h-5.1l1-5.4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>Faults</button>
3437
+ <button type="button" data-view="journal"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v12l-2.2-1.4-2.2 1.4-2.1-1.4L7.8 17l-2.2-1.4L3.5 17V5A1.5 1.5 0 0 1 5 3.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.7 7h6.6M6.7 10h6.6M6.7 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Journal</button>
3438
+ <button type="button" data-view="routes"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M6.5 4.5h7a2 2 0 0 1 2 2v7M13 11l2.5 2.5L18 11M13.5 15.5h-7a2 2 0 0 1-2-2v-7M7 9 4.5 6.5 2 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>Routes</button>
3043
3439
  </nav>
3044
3440
  <main id="views">
3045
- <p class="banner" id="banner" role="alert"></p>
3441
+ <div class="banner" id="banner" role="alert"><span id="banner-message"></span><button id="banner-dismiss" type="button" aria-label="Dismiss error">\xD7</button></div>
3046
3442
  <section class="view" id="view-overview">
3047
- <h2>Overview</h2>
3048
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3443
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3049
3444
  <div class="grid" id="overview-cards"></div>
3050
3445
  <h3>Recent requests</h3>
3051
3446
  <div id="overview-requests"></div>
3052
3447
  </section>
3053
3448
  <section class="view" id="view-state" hidden>
3054
- <h2>State</h2>
3055
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3449
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3056
3450
  <div class="split">
3057
3451
  <div id="collections"></div>
3058
3452
  <div>
@@ -3065,8 +3459,7 @@ var renderAdminDocument = (service) => {
3065
3459
  </div>
3066
3460
  </section>
3067
3461
  <section class="view" id="view-clock" hidden>
3068
- <h2>Clock</h2>
3069
- <p class="lede">The clock every timestamp in this mock reads.</p>
3462
+ <div class="view-head"><div><h2>Clock</h2><p class="lede">Control the source of time used by every timestamp in this mock.</p></div></div>
3070
3463
  <div class="card" id="clock-readout"></div>
3071
3464
  <div class="row-actions">
3072
3465
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3085,21 +3478,22 @@ var renderAdminDocument = (service) => {
3085
3478
  </form>
3086
3479
  </section>
3087
3480
  <section class="view" id="view-faults" hidden>
3088
- <h2>Faults</h2>
3089
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3481
+ <div class="view-head"><div><h2>Faults</h2><p class="lede">Test unhappy paths with a response, delay, or named preset scoped to this namespace.</p></div></div>
3090
3482
  <form id="fault-form" class="card stack">
3091
- <label class="field">Preset
3092
- <select id="fault-preset"><option value="">Custom rule</option></select>
3093
- </label>
3094
- <label class="field">Status
3095
- <input id="fault-status" inputmode="numeric" placeholder="503">
3096
- </label>
3097
- <label class="field">Path prefix
3098
- <input id="fault-path" placeholder="/v1">
3099
- </label>
3100
- <label class="field">Method
3101
- <input id="fault-method" placeholder="GET">
3102
- </label>
3483
+ <div class="form-grid">
3484
+ <label class="field wide">Preset
3485
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3486
+ </label>
3487
+ <label class="field">Status
3488
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3489
+ </label>
3490
+ <label class="field wide">Path prefix
3491
+ <input id="fault-path" placeholder="/v1">
3492
+ </label>
3493
+ <label class="field">Method
3494
+ <input id="fault-method" placeholder="GET">
3495
+ </label>
3496
+ </div>
3103
3497
  <div class="dialog-actions">
3104
3498
  <button class="btn-primary" type="submit">Add fault</button>
3105
3499
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3108,26 +3502,31 @@ var renderAdminDocument = (service) => {
3108
3502
  <div id="fault-list"></div>
3109
3503
  </section>
3110
3504
  <section class="view" id="view-journal" hidden>
3111
- <h2>Journal</h2>
3112
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3113
- <div class="row-actions">
3114
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3115
- </div>
3505
+ <div class="view-head"><div><h2>Journal</h2><p class="lede">Requests this namespace has seen. Bodies are never stored.</p></div><button class="btn-danger" id="journal-clear" type="button">Clear journal</button></div>
3116
3506
  <div id="journal"></div>
3117
3507
  </section>
3118
3508
  <section class="view" id="view-routes" hidden>
3119
- <h2>Routes</h2>
3120
- <p class="lede">Shared controls, plus any routes this mock adds. Extra routes are how a bespoke workflow shows up beside the default UI.</p>
3509
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3510
+ <div class="route-tools">
3511
+ <label class="field">Filter routes
3512
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3513
+ </label>
3514
+ <span class="route-count" id="route-count"></span>
3515
+ </div>
3121
3516
  <div id="route-list"></div>
3122
- <form id="route-form" class="card stack">
3517
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3123
3518
  <strong id="route-title">Select a route</strong>
3124
3519
  <label class="field">JSON body
3125
- <textarea id="route-body" placeholder="{ }"></textarea>
3520
+ <div class="json-editor" id="route-editor" hidden>
3521
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3522
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3523
+ </div>
3524
+ <span class="editor-meta" hidden><span class="json-status" id="route-json-status">Valid JSON</span><button class="format-json" type="button" data-format="route-body">Format JSON</button></span>
3126
3525
  </label>
3127
3526
  <div class="dialog-actions">
3128
- <button class="btn-primary" type="submit">Send</button>
3527
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3129
3528
  </div>
3130
- <pre id="route-result">Response will show here.</pre>
3529
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3131
3530
  </form>
3132
3531
  </section>
3133
3532
  </main>
@@ -3135,12 +3534,16 @@ var renderAdminDocument = (service) => {
3135
3534
  </div>
3136
3535
  <dialog id="editor">
3137
3536
  <form method="dialog" id="editor-form">
3138
- <strong id="editor-title">Record</strong>
3537
+ <div class="dialog-heading"><div><strong id="editor-title">Record</strong><br><span>Edit the ID and JSON value stored in this collection.</span></div></div>
3139
3538
  <label class="field">Id
3140
3539
  <input id="editor-id" required>
3141
3540
  </label>
3142
3541
  <label class="field">JSON value
3143
- <textarea id="editor-value"></textarea>
3542
+ <div class="json-editor" id="record-editor">
3543
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3544
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3545
+ </div>
3546
+ <span class="editor-meta"><span class="json-status" id="editor-json-status">Valid JSON</span><button class="format-json" type="button" data-format="editor-value">Format JSON</button></span>
3144
3547
  </label>
3145
3548
  <div class="dialog-actions">
3146
3549
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3182,8 +3585,8 @@ var describeExtension = (extension) => {
3182
3585
  ...extension.script !== void 0 ? { script: extension.script } : {}
3183
3586
  };
3184
3587
  };
3185
- var adminUiRoutes = (service, ui) => {
3186
- const shell = () => renderAdminDocument(service);
3588
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3589
+ const shell = () => renderAdminDocument(service, adminPrefix);
3187
3590
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3188
3591
  return {
3189
3592
  "GET /ui": () => htmlResponse(document3()),
@@ -3800,7 +4203,7 @@ var stateAdminRoutes = (routes) => {
3800
4203
  };
3801
4204
 
3802
4205
  // ../core/dist/version.js
3803
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4206
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3804
4207
 
3805
4208
  // ../core/dist/signing.js
3806
4209
  var encoder = new TextEncoder();
@@ -3893,7 +4296,9 @@ var createWebhookHub = (options) => {
3893
4296
  const faults = /* @__PURE__ */ new Map();
3894
4297
  const held = /* @__PURE__ */ new Map();
3895
4298
  const inFlight = /* @__PURE__ */ new Set();
4299
+ const controllers = /* @__PURE__ */ new Map();
3896
4300
  let immediate = Promise.resolve();
4301
+ const snapshots = /* @__PURE__ */ new WeakMap();
3897
4302
  const track = (work) => {
3898
4303
  inFlight.add(work);
3899
4304
  void work.finally(() => inFlight.delete(work));
@@ -3914,6 +4319,7 @@ var createWebhookHub = (options) => {
3914
4319
  responseBody: null
3915
4320
  };
3916
4321
  const controller = new AbortController();
4322
+ controllers.set(delivery.id, controller);
3917
4323
  const timer = scheduleTimer(() => controller.abort(), timeoutMs);
3918
4324
  try {
3919
4325
  const signed = await options.signer({
@@ -3943,6 +4349,7 @@ var createWebhookHub = (options) => {
3943
4349
  } catch (error2) {
3944
4350
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error2 instanceof Error ? error2.message : String(error2);
3945
4351
  } finally {
4352
+ controllers.delete(delivery.id);
3946
4353
  cancel(timer);
3947
4354
  record.durationMs = now() - started;
3948
4355
  delivery.attempts.push(record);
@@ -3960,6 +4367,8 @@ var createWebhookHub = (options) => {
3960
4367
  if (!payloads.has(delivery.id))
3961
4368
  return;
3962
4369
  await attempt(delivery).then((ok) => {
4370
+ if (!payloads.has(delivery.id))
4371
+ return;
3963
4372
  if (ok)
3964
4373
  delivery.state = "delivered";
3965
4374
  else
@@ -4021,6 +4430,37 @@ var createWebhookHub = (options) => {
4021
4430
  fanOut(message);
4022
4431
  };
4023
4432
  const hub = {
4433
+ pending(namespace) {
4434
+ return inFlight.size + (held.get(namespace)?.length ?? 0) + [...deliveries.values()].filter((d) => d.namespace === namespace && d.state === "pending").length;
4435
+ },
4436
+ snapshot(namespace) {
4437
+ if (hub.pending(namespace) > 0)
4438
+ throw new Error("webhooks are not quiescent");
4439
+ const handle = {};
4440
+ snapshots.set(handle, structuredClone({
4441
+ namespace,
4442
+ endpoints: own.get(namespace) ?? [],
4443
+ messages: messages.filter((m) => m.namespace === namespace),
4444
+ deliveries: [...deliveries].filter(([, d]) => d.namespace === namespace),
4445
+ payloads: [...payloads].filter(([, p]) => p.message.namespace === namespace),
4446
+ faults: faults.get(namespace) ?? []
4447
+ }));
4448
+ return handle;
4449
+ },
4450
+ restore(namespace, handle) {
4451
+ const point = typeof handle === "object" && handle !== null ? snapshots.get(handle) : void 0;
4452
+ if (!point || point.namespace !== namespace || hub.pending(namespace) > 0)
4453
+ throw new Error("invalid webhook restore");
4454
+ hub.clear(namespace);
4455
+ const copied = structuredClone(point);
4456
+ own.set(namespace, copied.endpoints);
4457
+ messages.push(...copied.messages);
4458
+ for (const [id2, delivery] of copied.deliveries)
4459
+ deliveries.set(id2, delivery);
4460
+ for (const [id2, payload] of copied.payloads)
4461
+ payloads.set(id2, payload);
4462
+ faults.set(namespace, copied.faults);
4463
+ },
4024
4464
  publish(input) {
4025
4465
  const contentType = input.contentType ?? (input.form ? "application/x-www-form-urlencoded" : "application/json");
4026
4466
  const body = typeof input.body === "string" ? input.body : input.form ? new URLSearchParams(input.form).toString() : JSON.stringify(input.body);
@@ -4111,6 +4551,7 @@ var createWebhookHub = (options) => {
4111
4551
  for (const [id2, delivery] of deliveries) {
4112
4552
  if (namespace !== void 0 && delivery.namespace !== namespace)
4113
4553
  continue;
4554
+ controllers.get(id2)?.abort();
4114
4555
  const timer = pending.get(id2);
4115
4556
  if (timer !== void 0)
4116
4557
  cancel(timer);
@@ -4135,7 +4576,10 @@ var createWebhookHub = (options) => {
4135
4576
  };
4136
4577
  return hub;
4137
4578
  };
4138
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4579
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4580
+ status,
4581
+ headers: { "content-type": "application/json; charset=utf-8" }
4582
+ });
4139
4583
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
4140
4584
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4141
4585
  var parseEndpoint = (value) => {
@@ -4243,7 +4687,6 @@ var AT_HEADER = "x-mockingbird-at";
4243
4687
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4244
4688
  var DEFAULT_NAMESPACE = "default";
4245
4689
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4246
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4247
4690
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4248
4691
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4249
4692
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4284,6 +4727,22 @@ var operationMatcher = (document3) => {
4284
4727
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4285
4728
  };
4286
4729
  var createRuntime = (options) => {
4730
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4731
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4732
+ for (const path of internalPaths) {
4733
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4734
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4735
+ }
4736
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4737
+ const internalMatch = (path) => {
4738
+ for (const pattern of internalPaths) {
4739
+ const actual = path.split("/");
4740
+ const expected = pattern.split("/");
4741
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4742
+ return pattern;
4743
+ }
4744
+ return void 0;
4745
+ };
4287
4746
  assertAdminUi(options.adminUi);
4288
4747
  const sqlite = bootSqlite(options.sqlite);
4289
4748
  const clock = options.clock ?? createClock();
@@ -4298,6 +4757,20 @@ var createRuntime = (options) => {
4298
4757
  const instances = /* @__PURE__ */ new Map();
4299
4758
  const publicNamespaces = /* @__PURE__ */ new Set();
4300
4759
  const branchRngs = /* @__PURE__ */ new Map();
4760
+ const namespaceClocks = /* @__PURE__ */ new Map();
4761
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4762
+ let isolatedNamespaces = false;
4763
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4764
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4765
+ return clock;
4766
+ let found = namespaceClocks.get(name);
4767
+ if (!found) {
4768
+ found = createClock();
4769
+ namespaceClocks.set(name, found);
4770
+ }
4771
+ return found;
4772
+ };
4773
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4301
4774
  const timelines = /* @__PURE__ */ new Map();
4302
4775
  const branchStorage = /* @__PURE__ */ new Map();
4303
4776
  const captured = /* @__PURE__ */ new Map();
@@ -4311,17 +4784,22 @@ var createRuntime = (options) => {
4311
4784
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4312
4785
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4313
4786
  }
4787
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4314
4788
  const created = options.create({
4315
4789
  namespace: storageNamespace(key),
4316
4790
  publicNamespace,
4791
+ adminPrefix,
4317
4792
  sqlite,
4318
- clock,
4319
- rng: isolatedRng ?? rng
4793
+ clock: namespaceClock(publicNamespace),
4794
+ rng: createdContextRng
4320
4795
  });
4321
4796
  instances.set(key, created);
4797
+ namespaceForStorage.set(key, publicNamespace);
4322
4798
  publicNamespaces.add(publicNamespace);
4323
4799
  if (isolatedRng)
4324
4800
  branchRngs.set(key, isolatedRng);
4801
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4802
+ branchRngs.set(key, createdContextRng);
4325
4803
  return created;
4326
4804
  };
4327
4805
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4339,7 +4817,7 @@ var createRuntime = (options) => {
4339
4817
  captured.set(storage, snapshot2);
4340
4818
  return Object.freeze({
4341
4819
  snapshot: snapshot2,
4342
- clock: Object.freeze(clock.state()),
4820
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4343
4821
  rngState: (branchRngs.get(storage) ?? rng).state()
4344
4822
  });
4345
4823
  };
@@ -4349,7 +4827,7 @@ var createRuntime = (options) => {
4349
4827
  return found;
4350
4828
  instance(name);
4351
4829
  found = new Timeline({
4352
- now: clock.now,
4830
+ now: namespaceClock(name).now,
4353
4831
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4354
4832
  });
4355
4833
  found.commit(capture(name));
@@ -4376,12 +4854,13 @@ var createRuntime = (options) => {
4376
4854
  const point = history2.checkout("main", at);
4377
4855
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4378
4856
  captured.set(namespace, point.value.snapshot);
4379
- rng.setState(point.value.rngState);
4380
- clock.set(point.value.clock.now);
4857
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4858
+ const selectedClock = namespaceClock(namespace);
4859
+ selectedClock.set(point.value.clock.now);
4381
4860
  if (point.value.clock.frozen)
4382
- clock.freeze();
4861
+ selectedClock.freeze();
4383
4862
  else
4384
- clock.unfreeze();
4863
+ selectedClock.unfreeze();
4385
4864
  }
4386
4865
  return namespace;
4387
4866
  }
@@ -4439,11 +4918,12 @@ var createRuntime = (options) => {
4439
4918
  const storage = ensureBranch(namespace, branchName);
4440
4919
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4441
4920
  captured.set(storage, point.value.snapshot);
4442
- clock.set(point.value.clock.now);
4921
+ const selectedClock = namespaceClock(namespace);
4922
+ selectedClock.set(point.value.clock.now);
4443
4923
  if (point.value.clock.frozen)
4444
- clock.freeze();
4924
+ selectedClock.freeze();
4445
4925
  else
4446
- clock.unfreeze();
4926
+ selectedClock.unfreeze();
4447
4927
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4448
4928
  };
4449
4929
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4451,10 +4931,21 @@ var createRuntime = (options) => {
4451
4931
  options.webhooks?.clear();
4452
4932
  for (const each of instances.values())
4453
4933
  await each.reset();
4934
+ for (const each of branchRngs.values())
4935
+ each.reset();
4454
4936
  timelines.clear();
4937
+ for (const storage of branchStorage.values()) {
4938
+ instances.delete(storage);
4939
+ branchRngs.delete(storage);
4940
+ namespaceForStorage.delete(storage);
4941
+ }
4455
4942
  branchStorage.clear();
4456
- branchRngs.clear();
4457
4943
  captured.clear();
4944
+ faults.clear();
4945
+ clock.reset();
4946
+ rng.reset();
4947
+ for (const each of namespaceClocks.values())
4948
+ each.reset();
4458
4949
  return;
4459
4950
  }
4460
4951
  options.webhooks?.clear(name);
@@ -4472,11 +4963,18 @@ var createRuntime = (options) => {
4472
4963
  else
4473
4964
  clearNamespace(sqlite, storageNamespace(storage));
4474
4965
  branchStorage.delete(mapping);
4966
+ instances.delete(storage);
4967
+ namespaceForStorage.delete(storage);
4475
4968
  branchRngs.delete(storage);
4476
4969
  captured.delete(storage);
4477
4970
  }
4478
4971
  timelines.delete(name);
4479
4972
  captured.delete(name);
4973
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4974
+ namespaceClock(name).reset();
4975
+ (branchRngs.get(name) ?? rng).reset();
4976
+ journal.clear(name);
4977
+ metrics.reset(name);
4480
4978
  };
4481
4979
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4482
4980
  return checkpoint(name, "main").value.snapshot;
@@ -4514,6 +5012,53 @@ var createRuntime = (options) => {
4514
5012
  rng,
4515
5013
  credentials,
4516
5014
  webhooks: options.webhooks,
5015
+ isolateNamespaces() {
5016
+ if (publicNamespaces.size > 1)
5017
+ throw new Error("enable namespace isolation before creating named namespaces");
5018
+ isolatedNamespaces = true;
5019
+ faults.isolateNamespaces();
5020
+ },
5021
+ namespaceClock,
5022
+ namespaceOf(request) {
5023
+ const url = new URL(request.url);
5024
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
5025
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
5026
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
5027
+ if (isAdminPath(path, adminPrefix))
5028
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
5029
+ if (selected !== void 0)
5030
+ return selected;
5031
+ const credential = options.credential?.(request);
5032
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
5033
+ },
5034
+ fleetSnapshot(namespace) {
5035
+ instance(namespace);
5036
+ const handle = {};
5037
+ fleetPoints.set(handle, {
5038
+ namespace,
5039
+ state: capture(namespace),
5040
+ faults: faults.snapshot(namespace),
5041
+ webhooks: options.webhooks?.snapshot(namespace)
5042
+ });
5043
+ return handle;
5044
+ },
5045
+ fleetRestore(handle, namespace) {
5046
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
5047
+ if (!point || point.namespace !== namespace)
5048
+ throw new Error("invalid fleet checkpoint");
5049
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
5050
+ captured.set(namespace, point.state.snapshot);
5051
+ const selectedClock = namespaceClock(namespace);
5052
+ selectedClock.freeze();
5053
+ selectedClock.set(point.state.clock.now);
5054
+ if (!point.state.clock.frozen)
5055
+ selectedClock.unfreeze();
5056
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
5057
+ faults.restore(namespace, point.faults);
5058
+ if (point.webhooks !== void 0)
5059
+ options.webhooks?.restore(namespace, point.webhooks);
5060
+ timelines.delete(namespace);
5061
+ },
4517
5062
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4518
5063
  const preset = options.presets?.[name];
4519
5064
  if (!preset)
@@ -4545,7 +5090,7 @@ var createRuntime = (options) => {
4545
5090
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4546
5091
  fetch: async (incoming) => {
4547
5092
  let request = incoming;
4548
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5093
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4549
5094
  if (prefixed) {
4550
5095
  const url2 = new URL(request.url);
4551
5096
  url2.pathname = prefixed[2] ?? "/";
@@ -4561,7 +5106,15 @@ var createRuntime = (options) => {
4561
5106
  signal: request.signal
4562
5107
  });
4563
5108
  }
4564
- let namespace = control.namespaceOf(request);
5109
+ const internalUrl = new URL(request.url);
5110
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5111
+ const internal = internalMatch(internalPath);
5112
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5113
+ if (internal) {
5114
+ internalUrl.pathname = internalPath;
5115
+ request = new Request(internalUrl, request);
5116
+ }
5117
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4565
5118
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4566
5119
  const credential = options.credential(request);
4567
5120
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4581,7 +5134,9 @@ var createRuntime = (options) => {
4581
5134
  return copy;
4582
5135
  }
4583
5136
  };
4584
- const handled = await control.handle(request);
5137
+ if (obsoleteInternalPath)
5138
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5139
+ const handled = internal ? void 0 : await control.handle(request);
4585
5140
  if (handled)
4586
5141
  return stamp(handled);
4587
5142
  const started = monotonicNow();
@@ -4622,7 +5177,7 @@ var createRuntime = (options) => {
4622
5177
  type: "mockingbird_admin",
4623
5178
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4624
5179
  }
4625
- }), { status: 400, headers: { "content-type": "application/json" } }));
5180
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4626
5181
  }
4627
5182
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4628
5183
  log(400);
@@ -4689,6 +5244,7 @@ var createRuntime = (options) => {
4689
5244
  startedAt: wallNow(),
4690
5245
  wallNow,
4691
5246
  clock,
5247
+ namespaceClock,
4692
5248
  faults,
4693
5249
  metrics,
4694
5250
  journal,
@@ -4738,15 +5294,16 @@ var createRuntime = (options) => {
4738
5294
  ...options.admin?.(runtime) ?? {},
4739
5295
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4740
5296
  // maps `presets`, and a service route must not replace that list with a record.
4741
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5297
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4742
5298
  ...stateAdminRoutes({
4743
5299
  open: stateScope,
4744
5300
  afterWrite: (namespace) => {
4745
5301
  checkpoint(namespace, "main");
4746
5302
  }
4747
5303
  }),
4748
- ...adminUiRoutes(options.name, options.adminUi)
5304
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4749
5305
  },
5306
+ adminPrefix,
4750
5307
  adminKey: options.adminKey
4751
5308
  });
4752
5309
  return defineMock(runtime);
@@ -4760,7 +5317,10 @@ var mutableResponse = (response) => {
4760
5317
  return new Response(response.body, response);
4761
5318
  }
4762
5319
  };
4763
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5320
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5321
+ status,
5322
+ headers: { "content-type": "application/json; charset=utf-8" }
5323
+ });
4764
5324
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4765
5325
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4766
5326
  var credentialRoutes = (registry) => ({
@@ -4812,7 +5372,7 @@ var credentialRoutes = (registry) => ({
4812
5372
  return adminJson(200, { status: "ok" });
4813
5373
  }
4814
5374
  });
4815
- var presetRoutes = (presets, runtime) => ({
5375
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4816
5376
  "GET /faults/presets": () => {
4817
5377
  const body = {
4818
5378
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4822,7 +5382,7 @@ var presetRoutes = (presets, runtime) => ({
4822
5382
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4823
5383
  const name = params.name;
4824
5384
  if (!presets[name])
4825
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5385
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4826
5386
  const overrides = isObject(body) ? body : {};
4827
5387
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4828
5388
  }
@@ -5931,6 +6491,7 @@ var createRuntime2 = (options = {}) => {
5931
6491
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5932
6492
  ...options.clock ? { clock: options.clock } : {},
5933
6493
  ...options.seed !== void 0 ? { seed: options.seed } : {},
6494
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5934
6495
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5935
6496
  ...options.onLog ? { onLog: options.onLog } : {},
5936
6497
  credential: formbricksCredential,
@@ -6560,6 +7121,9 @@ var FormbricksAPI = class {
6560
7121
  };
6561
7122
 
6562
7123
  export {
7124
+ createClock,
7125
+ resolveAdminPrefix,
7126
+ parseDuration,
6563
7127
  document2 as document,
6564
7128
  operationIds,
6565
7129
  supportedOperationIds,
@@ -6576,4 +7140,4 @@ export {
6576
7140
  formbricksError,
6577
7141
  FormbricksAPI
6578
7142
  };
6579
- //# sourceMappingURL=chunk-S2LM6VC4.js.map
7143
+ //# sourceMappingURL=chunk-2YLOIK2J.js.map