@crvouga/mockingbird-service-intercom 1.8.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
  };
@@ -474,7 +514,33 @@ var faultResponse = (rule) => {
474
514
  };
475
515
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
476
516
  const entries = [];
517
+ let isolated = false;
518
+ const scopedRngs = /* @__PURE__ */ new Map();
519
+ const random = (namespace) => {
520
+ if (!isolated || namespace === "default")
521
+ return rng;
522
+ let scoped = scopedRngs.get(namespace);
523
+ if (!scoped) {
524
+ scoped = createRng(rng.seed);
525
+ scopedRngs.set(namespace, scoped);
526
+ }
527
+ return scoped;
528
+ };
477
529
  return {
530
+ isolateNamespaces() {
531
+ isolated = true;
532
+ },
533
+ snapshot(namespace) {
534
+ return {
535
+ rules: structuredClone(entries.map((entry, position) => ({
536
+ ...entry.rule,
537
+ remaining: entry.remaining,
538
+ hits: entry.hits,
539
+ position
540
+ })).filter((entry) => entry.namespace === namespace)),
541
+ rngState: random(namespace).state()
542
+ };
543
+ },
478
544
  add(rule) {
479
545
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
480
546
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -494,6 +560,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
494
560
  },
495
561
  clear() {
496
562
  entries.length = 0;
563
+ rng.reset();
564
+ scopedRngs.clear();
565
+ },
566
+ restore(namespace, state) {
567
+ for (let i = entries.length - 1; i >= 0; i--)
568
+ if (entries[i]?.rule.namespace === namespace)
569
+ entries.splice(i, 1);
570
+ for (const { remaining, hits, position, ...rule } of state.rules)
571
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
572
+ random(namespace).setState(state.rngState);
497
573
  },
498
574
  async take(candidate) {
499
575
  const hits = [];
@@ -503,7 +579,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
503
579
  if (!matches(entry.rule, candidate))
504
580
  continue;
505
581
  const rate = entry.rule.rate ?? 1;
506
- if (rng.next() >= rate)
582
+ if (random(candidate.namespace).next() >= rate)
507
583
  continue;
508
584
  entry.hits++;
509
585
  if (entry.remaining !== null)
@@ -1112,7 +1188,8 @@ var annotateResponse = (response, extra) => {
1112
1188
  var responseNotes = (response) => notes.get(response);
1113
1189
 
1114
1190
  // ../core/dist/metrics.js
1115
- var createMetrics = () => {
1191
+ var createMetrics = (partition = true) => {
1192
+ const namespaces = /* @__PURE__ */ new Map();
1116
1193
  let requests = 0;
1117
1194
  let faults = 0;
1118
1195
  let totalDurationMs = 0;
@@ -1120,6 +1197,14 @@ var createMetrics = () => {
1120
1197
  const unmatched = /* @__PURE__ */ new Map();
1121
1198
  return {
1122
1199
  record(entry) {
1200
+ if (partition) {
1201
+ let scoped = namespaces.get(entry.namespace);
1202
+ if (!scoped) {
1203
+ scoped = createMetrics(false);
1204
+ namespaces.set(entry.namespace, scoped);
1205
+ }
1206
+ scoped.record(entry);
1207
+ }
1123
1208
  requests++;
1124
1209
  totalDurationMs += entry.durationMs;
1125
1210
  if (entry.faultId !== void 0)
@@ -1131,7 +1216,7 @@ var createMetrics = () => {
1131
1216
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1132
1217
  }
1133
1218
  },
1134
- report: () => ({
1219
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1135
1220
  requests,
1136
1221
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1137
1222
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1140,8 +1225,13 @@ var createMetrics = () => {
1140
1225
  }),
1141
1226
  faults,
1142
1227
  totalDurationMs
1143
- }),
1144
- reset() {
1228
+ },
1229
+ reset(namespace) {
1230
+ if (namespace !== void 0) {
1231
+ namespaces.delete(namespace);
1232
+ return;
1233
+ }
1234
+ namespaces.clear();
1145
1235
  requests = 0;
1146
1236
  faults = 0;
1147
1237
  totalDurationMs = 0;
@@ -1185,7 +1275,10 @@ var OutboxStore = class {
1185
1275
  return query.limit !== void 0 ? matched.slice(-Math.max(0, query.limit)) : matched;
1186
1276
  }
1187
1277
  };
1188
- var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
1278
+ var json2 = (status, body) => new Response(JSON.stringify(body), {
1279
+ status,
1280
+ headers: { "content-type": "application/json; charset=utf-8" }
1281
+ });
1189
1282
  var parseSince = (value) => {
1190
1283
  if (value === null)
1191
1284
  return void 0;
@@ -1821,6 +1914,34 @@ dialog:not([open]) {
1821
1914
  function isRecord2(value) {
1822
1915
  return typeof value === "object" && value !== null && !Array.isArray(value);
1823
1916
  }
1917
+ function highlightJsonText(text2) {
1918
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1919
+ switch (char) {
1920
+ case "&":
1921
+ return "&amp;";
1922
+ case "<":
1923
+ return "&lt;";
1924
+ case ">":
1925
+ return "&gt;";
1926
+ case '"':
1927
+ return "&quot;";
1928
+ default:
1929
+ return "&#39;";
1930
+ }
1931
+ });
1932
+ 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;
1933
+ let cursor = 0;
1934
+ let html = "";
1935
+ for (const match of text2.matchAll(token)) {
1936
+ const index = match.index ?? 0;
1937
+ const value = match[0];
1938
+ html += escapeToken(text2.slice(cursor, index));
1939
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1940
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1941
+ cursor = index + value.length;
1942
+ }
1943
+ return html + escapeToken(text2.slice(cursor));
1944
+ }
1824
1945
  function mountAdminBrand(doc, host, service, catalog) {
1825
1946
  const clip = (value, max) => {
1826
1947
  if (typeof value !== "string")
@@ -2182,14 +2303,16 @@ function bootAdmin(config) {
2182
2303
  const state = {
2183
2304
  namespace: "default",
2184
2305
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
2185
- view: "overview",
2306
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
2186
2307
  collection: "",
2187
2308
  shape: null,
2188
2309
  route: null,
2310
+ routes: [],
2189
2311
  editing: null
2190
2312
  };
2191
2313
  const standard = new Set(config.standardRoutes);
2192
2314
  const banner = el("banner", HTMLElement);
2315
+ const bannerMessage = el("banner-message", HTMLElement);
2193
2316
  const keyInput = el("admin-key", HTMLInputElement);
2194
2317
  keyInput.value = state.key;
2195
2318
  const params = new URLSearchParams(location.search);
@@ -2200,18 +2323,21 @@ function bootAdmin(config) {
2200
2323
  sessionStorage.setItem("mockingbird-admin-key", state.key);
2201
2324
  params.delete("key");
2202
2325
  const next = params.toString();
2203
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2326
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
2204
2327
  }
2205
2328
  const queryNamespace = params.get("namespace");
2206
2329
  if (queryNamespace)
2207
2330
  state.namespace = queryNamespace;
2208
2331
  const showError = (error) => {
2209
- banner.textContent = error instanceof Error ? error.message : String(error);
2332
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
2210
2333
  banner.classList.add("show");
2334
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
2211
2335
  };
2212
2336
  const clearError = () => {
2213
2337
  banner.classList.remove("show");
2338
+ bannerMessage.textContent = "";
2214
2339
  };
2340
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
2215
2341
  const api = async (method, path, body) => {
2216
2342
  const headers = { accept: "application/json" };
2217
2343
  if (state.key !== "")
@@ -2255,16 +2381,21 @@ function bootAdmin(config) {
2255
2381
  namespace: () => state.namespace
2256
2382
  };
2257
2383
  const isPanelScript = (value) => typeof value === "function";
2384
+ const themeButton = el("theme", HTMLButtonElement);
2258
2385
  const setTheme = (theme) => {
2259
2386
  if (theme)
2260
2387
  document.documentElement.dataset.theme = theme;
2261
2388
  else
2262
2389
  delete document.documentElement.dataset.theme;
2390
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2391
+ 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>';
2392
+ const label = dark ? "Use light theme" : "Use dark theme";
2393
+ themeButton.setAttribute("aria-label", label);
2394
+ themeButton.title = label;
2263
2395
  };
2264
2396
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2265
- if (storedTheme)
2266
- setTheme(storedTheme);
2267
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2397
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2398
+ themeButton.addEventListener("click", () => {
2268
2399
  const current = document.documentElement.dataset.theme;
2269
2400
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2270
2401
  const next = dark ? "light" : "dark";
@@ -2283,6 +2414,7 @@ function bootAdmin(config) {
2283
2414
  continue;
2284
2415
  section.hidden = section.id !== `view-${view}`;
2285
2416
  }
2417
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2286
2418
  };
2287
2419
  el("nav", HTMLElement).addEventListener("click", (event) => {
2288
2420
  const target = event.target;
@@ -2297,7 +2429,7 @@ function bootAdmin(config) {
2297
2429
  show(view);
2298
2430
  refresh().catch(showError);
2299
2431
  });
2300
- 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>`;
2432
+ 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>`;
2301
2433
  const table = (columns, rows) => {
2302
2434
  if (rows.length === 0)
2303
2435
  return '<p class="empty">Nothing here yet.</p>';
@@ -2307,16 +2439,22 @@ function bootAdmin(config) {
2307
2439
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2308
2440
  return `<tr${attrs}>${cells}</tr>`;
2309
2441
  }).join("");
2310
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2442
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2311
2443
  };
2312
2444
  const preview = (value) => {
2313
2445
  const text2 = JSON.stringify(value) ?? "null";
2314
- return esc(text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2);
2446
+ const clipped = text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2;
2447
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2315
2448
  };
2316
2449
  const when = (epoch) => {
2317
2450
  const date = new Date(epoch);
2318
2451
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2319
2452
  };
2453
+ const journalWhen = (value) => {
2454
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2455
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2456
+ return Number.isFinite(epoch) ? when(epoch) : value;
2457
+ };
2320
2458
  const clockCard = (epoch) => {
2321
2459
  const date = new Date(epoch);
2322
2460
  if (Number.isNaN(date.getTime()))
@@ -2436,7 +2574,7 @@ function bootAdmin(config) {
2436
2574
  };
2437
2575
  const renderOverview = async () => {
2438
2576
  const [health, shape, clock, journal] = await Promise.all([
2439
- originGet("/health"),
2577
+ originGet(`${config.adminPrefix}/health`),
2440
2578
  api("GET", "/state"),
2441
2579
  api("GET", "/clock"),
2442
2580
  api("GET", "/requests?limit=8")
@@ -2453,7 +2591,7 @@ function bootAdmin(config) {
2453
2591
  },
2454
2592
  {
2455
2593
  k: "Status",
2456
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2594
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2457
2595
  },
2458
2596
  { k: "Namespace", v: esc(state.namespace) },
2459
2597
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2540,26 +2678,46 @@ function bootAdmin(config) {
2540
2678
  const renderJournal = async () => {
2541
2679
  const journal = await api("GET", "/requests?limit=100");
2542
2680
  el("journal", HTMLElement).innerHTML = table([
2543
- { label: "When", cell: (row) => esc(row.at) },
2544
- { label: "Method", cell: (row) => esc(row.method) },
2681
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2682
+ {
2683
+ label: "Method",
2684
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2685
+ },
2545
2686
  { label: "Path", cell: (row) => esc(row.path) },
2546
- { label: "Status", cell: (row) => esc(row.status) },
2687
+ {
2688
+ label: "Status",
2689
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2690
+ },
2547
2691
  { label: "ms", cell: (row) => esc(row.durationMs) }
2548
2692
  ], decodeJournal(journal));
2549
2693
  };
2550
- const renderRoutes = async () => {
2551
- const data = await api("GET", "/");
2552
- if (!isRecord2(data))
2553
- throw new Error("Route list was not an object");
2554
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2694
+ const paintRoutes = () => {
2695
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2696
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2697
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2698
+ if (state.routes.length === 0) {
2699
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2700
+ return;
2701
+ }
2702
+ if (routes.length === 0) {
2703
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2704
+ return;
2705
+ }
2555
2706
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2556
2707
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2557
2708
  const parts = route.split(" ");
2558
2709
  const method = parts[0] ?? "";
2559
2710
  const path = parts.slice(1).join(" ");
2560
- 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>`;
2711
+ 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>`;
2561
2712
  }).join("");
2562
2713
  };
2714
+ const renderRoutes = async () => {
2715
+ const data = await api("GET", "/");
2716
+ if (!isRecord2(data))
2717
+ throw new Error("Route list was not an object");
2718
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2719
+ paintRoutes();
2720
+ };
2563
2721
  const renderers = {
2564
2722
  overview: renderOverview,
2565
2723
  state: renderState,
@@ -2568,19 +2726,50 @@ function bootAdmin(config) {
2568
2726
  journal: renderJournal,
2569
2727
  routes: renderRoutes
2570
2728
  };
2729
+ let refreshToken = 0;
2571
2730
  async function refresh() {
2731
+ const token = ++refreshToken;
2572
2732
  clearError();
2733
+ const main = el("views", HTMLElement);
2734
+ const sync = el("sync-status", HTMLElement);
2735
+ main.setAttribute("aria-busy", "true");
2736
+ sync.classList.add("busy");
2737
+ sync.textContent = "Updating";
2573
2738
  const render = renderers[state.view];
2574
- if (render)
2575
- await render();
2739
+ try {
2740
+ if (render)
2741
+ await render();
2742
+ if (token === refreshToken)
2743
+ sync.textContent = "Up to date";
2744
+ } finally {
2745
+ if (token === refreshToken) {
2746
+ main.removeAttribute("aria-busy");
2747
+ sync.classList.remove("busy");
2748
+ }
2749
+ }
2576
2750
  }
2577
2751
  const delay = (ms) => new Promise((resolve) => {
2578
2752
  window.setTimeout(resolve, ms);
2579
2753
  });
2580
- const confirmAction = async (button, pending, done, work) => {
2754
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2581
2755
  if (button.dataset.pending === "1")
2582
2756
  return;
2583
2757
  const label = button.textContent ?? "";
2758
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2759
+ button.dataset.confirming = "1";
2760
+ button.classList.add("confirming");
2761
+ button.textContent = confirmLabel;
2762
+ window.setTimeout(() => {
2763
+ if (button.dataset.confirming !== "1")
2764
+ return;
2765
+ button.textContent = label;
2766
+ button.classList.remove("confirming");
2767
+ delete button.dataset.confirming;
2768
+ }, 3e3);
2769
+ return;
2770
+ }
2771
+ button.classList.remove("confirming");
2772
+ delete button.dataset.confirming;
2584
2773
  const width = button.offsetWidth;
2585
2774
  button.dataset.pending = "1";
2586
2775
  button.classList.add("pressed");
@@ -2601,6 +2790,65 @@ function bootAdmin(config) {
2601
2790
  delete button.dataset.pending;
2602
2791
  }
2603
2792
  };
2793
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2794
+ const textarea = el(textareaId, HTMLTextAreaElement);
2795
+ const highlight = el(highlightId, HTMLElement);
2796
+ const status = el(statusId, HTMLElement);
2797
+ const shell = el(shellId, HTMLElement);
2798
+ const sync = () => {
2799
+ const text2 = textarea.value;
2800
+ highlight.innerHTML = `${highlightJsonText(text2)}
2801
+ `;
2802
+ let valid = true;
2803
+ let message = "Valid JSON";
2804
+ if (allowEmpty && text2.trim() === "")
2805
+ message = "No body";
2806
+ else {
2807
+ try {
2808
+ readJson2(text2);
2809
+ } catch {
2810
+ valid = false;
2811
+ message = "Invalid JSON";
2812
+ }
2813
+ }
2814
+ shell.classList.toggle("invalid", !valid);
2815
+ status.classList.toggle("invalid", !valid);
2816
+ status.textContent = message;
2817
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2818
+ };
2819
+ const syncScroll = () => {
2820
+ const preview2 = highlight.parentElement;
2821
+ if (!(preview2 instanceof HTMLElement))
2822
+ return;
2823
+ preview2.scrollTop = textarea.scrollTop;
2824
+ preview2.scrollLeft = textarea.scrollLeft;
2825
+ };
2826
+ textarea.addEventListener("input", sync);
2827
+ textarea.addEventListener("scroll", syncScroll);
2828
+ sync();
2829
+ return sync;
2830
+ };
2831
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2832
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2833
+ for (const button of document.querySelectorAll("[data-format]")) {
2834
+ if (!(button instanceof HTMLButtonElement))
2835
+ continue;
2836
+ button.addEventListener("click", () => {
2837
+ const target = button.dataset.format;
2838
+ if (target === void 0)
2839
+ return;
2840
+ const textarea = document.getElementById(target);
2841
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2842
+ return;
2843
+ try {
2844
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2845
+ textarea.dispatchEvent(new Event("input"));
2846
+ textarea.focus();
2847
+ } catch {
2848
+ showError(new Error("Fix the JSON before formatting it"));
2849
+ }
2850
+ });
2851
+ }
2604
2852
  document.addEventListener("click", (event) => {
2605
2853
  const target = event.target;
2606
2854
  if (!(target instanceof Element))
@@ -2673,6 +2921,7 @@ function bootAdmin(config) {
2673
2921
  } else {
2674
2922
  editorValue.value = "{\n \n}";
2675
2923
  }
2924
+ syncRecordEditor();
2676
2925
  editor.showModal();
2677
2926
  }
2678
2927
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2702,10 +2951,10 @@ function bootAdmin(config) {
2702
2951
  const id = state.editing;
2703
2952
  if (!id)
2704
2953
  return;
2705
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2954
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2706
2955
  editor.close();
2707
2956
  return renderState();
2708
- }).catch(showError);
2957
+ }), "Confirm delete").catch(showError);
2709
2958
  });
2710
2959
  for (const button of document.querySelectorAll("[data-advance]")) {
2711
2960
  if (!(button instanceof HTMLButtonElement))
@@ -2751,10 +3000,10 @@ function bootAdmin(config) {
2751
3000
  }).then(() => renderFaults()).catch(showError);
2752
3001
  });
2753
3002
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2754
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
3003
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2755
3004
  });
2756
3005
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2757
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
3006
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2758
3007
  });
2759
3008
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2760
3009
  const target = event.target;
@@ -2768,9 +3017,15 @@ function bootAdmin(config) {
2768
3017
  return;
2769
3018
  state.route = route;
2770
3019
  el("route-title", HTMLElement).textContent = route;
3020
+ el("route-send", HTMLButtonElement).disabled = false;
2771
3021
  const method = route.split(" ")[0] ?? "";
2772
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
3022
+ const routeBody = el("route-body", HTMLTextAreaElement);
3023
+ routeBody.disabled = method === "GET" || method === "DELETE";
3024
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
3025
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
3026
+ syncRouteEditor();
2773
3027
  });
3028
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2774
3029
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2775
3030
  event.preventDefault();
2776
3031
  const send = async () => {
@@ -2792,7 +3047,8 @@ function bootAdmin(config) {
2792
3047
  }
2793
3048
  }
2794
3049
  const data = await api(method, concrete, body);
2795
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
3050
+ const text2 = JSON.stringify(data, null, 2) ?? "null";
3051
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text2)}</code>`;
2796
3052
  };
2797
3053
  send().catch(showError);
2798
3054
  });
@@ -2910,11 +3166,13 @@ function bootAdmin(config) {
2910
3166
  views.append(section);
2911
3167
  mountSqlExplorer(body, panelApi);
2912
3168
  }
3169
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2913
3170
  return refresh();
2914
3171
  }).catch(showError);
2915
3172
  }
2916
3173
  var clientPrelude = [
2917
3174
  isRecord2,
3175
+ highlightJsonText,
2918
3176
  mountAdminBrand,
2919
3177
  startAdminBrand,
2920
3178
  faultPresetList,
@@ -2937,6 +3195,7 @@ ${entry}(${payload});
2937
3195
  var defineMock = (runtime) => runtime;
2938
3196
  var STANDARD_ADMIN_ROUTES = [
2939
3197
  "GET /",
3198
+ "GET /health",
2940
3199
  "POST /reset",
2941
3200
  "GET /namespaces",
2942
3201
  "GET /clock",
@@ -3016,11 +3275,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
3016
3275
  }
3017
3276
  });
3018
3277
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
3019
- var renderAdminDocument = (service) => {
3278
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
3020
3279
  const name = escapeHtml(service);
3021
3280
  const script = adminClientSource({
3022
3281
  standardRoutes: STANDARD_ADMIN_ROUTES,
3023
- adminPrefix: ADMIN_PREFIX,
3282
+ adminPrefix,
3024
3283
  adminKeyHeader: ADMIN_KEY_HEADER,
3025
3284
  brandsUrl: ADMIN_BRANDS_URL,
3026
3285
  service
@@ -3037,75 +3296,99 @@ var renderAdminDocument = (service) => {
3037
3296
  ${CSS_RESET}
3038
3297
  :root {
3039
3298
  color-scheme: light;
3040
- --bg: #fafafa;
3299
+ --bg: #f7f8fb;
3041
3300
  --bg-elev: #ffffff;
3042
- --bg-muted: #f4f4f5;
3043
- --bg-inset: #f4f4f5;
3044
- --line: #dedee3;
3045
- --line-strong: #c4c4cc;
3046
- --ink: #18181b;
3047
- --muted: #62626b;
3048
- --faint: #71717a;
3049
- --accent: #27272a;
3301
+ --bg-muted: #f1f3f7;
3302
+ --bg-inset: #f8f9fc;
3303
+ --line: #e4e7ec;
3304
+ --line-strong: #cfd4dc;
3305
+ --ink: #101828;
3306
+ --muted: #475467;
3307
+ --faint: #667085;
3308
+ --accent: #6941c6;
3309
+ --accent-hover: #5933b4;
3050
3310
  --accent-ink: #ffffff;
3051
- --accent-soft: #f4f4f5;
3052
- --ok: #166534;
3053
- --ok-soft: #f0fdf4;
3054
- --warn: #a16207;
3055
- --warn-soft: #fefce8;
3056
- --err: #a82d32;
3057
- --err-soft: #fef2f2;
3058
- --shadow: none;
3311
+ --accent-soft: #f4f0ff;
3312
+ --ok: #067647;
3313
+ --ok-soft: #ecfdf3;
3314
+ --warn: #b54708;
3315
+ --warn-soft: #fffaeb;
3316
+ --err: #b42318;
3317
+ --err-soft: #fef3f2;
3318
+ --code-key: #6941c6;
3319
+ --code-string: #067647;
3320
+ --code-number: #175cd3;
3321
+ --code-boolean: #c11574;
3322
+ --code-null: #667085;
3323
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3324
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3325
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
3059
3326
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3060
3327
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
3061
- --radius: 10px;
3062
- --header: 56px;
3063
- --nav: 200px;
3328
+ --radius: 12px;
3329
+ --header: 72px;
3330
+ --nav: 224px;
3064
3331
  }
3065
3332
  :root[data-theme="dark"] {
3066
3333
  color-scheme: dark;
3067
- --bg: #111113;
3068
- --bg-elev: #19191c;
3069
- --bg-muted: #242428;
3070
- --bg-inset: #111113;
3071
- --line: #36363c;
3072
- --line-strong: #52525b;
3073
- --ink: #f4f4f5;
3074
- --muted: #a9a9b2;
3075
- --faint: #71717a;
3076
- --accent: #e4e4e7;
3077
- --accent-ink: #18181b;
3078
- --accent-soft: #242428;
3079
- --ok: #86efac;
3080
- --ok-soft: #14241b;
3081
- --warn: #fde68a;
3082
- --warn-soft: #2a2410;
3083
- --err: #ffaaaa;
3084
- --err-soft: #2c1516;
3085
- --shadow: none;
3334
+ --bg: #0c0e14;
3335
+ --bg-elev: #151821;
3336
+ --bg-muted: #20232e;
3337
+ --bg-inset: #11141c;
3338
+ --line: #2b303d;
3339
+ --line-strong: #414858;
3340
+ --ink: #f5f7fa;
3341
+ --muted: #b6bdc9;
3342
+ --faint: #8d96a7;
3343
+ --accent: #9e77ed;
3344
+ --accent-hover: #b692f6;
3345
+ --accent-ink: #ffffff;
3346
+ --accent-soft: #2b2142;
3347
+ --ok: #75e0a7;
3348
+ --ok-soft: #102a20;
3349
+ --warn: #fec84b;
3350
+ --warn-soft: #302611;
3351
+ --err: #fda29b;
3352
+ --err-soft: #321817;
3353
+ --code-key: #c3a6ff;
3354
+ --code-string: #75e0a7;
3355
+ --code-number: #84adff;
3356
+ --code-boolean: #f9a8d4;
3357
+ --code-null: #98a2b3;
3358
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3359
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3360
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3086
3361
  }
3087
3362
  @media (prefers-color-scheme: dark) {
3088
3363
  :root:not([data-theme="light"]) {
3089
3364
  color-scheme: dark;
3090
- --bg: #111113;
3091
- --bg-elev: #19191c;
3092
- --bg-muted: #242428;
3093
- --bg-inset: #111113;
3094
- --line: #36363c;
3095
- --line-strong: #52525b;
3096
- --ink: #f4f4f5;
3097
- --muted: #a9a9b2;
3098
- --faint: #71717a;
3099
- --accent: #e4e4e7;
3100
- --accent-ink: #18181b;
3101
- --accent-soft: #242428;
3102
- --ok: #86efac;
3103
- --ok-soft: #14241b;
3104
- --warn: #fde68a;
3105
- --warn-soft: #2a2410;
3106
- --err: #ffaaaa;
3107
- --err-soft: #2c1516;
3108
- --shadow: none;
3365
+ --bg: #0c0e14;
3366
+ --bg-elev: #151821;
3367
+ --bg-muted: #20232e;
3368
+ --bg-inset: #11141c;
3369
+ --line: #2b303d;
3370
+ --line-strong: #414858;
3371
+ --ink: #f5f7fa;
3372
+ --muted: #b6bdc9;
3373
+ --faint: #8d96a7;
3374
+ --accent: #9e77ed;
3375
+ --accent-hover: #b692f6;
3376
+ --accent-ink: #ffffff;
3377
+ --accent-soft: #2b2142;
3378
+ --ok: #75e0a7;
3379
+ --ok-soft: #102a20;
3380
+ --warn: #fec84b;
3381
+ --warn-soft: #302611;
3382
+ --err: #fda29b;
3383
+ --err-soft: #321817;
3384
+ --code-key: #c3a6ff;
3385
+ --code-string: #75e0a7;
3386
+ --code-number: #84adff;
3387
+ --code-boolean: #f9a8d4;
3388
+ --code-null: #98a2b3;
3389
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3390
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3391
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3109
3392
  }
3110
3393
  }
3111
3394
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -3123,13 +3406,17 @@ var renderAdminDocument = (service) => {
3123
3406
  padding-right: env(safe-area-inset-right);
3124
3407
  -webkit-text-size-adjust: 100%;
3125
3408
  }
3126
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3409
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3410
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3411
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3412
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
3127
3413
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
3128
3414
  header.top {
3129
- position: sticky; top: 0; z-index: 5;
3415
+ position: sticky; top: 0; z-index: 20;
3130
3416
  display: flex; align-items: center; gap: 12px;
3131
- min-height: var(--header); padding: 10px 16px;
3132
- background: var(--bg-elev);
3417
+ min-height: var(--header); padding: 12px 20px;
3418
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3419
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
3133
3420
  border-bottom: 1px solid var(--line);
3134
3421
  min-width: 0; max-width: 100%;
3135
3422
  }
@@ -3137,12 +3424,21 @@ var renderAdminDocument = (service) => {
3137
3424
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
3138
3425
  min-width: 0; max-width: 100%; margin-right: auto;
3139
3426
  }
3140
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
3141
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3427
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3428
+ .brand-mark {
3429
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3430
+ border-radius: 11px; color: #fff;
3431
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3432
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3433
+ }
3434
+ .brand-mark svg { width: 22px; height: 22px; }
3435
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3436
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3142
3437
  .brand-id span { color: var(--faint); font-size: 12px; }
3143
3438
  .vendor {
3144
3439
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
3145
3440
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3441
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
3146
3442
  }
3147
3443
  .vendor[hidden] { display: none; }
3148
3444
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -3154,12 +3450,17 @@ var renderAdminDocument = (service) => {
3154
3450
  .vendor-sep { color: var(--faint); }
3155
3451
  .vendor-link { color: inherit; text-decoration: none; }
3156
3452
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
3157
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
3158
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3453
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3454
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3455
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3456
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3457
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
3159
3458
  label.field input, label.field select, .controls input[type="password"] {
3160
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
3161
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3459
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3460
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
3162
3461
  }
3462
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3463
+ 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; }
3163
3464
  /* A native menu ignores min-height, so Preset rendered about half as tall as
3164
3465
  the text fields in the same form. Paint it like those fields. */
3165
3466
  label.field select {
@@ -3176,12 +3477,20 @@ var renderAdminDocument = (service) => {
3176
3477
  dialog .field > textarea { width: 100%; }
3177
3478
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
3178
3479
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
3179
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
3180
- background: var(--bg-elev);
3181
- }
3182
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
3183
- .btn-quiet { background: transparent; }
3184
- .btn-danger { color: var(--err); }
3480
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3481
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3482
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3483
+ box-shadow: var(--shadow-xs);
3484
+ }
3485
+ .icon-btn { width: 38px; padding: 0; }
3486
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3487
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3488
+ .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; }
3489
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3490
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3491
+ .btn-quiet:hover { background: var(--bg-muted); }
3492
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3493
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
3185
3494
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
3186
3495
  so the press itself has to stay visible. */
3187
3496
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -3197,93 +3506,159 @@ var renderAdminDocument = (service) => {
3197
3506
  flex: 1 0 auto; min-width: 0;
3198
3507
  }
3199
3508
  nav.side {
3200
- border-right: 1px solid var(--line); padding: 12px;
3509
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3510
+ border-right: 1px solid var(--line); padding: 18px 12px;
3201
3511
  display: flex; flex-direction: column; gap: 4px;
3202
- background: var(--bg-elev);
3512
+ background: var(--bg-elev); overflow-y: auto;
3203
3513
  }
3514
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
3204
3515
  nav.side button {
3516
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
3205
3517
  text-align: left; border: 0; background: transparent; border-radius: 9px;
3206
- padding: 9px 10px; min-height: 40px; color: var(--muted);
3207
- }
3208
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
3209
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
3210
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
3211
- .lede { margin: 0 0 16px; color: var(--muted); }
3212
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3518
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3519
+ }
3520
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3521
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3522
+ 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); }
3523
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3524
+ .nav-icon svg { width: 18px; height: 18px; }
3525
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3526
+ main[aria-busy="true"] .view { opacity: 0.62; }
3527
+ .view { animation: view-in 180ms ease-out; }
3528
+ .view[hidden] { display: none; }
3529
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3530
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3531
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3532
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3533
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
3213
3534
  .card {
3214
3535
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
3215
- padding: 14px 16px; box-shadow: var(--shadow);
3536
+ padding: 18px; box-shadow: var(--shadow-sm);
3216
3537
  }
3217
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
3218
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3538
+ .stat-card { position: relative; overflow: hidden; }
3539
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3540
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3541
+ .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; }
3219
3542
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
3220
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3543
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3544
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3545
+ .section-title h3 { margin: 0; }
3546
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3547
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3221
3548
  .collection {
3222
3549
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
3223
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3550
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
3224
3551
  }
3225
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3552
+ .collection:hover { background: var(--bg-muted); }
3553
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
3226
3554
  .collection small { display: block; color: var(--faint); }
3227
3555
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
3228
- .chip {
3229
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
3230
- border-radius: 999px; padding: 4px 8px;
3231
- }
3556
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
3232
3557
  .chip i { color: var(--faint); font-style: normal; }
3233
3558
  .sql-layout {
3234
3559
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
3235
3560
  }
3236
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
3237
- .sql-tables button { text-align: left; }
3561
+ .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); }
3562
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3563
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
3238
3564
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
3239
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
3240
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
3241
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3565
+ .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); }
3566
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3567
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3568
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3569
+ tbody tr:last-child td { border-bottom: 0; }
3242
3570
  tr.clickable { cursor: pointer; }
3243
- tr.clickable:hover { background: var(--bg-muted); }
3571
+ tr.clickable:hover { background: var(--accent-soft); }
3244
3572
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
3245
3573
  pre {
3246
3574
  margin: 0; white-space: pre-wrap; word-break: break-word;
3247
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
3248
- }
3249
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3575
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3576
+ }
3577
+ .json-key { color: var(--code-key); }
3578
+ .json-string { color: var(--code-string); }
3579
+ .json-number { color: var(--code-number); }
3580
+ .json-boolean { color: var(--code-boolean); }
3581
+ .json-null { color: var(--code-null); font-style: italic; }
3582
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3583
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3584
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3585
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3586
+ .json-editor.invalid { border-color: var(--err); }
3587
+ .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; }
3588
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3589
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3590
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3591
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3592
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3593
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3594
+ .json-status.invalid { color: var(--err); }
3595
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3596
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
3250
3597
  .banner {
3251
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3252
- background: var(--err-soft); color: var(--err);
3253
- }
3254
- .banner.show { display: block; }
3255
- .empty { color: var(--muted); padding: 24px 8px; }
3598
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3599
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3600
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3601
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3602
+ }
3603
+ .banner.show { display: flex; }
3604
+ .banner span { flex: 1; }
3605
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3606
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3607
+ .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); }
3256
3608
  dialog {
3257
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3258
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3259
- }
3260
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3261
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3609
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3610
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3611
+ }
3612
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3613
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3614
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3615
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3616
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3617
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3262
3618
  textarea {
3263
3619
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3264
3620
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3265
3621
  font-family: var(--mono); font-size: 12px;
3266
3622
  }
3267
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3268
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3623
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3624
+ .dialog-actions .btn-danger { margin-right: auto; }
3625
+ .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); }
3269
3626
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3270
3627
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3271
3628
  .status-2 { color: var(--ok); }
3272
3629
  .status-4, .status-5, .status-0 { color: var(--err); }
3273
3630
  .stack { display: flex; flex-direction: column; gap: 12px; }
3631
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3632
+ .form-grid .wide { grid-column: span 2; }
3274
3633
  .route {
3275
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3276
- padding: 8px 0; border-bottom: 1px solid var(--line);
3277
- }
3278
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3634
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3635
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3636
+ }
3637
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3638
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3639
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3640
+ .route-tools .field { flex: 1; }
3641
+ .route-tools input { width: 100%; }
3642
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3643
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3644
+ .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; }
3645
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3646
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3647
+ @keyframes pulse { 50% { opacity: 0.45; } }
3648
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3649
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3279
3650
  @media (max-width: 800px) {
3651
+ :root { --header: 0px; }
3280
3652
  header.top {
3281
- align-items: stretch; flex-direction: column;
3282
- padding-top: max(10px, env(safe-area-inset-top));
3653
+ position: relative; align-items: stretch; flex-direction: column;
3654
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3283
3655
  }
3284
3656
  input, select, textarea { font-size: 16px; }
3285
3657
  .brand, .vendor, .controls { width: 100%; }
3658
+ .brand { gap: 10px; }
3659
+ .vendor { order: 2; border-radius: 9px; }
3286
3660
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3661
+ .sync-status { display: none; }
3287
3662
  /* The layout fills the screen. A single-column grid stretches every auto
3288
3663
  row, so the tab bar grew with the leftover height and the buttons floated
3289
3664
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3293,14 +3668,23 @@ var renderAdminDocument = (service) => {
3293
3668
  align-content: start;
3294
3669
  }
3295
3670
  nav.side {
3296
- align-self: start; height: auto;
3671
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3297
3672
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3298
3673
  border-right: 0; border-bottom: 1px solid var(--line);
3299
- padding: 8px;
3674
+ padding: 8px; box-shadow: var(--shadow-xs);
3300
3675
  }
3301
- nav.side button { white-space: nowrap; flex: none; }
3676
+ .nav-label { display: none; }
3677
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3678
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3302
3679
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3303
- main { padding: 14px; }
3680
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3681
+ .form-grid .wide { grid-column: auto; }
3682
+ main { padding: 22px 14px 48px; }
3683
+ .view-head { margin-bottom: 18px; }
3684
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3685
+ .card { padding: 14px; }
3686
+ .card .v { font-size: 20px; }
3687
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3304
3688
  table, thead, tbody, tr, th, td { display: block; }
3305
3689
  thead { display: none; }
3306
3690
  tr {
@@ -3315,50 +3699,63 @@ var renderAdminDocument = (service) => {
3315
3699
  .route { grid-template-columns: 72px 1fr; }
3316
3700
  .route button { grid-column: 1 / -1; }
3317
3701
  }
3702
+ @media (max-width: 460px) {
3703
+ .controls label.field { min-width: calc(50% - 4px); }
3704
+ .controls .icon-btn, .controls .btn { flex: 1; }
3705
+ .grid { grid-template-columns: minmax(0, 1fr); }
3706
+ .dialog-actions { flex-wrap: wrap; }
3707
+ .dialog-actions button { flex: 1; }
3708
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3709
+ }
3318
3710
  </style>
3319
3711
  </head>
3320
3712
  <body>
3321
3713
  <div class="app" data-service="${name}" data-mockingbird-admin>
3322
3714
  <header class="top">
3323
3715
  <div class="brand">
3324
- <div class="brand-id">
3325
- <strong>${name}</strong>
3326
- <span>Admin</span>
3716
+ <div class="brand-lockup">
3717
+ <div class="brand-mark" aria-hidden="true">
3718
+ <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>
3719
+ </div>
3720
+ <div class="brand-id">
3721
+ <strong>${name}</strong>
3722
+ <span>Mock admin</span>
3723
+ </div>
3327
3724
  </div>
3328
3725
  <div class="vendor" id="vendor" hidden></div>
3329
3726
  </div>
3330
3727
  <div class="controls">
3728
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3331
3729
  <label class="field">Namespace
3332
3730
  <select id="namespace" aria-label="Namespace"></select>
3333
3731
  </label>
3334
3732
  <label class="field">Admin key
3335
3733
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3336
3734
  </label>
3337
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3338
- <button class="btn" id="refresh" type="button">Refresh</button>
3735
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3736
+ <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>
3339
3737
  </div>
3340
3738
  </header>
3341
3739
  <div class="layout">
3342
3740
  <nav class="side" id="nav" aria-label="Admin sections">
3343
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3344
- <button type="button" data-view="state">State</button>
3345
- <button type="button" data-view="clock">Clock</button>
3346
- <button type="button" data-view="faults">Faults</button>
3347
- <button type="button" data-view="journal">Journal</button>
3348
- <button type="button" data-view="routes">Routes</button>
3741
+ <span class="nav-label">Workspace</span>
3742
+ <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>
3743
+ <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>
3744
+ <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>
3745
+ <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>
3746
+ <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>
3747
+ <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>
3349
3748
  </nav>
3350
3749
  <main id="views">
3351
- <p class="banner" id="banner" role="alert"></p>
3750
+ <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>
3352
3751
  <section class="view" id="view-overview">
3353
- <h2>Overview</h2>
3354
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3752
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3355
3753
  <div class="grid" id="overview-cards"></div>
3356
3754
  <h3>Recent requests</h3>
3357
3755
  <div id="overview-requests"></div>
3358
3756
  </section>
3359
3757
  <section class="view" id="view-state" hidden>
3360
- <h2>State</h2>
3361
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3758
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3362
3759
  <div class="split">
3363
3760
  <div id="collections"></div>
3364
3761
  <div>
@@ -3371,8 +3768,7 @@ var renderAdminDocument = (service) => {
3371
3768
  </div>
3372
3769
  </section>
3373
3770
  <section class="view" id="view-clock" hidden>
3374
- <h2>Clock</h2>
3375
- <p class="lede">The clock every timestamp in this mock reads.</p>
3771
+ <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>
3376
3772
  <div class="card" id="clock-readout"></div>
3377
3773
  <div class="row-actions">
3378
3774
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3391,21 +3787,22 @@ var renderAdminDocument = (service) => {
3391
3787
  </form>
3392
3788
  </section>
3393
3789
  <section class="view" id="view-faults" hidden>
3394
- <h2>Faults</h2>
3395
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3790
+ <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>
3396
3791
  <form id="fault-form" class="card stack">
3397
- <label class="field">Preset
3398
- <select id="fault-preset"><option value="">Custom rule</option></select>
3399
- </label>
3400
- <label class="field">Status
3401
- <input id="fault-status" inputmode="numeric" placeholder="503">
3402
- </label>
3403
- <label class="field">Path prefix
3404
- <input id="fault-path" placeholder="/v1">
3405
- </label>
3406
- <label class="field">Method
3407
- <input id="fault-method" placeholder="GET">
3408
- </label>
3792
+ <div class="form-grid">
3793
+ <label class="field wide">Preset
3794
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3795
+ </label>
3796
+ <label class="field">Status
3797
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3798
+ </label>
3799
+ <label class="field wide">Path prefix
3800
+ <input id="fault-path" placeholder="/v1">
3801
+ </label>
3802
+ <label class="field">Method
3803
+ <input id="fault-method" placeholder="GET">
3804
+ </label>
3805
+ </div>
3409
3806
  <div class="dialog-actions">
3410
3807
  <button class="btn-primary" type="submit">Add fault</button>
3411
3808
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3414,26 +3811,31 @@ var renderAdminDocument = (service) => {
3414
3811
  <div id="fault-list"></div>
3415
3812
  </section>
3416
3813
  <section class="view" id="view-journal" hidden>
3417
- <h2>Journal</h2>
3418
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3419
- <div class="row-actions">
3420
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3421
- </div>
3814
+ <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>
3422
3815
  <div id="journal"></div>
3423
3816
  </section>
3424
3817
  <section class="view" id="view-routes" hidden>
3425
- <h2>Routes</h2>
3426
- <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>
3818
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3819
+ <div class="route-tools">
3820
+ <label class="field">Filter routes
3821
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3822
+ </label>
3823
+ <span class="route-count" id="route-count"></span>
3824
+ </div>
3427
3825
  <div id="route-list"></div>
3428
- <form id="route-form" class="card stack">
3826
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3429
3827
  <strong id="route-title">Select a route</strong>
3430
3828
  <label class="field">JSON body
3431
- <textarea id="route-body" placeholder="{ }"></textarea>
3829
+ <div class="json-editor" id="route-editor" hidden>
3830
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3831
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3832
+ </div>
3833
+ <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>
3432
3834
  </label>
3433
3835
  <div class="dialog-actions">
3434
- <button class="btn-primary" type="submit">Send</button>
3836
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3435
3837
  </div>
3436
- <pre id="route-result">Response will show here.</pre>
3838
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3437
3839
  </form>
3438
3840
  </section>
3439
3841
  </main>
@@ -3441,12 +3843,16 @@ var renderAdminDocument = (service) => {
3441
3843
  </div>
3442
3844
  <dialog id="editor">
3443
3845
  <form method="dialog" id="editor-form">
3444
- <strong id="editor-title">Record</strong>
3846
+ <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>
3445
3847
  <label class="field">Id
3446
3848
  <input id="editor-id" required>
3447
3849
  </label>
3448
3850
  <label class="field">JSON value
3449
- <textarea id="editor-value"></textarea>
3851
+ <div class="json-editor" id="record-editor">
3852
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3853
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3854
+ </div>
3855
+ <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>
3450
3856
  </label>
3451
3857
  <div class="dialog-actions">
3452
3858
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3488,8 +3894,8 @@ var describeExtension = (extension) => {
3488
3894
  ...extension.script !== void 0 ? { script: extension.script } : {}
3489
3895
  };
3490
3896
  };
3491
- var adminUiRoutes = (service, ui) => {
3492
- const shell = () => renderAdminDocument(service);
3897
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3898
+ const shell = () => renderAdminDocument(service, adminPrefix);
3493
3899
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3494
3900
  return {
3495
3901
  "GET /ui": () => htmlResponse(document3()),
@@ -4180,7 +4586,7 @@ var stateAdminRoutes = (routes) => {
4180
4586
  };
4181
4587
 
4182
4588
  // ../core/dist/version.js
4183
- var PACKAGE_VERSION = true ? "1.8.1" : UNRELEASED_VERSION;
4589
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
4184
4590
 
4185
4591
  // ../core/dist/signing.js
4186
4592
  var encoder = new TextEncoder();
@@ -4273,7 +4679,9 @@ var createWebhookHub = (options) => {
4273
4679
  const faults = /* @__PURE__ */ new Map();
4274
4680
  const held = /* @__PURE__ */ new Map();
4275
4681
  const inFlight2 = /* @__PURE__ */ new Set();
4682
+ const controllers = /* @__PURE__ */ new Map();
4276
4683
  let immediate = Promise.resolve();
4684
+ const snapshots = /* @__PURE__ */ new WeakMap();
4277
4685
  const track = (work) => {
4278
4686
  inFlight2.add(work);
4279
4687
  void work.finally(() => inFlight2.delete(work));
@@ -4294,6 +4702,7 @@ var createWebhookHub = (options) => {
4294
4702
  responseBody: null
4295
4703
  };
4296
4704
  const controller = new AbortController();
4705
+ controllers.set(delivery.id, controller);
4297
4706
  const timer = scheduleTimer(() => controller.abort(), timeoutMs);
4298
4707
  try {
4299
4708
  const signed = await options.signer({
@@ -4323,6 +4732,7 @@ var createWebhookHub = (options) => {
4323
4732
  } catch (error) {
4324
4733
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
4325
4734
  } finally {
4735
+ controllers.delete(delivery.id);
4326
4736
  cancel(timer);
4327
4737
  record.durationMs = now() - started;
4328
4738
  delivery.attempts.push(record);
@@ -4340,6 +4750,8 @@ var createWebhookHub = (options) => {
4340
4750
  if (!payloads.has(delivery.id))
4341
4751
  return;
4342
4752
  await attempt(delivery).then((ok) => {
4753
+ if (!payloads.has(delivery.id))
4754
+ return;
4343
4755
  if (ok)
4344
4756
  delivery.state = "delivered";
4345
4757
  else
@@ -4401,6 +4813,37 @@ var createWebhookHub = (options) => {
4401
4813
  fanOut(message);
4402
4814
  };
4403
4815
  const hub = {
4816
+ pending(namespace) {
4817
+ return inFlight2.size + (held.get(namespace)?.length ?? 0) + [...deliveries.values()].filter((d) => d.namespace === namespace && d.state === "pending").length;
4818
+ },
4819
+ snapshot(namespace) {
4820
+ if (hub.pending(namespace) > 0)
4821
+ throw new Error("webhooks are not quiescent");
4822
+ const handle = {};
4823
+ snapshots.set(handle, structuredClone({
4824
+ namespace,
4825
+ endpoints: own.get(namespace) ?? [],
4826
+ messages: messages.filter((m) => m.namespace === namespace),
4827
+ deliveries: [...deliveries].filter(([, d]) => d.namespace === namespace),
4828
+ payloads: [...payloads].filter(([, p]) => p.message.namespace === namespace),
4829
+ faults: faults.get(namespace) ?? []
4830
+ }));
4831
+ return handle;
4832
+ },
4833
+ restore(namespace, handle) {
4834
+ const point = typeof handle === "object" && handle !== null ? snapshots.get(handle) : void 0;
4835
+ if (!point || point.namespace !== namespace || hub.pending(namespace) > 0)
4836
+ throw new Error("invalid webhook restore");
4837
+ hub.clear(namespace);
4838
+ const copied = structuredClone(point);
4839
+ own.set(namespace, copied.endpoints);
4840
+ messages.push(...copied.messages);
4841
+ for (const [id2, delivery] of copied.deliveries)
4842
+ deliveries.set(id2, delivery);
4843
+ for (const [id2, payload] of copied.payloads)
4844
+ payloads.set(id2, payload);
4845
+ faults.set(namespace, copied.faults);
4846
+ },
4404
4847
  publish(input) {
4405
4848
  const contentType = input.contentType ?? (input.form ? "application/x-www-form-urlencoded" : "application/json");
4406
4849
  const body = typeof input.body === "string" ? input.body : input.form ? new URLSearchParams(input.form).toString() : JSON.stringify(input.body);
@@ -4491,6 +4934,7 @@ var createWebhookHub = (options) => {
4491
4934
  for (const [id2, delivery] of deliveries) {
4492
4935
  if (namespace !== void 0 && delivery.namespace !== namespace)
4493
4936
  continue;
4937
+ controllers.get(id2)?.abort();
4494
4938
  const timer = pending.get(id2);
4495
4939
  if (timer !== void 0)
4496
4940
  cancel(timer);
@@ -4515,7 +4959,10 @@ var createWebhookHub = (options) => {
4515
4959
  };
4516
4960
  return hub;
4517
4961
  };
4518
- var json5 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4962
+ var json5 = (status, body) => new Response(JSON.stringify(body), {
4963
+ status,
4964
+ headers: { "content-type": "application/json; charset=utf-8" }
4965
+ });
4519
4966
  var adminError3 = (status, message) => json5(status, { error: { type: "mockingbird_admin", message } });
4520
4967
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4521
4968
  var parseEndpoint = (value) => {
@@ -4623,7 +5070,6 @@ var AT_HEADER = "x-mockingbird-at";
4623
5070
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4624
5071
  var DEFAULT_NAMESPACE = "default";
4625
5072
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4626
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4627
5073
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4628
5074
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4629
5075
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4664,6 +5110,22 @@ var operationMatcher = (document3) => {
4664
5110
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4665
5111
  };
4666
5112
  var createRuntime = (options) => {
5113
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
5114
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
5115
+ for (const path of internalPaths) {
5116
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
5117
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
5118
+ }
5119
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
5120
+ const internalMatch = (path) => {
5121
+ for (const pattern of internalPaths) {
5122
+ const actual = path.split("/");
5123
+ const expected = pattern.split("/");
5124
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
5125
+ return pattern;
5126
+ }
5127
+ return void 0;
5128
+ };
4667
5129
  assertAdminUi(options.adminUi);
4668
5130
  const sqlite = bootSqlite(options.sqlite);
4669
5131
  const clock = options.clock ?? createClock();
@@ -4678,6 +5140,20 @@ var createRuntime = (options) => {
4678
5140
  const instances = /* @__PURE__ */ new Map();
4679
5141
  const publicNamespaces = /* @__PURE__ */ new Set();
4680
5142
  const branchRngs = /* @__PURE__ */ new Map();
5143
+ const namespaceClocks = /* @__PURE__ */ new Map();
5144
+ const namespaceForStorage = /* @__PURE__ */ new Map();
5145
+ let isolatedNamespaces = false;
5146
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
5147
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
5148
+ return clock;
5149
+ let found = namespaceClocks.get(name);
5150
+ if (!found) {
5151
+ found = createClock();
5152
+ namespaceClocks.set(name, found);
5153
+ }
5154
+ return found;
5155
+ };
5156
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4681
5157
  const timelines = /* @__PURE__ */ new Map();
4682
5158
  const branchStorage = /* @__PURE__ */ new Map();
4683
5159
  const captured = /* @__PURE__ */ new Map();
@@ -4691,17 +5167,22 @@ var createRuntime = (options) => {
4691
5167
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4692
5168
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4693
5169
  }
5170
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4694
5171
  const created = options.create({
4695
5172
  namespace: storageNamespace(key),
4696
5173
  publicNamespace,
5174
+ adminPrefix,
4697
5175
  sqlite,
4698
- clock,
4699
- rng: isolatedRng ?? rng
5176
+ clock: namespaceClock(publicNamespace),
5177
+ rng: createdContextRng
4700
5178
  });
4701
5179
  instances.set(key, created);
5180
+ namespaceForStorage.set(key, publicNamespace);
4702
5181
  publicNamespaces.add(publicNamespace);
4703
5182
  if (isolatedRng)
4704
5183
  branchRngs.set(key, isolatedRng);
5184
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
5185
+ branchRngs.set(key, createdContextRng);
4705
5186
  return created;
4706
5187
  };
4707
5188
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4719,7 +5200,7 @@ var createRuntime = (options) => {
4719
5200
  captured.set(storage, snapshot2);
4720
5201
  return Object.freeze({
4721
5202
  snapshot: snapshot2,
4722
- clock: Object.freeze(clock.state()),
5203
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4723
5204
  rngState: (branchRngs.get(storage) ?? rng).state()
4724
5205
  });
4725
5206
  };
@@ -4729,7 +5210,7 @@ var createRuntime = (options) => {
4729
5210
  return found;
4730
5211
  instance(name);
4731
5212
  found = new Timeline({
4732
- now: clock.now,
5213
+ now: namespaceClock(name).now,
4733
5214
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4734
5215
  });
4735
5216
  found.commit(capture(name));
@@ -4756,12 +5237,13 @@ var createRuntime = (options) => {
4756
5237
  const point = history2.checkout("main", at);
4757
5238
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4758
5239
  captured.set(namespace, point.value.snapshot);
4759
- rng.setState(point.value.rngState);
4760
- clock.set(point.value.clock.now);
5240
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
5241
+ const selectedClock = namespaceClock(namespace);
5242
+ selectedClock.set(point.value.clock.now);
4761
5243
  if (point.value.clock.frozen)
4762
- clock.freeze();
5244
+ selectedClock.freeze();
4763
5245
  else
4764
- clock.unfreeze();
5246
+ selectedClock.unfreeze();
4765
5247
  }
4766
5248
  return namespace;
4767
5249
  }
@@ -4819,11 +5301,12 @@ var createRuntime = (options) => {
4819
5301
  const storage = ensureBranch(namespace, branchName);
4820
5302
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4821
5303
  captured.set(storage, point.value.snapshot);
4822
- clock.set(point.value.clock.now);
5304
+ const selectedClock = namespaceClock(namespace);
5305
+ selectedClock.set(point.value.clock.now);
4823
5306
  if (point.value.clock.frozen)
4824
- clock.freeze();
5307
+ selectedClock.freeze();
4825
5308
  else
4826
- clock.unfreeze();
5309
+ selectedClock.unfreeze();
4827
5310
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4828
5311
  };
4829
5312
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4831,10 +5314,21 @@ var createRuntime = (options) => {
4831
5314
  options.webhooks?.clear();
4832
5315
  for (const each of instances.values())
4833
5316
  await each.reset();
5317
+ for (const each of branchRngs.values())
5318
+ each.reset();
4834
5319
  timelines.clear();
5320
+ for (const storage of branchStorage.values()) {
5321
+ instances.delete(storage);
5322
+ branchRngs.delete(storage);
5323
+ namespaceForStorage.delete(storage);
5324
+ }
4835
5325
  branchStorage.clear();
4836
- branchRngs.clear();
4837
5326
  captured.clear();
5327
+ faults.clear();
5328
+ clock.reset();
5329
+ rng.reset();
5330
+ for (const each of namespaceClocks.values())
5331
+ each.reset();
4838
5332
  return;
4839
5333
  }
4840
5334
  options.webhooks?.clear(name);
@@ -4852,11 +5346,18 @@ var createRuntime = (options) => {
4852
5346
  else
4853
5347
  clearNamespace(sqlite, storageNamespace(storage));
4854
5348
  branchStorage.delete(mapping);
5349
+ instances.delete(storage);
5350
+ namespaceForStorage.delete(storage);
4855
5351
  branchRngs.delete(storage);
4856
5352
  captured.delete(storage);
4857
5353
  }
4858
5354
  timelines.delete(name);
4859
5355
  captured.delete(name);
5356
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
5357
+ namespaceClock(name).reset();
5358
+ (branchRngs.get(name) ?? rng).reset();
5359
+ journal.clear(name);
5360
+ metrics.reset(name);
4860
5361
  };
4861
5362
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4862
5363
  return checkpoint(name, "main").value.snapshot;
@@ -4894,6 +5395,53 @@ var createRuntime = (options) => {
4894
5395
  rng,
4895
5396
  credentials,
4896
5397
  webhooks: options.webhooks,
5398
+ isolateNamespaces() {
5399
+ if (publicNamespaces.size > 1)
5400
+ throw new Error("enable namespace isolation before creating named namespaces");
5401
+ isolatedNamespaces = true;
5402
+ faults.isolateNamespaces();
5403
+ },
5404
+ namespaceClock,
5405
+ namespaceOf(request) {
5406
+ const url = new URL(request.url);
5407
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
5408
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
5409
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
5410
+ if (isAdminPath(path, adminPrefix))
5411
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
5412
+ if (selected !== void 0)
5413
+ return selected;
5414
+ const credential = options.credential?.(request);
5415
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
5416
+ },
5417
+ fleetSnapshot(namespace) {
5418
+ instance(namespace);
5419
+ const handle = {};
5420
+ fleetPoints.set(handle, {
5421
+ namespace,
5422
+ state: capture(namespace),
5423
+ faults: faults.snapshot(namespace),
5424
+ webhooks: options.webhooks?.snapshot(namespace)
5425
+ });
5426
+ return handle;
5427
+ },
5428
+ fleetRestore(handle, namespace) {
5429
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
5430
+ if (!point || point.namespace !== namespace)
5431
+ throw new Error("invalid fleet checkpoint");
5432
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
5433
+ captured.set(namespace, point.state.snapshot);
5434
+ const selectedClock = namespaceClock(namespace);
5435
+ selectedClock.freeze();
5436
+ selectedClock.set(point.state.clock.now);
5437
+ if (!point.state.clock.frozen)
5438
+ selectedClock.unfreeze();
5439
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
5440
+ faults.restore(namespace, point.faults);
5441
+ if (point.webhooks !== void 0)
5442
+ options.webhooks?.restore(namespace, point.webhooks);
5443
+ timelines.delete(namespace);
5444
+ },
4897
5445
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4898
5446
  const preset = options.presets?.[name];
4899
5447
  if (!preset)
@@ -4925,7 +5473,7 @@ var createRuntime = (options) => {
4925
5473
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4926
5474
  fetch: async (incoming) => {
4927
5475
  let request = incoming;
4928
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5476
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4929
5477
  if (prefixed) {
4930
5478
  const url2 = new URL(request.url);
4931
5479
  url2.pathname = prefixed[2] ?? "/";
@@ -4941,7 +5489,15 @@ var createRuntime = (options) => {
4941
5489
  signal: request.signal
4942
5490
  });
4943
5491
  }
4944
- let namespace = control.namespaceOf(request);
5492
+ const internalUrl = new URL(request.url);
5493
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5494
+ const internal = internalMatch(internalPath);
5495
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5496
+ if (internal) {
5497
+ internalUrl.pathname = internalPath;
5498
+ request = new Request(internalUrl, request);
5499
+ }
5500
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4945
5501
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4946
5502
  const credential = options.credential(request);
4947
5503
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4961,7 +5517,9 @@ var createRuntime = (options) => {
4961
5517
  return copy;
4962
5518
  }
4963
5519
  };
4964
- const handled = await control.handle(request);
5520
+ if (obsoleteInternalPath)
5521
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5522
+ const handled = internal ? void 0 : await control.handle(request);
4965
5523
  if (handled)
4966
5524
  return stamp(handled);
4967
5525
  const started = monotonicNow();
@@ -5002,7 +5560,7 @@ var createRuntime = (options) => {
5002
5560
  type: "mockingbird_admin",
5003
5561
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
5004
5562
  }
5005
- }), { status: 400, headers: { "content-type": "application/json" } }));
5563
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
5006
5564
  }
5007
5565
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
5008
5566
  log(400);
@@ -5069,6 +5627,7 @@ var createRuntime = (options) => {
5069
5627
  startedAt: wallNow(),
5070
5628
  wallNow,
5071
5629
  clock,
5630
+ namespaceClock,
5072
5631
  faults,
5073
5632
  metrics,
5074
5633
  journal,
@@ -5118,15 +5677,16 @@ var createRuntime = (options) => {
5118
5677
  ...options.admin?.(runtime) ?? {},
5119
5678
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
5120
5679
  // maps `presets`, and a service route must not replace that list with a record.
5121
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5680
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
5122
5681
  ...stateAdminRoutes({
5123
5682
  open: stateScope,
5124
5683
  afterWrite: (namespace) => {
5125
5684
  checkpoint(namespace, "main");
5126
5685
  }
5127
5686
  }),
5128
- ...adminUiRoutes(options.name, options.adminUi)
5687
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
5129
5688
  },
5689
+ adminPrefix,
5130
5690
  adminKey: options.adminKey
5131
5691
  });
5132
5692
  return defineMock(runtime);
@@ -5140,7 +5700,10 @@ var mutableResponse = (response) => {
5140
5700
  return new Response(response.body, response);
5141
5701
  }
5142
5702
  };
5143
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5703
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5704
+ status,
5705
+ headers: { "content-type": "application/json; charset=utf-8" }
5706
+ });
5144
5707
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
5145
5708
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5146
5709
  var credentialRoutes = (registry) => ({
@@ -5192,7 +5755,7 @@ var credentialRoutes = (registry) => ({
5192
5755
  return adminJson(200, { status: "ok" });
5193
5756
  }
5194
5757
  });
5195
- var presetRoutes = (presets, runtime) => ({
5758
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
5196
5759
  "GET /faults/presets": () => {
5197
5760
  const body = {
5198
5761
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -5202,7 +5765,7 @@ var presetRoutes = (presets, runtime) => ({
5202
5765
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
5203
5766
  const name = params.name;
5204
5767
  if (!presets[name])
5205
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5768
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
5206
5769
  const overrides = isObject(body) ? body : {};
5207
5770
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
5208
5771
  }
@@ -5622,6 +6185,7 @@ var createRuntime2 = (options = {}) => {
5622
6185
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5623
6186
  ...options.clock ? { clock: options.clock } : {},
5624
6187
  ...options.seed !== void 0 ? { seed: options.seed } : {},
6188
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5625
6189
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5626
6190
  ...options.onLog ? { onLog: options.onLog } : {},
5627
6191
  credential: bearerToken,
@@ -6540,6 +7104,9 @@ var IntercomAPI = class {
6540
7104
  };
6541
7105
 
6542
7106
  export {
7107
+ createClock,
7108
+ resolveAdminPrefix,
7109
+ parseDuration,
6543
7110
  document2 as document,
6544
7111
  operationIds,
6545
7112
  supportedOperationIds,
@@ -6558,4 +7125,4 @@ export {
6558
7125
  IntercomError,
6559
7126
  IntercomAPI
6560
7127
  };
6561
- //# sourceMappingURL=chunk-TWQJOOK7.js.map
7128
+ //# sourceMappingURL=chunk-DHV6Y6ZE.js.map