@crvouga/mockingbird-service-easypost 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
  };
@@ -485,7 +525,33 @@ var faultResponse = (rule) => {
485
525
  };
486
526
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
487
527
  const entries = [];
528
+ let isolated = false;
529
+ const scopedRngs = /* @__PURE__ */ new Map();
530
+ const random = (namespace) => {
531
+ if (!isolated || namespace === "default")
532
+ return rng;
533
+ let scoped = scopedRngs.get(namespace);
534
+ if (!scoped) {
535
+ scoped = createRng(rng.seed);
536
+ scopedRngs.set(namespace, scoped);
537
+ }
538
+ return scoped;
539
+ };
488
540
  return {
541
+ isolateNamespaces() {
542
+ isolated = true;
543
+ },
544
+ snapshot(namespace) {
545
+ return {
546
+ rules: structuredClone(entries.map((entry, position) => ({
547
+ ...entry.rule,
548
+ remaining: entry.remaining,
549
+ hits: entry.hits,
550
+ position
551
+ })).filter((entry) => entry.namespace === namespace)),
552
+ rngState: random(namespace).state()
553
+ };
554
+ },
489
555
  add(rule) {
490
556
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
491
557
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -505,6 +571,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
505
571
  },
506
572
  clear() {
507
573
  entries.length = 0;
574
+ rng.reset();
575
+ scopedRngs.clear();
576
+ },
577
+ restore(namespace, state) {
578
+ for (let i = entries.length - 1; i >= 0; i--)
579
+ if (entries[i]?.rule.namespace === namespace)
580
+ entries.splice(i, 1);
581
+ for (const { remaining, hits, position, ...rule } of state.rules)
582
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
583
+ random(namespace).setState(state.rngState);
508
584
  },
509
585
  async take(candidate) {
510
586
  const hits = [];
@@ -514,7 +590,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
514
590
  if (!matches(entry.rule, candidate))
515
591
  continue;
516
592
  const rate = entry.rule.rate ?? 1;
517
- if (rng.next() >= rate)
593
+ if (random(candidate.namespace).next() >= rate)
518
594
  continue;
519
595
  entry.hits++;
520
596
  if (entry.remaining !== null)
@@ -1123,7 +1199,8 @@ var annotateResponse = (response, extra) => {
1123
1199
  var responseNotes = (response) => notes.get(response);
1124
1200
 
1125
1201
  // ../core/dist/metrics.js
1126
- var createMetrics = () => {
1202
+ var createMetrics = (partition = true) => {
1203
+ const namespaces = /* @__PURE__ */ new Map();
1127
1204
  let requests = 0;
1128
1205
  let faults = 0;
1129
1206
  let totalDurationMs = 0;
@@ -1131,6 +1208,14 @@ var createMetrics = () => {
1131
1208
  const unmatched = /* @__PURE__ */ new Map();
1132
1209
  return {
1133
1210
  record(entry) {
1211
+ if (partition) {
1212
+ let scoped = namespaces.get(entry.namespace);
1213
+ if (!scoped) {
1214
+ scoped = createMetrics(false);
1215
+ namespaces.set(entry.namespace, scoped);
1216
+ }
1217
+ scoped.record(entry);
1218
+ }
1134
1219
  requests++;
1135
1220
  totalDurationMs += entry.durationMs;
1136
1221
  if (entry.faultId !== void 0)
@@ -1142,7 +1227,7 @@ var createMetrics = () => {
1142
1227
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1143
1228
  }
1144
1229
  },
1145
- report: () => ({
1230
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1146
1231
  requests,
1147
1232
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1148
1233
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1151,8 +1236,13 @@ var createMetrics = () => {
1151
1236
  }),
1152
1237
  faults,
1153
1238
  totalDurationMs
1154
- }),
1155
- reset() {
1239
+ },
1240
+ reset(namespace) {
1241
+ if (namespace !== void 0) {
1242
+ namespaces.delete(namespace);
1243
+ return;
1244
+ }
1245
+ namespaces.clear();
1156
1246
  requests = 0;
1157
1247
  faults = 0;
1158
1248
  totalDurationMs = 0;
@@ -1765,6 +1855,34 @@ dialog:not([open]) {
1765
1855
  function isRecord2(value) {
1766
1856
  return typeof value === "object" && value !== null && !Array.isArray(value);
1767
1857
  }
1858
+ function highlightJsonText(text) {
1859
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1860
+ switch (char) {
1861
+ case "&":
1862
+ return "&amp;";
1863
+ case "<":
1864
+ return "&lt;";
1865
+ case ">":
1866
+ return "&gt;";
1867
+ case '"':
1868
+ return "&quot;";
1869
+ default:
1870
+ return "&#39;";
1871
+ }
1872
+ });
1873
+ 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;
1874
+ let cursor = 0;
1875
+ let html = "";
1876
+ for (const match of text.matchAll(token)) {
1877
+ const index = match.index ?? 0;
1878
+ const value = match[0];
1879
+ html += escapeToken(text.slice(cursor, index));
1880
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1881
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1882
+ cursor = index + value.length;
1883
+ }
1884
+ return html + escapeToken(text.slice(cursor));
1885
+ }
1768
1886
  function mountAdminBrand(doc, host, service, catalog) {
1769
1887
  const clip = (value, max) => {
1770
1888
  if (typeof value !== "string")
@@ -2126,14 +2244,16 @@ function bootAdmin(config) {
2126
2244
  const state = {
2127
2245
  namespace: "default",
2128
2246
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
2129
- view: "overview",
2247
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
2130
2248
  collection: "",
2131
2249
  shape: null,
2132
2250
  route: null,
2251
+ routes: [],
2133
2252
  editing: null
2134
2253
  };
2135
2254
  const standard = new Set(config.standardRoutes);
2136
2255
  const banner = el("banner", HTMLElement);
2256
+ const bannerMessage = el("banner-message", HTMLElement);
2137
2257
  const keyInput = el("admin-key", HTMLInputElement);
2138
2258
  keyInput.value = state.key;
2139
2259
  const params = new URLSearchParams(location.search);
@@ -2144,18 +2264,21 @@ function bootAdmin(config) {
2144
2264
  sessionStorage.setItem("mockingbird-admin-key", state.key);
2145
2265
  params.delete("key");
2146
2266
  const next = params.toString();
2147
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2267
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
2148
2268
  }
2149
2269
  const queryNamespace = params.get("namespace");
2150
2270
  if (queryNamespace)
2151
2271
  state.namespace = queryNamespace;
2152
2272
  const showError = (error) => {
2153
- banner.textContent = error instanceof Error ? error.message : String(error);
2273
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
2154
2274
  banner.classList.add("show");
2275
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
2155
2276
  };
2156
2277
  const clearError = () => {
2157
2278
  banner.classList.remove("show");
2279
+ bannerMessage.textContent = "";
2158
2280
  };
2281
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
2159
2282
  const api = async (method, path, body) => {
2160
2283
  const headers = { accept: "application/json" };
2161
2284
  if (state.key !== "")
@@ -2199,16 +2322,21 @@ function bootAdmin(config) {
2199
2322
  namespace: () => state.namespace
2200
2323
  };
2201
2324
  const isPanelScript = (value) => typeof value === "function";
2325
+ const themeButton = el("theme", HTMLButtonElement);
2202
2326
  const setTheme = (theme) => {
2203
2327
  if (theme)
2204
2328
  document.documentElement.dataset.theme = theme;
2205
2329
  else
2206
2330
  delete document.documentElement.dataset.theme;
2331
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2332
+ 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>';
2333
+ const label = dark ? "Use light theme" : "Use dark theme";
2334
+ themeButton.setAttribute("aria-label", label);
2335
+ themeButton.title = label;
2207
2336
  };
2208
2337
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2209
- if (storedTheme)
2210
- setTheme(storedTheme);
2211
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2338
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2339
+ themeButton.addEventListener("click", () => {
2212
2340
  const current = document.documentElement.dataset.theme;
2213
2341
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2214
2342
  const next = dark ? "light" : "dark";
@@ -2227,6 +2355,7 @@ function bootAdmin(config) {
2227
2355
  continue;
2228
2356
  section.hidden = section.id !== `view-${view}`;
2229
2357
  }
2358
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2230
2359
  };
2231
2360
  el("nav", HTMLElement).addEventListener("click", (event) => {
2232
2361
  const target = event.target;
@@ -2241,7 +2370,7 @@ function bootAdmin(config) {
2241
2370
  show(view);
2242
2371
  refresh().catch(showError);
2243
2372
  });
2244
- 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>`;
2373
+ 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>`;
2245
2374
  const table = (columns, rows) => {
2246
2375
  if (rows.length === 0)
2247
2376
  return '<p class="empty">Nothing here yet.</p>';
@@ -2251,16 +2380,22 @@ function bootAdmin(config) {
2251
2380
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2252
2381
  return `<tr${attrs}>${cells}</tr>`;
2253
2382
  }).join("");
2254
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2383
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2255
2384
  };
2256
2385
  const preview = (value) => {
2257
2386
  const text = JSON.stringify(value) ?? "null";
2258
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2387
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2388
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2259
2389
  };
2260
2390
  const when = (epoch) => {
2261
2391
  const date = new Date(epoch);
2262
2392
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2263
2393
  };
2394
+ const journalWhen = (value) => {
2395
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2396
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2397
+ return Number.isFinite(epoch) ? when(epoch) : value;
2398
+ };
2264
2399
  const clockCard = (epoch) => {
2265
2400
  const date = new Date(epoch);
2266
2401
  if (Number.isNaN(date.getTime()))
@@ -2380,7 +2515,7 @@ function bootAdmin(config) {
2380
2515
  };
2381
2516
  const renderOverview = async () => {
2382
2517
  const [health, shape, clock, journal] = await Promise.all([
2383
- originGet("/health"),
2518
+ originGet(`${config.adminPrefix}/health`),
2384
2519
  api("GET", "/state"),
2385
2520
  api("GET", "/clock"),
2386
2521
  api("GET", "/requests?limit=8")
@@ -2397,7 +2532,7 @@ function bootAdmin(config) {
2397
2532
  },
2398
2533
  {
2399
2534
  k: "Status",
2400
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2535
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2401
2536
  },
2402
2537
  { k: "Namespace", v: esc(state.namespace) },
2403
2538
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2484,26 +2619,46 @@ function bootAdmin(config) {
2484
2619
  const renderJournal = async () => {
2485
2620
  const journal = await api("GET", "/requests?limit=100");
2486
2621
  el("journal", HTMLElement).innerHTML = table([
2487
- { label: "When", cell: (row) => esc(row.at) },
2488
- { label: "Method", cell: (row) => esc(row.method) },
2622
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2623
+ {
2624
+ label: "Method",
2625
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2626
+ },
2489
2627
  { label: "Path", cell: (row) => esc(row.path) },
2490
- { label: "Status", cell: (row) => esc(row.status) },
2628
+ {
2629
+ label: "Status",
2630
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2631
+ },
2491
2632
  { label: "ms", cell: (row) => esc(row.durationMs) }
2492
2633
  ], decodeJournal(journal));
2493
2634
  };
2494
- const renderRoutes = async () => {
2495
- const data = await api("GET", "/");
2496
- if (!isRecord2(data))
2497
- throw new Error("Route list was not an object");
2498
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2635
+ const paintRoutes = () => {
2636
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2637
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2638
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2639
+ if (state.routes.length === 0) {
2640
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2641
+ return;
2642
+ }
2643
+ if (routes.length === 0) {
2644
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2645
+ return;
2646
+ }
2499
2647
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2500
2648
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2501
2649
  const parts = route.split(" ");
2502
2650
  const method = parts[0] ?? "";
2503
2651
  const path = parts.slice(1).join(" ");
2504
- 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>`;
2652
+ 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>`;
2505
2653
  }).join("");
2506
2654
  };
2655
+ const renderRoutes = async () => {
2656
+ const data = await api("GET", "/");
2657
+ if (!isRecord2(data))
2658
+ throw new Error("Route list was not an object");
2659
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2660
+ paintRoutes();
2661
+ };
2507
2662
  const renderers = {
2508
2663
  overview: renderOverview,
2509
2664
  state: renderState,
@@ -2512,19 +2667,50 @@ function bootAdmin(config) {
2512
2667
  journal: renderJournal,
2513
2668
  routes: renderRoutes
2514
2669
  };
2670
+ let refreshToken = 0;
2515
2671
  async function refresh() {
2672
+ const token = ++refreshToken;
2516
2673
  clearError();
2674
+ const main = el("views", HTMLElement);
2675
+ const sync = el("sync-status", HTMLElement);
2676
+ main.setAttribute("aria-busy", "true");
2677
+ sync.classList.add("busy");
2678
+ sync.textContent = "Updating";
2517
2679
  const render = renderers[state.view];
2518
- if (render)
2519
- await render();
2680
+ try {
2681
+ if (render)
2682
+ await render();
2683
+ if (token === refreshToken)
2684
+ sync.textContent = "Up to date";
2685
+ } finally {
2686
+ if (token === refreshToken) {
2687
+ main.removeAttribute("aria-busy");
2688
+ sync.classList.remove("busy");
2689
+ }
2690
+ }
2520
2691
  }
2521
2692
  const delay = (ms) => new Promise((resolve) => {
2522
2693
  window.setTimeout(resolve, ms);
2523
2694
  });
2524
- const confirmAction = async (button, pending, done, work) => {
2695
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2525
2696
  if (button.dataset.pending === "1")
2526
2697
  return;
2527
2698
  const label = button.textContent ?? "";
2699
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2700
+ button.dataset.confirming = "1";
2701
+ button.classList.add("confirming");
2702
+ button.textContent = confirmLabel;
2703
+ window.setTimeout(() => {
2704
+ if (button.dataset.confirming !== "1")
2705
+ return;
2706
+ button.textContent = label;
2707
+ button.classList.remove("confirming");
2708
+ delete button.dataset.confirming;
2709
+ }, 3e3);
2710
+ return;
2711
+ }
2712
+ button.classList.remove("confirming");
2713
+ delete button.dataset.confirming;
2528
2714
  const width = button.offsetWidth;
2529
2715
  button.dataset.pending = "1";
2530
2716
  button.classList.add("pressed");
@@ -2545,6 +2731,65 @@ function bootAdmin(config) {
2545
2731
  delete button.dataset.pending;
2546
2732
  }
2547
2733
  };
2734
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2735
+ const textarea = el(textareaId, HTMLTextAreaElement);
2736
+ const highlight = el(highlightId, HTMLElement);
2737
+ const status = el(statusId, HTMLElement);
2738
+ const shell = el(shellId, HTMLElement);
2739
+ const sync = () => {
2740
+ const text = textarea.value;
2741
+ highlight.innerHTML = `${highlightJsonText(text)}
2742
+ `;
2743
+ let valid = true;
2744
+ let message = "Valid JSON";
2745
+ if (allowEmpty && text.trim() === "")
2746
+ message = "No body";
2747
+ else {
2748
+ try {
2749
+ readJson2(text);
2750
+ } catch {
2751
+ valid = false;
2752
+ message = "Invalid JSON";
2753
+ }
2754
+ }
2755
+ shell.classList.toggle("invalid", !valid);
2756
+ status.classList.toggle("invalid", !valid);
2757
+ status.textContent = message;
2758
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2759
+ };
2760
+ const syncScroll = () => {
2761
+ const preview2 = highlight.parentElement;
2762
+ if (!(preview2 instanceof HTMLElement))
2763
+ return;
2764
+ preview2.scrollTop = textarea.scrollTop;
2765
+ preview2.scrollLeft = textarea.scrollLeft;
2766
+ };
2767
+ textarea.addEventListener("input", sync);
2768
+ textarea.addEventListener("scroll", syncScroll);
2769
+ sync();
2770
+ return sync;
2771
+ };
2772
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2773
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2774
+ for (const button of document.querySelectorAll("[data-format]")) {
2775
+ if (!(button instanceof HTMLButtonElement))
2776
+ continue;
2777
+ button.addEventListener("click", () => {
2778
+ const target = button.dataset.format;
2779
+ if (target === void 0)
2780
+ return;
2781
+ const textarea = document.getElementById(target);
2782
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2783
+ return;
2784
+ try {
2785
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2786
+ textarea.dispatchEvent(new Event("input"));
2787
+ textarea.focus();
2788
+ } catch {
2789
+ showError(new Error("Fix the JSON before formatting it"));
2790
+ }
2791
+ });
2792
+ }
2548
2793
  document.addEventListener("click", (event) => {
2549
2794
  const target = event.target;
2550
2795
  if (!(target instanceof Element))
@@ -2617,6 +2862,7 @@ function bootAdmin(config) {
2617
2862
  } else {
2618
2863
  editorValue.value = "{\n \n}";
2619
2864
  }
2865
+ syncRecordEditor();
2620
2866
  editor.showModal();
2621
2867
  }
2622
2868
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2646,10 +2892,10 @@ function bootAdmin(config) {
2646
2892
  const id = state.editing;
2647
2893
  if (!id)
2648
2894
  return;
2649
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2895
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2650
2896
  editor.close();
2651
2897
  return renderState();
2652
- }).catch(showError);
2898
+ }), "Confirm delete").catch(showError);
2653
2899
  });
2654
2900
  for (const button of document.querySelectorAll("[data-advance]")) {
2655
2901
  if (!(button instanceof HTMLButtonElement))
@@ -2695,10 +2941,10 @@ function bootAdmin(config) {
2695
2941
  }).then(() => renderFaults()).catch(showError);
2696
2942
  });
2697
2943
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2698
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2944
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2699
2945
  });
2700
2946
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2701
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2947
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2702
2948
  });
2703
2949
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2704
2950
  const target = event.target;
@@ -2712,9 +2958,15 @@ function bootAdmin(config) {
2712
2958
  return;
2713
2959
  state.route = route;
2714
2960
  el("route-title", HTMLElement).textContent = route;
2961
+ el("route-send", HTMLButtonElement).disabled = false;
2715
2962
  const method = route.split(" ")[0] ?? "";
2716
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2963
+ const routeBody = el("route-body", HTMLTextAreaElement);
2964
+ routeBody.disabled = method === "GET" || method === "DELETE";
2965
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2966
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2967
+ syncRouteEditor();
2717
2968
  });
2969
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2718
2970
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2719
2971
  event.preventDefault();
2720
2972
  const send = async () => {
@@ -2736,7 +2988,8 @@ function bootAdmin(config) {
2736
2988
  }
2737
2989
  }
2738
2990
  const data = await api(method, concrete, body);
2739
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2991
+ const text = JSON.stringify(data, null, 2) ?? "null";
2992
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2740
2993
  };
2741
2994
  send().catch(showError);
2742
2995
  });
