@crvouga/mockingbird-service-resend 1.7.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
183
215
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
216
  },
185
217
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
218
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
219
+ "POST /clock": ({ body, namespace }) => {
220
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
221
  if (!isRecord(body))
189
222
  return adminError(400, "expected a JSON object");
190
223
  if (body.reset === true)
191
- context.clock.reset();
224
+ clock.reset();
192
225
  if (body.freeze === true)
193
- context.clock.freeze();
226
+ clock.freeze();
194
227
  if (body.set !== void 0) {
195
228
  const instant = parseInstant(body.set);
196
229
  if (instant === void 0)
197
230
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
231
+ clock.set(instant);
199
232
  }
200
233
  if (body.advance !== void 0) {
201
234
  const delta = parseDuration(body.advance);
202
235
  if (delta === void 0)
203
236
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
237
+ clock.advance(delta);
205
238
  }
206
239
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
240
+ clock.unfreeze();
241
+ return json(200, clock.state());
209
242
  },
210
243
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
244
  "POST /faults": ({ body, namespace }) => {
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
338
371
  return json(200, { status: "ok" });
339
372
  }
340
373
  };
341
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
342
382
  const space = key.indexOf(" ");
343
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
344
384
  });
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
346
386
  namespaceOf: headerNamespace,
347
387
  async handle(request) {
348
388
  const url = new URL(request.url);
349
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
350
- return json(200, {
351
- status: "ok",
352
- service: context.name,
353
- uptimeMs: context.wallNow() - context.startedAt,
354
- clock: context.clock.state(),
355
- namespaces: context.namespaces().length,
356
- adminUi: `${ADMIN_PREFIX}/ui`,
357
- ...context.describe()
358
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
359
391
  }
360
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
361
393
  return void 0;
362
394
  }
363
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
364
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
366
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
368
400
  for (const route of routes) {
369
401
  if (route.method !== request.method)
370
402
  continue;
371
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
372
409
  if (!params)
373
410
  continue;
374
411
  let body;
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
377
414
  } catch {
378
415
  return adminError(400, "request body is not valid JSON");
379
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
380
420
  return route.handler({
381
421
  request,
382
422
  url,
383
423
  params,
384
- namespace: adminNamespace(request, url),
424
+ namespace,
385
425
  body
386
426
  });
387
427
  }
388
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
389
429
  }
390
430
  };
391
431
  };
