@crvouga/mockingbird-service-junction 1.3.0 → 1.5.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.
@@ -42,30 +42,30 @@ var createClock = (source = Date.now) => {
42
42
  var Collection = class {
43
43
  sqlite;
44
44
  namespace;
45
- name;
46
- constructor(sqlite, namespace, name) {
45
+ collectionName;
46
+ constructor(sqlite, namespace, collectionName) {
47
47
  this.sqlite = sqlite;
48
48
  this.namespace = namespace;
49
- this.name = name;
49
+ this.collectionName = collectionName;
50
50
  }
51
51
  bumpCollectionSeq() {
52
- const row = this.sqlite.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.name);
52
+ const row = this.sqlite.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.collectionName);
53
53
  const next = (row?.value ?? 0) + 1;
54
54
  this.sqlite.prepare(`INSERT INTO mockingbird_sequences (namespace, name, kind, value) VALUES (?, ?, 'collection', ?)
55
- ON CONFLICT(namespace, name, kind) DO UPDATE SET value = excluded.value`).run(this.namespace, this.name, next);
55
+ ON CONFLICT(namespace, name, kind) DO UPDATE SET value = excluded.value`).run(this.namespace, this.collectionName, next);
56
56
  return next;
57
57
  }
58
58
  nextSequence() {
59
59
  return this.sqlite.transaction(() => this.bumpCollectionSeq());
60
60
  }
61
61
  get(id) {
62
- const row = this.sqlite.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
62
+ const row = this.sqlite.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
63
63
  if (!row)
64
64
  return void 0;
65
65
  return JSON.parse(row.value).value;
66
66
  }
67
67
  has(id) {
68
- const row = this.sqlite.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
68
+ const row = this.sqlite.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
69
69
  return row !== void 0;
70
70
  }
71
71
  /** Insert a new record, assigning it the next sequence number. */
@@ -75,32 +75,32 @@ var Collection = class {
75
75
  const stored = { seq, value };
76
76
  this.sqlite.prepare(`INSERT INTO mockingbird_records (namespace, collection, id, seq, value)
77
77
  VALUES (?, ?, ?, ?, ?)
78
- ON CONFLICT(namespace, collection, id) DO UPDATE SET seq = excluded.seq, value = excluded.value`).run(this.namespace, this.name, id, seq, JSON.stringify(stored));
78
+ ON CONFLICT(namespace, collection, id) DO UPDATE SET seq = excluded.seq, value = excluded.value`).run(this.namespace, this.collectionName, id, seq, JSON.stringify(stored));
79
79
  return stored;
80
80
  });
81
81
  }
82
82
  /** Replace an existing record's value, keeping its position. */
83
83
  update(id, value) {
84
84
  return this.sqlite.transaction(() => {
85
- const row = this.sqlite.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
85
+ const row = this.sqlite.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
86
86
  if (!row)
87
87
  return void 0;
88
88
  const stored = { seq: row.seq, value };
89
- this.sqlite.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.name, id);
89
+ this.sqlite.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.collectionName, id);
90
90
  return stored;
91
91
  });
92
92
  }
93
93
  delete(id) {
94
- const result = this.sqlite.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.name, id);
94
+ const result = this.sqlite.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.collectionName, id);
95
95
  return result.changes > 0;
96
96
  }
97
97
  /** How many records the collection holds, without reading them. */
98
98
  count() {
99
- const row = this.sqlite.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.name);
99
+ const row = this.sqlite.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.collectionName);
100
100
  return Number(row?.n ?? 0);
101
101
  }
102
102
  list(options = {}) {
103
- const rows = this.sqlite.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.name);
103
+ const rows = this.sqlite.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.collectionName);
104
104
  const out = [];
105
105
  for (const row of rows) {
106
106
  const stored = JSON.parse(row.value);
@@ -357,16 +357,18 @@ var createControlPlane = (context) => {
357
357
  uptimeMs: context.wallNow() - context.startedAt,
358
358
  clock: context.clock.state(),
359
359
  namespaces: context.namespaces().length,
360
+ adminUi: `${ADMIN_PREFIX}/ui`,
360
361
  ...context.describe()
361
362
  });
362
363
  }
363
364
  if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
364
365
  return void 0;
365
366
  }
366
- if (context.adminKey !== void 0 && request2.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
367
+ const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
368
+ const shell = request2.method === "GET" && (path === "/ui" || path === "/ui/");
369
+ if (!shell && context.adminKey !== void 0 && request2.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
367
370
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
368
371
  }
369
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
370
372
  for (const route of routes) {
371
373
  if (route.method !== request2.method)
372
374
  continue;
@@ -1140,6 +1142,936 @@ var clearNamespace = (sqlite, namespace) => {
1140
1142
  });
1141
1143
  };
1142
1144
 