@@ -2854,11 +3107,13 @@ function bootAdmin(config) {
2854
3107
  views.append(section);
2855
3108
  mountSqlExplorer(body, panelApi);
2856
3109
  }
3110
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2857
3111
  return refresh();
2858
3112
  }).catch(showError);
2859
3113
  }
2860
3114
  var clientPrelude = [
2861
3115
  isRecord2,
3116
+ highlightJsonText,
2862
3117
  mountAdminBrand,
2863
3118
  startAdminBrand,
2864
3119
  faultPresetList,
@@ -2881,6 +3136,7 @@ ${entry}(${payload});
2881
3136
  var defineMock = (runtime) => runtime;
2882
3137
  var STANDARD_ADMIN_ROUTES = [
2883
3138
  "GET /",
3139
+ "GET /health",
2884
3140
  "POST /reset",
2885
3141
  "GET /namespaces",
2886
3142
  "GET /clock",
@@ -2960,11 +3216,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2960
3216
  }
2961
3217
  });
2962
3218
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2963
- var renderAdminDocument = (service) => {
3219
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2964
3220
  const name = escapeHtml(service);
2965
3221
  const script = adminClientSource({
2966
3222
  standardRoutes: STANDARD_ADMIN_ROUTES,
2967
- adminPrefix: ADMIN_PREFIX,
3223
+ adminPrefix,
2968
3224
  adminKeyHeader: ADMIN_KEY_HEADER,
2969
3225
  brandsUrl: ADMIN_BRANDS_URL,
2970
3226
  service
@@ -2981,75 +3237,99 @@ var renderAdminDocument = (service) => {
2981
3237
  ${CSS_RESET}
2982
3238
  :root {
2983
3239
  color-scheme: light;
2984
- --bg: #fafafa;
3240
+ --bg: #f7f8fb;
2985
3241
  --bg-elev: #ffffff;
2986
- --bg-muted: #f4f4f5;
2987
- --bg-inset: #f4f4f5;
2988
- --line: #dedee3;
2989
- --line-strong: #c4c4cc;
2990
- --ink: #18181b;
2991
- --muted: #62626b;
2992
- --faint: #71717a;
2993
- --accent: #27272a;
3242
+ --bg-muted: #f1f3f7;
3243
+ --bg-inset: #f8f9fc;
3244
+ --line: #e4e7ec;
3245
+ --line-strong: #cfd4dc;
3246
+ --ink: #101828;
3247
+ --muted: #475467;
3248
+ --faint: #667085;
3249
+ --accent: #6941c6;
3250
+ --accent-hover: #5933b4;
2994
3251
  --accent-ink: #ffffff;
2995
- --accent-soft: #f4f4f5;
2996
- --ok: #166534;
2997
- --ok-soft: #f0fdf4;
2998
- --warn: #a16207;
2999
- --warn-soft: #fefce8;
3000
- --err: #a82d32;
3001
- --err-soft: #fef2f2;
3002
- --shadow: none;
3252
+ --accent-soft: #f4f0ff;
3253
+ --ok: #067647;
3254
+ --ok-soft: #ecfdf3;
3255
+ --warn: #b54708;
3256
+ --warn-soft: #fffaeb;
3257
+ --err: #b42318;
3258
+ --err-soft: #fef3f2;
3259
+ --code-key: #6941c6;
3260
+ --code-string: #067647;
3261
+ --code-number: #175cd3;
3262
+ --code-boolean: #c11574;
3263
+ --code-null: #667085;
3264
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3265
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3266
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
3003
3267
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3004
3268
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
3005
- --radius: 10px;
3006
- --header: 56px;
3007
- --nav: 200px;
3269
+ --radius: 12px;
3270
+ --header: 72px;
3271
+ --nav: 224px;
3008
3272
  }
3009
3273
  :root[data-theme="dark"] {
3010
3274
  color-scheme: dark;
3011
- --bg: #111113;
3012
- --bg-elev: #19191c;
3013
- --bg-muted: #242428;
3014
- --bg-inset: #111113;
3015
- --line: #36363c;
3016
- --line-strong: #52525b;
3017
- --ink: #f4f4f5;
3018
- --muted: #a9a9b2;
3019
- --faint: #71717a;
3020
- --accent: #e4e4e7;
3021
- --accent-ink: #18181b;
3022
- --accent-soft: #242428;
3023
- --ok: #86efac;
3024
- --ok-soft: #14241b;
3025
- --warn: #fde68a;
3026
- --warn-soft: #2a2410;
3027
- --err: #ffaaaa;
3028
- --err-soft: #2c1516;
3029
- --shadow: none;
3275
+ --bg: #0c0e14;
3276
+ --bg-elev: #151821;
3277
+ --bg-muted: #20232e;
3278
+ --bg-inset: #11141c;
3279
+ --line: #2b303d;
3280
+ --line-strong: #414858;
3281
+ --ink: #f5f7fa;
3282
+ --muted: #b6bdc9;
3283
+ --faint: #8d96a7;
3284
+ --accent: #9e77ed;
3285
+ --accent-hover: #b692f6;
3286
+ --accent-ink: #ffffff;
3287
+ --accent-soft: #2b2142;
3288
+ --ok: #75e0a7;
3289
+ --ok-soft: #102a20;
3290
+ --warn: #fec84b;
3291
+ --warn-soft: #302611;
3292
+ --err: #fda29b;
3293
+ --err-soft: #321817;
3294
+ --code-key: #c3a6ff;
3295
+ --code-string: #75e0a7;
3296
+ --code-number: #84adff;
3297
+ --code-boolean: #f9a8d4;
3298
+ --code-null: #98a2b3;
3299
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3300
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3301
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3030
3302
  }
3031
3303
  @media (prefers-color-scheme: dark) {
3032
3304
  :root:not([data-theme="light"]) {
3033
3305
  color-scheme: dark;
3034
- --bg: #111113;
3035
- --bg-elev: #19191c;
3036
- --bg-muted: #242428;
3037
- --bg-inset: #111113;
3038
- --line: #36363c;
3039
- --line-strong: #52525b;
3040
- --ink: #f4f4f5;
3041
- --muted: #a9a9b2;
3042
- --faint: #71717a;
3043
- --accent: #e4e4e7;
3044
- --accent-ink: #18181b;
3045
- --accent-soft: #242428;
3046
- --ok: #86efac;
3047
- --ok-soft: #14241b;
3048
- --warn: #fde68a;
3049
- --warn-soft: #2a2410;
3050
- --err: #ffaaaa;
3051
- --err-soft: #2c1516;
3052
- --shadow: none;
3306
+ --bg: #0c0e14;
3307
+ --bg-elev: #151821;
3308
+ --bg-muted: #20232e;
3309
+ --bg-inset: #11141c;
3310
+ --line: #2b303d;
3311
+ --line-strong: #414858;
3312
+ --ink: #f5f7fa;
3313
+ --muted: #b6bdc9;
3314
+ --faint: #8d96a7;
3315
+ --accent: #9e77ed;
3316
+ --accent-hover: #b692f6;
3317
+ --accent-ink: #ffffff;
3318
+ --accent-soft: #2b2142;
3319
+ --ok: #75e0a7;
3320
+ --ok-soft: #102a20;
3321
+ --warn: #fec84b;
3322
+ --warn-soft: #302611;
3323
+ --err: #fda29b;
3324
+ --err-soft: #321817;
3325
+ --code-key: #c3a6ff;
3326
+ --code-string: #75e0a7;
3327
+ --code-number: #84adff;
3328
+ --code-boolean: #f9a8d4;
3329
+ --code-null: #98a2b3;
3330
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3331
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3332
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3053
3333
  }
3054
3334
  }
3055
3335
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -3067,13 +3347,17 @@ var renderAdminDocument = (service) => {
3067
3347
  padding-right: env(safe-area-inset-right);
3068
3348
  -webkit-text-size-adjust: 100%;
3069
3349
  }
3070
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3350
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3351
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3352
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3353
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
3071
3354
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
3072
3355
  header.top {
3073
- position: sticky; top: 0; z-index: 5;
3356
+ position: sticky; top: 0; z-index: 20;
3074
3357
  display: flex; align-items: center; gap: 12px;
3075
- min-height: var(--header); padding: 10px 16px;
3076
- background: var(--bg-elev);
3358
+ min-height: var(--header); padding: 12px 20px;
3359
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3360
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
3077
3361
  border-bottom: 1px solid var(--line);
3078
3362
  min-width: 0; max-width: 100%;
3079
3363
  }
@@ -3081,12 +3365,21 @@ var renderAdminDocument = (service) => {
3081
3365
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
3082
3366
  min-width: 0; max-width: 100%; margin-right: auto;
3083
3367
  }
3084
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
3085
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3368
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3369
+ .brand-mark {
3370
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3371
+ border-radius: 11px; color: #fff;
3372
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3373
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3374
+ }
3375
+ .brand-mark svg { width: 22px; height: 22px; }
3376
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3377
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3086
3378
  .brand-id span { color: var(--faint); font-size: 12px; }
3087
3379
  .vendor {
3088
3380
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
3089
3381
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3382
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
3090
3383
  }
3091
3384
  .vendor[hidden] { display: none; }
3092
3385
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -3098,12 +3391,17 @@ var renderAdminDocument = (service) => {
3098
3391
  .vendor-sep { color: var(--faint); }
3099
3392
  .vendor-link { color: inherit; text-decoration: none; }
3100
3393
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
3101
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
3102
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3394
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3395
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3396
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3397
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3398
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
3103
3399
  label.field input, label.field select, .controls input[type="password"] {
3104
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
3105
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3400
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3401
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
3106
3402
  }
3403
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3404
+ 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; }
3107
3405
  /* A native menu ignores min-height, so Preset rendered about half as tall as
3108
3406
  the text fields in the same form. Paint it like those fields. */
3109
3407
  label.field select {
@@ -3120,12 +3418,20 @@ var renderAdminDocument = (service) => {
3120
3418
  dialog .field > textarea { width: 100%; }
3121
3419
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
3122
3420
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
3123
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
3124
- background: var(--bg-elev);
3125
- }
3126
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
3127
- .btn-quiet { background: transparent; }
3128
- .btn-danger { color: var(--err); }
3421
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3422
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3423
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3424
+ box-shadow: var(--shadow-xs);
3425
+ }
3426
+ .icon-btn { width: 38px; padding: 0; }
3427
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3428
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3429
+ .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; }
3430
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3431
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3432
+ .btn-quiet:hover { background: var(--bg-muted); }
3433
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3434
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
3129
3435
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
3130
3436
  so the press itself has to stay visible. */
3131
3437
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -3141,93 +3447,159 @@ var renderAdminDocument = (service) => {
3141
3447
  flex: 1 0 auto; min-width: 0;
3142
3448
  }
3143
3449
  nav.side {
3144
- border-right: 1px solid var(--line); padding: 12px;
3450
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3451
+ border-right: 1px solid var(--line); padding: 18px 12px;
3145
3452
  display: flex; flex-direction: column; gap: 4px;
3146
- background: var(--bg-elev);
3453
+ background: var(--bg-elev); overflow-y: auto;
3147
3454
  }
3455
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
3148
3456
  nav.side button {
3457
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
3149
3458
  text-align: left; border: 0; background: transparent; border-radius: 9px;
3150
- padding: 9px 10px; min-height: 40px; color: var(--muted);
3151
- }
3152
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
3153
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
3154
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
3155
- .lede { margin: 0 0 16px; color: var(--muted); }
3156
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3459
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3460
+ }
3461
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3462
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3463
+ 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); }
3464
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3465
+ .nav-icon svg { width: 18px; height: 18px; }
3466
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3467
+ main[aria-busy="true"] .view { opacity: 0.62; }
3468
+ .view { animation: view-in 180ms ease-out; }
3469
+ .view[hidden] { display: none; }
3470
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3471
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3472
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3473
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3474
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
3157
3475
  .card {
3158
3476
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
3159
- padding: 14px 16px; box-shadow: var(--shadow);
3477
+ padding: 18px; box-shadow: var(--shadow-sm);
3160
3478
  }