@@ -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)
@@ -1131,7 +1207,8 @@ var annotateResponse = (response, extra) => {
1131
1207
  var responseNotes = (response) => notes.get(response);
1132
1208
 
1133
1209
  // ../core/dist/metrics.js
1134
- var createMetrics = () => {
1210
+ var createMetrics = (partition = true) => {
1211
+ const namespaces = /* @__PURE__ */ new Map();
1135
1212
  let requests = 0;
1136
1213
  let faults = 0;
1137
1214
  let totalDurationMs = 0;
@@ -1139,6 +1216,14 @@ var createMetrics = () => {
1139
1216
  const unmatched = /* @__PURE__ */ new Map();
1140
1217
  return {
1141
1218
  record(entry) {
1219
+ if (partition) {
1220
+ let scoped = namespaces.get(entry.namespace);
1221
+ if (!scoped) {
1222
+ scoped = createMetrics(false);
1223
+ namespaces.set(entry.namespace, scoped);
1224
+ }
1225
+ scoped.record(entry);
1226
+ }
1142
1227
  requests++;
1143
1228
  totalDurationMs += entry.durationMs;
1144
1229
  if (entry.faultId !== void 0)
@@ -1150,7 +1235,7 @@ var createMetrics = () => {
1150
1235
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1151
1236
  }
1152
1237
  },
1153
- report: () => ({
1238
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1154
1239
  requests,
1155
1240
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1156
1241
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1159,8 +1244,13 @@ var createMetrics = () => {
1159
1244
  }),
1160
1245
  faults,
1161
1246
  totalDurationMs
1162
- }),
1163
- reset() {
1247
+ },
1248
+ reset(namespace) {
1249
+ if (namespace !== void 0) {
1250
+ namespaces.delete(namespace);
1251
+ return;
1252
+ }
1253
+ namespaces.clear();
1164
1254
  requests = 0;
1165
1255
  faults = 0;
1166
1256
  totalDurationMs = 0;
@@ -1204,7 +1294,10 @@ var OutboxStore = class {
1204
1294
  return query.limit !== void 0 ? matched.slice(-Math.max(0, query.limit)) : matched;
1205
1295
  }
1206
1296
  };
1207
- var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
1297
+ var json2 = (status, body) => new Response(JSON.stringify(body), {
1298
+ status,
1299
+ headers: { "content-type": "application/json; charset=utf-8" }
1300
+ });
1208
1301
  var parseSince = (value) => {
1209
1302
  if (value === null)
1210
1303
  return void 0;
@@ -1850,6 +1943,34 @@ dialog:not([open]) {
1850
1943
  function isRecord2(value) {
1851
1944
  return typeof value === "object" && value !== null && !Array.isArray(value);
1852
1945
  }
1946
+ function highlightJsonText(text) {
1947
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1948
+ switch (char) {
1949
+ case "&":
1950
+ return "&amp;";
1951
+ case "<":
1952
+ return "&lt;";
1953
+ case ">":
1954
+ return "&gt;";
1955
+ case '"':
1956
+ return "&quot;";
1957
+ default:
1958
+ return "&#39;";
1959
+ }
1960
+ });
1961
+ 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;
1962
+ let cursor = 0;
1963
+ let html = "";
1964
+ for (const match of text.matchAll(token)) {
1965
+ const index = match.index ?? 0;
1966
+ const value = match[0];
1967
+ html += escapeToken(text.slice(cursor, index));
1968
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1969
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1970
+ cursor = index + value.length;
1971
+ }
1972
+ return html + escapeToken(text.slice(cursor));
1973
+ }
1853
1974
  function mountAdminBrand(doc, host, service, catalog) {
1854
1975
  const clip = (value, max) => {
1855
1976
  if (typeof value !== "string")
@@ -2211,14 +2332,16 @@ function bootAdmin(config) {
2211
2332
  const state = {
2212
2333
  namespace: "default",
2213
2334
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
2214
- view: "overview",
2335
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
2215
2336
  collection: "",
2216
2337
  shape: null,
2217
2338
  route: null,
2339
+ routes: [],
2218
2340
  editing: null
2219
2341
  };
2220
2342
  const standard = new Set(config.standardRoutes);
2221
2343
  const banner = el("banner", HTMLElement);
2344
+ const bannerMessage = el("banner-message", HTMLElement);
2222
2345
  const keyInput = el("admin-key", HTMLInputElement);
2223
2346
  keyInput.value = state.key;
2224
2347
  const params = new URLSearchParams(location.search);
@@ -2229,18 +2352,21 @@ function bootAdmin(config) {
2229
2352
  sessionStorage.setItem("mockingbird-admin-key", state.key);
2230
2353
  params.delete("key");
2231
2354
  const next = params.toString();
2232
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2355
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
2233
2356
  }
2234
2357
  const queryNamespace = params.get("namespace");
2235
2358
  if (queryNamespace)
2236
2359
  state.namespace = queryNamespace;
2237
2360
  const showError = (error2) => {
2238
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
2361
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
2239
2362
  banner.classList.add("show");
2363
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
2240
2364
  };
2241
2365
  const clearError = () => {
2242
2366
  banner.classList.remove("show");
2367
+ bannerMessage.textContent = "";
2243
2368
  };
2369
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
2244
2370
  const api = async (method, path, body) => {
2245
2371
  const headers = { accept: "application/json" };
2246
2372
  if (state.key !== "")
@@ -2284,16 +2410,21 @@ function bootAdmin(config) {
2284
2410
  namespace: () => state.namespace
2285
2411
  };
2286
2412
  const isPanelScript = (value) => typeof value === "function";
2413
+ const themeButton = el("theme", HTMLButtonElement);
2287
2414
  const setTheme = (theme) => {
2288
2415
  if (theme)
2289
2416
  document.documentElement.dataset.theme = theme;
2290
2417
  else
2291
2418
  delete document.documentElement.dataset.theme;
2419
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2420
+ 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>';
2421
+ const label = dark ? "Use light theme" : "Use dark theme";
2422
+ themeButton.setAttribute("aria-label", label);
2423
+ themeButton.title = label;
2292
2424
  };
2293
2425
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2294
- if (storedTheme)
2295
- setTheme(storedTheme);
2296
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2426
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2427
+ themeButton.addEventListener("click", () => {
2297
2428
  const current = document.documentElement.dataset.theme;
2298
2429
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2299
2430
  const next = dark ? "light" : "dark";
@@ -2312,6 +2443,7 @@ function bootAdmin(config) {
2312
2443
  continue;
2313
2444
  section.hidden = section.id !== `view-${view}`;
2314
2445
  }
2446
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2315
2447
  };
2316
2448
  el("nav", HTMLElement).addEventListener("click", (event) => {
2317
2449
  const target = event.target;
@@ -2326,7 +2458,7 @@ function bootAdmin(config) {
2326
2458
  show(view);
2327
2459
  refresh().catch(showError);
2328
2460
  });
2329
- 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>`;
2461
+ 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>`;
2330
2462
  const table = (columns, rows) => {
2331
2463
  if (rows.length === 0)
2332
2464
  return '<p class="empty">Nothing here yet.</p>';
@@ -2336,16 +2468,22 @@ function bootAdmin(config) {
2336
2468
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2337
2469
  return `<tr${attrs}>${cells}</tr>`;
2338
2470
  }).join("");
2339
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2471
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2340
2472
  };
2341
2473
  const preview = (value) => {
2342
2474
  const text = JSON.stringify(value) ?? "null";
2343
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2475
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2476
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2344
2477
  };
2345
2478
  const when = (epoch) => {
2346
2479
  const date = new Date(epoch);
2347
2480
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2348
2481
  };
2482
+ const journalWhen = (value) => {
2483
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2484
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2485
+ return Number.isFinite(epoch) ? when(epoch) : value;
2486
+ };
2349
2487
  const clockCard = (epoch) => {
2350
2488
  const date = new Date(epoch);
2351
2489
  if (Number.isNaN(date.getTime()))
@@ -2465,7 +2603,7 @@ function bootAdmin(config) {
2465
2603
  };
2466
2604
  const renderOverview = async () => {
2467
2605
  const [health, shape, clock, journal] = await Promise.all([
2468
- originGet("/health"),
2606
+ originGet(`${config.adminPrefix}/health`),
2469
2607
  api("GET", "/state"),
2470
2608
  api("GET", "/clock"),
2471
2609
  api("GET", "/requests?limit=8")
@@ -2482,7 +2620,7 @@ function bootAdmin(config) {
2482
2620
  },
2483
2621
  {
2484
2622
  k: "Status",
2485
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2623
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2486
2624
  },
2487
2625
  { k: "Namespace", v: esc(state.namespace) },
2488
2626
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2569,26 +2707,46 @@ function bootAdmin(config) {
2569
2707
  const renderJournal = async () => {
2570
2708
  const journal = await api("GET", "/requests?limit=100");
2571
2709
  el("journal", HTMLElement).innerHTML = table([
2572
- { label: "When", cell: (row) => esc(row.at) },
2573
- { label: "Method", cell: (row) => esc(row.method) },
2710
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2711
+ {
2712
+ label: "Method",
2713
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2714
+ },
2574
2715
  { label: "Path", cell: (row) => esc(row.path) },
2575
- { label: "Status", cell: (row) => esc(row.status) },
2716
+ {
2717
+ label: "Status",
2718
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2719
+ },
2576
2720
  { label: "ms", cell: (row) => esc(row.durationMs) }
2577
2721
  ], decodeJournal(journal));
2578
2722
  };
2579
- const renderRoutes = async () => {
2580
- const data = await api("GET", "/");
2581
- if (!isRecord2(data))
2582
- throw new Error("Route list was not an object");
2583
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2723
+ const paintRoutes = () => {
2724
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2725
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2726
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2727
+ if (state.routes.length === 0) {
2728
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2729
+ return;
2730
+ }
2731
+ if (routes.length === 0) {
2732
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2733
+ return;
2734
+ }
2584
2735
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2585
2736
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2586
2737
  const parts = route.split(" ");
2587
2738
  const method = parts[0] ?? "";
2588
2739
  const path = parts.slice(1).join(" ");
2589
- 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>`;
2740
+ 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>`;
2590
2741
  }).join("");
2591
2742
  };
2743
+ const renderRoutes = async () => {
2744
+ const data = await api("GET", "/");
2745
+ if (!isRecord2(data))
2746
+ throw new Error("Route list was not an object");
2747
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2748
+ paintRoutes();
2749
+ };
2592
2750
  const renderers = {
2593
2751
  overview: renderOverview,
2594
2752
  state: renderState,
@@ -2597,19 +2755,50 @@ function bootAdmin(config) {
2597
2755
  journal: renderJournal,
2598
2756
  routes: renderRoutes
2599
2757
  };
2758
+ let refreshToken = 0;
2600
2759
  async function refresh() {
2760
+ const token = ++refreshToken;
2601
2761
  clearError();
2762
+ const main = el("views", HTMLElement);
2763
+ const sync = el("sync-status", HTMLElement);
2764
+ main.setAttribute("aria-busy", "true");
2765
+ sync.classList.add("busy");
2766
+ sync.textContent = "Updating";
2602
2767
  const render = renderers[state.view];
2603
- if (render)
2604
- await render();
2768
+ try {
2769
+ if (render)
2770
+ await render();
2771
+ if (token === refreshToken)
2772
+ sync.textContent = "Up to date";
2773
+ } finally {
2774
+ if (token === refreshToken) {
2775
+ main.removeAttribute("aria-busy");
2776
+ sync.classList.remove("busy");
2777
+ }
2778
+ }
2605
2779
  }
2606
2780
  const delay = (ms) => new Promise((resolve) => {
2607
2781
  window.setTimeout(resolve, ms);
2608
2782
  });
2609
- const confirmAction = async (button, pending, done, work) => {
2783
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2610
2784
  if (button.dataset.pending === "1")
2611
2785
  return;
2612
2786
  const label = button.textContent ?? "";
2787
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2788
+ button.dataset.confirming = "1";
2789
+ button.classList.add("confirming");
2790
+ button.textContent = confirmLabel;
2791
+ window.setTimeout(() => {
2792
+ if (button.dataset.confirming !== "1")
2793
+ return;
2794
+ button.textContent = label;
2795
+ button.classList.remove("confirming");
2796
+ delete button.dataset.confirming;
2797
+ }, 3e3);
2798
+ return;
2799
+ }
2800
+ button.classList.remove("confirming");
2801
+ delete button.dataset.confirming;
2613
2802
  const width = button.offsetWidth;
2614
2803
  button.dataset.pending = "1";
2615
2804
  button.classList.add("pressed");
@@ -2630,6 +2819,65 @@ function bootAdmin(config) {
2630
2819
  delete button.dataset.pending;
2631
2820
  }
2632
2821
  };
2822
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2823
+ const textarea = el(textareaId, HTMLTextAreaElement);
2824
+ const highlight = el(highlightId, HTMLElement);
2825
+ const status = el(statusId, HTMLElement);
2826
+ const shell = el(shellId, HTMLElement);
2827
+ const sync = () => {
2828
+ const text = textarea.value;
2829
+ highlight.innerHTML = `${highlightJsonText(text)}
2830
+ `;
2831
+ let valid = true;
2832
+ let message = "Valid JSON";
2833
+ if (allowEmpty && text.trim() === "")
2834
+ message = "No body";
2835
+ else {
2836
+ try {
2837
+ readJson2(text);
2838
+ } catch {
2839
+ valid = false;
2840
+ message = "Invalid JSON";
2841
+ }
2842
+ }
2843
+ shell.classList.toggle("invalid", !valid);
2844
+ status.classList.toggle("invalid", !valid);
2845
+ status.textContent = message;
2846
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2847
+ };
2848
+ const syncScroll = () => {
2849
+ const preview2 = highlight.parentElement;
2850
+ if (!(preview2 instanceof HTMLElement))
2851
+ return;
2852
+ preview2.scrollTop = textarea.scrollTop;
2853
+ preview2.scrollLeft = textarea.scrollLeft;
2854
+ };
2855
+ textarea.addEventListener("input", sync);
2856
+ textarea.addEventListener("scroll", syncScroll);
2857
+ sync();
2858
+ return sync;
2859
+ };
2860
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2861
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2862
+ for (const button of document.querySelectorAll("[data-format]")) {
2863
+ if (!(button instanceof HTMLButtonElement))
2864
+ continue;
2865
+ button.addEventListener("click", () => {
2866
+ const target = button.dataset.format;
2867
+ if (target === void 0)
2868
+ return;
2869
+ const textarea = document.getElementById(target);
2870
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2871
+ return;
2872
+ try {
2873
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2874
+ textarea.dispatchEvent(new Event("input"));
2875
+ textarea.focus();
2876
+ } catch {
2877
+ showError(new Error("Fix the JSON before formatting it"));
2878
+ }
2879
+ });
2880
+ }
2633
2881
  document.addEventListener("click", (event) => {
2634
2882
  const target = event.target;
2635
2883
  if (!(target instanceof Element))
@@ -2702,6 +2950,7 @@ function bootAdmin(config) {
2702
2950
  } else {
2703
2951
  editorValue.value = "{\n \n}";
2704
2952
  }
2953
+ syncRecordEditor();
2705
2954
  editor.showModal();
2706
2955
  }
2707
2956
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2731,10 +2980,10 @@ function bootAdmin(config) {
2731
2980
  const id = state.editing;
2732
2981
  if (!id)
2733
2982
  return;
2734
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2983
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2735
2984
  editor.close();
2736
2985
  return renderState();
2737
- }).catch(showError);
2986
+ }), "Confirm delete").catch(showError);
2738
2987
  });
2739
2988
  for (const button of document.querySelectorAll("[data-advance]")) {
2740
2989
  if (!(button instanceof HTMLButtonElement))
@@ -2780,10 +3029,10 @@ function bootAdmin(config) {
2780
3029
  }).then(() => renderFaults()).catch(showError);
2781
3030
  });
2782
3031
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2783
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
3032
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2784
3033
  });
2785
3034
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2786
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
3035
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2787
3036
  });
2788
3037
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2789
3038
  const target = event.target;
@@ -2797,9 +3046,15 @@ function bootAdmin(config) {
2797
3046
  return;
2798
3047
  state.route = route;
2799
3048
  el("route-title", HTMLElement).textContent = route;
3049
+ el("route-send", HTMLButtonElement).disabled = false;
2800
3050
  const method = route.split(" ")[0] ?? "";
2801
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
3051
+ const routeBody = el("route-body", HTMLTextAreaElement);
3052
+ routeBody.disabled = method === "GET" || method === "DELETE";
3053
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
3054
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
3055
+ syncRouteEditor();
2802
3056
  });