1145
+ // ../core/dist/surface.js
1146
+ var defineMock = (runtime) => runtime;
1147
+ var STANDARD_ADMIN_ROUTES = [
1148
+ "GET /",
1149
+ "POST /reset",
1150
+ "GET /namespaces",
1151
+ "GET /clock",
1152
+ "POST /clock",
1153
+ "GET /faults",
1154
+ "POST /faults",
1155
+ "DELETE /faults",
1156
+ "POST /snapshots",
1157
+ "POST /snapshots/:id/restore",
1158
+ "DELETE /snapshots/:id",
1159
+ "GET /timeline",
1160
+ "POST /checkpoints",
1161
+ "POST /branches/:name",
1162
+ "POST /branches/:name/checkout",
1163
+ "GET /requests",
1164
+ "DELETE /requests",
1165
+ "GET /metrics",
1166
+ "DELETE /metrics",
1167
+ "GET /credentials",
1168
+ "PUT /credentials",
1169
+ "DELETE /credentials",
1170
+ "GET /state",
1171
+ "GET /state/:collection",
1172
+ "GET /state/:collection/:id",
1173
+ "POST /state/:collection",
1174
+ "PUT /state/:collection/:id",
1175
+ "PATCH /state/:collection/:id",
1176
+ "DELETE /state/:collection/:id",
1177
+ "GET /ui",
1178
+ "GET /ui/",
1179
+ "GET /ui/manifest"
1180
+ ];
1181
+ var PANEL_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
1182
+ var assertAdminUi = (ui) => {
1183
+ if (!ui?.panels)
1184
+ return;
1185
+ const seen = /* @__PURE__ */ new Set();
1186
+ for (const panel of ui.panels) {
1187
+ if (!PANEL_ID.test(panel.id)) {
1188
+ throw new RangeError(`admin panel id must match ${PANEL_ID}: ${JSON.stringify(panel.id)}`);
1189
+ }
1190
+ if (seen.has(panel.id))
1191
+ throw new RangeError(`duplicate admin panel id ${panel.id}`);
1192
+ seen.add(panel.id);
1193
+ if (panel.title.trim() === "")
1194
+ throw new RangeError(`admin panel ${panel.id} needs a title`);
1195
+ }
1196
+ };
1197
+
1198
+ // ../core/dist/admin-ui.js
1199
+ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
1200
+ switch (char) {
1201
+ case "&":
1202
+ return "&amp;";
1203
+ case "<":
1204
+ return "&lt;";
1205
+ case ">":
1206
+ return "&gt;";
1207
+ case '"':
1208
+ return "&quot;";
1209
+ default:
1210
+ return "&#39;";
1211
+ }
1212
+ });
1213
+ var renderAdminDocument = (service) => {
1214
+ const name = escapeHtml(service);
1215
+ const standard = JSON.stringify(STANDARD_ADMIN_ROUTES);
1216
+ return `<!DOCTYPE html>
1217
+ <html lang="en">
1218
+ <head>
1219
+ <meta charset="utf-8">
1220
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1221
+ <meta name="color-scheme" content="light dark">
1222
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%233d4f3a'/%3E%3C/svg%3E">
1223
+ <title>${name} admin</title>
1224
+ <style>
1225
+ :root {
1226
+ color-scheme: light;
1227
+ --bg: #f6f5f2;
1228
+ --bg-elev: #ffffff;
1229
+ --bg-muted: #eeece6;
1230
+ --bg-inset: #f3f1eb;
1231
+ --line: #e2dfd6;
1232
+ --line-strong: #d0ccc1;
1233
+ --ink: #1c1915;
1234
+ --muted: #5e584f;
1235
+ --faint: #8a8378;
1236
+ --accent: #3d4f3a;
1237
+ --accent-ink: #f7f6f2;
1238
+ --accent-soft: #e5ece2;
1239
+ --ok: #1f7a45;
1240
+ --ok-soft: #e5f4eb;
1241
+ --warn: #9a6700;
1242
+ --warn-soft: #fbf3df;
1243
+ --err: #a33b32;
1244
+ --err-soft: #fdecea;
1245
+ --shadow: 0 1px 2px rgb(28 25 21 / 0.05), 0 12px 32px rgb(28 25 21 / 0.06);
1246
+ --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1247
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
1248
+ --radius: 14px;
1249
+ --header: 60px;
1250
+ --nav: 220px;
1251
+ }
1252
+ :root[data-theme="dark"] {
1253
+ color-scheme: dark;
1254
+ --bg: #141311;
1255
+ --bg-elev: #1e1c19;
1256
+ --bg-muted: #26231f;
1257
+ --bg-inset: #181715;
1258
+ --line: #34302b;
1259
+ --line-strong: #4a453d;
1260
+ --ink: #f4f1ea;
1261
+ --muted: #c4bdb1;
1262
+ --faint: #8f877c;
1263
+ --accent: #c5d5b8;
1264
+ --accent-ink: #1a2118;
1265
+ --accent-soft: #2a3328;
1266
+ --ok: #8fd6a8;
1267
+ --ok-soft: #1a2e22;
1268
+ --warn: #f0cc78;
1269
+ --warn-soft: #332911;
1270
+ --err: #f0a8a2;
1271
+ --err-soft: #3a201e;
1272
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.28);
1273
+ }
1274
+ @media (prefers-color-scheme: dark) {
1275
+ :root:not([data-theme="light"]) {
1276
+ color-scheme: dark;
1277
+ --bg: #141311;
1278
+ --bg-elev: #1e1c19;
1279
+ --bg-muted: #26231f;
1280
+ --bg-inset: #181715;
1281
+ --line: #34302b;
1282
+ --line-strong: #4a453d;
1283
+ --ink: #f4f1ea;
1284
+ --muted: #c4bdb1;
1285
+ --faint: #8f877c;
1286
+ --accent: #c5d5b8;
1287
+ --accent-ink: #1a2118;
1288
+ --accent-soft: #2a3328;
1289
+ --ok: #8fd6a8;
1290
+ --ok-soft: #1a2e22;
1291
+ --warn: #f0cc78;
1292
+ --warn-soft: #332911;
1293
+ --err: #f0a8a2;
1294
+ --err-soft: #3a201e;
1295
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.28);
1296
+ }
1297
+ }
1298
+ * { box-sizing: border-box; }
1299
+ html, body { margin: 0; height: 100%; }
1300
+ body {
1301
+ font-family: var(--sans);
1302
+ background: var(--bg);
1303
+ color: var(--ink);
1304
+ line-height: 1.45;
1305
+ padding-left: env(safe-area-inset-left);
1306
+ padding-right: env(safe-area-inset-right);
1307
+ -webkit-text-size-adjust: 100%;
1308
+ }
1309
+ button, input, select, textarea { font: inherit; color: inherit; }
1310
+ button { cursor: pointer; }
1311
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1312
+ @media (prefers-reduced-motion: reduce) {
1313
+ * { scroll-behavior: auto !important; transition: none !important; }
1314
+ }
1315
+ .app { min-height: 100%; display: flex; flex-direction: column; }
1316
+ header.top {
1317
+ position: sticky; top: 0; z-index: 5;
1318
+ display: flex; align-items: center; gap: 12px;
1319
+ min-height: var(--header); padding: 10px 16px;
1320
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
1321
+ border-bottom: 1px solid var(--line);
1322
+ backdrop-filter: blur(10px);
1323
+ }
1324
+ .brand { display: flex; flex-direction: column; min-width: 0; margin-right: auto; }
1325
+ .brand strong { font-size: 15px; letter-spacing: -0.01em; }
1326
+ .brand span { color: var(--faint); font-size: 12px; }
1327
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1328
+ label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
1329
+ label.field input, label.field select, .controls input[type="password"] {
1330
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
1331
+ padding: 7px 8px; min-height: 36px;
1332
+ }
1333
+ .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
1334
+ border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
1335
+ background: var(--bg-elev);
1336
+ }
1337
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
1338
+ .btn-quiet { background: transparent; }
1339
+ .btn-danger { color: var(--err); }
1340
+ /* minmax(0, 1fr): a 1fr column will not shrink below its content, so a long
1341
+ record or the full nav row would widen the page past the viewport. */
1342
+ .layout {
1343
+ display: grid; grid-template-columns: var(--nav) minmax(0, 1fr);
1344
+ flex: 1; min-height: 0; min-width: 0;
1345
+ }
1346
+ nav.side {
1347
+ border-right: 1px solid var(--line); padding: 12px;
1348
+ display: flex; flex-direction: column; gap: 4px;
1349
+ background: var(--bg-elev);
1350
+ }
1351
+ nav.side button {
1352
+ text-align: left; border: 0; background: transparent; border-radius: 9px;
1353
+ padding: 9px 10px; min-height: 40px; color: var(--muted);
1354
+ }
1355
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
1356
+ main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
1357
+ .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
1358
+ .lede { margin: 0 0 16px; color: var(--muted); }
1359
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
1360
+ .card {
1361
+ background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
1362
+ padding: 14px 16px; box-shadow: var(--shadow);
1363
+ }
1364
+ .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
1365
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
1366
+ .card .v .clock-time { font-size: 14px; color: var(--muted); }
1367
+ .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
1368
+ .collection {
1369
+ width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
1370
+ border-radius: 10px; padding: 8px 10px; min-height: 44px;
1371
+ }
1372
+ .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
1373
+ .collection small { display: block; color: var(--faint); }
1374
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
1375
+ .chip {
1376
+ font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
1377
+ border-radius: 999px; padding: 4px 8px;
1378
+ }
1379
+ .chip i { color: var(--faint); font-style: normal; }
1380
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
1381
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
1382
+ th { color: var(--faint); font-weight: 600; font-size: 12px; }
1383
+ tr.clickable { cursor: pointer; }
1384
+ tr.clickable:hover { background: var(--bg-muted); }
1385
+ pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
1386
+ pre {
1387
+ margin: 0; white-space: pre-wrap; word-break: break-word;
1388
+ background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
1389
+ }
1390
+ .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
1391
+ .banner {
1392
+ display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
1393
+ background: var(--err-soft); color: var(--err);
1394
+ }
1395
+ .banner.show { display: block; }
1396
+ .empty { color: var(--muted); padding: 24px 8px; }
1397
+ dialog {
1398
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
1399
+ color: var(--ink); width: min(640px, calc(100vw - 24px)); box-shadow: var(--shadow);
1400
+ }
1401
+ dialog::backdrop { background: rgb(20 19 17 / 0.45); }
1402
+ dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
1403
+ textarea {
1404
+ width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
1405
+ border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
1406
+ font-family: var(--mono); font-size: 12px;
1407
+ }
1408
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
1409
+ .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
1410
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
1411
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
1412
+ .status-2 { color: var(--ok); }
1413
+ .status-4, .status-5, .status-0 { color: var(--err); }
1414
+ .stack { display: flex; flex-direction: column; gap: 12px; }
1415
+ .route {
1416
+ display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
1417
+ padding: 8px 0; border-bottom: 1px solid var(--line);
1418
+ }
1419
+ .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
1420
+ @media (max-width: 800px) {
1421
+ header.top {
1422
+ align-items: stretch; flex-direction: column;
1423
+ padding-top: max(10px, env(safe-area-inset-top));
1424
+ }
1425
+ input, select, textarea { font-size: 16px; }
1426
+ .controls { width: 100%; }
1427
+ label.field, label.field select, label.field input { flex: 1; min-width: 0; }
1428
+ .layout { grid-template-columns: minmax(0, 1fr); }
1429
+ nav.side {
1430
+ flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
1431
+ border-right: 0; border-bottom: 1px solid var(--line);
1432
+ padding: 8px;
1433
+ }
1434
+ nav.side button { white-space: nowrap; flex: none; }
1435
+ .split { grid-template-columns: minmax(0, 1fr); }
1436
+ main { padding: 14px; }
1437
+ table, thead, tbody, tr, th, td { display: block; }
1438
+ thead { display: none; }
1439
+ tr {
1440
+ border: 1px solid var(--line); border-radius: 12px; margin-bottom: 8px; padding: 6px;
1441
+ background: var(--bg-elev); min-width: 0;
1442
+ }
1443
+ td {
1444
+ border: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px;
1445
+ min-width: 0; overflow-wrap: anywhere;
1446
+ }
1447
+ td::before { content: attr(data-label); color: var(--faint); font-size: 12px; flex: none; }
1448
+ .route { grid-template-columns: 72px 1fr; }
1449
+ .route button { grid-column: 1 / -1; }
1450
+ }
1451
+ </style>
1452
+ </head>
1453
+ <body>
1454
+ <div class="app" data-service="${name}" data-mockingbird-admin>
1455
+ <header class="top">
1456
+ <div class="brand">
1457
+ <strong>${name}</strong>
1458
+ <span>Mockingbird admin</span>
1459
+ </div>
1460
+ <div class="controls">
1461
+ <label class="field">Namespace
1462
+ <select id="namespace" aria-label="Namespace"></select>
1463
+ </label>
1464
+ <label class="field">Admin key
1465
+ <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
1466
+ </label>
1467
+ <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
1468
+ <button class="btn" id="refresh" type="button">Refresh</button>
1469
+ </div>
1470
+ </header>
1471
+ <div class="layout">
1472
+ <nav class="side" id="nav" aria-label="Admin sections">
1473
+ <button type="button" data-view="overview" aria-current="true">Overview</button>
1474
+ <button type="button" data-view="state">State</button>
1475
+ <button type="button" data-view="clock">Clock</button>
1476
+ <button type="button" data-view="faults">Faults</button>
1477
+ <button type="button" data-view="journal">Journal</button>
1478
+ <button type="button" data-view="routes">Routes</button>
1479
+ </nav>
1480
+ <main id="views">
1481
+ <p class="banner" id="banner" role="alert"></p>
1482
+ <section class="view" id="view-overview">
1483
+ <h2>Overview</h2>
1484
+ <p class="lede">Health, clock, and the collections this namespace is holding.</p>
1485
+ <div class="grid" id="overview-cards"></div>
1486
+ <h3>Recent requests</h3>
1487
+ <div id="overview-requests"></div>
1488
+ </section>
1489
+ <section class="view" id="view-state" hidden>
1490
+ <h2>State</h2>
1491
+ <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
1492
+ <div class="split">
1493
+ <div id="collections"></div>
1494
+ <div>
1495
+ <div id="schema"></div>
1496
+ <div class="row-actions">
1497
+ <button class="btn-primary" id="new-record" type="button">New record</button>
1498
+ </div>
1499
+ <div id="records"></div>
1500
+ </div>
1501
+ </div>
1502
+ </section>
1503
+ <section class="view" id="view-clock" hidden>
1504
+ <h2>Clock</h2>
1505
+ <p class="lede">The clock every timestamp in this mock reads.</p>
1506
+ <div class="card" id="clock-readout"></div>
1507
+ <div class="row-actions">
1508
+ <button class="btn" type="button" data-advance="60000">+1m</button>
1509
+ <button class="btn" type="button" data-advance="900000">+15m</button>
1510
+ <button class="btn" type="button" data-advance="3600000">+1h</button>
1511
+ <button class="btn" type="button" data-advance="86400000">+1d</button>
1512
+ <button class="btn" type="button" data-advance="-3600000">\u22121h</button>
1513
+ <button class="btn" id="freeze" type="button">Freeze</button>
1514
+ <button class="btn-quiet" id="clock-reset" type="button">Reset</button>
1515
+ </div>
1516
+ <form id="clock-set" class="row-actions">
1517
+ <label class="field">Set instant
1518
+ <input id="clock-instant" type="text" placeholder="2026-01-01T00:00:00Z or epoch ms" size="32">
1519
+ </label>
1520
+ <button class="btn-primary" type="submit">Set</button>
1521
+ </form>
1522
+ </section>
1523
+ <section class="view" id="view-faults" hidden>
1524
+ <h2>Faults</h2>
1525
+ <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
1526
+ <form id="fault-form" class="card stack">
1527
+ <label class="field">Preset
1528
+ <select id="fault-preset"><option value="">Custom rule</option></select>
1529
+ </label>
1530
+ <label class="field">Status
1531
+ <input id="fault-status" inputmode="numeric" placeholder="503">
1532
+ </label>
1533
+ <label class="field">Path prefix
1534
+ <input id="fault-path" placeholder="/v1">
1535
+ </label>
1536
+ <label class="field">Method
1537
+ <input id="fault-method" placeholder="GET">
1538
+ </label>
1539
+ <div class="dialog-actions">
1540
+ <button class="btn-primary" type="submit">Add fault</button>
1541
+ <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
1542
+ </div>
1543
+ </form>
1544
+ <div id="fault-list"></div>
1545
+ </section>
1546
+ <section class="view" id="view-journal" hidden>
1547
+ <h2>Journal</h2>
1548
+ <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
1549
+ <div class="row-actions">
1550
+ <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
1551
+ </div>
1552
+ <div id="journal"></div>
1553
+ </section>
1554
+ <section class="view" id="view-routes" hidden>
1555
+ <h2>Routes</h2>
1556
+ <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>
1557
+ <div id="route-list"></div>
1558
+ <form id="route-form" class="card stack">
1559
+ <strong id="route-title">Select a route</strong>
1560
+ <label class="field">JSON body
1561
+ <textarea id="route-body" placeholder="{ }"></textarea>
1562
+ </label>
1563
+ <div class="dialog-actions">
1564
+ <button class="btn-primary" type="submit">Send</button>
1565
+ </div>
1566
+ <pre id="route-result">Response will show here.</pre>
1567
+ </form>
1568
+ </section>
1569
+ </main>
1570
+ </div>
1571
+ </div>
1572
+ <dialog id="editor">
1573
+ <form method="dialog" id="editor-form">
1574
+ <strong id="editor-title">Record</strong>
1575
+ <label class="field">Id
1576
+ <input id="editor-id" required>
1577
+ </label>
1578
+ <label class="field">JSON value
1579
+ <textarea id="editor-value"></textarea>
1580
+ </label>
1581
+ <div class="dialog-actions">
1582
+ <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
1583
+ <button class="btn-quiet" type="submit" value="cancel">Cancel</button>
1584
+ <button class="btn-primary" type="submit" value="save">Save</button>
1585
+ </div>
1586
+ </form>
1587
+ </dialog>
1588
+ <script>
1589
+ (() => {
1590
+ const STANDARD = new Set(${standard})
1591
+ const state = {
1592
+ namespace: "default",
1593
+ key: sessionStorage.getItem("mockingbird-admin-key") || "",
1594
+ view: "overview",
1595
+ collection: "",
1596
+ shape: null,
1597
+ route: null,
1598
+ editing: null
1599
+ }
1600
+ const $ = (id) => document.getElementById(id)
1601
+ const banner = $("banner")
1602
+ const keyInput = $("admin-key")
1603
+ keyInput.value = state.key
1604
+ const params = new URLSearchParams(location.search)
1605
+ if (params.get("key")) {
1606
+ state.key = params.get("key")
1607
+ keyInput.value = state.key
1608
+ sessionStorage.setItem("mockingbird-admin-key", state.key)
1609
+ params.delete("key")
1610
+ const next = params.toString()
1611
+ history.replaceState(null, "", location.pathname + (next ? "?" + next : ""))
1612
+ }
1613
+ if (params.get("namespace")) state.namespace = params.get("namespace")
1614
+
1615
+ const showError = (error) => {
1616
+ banner.textContent = error instanceof Error ? error.message : String(error)
1617
+ banner.classList.add("show")
1618
+ }
1619
+ const clearError = () => banner.classList.remove("show")
1620
+
1621
+ async function originGet(path) {
1622
+ const response = await fetch(path, { headers: { accept: "application/json" } })
1623
+ if (!response.ok) throw new Error(response.status + " " + path)
1624
+ return response.json()
1625
+ }
1626
+ async function api(method, path, body) {
1627
+ const headers = { accept: "application/json" }
1628
+ if (state.key) headers["${ADMIN_KEY_HEADER}"] = state.key
1629
+ if (state.namespace) headers["x-mockingbird-namespace"] = state.namespace
1630
+ if (body !== undefined) headers["content-type"] = "application/json"
1631
+ const response = await fetch("${ADMIN_PREFIX}" + path, {
1632
+ method,
1633
+ headers,
1634
+ body: body === undefined ? undefined : JSON.stringify(body)
1635
+ })
1636
+ const text = await response.text()
1637
+ let data = null
1638
+ if (text) {
1639
+ try { data = JSON.parse(text) } catch { data = { raw: text } }
1640
+ }
1641
+ if (response.status === 401) {
1642
+ keyInput.focus()
1643
+ throw new Error((data && data.error && data.error.message) || "Admin key required")
1644
+ }
1645
+ if (!response.ok) {
1646
+ throw new Error((data && data.error && data.error.message) || (response.status + " " + method + " " + path))
1647
+ }
1648
+ return data
1649
+ }
1650
+ const panelApi = {
1651
+ get: (path) => api("GET", path),
1652
+ send: (method, path, body) => api(method, path, body),
1653
+ namespace: () => state.namespace
1654
+ }
1655
+
1656
+ function setTheme(theme) {
1657
+ if (theme) document.documentElement.dataset.theme = theme
1658
+ else delete document.documentElement.dataset.theme
1659
+ }
1660
+ const storedTheme = localStorage.getItem("mockingbird-admin-theme")
1661
+ if (storedTheme) setTheme(storedTheme)
1662
+ $("theme").addEventListener("click", () => {
1663
+ const current = document.documentElement.dataset.theme
1664
+ const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches
1665
+ const next = dark ? "light" : "dark"
1666
+ localStorage.setItem("mockingbird-admin-theme", next)
1667
+ setTheme(next)
1668
+ })
1669
+
1670
+ function show(view) {
1671
+ state.view = view
1672
+ for (const button of document.querySelectorAll("#nav button")) {
1673
+ button.setAttribute("aria-current", button.dataset.view === view ? "true" : "false")
1674
+ }
1675
+ for (const section of document.querySelectorAll("main .view")) {
1676
+ section.hidden = section.id !== "view-" + view
1677
+ }
1678
+ }
1679
+ document.getElementById("nav").addEventListener("click", (event) => {
1680
+ const button = event.target.closest("button[data-view]")
1681
+ if (!button) return
1682
+ show(button.dataset.view)
1683
+ refresh().catch(showError)
1684
+ })
1685
+
1686
+ function cards(items) {
1687
+ return '<div class="grid">' + items.map((item) =>
1688
+ '<article class="card"><div class="k">' + item.k + '</div><div class="v">' + item.v + '</div></article>'
1689
+ ).join("") + "</div>"
1690
+ }
1691
+ function table(columns, rows) {
1692
+ if (rows.length === 0) return '<p class="empty">Nothing here yet.</p>'
1693
+ const head = "<tr>" + columns.map((column) => "<th>" + column.label + "</th>").join("") + "</tr>"
1694
+ const body = rows.map((row) => "<tr" + (row.attrs || "") + ">" + columns.map((column) =>
1695
+ '<td data-label="' + column.label + '">' + column.cell(row) + "</td>"
1696
+ ).join("") + "</tr>").join("")
1697
+ return "<table><thead>" + head + "</thead><tbody>" + body + "</tbody></table>"
1698
+ }
1699
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
1700
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
1701
+ }[char]))
1702
+ const preview = (value) => {
1703
+ const text = JSON.stringify(value)
1704
+ return esc(text.length > 180 ? text.slice(0, 180) + "\u2026" : text)
1705
+ }
1706
+ const when = (epoch) => {
1707
+ const date = new Date(epoch)
1708
+ return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString()
1709
+ }
1710
+ const clockCard = (epoch) => {
1711
+ const date = new Date(epoch)
1712
+ if (Number.isNaN(date.getTime())) return esc(String(epoch))
1713
+ const iso = date.toISOString()
1714
+ return esc(iso.slice(0, 10)) + '<span class="clock-time">' + esc(iso.slice(11, 19)) + " UTC</span>"
1715
+ }
1716
+
1717
+ async function loadNamespaces() {
1718
+ const data = await api("GET", "/namespaces")
1719
+ const select = $("namespace")
1720
+ const names = data.namespaces && data.namespaces.length ? data.namespaces : [data.default || "default"]
1721
+ if (!names.includes(state.namespace)) names.unshift(state.namespace)
1722
+ select.innerHTML = names.map((name) =>
1723
+ '<option value="' + esc(name) + '">' + esc(name) + "</option>"
1724
+ ).join("")
1725
+ select.value = state.namespace
1726
+ }
1727
+
1728
+ async function renderOverview() {
1729
+ const [health, shape, clock, journal] = await Promise.all([
1730
+ originGet("/health"),
1731
+ api("GET", "/state"),
1732
+ api("GET", "/clock"),
1733
+ api("GET", "/requests?limit=8")
1734
+ ])
1735
+ state.shape = shape
1736
+ $("overview-cards").innerHTML = cards([
1737
+ { k: "Service", v: esc(health.service || "${name}") },
1738
+ { k: "Status", v: esc(health.status || "ok") },
1739
+ { k: "Namespace", v: esc(state.namespace) },
1740
+ { k: "Collections", v: String(shape.collections.length) },
1741
+ { k: "Records", v: String(shape.collections.reduce((sum, item) => sum + item.count, 0)) },
1742
+ { k: "Clock", v: clockCard(clock.now) }
1743
+ ])
1744
+ const requests = (journal && journal.requests) || []
1745
+ $("overview-requests").innerHTML = table(
1746
+ [
1747
+ { label: "Method", cell: (row) => esc(row.method) },
1748
+ { label: "Path", cell: (row) => esc(row.path) },
1749
+ { label: "Status", cell: (row) => '<span class="status-' + String(row.status).charAt(0) + '">' + esc(row.status) + "</span>" },
1750
+ { label: "Operation", cell: (row) => esc(row.operationId || "") }
1751
+ ],
1752
+ requests
1753
+ )
1754
+ }
1755
+
1756
+ function schemaChips(collection) {
1757
+ if (!collection || collection.fields.length === 0) {
1758
+ return '<p class="empty">No fields yet. Add a record or declare the collection on the service.</p>'
1759
+ }
1760
+ return '<div class="chips">' + collection.fields.map((field) =>
1761
+ '<span class="chip">' + esc(field.name) + ' <i>' + esc(field.kind) + (field.optional ? "?" : "") + "</i></span>"
1762
+ ).join("") + "</div>"
1763
+ }
1764
+
1765
+ async function renderState() {
1766
+ const shape = await api("GET", "/state")
1767
+ state.shape = shape
1768
+ const host = $("collections")
1769
+ if (shape.collections.length === 0) {
1770
+ host.innerHTML = '<p class="empty">This namespace has no collections yet.</p>'
1771
+ $("schema").innerHTML = ""
1772
+ $("records").innerHTML = ""
1773
+ return
1774
+ }
1775
+ if (!shape.collections.some((item) => item.name === state.collection)) {
1776
+ state.collection = shape.collections[0].name
1777
+ }
1778
+ host.innerHTML = shape.collections.map((item) =>
1779
+ '<button type="button" class="collection" data-collection="' + esc(item.name) + '" aria-current="' +
1780
+ (item.name === state.collection ? "true" : "false") + '"><strong>' + esc(item.label) +
1781
+ '</strong><small>' + esc(item.count) + " \xB7 " + esc(item.source) + "</small></button>"
1782
+ ).join("")
1783
+ const selected = shape.collections.find((item) => item.name === state.collection)
1784
+ $("schema").innerHTML = "<h3>" + esc(selected.label) + "</h3>" +
1785
+ (selected.description ? "<p class=" + '"lede">' + esc(selected.description) + "</p>" : "") +
1786
+ schemaChips(selected)
1787
+ const page = await api("GET", "/state/" + encodeURIComponent(state.collection) + "?limit=50")
1788
+ const rows = page.records.map((record) => ({
1789
+ ...record,
1790
+ attrs: ' class="clickable" data-id="' + esc(record.id) + '"'
1791
+ }))
1792
+ $("records").innerHTML = table(
1793
+ [
1794
+ { label: "Id", cell: (row) => "<code>" + esc(row.id) + "</code>" },
1795
+ { label: "Seq", cell: (row) => esc(row.seq) },
1796
+ { label: "Value", cell: (row) => preview(row.value) }
1797
+ ],
1798
+ rows
1799
+ )
1800
+ }
1801
+
1802
+ async function renderClock() {
1803
+ const clock = await api("GET", "/clock")
1804
+ $("clock-readout").innerHTML = "<div class='k'>Now</div><div class='v'>" + esc(when(clock.now)) +
1805
+ "</div><p>" + esc(clock.now) + " ms \xB7 offset " + esc(clock.offsetMs) + " ms " +
1806
+ (clock.frozen ? "<span class='badge warn'>frozen</span>" : "<span class='badge ok'>live</span>") + "</p>"
1807
+ $("freeze").textContent = clock.frozen ? "Unfreeze" : "Freeze"
1808
+ $("freeze").dataset.frozen = clock.frozen ? "1" : "0"
1809
+ }
1810
+
1811
+ async function renderFaults() {
1812
+ const [faults, presets] = await Promise.all([
1813
+ api("GET", "/faults"),
1814
+ api("GET", "/faults/presets").catch(() => ({ presets: [] }))
1815
+ ])
1816
+ const select = $("fault-preset")
1817
+ const current = select.value
1818
+ const list = presets.presets || []
1819
+ select.innerHTML = '<option value="">Custom rule</option>' + list.map((preset) =>
1820
+ '<option value="' + esc(preset.name) + '">' + esc(preset.name) + "</option>"
1821
+ ).join("")
1822
+ select.value = current
1823
+ const rules = faults.faults || []
1824
+ $("fault-list").innerHTML = table(
1825
+ [
1826
+ { label: "Id", cell: (row) => esc(row.id) },
1827
+ { label: "Match", cell: (row) => esc([row.method, row.pathPrefix, row.operationId].filter(Boolean).join(" ") || "all") },
1828
+ { label: "Effect", cell: (row) => esc(row.status ?? row.effect ?? (row.drop ? "drop" : row.latencyMs || "")) }
1829
+ ],
1830
+ rules
1831
+ )
1832
+ }
1833
+
1834
+ async function renderJournal() {
1835
+ const journal = await api("GET", "/requests?limit=100")
1836
+ $("journal").innerHTML = table(
1837
+ [
1838
+ { label: "When", cell: (row) => esc(row.at || "") },
1839
+ { label: "Method", cell: (row) => esc(row.method) },
1840
+ { label: "Path", cell: (row) => esc(row.path) },
1841
+ { label: "Status", cell: (row) => esc(row.status) },
1842
+ { label: "ms", cell: (row) => esc(row.durationMs) }
1843
+ ],
1844
+ journal.requests || []
1845
+ )
1846
+ }
1847
+
1848
+ async function renderRoutes() {
1849
+ const data = await api("GET", "/")
1850
+ const routes = data.routes || []
1851
+ $("route-list").innerHTML = routes.map((route) => {
1852
+ const extra = STANDARD.has(route) ? "" : " <span class='badge'>extension</span>"
1853
+ return '<div class="route"><span class="method">' + esc(route.split(" ")[0]) +
1854
+ '</span><span>' + esc(route.split(" ").slice(1).join(" ")) + extra +
1855
+ '</span><button class="btn" type="button" data-route="' + esc(route) + '">Use</button></div>'
1856
+ }).join("")
1857
+ }
1858
+
1859
+ const renderers = {
1860
+ overview: renderOverview,
1861
+ state: renderState,
1862
+ clock: renderClock,
1863
+ faults: renderFaults,
1864
+ journal: renderJournal,
1865
+ routes: renderRoutes
1866
+ }
1867
+
1868
+ async function refresh() {
1869
+ clearError()
1870
+ if (state.view in renderers) await renderers[state.view]()
1871
+ }
1872
+
1873
+ $("namespace").addEventListener("change", () => {
1874
+ state.namespace = $("namespace").value
1875
+ refresh().catch(showError)
1876
+ })
1877
+ keyInput.addEventListener("change", () => {
1878
+ state.key = keyInput.value.trim()
1879
+ sessionStorage.setItem("mockingbird-admin-key", state.key)
1880
+ refresh().catch(showError)
1881
+ })
1882
+ $("refresh").addEventListener("click", () => refresh().catch(showError))
1883
+
1884
+ $("collections").addEventListener("click", (event) => {
1885
+ const button = event.target.closest("[data-collection]")
1886
+ if (!button) return
1887
+ state.collection = button.dataset.collection
1888
+ renderState().catch(showError)
1889
+ })
1890
+ $("records").addEventListener("click", (event) => {
1891
+ const row = event.target.closest("[data-id]")
1892
+ if (!row) return
1893
+ openEditor(row.dataset.id).catch(showError)
1894
+ })
1895
+
1896
+ const editor = $("editor")
1897
+ async function openEditor(id) {
1898
+ state.editing = id || null
1899
+ $("editor-title").textContent = id ? "Edit record" : "New record"
1900
+ $("editor-id").value = id || ""
1901
+ $("editor-id").readOnly = Boolean(id)
1902
+ $("editor-delete").hidden = !id
1903
+ if (id) {
1904
+ const record = await api("GET", "/state/" + encodeURIComponent(state.collection) + "/" + encodeURIComponent(id))
1905
+ $("editor-value").value = JSON.stringify(record.value, null, 2)
1906
+ } else {
1907
+ $("editor-value").value = "{\\n \\n}"
1908
+ }
1909
+ editor.showModal()
1910
+ }
1911
+ $("new-record").addEventListener("click", () => openEditor(null).catch(showError))
1912
+ $("editor-form").addEventListener("submit", (event) => {
1913
+ const submitter = event.submitter
1914
+ if (!submitter || submitter.value !== "save") return
1915
+ event.preventDefault()
1916
+ let value
1917
+ try { value = JSON.parse($("editor-value").value) }
1918
+ catch { showError(new Error("Value is not JSON")); return }
1919
+ const id = $("editor-id").value.trim()
1920
+ const path = "/state/" + encodeURIComponent(state.collection)
1921
+ const request = state.editing
1922
+ ? api("PUT", path + "/" + encodeURIComponent(id), { value })
1923
+ : api("POST", path, { id: id || undefined, value })
1924
+ request.then(() => { editor.close(); return renderState() }).catch(showError)
1925
+ })
1926
+ $("editor-delete").addEventListener("click", () => {
1927
+ const id = state.editing
1928
+ if (!id) return
1929
+ api("DELETE", "/state/" + encodeURIComponent(state.collection) + "/" + encodeURIComponent(id))
1930
+ .then(() => { editor.close(); return renderState() })
1931
+ .catch(showError)
1932
+ })
1933
+
1934
+ document.querySelectorAll("[data-advance]").forEach((button) => {
1935
+ button.addEventListener("click", () => {
1936
+ api("POST", "/clock", { advance: Number(button.dataset.advance) }).then(renderClock).catch(showError)
1937
+ })
1938
+ })
1939
+ $("freeze").addEventListener("click", () => {
1940
+ const frozen = $("freeze").dataset.frozen === "1"
1941
+ api("POST", "/clock", { freeze: !frozen }).then(renderClock).catch(showError)
1942
+ })
1943
+ $("clock-reset").addEventListener("click", () => {
1944
+ api("POST", "/clock", { reset: true }).then(renderClock).catch(showError)
1945
+ })
1946
+ $("clock-set").addEventListener("submit", (event) => {
1947
+ event.preventDefault()
1948
+ const raw = $("clock-instant").value.trim()
1949
+ const set = /^-?\\d+$/.test(raw) ? Number(raw) : raw
1950
+ api("POST", "/clock", { set }).then(renderClock).catch(showError)
1951
+ })
1952
+
1953
+ $("fault-form").addEventListener("submit", (event) => {
1954
+ event.preventDefault()
1955
+ const preset = $("fault-preset").value
1956
+ const body = preset
1957
+ ? { preset }
1958
+ : {
1959
+ status: Number($("fault-status").value),
1960
+ ...($("fault-path").value ? { pathPrefix: $("fault-path").value } : {}),
1961
+ ...($("fault-method").value ? { method: $("fault-method").value } : {})
1962
+ }
1963
+ if (!preset && !Number.isFinite(body.status)) {
1964
+ showError(new Error("Status must be a number, or choose a preset"))
1965
+ return
1966
+ }
1967
+ api("POST", "/faults", body).then(renderFaults).catch(showError)
1968
+ })
1969
+ $("fault-clear").addEventListener("click", () => api("DELETE", "/faults").then(renderFaults).catch(showError))
1970
+ $("journal-clear").addEventListener("click", () => api("DELETE", "/requests").then(renderJournal).catch(showError))
1971
+
1972
+ $("route-list").addEventListener("click", (event) => {
1973
+ const button = event.target.closest("[data-route]")
1974
+ if (!button) return
1975
+ state.route = button.dataset.route
1976
+ $("route-title").textContent = state.route
1977
+ const method = state.route.split(" ")[0]
1978
+ $("route-body").disabled = method === "GET" || method === "DELETE"
1979
+ })
1980
+ $("route-form").addEventListener("submit", async (event) => {
1981
+ event.preventDefault()
1982
+ if (!state.route) return showError(new Error("Choose a route first"))
1983
+ const [method, path] = [state.route.split(" ")[0], state.route.split(" ").slice(1).join(" ")]
1984
+ const concrete = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => prompt("Value for " + key) || "")
1985
+ let body
1986
+ if (method !== "GET" && method !== "DELETE" && $("route-body").value.trim()) {
1987
+ try { body = JSON.parse($("route-body").value) }
1988
+ catch { return showError(new Error("Body is not JSON")) }
1989
+ }
1990
+ try {
1991
+ const data = await api(method, concrete, body)
1992
+ $("route-result").textContent = JSON.stringify(data, null, 2)
1993
+ } catch (error) {
1994
+ showError(error)
1995
+ }
1996
+ })
1997
+
1998
+ function mountPanels(panels) {
1999
+ const nav = $("nav")
2000
+ const views = $("views")
2001
+ for (const panel of panels) {
2002
+ const button = document.createElement("button")
2003
+ button.type = "button"
2004
+ button.dataset.view = panel.id
2005
+ button.textContent = panel.title
2006
+ nav.append(button)
2007
+ const section = document.createElement("section")
2008
+ section.className = "view"
2009
+ section.id = "view-" + panel.id
2010
+ section.hidden = true
2011
+ const title = document.createElement("h2")
2012
+ title.textContent = panel.title
2013
+ const lede = document.createElement("p")
2014
+ lede.className = "lede"
2015
+ lede.textContent = panel.description || ""
2016
+ const body = document.createElement("div")
2017
+ body.className = "panel-body"
2018
+ body.id = "panel-" + panel.id
2019
+ body.innerHTML = panel.html || ""
2020
+ section.append(title, lede, body)
2021
+ views.append(section)
2022
+ if (panel.script) {
2023
+ try {
2024
+ const run = new Function("root", "api", panel.script)
2025
+ run(body, panelApi)
2026
+ } catch (error) {
2027
+ const note = document.createElement("p")
2028
+ note.className = "banner show"
2029
+ note.textContent = error instanceof Error ? error.message : String(error)
2030
+ body.append(note)
2031
+ }
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ loadNamespaces()
2037
+ .then(() => api("GET", "/ui/manifest"))
2038
+ .then((manifest) => { mountPanels((manifest && manifest.panels) || []); return refresh() })
2039
+ .catch(showError)
2040
+ })()
2041
+ </script>
2042
+ </body>
2043
+ </html>`;
2044
+ };
2045
+ var htmlResponse = (body) => new Response(body, {
2046
+ status: 200,
2047
+ headers: {
2048
+ "content-type": "text/html; charset=utf-8",
2049
+ "cache-control": "no-store"
2050
+ }
2051
+ });
2052
+ var json2 = (body) => new Response(JSON.stringify(body), {
2053
+ status: 200,
2054
+ headers: { "content-type": "application/json; charset=utf-8" }
2055
+ });
2056
+ var adminUiRoutes = (service, ui) => {
2057
+ const shell = () => renderAdminDocument(service);
2058
+ const document2 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
2059
+ return {
2060
+ "GET /ui": () => htmlResponse(document2()),
2061
+ "GET /ui/": () => htmlResponse(document2()),
2062
+ "GET /ui/manifest": () => json2({
2063
+ service,
2064
+ panels: (ui?.panels ?? []).map((panel) => ({
2065
+ id: panel.id,
2066
+ title: panel.title,
2067
+ ...panel.description !== void 0 ? { description: panel.description } : {},
2068
+ html: panel.html,
2069
+ ...panel.script !== void 0 ? { script: panel.script } : {}
2070
+ }))
2071
+ })
2072
+ };
2073
+ };
2074
+
1143
2075
  // ../../openapi/metadata/dist/types.js