3161
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
3162
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3479
+ .stat-card { position: relative; overflow: hidden; }
3480
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3481
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3482
+ .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; }
3163
3483
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
3164
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3484
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3485
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3486
+ .section-title h3 { margin: 0; }
3487
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3488
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3165
3489
  .collection {
3166
3490
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
3167
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3491
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
3168
3492
  }
3169
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3493
+ .collection:hover { background: var(--bg-muted); }
3494
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
3170
3495
  .collection small { display: block; color: var(--faint); }
3171
3496
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
3172
- .chip {
3173
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
3174
- border-radius: 999px; padding: 4px 8px;
3175
- }
3497
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
3176
3498
  .chip i { color: var(--faint); font-style: normal; }
3177
3499
  .sql-layout {
3178
3500
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
3179
3501
  }
3180
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
3181
- .sql-tables button { text-align: left; }
3502
+ .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); }
3503
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3504
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
3182
3505
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
3183
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
3184
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
3185
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3506
+ .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); }
3507
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3508
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3509
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3510
+ tbody tr:last-child td { border-bottom: 0; }
3186
3511
  tr.clickable { cursor: pointer; }
3187
- tr.clickable:hover { background: var(--bg-muted); }
3512
+ tr.clickable:hover { background: var(--accent-soft); }
3188
3513
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
3189
3514
  pre {
3190
3515
  margin: 0; white-space: pre-wrap; word-break: break-word;
3191
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
3192
- }
3193
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3516
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3517
+ }
3518
+ .json-key { color: var(--code-key); }
3519
+ .json-string { color: var(--code-string); }
3520
+ .json-number { color: var(--code-number); }
3521
+ .json-boolean { color: var(--code-boolean); }
3522
+ .json-null { color: var(--code-null); font-style: italic; }
3523
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3524
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3525
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3526
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3527
+ .json-editor.invalid { border-color: var(--err); }
3528
+ .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; }
3529
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3530
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3531
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3532
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3533
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3534
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3535
+ .json-status.invalid { color: var(--err); }
3536
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3537
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
3194
3538
  .banner {
3195
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3196
- background: var(--err-soft); color: var(--err);
3197
- }
3198
- .banner.show { display: block; }
3199
- .empty { color: var(--muted); padding: 24px 8px; }
3539
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3540
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3541
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3542
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3543
+ }
3544
+ .banner.show { display: flex; }
3545
+ .banner span { flex: 1; }
3546
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3547
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3548
+ .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); }
3200
3549
  dialog {
3201
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3202
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3203
- }
3204
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3205
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3550
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3551
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3552
+ }
3553
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3554
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3555
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3556
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3557
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3558
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3206
3559
  textarea {
3207
3560
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3208
3561
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3209
3562
  font-family: var(--mono); font-size: 12px;
3210
3563
  }