3057
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2803
3058
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2804
3059
  event.preventDefault();
2805
3060
  const send = async () => {
@@ -2821,7 +3076,8 @@ function bootAdmin(config) {
2821
3076
  }
2822
3077
  }
2823
3078
  const data = await api(method, concrete, body);
2824
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
3079
+ const text = JSON.stringify(data, null, 2) ?? "null";
3080
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2825
3081
  };
2826
3082
  send().catch(showError);
2827
3083
  });
@@ -2939,11 +3195,13 @@ function bootAdmin(config) {
2939
3195
  views.append(section);
2940
3196
  mountSqlExplorer(body, panelApi);
2941
3197
  }
3198
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2942
3199
  return refresh();
2943
3200
  }).catch(showError);
2944
3201
  }
2945
3202
  var clientPrelude = [
2946
3203
  isRecord2,
3204
+ highlightJsonText,
2947
3205
  mountAdminBrand,
2948
3206
  startAdminBrand,
2949
3207
  faultPresetList,
@@ -2966,6 +3224,7 @@ ${entry}(${payload});
2966
3224
  var defineMock = (runtime) => runtime;
2967
3225
  var STANDARD_ADMIN_ROUTES = [
2968
3226
  "GET /",
3227
+ "GET /health",
2969
3228
  "POST /reset",
2970
3229
  "GET /namespaces",
2971
3230
  "GET /clock",
@@ -3045,11 +3304,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
3045
3304
  }
3046
3305
  });