1144
2076
  var EXTENSION_KEYS = {
1145
2077
  operation: "x-mockingbird",
@@ -1303,15 +2235,449 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1303
2235
  });
1304
2236
  };
1305
2237
 
2238
+ // ../core/dist/state-view.js
2239
+ var COLLECTION_NAME = /^[A-Za-z0-9_.@:-]{1,200}$/;
2240
+ var RECORD_ID = /^[^\s/]{1,256}$/;
2241
+ var SAMPLE = 25;
2242
+ var WALK_LIMIT = 1e4;
2243
+ var SKIP_CTORS = /* @__PURE__ */ new Set(["Hono", "HonoRequest", "Router"]);
2244
+ var fail = (status, message) => ({ ok: false, status, message });
2245
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2246
+ var kindOf = (value) => {
2247
+ if (value === null)
2248
+ return "null";
2249
+ if (Array.isArray(value))
2250
+ return "array";
2251
+ switch (typeof value) {
2252
+ case "string":
2253
+ return "string";
2254
+ case "number":
2255
+ return Number.isFinite(value) ? "number" : "unknown";
2256
+ case "boolean":
2257
+ return "boolean";
2258
+ case "object":
2259
+ return "object";
2260
+ default:
2261
+ return "unknown";
2262
+ }
2263
+ };
2264
+ var humanize = (name) => {
2265
+ const words = name.replace(/[@:_-]+/g, " ").trim();
2266
+ if (words === "")
2267
+ return name;
2268
+ return words.charAt(0).toUpperCase() + words.slice(1);
2269
+ };
2270
+ var jsonValue = (value) => {
2271
+ if (value === void 0)
2272
+ return fail(400, "value is required");
2273
+ try {
2274
+ return { ok: true, value: JSON.parse(JSON.stringify(value)) };
2275
+ } catch {
2276
+ return fail(400, "value must be JSON");
2277
+ }
2278
+ };
2279
+ var ctorName = (value) => {
2280
+ const ctor = value.constructor;
2281
+ return typeof ctor?.name === "string" ? ctor.name : "";
2282
+ };
2283
+ var isSqlite = (value) => "prepare" in value && typeof value.prepare === "function" && "transaction" in value;
2284
+ var discoverCollections = (root) => {
2285
+ const found = [];
2286
+ const seen = /* @__PURE__ */ new Set();
2287
+ const visit = (value, depth) => {
2288
+ if (found.length + seen.size > WALK_LIMIT)
2289
+ return;
2290
+ if (value === null || typeof value !== "object")
2291
+ return;
2292
+ if (seen.has(value))
2293
+ return;
2294
+ seen.add(value);
2295
+ if (value instanceof Collection) {
2296
+ found.push(value);
2297
+ return;
2298
+ }
2299
+ if (value instanceof Map) {
2300
+ for (const child of value.values())
2301
+ visit(child, depth + 1);
2302
+ return;
2303
+ }
2304
+ if (value instanceof Set) {
2305
+ for (const child of value.values())
2306
+ visit(child, depth + 1);
2307
+ return;
2308
+ }
2309
+ if (depth > 6 || isSqlite(value) || SKIP_CTORS.has(ctorName(value)))
2310
+ return;
2311
+ if (Array.isArray(value)) {
2312
+ const cap = Math.min(value.length, 32);
2313
+ for (let i = 0; i < cap; i++)
2314
+ visit(value[i], depth + 1);
2315
+ return;
2316
+ }
2317
+ let children;
2318
+ try {
2319
+ children = Object.values(value);
2320
+ } catch {
2321
+ return;
2322
+ }
2323
+ for (const child of children)
2324
+ visit(child, depth + 1);
2325
+ };
2326
+ visit(root, 0);
2327
+ return found;
2328
+ };
2329
+ var storedNames = (sqlite, namespace) => {
2330
+ const records = sqlite.prepare("SELECT DISTINCT collection AS name FROM mockingbird_records WHERE namespace = ?").all(namespace);
2331
+ const sequences = sqlite.prepare("SELECT DISTINCT name FROM mockingbird_sequences WHERE namespace = ? AND kind = 'collection'").all(namespace);
2332
+ return [...records, ...sequences].map((row) => row.name);
2333
+ };
2334
+ var blankAccum = (name) => ({
2335
+ name,
2336
+ kinds: /* @__PURE__ */ new Set(),
2337
+ present: 0,
2338
+ nested: /* @__PURE__ */ new Map()
2339
+ });
2340
+ var absorb = (accum, value) => {
2341
+ const kind = kindOf(value);
2342
+ accum.kinds.add(kind);
2343
+ accum.present++;
2344
+ if (!isRecord3(value))
2345
+ return;
2346
+ for (const [key, child] of Object.entries(value)) {
2347
+ const nested = accum.nested.get(key) ?? blankAccum(key);
2348
+ absorb(nested, child);
2349
+ accum.nested.set(key, nested);
2350
+ }
2351
+ };
2352
+ var finishField = (accum, samples) => {
2353
+ const kinds = [...accum.kinds];
2354
+ const kind = kinds.length === 1 ? kinds[0] : "unknown";
2355
+ const field = {
2356
+ name: accum.name,
2357
+ kind,
2358
+ optional: accum.present < samples || kinds.includes("null")
2359
+ };
2360
+ if (accum.nested.size > 0) {
2361
+ field.fields = [...accum.nested.values()].sort((a, b) => a.name.localeCompare(b.name)).map((child) => finishField(child, accum.present));
2362
+ }
2363
+ return field;
2364
+ };
2365
+ var inferFields = (records) => {
2366
+ if (records.length === 0)
2367
+ return [];
2368
+ const sample = records.slice(0, SAMPLE);
2369
+ const objects = sample.filter((record) => isRecord3(record.value));
2370
+ if (objects.length === 0) {
2371
+ const kinds = new Set(sample.map((record) => kindOf(record.value)));
2372
+ const list = [...kinds];
2373
+ return [
2374
+ {
2375
+ name: "(value)",
2376
+ kind: list.length === 1 ? list[0] : "unknown",
2377
+ optional: false
2378
+ }
2379
+ ];
2380
+ }
2381
+ const root = /* @__PURE__ */ new Map();
2382
+ for (const record of objects) {
2383
+ for (const [key, value] of Object.entries(record.value)) {
2384
+ const accum = root.get(key) ?? blankAccum(key);
2385
+ absorb(accum, value);
2386
+ root.set(key, accum);
2387
+ }
2388
+ }
2389
+ return [...root.values()].sort((a, b) => a.name.localeCompare(b.name)).map((accum) => finishField(accum, objects.length));
2390
+ };
2391
+ var mergeFields = (declared, inferred) => {
2392
+ const byName = /* @__PURE__ */ new Map();
2393
+ for (const field of inferred)
2394
+ byName.set(field.name, field);
2395
+ const ordered = [];
2396
+ const seen = /* @__PURE__ */ new Set();
2397
+ for (const field of declared ?? []) {
2398
+ const found = byName.get(field.name);
2399
+ seen.add(field.name);
2400
+ if (!found) {
2401
+ ordered.push({ ...field, optional: field.optional });
2402
+ continue;
2403
+ }
2404
+ ordered.push({
2405
+ ...found,
2406
+ kind: field.kind === "unknown" ? found.kind : field.kind,
2407
+ optional: field.optional || found.optional,
2408
+ ...field.description !== void 0 ? { description: field.description } : {},
2409
+ ...field.meta !== void 0 ? { meta: field.meta } : {},
2410
+ ...field.fields !== void 0 ? { fields: mergeFields(field.fields, found.fields ?? []) } : {}
2411
+ });
2412
+ }
2413
+ for (const field of inferred) {
2414
+ if (!seen.has(field.name))
2415
+ ordered.push(field);
2416
+ }
2417
+ return ordered;
2418
+ };
2419
+ var collectionOf = (sqlite, namespace, name) => new Collection(sqlite, namespace, name);
2420
+ var readRecord = (collection, id) => {
2421
+ const rows = collection.list({ where: (_value, _seq) => true, order: "oldest" });
2422
+ const row = rows.find((entry) => entry.id === id);
2423
+ return row ? { id: row.id, seq: row.seq, value: row.value } : void 0;
2424
+ };
2425
+ var inspectState = (scope) => {
2426
+ const declared = /* @__PURE__ */ new Map();
2427
+ for (const declaration of scope.declarations ?? []) {
2428
+ if (COLLECTION_NAME.test(declaration.name))
2429
+ declared.set(declaration.name, declaration);
2430
+ }
2431
+ const names = new Set(declared.keys());
2432
+ for (const name of storedNames(scope.sqlite, scope.storageNamespace))
2433
+ names.add(name);
2434
+ for (const collection of discoverCollections(scope.root)) {
2435
+ if (collection.namespace === scope.storageNamespace)
2436
+ names.add(collection.collectionName);
2437
+ }
2438
+ const collections = [];
2439
+ for (const name of names) {
2440
+ if (!COLLECTION_NAME.test(name))
2441
+ continue;
2442
+ const declaration = declared.get(name);
2443
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2444
+ const records = collection.list({ order: "newest" }).slice(0, SAMPLE).map((row) => ({ id: row.id, seq: row.seq, value: row.value }));
2445
+ const inferred = inferFields(records);
2446
+ const fields = mergeFields(declaration?.fields, inferred);
2447
+ const source = declaration && records.length > 0 ? "mixed" : declaration ? "declared" : "inferred";
2448
+ collections.push({
2449
+ name,
2450
+ label: declaration?.label ?? humanize(name),
2451
+ ...declaration?.description !== void 0 ? { description: declaration.description } : {},
2452
+ count: collection.count(),
2453
+ source,
2454
+ fields,
2455
+ ...declaration?.meta !== void 0 ? { meta: declaration.meta } : {}
2456
+ });
2457
+ }
2458
+ const order = /* @__PURE__ */ new Map();
2459
+ for (const [index, item] of (scope.declarations ?? []).entries())
2460
+ order.set(item.name, index);
2461
+ collections.sort((a, b) => {
2462
+ const left = order.get(a.name);
2463
+ const right = order.get(b.name);
2464
+ if (left !== void 0 && right !== void 0)
2465
+ return left - right;
2466
+ if (left !== void 0)
2467
+ return -1;
2468
+ if (right !== void 0)
2469
+ return 1;
2470
+ return a.name.localeCompare(b.name);
2471
+ });
2472
+ return {
2473
+ namespace: scope.namespace,
2474
+ storageNamespace: scope.storageNamespace,
2475
+ collections
2476
+ };
2477
+ };
2478
+ var pageOf = (sqlite, storageNamespace, name, query) => {
2479
+ if (!COLLECTION_NAME.test(name))
2480
+ return fail(400, "collection name is not allowed");
2481
+ const limit = query.limit ?? 50;
2482
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
2483
+ return fail(400, "limit must be an integer from 1 to 200");
2484
+ }
2485
+ const order = query.order ?? "newest";
2486
+ if (order !== "newest" && order !== "oldest")
2487
+ return fail(400, 'order must be "newest" or "oldest"');
2488
+ const collection = collectionOf(sqlite, storageNamespace, name);
2489
+ const rows = collection.list({ order }).filter((row) => {
2490
+ if (query.after === void 0)
2491
+ return true;
2492
+ return order === "newest" ? row.seq < query.after : row.seq > query.after;
2493
+ });
2494
+ const slice = rows.slice(0, limit);
2495
+ const last = slice.at(-1);
2496
+ return {
2497
+ ok: true,
2498
+ value: {
2499
+ collection: name,
2500
+ count: collection.count(),
2501
+ records: slice.map((row) => ({ id: row.id, seq: row.seq, value: row.value })),
2502
+ next: rows.length > limit && last ? last.seq : null
2503
+ }
2504
+ };
2505
+ };
2506
+ var readStatePage = (scope, name, query = {}) => pageOf(scope.sqlite, scope.storageNamespace, name, query);
2507
+ var readStateRecord = (scope, name, id) => {
2508
+ if (!COLLECTION_NAME.test(name))
2509
+ return fail(400, "collection name is not allowed");
2510
+ if (!RECORD_ID.test(id))
2511
+ return fail(400, "record id is not allowed");
2512
+ const record = readRecord(collectionOf(scope.sqlite, scope.storageNamespace, name), id);
2513
+ return record ? { ok: true, value: record } : fail(404, `no record ${id} in ${name}`);
2514
+ };
2515
+ var requireId = (id) => RECORD_ID.test(id) ? { ok: true, value: id } : fail(400, "id must be a single path segment");
2516
+ var createStateRecord = (scope, name, input) => {
2517
+ if (!COLLECTION_NAME.test(name))
2518
+ return fail(400, "collection name is not allowed");
2519
+ const parsed = jsonValue(input.value);
2520
+ if (!parsed.ok)
2521
+ return parsed;
2522
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2523
+ const id = input.id ?? `${name}_${collection.nextSequence()}`;
2524
+ const checked = requireId(id);
2525
+ if (!checked.ok)
2526
+ return checked;
2527
+ if (collection.has(checked.value))
2528
+ return fail(409, `record ${checked.value} already exists`);
2529
+ const stored = collection.insert(checked.value, parsed.value);
2530
+ return { ok: true, value: { id: checked.value, seq: stored.seq, value: stored.value } };
2531
+ };
2532
+ var replaceStateRecord = (scope, name, id, value) => {
2533
+ if (!COLLECTION_NAME.test(name))
2534
+ return fail(400, "collection name is not allowed");
2535
+ const checked = requireId(id);
2536
+ if (!checked.ok)
2537
+ return checked;
2538
+ const parsed = jsonValue(value);
2539
+ if (!parsed.ok)
2540
+ return parsed;
2541
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2542
+ if (!collection.has(checked.value))
2543
+ return fail(404, `no record ${checked.value} in ${name}`);
2544
+ const stored = collection.update(checked.value, parsed.value);
2545
+ if (!stored)
2546
+ return fail(404, `no record ${checked.value} in ${name}`);
2547
+ return { ok: true, value: { id: checked.value, seq: stored.seq, value: stored.value } };
2548
+ };
2549
+ var patchStateRecord = (scope, name, id, value) => {
2550
+ const current = readStateRecord(scope, name, id);
2551
+ if (!current.ok)
2552
+ return current;
2553
+ const parsed = jsonValue(value);
2554
+ if (!parsed.ok)
2555
+ return parsed;
2556
+ const next = isRecord3(current.value.value) && isRecord3(parsed.value) ? { ...current.value.value, ...parsed.value } : parsed.value;
2557
+ return replaceStateRecord(scope, name, id, next);
2558
+ };
2559
+ var deleteStateRecord = (scope, name, id) => {
2560
+ if (!COLLECTION_NAME.test(name))
2561
+ return fail(400, "collection name is not allowed");
2562
+ const checked = requireId(id);
2563
+ if (!checked.ok)
2564
+ return checked;
2565
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2566
+ return collection.delete(checked.value) ? { ok: true, value: { id: checked.value } } : fail(404, `no record ${checked.value} in ${name}`);
2567
+ };
2568
+ var json3 = (status, body) => new Response(JSON.stringify(body), {
2569
+ status,
2570
+ headers: { "content-type": "application/json; charset=utf-8" }
2571
+ });
2572
+ var adminError2 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2573
+ var fromResult = (result, status) => result.ok ? json3(status, result.value) : adminError2(result.status, result.message);
2574
+ var integerParam = (value) => {
2575
+ if (value === null)
2576
+ return void 0;
2577
+ if (!/^-?\d+$/.test(value))
2578
+ return "bad";
2579
+ return Number(value);
2580
+ };
2581
+ var stateAdminRoutes = (routes) => {
2582
+ const open = (namespace) => {
2583
+ try {
2584
+ return { ok: true, value: routes.open(namespace) };
2585
+ } catch (error) {
2586
+ return fail(400, error instanceof Error ? error.message : String(error));
2587
+ }
2588
+ };
2589
+ const wrote = (namespace) => {
2590
+ routes.afterWrite?.(namespace);
2591
+ };
2592
+ return {
2593
+ "GET /state": ({ namespace }) => {
2594
+ const scope = open(namespace);
2595
+ return fromResult(scope.ok ? { ok: true, value: inspectState(scope.value) } : scope, 200);
2596
+ },
2597
+ "GET /state/:collection": ({ namespace, params, url }) => {
2598
+ const scope = open(namespace);
2599
+ if (!scope.ok)
2600
+ return fromResult(scope, 200);
2601
+ const limit = integerParam(url.searchParams.get("limit"));
2602
+ const after = integerParam(url.searchParams.get("after"));
2603
+ if (limit === "bad")
2604
+ return adminError2(400, "limit: expected an integer");
2605
+ if (after === "bad")
2606
+ return adminError2(400, "after: expected a sequence number");
2607
+ const order = url.searchParams.get("order");
2608
+ return fromResult(readStatePage(scope.value, params.collection ?? "", {
2609
+ ...limit !== void 0 ? { limit } : {},
2610
+ ...after !== void 0 ? { after } : {},
2611
+ ...order === "newest" || order === "oldest" ? { order } : {}
2612
+ }), 200);
2613
+ },
2614
+ "GET /state/:collection/:id": ({ namespace, params }) => {
2615
+ const scope = open(namespace);
2616
+ if (!scope.ok)
2617
+ return fromResult(scope, 200);
2618
+ return fromResult(readStateRecord(scope.value, params.collection ?? "", params.id ?? ""), 200);
2619
+ },
2620
+ "POST /state/:collection": ({ namespace, params, body }) => {
2621
+ const scope = open(namespace);
2622
+ if (!scope.ok)
2623
+ return fromResult(scope, 201);
2624
+ if (!isRecord3(body) || !("value" in body)) {
2625
+ return adminError2(400, 'expected {"id?": "<id>", "value": <json>}');
2626
+ }
2627
+ const id = body.id;
2628
+ if (id !== void 0 && typeof id !== "string")
2629
+ return adminError2(400, "id must be a string");
2630
+ const created = createStateRecord(scope.value, params.collection ?? "", {
2631
+ ...typeof id === "string" ? { id } : {},
2632
+ value: body.value
2633
+ });
2634
+ if (created.ok)
2635
+ wrote(namespace);
2636
+ return fromResult(created, 201);
2637
+ },
2638
+ "PUT /state/:collection/:id": ({ namespace, params, body }) => {
2639
+ const scope = open(namespace);
2640
+ if (!scope.ok)
2641
+ return fromResult(scope, 200);
2642
+ if (!isRecord3(body) || !("value" in body))
2643
+ return adminError2(400, 'expected {"value": <json>}');
2644
+ const replaced = replaceStateRecord(scope.value, params.collection ?? "", params.id ?? "", body.value);
2645
+ if (replaced.ok)
2646
+ wrote(namespace);
2647
+ return fromResult(replaced, 200);
2648
+ },
2649
+ "PATCH /state/:collection/:id": ({ namespace, params, body }) => {
2650
+ const scope = open(namespace);
2651
+ if (!scope.ok)
2652
+ return fromResult(scope, 200);
2653
+ if (!isRecord3(body) || !("value" in body))
2654
+ return adminError2(400, 'expected {"value": <json>}');
2655
+ const patched = patchStateRecord(scope.value, params.collection ?? "", params.id ?? "", body.value);
2656
+ if (patched.ok)
2657
+ wrote(namespace);
2658
+ return fromResult(patched, 200);
2659
+ },
2660
+ "DELETE /state/:collection/:id": ({ namespace, params }) => {
2661
+ const scope = open(namespace);
2662
+ if (!scope.ok)
2663
+ return fromResult(scope, 200);
2664
+ const removed = deleteStateRecord(scope.value, params.collection ?? "", params.id ?? "");
2665
+ if (removed.ok)
2666
+ wrote(namespace);
2667
+ return fromResult(removed, 200);
2668
+ }
2669
+ };
2670
+ };
2671
+
1306
2672
  // ../core/dist/version.js