3211
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3212
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3564
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3565
+ .dialog-actions .btn-danger { margin-right: auto; }
3566
+ .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); }
3213
3567
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3214
3568
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3215
3569
  .status-2 { color: var(--ok); }
3216
3570
  .status-4, .status-5, .status-0 { color: var(--err); }
3217
3571
  .stack { display: flex; flex-direction: column; gap: 12px; }
3572
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3573
+ .form-grid .wide { grid-column: span 2; }
3218
3574
  .route {
3219
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3220
- padding: 8px 0; border-bottom: 1px solid var(--line);
3221
- }
3222
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3575
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3576
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3577
+ }
3578
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3579
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3580
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3581
+ .route-tools .field { flex: 1; }
3582
+ .route-tools input { width: 100%; }
3583
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3584
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3585
+ .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; }
3586
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3587
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3588
+ @keyframes pulse { 50% { opacity: 0.45; } }
3589
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3590
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3223
3591
  @media (max-width: 800px) {
3592
+ :root { --header: 0px; }
3224
3593
  header.top {
3225
- align-items: stretch; flex-direction: column;
3226
- padding-top: max(10px, env(safe-area-inset-top));
3594
+ position: relative; align-items: stretch; flex-direction: column;
3595
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3227
3596
  }
3228
3597
  input, select, textarea { font-size: 16px; }
3229
3598
  .brand, .vendor, .controls { width: 100%; }
3599
+ .brand { gap: 10px; }
3600
+ .vendor { order: 2; border-radius: 9px; }
3230
3601
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3602
+ .sync-status { display: none; }
3231
3603
  /* The layout fills the screen. A single-column grid stretches every auto
3232
3604
  row, so the tab bar grew with the leftover height and the buttons floated
3233
3605
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3237,14 +3609,23 @@ var renderAdminDocument = (service) => {
3237
3609
  align-content: start;
3238
3610
  }
3239
3611
  nav.side {
3240
- align-self: start; height: auto;
3612
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3241
3613
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3242
3614
  border-right: 0; border-bottom: 1px solid var(--line);
3243
- padding: 8px;
3615
+ padding: 8px; box-shadow: var(--shadow-xs);
3244
3616
  }
3245
- nav.side button { white-space: nowrap; flex: none; }
3617
+ .nav-label { display: none; }
3618
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3619
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3246
3620
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3247
- main { padding: 14px; }
3621
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3622
+ .form-grid .wide { grid-column: auto; }
3623
+ main { padding: 22px 14px 48px; }
3624
+ .view-head { margin-bottom: 18px; }
3625
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3626
+ .card { padding: 14px; }
3627
+ .card .v { font-size: 20px; }
3628
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3248
3629
  table, thead, tbody, tr, th, td { display: block; }
3249
3630
  thead { display: none; }
3250
3631
  tr {
@@ -3259,50 +3640,63 @@ var renderAdminDocument = (service) => {
3259
3640
  .route { grid-template-columns: 72px 1fr; }
3260
3641
  .route button { grid-column: 1 / -1; }
3261
3642
  }
3643
+ @media (max-width: 460px) {
3644
+ .controls label.field { min-width: calc(50% - 4px); }
3645
+ .controls .icon-btn, .controls .btn { flex: 1; }
3646
+ .grid { grid-template-columns: minmax(0, 1fr); }
3647
+ .dialog-actions { flex-wrap: wrap; }
3648
+ .dialog-actions button { flex: 1; }
3649
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3650
+ }
3262
3651
  </style>
3263
3652
  </head>
3264
3653
  <body>
3265
3654
  <div class="app" data-service="${name}" data-mockingbird-admin>
3266
3655
  <header class="top">
3267
3656
  <div class="brand">
3268
- <div class="brand-id">
3269
- <strong>${name}</strong>
3270
- <span>Admin</span>
3657
+ <div class="brand-lockup">
3658
+ <div class="brand-mark" aria-hidden="true">
3659
+ <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>
3660
+ </div>
3661
+ <div class="brand-id">
3662
+ <strong>${name}</strong>
3663
+ <span>Mock admin</span>
3664
+ </div>
3271
3665
  </div>
3272
3666
  <div class="vendor" id="vendor" hidden></div>
3273
3667
  </div>
3274
3668
  <div class="controls">
3669
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3275
3670
  <label class="field">Namespace
3276
3671
  <select id="namespace" aria-label="Namespace"></select>
3277
3672
  </label>
3278
3673
  <label class="field">Admin key
3279
3674
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3280
3675
  </label>
3281
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3282
- <button class="btn" id="refresh" type="button">Refresh</button>
3676
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3677
+ <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>
3283
3678
  </div>
3284
3679
  </header>
3285
3680
  <div class="layout">
3286
3681
  <nav class="side" id="nav" aria-label="Admin sections">
3287
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3288
- <button type="button" data-view="state">State</button>
3289
- <button type="button" data-view="clock">Clock</button>
3290
- <button type="button" data-view="faults">Faults</button>
3291
- <button type="button" data-view="journal">Journal</button>
3292
- <button type="button" data-view="routes">Routes</button>
3682
+ <span class="nav-label">Workspace</span>
3683
+ <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>
3684
+ <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>
3685
+ <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>
3686
+ <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>
3687
+ <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>
3688
+ <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>
3293
3689
  </nav>
3294
3690
  <main id="views">
3295
- <p class="banner" id="banner" role="alert"></p>
3691
+ <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>
3296
3692
  <section class="view" id="view-overview">
3297
- <h2>Overview</h2>
3298
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3693
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3299
3694
  <div class="grid" id="overview-cards"></div>
3300
3695
  <h3>Recent requests</h3>
3301
3696
  <div id="overview-requests"></div>
3302
3697
  </section>
3303
3698
  <section class="view" id="view-state" hidden>
3304
- <h2>State</h2>
3305
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3699
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3306
3700
  <div class="split">
3307
3701
  <div id="collections"></div>
3308
3702
  <div>
@@ -3315,8 +3709,7 @@ var renderAdminDocument = (service) => {
3315
3709
  </div>
3316
3710
  </section>
3317
3711
  <section class="view" id="view-clock" hidden>
3318
- <h2>Clock</h2>
3319
- <p class="lede">The clock every timestamp in this mock reads.</p>
3712
+ <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>
3320
3713
  <div class="card" id="clock-readout"></div>
3321
3714
  <div class="row-actions">
3322
3715
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3335,21 +3728,22 @@ var renderAdminDocument = (service) => {
3335
3728
  </form>
3336
3729
  </section>
3337
3730
  <section class="view" id="view-faults" hidden>
3338
- <h2>Faults</h2>
3339
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3731
+ <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>
3340
3732
  <form id="fault-form" class="card stack">
3341
- <label class="field">Preset
3342
- <select id="fault-preset"><option value="">Custom rule</option></select>
3343
- </label>
3344
- <label class="field">Status
3345
- <input id="fault-status" inputmode="numeric" placeholder="503">
3346
- </label>
3347
- <label class="field">Path prefix
3348
- <input id="fault-path" placeholder="/v1">
3349
- </label>
3350
- <label class="field">Method
3351
- <input id="fault-method" placeholder="GET">
3352
- </label>
3733
+ <div class="form-grid">
3734
+ <label class="field wide">Preset
3735
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3736
+ </label>
3737
+ <label class="field">Status
3738
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3739
+ </label>
3740
+ <label class="field wide">Path prefix
3741
+ <input id="fault-path" placeholder="/v1">
3742
+ </label>
3743
+ <label class="field">Method
3744
+ <input id="fault-method" placeholder="GET">
3745
+ </label>
3746
+ </div>
3353
3747
  <div class="dialog-actions">
3354
3748
  <button class="btn-primary" type="submit">Add fault</button>
3355
3749
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3358,26 +3752,31 @@ var renderAdminDocument = (service) => {
3358
3752
  <div id="fault-list"></div>
3359
3753
  </section>
3360
3754
  <section class="view" id="view-journal" hidden>
3361
- <h2>Journal</h2>
3362
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3363
- <div class="row-actions">
3364
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3365
- </div>
3755
+ <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>
3366
3756
  <div id="journal"></div>
3367
3757
  </section>
3368
3758
  <section class="view" id="view-routes" hidden>
3369
- <h2>Routes</h2>
3370
- <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>
3759
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3760
+ <div class="route-tools">
3761
+ <label class="field">Filter routes
3762
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3763
+ </label>
3764
+ <span class="route-count" id="route-count"></span>
3765
+ </div>
3371
3766
  <div id="route-list"></div>
3372
- <form id="route-form" class="card stack">
3767
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3373
3768
  <strong id="route-title">Select a route</strong>
3374
3769
  <label class="field">JSON body
3375
- <textarea id="route-body" placeholder="{ }"></textarea>
3770
+ <div class="json-editor" id="route-editor" hidden>
3771
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3772
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3773
+ </div>
3774
+ <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>
3376
3775
  </label>
3377
3776
  <div class="dialog-actions">
3378
- <button class="btn-primary" type="submit">Send</button>
3777
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3379
3778
  </div>
3380
- <pre id="route-result">Response will show here.</pre>
3779
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3381
3780
  </form>
3382
3781
  </section>
3383
3782
  </main>
@@ -3385,12 +3784,16 @@ var renderAdminDocument = (service) => {
3385
3784
  </div>
3386
3785
  <dialog id="editor">
3387
3786
  <form method="dialog" id="editor-form">
3388
- <strong id="editor-title">Record</strong>
3787
+ <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>
3389
3788
  <label class="field">Id
3390
3789
  <input id="editor-id" required>
3391
3790
  </label>
3392
3791
  <label class="field">JSON value
3393
- <textarea id="editor-value"></textarea>
3792
+ <div class="json-editor" id="record-editor">
3793
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3794
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3795
+ </div>
3796
+ <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>
3394
3797
  </label>
3395
3798
  <div class="dialog-actions">
3396
3799
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3432,8 +3835,8 @@ var describeExtension = (extension) => {
3432
3835
  ...extension.script !== void 0 ? { script: extension.script } : {}
3433
3836
  };
3434
3837
  };
3435
- var adminUiRoutes = (service, ui) => {
3436
- const shell = () => renderAdminDocument(service);
3838
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3839
+ const shell = () => renderAdminDocument(service, adminPrefix);
3437
3840
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3438
3841
  return {
3439
3842
  "GET /ui": () => htmlResponse(document3()),
@@ -4124,10 +4527,13 @@ var stateAdminRoutes = (routes) => {
4124
4527
  };
4125
4528
 
4126
4529
  // ../core/dist/version.js
4127
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4530
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
4128
4531
 
4129
4532
  // ../core/dist/webhooks.js
4130
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4533
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4534
+ status,
4535
+ headers: { "content-type": "application/json; charset=utf-8" }
4536
+ });
4131
4537
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
4132
4538
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4133
4539
  var parseEndpoint = (value) => {
@@ -4235,7 +4641,6 @@ var AT_HEADER = "x-mockingbird-at";
4235
4641
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4236
4642
  var DEFAULT_NAMESPACE = "default";
4237
4643
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4238
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4239
4644
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4240
4645
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4241
4646
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4274,6 +4679,22 @@ var operationMatcher = (document3) => {
4274
4679
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4275
4680
  };
4276
4681
  var createRuntime = (options) => {
4682
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4683
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4684
+ for (const path of internalPaths) {
4685
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4686
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4687
+ }
4688
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4689
+ const internalMatch = (path) => {
4690
+ for (const pattern of internalPaths) {
4691
+ const actual = path.split("/");
4692
+ const expected = pattern.split("/");
4693
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4694
+ return pattern;
4695
+ }
4696
+ return void 0;
4697
+ };
4277
4698
  assertAdminUi(options.adminUi);
4278
4699
  const sqlite = bootSqlite(options.sqlite);
4279
4700
  const clock = options.clock ?? createClock();
@@ -4288,6 +4709,20 @@ var createRuntime = (options) => {
4288
4709
  const instances = /* @__PURE__ */ new Map();
4289
4710
  const publicNamespaces = /* @__PURE__ */ new Set();
4290
4711
  const branchRngs = /* @__PURE__ */ new Map();
4712
+ const namespaceClocks = /* @__PURE__ */ new Map();
4713
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4714
+ let isolatedNamespaces = false;
4715
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4716
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4717
+ return clock;
4718
+ let found = namespaceClocks.get(name);
4719
+ if (!found) {
4720
+ found = createClock();
4721
+ namespaceClocks.set(name, found);
4722
+ }
4723
+ return found;
4724
+ };
4725
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4291
4726
  const timelines = /* @__PURE__ */ new Map();