3047
3306
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
3048
- var renderAdminDocument = (service) => {
3307
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
3049
3308
  const name = escapeHtml(service);
3050
3309
  const script = adminClientSource({
3051
3310
  standardRoutes: STANDARD_ADMIN_ROUTES,
3052
- adminPrefix: ADMIN_PREFIX,
3311
+ adminPrefix,
3053
3312
  adminKeyHeader: ADMIN_KEY_HEADER,
3054
3313
  brandsUrl: ADMIN_BRANDS_URL,
3055
3314
  service
@@ -3066,75 +3325,99 @@ var renderAdminDocument = (service) => {
3066
3325
  ${CSS_RESET}
3067
3326
  :root {
3068
3327
  color-scheme: light;
3069
- --bg: #fafafa;
3328
+ --bg: #f7f8fb;
3070
3329
  --bg-elev: #ffffff;
3071
- --bg-muted: #f4f4f5;
3072
- --bg-inset: #f4f4f5;
3073
- --line: #dedee3;
3074
- --line-strong: #c4c4cc;
3075
- --ink: #18181b;
3076
- --muted: #62626b;
3077
- --faint: #71717a;
3078
- --accent: #27272a;
3330
+ --bg-muted: #f1f3f7;
3331
+ --bg-inset: #f8f9fc;
3332
+ --line: #e4e7ec;
3333
+ --line-strong: #cfd4dc;
3334
+ --ink: #101828;
3335
+ --muted: #475467;
3336
+ --faint: #667085;
3337
+ --accent: #6941c6;
3338
+ --accent-hover: #5933b4;
3079
3339
  --accent-ink: #ffffff;
3080
- --accent-soft: #f4f4f5;
3081
- --ok: #166534;
3082
- --ok-soft: #f0fdf4;
3083
- --warn: #a16207;
3084
- --warn-soft: #fefce8;
3085
- --err: #a82d32;
3086
- --err-soft: #fef2f2;
3087
- --shadow: none;
3340
+ --accent-soft: #f4f0ff;
3341
+ --ok: #067647;
3342
+ --ok-soft: #ecfdf3;
3343
+ --warn: #b54708;
3344
+ --warn-soft: #fffaeb;
3345
+ --err: #b42318;
3346
+ --err-soft: #fef3f2;
3347
+ --code-key: #6941c6;
3348
+ --code-string: #067647;
3349
+ --code-number: #175cd3;
3350
+ --code-boolean: #c11574;
3351
+ --code-null: #667085;
3352
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3353
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3354
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
3088
3355
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3089
3356
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
3090
- --radius: 10px;
3091
- --header: 56px;
3092
- --nav: 200px;
3357
+ --radius: 12px;
3358
+ --header: 72px;
3359
+ --nav: 224px;
3093
3360
  }
3094
3361
  :root[data-theme="dark"] {
3095
3362
  color-scheme: dark;
3096
- --bg: #111113;
3097
- --bg-elev: #19191c;
3098
- --bg-muted: #242428;
3099
- --bg-inset: #111113;
3100
- --line: #36363c;
3101
- --line-strong: #52525b;
3102
- --ink: #f4f4f5;
3103
- --muted: #a9a9b2;
3104
- --faint: #71717a;
3105
- --accent: #e4e4e7;
3106
- --accent-ink: #18181b;
3107
- --accent-soft: #242428;
3108
- --ok: #86efac;
3109
- --ok-soft: #14241b;
3110
- --warn: #fde68a;
3111
- --warn-soft: #2a2410;
3112
- --err: #ffaaaa;
3113
- --err-soft: #2c1516;
3114
- --shadow: none;
3363
+ --bg: #0c0e14;
3364
+ --bg-elev: #151821;
3365
+ --bg-muted: #20232e;
3366
+ --bg-inset: #11141c;
3367
+ --line: #2b303d;
3368
+ --line-strong: #414858;
3369
+ --ink: #f5f7fa;
3370
+ --muted: #b6bdc9;
3371
+ --faint: #8d96a7;
3372
+ --accent: #9e77ed;
3373
+ --accent-hover: #b692f6;
3374
+ --accent-ink: #ffffff;
3375
+ --accent-soft: #2b2142;
3376
+ --ok: #75e0a7;
3377
+ --ok-soft: #102a20;
3378
+ --warn: #fec84b;
3379
+ --warn-soft: #302611;
3380
+ --err: #fda29b;
3381
+ --err-soft: #321817;
3382
+ --code-key: #c3a6ff;
3383
+ --code-string: #75e0a7;
3384
+ --code-number: #84adff;
3385
+ --code-boolean: #f9a8d4;
3386
+ --code-null: #98a2b3;
3387
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3388
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3389
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3115
3390
  }
3116
3391
  @media (prefers-color-scheme: dark) {
3117
3392
  :root:not([data-theme="light"]) {
3118
3393
  color-scheme: dark;
3119
- --bg: #111113;
3120
- --bg-elev: #19191c;
3121
- --bg-muted: #242428;
3122
- --bg-inset: #111113;
3123
- --line: #36363c;
3124
- --line-strong: #52525b;
3125
- --ink: #f4f4f5;
3126
- --muted: #a9a9b2;
3127
- --faint: #71717a;
3128
- --accent: #e4e4e7;
3129
- --accent-ink: #18181b;
3130
- --accent-soft: #242428;
3131
- --ok: #86efac;
3132
- --ok-soft: #14241b;
3133
- --warn: #fde68a;
3134
- --warn-soft: #2a2410;
3135
- --err: #ffaaaa;
3136
- --err-soft: #2c1516;
3137
- --shadow: none;
3394
+ --bg: #0c0e14;
3395
+ --bg-elev: #151821;
3396
+ --bg-muted: #20232e;
3397
+ --bg-inset: #11141c;
3398
+ --line: #2b303d;
3399
+ --line-strong: #414858;
3400
+ --ink: #f5f7fa;
3401
+ --muted: #b6bdc9;
3402
+ --faint: #8d96a7;
3403
+ --accent: #9e77ed;
3404
+ --accent-hover: #b692f6;
3405
+ --accent-ink: #ffffff;
3406
+ --accent-soft: #2b2142;
3407
+ --ok: #75e0a7;
3408
+ --ok-soft: #102a20;
3409
+ --warn: #fec84b;
3410
+ --warn-soft: #302611;
3411
+ --err: #fda29b;
3412
+ --err-soft: #321817;
3413
+ --code-key: #c3a6ff;
3414
+ --code-string: #75e0a7;
3415
+ --code-number: #84adff;
3416
+ --code-boolean: #f9a8d4;
3417
+ --code-null: #98a2b3;
3418
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3419
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3420
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3138
3421
  }
3139
3422
  }
3140
3423
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -3152,13 +3435,17 @@ var renderAdminDocument = (service) => {
3152
3435
  padding-right: env(safe-area-inset-right);
3153
3436
  -webkit-text-size-adjust: 100%;
3154
3437
  }
3155
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3438
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3439
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3440
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3441
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
3156
3442
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
3157
3443
  header.top {
3158
- position: sticky; top: 0; z-index: 5;
3444
+ position: sticky; top: 0; z-index: 20;
3159
3445
  display: flex; align-items: center; gap: 12px;
3160
- min-height: var(--header); padding: 10px 16px;
3161
- background: var(--bg-elev);
3446
+ min-height: var(--header); padding: 12px 20px;
3447
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3448
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
3162
3449
  border-bottom: 1px solid var(--line);
3163
3450
  min-width: 0; max-width: 100%;
3164
3451
  }
@@ -3166,12 +3453,21 @@ var renderAdminDocument = (service) => {
3166
3453
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
3167
3454
  min-width: 0; max-width: 100%; margin-right: auto;
3168
3455
  }
3169
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
3170
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3456
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3457
+ .brand-mark {
3458
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3459
+ border-radius: 11px; color: #fff;
3460
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3461
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3462
+ }
3463
+ .brand-mark svg { width: 22px; height: 22px; }
3464
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3465
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3171
3466
  .brand-id span { color: var(--faint); font-size: 12px; }
3172
3467
  .vendor {
3173
3468
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
3174
3469
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3470
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
3175
3471
  }
3176
3472
  .vendor[hidden] { display: none; }
3177
3473
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -3183,12 +3479,17 @@ var renderAdminDocument = (service) => {
3183
3479
  .vendor-sep { color: var(--faint); }
3184
3480
  .vendor-link { color: inherit; text-decoration: none; }
3185
3481
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
3186
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
3187
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3482
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3483
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3484
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3485
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3486
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
3188
3487
  label.field input, label.field select, .controls input[type="password"] {
3189
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
3190
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3488
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3489
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
3191
3490
  }
3491
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3492
+ 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; }
3192
3493
  /* A native menu ignores min-height, so Preset rendered about half as tall as
3193
3494
  the text fields in the same form. Paint it like those fields. */
3194
3495
  label.field select {
@@ -3205,12 +3506,20 @@ var renderAdminDocument = (service) => {
3205
3506
  dialog .field > textarea { width: 100%; }
3206
3507
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
3207
3508
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
3208
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
3209
- background: var(--bg-elev);
3210
- }
3211
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
3212
- .btn-quiet { background: transparent; }
3213
- .btn-danger { color: var(--err); }
3509
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3510
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3511
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3512
+ box-shadow: var(--shadow-xs);
3513
+ }
3514
+ .icon-btn { width: 38px; padding: 0; }
3515
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3516
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3517
+ .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; }
3518
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3519
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3520
+ .btn-quiet:hover { background: var(--bg-muted); }
3521
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3522
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
3214
3523
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
3215
3524
  so the press itself has to stay visible. */
3216
3525
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -3226,93 +3535,159 @@ var renderAdminDocument = (service) => {
3226
3535
  flex: 1 0 auto; min-width: 0;
3227
3536
  }
3228
3537
  nav.side {
3229
- border-right: 1px solid var(--line); padding: 12px;
3538
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3539
+ border-right: 1px solid var(--line); padding: 18px 12px;
3230
3540
  display: flex; flex-direction: column; gap: 4px;
3231
- background: var(--bg-elev);
3541
+ background: var(--bg-elev); overflow-y: auto;
3232
3542
  }
3543
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
3233
3544
  nav.side button {
3545
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
3234
3546
  text-align: left; border: 0; background: transparent; border-radius: 9px;
3235
- padding: 9px 10px; min-height: 40px; color: var(--muted);
3236
- }
3237
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
3238
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
3239
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
3240
- .lede { margin: 0 0 16px; color: var(--muted); }
3241
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3547
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3548
+ }
3549
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3550
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3551
+ 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); }
3552
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3553
+ .nav-icon svg { width: 18px; height: 18px; }
3554
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3555
+ main[aria-busy="true"] .view { opacity: 0.62; }
3556
+ .view { animation: view-in 180ms ease-out; }
3557
+ .view[hidden] { display: none; }
3558
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3559
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3560
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3561
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3562
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
3242
3563
  .card {
3243
3564
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
3244
- padding: 14px 16px; box-shadow: var(--shadow);
3565
+ padding: 18px; box-shadow: var(--shadow-sm);
3245
3566
  }
3246
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
3247
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3567
+ .stat-card { position: relative; overflow: hidden; }
3568
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3569
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3570
+ .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; }
3248
3571
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
3249
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3572
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3573
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3574
+ .section-title h3 { margin: 0; }
3575
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3576
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3250
3577
  .collection {
3251
3578
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
3252
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3579
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
3253
3580
  }
3254
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3581
+ .collection:hover { background: var(--bg-muted); }
3582
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
3255
3583
  .collection small { display: block; color: var(--faint); }
3256
3584
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
3257
- .chip {
3258
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
3259
- border-radius: 999px; padding: 4px 8px;
3260
- }
3585
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
3261
3586
  .chip i { color: var(--faint); font-style: normal; }
3262
3587
  .sql-layout {
3263
3588
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
3264
3589
  }
3265
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
3266
- .sql-tables button { text-align: left; }
3590
+ .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); }
3591
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3592
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
3267
3593
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
3268
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
3269
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
3270
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3594
+ .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); }
3595
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3596
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3597
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3598
+ tbody tr:last-child td { border-bottom: 0; }
3271
3599
  tr.clickable { cursor: pointer; }
3272
- tr.clickable:hover { background: var(--bg-muted); }
3600
+ tr.clickable:hover { background: var(--accent-soft); }
3273
3601
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
3274
3602
  pre {
3275
3603
  margin: 0; white-space: pre-wrap; word-break: break-word;
3276
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
3277
- }
3278
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3604
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3605
+ }
3606
+ .json-key { color: var(--code-key); }
3607
+ .json-string { color: var(--code-string); }
3608
+ .json-number { color: var(--code-number); }
3609
+ .json-boolean { color: var(--code-boolean); }
3610
+ .json-null { color: var(--code-null); font-style: italic; }
3611
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3612
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3613
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3614
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3615
+ .json-editor.invalid { border-color: var(--err); }
3616
+ .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; }
3617
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3618
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3619
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3620
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3621
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3622
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3623
+ .json-status.invalid { color: var(--err); }
3624
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3625
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
3279
3626
  .banner {
3280
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3281
- background: var(--err-soft); color: var(--err);
3282
- }
3283
- .banner.show { display: block; }
3284
- .empty { color: var(--muted); padding: 24px 8px; }
3627
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3628
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3629
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3630
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3631
+ }
3632
+ .banner.show { display: flex; }
3633
+ .banner span { flex: 1; }
3634
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3635
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3636
+ .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); }
3285
3637
  dialog {
3286
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3287
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3288
- }
3289
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3290
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3638
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3639
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3640
+ }
3641
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3642
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3643
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3644
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3645
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3646
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3291
3647
  textarea {
3292
3648
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3293
3649
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3294
3650
  font-family: var(--mono); font-size: 12px;
3295
3651
  }