1307
- var PACKAGE_VERSION = true ? "1.3.0" : UNRELEASED_VERSION;
2673
+ var PACKAGE_VERSION = true ? "1.5.0" : UNRELEASED_VERSION;
1308
2674
 
1309
2675
  // ../core/dist/webhooks.js
1310
- var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
1311
- var adminError2 = (status, message) => json2(status, { error: { type: "mockingbird_admin", message } });
1312
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2676
+ var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
2677
+ var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
2678
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1313
2679
  var parseEndpoint = (value) => {
1314
- if (!isRecord3(value) || typeof value.url !== "string")
2680
+ if (!isRecord4(value) || typeof value.url !== "string")
1315
2681
  return "each endpoint needs a url";
1316
2682
  try {
1317
2683
  new URL(value.url);
@@ -1328,18 +2694,18 @@ var parseEndpoint = (value) => {
1328
2694
  const events = value.events ?? value.enabledEvents;
1329
2695
  if (Array.isArray(events))
1330
2696
  endpoint.events = events.map(String);
1331
- if (isRecord3(value.tags)) {
2697
+ if (isRecord4(value.tags)) {
1332
2698
  endpoint.tags = Object.fromEntries(Object.entries(value.tags).map(([k, v]) => [k, String(v)]));
1333
2699
  }
1334
2700
  if (typeof value.account === "string")
1335
2701
  endpoint.tags = { ...endpoint.tags, account: value.account };
1336
- if (isRecord3(value.headers)) {
2702
+ if (isRecord4(value.headers)) {
1337
2703
  endpoint.headers = Object.fromEntries(Object.entries(value.headers).map(([k, v]) => [k, String(v)]));
1338
2704
  }
1339
2705
  return endpoint;
1340
2706
  };
1341
2707
  var webhookAdminRoutes = (hub) => ({
1342
- "GET /webhooks": ({ url, namespace }) => json2(200, {
2708
+ "GET /webhooks": ({ url, namespace }) => json4(200, {
1343
2709
  deliveries: hub.deliveries(url.searchParams.get("all") === "1" ? void 0 : namespace).filter((d) => {
1344
2710
  const type = url.searchParams.get("type");
1345
2711
  return type === null || d.type === type;
@@ -1347,51 +2713,51 @@ var webhookAdminRoutes = (hub) => ({
1347
2713
  }),
1348
2714
  "GET /webhooks/events": ({ url, namespace }) => {
1349
2715
  const type = url.searchParams.get("type");
1350
- return json2(200, {
2716
+ return json4(200, {
1351
2717
  events: hub.messages(url.searchParams.get("all") === "1" ? void 0 : namespace).filter((m) => type === null || m.type === type).map((m) => ({ ...m, payload: parsePayload(m) }))
1352
2718
  });
1353
2719
  },
1354
2720
  "POST /webhooks/:id/replay": async ({ params }) => {
1355
2721
  const replayed = await hub.replay(params.id);
1356
- return replayed ? json2(200, replayed) : adminError2(404, `no delivery ${params.id}`);
2722
+ return replayed ? json4(200, replayed) : adminError3(404, `no delivery ${params.id}`);
1357
2723
  },
1358
2724
  "POST /webhooks/flush": async () => {
1359
2725
  await hub.flush();
1360
- return json2(200, { status: "ok" });
2726
+ return json4(200, { status: "ok" });
1361
2727
  },
1362
2728
  "POST /webhooks/faults": ({ body, namespace }) => {
1363
- if (!isRecord3(body) || !["duplicate", "reorder", "drop"].includes(String(body.mode))) {
1364
- return adminError2(400, "mode must be duplicate, reorder or drop");
2729
+ if (!isRecord4(body) || !["duplicate", "reorder", "drop"].includes(String(body.mode))) {
2730
+ return adminError3(400, "mode must be duplicate, reorder or drop");
1365
2731
  }
1366
2732
  const fault = { mode: body.mode };
1367
2733
  if (typeof body.count === "number")
1368
2734
  fault.count = body.count;
1369
2735
  hub.fault(namespace, fault);
1370
- return json2(201, { namespace, ...fault });
2736
+ return json4(201, { namespace, ...fault });
1371
2737
  },
1372
- "GET /webhook-endpoints": ({ namespace }) => json2(200, {
2738
+ "GET /webhook-endpoints": ({ namespace }) => json4(200, {
1373
2739
  endpoints: hub.endpoints(namespace).map(({ secret, ...rest }) => ({
1374
2740
  ...rest,
1375
2741
  secret: secret ? "(set)" : null
1376
2742
  }))
1377
2743
  }),
1378
2744
  "PUT /webhook-endpoints": ({ body, namespace }) => {
1379
- const list = Array.isArray(body) ? body : isRecord3(body) ? body.endpoints : void 0;
2745
+ const list = Array.isArray(body) ? body : isRecord4(body) ? body.endpoints : void 0;
1380
2746
  if (!Array.isArray(list))
1381
- return adminError2(400, "expected [{url, secret?, events?}]");
2747
+ return adminError3(400, "expected [{url, secret?, events?}]");
1382
2748
  const parsed = [];
1383
2749
  for (const each of list) {
1384
2750
  const endpoint = parseEndpoint(each);
1385
2751
  if (typeof endpoint === "string")
1386
- return adminError2(400, endpoint);
2752
+ return adminError3(400, endpoint);
1387
2753
  parsed.push(endpoint);
1388
2754
  }
1389
2755
  const set = hub.setEndpoints(namespace, parsed);
1390
- return json2(200, { endpoints: set.map((e) => ({ ...e, secret: e.secret ? "(set)" : null })) });
2756
+ return json4(200, { endpoints: set.map((e) => ({ ...e, secret: e.secret ? "(set)" : null })) });
1391
2757
  },
1392
2758
  "DELETE /webhook-endpoints": ({ namespace }) => {
1393
2759
  hub.setEndpoints(namespace, []);
1394
- return json2(200, { status: "ok" });
2760
+ return json4(200, { status: "ok" });
1395
2761
  }
1396
2762
  });
1397
2763
  var parsePayload = (message) => {
@@ -1454,6 +2820,7 @@ var operationMatcher = (document2) => {
1454
2820
  return (request2, path) => matchers.find((m) => m.method === request2.method && m.pattern.test(path))?.operationId;
1455
2821
  };
1456
2822
  var createRuntime = (options) => {
2823
+ assertAdminUi(options.adminUi);
1457
2824
  const sqlite = bootSqlite(options.sqlite);
1458
2825
  const clock = options.clock ?? createClock();
1459
2826
  const rng = createRng(options.seed ?? 0);
@@ -1660,6 +3027,19 @@ var createRuntime = (options) => {
1660
3027
  else
1661
3028
  timeline(name);
1662
3029
  };
3030
+ const declarationsFor = (namespace) => {
3031
+ const declared = options.state;
3032
+ if (!declared)
3033
+ return [];
3034
+ return typeof declared === "function" ? declared(instance(namespace), namespace) : declared;
3035
+ };
3036
+ const stateScope = (namespace) => ({
3037
+ sqlite,
3038
+ namespace,
3039
+ storageNamespace: storageNamespace(namespace),
3040
+ root: instance(namespace),
3041
+ declarations: declarationsFor(namespace)
3042
+ });
1663
3043
  const runtime = {
1664
3044
  name: options.name,
1665
3045
  sqlite,
@@ -1698,6 +3078,7 @@ var createRuntime = (options) => {
1698
3078
  branch,
1699
3079
  checkout,
1700
3080
  timeline,
3081
+ state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
1701
3082
  fetch: async (incoming) => {
1702
3083
  let request2 = incoming;
1703
3084
  const prefixed = PATH_PREFIX.exec(new URL(request2.url).pathname);
@@ -1891,11 +3272,20 @@ var createRuntime = (options) => {
1891
3272
  ...credentialRoutes(credentials),
1892
3273
  ...options.presets ? presetRoutes(options.presets, runtime) : {},
1893
3274
  ...options.webhooks ? webhookAdminRoutes(options.webhooks) : {},
1894
- ...options.admin?.(runtime) ?? {}
3275
+ ...options.admin?.(runtime) ?? {},
3276
+ // Standard state and UI routes win a colliding key. A service adds its own keys
3277
+ // beside these; it does not replace `/state` or `/ui`.
3278
+ ...stateAdminRoutes({
3279
+ open: stateScope,
3280
+ afterWrite: (namespace) => {
3281
+ checkpoint(namespace, "main");
3282
+ }
3283
+ }),
3284
+ ...adminUiRoutes(options.name, options.adminUi)
1895
3285
  },
1896
3286
  adminKey: options.adminKey
1897
3287
  });
1898
- return runtime;
3288
+ return defineMock(runtime);
1899
3289
  };
1900
3290
  var mutableResponse = (response) => {
1901
3291
  try {
@@ -3418,7 +4808,7 @@ var renderLabAccount = (entry) => ({
3418
4808
  ),
3419
4809
  team_id_allowlist: [...entry.team_id_allowlist]
3420
4810
  });
3421
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4811
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3422
4812
  var DELEGATED_FLOWS = [
3423
4813
  "order_delegated",
3424
4814
  "result_delegated",
@@ -3434,7 +4824,7 @@ var checkStates = (value, where) => {
3434
4824
  };
3435
4825
  var stringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string") ? [...value] : void 0;
3436
4826
  var labAccountFromInput = (input, teamId = MOCK_TEAM_ID) => {
3437
- if (!isRecord4(input)) throw new TypeError("lab account must be an object");
4827
+ if (!isRecord5(input)) throw new TypeError("lab account must be an object");
3438
4828
  if (typeof input.id !== "string" || input.id === "")
3439
4829
  throw new TypeError("lab account needs a non-empty id");
3440
4830
  const where = `lab account ${input.id}`;
@@ -3446,7 +4836,7 @@ var labAccountFromInput = (input, teamId = MOCK_TEAM_ID) => {
3446
4836
  throw new TypeError(`${where}: delegated_flow must be one of ${DELEGATED_FLOWS.join(", ")}`);
3447
4837
  if (input.states !== void 0) checkStates(input.states, `${where}: states`);
3448
4838
  if (input.allowed_billing !== void 0) {
3449
- if (!isRecord4(input.allowed_billing))
4839
+ if (!isRecord5(input.allowed_billing))
3450
4840
  throw new TypeError(`${where}: allowed_billing must map billing types to state lists`);
3451
4841
  for (const [type, states] of Object.entries(input.allowed_billing)) {
3452
4842
  if (!BILLING_TYPES.includes(type))
@@ -3485,7 +4875,7 @@ var labAccountFromInput = (input, teamId = MOCK_TEAM_ID) => {
3485
4875
  };
3486
4876
  };
3487
4877
  var patchLabAccount = (existing, patch, teamId) => {
3488
- if (!isRecord4(patch)) throw new TypeError("lab account patch must be an object");
4878
+ if (!isRecord5(patch)) throw new TypeError("lab account patch must be an object");
3489
4879
  if (patch.id !== void 0 && patch.id !== existing.id)
3490
4880
  throw new TypeError(`lab account ${existing.id}: id cannot be changed`);
3491
4881
  const { states, ...rest } = patch;
@@ -3505,7 +4895,7 @@ var patchLabAccount = (existing, patch, teamId) => {
3505
4895
  var labAccountFromClientFacing = (value, recordedTeamId) => {
3506
4896
  if (typeof value.id !== "string" || typeof value.lab !== "string") return void 0;
3507
4897
  const billing = {};
3508
- if (isRecord4(value.allowed_billing)) {
4898
+ if (isRecord5(value.allowed_billing)) {
3509
4899
  for (const [type, states] of Object.entries(value.allowed_billing)) {
3510
4900
  const list = stringList(states);
3511
4901
  if (list) billing[type] = list;
@@ -3768,18 +5158,18 @@ var resolveTransition = (method, target) => {
3768
5158
  error: `${JSON.stringify(target)} is not a ${method} status. Use one of: ${names.join(", ")}, or a full status such as ${allowed.slice(0, 2).map((s) => `${s.split(".")[0]}.${method}.${s.split(".")[1]}`).join(", ")}`
3769
5159
  };
3770
5160
  };
3771
- var adminError3 = (status, message) => jsonRes(status, { error: { type: "mockingbird_admin", message } });
3772
- var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5161
+ var adminError4 = (status, message) => jsonRes(status, { error: { type: "mockingbird_admin", message } });
5162
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3773
5163
  var guarded = (run) => {
3774
5164
  try {
3775
5165
  return run();
3776
5166
  } catch (error) {
3777
- if (error instanceof TypeError) return adminError3(400, error.message);
5167
+ if (error instanceof TypeError) return adminError4(400, error.message);
3778
5168
  if (error instanceof Error && error.name === "LabAccountConflict") {
3779
- return adminError3(409, error.message);
5169
+ return adminError4(409, error.message);
3780
5170
  }
3781
5171
  const { status, message } = fixtureErrorMessage(error);
3782
- return adminError3(status, message);
5172
+ return adminError4(status, message);
3783
5173
  }
3784
5174
  };
3785
5175
  var junctionAdminRoutes = (options) => (runtime) => {
@@ -3796,20 +5186,20 @@ var junctionAdminRoutes = (options) => (runtime) => {
3796
5186
  }))
3797
5187
  }),
3798
5188
  "POST /orders/:id/transition": (request2) => {
3799
- const body = isRecord5(request2.body) ? request2.body : {};
5189
+ const body = isRecord6(request2.body) ? request2.body : {};
3800
5190
  const order = api(request2).order(request2.params.id);
3801
- if (!order) return adminError3(404, `no order ${request2.params.id} in ${request2.namespace}`);
5191
+ if (!order) return adminError4(404, `no order ${request2.params.id} in ${request2.namespace}`);
3802
5192
  if (typeof body.to !== "string") {
3803
- return adminError3(400, 'body needs "to": a status like "completed" or "at_lab"');
5193
+ return adminError4(400, 'body needs "to": a status like "completed" or "at_lab"');
3804
5194
  }
3805
5195
  const method = methodOf(order);
3806
5196
  const resolved = resolveTransition(method, body.to);
3807
- if ("error" in resolved) return adminError3(400, resolved.error);
3808
- let flags = isRecord5(body.flags) ? body.flags : null;
5197
+ if ("error" in resolved) return adminError4(400, resolved.error);
5198
+ let flags = isRecord6(body.flags) ? body.flags : null;
3809
5199
  if (typeof body.result === "string") {
3810
5200
  const named = RESULT_FIXTURES[body.result];
3811
5201
  if (!named) {
3812
- return adminError3(
5202
+ return adminError4(
3813
5203
  400,
3814
5204
  `no result fixture ${body.result}; one of ${Object.keys(RESULT_FIXTURES).join(", ")}`
3815
5205
  );
@@ -3829,23 +5219,23 @@ var junctionAdminRoutes = (options) => (runtime) => {
3829
5219
  "GET /result-fixtures": () => jsonRes(200, { fixtures: RESULT_FIXTURES }),
3830
5220
  "GET /results/:id": (request2) => {
3831
5221
  const fixture = api(request2).resultFixture(request2.params.id);
3832
- return fixture ? jsonRes(200, fixture) : adminError3(404, "no result fixture for that order");
5222
+ return fixture ? jsonRes(200, fixture) : adminError4(404, "no result fixture for that order");
3833
5223
  },
3834
5224
  "PUT /results/:id": (request2) => {
3835
- if (!isRecord5(request2.body)) return adminError3(400, "expected a result fixture object");
5225
+ if (!isRecord6(request2.body)) return adminError4(400, "expected a result fixture object");
3836
5226
  const fixture = { name: "custom", ...request2.body };
3837
5227
  if (fixture.results !== void 0 && !Array.isArray(fixture.results)) {
3838
- return adminError3(400, "results must be an array of result lines");
5228
+ return adminError4(400, "results must be an array of result lines");
3839
5229
  }
3840
- return api(request2).installResultFixture(request2.params.id, fixture) ? jsonRes(200, fixture) : adminError3(404, `no order ${request2.params.id} in ${request2.namespace}`);
5230
+ return api(request2).installResultFixture(request2.params.id, fixture) ? jsonRes(200, fixture) : adminError4(404, `no order ${request2.params.id} in ${request2.namespace}`);
3841
5231
  },
3842
5232
  "GET /lab-accounts": (request2) => jsonRes(200, { data: api(request2).labAccounts() }),
3843
5233
  "PUT /lab-accounts": (request2) => {
3844
- const body = isRecord5(request2.body) ? request2.body : {};
5234
+ const body = isRecord6(request2.body) ? request2.body : {};
3845
5235
  const accounts = body.accounts;
3846
5236
  const presets = body.presets;
3847
5237
  if (accounts === void 0 && presets === void 0 || accounts !== void 0 && accounts !== null && !Array.isArray(accounts) || presets !== void 0 && !Array.isArray(presets)) {
3848
- return adminError3(
5238
+ return adminError4(
3849
5239
  400,
3850
5240
  'body needs "accounts": an array (or null to restore the default), and/or "presets": an array of preset names'
3851
5241
  );
@@ -3857,20 +5247,20 @@ var junctionAdminRoutes = (options) => (runtime) => {
3857
5247
  return guarded(() => jsonRes(200, { data: api(request2).configureLabAccounts(layout) }));
3858
5248
  },
3859
5249
  "POST /lab-accounts": (request2) => {
3860
- if (!isRecord5(request2.body)) return adminError3(400, "expected one lab account object");
5250
+ if (!isRecord6(request2.body)) return adminError4(400, "expected one lab account object");
3861
5251
  const input = request2.body;
3862
5252
  return guarded(() => jsonRes(201, api(request2).addLabAccount(input)));
3863
5253
  },
3864
5254
  "PATCH /lab-accounts/:id": (request2) => {
3865
- if (!isRecord5(request2.body))
3866
- return adminError3(400, "expected an object of fields to change");
5255
+ if (!isRecord6(request2.body))
5256
+ return adminError4(400, "expected an object of fields to change");
3867
5257
  const patch = request2.body;
3868
5258
  return guarded(() => {
3869
5259
  const updated = api(request2).patchLabAccount(request2.params.id, patch);
3870
- return updated ? jsonRes(200, updated) : adminError3(404, `no lab account ${request2.params.id} in ${request2.namespace}`);
5260
+ return updated ? jsonRes(200, updated) : adminError4(404, `no lab account ${request2.params.id} in ${request2.namespace}`);
3871
5261
  });
3872
5262
  },
3873
- "DELETE /lab-accounts/:id": (request2) => api(request2).removeLabAccount(request2.params.id) ? jsonRes(200, { status: "ok" }) : adminError3(404, `no lab account ${request2.params.id} in ${request2.namespace}`),
5263
+ "DELETE /lab-accounts/:id": (request2) => api(request2).removeLabAccount(request2.params.id) ? jsonRes(200, { status: "ok" }) : adminError4(404, `no lab account ${request2.params.id} in ${request2.namespace}`),
3874
5264
  "GET /lab-accounts/presets": (request2) => jsonRes(200, {
3875
5265
  presets: Object.fromEntries(
3876
5266
  Object.keys(LAB_ACCOUNT_PRESETS).map((name) => {
@@ -3882,14 +5272,14 @@ var junctionAdminRoutes = (options) => (runtime) => {
3882
5272
  synthetic: SYNTHETIC_LAB_ACCOUNT_PRESETS
3883
5273
  }),
3884
5274
  "POST /lab-accounts/presets/:name": (request2) => {
3885
- const body = isRecord5(request2.body) ? request2.body : {};
5275
+ const body = isRecord6(request2.body) ? request2.body : {};
3886
5276
  if (body.id !== void 0 && typeof body.id !== "string") {
3887
- return adminError3(400, "id must be a string");
5277
+ return adminError4(400, "id must be a string");
3888
5278
  }
3889
5279
  const name = request2.params.name;
3890
5280
  return guarded(() => {
3891
5281
  const record = api(request2).addLabAccountPreset(name, body.id);
3892
- return record ? jsonRes(201, record) : adminError3(
5282
+ return record ? jsonRes(201, record) : adminError4(
3893
5283
  404,
3894
5284
  `no lab-account preset ${name}; one of ${Object.keys(LAB_ACCOUNT_PRESETS).join(", ")}`
3895
5285
  );
@@ -3898,15 +5288,15 @@ var junctionAdminRoutes = (options) => (runtime) => {
3898
5288
  "GET /team": (request2) => jsonRes(200, { teamId: api(request2).teamId }),
3899
5289
  "GET /limits": (request2) => jsonRes(200, { limits: api(request2).limits }),
3900
5290
  "PUT /limits": (request2) => {
3901
- if (!isRecord5(request2.body)) return adminError3(400, "expected an object of limits");
5291
+ if (!isRecord6(request2.body)) return adminError4(400, "expected an object of limits");
3902
5292
  const input = request2.body;
3903
5293
  return guarded(() => jsonRes(200, { limits: api(request2).configureLimits(input) }));
3904
5294
  },
3905
5295
  "GET /default-billing-types": (request2) => jsonRes(200, { defaultBillingTypes: api(request2).defaultBillingTypes }),
3906
5296
  "PUT /default-billing-types": (request2) => {
3907
- const input = isRecord5(request2.body) ? request2.body.defaultBillingTypes : void 0;
3908
- if (!isRecord5(input))
3909
- return adminError3(
5297
+ const input = isRecord6(request2.body) ? request2.body.defaultBillingTypes : void 0;
5298
+ if (!isRecord6(input))
5299
+ return adminError4(
3910
5300
  400,
3911
5301
  'body needs "defaultBillingTypes": { "<lab slug>": "<billing_type>" } ({} restores client_bill)'
3912
5302
  );
@@ -3917,28 +5307,28 @@ var junctionAdminRoutes = (options) => (runtime) => {
3917
5307
  },
3918
5308
  "GET /identity": (request2) => jsonRes(200, { identity: api(request2).identity }),
3919
5309
  "PUT /identity": (request2) => {
3920
- const mode = isRecord5(request2.body) ? request2.body.mode : void 0;
5310
+ const mode = isRecord6(request2.body) ? request2.body.mode : void 0;
3921
5311
  if (!IDENTITY_MODES.includes(mode)) {
3922
- return adminError3(400, `body needs "mode": one of ${IDENTITY_MODES.join(", ")}`);
5312
+ return adminError4(400, `body needs "mode": one of ${IDENTITY_MODES.join(", ")}`);
3923
5313
  }
3924
5314
  api(request2).identity = mode;
3925
5315
  return jsonRes(200, { identity: mode });
3926
5316
  },
3927
5317
  "POST /users": (request2) => {
3928
- if (!isRecord5(request2.body)) return adminError3(400, "expected one user object");
5318
+ if (!isRecord6(request2.body)) return adminError4(400, "expected one user object");
3929
5319
  const input = request2.body;
3930
5320
  return guarded(() => jsonRes(201, api(request2).insertUsers([input])[0]));
3931
5321
  },
3932
5322
  "POST /users/bulk": (request2) => {
3933
- const users = isRecord5(request2.body) ? request2.body.users : void 0;
3934
- if (!Array.isArray(users)) return adminError3(400, 'body needs "users": an array');
5323
+ const users = isRecord6(request2.body) ? request2.body.users : void 0;
5324
+ if (!Array.isArray(users)) return adminError4(400, 'body needs "users": an array');
3935
5325
  return guarded(
3936
5326
  () => jsonRes(201, { users: api(request2).insertUsers(users) })
3937
5327
  );
3938
5328
  },
3939
- "DELETE /users/:id": (request2) => api(request2).hardDeleteUser(request2.params.id) ? jsonRes(200, { status: "ok" }) : adminError3(404, `no user ${request2.params.id} in ${request2.namespace}`),
5329
+ "DELETE /users/:id": (request2) => api(request2).hardDeleteUser(request2.params.id) ? jsonRes(200, { status: "ok" }) : adminError4(404, `no user ${request2.params.id} in ${request2.namespace}`),
3940
5330
  "POST /orders": (request2) => {
3941
- if (!isRecord5(request2.body)) return adminError3(400, "expected one order object");
5331
+ if (!isRecord6(request2.body)) return adminError4(400, "expected one order object");
3942
5332
  const { emitWebhooks, ...input } = request2.body;
3943
5333
  return guarded(
3944
5334
  () => jsonRes(
@@ -3950,8 +5340,8 @@ var junctionAdminRoutes = (options) => (runtime) => {
3950
5340
  );
3951
5341
  },
3952
5342
  "POST /import": (request2) => {
3953
- if (!isRecord5(request2.body)) {
3954
- return adminError3(400, 'expected { "users": [...], "orders": [...] }');
5343
+ if (!isRecord6(request2.body)) {
5344
+ return adminError4(400, 'expected { "users": [...], "orders": [...] }');
3955
5345
  }
3956
5346
  const { emitWebhooks, ...fixtures } = request2.body;
3957
5347
  return guarded(() => {
@@ -3966,25 +5356,25 @@ var junctionAdminRoutes = (options) => (runtime) => {
3966
5356
  geo: api(request2).geoMode
3967
5357
  }),
3968
5358
  "PUT /geo": (request2) => {
3969
- const mode = isRecord5(request2.body) ? request2.body.mode : void 0;
5359
+ const mode = isRecord6(request2.body) ? request2.body.mode : void 0;
3970
5360
  if (mode !== "corpus" && mode !== "synthetic") {
3971
- return adminError3(400, 'body needs "mode": "corpus" or "synthetic"');
5361
+ return adminError4(400, 'body needs "mode": "corpus" or "synthetic"');
3972
5362
  }
3973
5363
  api(request2).geoMode = mode;
3974
5364
  return jsonRes(200, { geo: mode });
3975
5365
  },
3976
5366
  "GET /webhooks/events": (request2) => jsonRes(200, { events: api(request2).webhookEvents() }),
3977
- "GET /webhooks": (request2) => options.webhooks ? jsonRes(200, { deliveries: options.webhooks.deliveries(request2.namespace) }) : adminError3(
5367
+ "GET /webhooks": (request2) => options.webhooks ? jsonRes(200, { deliveries: options.webhooks.deliveries(request2.namespace) }) : adminError4(
3978
5368
  409,
3979
5369
  "webhook delivery is off; serve with --webhook-url and --webhook-secret"
3980
5370
  ),
3981
5371
  "POST /webhooks/:id/replay": async (request2) => {
3982
- if (!options.webhooks) return adminError3(409, "webhook delivery is off");
5372
+ if (!options.webhooks) return adminError4(409, "webhook delivery is off");
3983
5373
  const delivery = await options.webhooks.replay(request2.params.id);
3984
- return delivery ? jsonRes(200, delivery) : adminError3(404, `no message ${request2.params.id}`);
5374
+ return delivery ? jsonRes(200, delivery) : adminError4(404, `no message ${request2.params.id}`);
3985
5375
  },
3986
5376
  "POST /webhooks/flush": async () => {
3987
- if (!options.webhooks) return adminError3(409, "webhook delivery is off");
5377
+ if (!options.webhooks) return adminError4(409, "webhook delivery is off");
3988
5378
  await options.webhooks.flush();
3989
5379
  return jsonRes(200, { status: "ok" });
3990
5380
  },
@@ -3992,12 +5382,12 @@ var junctionAdminRoutes = (options) => (runtime) => {
3992
5382
  "POST /faults/presets/:name": (request2) => {
3993
5383
  const preset = FAULT_PRESETS[request2.params.name];
3994
5384
  if (!preset) {
3995
- return adminError3(
5385
+ return adminError4(
3996
5386
  404,
3997
5387
  `no preset ${request2.params.name}; one of ${Object.keys(FAULT_PRESETS).join(", ")}`
3998
5388
  );
3999
5389
  }
4000
- const overrides = isRecord5(request2.body) ? request2.body : {};
5390
+ const overrides = isRecord6(request2.body) ? request2.body : {};
4001
5391
  const rule = runtime.faults.add({
4002
5392
  namespace: request2.namespace,
4003
5393
  ...preset,
@@ -6688,7 +8078,7 @@ var FixtureError = class extends Error {
6688
8078
  }
6689
8079
  status;
6690
8080
  };
6691
- var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8081
+ var isRecord7 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6692
8082
  var instant = (value, field, fallback) => {
6693
8083
  if (value === void 0) return fallback;
6694
8084
  if (typeof value !== "string" || Number.isNaN(Date.parse(value)))
@@ -6712,7 +8102,7 @@ var DEFAULT_PATIENT_ADDRESS = {
6712
8102
  country: "US"
6713
8103
  };
6714
8104
  var buildUser = (state, input, now) => {
6715
- if (!isRecord6(input)) throw new FixtureError(400, "a user fixture must be an object");
8105
+ if (!isRecord7(input)) throw new FixtureError(400, "a user fixture must be an object");
6716
8106
  if (typeof input.client_user_id !== "string" || input.client_user_id === "")
6717
8107
  throw new FixtureError(400, "client_user_id must be a non-empty string");
6718
8108
  if (input.user_id !== void 0 && (typeof input.user_id !== "string" || !isUuid3(input.user_id)))
@@ -6762,7 +8152,7 @@ var insertOrders = (state, inputs, now, options = {}) => {
6762
8152
  const prepared = [];
6763
8153
  const ids = /* @__PURE__ */ new Set();
6764
8154
  for (const input of inputs) {
6765
- if (!isRecord6(input)) throw new FixtureError(400, "an order fixture must be an object");
8155
+ if (!isRecord7(input)) throw new FixtureError(400, "an order fixture must be an object");
6766
8156
  if (typeof input.user_id !== "string") throw new FixtureError(400, "user_id is required");
6767
8157
  if (typeof input.lab_test_id !== "string")
6768
8158
  throw new FixtureError(400, "lab_test_id is required");
@@ -6832,7 +8222,7 @@ var insertOrders = (state, inputs, now, options = {}) => {
6832
8222
  return prepared.flatMap(({ order }) => state.orders.get(order.id) ?? []);
6833
8223
  };
6834
8224
  var importFixtures = (state, fixtures, now, options = {}) => {
6835
- if (!isRecord6(fixtures))
8225
+ if (!isRecord7(fixtures))
6836
8226
  throw new FixtureError(400, 'fixtures must be { "users": [...], "orders": [...] }');
6837
8227
  const { users = [], orders = [] } = fixtures;
6838
8228
  if (!Array.isArray(users)) throw new FixtureError(400, "users must be an array");
@@ -6857,7 +8247,7 @@ var requireUser = (state, userId, context, detail = "User does not exist on this
6857
8247
  var fixtureErrorMessage = (error) => {
6858
8248
  if (error instanceof FixtureError) return { status: error.status, message: error.message };
6859
8249
  if (error instanceof HttpError) {
6860
- const detail = isRecord6(error.body) ? error.body.detail : error.body;
8250
+ const detail = isRecord7(error.body) ? error.body.detail : error.body;
6861
8251
  return {
6862
8252
  status: error.status,
6863
8253
  message: typeof detail === "string" ? detail : JSON.stringify(detail)
@@ -9795,4 +11185,4 @@ export {
9795
11185
  JunctionAPI,
9796
11186
  LabAccountConflict
9797
11187
  };
9798
- //# sourceMappingURL=chunk-KSSFYWXV.js.map
11188
+ //# sourceMappingURL=chunk-AKZJPCBU.js.map