4292
4727
  const branchStorage = /* @__PURE__ */ new Map();
4293
4728
  const captured = /* @__PURE__ */ new Map();
@@ -4301,17 +4736,22 @@ var createRuntime = (options) => {
4301
4736
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4302
4737
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4303
4738
  }
4739
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4304
4740
  const created = options.create({
4305
4741
  namespace: storageNamespace(key),
4306
4742
  publicNamespace,
4743
+ adminPrefix,
4307
4744
  sqlite,
4308
- clock,
4309
- rng: isolatedRng ?? rng
4745
+ clock: namespaceClock(publicNamespace),
4746
+ rng: createdContextRng
4310
4747
  });
4311
4748
  instances.set(key, created);
4749
+ namespaceForStorage.set(key, publicNamespace);
4312
4750
  publicNamespaces.add(publicNamespace);
4313
4751
  if (isolatedRng)
4314
4752
  branchRngs.set(key, isolatedRng);
4753
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4754
+ branchRngs.set(key, createdContextRng);
4315
4755
  return created;
4316
4756
  };
4317
4757
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4329,7 +4769,7 @@ var createRuntime = (options) => {
4329
4769
  captured.set(storage, snapshot2);
4330
4770
  return Object.freeze({
4331
4771
  snapshot: snapshot2,
4332
- clock: Object.freeze(clock.state()),
4772
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4333
4773
  rngState: (branchRngs.get(storage) ?? rng).state()
4334
4774
  });
4335
4775
  };