3296
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3297
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3652
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3653
+ .dialog-actions .btn-danger { margin-right: auto; }
3654
+ .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); }
3298
3655
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3299
3656
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3300
3657
  .status-2 { color: var(--ok); }
3301
3658
  .status-4, .status-5, .status-0 { color: var(--err); }
3302
3659
  .stack { display: flex; flex-direction: column; gap: 12px; }
3660
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3661
+ .form-grid .wide { grid-column: span 2; }
3303
3662
  .route {
3304
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3305
- padding: 8px 0; border-bottom: 1px solid var(--line);
3306
- }
3307
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3663
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3664
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3665
+ }
3666
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3667
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3668
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3669
+ .route-tools .field { flex: 1; }
3670
+ .route-tools input { width: 100%; }
3671
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3672
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3673
+ .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; }
3674
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3675
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3676
+ @keyframes pulse { 50% { opacity: 0.45; } }
3677
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3678
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3308
3679
  @media (max-width: 800px) {
3680
+ :root { --header: 0px; }
3309
3681
  header.top {
3310
- align-items: stretch; flex-direction: column;
3311
- padding-top: max(10px, env(safe-area-inset-top));
3682
+ position: relative; align-items: stretch; flex-direction: column;
3683
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3312
3684
  }
3313
3685
  input, select, textarea { font-size: 16px; }
3314
3686
  .brand, .vendor, .controls { width: 100%; }
3687
+ .brand { gap: 10px; }
3688
+ .vendor { order: 2; border-radius: 9px; }
3315
3689
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3690
+ .sync-status { display: none; }
3316
3691
  /* The layout fills the screen. A single-column grid stretches every auto
3317
3692
  row, so the tab bar grew with the leftover height and the buttons floated
3318
3693
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3322,14 +3697,23 @@ var renderAdminDocument = (service) => {
3322
3697
  align-content: start;
3323
3698
  }
3324
3699
  nav.side {
3325
- align-self: start; height: auto;
3700
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3326
3701
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3327
3702
  border-right: 0; border-bottom: 1px solid var(--line);
3328
- padding: 8px;
3703
+ padding: 8px; box-shadow: var(--shadow-xs);
3329
3704
  }
3330
- nav.side button { white-space: nowrap; flex: none; }
3705
+ .nav-label { display: none; }
3706
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3707
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3331
3708
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3332
- main { padding: 14px; }
3709
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3710
+ .form-grid .wide { grid-column: auto; }
3711
+ main { padding: 22px 14px 48px; }
3712
+ .view-head { margin-bottom: 18px; }
3713
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3714
+ .card { padding: 14px; }
3715
+ .card .v { font-size: 20px; }
3716
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3333
3717
  table, thead, tbody, tr, th, td { display: block; }
3334
3718
  thead { display: none; }
3335
3719
  tr {
@@ -3344,50 +3728,63 @@ var renderAdminDocument = (service) => {
3344
3728
  .route { grid-template-columns: 72px 1fr; }
3345
3729
  .route button { grid-column: 1 / -1; }
3346
3730
  }
3731
+ @media (max-width: 460px) {
3732
+ .controls label.field { min-width: calc(50% - 4px); }
3733
+ .controls .icon-btn, .controls .btn { flex: 1; }
3734
+ .grid { grid-template-columns: minmax(0, 1fr); }
3735
+ .dialog-actions { flex-wrap: wrap; }
3736
+ .dialog-actions button { flex: 1; }
3737
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3738
+ }
3347
3739
  </style>
3348
3740
  </head>
3349
3741
  <body>
3350
3742
  <div class="app" data-service="${name}" data-mockingbird-admin>
3351
3743
  <header class="top">
3352
3744
  <div class="brand">
3353
- <div class="brand-id">
3354
- <strong>${name}</strong>
3355
- <span>Admin</span>
3745
+ <div class="brand-lockup">
3746
+ <div class="brand-mark" aria-hidden="true">
3747
+ <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>
3748
+ </div>
3749
+ <div class="brand-id">
3750
+ <strong>${name}</strong>
3751
+ <span>Mock admin</span>
3752
+ </div>
3356
3753
  </div>
3357
3754
  <div class="vendor" id="vendor" hidden></div>
3358
3755
  </div>
3359
3756
  <div class="controls">
3757
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3360
3758
  <label class="field">Namespace
3361
3759
  <select id="namespace" aria-label="Namespace"></select>
3362
3760
  </label>
3363
3761
  <label class="field">Admin key
3364
3762
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3365
3763
  </label>
3366
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3367
- <button class="btn" id="refresh" type="button">Refresh</button>
3764
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3765
+ <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>
3368
3766
  </div>
3369
3767
  </header>
3370
3768
  <div class="layout">
3371
3769
  <nav class="side" id="nav" aria-label="Admin sections">
3372
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3373
- <button type="button" data-view="state">State</button>
3374
- <button type="button" data-view="clock">Clock</button>
3375
- <button type="button" data-view="faults">Faults</button>
3376
- <button type="button" data-view="journal">Journal</button>
3377
- <button type="button" data-view="routes">Routes</button>
3770
+ <span class="nav-label">Workspace</span>
3771
+ <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>
3772
+ <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>
3773
+ <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>
3774
+ <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>
3775
+ <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>
3776
+ <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>
3378
3777
  </nav>
3379
3778
  <main id="views">
3380
- <p class="banner" id="banner" role="alert"></p>
3779
+ <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>
3381
3780
  <section class="view" id="view-overview">
3382
- <h2>Overview</h2>
3383
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3781
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3384
3782
  <div class="grid" id="overview-cards"></div>
3385
3783
  <h3>Recent requests</h3>
3386
3784
  <div id="overview-requests"></div>
3387
3785
  </section>
3388
3786
  <section class="view" id="view-state" hidden>
3389
- <h2>State</h2>
3390
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3787
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3391
3788
  <div class="split">
3392
3789
  <div id="collections"></div>
3393
3790
  <div>
@@ -3400,8 +3797,7 @@ var renderAdminDocument = (service) => {
3400
3797
  </div>
3401
3798
  </section>
3402
3799
  <section class="view" id="view-clock" hidden>
3403
- <h2>Clock</h2>
3404
- <p class="lede">The clock every timestamp in this mock reads.</p>
3800
+ <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>
3405
3801
  <div class="card" id="clock-readout"></div>
3406
3802
  <div class="row-actions">
3407
3803
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3420,21 +3816,22 @@ var renderAdminDocument = (service) => {
3420
3816
  </form>
3421
3817
  </section>
3422
3818
  <section class="view" id="view-faults" hidden>
3423
- <h2>Faults</h2>
3424
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3819
+ <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>
3425
3820
  <form id="fault-form" class="card stack">
3426
- <label class="field">Preset
3427
- <select id="fault-preset"><option value="">Custom rule</option></select>
3428
- </label>
3429
- <label class="field">Status
3430
- <input id="fault-status" inputmode="numeric" placeholder="503">
3431
- </label>
3432
- <label class="field">Path prefix
3433
- <input id="fault-path" placeholder="/v1">
3434
- </label>
3435
- <label class="field">Method
3436
- <input id="fault-method" placeholder="GET">
3437
- </label>
3821
+ <div class="form-grid">
3822
+ <label class="field wide">Preset
3823
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3824
+ </label>
3825
+ <label class="field">Status
3826
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3827
+ </label>
3828
+ <label class="field wide">Path prefix
3829
+ <input id="fault-path" placeholder="/v1">
3830
+ </label>
3831
+ <label class="field">Method
3832
+ <input id="fault-method" placeholder="GET">
3833
+ </label>
3834
+ </div>
3438
3835
  <div class="dialog-actions">
3439
3836
  <button class="btn-primary" type="submit">Add fault</button>
3440
3837
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3443,26 +3840,31 @@ var renderAdminDocument = (service) => {
3443
3840
  <div id="fault-list"></div>
3444
3841
  </section>
3445
3842
  <section class="view" id="view-journal" hidden>
3446
- <h2>Journal</h2>
3447
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3448
- <div class="row-actions">
3449
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3450
- </div>
3843
+ <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>
3451
3844
  <div id="journal"></div>
3452
3845
  </section>
3453
3846
  <section class="view" id="view-routes" hidden>
3454
- <h2>Routes</h2>
3455
- <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>
3847
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3848
+ <div class="route-tools">
3849
+ <label class="field">Filter routes
3850
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3851
+ </label>
3852
+ <span class="route-count" id="route-count"></span>
3853
+ </div>
3456
3854
  <div id="route-list"></div>
3457
- <form id="route-form" class="card stack">
3855
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3458
3856
  <strong id="route-title">Select a route</strong>
3459
3857
  <label class="field">JSON body
3460
- <textarea id="route-body" placeholder="{ }"></textarea>
3858
+ <div class="json-editor" id="route-editor" hidden>
3859
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3860
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3861
+ </div>
3862
+ <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>
3461
3863
  </label>
3462
3864
  <div class="dialog-actions">
3463
- <button class="btn-primary" type="submit">Send</button>
3865
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3464
3866
  </div>
3465
- <pre id="route-result">Response will show here.</pre>
3867
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3466
3868
  </form>
3467
3869
  </section>
3468
3870
  </main>
@@ -3470,12 +3872,16 @@ var renderAdminDocument = (service) => {
3470
3872
  </div>
3471
3873
  <dialog id="editor">
3472
3874
  <form method="dialog" id="editor-form">
3473
- <strong id="editor-title">Record</strong>
3875
+ <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>
3474
3876
  <label class="field">Id
3475
3877
  <input id="editor-id" required>
3476
3878
  </label>
3477
3879
  <label class="field">JSON value
3478
- <textarea id="editor-value"></textarea>
3880
+ <div class="json-editor" id="record-editor">
3881
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3882
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3883
+ </div>
3884
+ <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>
3479
3885
  </label>
3480
3886
  <div class="dialog-actions">
3481
3887
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3517,8 +3923,8 @@ var describeExtension = (extension) => {
3517
3923
  ...extension.script !== void 0 ? { script: extension.script } : {}
3518
3924
  };
3519
3925
  };
3520
- var adminUiRoutes = (service, ui) => {
3521
- const shell = () => renderAdminDocument(service);
3926
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3927
+ const shell = () => renderAdminDocument(service, adminPrefix);
3522
3928
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3523
3929
  return {
3524
3930
  "GET /ui": () => htmlResponse(document3()),
@@ -4209,7 +4615,7 @@ var stateAdminRoutes = (routes) => {
4209
4615
  };
4210
4616
 
4211
4617
  // ../core/dist/version.js
4212
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4618
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
4213
4619
 
4214
4620
  // ../core/dist/signing.js
4215
4621
  var encoder = new TextEncoder();
@@ -4302,7 +4708,9 @@ var createWebhookHub = (options) => {
4302
4708
  const faults = /* @__PURE__ */ new Map();
4303
4709
  const held = /* @__PURE__ */ new Map();
4304
4710
  const inFlight2 = /* @__PURE__ */ new Set();
4711
+ const controllers = /* @__PURE__ */ new Map();
4305
4712
  let immediate = Promise.resolve();
4713
+ const snapshots = /* @__PURE__ */ new WeakMap();
4306
4714
  const track = (work) => {
4307
4715
  inFlight2.add(work);
4308
4716
  void work.finally(() => inFlight2.delete(work));
@@ -4323,6 +4731,7 @@ var createWebhookHub = (options) => {
4323
4731
  responseBody: null
4324
4732
  };
4325
4733
  const controller = new AbortController();
4734
+ controllers.set(delivery.id, controller);
4326
4735
  const timer = scheduleTimer(() => controller.abort(), timeoutMs);
4327
4736
  try {
4328
4737
  const signed = await options.signer({
@@ -4352,6 +4761,7 @@ var createWebhookHub = (options) => {
4352
4761
  } catch (error2) {
4353
4762
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error2 instanceof Error ? error2.message : String(error2);
4354
4763
  } finally {
4764
+ controllers.delete(delivery.id);
4355
4765
  cancel(timer);
4356
4766
  record.durationMs = now() - started;
4357
4767
  delivery.attempts.push(record);
@@ -4369,6 +4779,8 @@ var createWebhookHub = (options) => {
4369
4779
  if (!payloads.has(delivery.id))
4370
4780
  return;
4371
4781
  await attempt(delivery).then((ok) => {
4782
+ if (!payloads.has(delivery.id))
4783
+ return;
4372
4784
  if (ok)
4373
4785
  delivery.state = "delivered";
4374
4786
  else
@@ -4430,6 +4842,37 @@ var createWebhookHub = (options) => {
4430
4842
  fanOut(message);
4431
4843
  };
4432
4844
  const hub = {
4845
+ pending(namespace) {
4846
+ return inFlight2.size + (held.get(namespace)?.length ?? 0) + [...deliveries.values()].filter((d) => d.namespace === namespace && d.state === "pending").length;
4847
+ },
4848
+ snapshot(namespace) {
4849
+ if (hub.pending(namespace) > 0)
4850
+ throw new Error("webhooks are not quiescent");
4851
+ const handle = {};
4852
+ snapshots.set(handle, structuredClone({
4853
+ namespace,
4854
+ endpoints: own.get(namespace) ?? [],
4855
+ messages: messages.filter((m) => m.namespace === namespace),
4856
+ deliveries: [...deliveries].filter(([, d]) => d.namespace === namespace),
4857
+ payloads: [...payloads].filter(([, p]) => p.message.namespace === namespace),
4858
+ faults: faults.get(namespace) ?? []
4859
+ }));
4860
+ return handle;
4861
+ },
4862
+ restore(namespace, handle) {
4863
+ const point = typeof handle === "object" && handle !== null ? snapshots.get(handle) : void 0;
4864
+ if (!point || point.namespace !== namespace || hub.pending(namespace) > 0)
4865
+ throw new Error("invalid webhook restore");
4866
+ hub.clear(namespace);
4867
+ const copied = structuredClone(point);
4868
+ own.set(namespace, copied.endpoints);
4869
+ messages.push(...copied.messages);
4870
+ for (const [id2, delivery] of copied.deliveries)
4871
+ deliveries.set(id2, delivery);
4872
+ for (const [id2, payload] of copied.payloads)
4873
+ payloads.set(id2, payload);
4874
+ faults.set(namespace, copied.faults);
4875
+ },
4433
4876
  publish(input) {
4434
4877
  const contentType = input.contentType ?? (input.form ? "application/x-www-form-urlencoded" : "application/json");
4435
4878
  const body = typeof input.body === "string" ? input.body : input.form ? new URLSearchParams(input.form).toString() : JSON.stringify(input.body);
@@ -4520,6 +4963,7 @@ var createWebhookHub = (options) => {
4520
4963
  for (const [id2, delivery] of deliveries) {
4521
4964
  if (namespace !== void 0 && delivery.namespace !== namespace)
4522
4965
  continue;
4966
+ controllers.get(id2)?.abort();
4523
4967
  const timer = pending.get(id2);
4524
4968
  if (timer !== void 0)
4525
4969
  cancel(timer);
@@ -4544,7 +4988,10 @@ var createWebhookHub = (options) => {
4544
4988
  };
4545
4989
  return hub;
4546
4990
  };
4547
- var json5 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4991
+ var json5 = (status, body) => new Response(JSON.stringify(body), {
4992
+ status,
4993
+ headers: { "content-type": "application/json; charset=utf-8" }
4994
+ });
4548
4995
  var adminError3 = (status, message) => json5(status, { error: { type: "mockingbird_admin", message } });
4549
4996
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4550
4997
  var parseEndpoint = (value) => {
@@ -4652,7 +5099,6 @@ var AT_HEADER = "x-mockingbird-at";
4652
5099
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4653
5100
  var DEFAULT_NAMESPACE = "default";
4654
5101
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4655
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4656
5102
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4657
5103
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4658
5104
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4693,6 +5139,22 @@ var operationMatcher = (document3) => {
4693
5139
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4694
5140
  };
4695
5141
  var createRuntime = (options) => {
5142
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
5143
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
5144
+ for (const path of internalPaths) {
5145
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
5146
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
5147
+ }
5148
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
5149
+ const internalMatch = (path) => {
5150
+ for (const pattern of internalPaths) {
5151
+ const actual = path.split("/");
5152
+ const expected = pattern.split("/");
5153
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
5154
+ return pattern;
5155
+ }
5156
+ return void 0;
5157
+ };
4696
5158
  assertAdminUi(options.adminUi);
4697
5159
  const sqlite = bootSqlite(options.sqlite);
4698
5160
  const clock = options.clock ?? createClock();
@@ -4707,6 +5169,20 @@ var createRuntime = (options) => {
4707
5169
  const instances = /* @__PURE__ */ new Map();
4708
5170
  const publicNamespaces = /* @__PURE__ */ new Set();
4709
5171
  const branchRngs = /* @__PURE__ */ new Map();
5172
+ const namespaceClocks = /* @__PURE__ */ new Map();
5173
+ const namespaceForStorage = /* @__PURE__ */ new Map();
5174
+ let isolatedNamespaces = false;
5175
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
5176
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
5177
+ return clock;
5178
+ let found = namespaceClocks.get(name);
5179
+ if (!found) {
5180
+ found = createClock();
5181
+ namespaceClocks.set(name, found);
5182
+ }
5183
+ return found;
5184
+ };
5185
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4710
5186
  const timelines = /* @__PURE__ */ new Map();
4711
5187
  const branchStorage = /* @__PURE__ */ new Map();
4712
5188
  const captured = /* @__PURE__ */ new Map();
@@ -4720,17 +5196,22 @@ var createRuntime = (options) => {
4720
5196
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4721
5197
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4722
5198
  }
5199
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4723
5200
  const created = options.create({
4724
5201
  namespace: storageNamespace(key),
4725
5202
  publicNamespace,
5203
+ adminPrefix,
4726
5204
  sqlite,
4727
- clock,
4728
- rng: isolatedRng ?? rng
5205
+ clock: namespaceClock(publicNamespace),
5206
+ rng: createdContextRng
4729
5207
  });
4730
5208
  instances.set(key, created);
5209
+ namespaceForStorage.set(key, publicNamespace);
4731
5210
  publicNamespaces.add(publicNamespace);
4732
5211
  if (isolatedRng)
4733
5212
  branchRngs.set(key, isolatedRng);
5213
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
5214
+ branchRngs.set(key, createdContextRng);
4734
5215
  return created;
4735
5216
  };
4736
5217
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4748,7 +5229,7 @@ var createRuntime = (options) => {
4748
5229
  captured.set(storage, snapshot2);
4749
5230
  return Object.freeze({
4750
5231
  snapshot: snapshot2,
4751
- clock: Object.freeze(clock.state()),
5232
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4752
5233
  rngState: (branchRngs.get(storage) ?? rng).state()
4753
5234
  });
4754
5235
  };
@@ -4758,7 +5239,7 @@ var createRuntime = (options) => {
4758
5239
  return found;
4759
5240
  instance(name);
4760
5241
  found = new Timeline({
4761
- now: clock.now,
5242
+ now: namespaceClock(name).now,
4762
5243
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4763
5244
  });
4764
5245
  found.commit(capture(name));
@@ -4785,12 +5266,13 @@ var createRuntime = (options) => {
4785
5266
  const point = history2.checkout("main", at);
4786
5267
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4787
5268
  captured.set(namespace, point.value.snapshot);
4788
- rng.setState(point.value.rngState);
4789
- clock.set(point.value.clock.now);
5269
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
5270
+ const selectedClock = namespaceClock(namespace);
5271
+ selectedClock.set(point.value.clock.now);
4790
5272
  if (point.value.clock.frozen)
4791
- clock.freeze();
5273
+ selectedClock.freeze();
4792
5274
  else
4793
- clock.unfreeze();
5275
+ selectedClock.unfreeze();
4794
5276
  }
4795
5277
  return namespace;
4796
5278
  }
@@ -4848,11 +5330,12 @@ var createRuntime = (options) => {
4848
5330
  const storage = ensureBranch(namespace, branchName);
4849
5331
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4850
5332
  captured.set(storage, point.value.snapshot);
4851
- clock.set(point.value.clock.now);
5333
+ const selectedClock = namespaceClock(namespace);
5334
+ selectedClock.set(point.value.clock.now);
4852
5335
  if (point.value.clock.frozen)
4853
- clock.freeze();
5336
+ selectedClock.freeze();
4854
5337
  else
4855
- clock.unfreeze();
5338
+ selectedClock.unfreeze();
4856
5339
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4857
5340
  };
4858
5341
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4860,10 +5343,21 @@ var createRuntime = (options) => {
4860
5343
  options.webhooks?.clear();
4861
5344
  for (const each of instances.values())
4862
5345
  await each.reset();
5346
+ for (const each of branchRngs.values())
5347
+ each.reset();
4863
5348
  timelines.clear();
5349
+ for (const storage of branchStorage.values()) {
5350
+ instances.delete(storage);
5351
+ branchRngs.delete(storage);
5352
+ namespaceForStorage.delete(storage);
5353
+ }
4864
5354
  branchStorage.clear();
4865
- branchRngs.clear();
4866
5355
  captured.clear();
5356
+ faults.clear();
5357
+ clock.reset();
5358
+ rng.reset();
5359
+ for (const each of namespaceClocks.values())
5360
+ each.reset();
4867
5361
  return;
4868
5362
  }
4869
5363
  options.webhooks?.clear(name);
@@ -4881,11 +5375,18 @@ var createRuntime = (options) => {
4881
5375
  else
4882
5376
  clearNamespace(sqlite, storageNamespace(storage));
4883
5377
  branchStorage.delete(mapping);
5378
+ instances.delete(storage);
5379
+ namespaceForStorage.delete(storage);
4884
5380
  branchRngs.delete(storage);
4885
5381
  captured.delete(storage);
4886
5382
  }
4887
5383
  timelines.delete(name);
4888
5384
  captured.delete(name);
5385
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
5386
+ namespaceClock(name).reset();
5387
+ (branchRngs.get(name) ?? rng).reset();
5388
+ journal.clear(name);
5389
+ metrics.reset(name);
4889
5390
  };
4890
5391
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4891
5392
  return checkpoint(name, "main").value.snapshot;
@@ -4923,6 +5424,53 @@ var createRuntime = (options) => {
4923
5424
  rng,
4924
5425
  credentials,
4925
5426
  webhooks: options.webhooks,
5427
+ isolateNamespaces() {
5428
+ if (publicNamespaces.size > 1)
5429
+ throw new Error("enable namespace isolation before creating named namespaces");
5430
+ isolatedNamespaces = true;
5431
+ faults.isolateNamespaces();
5432
+ },
5433
+ namespaceClock,
5434
+ namespaceOf(request) {
5435
+ const url = new URL(request.url);
5436
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
5437
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
5438
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
5439
+ if (isAdminPath(path, adminPrefix))
5440
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
5441
+ if (selected !== void 0)
5442
+ return selected;
5443
+ const credential = options.credential?.(request);
5444
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
5445
+ },
5446
+ fleetSnapshot(namespace) {
5447
+ instance(namespace);
5448
+ const handle = {};
5449
+ fleetPoints.set(handle, {
5450
+ namespace,
5451
+ state: capture(namespace),
5452
+ faults: faults.snapshot(namespace),
5453
+ webhooks: options.webhooks?.snapshot(namespace)
5454
+ });
5455
+ return handle;
5456
+ },
5457
+ fleetRestore(handle, namespace) {
5458
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
5459
+ if (!point || point.namespace !== namespace)
5460
+ throw new Error("invalid fleet checkpoint");
5461
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
5462
+ captured.set(namespace, point.state.snapshot);
5463
+ const selectedClock = namespaceClock(namespace);
5464
+ selectedClock.freeze();
5465
+ selectedClock.set(point.state.clock.now);
5466
+ if (!point.state.clock.frozen)
5467
+ selectedClock.unfreeze();
5468
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
5469
+ faults.restore(namespace, point.faults);
5470
+ if (point.webhooks !== void 0)
5471
+ options.webhooks?.restore(namespace, point.webhooks);
5472
+ timelines.delete(namespace);
5473
+ },
4926
5474
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4927
5475
  const preset = options.presets?.[name];
4928
5476
  if (!preset)
@@ -4954,7 +5502,7 @@ var createRuntime = (options) => {
4954
5502
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4955
5503
  fetch: async (incoming) => {
4956
5504
  let request = incoming;
4957
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5505
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4958
5506
  if (prefixed) {
4959
5507
  const url2 = new URL(request.url);
4960
5508
  url2.pathname = prefixed[2] ?? "/";
@@ -4970,7 +5518,15 @@ var createRuntime = (options) => {
4970
5518
  signal: request.signal
4971
5519
  });
4972
5520
  }
4973
- let namespace = control.namespaceOf(request);
5521
+ const internalUrl = new URL(request.url);
5522
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5523
+ const internal = internalMatch(internalPath);
5524
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5525
+ if (internal) {
5526
+ internalUrl.pathname = internalPath;
5527
+ request = new Request(internalUrl, request);
5528
+ }
5529
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4974
5530
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4975
5531
  const credential = options.credential(request);
4976
5532
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4990,7 +5546,9 @@ var createRuntime = (options) => {
4990
5546
  return copy;
4991
5547
  }
4992
5548
  };
4993
- const handled = await control.handle(request);
5549
+ if (obsoleteInternalPath)
5550
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5551
+ const handled = internal ? void 0 : await control.handle(request);
4994
5552
  if (handled)
4995
5553
  return stamp(handled);
4996
5554
  const started = monotonicNow();
@@ -5031,7 +5589,7 @@ var createRuntime = (options) => {
5031
5589
  type: "mockingbird_admin",
5032
5590
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
5033
5591
  }
5034
- }), { status: 400, headers: { "content-type": "application/json" } }));
5592
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
5035
5593
  }
5036
5594
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
5037
5595
  log(400);
@@ -5098,6 +5656,7 @@ var createRuntime = (options) => {
5098
5656
  startedAt: wallNow(),
5099
5657
  wallNow,
5100
5658
  clock,
5659
+ namespaceClock,
5101
5660
  faults,
5102
5661
  metrics,
5103
5662
  journal,
@@ -5147,15 +5706,16 @@ var createRuntime = (options) => {
5147
5706
  ...options.admin?.(runtime) ?? {},
5148
5707
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
5149
5708
  // maps `presets`, and a service route must not replace that list with a record.
5150
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5709
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
5151
5710
  ...stateAdminRoutes({
5152
5711
  open: stateScope,
5153
5712
  afterWrite: (namespace) => {
5154
5713
  checkpoint(namespace, "main");
5155
5714
  }
5156
5715
  }),
5157
- ...adminUiRoutes(options.name, options.adminUi)
5716
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
5158
5717
  },
5718
+ adminPrefix,
5159
5719
  adminKey: options.adminKey
5160
5720
  });
5161
5721
  return defineMock(runtime);
@@ -5169,7 +5729,10 @@ var mutableResponse = (response) => {
5169
5729
  return new Response(response.body, response);
5170
5730
  }
5171
5731
  };
5172
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5732
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5733
+ status,
5734
+ headers: { "content-type": "application/json; charset=utf-8" }
5735
+ });
5173
5736
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
5174
5737
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5175
5738
  var credentialRoutes = (registry) => ({
@@ -5221,7 +5784,7 @@ var credentialRoutes = (registry) => ({
5221
5784
  return adminJson(200, { status: "ok" });
5222
5785
  }
5223
5786
  });
5224
- var presetRoutes = (presets, runtime) => ({
5787
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
5225
5788
  "GET /faults/presets": () => {
5226
5789
  const body = {
5227
5790
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -5231,7 +5794,7 @@ var presetRoutes = (presets, runtime) => ({
5231
5794
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
5232
5795
  const name = params.name;
5233
5796
  if (!presets[name])
5234
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5797
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
5235
5798
  const overrides = isObject(body) ? body : {};
5236
5799
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
5237
5800
  }
@@ -5364,25 +5927,28 @@ var RESEND_PRESETS = {
5364
5927
  var forwardToInbox = async (target, email, namespace) => {
5365
5928
  const send = target.fetch ?? ((request) => fetch(request));
5366
5929
  const response = await send(
5367
- new Request(`${target.url.replace(/\/$/, "")}/__admin/ingest`, {
5368
- method: "POST",
5369
- headers: {
5370
- "content-type": "application/json",
5371
- "x-mockingbird-namespace": namespace,
5372
- ...target.adminKey ? { "x-mockingbird-admin-key": target.adminKey } : {}
5373
- },
5374
- body: JSON.stringify({
5375
- from: email.from,
5376
- to: email.toHeader,
5377
- cc: email.cc,
5378
- bcc: email.bcc,
5379
- subject: email.subject,
5380
- html: email.html,
5381
- text: email.text,
5382
- headers: email.headers
5383
- }),
5384
- signal: AbortSignal.timeout(target.timeoutMs ?? 2e3)
5385
- })
5930
+ new Request(
5931
+ `${target.url.replace(/\/$/, "")}${resolveAdminPrefix(target.adminPrefix)}/ingest`,
5932
+ {
5933
+ method: "POST",
5934
+ headers: {
5935
+ "content-type": "application/json",
5936
+ "x-mockingbird-namespace": namespace,
5937
+ ...target.adminKey ? { "x-mockingbird-admin-key": target.adminKey } : {}
5938
+ },
5939
+ body: JSON.stringify({
5940
+ from: email.from,
5941
+ to: email.toHeader,
5942
+ cc: email.cc,
5943
+ bcc: email.bcc,
5944
+ subject: email.subject,
5945
+ html: email.html,
5946
+ text: email.text,
5947
+ headers: email.headers
5948
+ }),
5949
+ signal: AbortSignal.timeout(target.timeoutMs ?? 2e3)
5950
+ }
5951
+ )
5386
5952
  );
5387
5953
  await response.body?.cancel();
5388
5954
  return response.ok;
@@ -5469,15 +6035,17 @@ var createRuntime2 = (options = {}) => {
5469
6035
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5470
6036
  ...options.clock ? { clock: options.clock } : {},
5471
6037
  ...options.seed !== void 0 ? { seed: options.seed } : {},
6038
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5472
6039
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5473
6040
  ...options.onLog ? { onLog: options.onLog } : {},
5474
6041
  credential: bearerToken,
5475
6042
  presets: RESEND_PRESETS,
5476
6043
  webhooks: hub,
5477
- create: ({ sqlite, namespace, publicNamespace, clock }) => new ResendAPI({
6044
+ create: ({ sqlite, namespace, publicNamespace, adminPrefix, clock }) => new ResendAPI({
5478
6045
  sqlite,
5479
6046
  namespace,
5480
6047
  publicNamespace,
6048
+ adminPrefix,
5481
6049
  now: clock.now,
5482
6050
  onOutcome: (event) => recordOutcome(event),
5483
6051
  ...target ? {
@@ -5589,6 +6157,7 @@ var ResendAPI = class {
5589
6157
  state;
5590
6158
  service;
5591
6159
  now;
6160
+ adminPrefix;
5592
6161
  publicNamespace;
5593
6162
  onSent;
5594
6163
  onOutcome;
@@ -5602,6 +6171,7 @@ var ResendAPI = class {
5602
6171
  const sqlite = bootSqlite(options.sqlite);
5603
6172
  const namespace = options.namespace ?? RESEND_NAMESPACE;
5604
6173
  this.now = options.now ?? (() => Date.now());
6174
+ this.adminPrefix = options.adminPrefix ?? "/__admin";
5605
6175
  this.publicNamespace = options.publicNamespace;
5606
6176
  this.onSent = options.onSent;
5607
6177
  this.onOutcome = options.onOutcome;
@@ -5853,7 +6423,7 @@ var ResendAPI = class {
5853
6423
  };
5854
6424
  }
5855
6425
  downloadUrl(origin, attachmentId) {
5856
- const prefix = this.publicNamespace && this.publicNamespace !== "default" ? `/ns/${encodeURIComponent(this.publicNamespace)}` : "";
6426
+ const prefix = this.publicNamespace && this.publicNamespace !== "default" ? `${this.adminPrefix}/ns/${encodeURIComponent(this.publicNamespace)}` : "";
5857
6427
  return `${origin}${prefix}/downloads/inbound/${attachmentId}`;
5858
6428
  }
5859
6429
  getReceived(context) {
@@ -5914,6 +6484,9 @@ var ResendAPI = class {
5914
6484
  };
5915
6485
 
5916
6486
  export {
6487
+ createClock,
6488
+ resolveAdminPrefix,
6489
+ parseDuration,
5917
6490
  document2 as document,
5918
6491
  operationIds,
5919
6492
  supportedOperationIds,
@@ -5925,4 +6498,4 @@ export {
5925
6498
  bareAddress,
5926
6499
  ResendAPI
5927
6500
  };
5928
- //# sourceMappingURL=chunk-ESQWM4LS.js.map
6501
+ //# sourceMappingURL=chunk-AVLXDCB3.js.map