@@ -4339,7 +4779,7 @@ var createRuntime = (options) => {
4339
4779
  return found;
4340
4780
  instance(name);
4341
4781
  found = new Timeline({
4342
- now: clock.now,
4782
+ now: namespaceClock(name).now,
4343
4783
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4344
4784
  });
4345
4785
  found.commit(capture(name));
@@ -4366,12 +4806,13 @@ var createRuntime = (options) => {
4366
4806
  const point = history2.checkout("main", at);
4367
4807
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4368
4808
  captured.set(namespace, point.value.snapshot);
4369
- rng.setState(point.value.rngState);
4370
- clock.set(point.value.clock.now);
4809
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4810
+ const selectedClock = namespaceClock(namespace);
4811
+ selectedClock.set(point.value.clock.now);
4371
4812
  if (point.value.clock.frozen)
4372
- clock.freeze();
4813
+ selectedClock.freeze();
4373
4814
  else
4374
- clock.unfreeze();
4815
+ selectedClock.unfreeze();
4375
4816
  }
4376
4817
  return namespace;
4377
4818
  }
@@ -4429,11 +4870,12 @@ var createRuntime = (options) => {
4429
4870
  const storage = ensureBranch(namespace, branchName);
4430
4871
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4431
4872
  captured.set(storage, point.value.snapshot);
4432
- clock.set(point.value.clock.now);
4873
+ const selectedClock = namespaceClock(namespace);
4874
+ selectedClock.set(point.value.clock.now);
4433
4875
  if (point.value.clock.frozen)
4434
- clock.freeze();
4876
+ selectedClock.freeze();
4435
4877
  else
4436
- clock.unfreeze();
4878
+ selectedClock.unfreeze();
4437
4879
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4438
4880
  };
4439
4881
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4441,10 +4883,21 @@ var createRuntime = (options) => {
4441
4883
  options.webhooks?.clear();
4442
4884
  for (const each of instances.values())
4443
4885
  await each.reset();
4886
+ for (const each of branchRngs.values())
4887
+ each.reset();
4444
4888
  timelines.clear();
4889
+ for (const storage of branchStorage.values()) {
4890
+ instances.delete(storage);
4891
+ branchRngs.delete(storage);
4892
+ namespaceForStorage.delete(storage);
4893
+ }
4445
4894
  branchStorage.clear();
4446
- branchRngs.clear();
4447
4895
  captured.clear();
4896
+ faults.clear();
4897
+ clock.reset();
4898
+ rng.reset();
4899
+ for (const each of namespaceClocks.values())
4900
+ each.reset();
4448
4901
  return;
4449
4902
  }
4450
4903
  options.webhooks?.clear(name);
@@ -4462,11 +4915,18 @@ var createRuntime = (options) => {
4462
4915
  else
4463
4916
  clearNamespace(sqlite, storageNamespace(storage));
4464
4917
  branchStorage.delete(mapping);
4918
+ instances.delete(storage);
4919
+ namespaceForStorage.delete(storage);
4465
4920
  branchRngs.delete(storage);
4466
4921
  captured.delete(storage);
4467
4922
  }
4468
4923
  timelines.delete(name);
4469
4924
  captured.delete(name);
4925
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4926
+ namespaceClock(name).reset();
4927
+ (branchRngs.get(name) ?? rng).reset();
4928
+ journal.clear(name);
4929
+ metrics.reset(name);
4470
4930
  };
4471
4931
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4472
4932
  return checkpoint(name, "main").value.snapshot;
@@ -4504,6 +4964,53 @@ var createRuntime = (options) => {
4504
4964
  rng,
4505
4965
  credentials,
4506
4966
  webhooks: options.webhooks,
4967
+ isolateNamespaces() {
4968
+ if (publicNamespaces.size > 1)
4969
+ throw new Error("enable namespace isolation before creating named namespaces");
4970
+ isolatedNamespaces = true;
4971
+ faults.isolateNamespaces();
4972
+ },
4973
+ namespaceClock,
4974
+ namespaceOf(request) {
4975
+ const url = new URL(request.url);
4976
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4977
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4978
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4979
+ if (isAdminPath(path, adminPrefix))
4980
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4981
+ if (selected !== void 0)
4982
+ return selected;
4983
+ const credential = options.credential?.(request);
4984
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4985
+ },
4986
+ fleetSnapshot(namespace) {
4987
+ instance(namespace);
4988
+ const handle = {};
4989
+ fleetPoints.set(handle, {
4990
+ namespace,
4991
+ state: capture(namespace),
4992
+ faults: faults.snapshot(namespace),
4993
+ webhooks: options.webhooks?.snapshot(namespace)
4994
+ });
4995
+ return handle;
4996
+ },
4997
+ fleetRestore(handle, namespace) {
4998
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4999
+ if (!point || point.namespace !== namespace)
5000
+ throw new Error("invalid fleet checkpoint");
5001
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
5002
+ captured.set(namespace, point.state.snapshot);
5003
+ const selectedClock = namespaceClock(namespace);
5004
+ selectedClock.freeze();
5005
+ selectedClock.set(point.state.clock.now);
5006
+ if (!point.state.clock.frozen)
5007
+ selectedClock.unfreeze();
5008
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
5009
+ faults.restore(namespace, point.faults);
5010
+ if (point.webhooks !== void 0)
5011
+ options.webhooks?.restore(namespace, point.webhooks);
5012
+ timelines.delete(namespace);
5013
+ },
4507
5014
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4508
5015
  const preset = options.presets?.[name];
4509
5016
  if (!preset)
@@ -4535,7 +5042,7 @@ var createRuntime = (options) => {
4535
5042
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4536
5043
  fetch: async (incoming) => {
4537
5044
  let request = incoming;
4538
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5045
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4539
5046
  if (prefixed) {
4540
5047
  const url2 = new URL(request.url);
4541
5048
  url2.pathname = prefixed[2] ?? "/";
@@ -4551,7 +5058,15 @@ var createRuntime = (options) => {
4551
5058
  signal: request.signal
4552
5059
  });
4553
5060
  }
4554
- let namespace = control.namespaceOf(request);
5061
+ const internalUrl = new URL(request.url);
5062
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5063
+ const internal = internalMatch(internalPath);
5064
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5065
+ if (internal) {
5066
+ internalUrl.pathname = internalPath;
5067
+ request = new Request(internalUrl, request);
5068
+ }
5069
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4555
5070
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4556
5071
  const credential = options.credential(request);
4557
5072
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4571,7 +5086,9 @@ var createRuntime = (options) => {
4571
5086
  return copy;
4572
5087
  }
4573
5088
  };
4574
- const handled = await control.handle(request);
5089
+ if (obsoleteInternalPath)
5090
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5091
+ const handled = internal ? void 0 : await control.handle(request);
4575
5092
  if (handled)
4576
5093
  return stamp(handled);
4577
5094
  const started = monotonicNow();
@@ -4612,7 +5129,7 @@ var createRuntime = (options) => {
4612
5129
  type: "mockingbird_admin",
4613
5130
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4614
5131
  }
4615
- }), { status: 400, headers: { "content-type": "application/json" } }));
5132
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4616
5133
  }
4617
5134
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4618
5135
  log(400);
@@ -4679,6 +5196,7 @@ var createRuntime = (options) => {
4679
5196
  startedAt: wallNow(),
4680
5197
  wallNow,
4681
5198
  clock,
5199
+ namespaceClock,
4682
5200
  faults,
4683
5201
  metrics,
4684
5202
  journal,
@@ -4728,15 +5246,16 @@ var createRuntime = (options) => {
4728
5246
  ...options.admin?.(runtime) ?? {},
4729
5247
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4730
5248
  // maps `presets`, and a service route must not replace that list with a record.
4731
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5249
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4732
5250
  ...stateAdminRoutes({
4733
5251
  open: stateScope,
4734
5252
  afterWrite: (namespace) => {
4735
5253
  checkpoint(namespace, "main");
4736
5254
  }
4737
5255
  }),
4738
- ...adminUiRoutes(options.name, options.adminUi)
5256
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4739
5257
  },
5258
+ adminPrefix,
4740
5259
  adminKey: options.adminKey
4741
5260
  });
4742
5261
  return defineMock(runtime);
@@ -4750,7 +5269,10 @@ var mutableResponse = (response) => {
4750
5269
  return new Response(response.body, response);
4751
5270
  }
4752
5271
  };
4753
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5272
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5273
+ status,
5274
+ headers: { "content-type": "application/json; charset=utf-8" }
5275
+ });
4754
5276
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4755
5277
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4756
5278
  var credentialRoutes = (registry) => ({
@@ -4802,7 +5324,7 @@ var credentialRoutes = (registry) => ({
4802
5324
  return adminJson(200, { status: "ok" });
4803
5325
  }
4804
5326
  });
4805
- var presetRoutes = (presets, runtime) => ({
5327
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4806
5328
  "GET /faults/presets": () => {
4807
5329
  const body = {
4808
5330
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4812,7 +5334,7 @@ var presetRoutes = (presets, runtime) => ({
4812
5334
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4813
5335
  const name = params.name;
4814
5336
  if (!presets[name])
4815
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5337
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4816
5338
  const overrides = isObject(body) ? body : {};
4817
5339
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4818
5340
  }
@@ -4977,6 +5499,7 @@ var createRuntime2 = (options = {}) => createRuntime({
4977
5499
  ...options.sqlite ? { sqlite: options.sqlite } : {},
4978
5500
  ...options.clock ? { clock: options.clock } : {},
4979
5501
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5502
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
4980
5503
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
4981
5504
  ...options.onLog ? { onLog: options.onLog } : {},
4982
5505
  credential: apiKeyCredential,
@@ -5215,6 +5738,9 @@ var EasyPostAPI = class {
5215
5738
  };
5216
5739
 
5217
5740
  export {
5741
+ createClock,
5742
+ resolveAdminPrefix,
5743
+ parseDuration,
5218
5744
  document2 as document,
5219
5745
  operationIds,
5220
5746
  supportedOperationIds,
@@ -5229,4 +5755,4 @@ export {
5229
5755
  isTrackerStatus,
5230
5756
  EasyPostAPI
5231
5757
  };
5232
- //# sourceMappingURL=chunk-3TOBBMZD.js.map
5758
+ //# sourceMappingURL=chunk-32BNNR7Z.js.map